diff --git a/.env.production.sample b/.env.production.sample index 3e054db84..e022a8405 100644 --- a/.env.production.sample +++ b/.env.production.sample @@ -101,11 +101,19 @@ SMTP_FROM_ADDRESS=notifications@example.com # Swift (optional) # SWIFT_ENABLED=true # SWIFT_USERNAME= +# For Keystone V3, the value for SWIFT_TENANT should be the project name # SWIFT_TENANT= # SWIFT_PASSWORD= +# Keystone V2 and V3 URLs are supported. Use a V3 URL if possible to avoid +# issues with token rate-limiting during high load. # SWIFT_AUTH_URL= # SWIFT_CONTAINER= # SWIFT_OBJECT_URL= +# SWIFT_REGION= +# Defaults to 'default' +# SWIFT_DOMAIN_NAME= +# Defaults to 60 seconds. Set to 0 to disable +# SWIFT_CACHE_TTL= # Optional alias for S3 if you want to use Cloudfront or Cloudflare in front # S3_CLOUDFRONT_HOST= diff --git a/.ruby-version b/.ruby-version index 005119baa..8e8299dcc 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -2.4.1 +2.4.2 diff --git a/.travis.yml b/.travis.yml index d5b51fcb0..52ff15c01 100644 --- a/.travis.yml +++ b/.travis.yml @@ -26,18 +26,16 @@ addons: postgresql: 9.4 apt: sources: - - ubuntu-toolchain-r-test - trusty-media packages: - ffmpeg - - g++-6 - libprotobuf-dev - protobuf-compiler - libicu-dev rvm: - 2.3.4 - - 2.4.1 + - 2.4.2 services: - redis-server diff --git a/Aptfile b/Aptfile index 48dff1a77..5dac83607 100644 --- a/Aptfile +++ b/Aptfile @@ -1,4 +1,5 @@ ffmpeg +libicu[0-9][0-9] libicu-dev libidn11 libidn11-dev diff --git a/Dockerfile b/Dockerfile index 15138065b..3ad2ad7ef 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM ruby:2.4.1-alpine3.6 +FROM ruby:2.4.2-alpine3.6 LABEL maintainer="https://github.com/tootsuite/mastodon" \ description="A GNU Social-compatible microblogging server" diff --git a/Gemfile b/Gemfile index 637bf53ee..4f4861913 100644 --- a/Gemfile +++ b/Gemfile @@ -5,8 +5,8 @@ ruby '>= 2.3.0', '< 2.5.0' gem 'pkg-config', '~> 1.2' -gem 'puma', '~> 3.8' -gem 'rails', '~> 5.1.0' +gem 'puma', '~> 3.10' +gem 'rails', '~> 5.1.4' gem 'uglifier', '~> 3.2' gem 'hamlit-rails', '~> 0.2' @@ -25,7 +25,7 @@ gem 'bootsnap' gem 'browser' gem 'charlock_holmes', '~> 0.7.5' gem 'iso-639' -gem 'cld3', '~> 3.1' +gem 'cld3', '~> 3.2.0' gem 'devise', '~> 4.2' gem 'devise-two-factor', '~> 3.0' gem 'doorkeeper', '~> 4.2' diff --git a/Gemfile.lock b/Gemfile.lock index ddb97dd94..97db3aa9a 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,25 +1,25 @@ GEM remote: https://rubygems.org/ specs: - actioncable (5.1.3) - actionpack (= 5.1.3) + actioncable (5.1.4) + actionpack (= 5.1.4) nio4r (~> 2.0) websocket-driver (~> 0.6.1) - actionmailer (5.1.3) - actionpack (= 5.1.3) - actionview (= 5.1.3) - activejob (= 5.1.3) + actionmailer (5.1.4) + actionpack (= 5.1.4) + actionview (= 5.1.4) + activejob (= 5.1.4) mail (~> 2.5, >= 2.5.4) rails-dom-testing (~> 2.0) - actionpack (5.1.3) - actionview (= 5.1.3) - activesupport (= 5.1.3) + actionpack (5.1.4) + actionview (= 5.1.4) + activesupport (= 5.1.4) rack (~> 2.0) - rack-test (~> 0.6.3) + rack-test (>= 0.6.3) rails-dom-testing (~> 2.0) rails-html-sanitizer (~> 1.0, >= 1.0.2) - actionview (5.1.3) - activesupport (= 5.1.3) + actionview (5.1.4) + activesupport (= 5.1.4) builder (~> 3.1) erubi (~> 1.4) rails-dom-testing (~> 2.0) @@ -30,16 +30,16 @@ GEM case_transform (>= 0.2) jsonapi-renderer (>= 0.1.1.beta1, < 0.2) active_record_query_trace (1.5.4) - activejob (5.1.3) - activesupport (= 5.1.3) + activejob (5.1.4) + activesupport (= 5.1.4) globalid (>= 0.3.6) - activemodel (5.1.3) - activesupport (= 5.1.3) - activerecord (5.1.3) - activemodel (= 5.1.3) - activesupport (= 5.1.3) + activemodel (5.1.4) + activesupport (= 5.1.4) + activerecord (5.1.4) + activemodel (= 5.1.4) + activesupport (= 5.1.4) arel (~> 8.0) - activesupport (5.1.3) + activesupport (5.1.4) concurrent-ruby (~> 1.0, >= 1.0.2) i18n (~> 0.7) minitest (~> 5.1) @@ -57,33 +57,33 @@ GEM encryptor (~> 3.0.0) av (0.9.0) cocaine (~> 0.5.3) - aws-sdk (2.10.21) - aws-sdk-resources (= 2.10.21) - aws-sdk-core (2.10.21) + aws-sdk (2.10.46) + aws-sdk-resources (= 2.10.46) + aws-sdk-core (2.10.46) aws-sigv4 (~> 1.0) jmespath (~> 1.0) - aws-sdk-resources (2.10.21) - aws-sdk-core (= 2.10.21) - aws-sigv4 (1.0.1) + aws-sdk-resources (2.10.46) + aws-sdk-core (= 2.10.46) + aws-sigv4 (1.0.2) bcrypt (3.1.11) - better_errors (2.1.1) + better_errors (2.3.0) coderay (>= 1.0.0) - erubis (>= 2.6.6) + erubi (>= 1.0.0) rack (>= 0.9.0) binding_of_caller (0.7.2) debug_inspector (>= 0.0.1) - bootsnap (1.1.2) + bootsnap (1.1.3) msgpack (~> 1.0) brakeman (3.7.2) - browser (2.4.0) + browser (2.5.1) builder (3.2.3) - bullet (5.5.1) + bullet (5.6.1) activesupport (>= 3.0.0) uniform_notifier (~> 1.10.0) bundler-audit (0.6.0) bundler (~> 1.2) thor (~> 0.18) - capistrano (3.8.2) + capistrano (3.9.1) airbrussh (>= 1.0.0) i18n rake (>= 10.0.0) @@ -99,9 +99,9 @@ GEM sshkit (~> 1.3) capistrano-yarn (2.0.2) capistrano (~> 3.0) - capybara (2.14.4) + capybara (2.15.1) addressable - mime-types (>= 1.16) + mini_mime (>= 0.1.3) nokogiri (>= 1.3.3) rack (>= 1.0.0) rack-test (>= 0.5.4) @@ -110,12 +110,12 @@ GEM activesupport charlock_holmes (0.7.5) chunky_png (1.3.8) - cld3 (3.1.3) + cld3 (3.2.0) ffi (>= 1.1.0, < 1.10.0) climate_control (0.2.0) cocaine (0.5.8) climate_control (>= 0.0.3, < 1.0) - coderay (1.1.1) + coderay (1.1.2) colorize (0.8.1) concurrent-ruby (1.0.5) connection_pool (2.2.1) @@ -151,13 +151,12 @@ GEM thread_safe encryptor (3.0.0) erubi (1.6.1) - erubis (2.7.0) et-orbi (1.0.5) tzinfo - excon (0.58.0) + excon (0.59.0) execjs (2.7.0) - fabrication (2.16.2) - faker (1.7.3) + fabrication (2.16.3) + faker (1.8.4) i18n (~> 0.5) fast_blank (1.0.0) ffi (1.9.18) @@ -194,7 +193,7 @@ GEM railties (>= 4.0.1) hamster (3.0.0) concurrent-ruby (~> 1.0) - hashdiff (0.3.5) + hashdiff (0.3.6) highline (1.7.8) hiredis (0.6.1) hkdf (0.3.0) @@ -213,11 +212,11 @@ GEM colorize rack i18n (0.8.6) - i18n-tasks (0.9.16) + i18n-tasks (0.9.18) activesupport (>= 4.0.2) ast (>= 2.1.0) easy_translate (>= 0.5.0) - erubis + erubi highline (>= 1.7.3) i18n parser (>= 2.2.3.0) @@ -231,7 +230,7 @@ GEM json-ld (2.1.5) multi_json (~> 1.12) rdf (~> 2.2) - json-ld-preloaded (2.2.1) + json-ld-preloaded (2.2.2) json-ld (~> 2.1, >= 2.1.5) multi_json (~> 1.11) rdf (~> 2.2) @@ -258,10 +257,11 @@ GEM letter_opener (~> 1.0) railties (>= 3.2) link_header (0.0.8) - lograge (0.5.1) + lograge (0.6.0) actionpack (>= 4, < 5.2) activesupport (>= 4, < 5.2) railties (>= 4, < 5.2) + request_store (~> 1.0) loofah (2.0.3) nokogiri (>= 1.5.9) mail (2.6.6) @@ -276,27 +276,28 @@ GEM mime-types-data (~> 3.2015) mime-types-data (3.2016.0521) mimemagic (0.3.2) + mini_mime (0.1.4) mini_portile2 (2.2.0) minitest (5.10.3) msgpack (1.1.0) - multi_json (1.12.1) + multi_json (1.12.2) net-scp (1.2.1) net-ssh (>= 2.6.5) - net-ssh (4.1.0) + net-ssh (4.2.0) nio4r (2.1.0) nokogiri (1.8.0) mini_portile2 (~> 2.2.0) nokogumbo (1.4.13) nokogiri - oj (3.3.4) - openssl (2.0.4) + oj (3.3.5) + openssl (2.0.5) orm_adapter (0.5.0) ostatus2 (2.0.1) addressable (~> 2.4) http (~> 2.0) nokogiri (~> 1.6) openssl (~> 2.0) - ox (2.5.0) + ox (2.6.0) paperclip (5.1.0) activemodel (>= 4.2.0) activesupport (>= 4.2.0) @@ -306,15 +307,15 @@ GEM paperclip-av-transcoder (0.6.4) av (~> 0.9.0) paperclip (>= 2.5.2) - parallel (1.11.2) - parallel_tests (2.14.2) + parallel (1.12.0) + parallel_tests (2.15.0) parallel parser (2.4.0.0) ast (~> 2.2) pg (0.21.0) pghero (1.7.0) activerecord - pkg-config (1.2.4) + pkg-config (1.2.7) powerpack (0.1.1) pry (0.10.4) coderay (~> 1.1.0) @@ -323,7 +324,7 @@ GEM pry-rails (0.3.6) pry (>= 0.10.4) public_suffix (3.0.0) - puma (3.9.1) + puma (3.10.0) pundit (1.1.0) activesupport (>= 3.0.0) rabl (0.13.1) @@ -334,20 +335,20 @@ GEM rack-cors (0.4.1) rack-protection (2.0.0) rack - rack-test (0.6.3) - rack (>= 1.0) + rack-test (0.7.0) + rack (>= 1.0, < 3) rack-timeout (0.4.2) - rails (5.1.3) - actioncable (= 5.1.3) - actionmailer (= 5.1.3) - actionpack (= 5.1.3) - actionview (= 5.1.3) - activejob (= 5.1.3) - activemodel (= 5.1.3) - activerecord (= 5.1.3) - activesupport (= 5.1.3) + rails (5.1.4) + actioncable (= 5.1.4) + actionmailer (= 5.1.4) + actionpack (= 5.1.4) + actionview (= 5.1.4) + activejob (= 5.1.4) + activemodel (= 5.1.4) + activerecord (= 5.1.4) + activesupport (= 5.1.4) bundler (>= 1.3.0) - railties (= 5.1.3) + railties (= 5.1.4) sprockets-rails (>= 2.0.0) rails-controller-testing (1.0.2) actionpack (~> 5.x, >= 5.0.1) @@ -363,16 +364,16 @@ GEM railties (~> 5.0) rails-settings-cached (0.6.6) rails (>= 4.2.0) - railties (5.1.3) - actionpack (= 5.1.3) - activesupport (= 5.1.3) + railties (5.1.4) + actionpack (= 5.1.4) + activesupport (= 5.1.4) method_source rake (>= 0.8.7) thor (>= 0.18.1, < 2.0) rainbow (2.2.2) rake - rake (12.0.0) - rdf (2.2.8) + rake (12.1.0) + rdf (2.2.9) hamster (~> 3.0) link_header (~> 0.0, >= 0.0.8) rdf-normalize (0.3.2) @@ -396,6 +397,7 @@ GEM redis-store (>= 1.2, < 2) redis-store (1.3.0) redis (>= 2.2) + request_store (1.3.2) responders (2.4.0) actionpack (>= 4.2.0, < 5.3) railties (>= 4.2.0, < 5.3) @@ -410,7 +412,7 @@ GEM rspec-mocks (3.6.0) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.6.0) - rspec-rails (3.6.0) + rspec-rails (3.6.1) actionpack (>= 3.0) activesupport (>= 3.0) railties (>= 3.0) @@ -422,15 +424,15 @@ GEM rspec-core (~> 3.0, >= 3.0.0) sidekiq (>= 2.4.0) rspec-support (3.6.0) - rubocop (0.49.1) + rubocop (0.50.0) parallel (~> 1.10) parser (>= 2.3.3.1, < 3.0) powerpack (~> 0.1) - rainbow (>= 1.99.1, < 3.0) + rainbow (>= 2.2.2, < 3.0) ruby-progressbar (~> 1.7) unicode-display_width (~> 1.0, >= 1.0.1) ruby-oembed (0.12.0) - ruby-progressbar (1.8.1) + ruby-progressbar (1.8.3) rufus-scheduler (3.4.2) et-orbi (~> 1.0) safe_yaml (1.0.4) @@ -438,7 +440,7 @@ GEM crass (~> 1.0.2) nokogiri (>= 1.4.4) nokogumbo (~> 1.4.1) - sass (3.4.24) + sass (3.4.25) scss_lint (0.54.0) rake (>= 0.9, < 13) sass (~> 3.4.20) @@ -450,12 +452,12 @@ GEM sidekiq-bulk (0.1.1) activesupport sidekiq - sidekiq-scheduler (2.1.8) + sidekiq-scheduler (2.1.9) redis (~> 3) rufus-scheduler (~> 3.2) sidekiq (>= 3) tilt (>= 1.4.0) - sidekiq-unique-jobs (5.0.9) + sidekiq-unique-jobs (5.0.10) sidekiq (>= 4.0, <= 6.0) thor (~> 0) simple-navigation (4.0.5) @@ -463,20 +465,20 @@ GEM simple_form (3.5.0) actionpack (> 4, < 5.2) activemodel (> 4, < 5.2) - simplecov (0.14.1) + simplecov (0.15.1) docile (~> 1.1.0) json (>= 1.8, < 3) simplecov-html (~> 0.10.0) - simplecov-html (0.10.1) + simplecov-html (0.10.2) slop (3.6.0) sprockets (3.7.1) concurrent-ruby (~> 1.0) rack (> 1, < 3) - sprockets-rails (3.2.0) + sprockets-rails (3.2.1) actionpack (>= 4.0) activesupport (>= 4.0) sprockets (>= 3.0.0) - sshkit (1.13.1) + sshkit (1.14.0) net-scp (>= 1.1.2) net-ssh (>= 2.8.0) statsd-instrument (2.1.4) @@ -541,7 +543,7 @@ DEPENDENCIES capistrano-yarn (~> 2.0) capybara (~> 2.14) charlock_holmes (~> 0.7.5) - cld3 (~> 3.1) + cld3 (~> 3.2.0) climate_control (~> 0.2) devise (~> 4.2) devise-two-factor (~> 3.0) @@ -582,13 +584,13 @@ DEPENDENCIES pghero (~> 1.7) pkg-config (~> 1.2) pry-rails (~> 0.3) - puma (~> 3.8) + puma (~> 3.10) pundit (~> 1.1) rabl (~> 0.13) rack-attack (~> 5.0) rack-cors (~> 0.4) rack-timeout (~> 0.4) - rails (~> 5.1.0) + rails (~> 5.1.4) rails-controller-testing (~> 1.0) rails-i18n (~> 5.0) rails-settings-cached (~> 0.6) @@ -620,7 +622,7 @@ DEPENDENCIES webpush RUBY VERSION - ruby 2.4.1p111 + ruby 2.4.2p198 BUNDLED WITH 1.15.4 diff --git a/app/controllers/admin/custom_emojis_controller.rb b/app/controllers/admin/custom_emojis_controller.rb new file mode 100644 index 000000000..572ad1ac2 --- /dev/null +++ b/app/controllers/admin/custom_emojis_controller.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module Admin + class CustomEmojisController < BaseController + def index + @custom_emojis = CustomEmoji.where(domain: nil) + end + + def new + @custom_emoji = CustomEmoji.new + end + + def create + @custom_emoji = CustomEmoji.new(resource_params) + + if @custom_emoji.save + redirect_to admin_custom_emojis_path, notice: I18n.t('admin.custom_emojis.created_msg') + else + render :new + end + end + + def destroy + CustomEmoji.find(params[:id]).destroy + redirect_to admin_custom_emojis_path, notice: I18n.t('admin.custom_emojis.destroyed_msg') + end + + private + + def resource_params + params.require(:custom_emoji).permit(:shortcode, :image) + end + end +end diff --git a/app/controllers/admin/instances_controller.rb b/app/controllers/admin/instances_controller.rb index 3296e08db..22f02e5d0 100644 --- a/app/controllers/admin/instances_controller.rb +++ b/app/controllers/admin/instances_controller.rb @@ -14,8 +14,12 @@ module Admin private + def filtered_instances + InstanceFilter.new(filter_params).results + end + def paginated_instances - Account.remote.by_domain_accounts.page(params[:page]) + filtered_instances.page(params[:page]) end helper_method :paginated_instances @@ -27,5 +31,11 @@ module Admin def subscribeable_accounts Account.with_followers.remote.where(domain: params[:by_domain]) end + + def filter_params + params.permit( + :domain_name + ) + end end end diff --git a/app/controllers/admin/settings_controller.rb b/app/controllers/admin/settings_controller.rb index c5e6fe4e5..a2f86b8a9 100644 --- a/app/controllers/admin/settings_controller.rb +++ b/app/controllers/admin/settings_controller.rb @@ -14,6 +14,7 @@ module Admin open_deletion timeline_preview bootstrap_timeline_accounts + thumbnail ).freeze BOOLEAN_SETTINGS = %w( @@ -22,14 +23,23 @@ module Admin timeline_preview ).freeze + UPLOAD_SETTINGS = %w( + thumbnail + ).freeze + def edit @admin_settings = Form::AdminSettings.new end def update settings_params.each do |key, value| - setting = Setting.where(var: key).first_or_initialize(var: key) - setting.update(value: value_for_update(key, value)) + if UPLOAD_SETTINGS.include?(key) + upload = SiteUpload.where(var: key).first_or_initialize(var: key) + upload.update(file: value) + else + setting = Setting.where(var: key).first_or_initialize(var: key) + setting.update(value: value_for_update(key, value)) + end end flash[:notice] = I18n.t('generic.changes_saved_msg') diff --git a/app/controllers/home_controller.rb b/app/controllers/home_controller.rb index fbfb5473e..ad7f09f34 100644 --- a/app/controllers/home_controller.rb +++ b/app/controllers/home_controller.rb @@ -12,7 +12,30 @@ class HomeController < ApplicationController private def authenticate_user! - redirect_to(single_user_mode? ? account_path(Account.first) : about_path) unless user_signed_in? + return if user_signed_in? + + matches = request.path.match(/\A\/web\/(statuses|accounts)\/([\d]+)\z/) + + if matches + case matches[1] + when 'statuses' + status = Status.find_by(id: matches[2]) + + if status && (status.public_visibility? || status.unlisted_visibility?) + redirect_to(ActivityPub::TagManager.instance.url_for(status)) + return + end + when 'accounts' + account = Account.find_by(id: matches[2]) + + if account + redirect_to(ActivityPub::TagManager.instance.url_for(account)) + return + end + end + end + + redirect_to(default_redirect_path) end def set_initial_state_json @@ -29,4 +52,14 @@ class HomeController < ApplicationController admin: Account.find_local(Setting.site_contact_username), } end + + def default_redirect_path + if request.path.start_with?('/web') + new_user_session_path + elsif single_user_mode? + short_account_path(Account.first) + else + about_path + end + end end diff --git a/app/controllers/media_proxy_controller.rb b/app/controllers/media_proxy_controller.rb new file mode 100644 index 000000000..155670837 --- /dev/null +++ b/app/controllers/media_proxy_controller.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +class MediaProxyController < ApplicationController + include RoutingHelper + + def show + RedisLock.acquire(lock_options) do |lock| + if lock.acquired? + @media_attachment = MediaAttachment.remote.find(params[:id]) + redownload! if @media_attachment.needs_redownload? && !reject_media? + end + end + + redirect_to full_asset_url(@media_attachment.file.url(version)) + end + + private + + def redownload! + @media_attachment.file_remote_url = @media_attachment.remote_url + @media_attachment.created_at = Time.now.utc + @media_attachment.save! + end + + def version + if request.path.ends_with?('/small') + :small + else + :original + end + end + + def lock_options + { redis: Redis.current, key: "media_download:#{params[:id]}" } + end + + def reject_media? + DomainBlock.find_by(domain: @media_attachment.account.domain)&.reject_media? + end +end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 61d4442c1..6d625e7db 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -42,4 +42,8 @@ module ApplicationHelper content_tag(:i, nil, attributes.merge(class: class_names.join(' '))) end + + def opengraph(property, content) + tag(:meta, content: content, property: property) + end end diff --git a/app/helpers/settings_helper.rb b/app/helpers/settings_helper.rb index 369a45680..14776b354 100644 --- a/app/helpers/settings_helper.rb +++ b/app/helpers/settings_helper.rb @@ -41,7 +41,7 @@ module SettingsHelper end def filterable_languages - I18n.available_locales.map { |locale| locale.to_s.split('-').first.to_sym }.uniq + LanguageDetector.instance.language_names.select(&HUMAN_LOCALES.method(:key?)) end def hash_to_object(hash) diff --git a/app/javascript/images/logo.svg b/app/javascript/images/logo.svg index 4b72b3ac8..034a9c221 100644 --- a/app/javascript/images/logo.svg +++ b/app/javascript/images/logo.svg @@ -1 +1 @@ - + diff --git a/app/javascript/images/logo_alt.svg b/app/javascript/images/logo_alt.svg index e88ca7418..102d4c787 100644 --- a/app/javascript/images/logo_alt.svg +++ b/app/javascript/images/logo_alt.svg @@ -1 +1 @@ - + diff --git a/app/javascript/images/logo_full.svg b/app/javascript/images/logo_full.svg index 8b1328e8c..c33883342 100644 --- a/app/javascript/images/logo_full.svg +++ b/app/javascript/images/logo_full.svg @@ -1 +1 @@ - + diff --git a/app/javascript/images/mastodon_small.jpg b/app/javascript/images/mastodon_small.jpg deleted file mode 100644 index 9c88ce3f7..000000000 Binary files a/app/javascript/images/mastodon_small.jpg and /dev/null differ diff --git a/app/javascript/images/preview.jpg b/app/javascript/images/preview.jpg new file mode 100644 index 000000000..ec2856748 Binary files /dev/null and b/app/javascript/images/preview.jpg differ diff --git a/app/javascript/mastodon/actions/height_cache.js b/app/javascript/mastodon/actions/height_cache.js new file mode 100644 index 000000000..4c752993f --- /dev/null +++ b/app/javascript/mastodon/actions/height_cache.js @@ -0,0 +1,17 @@ +export const HEIGHT_CACHE_SET = 'HEIGHT_CACHE_SET'; +export const HEIGHT_CACHE_CLEAR = 'HEIGHT_CACHE_CLEAR'; + +export function setHeight (key, id, height) { + return { + type: HEIGHT_CACHE_SET, + key, + id, + height, + }; +}; + +export function clearHeight () { + return { + type: HEIGHT_CACHE_CLEAR, + }; +}; diff --git a/app/javascript/mastodon/actions/statuses.js b/app/javascript/mastodon/actions/statuses.js index 0b5e72c17..2204e0b14 100644 --- a/app/javascript/mastodon/actions/statuses.js +++ b/app/javascript/mastodon/actions/statuses.js @@ -23,9 +23,6 @@ export const STATUS_UNMUTE_REQUEST = 'STATUS_UNMUTE_REQUEST'; export const STATUS_UNMUTE_SUCCESS = 'STATUS_UNMUTE_SUCCESS'; export const STATUS_UNMUTE_FAIL = 'STATUS_UNMUTE_FAIL'; -export const STATUS_SET_HEIGHT = 'STATUS_SET_HEIGHT'; -export const STATUSES_CLEAR_HEIGHT = 'STATUSES_CLEAR_HEIGHT'; - export function fetchStatusRequest(id, skipLoading) { return { type: STATUS_FETCH_REQUEST, @@ -218,17 +215,3 @@ export function unmuteStatusFail(id, error) { error, }; }; - -export function setStatusHeight (id, height) { - return { - type: STATUS_SET_HEIGHT, - id, - height, - }; -}; - -export function clearStatusesHeight () { - return { - type: STATUSES_CLEAR_HEIGHT, - }; -}; diff --git a/app/javascript/mastodon/components/intersection_observer_article.js b/app/javascript/mastodon/components/intersection_observer_article.js index 347767818..bb83a4da0 100644 --- a/app/javascript/mastodon/components/intersection_observer_article.js +++ b/app/javascript/mastodon/components/intersection_observer_article.js @@ -7,10 +7,13 @@ import getRectFromEntry from '../features/ui/util/get_rect_from_entry'; export default class IntersectionObserverArticle extends ImmutablePureComponent { static propTypes = { - intersectionObserverWrapper: PropTypes.object, + intersectionObserverWrapper: PropTypes.object.isRequired, id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), index: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), listLength: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + saveHeightKey: PropTypes.string, + cachedHeight: PropTypes.number, + onHeightChange: PropTypes.func, children: PropTypes.node, }; @@ -34,13 +37,10 @@ export default class IntersectionObserverArticle extends ImmutablePureComponent } componentDidMount () { - if (!this.props.intersectionObserverWrapper) { - // TODO: enable IntersectionObserver optimization for notification statuses. - // These are managed in notifications/index.js rather than status_list.js - return; - } - this.props.intersectionObserverWrapper.observe( - this.props.id, + const { intersectionObserverWrapper, id } = this.props; + + intersectionObserverWrapper.observe( + id, this.node, this.handleIntersection ); @@ -49,20 +49,21 @@ export default class IntersectionObserverArticle extends ImmutablePureComponent } componentWillUnmount () { - if (this.props.intersectionObserverWrapper) { - this.props.intersectionObserverWrapper.unobserve(this.props.id, this.node); - } + const { intersectionObserverWrapper, id } = this.props; + intersectionObserverWrapper.unobserve(id, this.node); this.componentMounted = false; } handleIntersection = (entry) => { + const { onHeightChange, saveHeightKey, id } = this.props; + if (this.node && this.node.children.length !== 0) { // save the height of the fully-rendered element this.height = getRectFromEntry(entry).height; - if (this.props.onHeightChange) { - this.props.onHeightChange(this.props.status, this.height); + if (onHeightChange && saveHeightKey) { + onHeightChange(saveHeightKey, id, this.height); } } @@ -94,16 +95,16 @@ export default class IntersectionObserverArticle extends ImmutablePureComponent } render () { - const { children, id, index, listLength } = this.props; + const { children, id, index, listLength, cachedHeight } = this.props; const { isIntersecting, isHidden } = this.state; - if (!isIntersecting && isHidden) { + if (!isIntersecting && (isHidden || cachedHeight)) { return (
diff --git a/app/javascript/mastodon/components/load_more.js b/app/javascript/mastodon/components/load_more.js index e2fe1fed7..c4c8c94a2 100644 --- a/app/javascript/mastodon/components/load_more.js +++ b/app/javascript/mastodon/components/load_more.js @@ -17,7 +17,7 @@ export default class LoadMore extends React.PureComponent { const { visible } = this.props; return ( - ); diff --git a/app/javascript/mastodon/components/media_gallery.js b/app/javascript/mastodon/components/media_gallery.js index fa6ea72d5..a03b682b2 100644 --- a/app/javascript/mastodon/components/media_gallery.js +++ b/app/javascript/mastodon/components/media_gallery.js @@ -122,8 +122,8 @@ class Item extends React.PureComponent { const hasSize = typeof originalWidth === 'number' && typeof previewWidth === 'number'; - const srcSet = hasSize && `${originalUrl} ${originalWidth}w, ${previewUrl} ${previewWidth}w`; - const sizes = hasSize && `(min-width: 1025px) ${320 * (width / 100)}px, ${width}vw`; + const srcSet = hasSize ? `${originalUrl} ${originalWidth}w, ${previewUrl} ${previewWidth}w` : null; + const sizes = hasSize ? `(min-width: 1025px) ${320 * (width / 100)}px, ${width}vw` : null; thumbnail = ( 0 && hasMore} onClick={this.handleLoadMore} />; + const loadMore = (hasMore && childrenCount > 0) ? : null; let scrollableArea = null; if (isLoading || childrenCount > 0 || !emptyMessage) { @@ -173,9 +177,16 @@ export default class ScrollableList extends PureComponent { {prepend} {React.Children.map(this.props.children, (child, index) => ( - + {child} - + ))} {loadMore} diff --git a/app/javascript/mastodon/components/status.js b/app/javascript/mastodon/components/status.js index b8617018d..78177c84d 100644 --- a/app/javascript/mastodon/components/status.js +++ b/app/javascript/mastodon/components/status.js @@ -12,7 +12,7 @@ import StatusContent from './status_content'; import StatusActionBar from './status_action_bar'; import { FormattedMessage } from 'react-intl'; import ImmutablePureComponent from 'react-immutable-pure-component'; -import { MediaGallery, VideoPlayer } from '../features/ui/util/async-components'; +import { MediaGallery, Video } from '../features/ui/util/async-components'; // We use the component (and not the container) since we do not want // to use the progress bar to show download progress @@ -91,6 +91,10 @@ export default class Status extends ImmutablePureComponent { return
; } + handleOpenVideo = startTime => { + this.props.onOpenVideo(this.props.status.getIn(['media_attachments', 0]), startTime); + } + render () { let media = null; let statusAvatar; @@ -130,9 +134,18 @@ export default class Status extends ImmutablePureComponent { if (status.get('media_attachments').some(item => item.get('type') === 'unknown')) { } else if (status.getIn(['media_attachments', 0, 'type']) === 'video') { + const video = status.getIn(['media_attachments', 0]); + media = ( - - {Component => } + + {Component => } ); } else { diff --git a/app/javascript/mastodon/containers/card_container.js b/app/javascript/mastodon/containers/card_container.js new file mode 100644 index 000000000..11b9f88d4 --- /dev/null +++ b/app/javascript/mastodon/containers/card_container.js @@ -0,0 +1,18 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import Card from '../features/status/components/card'; +import { fromJS } from 'immutable'; + +export default class CardContainer extends React.PureComponent { + + static propTypes = { + locale: PropTypes.string, + card: PropTypes.array.isRequired, + }; + + render () { + const { card, ...props } = this.props; + return ; + } + +} diff --git a/app/javascript/mastodon/containers/intersection_observer_article_container.js b/app/javascript/mastodon/containers/intersection_observer_article_container.js new file mode 100644 index 000000000..b6f162199 --- /dev/null +++ b/app/javascript/mastodon/containers/intersection_observer_article_container.js @@ -0,0 +1,17 @@ +import { connect } from 'react-redux'; +import IntersectionObserverArticle from '../components/intersection_observer_article'; +import { setHeight } from '../actions/height_cache'; + +const makeMapStateToProps = (state, props) => ({ + cachedHeight: state.getIn(['height_cache', props.saveHeightKey, props.id]), +}); + +const mapDispatchToProps = (dispatch) => ({ + + onHeightChange (key, id, height) { + dispatch(setHeight(key, id, height)); + }, + +}); + +export default connect(makeMapStateToProps, mapDispatchToProps)(IntersectionObserverArticle); diff --git a/app/javascript/mastodon/containers/media_gallery_container.js b/app/javascript/mastodon/containers/media_gallery_container.js new file mode 100644 index 000000000..812c3d4e5 --- /dev/null +++ b/app/javascript/mastodon/containers/media_gallery_container.js @@ -0,0 +1,34 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { IntlProvider, addLocaleData } from 'react-intl'; +import { getLocale } from '../locales'; +import MediaGallery from '../components/media_gallery'; +import { fromJS } from 'immutable'; + +const { localeData, messages } = getLocale(); +addLocaleData(localeData); + +export default class MediaGalleryContainer extends React.PureComponent { + + static propTypes = { + locale: PropTypes.string.isRequired, + media: PropTypes.array.isRequired, + }; + + handleOpenMedia = () => {} + + render () { + const { locale, media, ...props } = this.props; + + return ( + + + + ); + } + +} diff --git a/app/javascript/mastodon/containers/status_container.js b/app/javascript/mastodon/containers/status_container.js index 9dff79b72..e8821223d 100644 --- a/app/javascript/mastodon/containers/status_container.js +++ b/app/javascript/mastodon/containers/status_container.js @@ -21,7 +21,7 @@ import { blockAccount, muteAccount, } from '../actions/accounts'; -import { muteStatus, unmuteStatus, deleteStatus, setStatusHeight } from '../actions/statuses'; +import { muteStatus, unmuteStatus, deleteStatus } from '../actions/statuses'; import { initReport } from '../actions/reports'; import { openModal } from '../actions/modal'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; @@ -141,10 +141,6 @@ const mapDispatchToProps = (dispatch, { intl }) => ({ } }, - onHeightChange (status, height) { - dispatch(setStatusHeight(status.get('id'), height)); - }, - }); export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Status)); diff --git a/app/javascript/mastodon/containers/video_container.js b/app/javascript/mastodon/containers/video_container.js new file mode 100644 index 000000000..2fd353096 --- /dev/null +++ b/app/javascript/mastodon/containers/video_container.js @@ -0,0 +1,26 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { IntlProvider, addLocaleData } from 'react-intl'; +import { getLocale } from '../locales'; +import Video from '../features/video'; + +const { localeData, messages } = getLocale(); +addLocaleData(localeData); + +export default class VideoContainer extends React.PureComponent { + + static propTypes = { + locale: PropTypes.string.isRequired, + }; + + render () { + const { locale, ...props } = this.props; + + return ( + + + ); + } + +} diff --git a/app/javascript/mastodon/emoji.js b/app/javascript/mastodon/emoji.js index a41dfdd1d..865b85b61 100644 --- a/app/javascript/mastodon/emoji.js +++ b/app/javascript/mastodon/emoji.js @@ -3,28 +3,48 @@ import Trie from 'substring-trie'; const trie = new Trie(Object.keys(unicodeMapping)); -const emojify = str => { - let rtn = ''; - for (;;) { - let match, i = 0; - while (i < str.length && str[i] !== '<' && !(match = trie.search(str.slice(i)))) { - i += str.codePointAt(i) < 65536 ? 1 : 2; - } - if (i === str.length) - break; - else if (str[i] === '<') { - let tagend = str.indexOf('>', i + 1) + 1; - if (!tagend) - break; - rtn += str.slice(0, tagend); - str = str.slice(tagend); - } else { - const [filename, shortCode] = unicodeMapping[match]; - rtn += str.slice(0, i) + `${match}`; - str = str.slice(i + match.length); +const emojify = (str, customEmojis = {}) => { + // This walks through the string from start to end, ignoring any tags (

,
, etc.) + // and replacing valid unicode strings + // that _aren't_ within tags with an version. + // The goal is to be the same as an emojione.regUnicode replacement, but faster. + let i = -1; + let insideTag = false; + let insideShortname = false; + let shortnameStartIndex = -1; + let match; + while (++i < str.length) { + const char = str.charAt(i); + if (insideShortname && char === ':') { + const shortname = str.substring(shortnameStartIndex, i + 1); + if (shortname in customEmojis) { + const replacement = `${shortname}`; + str = str.substring(0, shortnameStartIndex) + replacement + str.substring(i + 1); + i += (replacement.length - shortname.length - 1); // jump ahead the length we've added to the string + } else { + i--; + } + insideShortname = false; + } else if (insideTag && char === '>') { + insideTag = false; + } else if (char === '<') { + insideTag = true; + insideShortname = false; + } else if (!insideTag && char === ':') { + insideShortname = true; + shortnameStartIndex = i; + } else if (!insideTag && (match = trie.search(str.substring(i)))) { + const unicodeStr = match; + if (unicodeStr in unicodeMapping) { + const [filename, shortCode] = unicodeMapping[unicodeStr]; + const alt = unicodeStr; + const replacement = `${alt}`; + str = str.substring(0, i) + replacement + str.substring(i + unicodeStr.length); + i += (replacement.length - unicodeStr.length); // jump ahead the length we've added to the string + } } } - return rtn + str; + return str; }; export default emojify; diff --git a/app/javascript/mastodon/features/compose/util/counter.js b/app/javascript/mastodon/features/compose/util/counter.js index f0fea1a0e..588a372c6 100644 --- a/app/javascript/mastodon/features/compose/util/counter.js +++ b/app/javascript/mastodon/features/compose/util/counter.js @@ -1,7 +1,9 @@ +import { urlRegex } from './url_regex'; + const urlPlaceholder = 'xxxxxxxxxxxxxxxxxxxxxxx'; export function countableText(inputText) { return inputText - .replace(/https?:\/\/\S+/g, urlPlaceholder) + .replace(urlRegex, urlPlaceholder) .replace(/(?:^|[^\/\w])@(([a-z0-9_]+)@[a-z0-9\.\-]+)/ig, '@$2'); }; diff --git a/app/javascript/mastodon/features/compose/util/url_regex.js b/app/javascript/mastodon/features/compose/util/url_regex.js new file mode 100644 index 000000000..e676d1879 --- /dev/null +++ b/app/javascript/mastodon/features/compose/util/url_regex.js @@ -0,0 +1,196 @@ +const regexen = {}; + +const regexSupplant = function(regex, flags) { + flags = flags || ''; + if (typeof regex !== 'string') { + if (regex.global && flags.indexOf('g') < 0) { + flags += 'g'; + } + if (regex.ignoreCase && flags.indexOf('i') < 0) { + flags += 'i'; + } + if (regex.multiline && flags.indexOf('m') < 0) { + flags += 'm'; + } + + regex = regex.source; + } + return new RegExp(regex.replace(/#\{(\w+)\}/g, function(match, name) { + var newRegex = regexen[name] || ''; + if (typeof newRegex !== 'string') { + newRegex = newRegex.source; + } + return newRegex; + }), flags); +}; + +const stringSupplant = function(str, values) { + return str.replace(/#\{(\w+)\}/g, function(match, name) { + return values[name] || ''; + }); +}; + +export const urlRegex = (function() { + regexen.spaces_group = /\x09-\x0D\x20\x85\xA0\u1680\u180E\u2000-\u200A\u2028\u2029\u202F\u205F\u3000/; + regexen.invalid_chars_group = /\uFFFE\uFEFF\uFFFF\u202A-\u202E/; + regexen.punct = /\!'#%&'\(\)*\+,\\\-\.\/:;<=>\?@\[\]\^_{|}~\$/; + regexen.validUrlPrecedingChars = regexSupplant(/(?:[^A-Za-z0-9@@$###{invalid_chars_group}]|^)/); + regexen.invalidDomainChars = stringSupplant('#{punct}#{spaces_group}#{invalid_chars_group}', regexen); + regexen.validDomainChars = regexSupplant(/[^#{invalidDomainChars}]/); + regexen.validSubdomain = regexSupplant(/(?:(?:#{validDomainChars}(?:[_-]|#{validDomainChars})*)?#{validDomainChars}\.)/); + regexen.validDomainName = regexSupplant(/(?:(?:#{validDomainChars}(?:-|#{validDomainChars})*)?#{validDomainChars}\.)/); + regexen.validGTLD = regexSupplant(RegExp( + '(?:(?:' + + '삼성|닷컴|닷넷|香格里拉|餐厅|食品|飞利浦|電訊盈科|集团|通販|购物|谷歌|诺基亚|联通|网络|网站|网店|网址|组织机构|移动|珠宝|点看|游戏|淡马锡|机构|書籍|时尚|新闻|政府|' + + '政务|手表|手机|我爱你|慈善|微博|广东|工行|家電|娱乐|天主教|大拿|大众汽车|在线|嘉里大酒店|嘉里|商标|商店|商城|公益|公司|八卦|健康|信息|佛山|企业|中文网|中信|世界|' + + 'ポイント|ファッション|セール|ストア|コム|グーグル|クラウド|みんな|คอม|संगठन|नेट|कॉम|همراه|موقع|موبايلي|كوم|كاثوليك|عرب|شبكة|' + + 'بيتك|بازار|العليان|ارامكو|اتصالات|ابوظبي|קום|сайт|рус|орг|онлайн|москва|ком|католик|дети|' + + 'zuerich|zone|zippo|zip|zero|zara|zappos|yun|youtube|you|yokohama|yoga|yodobashi|yandex|yamaxun|' + + 'yahoo|yachts|xyz|xxx|xperia|xin|xihuan|xfinity|xerox|xbox|wtf|wtc|wow|world|works|work|woodside|' + + 'wolterskluwer|wme|winners|wine|windows|win|williamhill|wiki|wien|whoswho|weir|weibo|wedding|wed|' + + 'website|weber|webcam|weatherchannel|weather|watches|watch|warman|wanggou|wang|walter|walmart|' + + 'wales|vuelos|voyage|voto|voting|vote|volvo|volkswagen|vodka|vlaanderen|vivo|viva|vistaprint|' + + 'vista|vision|visa|virgin|vip|vin|villas|viking|vig|video|viajes|vet|versicherung|' + + 'vermögensberatung|vermögensberater|verisign|ventures|vegas|vanguard|vana|vacations|ups|uol|uno|' + + 'university|unicom|uconnect|ubs|ubank|tvs|tushu|tunes|tui|tube|trv|trust|travelersinsurance|' + + 'travelers|travelchannel|travel|training|trading|trade|toys|toyota|town|tours|total|toshiba|' + + 'toray|top|tools|tokyo|today|tmall|tkmaxx|tjx|tjmaxx|tirol|tires|tips|tiffany|tienda|tickets|' + + 'tiaa|theatre|theater|thd|teva|tennis|temasek|telefonica|telecity|tel|technology|tech|team|tdk|' + + 'tci|taxi|tax|tattoo|tatar|tatamotors|target|taobao|talk|taipei|tab|systems|symantec|sydney|' + + 'swiss|swiftcover|swatch|suzuki|surgery|surf|support|supply|supplies|sucks|style|study|studio|' + + 'stream|store|storage|stockholm|stcgroup|stc|statoil|statefarm|statebank|starhub|star|staples|' + + 'stada|srt|srl|spreadbetting|spot|spiegel|space|soy|sony|song|solutions|solar|sohu|software|' + + 'softbank|social|soccer|sncf|smile|smart|sling|skype|sky|skin|ski|site|singles|sina|silk|shriram|' + + 'showtime|show|shouji|shopping|shop|shoes|shiksha|shia|shell|shaw|sharp|shangrila|sfr|sexy|sex|' + + 'sew|seven|ses|services|sener|select|seek|security|secure|seat|search|scot|scor|scjohnson|' + + 'science|schwarz|schule|school|scholarships|schmidt|schaeffler|scb|sca|sbs|sbi|saxo|save|sas|' + + 'sarl|sapo|sap|sanofi|sandvikcoromant|sandvik|samsung|samsclub|salon|sale|sakura|safety|safe|' + + 'saarland|ryukyu|rwe|run|ruhr|rugby|rsvp|room|rogers|rodeo|rocks|rocher|rmit|rip|rio|ril|' + + 'rightathome|ricoh|richardli|rich|rexroth|reviews|review|restaurant|rest|republican|report|' + + 'repair|rentals|rent|ren|reliance|reit|reisen|reise|rehab|redumbrella|redstone|red|recipes|' + + 'realty|realtor|realestate|read|raid|radio|racing|qvc|quest|quebec|qpon|pwc|pub|prudential|pru|' + + 'protection|property|properties|promo|progressive|prof|productions|prod|pro|prime|press|praxi|' + + 'pramerica|post|porn|politie|poker|pohl|pnc|plus|plumbing|playstation|play|place|pizza|pioneer|' + + 'pink|ping|pin|pid|pictures|pictet|pics|piaget|physio|photos|photography|photo|phone|philips|phd|' + + 'pharmacy|pfizer|pet|pccw|pay|passagens|party|parts|partners|pars|paris|panerai|panasonic|' + + 'pamperedchef|page|ovh|ott|otsuka|osaka|origins|orientexpress|organic|org|orange|oracle|open|ooo|' + + 'onyourside|online|onl|ong|one|omega|ollo|oldnavy|olayangroup|olayan|okinawa|office|off|observer|' + + 'obi|nyc|ntt|nrw|nra|nowtv|nowruz|now|norton|northwesternmutual|nokia|nissay|nissan|ninja|nikon|' + + 'nike|nico|nhk|ngo|nfl|nexus|nextdirect|next|news|newholland|new|neustar|network|netflix|netbank|' + + 'net|nec|nba|navy|natura|nationwide|name|nagoya|nadex|nab|mutuelle|mutual|museum|mtr|mtpc|mtn|' + + 'msd|movistar|movie|mov|motorcycles|moto|moscow|mortgage|mormon|mopar|montblanc|monster|money|' + + 'monash|mom|moi|moe|moda|mobily|mobile|mobi|mma|mls|mlb|mitsubishi|mit|mint|mini|mil|microsoft|' + + 'miami|metlife|merckmsd|meo|menu|men|memorial|meme|melbourne|meet|media|med|mckinsey|mcdonalds|' + + 'mcd|mba|mattel|maserati|marshalls|marriott|markets|marketing|market|map|mango|management|man|' + + 'makeup|maison|maif|madrid|macys|luxury|luxe|lupin|lundbeck|ltda|ltd|lplfinancial|lpl|love|lotto|' + + 'lotte|london|lol|loft|locus|locker|loans|loan|lixil|living|live|lipsy|link|linde|lincoln|limo|' + + 'limited|lilly|like|lighting|lifestyle|lifeinsurance|life|lidl|liaison|lgbt|lexus|lego|legal|' + + 'lefrak|leclerc|lease|lds|lawyer|law|latrobe|latino|lat|lasalle|lanxess|landrover|land|lancome|' + + 'lancia|lancaster|lamer|lamborghini|ladbrokes|lacaixa|kyoto|kuokgroup|kred|krd|kpn|kpmg|kosher|' + + 'komatsu|koeln|kiwi|kitchen|kindle|kinder|kim|kia|kfh|kerryproperties|kerrylogistics|kerryhotels|' + + 'kddi|kaufen|juniper|juegos|jprs|jpmorgan|joy|jot|joburg|jobs|jnj|jmp|jll|jlc|jio|jewelry|jetzt|' + + 'jeep|jcp|jcb|java|jaguar|iwc|iveco|itv|itau|istanbul|ist|ismaili|iselect|irish|ipiranga|' + + 'investments|intuit|international|intel|int|insure|insurance|institute|ink|ing|info|infiniti|' + + 'industries|immobilien|immo|imdb|imamat|ikano|iinet|ifm|ieee|icu|ice|icbc|ibm|hyundai|hyatt|' + + 'hughes|htc|hsbc|how|house|hotmail|hotels|hoteles|hot|hosting|host|hospital|horse|honeywell|' + + 'honda|homesense|homes|homegoods|homedepot|holiday|holdings|hockey|hkt|hiv|hitachi|hisamitsu|' + + 'hiphop|hgtv|hermes|here|helsinki|help|healthcare|health|hdfcbank|hdfc|hbo|haus|hangout|hamburg|' + + 'hair|guru|guitars|guide|guge|gucci|guardian|group|grocery|gripe|green|gratis|graphics|grainger|' + + 'gov|got|gop|google|goog|goodyear|goodhands|goo|golf|goldpoint|gold|godaddy|gmx|gmo|gmbh|gmail|' + + 'globo|global|gle|glass|glade|giving|gives|gifts|gift|ggee|george|genting|gent|gea|gdn|gbiz|' + + 'garden|gap|games|game|gallup|gallo|gallery|gal|fyi|futbol|furniture|fund|fun|fujixerox|fujitsu|' + + 'ftr|frontier|frontdoor|frogans|frl|fresenius|free|fox|foundation|forum|forsale|forex|ford|' + + 'football|foodnetwork|food|foo|fly|flsmidth|flowers|florist|flir|flights|flickr|fitness|fit|' + + 'fishing|fish|firmdale|firestone|fire|financial|finance|final|film|fido|fidelity|fiat|ferrero|' + + 'ferrari|feedback|fedex|fast|fashion|farmers|farm|fans|fan|family|faith|fairwinds|fail|fage|' + + 'extraspace|express|exposed|expert|exchange|everbank|events|eus|eurovision|etisalat|esurance|' + + 'estate|esq|erni|ericsson|equipment|epson|epost|enterprises|engineering|engineer|energy|emerck|' + + 'email|education|edu|edeka|eco|eat|earth|dvr|dvag|durban|dupont|duns|dunlop|duck|dubai|dtv|drive|' + + 'download|dot|doosan|domains|doha|dog|dodge|doctor|docs|dnp|diy|dish|discover|discount|directory|' + + 'direct|digital|diet|diamonds|dhl|dev|design|desi|dentist|dental|democrat|delta|deloitte|dell|' + + 'delivery|degree|deals|dealer|deal|dds|dclk|day|datsun|dating|date|data|dance|dad|dabur|cyou|' + + 'cymru|cuisinella|csc|cruises|cruise|crs|crown|cricket|creditunion|creditcard|credit|courses|' + + 'coupons|coupon|country|corsica|coop|cool|cookingchannel|cooking|contractors|contact|consulting|' + + 'construction|condos|comsec|computer|compare|company|community|commbank|comcast|com|cologne|' + + 'college|coffee|codes|coach|clubmed|club|cloud|clothing|clinique|clinic|click|cleaning|claims|' + + 'cityeats|city|citic|citi|citadel|cisco|circle|cipriani|church|chrysler|chrome|christmas|chloe|' + + 'chintai|cheap|chat|chase|channel|chanel|cfd|cfa|cern|ceo|center|ceb|cbs|cbre|cbn|cba|catholic|' + + 'catering|cat|casino|cash|caseih|case|casa|cartier|cars|careers|career|care|cards|caravan|car|' + + 'capitalone|capital|capetown|canon|cancerresearch|camp|camera|cam|calvinklein|call|cal|cafe|cab|' + + 'bzh|buzz|buy|business|builders|build|bugatti|budapest|brussels|brother|broker|broadway|' + + 'bridgestone|bradesco|box|boutique|bot|boston|bostik|bosch|boots|booking|book|boo|bond|bom|bofa|' + + 'boehringer|boats|bnpparibas|bnl|bmw|bms|blue|bloomberg|blog|blockbuster|blanco|blackfriday|' + + 'black|biz|bio|bingo|bing|bike|bid|bible|bharti|bet|bestbuy|best|berlin|bentley|beer|beauty|' + + 'beats|bcn|bcg|bbva|bbt|bbc|bayern|bauhaus|basketball|baseball|bargains|barefoot|barclays|' + + 'barclaycard|barcelona|bar|bank|band|bananarepublic|banamex|baidu|baby|azure|axa|aws|avianca|' + + 'autos|auto|author|auspost|audio|audible|audi|auction|attorney|athleta|associates|asia|asda|arte|' + + 'art|arpa|army|archi|aramco|arab|aquarelle|apple|app|apartments|aol|anz|anquan|android|analytics|' + + 'amsterdam|amica|amfam|amex|americanfamily|americanexpress|alstom|alsace|ally|allstate|allfinanz|' + + 'alipay|alibaba|alfaromeo|akdn|airtel|airforce|airbus|aigo|aig|agency|agakhan|africa|afl|' + + 'afamilycompany|aetna|aero|aeg|adult|ads|adac|actor|active|aco|accountants|accountant|accenture|' + + 'academy|abudhabi|abogado|able|abc|abbvie|abbott|abb|abarth|aarp|aaa|onion' + + ')(?=[^0-9a-zA-Z@]|$))')); + regexen.validCCTLD = regexSupplant(RegExp( + '(?:(?:' + + '한국|香港|澳門|新加坡|台灣|台湾|中國|中国|გე|ไทย|ලංකා|ഭാരതം|ಭಾರತ|భారత్|சிங்கப்பூர்|இலங்கை|இந்தியா|ଭାରତ|ભારત|ਭਾਰਤ|' + + 'ভাৰত|ভারত|বাংলা|भारोत|भारतम्|भारत|ڀارت|پاکستان|مليسيا|مصر|قطر|فلسطين|عمان|عراق|سورية|سودان|تونس|' + + 'بھارت|بارت|ایران|امارات|المغرب|السعودية|الجزائر|الاردن|հայ|қаз|укр|срб|рф|мон|мкд|ею|бел|бг|ελ|' + + 'zw|zm|za|yt|ye|ws|wf|vu|vn|vi|vg|ve|vc|va|uz|uy|us|um|uk|ug|ua|tz|tw|tv|tt|tr|tp|to|tn|tm|tl|tk|' + + 'tj|th|tg|tf|td|tc|sz|sy|sx|sv|su|st|ss|sr|so|sn|sm|sl|sk|sj|si|sh|sg|se|sd|sc|sb|sa|rw|ru|rs|ro|' + + 're|qa|py|pw|pt|ps|pr|pn|pm|pl|pk|ph|pg|pf|pe|pa|om|nz|nu|nr|np|no|nl|ni|ng|nf|ne|nc|na|mz|my|mx|' + + 'mw|mv|mu|mt|ms|mr|mq|mp|mo|mn|mm|ml|mk|mh|mg|mf|me|md|mc|ma|ly|lv|lu|lt|ls|lr|lk|li|lc|lb|la|kz|' + + 'ky|kw|kr|kp|kn|km|ki|kh|kg|ke|jp|jo|jm|je|it|is|ir|iq|io|in|im|il|ie|id|hu|ht|hr|hn|hm|hk|gy|gw|' + + 'gu|gt|gs|gr|gq|gp|gn|gm|gl|gi|gh|gg|gf|ge|gd|gb|ga|fr|fo|fm|fk|fj|fi|eu|et|es|er|eh|eg|ee|ec|dz|' + + 'do|dm|dk|dj|de|cz|cy|cx|cw|cv|cu|cr|co|cn|cm|cl|ck|ci|ch|cg|cf|cd|cc|ca|bz|by|bw|bv|bt|bs|br|bq|' + + 'bo|bn|bm|bl|bj|bi|bh|bg|bf|be|bd|bb|ba|az|ax|aw|au|at|as|ar|aq|ao|an|am|al|ai|ag|af|ae|ad|ac' + + ')(?=[^0-9a-zA-Z@]|$))')); + regexen.validPunycode = /(?:xn--[0-9a-z]+)/; + regexen.validSpecialCCTLD = /(?:(?:co|tv)(?=[^0-9a-zA-Z@]|$))/; + regexen.validDomain = regexSupplant(/(?:#{validSubdomain}*#{validDomainName}(?:#{validGTLD}|#{validCCTLD}|#{validPunycode}))/); + regexen.validPortNumber = /[0-9]+/; + regexen.pd = /\u002d\u058a\u05be\u1400\u1806\u2010-\u2015\u2e17\u2e1a\u2e3a\u2e40\u301c\u3030\u30a0\ufe31\ufe58\ufe63\uff0d/; + regexen.validGeneralUrlPathChars = regexSupplant(/[^#{spaces_group}\(\)\?]/i); + // Allow URL paths to contain up to two nested levels of balanced parens + // 1. Used in Wikipedia URLs like /Primer_(film) + // 2. Used in IIS sessions like /S(dfd346)/ + // 3. Used in Rdio URLs like /track/We_Up_(Album_Version_(Edited))/ + regexen.validUrlBalancedParens = regexSupplant( + '\\(' + + '(?:' + + '#{validGeneralUrlPathChars}+' + + '|' + + // allow one nested level of balanced parentheses + '(?:' + + '#{validGeneralUrlPathChars}*' + + '\\(' + + '#{validGeneralUrlPathChars}+' + + '\\)' + + '#{validGeneralUrlPathChars}*' + + ')' + + ')' + + '\\)' + , 'i'); + // Valid end-of-path chracters (so /foo. does not gobble the period). + // 1. Allow =&# for empty URL parameters and other URL-join artifacts + regexen.validUrlPathEndingChars = regexSupplant(/[^#{spaces_group}\(\)\?!\*';:=\,\.\$%\[\]#{pd}~&\|@]|(?:#{validUrlBalancedParens})/i); + // Allow @ in a url, but only in the middle. Catch things like http://example.com/@user/ + regexen.validUrlPath = regexSupplant('(?:' + + '(?:' + + '#{validGeneralUrlPathChars}*' + + '(?:#{validUrlBalancedParens}#{validGeneralUrlPathChars}*)*' + + '#{validUrlPathEndingChars}'+ + ')|(?:@#{validGeneralUrlPathChars}+\/)'+ + ')', 'i'); + regexen.validUrlQueryChars = /[a-z0-9!?\*'@\(\);:&=\+\$\/%#\[\]\-_\.,~|]/i; + regexen.validUrlQueryEndingChars = /[a-z0-9_&=#\/]/i; + regexen.validUrl = regexSupplant( + '(' + // $1 URL + '(https?:\\/\\/)' + // $2 Protocol + '(#{validDomain})' + // $3 Domain(s) + '(?::(#{validPortNumber}))?' + // $4 Port number (optional) + '(\\/#{validUrlPath}*)?' + // $5 URL Path + '(\\?#{validUrlQueryChars}*#{validUrlQueryEndingChars})?' + // $6 Query String + ')' + , 'gi'); + return regexen.validUrl; +}()); diff --git a/app/javascript/mastodon/features/standalone/compose/index.js b/app/javascript/mastodon/features/standalone/compose/index.js index 96d07fefb..0d764575f 100644 --- a/app/javascript/mastodon/features/standalone/compose/index.js +++ b/app/javascript/mastodon/features/standalone/compose/index.js @@ -2,6 +2,7 @@ import React from 'react'; import ComposeFormContainer from '../../compose/containers/compose_form_container'; import NotificationsContainer from '../../ui/containers/notifications_container'; import LoadingBarContainer from '../../ui/containers/loading_bar_container'; +import ModalContainer from '../../ui/containers/modal_container'; export default class Compose extends React.PureComponent { @@ -10,6 +11,7 @@ export default class Compose extends React.PureComponent {

+
); diff --git a/app/javascript/mastodon/features/status/components/card.js b/app/javascript/mastodon/features/status/components/card.js index 6b13e15cc..41c4300d3 100644 --- a/app/javascript/mastodon/features/status/components/card.js +++ b/app/javascript/mastodon/features/status/components/card.js @@ -1,4 +1,5 @@ import React from 'react'; +import PropTypes from 'prop-types'; import ImmutablePropTypes from 'react-immutable-proptypes'; import punycode from 'punycode'; import classnames from 'classnames'; @@ -22,10 +23,15 @@ export default class Card extends React.PureComponent { static propTypes = { card: ImmutablePropTypes.map, + maxDescription: PropTypes.number, + }; + + static defaultProps = { + maxDescription: 50, }; renderLink () { - const { card } = this.props; + const { card, maxDescription } = this.props; let image = ''; let provider = card.get('provider_name'); @@ -52,7 +58,7 @@ export default class Card extends React.PureComponent {
{card.get('title')} -

{(card.get('description') || '').substring(0, 50)}

+

{(card.get('description') || '').substring(0, maxDescription)}

{provider}
diff --git a/app/javascript/mastodon/features/status/components/detailed_status.js b/app/javascript/mastodon/features/status/components/detailed_status.js index b4979c603..8cd5abd3f 100644 --- a/app/javascript/mastodon/features/status/components/detailed_status.js +++ b/app/javascript/mastodon/features/status/components/detailed_status.js @@ -11,6 +11,7 @@ import Link from 'react-router-dom/Link'; import { FormattedDate, FormattedNumber } from 'react-intl'; import CardContainer from '../containers/card_container'; import ImmutablePureComponent from 'react-immutable-pure-component'; +import Video from '../../video'; import VisibilityIcon from '../../../../glitch/components/status/visibility_icon'; export default class DetailedStatus extends ImmutablePureComponent { @@ -36,6 +37,10 @@ export default class DetailedStatus extends ImmutablePureComponent { e.stopPropagation(); } + handleOpenVideo = startTime => { + this.props.onOpenVideo(this.props.status.getIn(['media_attachments', 0]), startTime); + } + render () { const status = this.props.status.get('reblog') ? this.props.status.get('reblog') : this.props.status; const { settings } = this.props; diff --git a/app/javascript/mastodon/features/ui/components/columns_area.js b/app/javascript/mastodon/features/ui/components/columns_area.js index 539af8ce3..5610095b9 100644 --- a/app/javascript/mastodon/features/ui/components/columns_area.js +++ b/app/javascript/mastodon/features/ui/components/columns_area.js @@ -78,7 +78,7 @@ export default class ColumnsArea extends ImmutablePureComponent { handleChildrenContentChange() { if (!this.props.singleColumn) { - scrollRight(this.node, this.node.scrollWidth - window.innerWidth); + this._interruptScrollAnimation = scrollRight(this.node, this.node.scrollWidth - window.innerWidth); } } diff --git a/app/javascript/mastodon/features/ui/components/video_modal.js b/app/javascript/mastodon/features/ui/components/video_modal.js index 9a9a49dfb..867c73ed5 100644 --- a/app/javascript/mastodon/features/ui/components/video_modal.js +++ b/app/javascript/mastodon/features/ui/components/video_modal.js @@ -1,35 +1,29 @@ import React from 'react'; import ImmutablePropTypes from 'react-immutable-proptypes'; import PropTypes from 'prop-types'; -import ExtendedVideoPlayer from '../../../components/extended_video_player'; -import { defineMessages, injectIntl } from 'react-intl'; -import IconButton from '../../../components/icon_button'; +import Video from '../../video'; import ImmutablePureComponent from 'react-immutable-pure-component'; -const messages = defineMessages({ - close: { id: 'lightbox.close', defaultMessage: 'Close' }, -}); - -@injectIntl export default class VideoModal extends ImmutablePureComponent { static propTypes = { media: ImmutablePropTypes.map.isRequired, time: PropTypes.number, onClose: PropTypes.func.isRequired, - intl: PropTypes.object.isRequired, }; render () { - const { media, intl, time, onClose } = this.props; - - const url = media.get('url'); + const { media, time, onClose } = this.props; return (
-
- +
); diff --git a/app/javascript/mastodon/features/ui/index.js b/app/javascript/mastodon/features/ui/index.js index 7d12210bb..3732d301f 100644 --- a/app/javascript/mastodon/features/ui/index.js +++ b/app/javascript/mastodon/features/ui/index.js @@ -11,7 +11,7 @@ import { debounce } from 'lodash'; import { uploadCompose } from '../../actions/compose'; import { refreshHomeTimeline } from '../../actions/timelines'; import { refreshNotifications } from '../../actions/notifications'; -import { clearStatusesHeight } from '../../actions/statuses'; +import { clearHeight } from '../../actions/height_cache'; import { WrappedSwitch, WrappedRoute } from './util/react_router_helpers'; import UploadArea from './components/upload_area'; import ColumnsAreaContainer from './containers/columns_area_container'; @@ -77,7 +77,7 @@ export default class UI extends React.PureComponent { handleResize = debounce(() => { // The cached heights are no longer accurate, invalidate - this.props.dispatch(clearStatusesHeight()); + this.props.dispatch(clearHeight()); this.setState({ width: window.innerWidth }); }, 500, { diff --git a/app/javascript/mastodon/features/ui/util/async-components.js b/app/javascript/mastodon/features/ui/util/async-components.js index 2f5c52e9e..ddb7e32c9 100644 --- a/app/javascript/mastodon/features/ui/util/async-components.js +++ b/app/javascript/mastodon/features/ui/util/async-components.js @@ -109,6 +109,10 @@ export function VideoPlayer () { return import(/* webpackChunkName: "status/video_player" */'../../../components/video_player'); } +export function Video () { + return import(/* webpackChunkName: "features/video" */'../../video'); +} + export function EmbedModal () { return import(/* webpackChunkName: "modals/embed_modal" */'../components/embed_modal'); } diff --git a/app/javascript/mastodon/features/video/index.js b/app/javascript/mastodon/features/video/index.js new file mode 100644 index 000000000..f228e434b --- /dev/null +++ b/app/javascript/mastodon/features/video/index.js @@ -0,0 +1,304 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import { throttle } from 'lodash'; +import classNames from 'classnames'; + +const messages = defineMessages({ + play: { id: 'video.play', defaultMessage: 'Play' }, + pause: { id: 'video.pause', defaultMessage: 'Pause' }, + mute: { id: 'video.mute', defaultMessage: 'Mute sound' }, + unmute: { id: 'video.unmute', defaultMessage: 'Unmute sound' }, + hide: { id: 'video.hide', defaultMessage: 'Hide video' }, + expand: { id: 'video.expand', defaultMessage: 'Expand video' }, + close: { id: 'video.close', defaultMessage: 'Close video' }, + fullscreen: { id: 'video.fullscreen', defaultMessage: 'Full screen' }, + exit_fullscreen: { id: 'video.exit_fullscreen', defaultMessage: 'Exit full screen' }, +}); + +const findElementPosition = el => { + let box; + + if (el.getBoundingClientRect && el.parentNode) { + box = el.getBoundingClientRect(); + } + + if (!box) { + return { + left: 0, + top: 0, + }; + } + + const docEl = document.documentElement; + const body = document.body; + + const clientLeft = docEl.clientLeft || body.clientLeft || 0; + const scrollLeft = window.pageXOffset || body.scrollLeft; + const left = (box.left + scrollLeft) - clientLeft; + + const clientTop = docEl.clientTop || body.clientTop || 0; + const scrollTop = window.pageYOffset || body.scrollTop; + const top = (box.top + scrollTop) - clientTop; + + return { + left: Math.round(left), + top: Math.round(top), + }; +}; + +const getPointerPosition = (el, event) => { + const position = {}; + const box = findElementPosition(el); + const boxW = el.offsetWidth; + const boxH = el.offsetHeight; + const boxY = box.top; + const boxX = box.left; + + let pageY = event.pageY; + let pageX = event.pageX; + + if (event.changedTouches) { + pageX = event.changedTouches[0].pageX; + pageY = event.changedTouches[0].pageY; + } + + position.y = Math.max(0, Math.min(1, ((boxY - pageY) + boxH) / boxH)); + position.x = Math.max(0, Math.min(1, (pageX - boxX) / boxW)); + + return position; +}; + +const isFullscreen = () => document.fullscreenElement || + document.webkitFullscreenElement || + document.mozFullScreenElement || + document.msFullscreenElement; + +const exitFullscreen = () => { + if (document.exitFullscreen) { + document.exitFullscreen(); + } else if (document.webkitExitFullscreen) { + document.webkitExitFullscreen(); + } else if (document.mozCancelFullScreen) { + document.mozCancelFullScreen(); + } else if (document.msExitFullscreen) { + document.msExitFullscreen(); + } +}; + +const requestFullscreen = el => { + if (el.requestFullscreen) { + el.requestFullscreen(); + } else if (el.webkitRequestFullscreen) { + el.webkitRequestFullscreen(); + } else if (el.mozRequestFullScreen) { + el.mozRequestFullScreen(); + } else if (el.msRequestFullscreen) { + el.msRequestFullscreen(); + } +}; + +@injectIntl +export default class Video extends React.PureComponent { + + static propTypes = { + preview: PropTypes.string, + src: PropTypes.string.isRequired, + width: PropTypes.number, + height: PropTypes.number, + sensitive: PropTypes.bool, + startTime: PropTypes.number, + onOpenVideo: PropTypes.func, + onCloseVideo: PropTypes.func, + intl: PropTypes.object.isRequired, + }; + + state = { + progress: 0, + paused: true, + dragging: false, + fullscreen: false, + hovered: false, + muted: false, + revealed: !this.props.sensitive, + }; + + setPlayerRef = c => { + this.player = c; + } + + setVideoRef = c => { + this.video = c; + } + + setSeekRef = c => { + this.seek = c; + } + + handlePlay = () => { + this.setState({ paused: false }); + } + + handlePause = () => { + this.setState({ paused: true }); + } + + handleTimeUpdate = () => { + this.setState({ progress: 100 * (this.video.currentTime / this.video.duration) }); + } + + handleMouseDown = e => { + document.addEventListener('mousemove', this.handleMouseMove, true); + document.addEventListener('mouseup', this.handleMouseUp, true); + document.addEventListener('touchmove', this.handleMouseMove, true); + document.addEventListener('touchend', this.handleMouseUp, true); + + this.setState({ dragging: true }); + this.video.pause(); + this.handleMouseMove(e); + } + + handleMouseUp = () => { + document.removeEventListener('mousemove', this.handleMouseMove, true); + document.removeEventListener('mouseup', this.handleMouseUp, true); + document.removeEventListener('touchmove', this.handleMouseMove, true); + document.removeEventListener('touchend', this.handleMouseUp, true); + + this.setState({ dragging: false }); + this.video.play(); + } + + handleMouseMove = throttle(e => { + const { x } = getPointerPosition(this.seek, e); + this.video.currentTime = this.video.duration * x; + this.setState({ progress: x * 100 }); + }, 60); + + togglePlay = () => { + if (this.state.paused) { + this.video.play(); + } else { + this.video.pause(); + } + } + + toggleFullscreen = () => { + if (isFullscreen()) { + exitFullscreen(); + } else { + requestFullscreen(this.player); + } + } + + componentDidMount () { + document.addEventListener('fullscreenchange', this.handleFullscreenChange, true); + document.addEventListener('webkitfullscreenchange', this.handleFullscreenChange, true); + document.addEventListener('mozfullscreenchange', this.handleFullscreenChange, true); + document.addEventListener('MSFullscreenChange', this.handleFullscreenChange, true); + } + + componentWillUnmount () { + document.removeEventListener('fullscreenchange', this.handleFullscreenChange, true); + document.removeEventListener('webkitfullscreenchange', this.handleFullscreenChange, true); + document.removeEventListener('mozfullscreenchange', this.handleFullscreenChange, true); + document.removeEventListener('MSFullscreenChange', this.handleFullscreenChange, true); + } + + handleFullscreenChange = () => { + this.setState({ fullscreen: isFullscreen() }); + } + + handleMouseEnter = () => { + this.setState({ hovered: true }); + } + + handleMouseLeave = () => { + this.setState({ hovered: false }); + } + + toggleMute = () => { + this.video.muted = !this.video.muted; + this.setState({ muted: this.video.muted }); + } + + toggleReveal = () => { + if (this.state.revealed) { + this.video.pause(); + } + + this.setState({ revealed: !this.state.revealed }); + } + + handleLoadedData = () => { + if (this.props.startTime) { + this.video.currentTime = this.props.startTime; + this.video.play(); + } + } + + handleOpenVideo = () => { + this.video.pause(); + this.props.onOpenVideo(this.video.currentTime); + } + + handleCloseVideo = () => { + this.video.pause(); + this.props.onCloseVideo(); + } + + render () { + const { preview, src, width, height, startTime, onOpenVideo, onCloseVideo, intl } = this.props; + const { progress, dragging, paused, fullscreen, hovered, muted, revealed } = this.state; + + return ( +
+