diff --git a/.env.production.sample b/.env.production.sample index 1d8a177aa..3e054db84 100644 --- a/.env.production.sample +++ b/.env.production.sample @@ -26,7 +26,7 @@ LOCAL_HTTPS=true # ALTERNATE_DOMAINS=example1.com,example2.com # Application secrets -# Generate each with the `rake secret` task (`docker-compose run --rm web rake secret` if you use docker compose) +# Generate each with the `RAILS_ENV=production bundle exec rake secret` task (`docker-compose run --rm web rake secret` if you use docker compose) PAPERCLIP_SECRET= SECRET_KEY_BASE= OTP_SECRET= @@ -36,7 +36,7 @@ OTP_SECRET= # You should only generate this once per instance. If you later decide to change it, all push subscription will # be invalidated, requiring the users to access the website again to resubscribe. # -# Generate with `rake mastodon:webpush:generate_vapid_key` task (`docker-compose run --rm web rake mastodon:webpush:generate_vapid_key` if you use docker compose) +# Generate with `RAILS_ENV=production bundle exec rake mastodon:webpush:generate_vapid_key` task (`docker-compose run --rm web rake mastodon:webpush:generate_vapid_key` if you use docker compose) # # For more information visit https://rossta.net/blog/using-the-web-push-api-with-vapid.html VAPID_PRIVATE_KEY= @@ -98,6 +98,15 @@ SMTP_FROM_ADDRESS=notifications@example.com # S3_ENDPOINT= # S3_SIGNATURE_VERSION= +# Swift (optional) +# SWIFT_ENABLED=true +# SWIFT_USERNAME= +# SWIFT_TENANT= +# SWIFT_PASSWORD= +# SWIFT_AUTH_URL= +# SWIFT_CONTAINER= +# SWIFT_OBJECT_URL= + # Optional alias for S3 if you want to use Cloudfront or Cloudflare in front # S3_CLOUDFRONT_HOST= diff --git a/.eslintrc.yml b/.eslintrc.yml index fd2ba46dd..1c60cbdb3 100644 --- a/.eslintrc.yml +++ b/.eslintrc.yml @@ -49,6 +49,7 @@ rules: - warn - allow: - error + - warn no-fallthrough: error no-irregular-whitespace: error no-mixed-spaces-and-tabs: warn diff --git a/.rubocop.yml b/.rubocop.yml index ae3697174..a36aa5cae 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -10,6 +10,7 @@ AllCops: - 'node_modules/**/*' - 'Vagrantfile' - 'vendor/**/*' + - 'lib/json_ld/*' Bundler/OrderedGems: Enabled: false diff --git a/.travis.yml b/.travis.yml index 4d4dc0893..d5b51fcb0 100644 --- a/.travis.yml +++ b/.travis.yml @@ -6,6 +6,7 @@ cache: - node_modules - public/assets - public/packs-test + - tmp/cache/babel-loader dist: trusty sudo: required diff --git a/CODEOWNERS b/CODEOWNERS new file mode 100644 index 000000000..42fc73ded --- /dev/null +++ b/CODEOWNERS @@ -0,0 +1,15 @@ +# CODEOWNERS for tootsuite/mastodon + +# Translators +# To add translator, copy these lines, replace `fr` with appropriate language code and replace `@żelipapą` with user's GitHub nickname preceded by `@` sign or e-mail address. +# /app/javascript/mastodon/locales/fr.json @żelipapą +# /app/views/user_mailer/*.fr.html.erb @żelipapą +# /app/views/user_mailer/*.fr.text.erb @żelipapą +# /config/locales/*.fr.yml @żelipapą +# /config/locales/fr.yml @żelipapą + +/app/javascript/mastodon/locales/pl.json @m4sk1n +/app/views/user_mailer/*.pl.html.erb @m4sk1n +/app/views/user_mailer/*.pl.text.erb @m4sk1n +/config/locales/*.pl.yml @m4sk1n +/config/locales/pl.yml @m4sk1n diff --git a/Dockerfile b/Dockerfile index ef139dcec..15138065b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM ruby:2.4.1-alpine +FROM ruby:2.4.1-alpine3.6 LABEL maintainer="https://github.com/tootsuite/mastodon" \ description="A GNU Social-compatible microblogging server" @@ -7,19 +7,19 @@ ENV UID=991 GID=991 \ RAILS_SERVE_STATIC_FILES=true \ RAILS_ENV=production NODE_ENV=production +ARG LIBICONV_VERSION=1.15 +ARG LIBICONV_DOWNLOAD_SHA256=ccf536620a45458d26ba83887a983b96827001e92a13847b45e4925cc8913178 + EXPOSE 3000 4000 WORKDIR /mastodon -RUN echo "@edge https://nl.alpinelinux.org/alpine/edge/main" >> /etc/apk/repositories \ - && echo "@edge https://nl.alpinelinux.org/alpine/edge/community" >> /etc/apk/repositories \ - && apk -U upgrade \ +RUN apk -U upgrade \ && apk add -t build-dependencies \ build-base \ icu-dev \ libidn-dev \ - libxml2-dev \ - libxslt-dev \ + libtool \ postgresql-dev \ protobuf-dev \ python \ @@ -29,23 +29,33 @@ RUN echo "@edge https://nl.alpinelinux.org/alpine/edge/main" >> /etc/apk/reposit file \ git \ icu-libs \ - imagemagick@edge \ + imagemagick \ libidn \ libpq \ - libxml2 \ - libxslt \ - nodejs-npm@edge \ - nodejs@edge \ + nodejs-npm \ + nodejs \ protobuf \ su-exec \ tini \ - yarn@edge \ + yarn \ && update-ca-certificates \ + && wget -O libiconv.tar.gz "http://ftp.gnu.org/pub/gnu/libiconv/libiconv-$LIBICONV_VERSION.tar.gz" \ + && echo "$LIBICONV_DOWNLOAD_SHA256 *libiconv.tar.gz" | sha256sum -c - \ + && mkdir -p /tmp/src \ + && tar -xzf libiconv.tar.gz -C /tmp/src \ + && rm libiconv.tar.gz \ + && cd /tmp/src/libiconv-$LIBICONV_VERSION \ + && ./configure --prefix=/usr/local \ + && make -j$(getconf _NPROCESSORS_ONLN)\ + && make install \ + && libtool --finish /usr/local/lib \ + && cd /mastodon \ && rm -rf /tmp/* /var/cache/apk/* COPY Gemfile Gemfile.lock package.json yarn.lock /mastodon/ -RUN bundle install --deployment --without test development \ +RUN bundle config build.nokogiri --with-iconv-lib=/usr/local/lib --with-iconv-include=/usr/local/include \ + && bundle install -j$(getconf _NPROCESSORS_ONLN) --deployment --without test development \ && yarn --ignore-optional --pure-lockfile COPY . /mastodon diff --git a/Gemfile b/Gemfile index f4182bff5..637bf53ee 100644 --- a/Gemfile +++ b/Gemfile @@ -15,6 +15,7 @@ gem 'pghero', '~> 1.7' gem 'dotenv-rails', '~> 2.2' gem 'aws-sdk', '~> 2.9' +gem 'fog-openstack', '~> 0.1' gem 'paperclip', '~> 5.1' gem 'paperclip-av-transcoder', '~> 0.6' @@ -22,7 +23,8 @@ gem 'active_model_serializers', '~> 0.10' gem 'addressable', '~> 2.5' gem 'bootsnap' gem 'browser' -gem 'charlock_holmes', '~> 0.7.3' +gem 'charlock_holmes', '~> 0.7.5' +gem 'iso-639' gem 'cld3', '~> 3.1' gem 'devise', '~> 4.2' gem 'devise-two-factor', '~> 3.0' @@ -68,6 +70,9 @@ gem 'tzinfo-data', '~> 1.2017' gem 'webpacker', '~> 2.0' gem 'webpush' +gem 'json-ld-preloaded', '~> 2.2.1' +gem 'rdf-normalize', '~> 0.3.1' + group :development, :test do gem 'fabrication', '~> 2.16' gem 'fuubar', '~> 2.2' diff --git a/Gemfile.lock b/Gemfile.lock index e5fd3506a..ddb97dd94 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,25 +1,25 @@ GEM remote: https://rubygems.org/ specs: - actioncable (5.1.2) - actionpack (= 5.1.2) + actioncable (5.1.3) + actionpack (= 5.1.3) nio4r (~> 2.0) websocket-driver (~> 0.6.1) - actionmailer (5.1.2) - actionpack (= 5.1.2) - actionview (= 5.1.2) - activejob (= 5.1.2) + actionmailer (5.1.3) + actionpack (= 5.1.3) + actionview (= 5.1.3) + activejob (= 5.1.3) mail (~> 2.5, >= 2.5.4) rails-dom-testing (~> 2.0) - actionpack (5.1.2) - actionview (= 5.1.2) - activesupport (= 5.1.2) + actionpack (5.1.3) + actionview (= 5.1.3) + activesupport (= 5.1.3) rack (~> 2.0) rack-test (~> 0.6.3) rails-dom-testing (~> 2.0) rails-html-sanitizer (~> 1.0, >= 1.0.2) - actionview (5.1.2) - activesupport (= 5.1.2) + actionview (5.1.3) + activesupport (= 5.1.3) builder (~> 3.1) erubi (~> 1.4) rails-dom-testing (~> 2.0) @@ -30,22 +30,22 @@ GEM case_transform (>= 0.2) jsonapi-renderer (>= 0.1.1.beta1, < 0.2) active_record_query_trace (1.5.4) - activejob (5.1.2) - activesupport (= 5.1.2) + activejob (5.1.3) + activesupport (= 5.1.3) globalid (>= 0.3.6) - activemodel (5.1.2) - activesupport (= 5.1.2) - activerecord (5.1.2) - activemodel (= 5.1.2) - activesupport (= 5.1.2) + activemodel (5.1.3) + activesupport (= 5.1.3) + activerecord (5.1.3) + activemodel (= 5.1.3) + activesupport (= 5.1.3) arel (~> 8.0) - activesupport (5.1.2) + activesupport (5.1.3) concurrent-ruby (~> 1.0, >= 1.0.2) i18n (~> 0.7) minitest (~> 5.1) tzinfo (~> 1.1) - addressable (2.5.1) - public_suffix (~> 2.0, >= 2.0.2) + addressable (2.5.2) + public_suffix (>= 2.0.2, < 4.0) airbrussh (1.3.0) sshkit (>= 1.6.1, != 1.7.0) annotate (2.7.2) @@ -57,14 +57,14 @@ GEM encryptor (~> 3.0.0) av (0.9.0) cocaine (~> 0.5.3) - aws-sdk (2.10.6) - aws-sdk-resources (= 2.10.6) - aws-sdk-core (2.10.6) + aws-sdk (2.10.21) + aws-sdk-resources (= 2.10.21) + aws-sdk-core (2.10.21) aws-sigv4 (~> 1.0) jmespath (~> 1.0) - aws-sdk-resources (2.10.6) - aws-sdk-core (= 2.10.6) - aws-sigv4 (1.0.0) + aws-sdk-resources (2.10.21) + aws-sdk-core (= 2.10.21) + aws-sigv4 (1.0.1) bcrypt (3.1.11) better_errors (2.1.1) coderay (>= 1.0.0) @@ -72,15 +72,15 @@ GEM rack (>= 0.9.0) binding_of_caller (0.7.2) debug_inspector (>= 0.0.1) - bootsnap (1.1.1) + bootsnap (1.1.2) msgpack (~> 1.0) - brakeman (3.6.2) + brakeman (3.7.2) browser (2.4.0) builder (3.2.3) bullet (5.5.1) activesupport (>= 3.0.0) uniform_notifier (~> 1.10.0) - bundler-audit (0.5.0) + bundler-audit (0.6.0) bundler (~> 1.2) thor (~> 0.18) capistrano (3.8.2) @@ -108,7 +108,7 @@ GEM xpath (~> 2.0) case_transform (0.2) activesupport - charlock_holmes (0.7.3) + charlock_holmes (0.7.5) chunky_png (1.3.8) cld3 (3.1.3) ffi (>= 1.1.0, < 1.10.0) @@ -154,18 +154,31 @@ GEM erubis (2.7.0) et-orbi (1.0.5) tzinfo + excon (0.58.0) execjs (2.7.0) - fabrication (2.16.1) + fabrication (2.16.2) faker (1.7.3) i18n (~> 0.5) fast_blank (1.0.0) ffi (1.9.18) + fog-core (1.45.0) + builder + excon (~> 0.58) + formatador (~> 0.2) + fog-json (1.0.2) + fog-core (~> 1.0) + multi_json (~> 1.10) + fog-openstack (0.1.21) + fog-core (>= 1.40) + fog-json (>= 1.0) + ipaddress (>= 0.8) + formatador (0.2.5) fuubar (2.2.0) rspec-core (~> 3.0) ruby-progressbar (~> 1.4) globalid (0.4.0) activesupport (>= 4.2.0) - goldfinger (2.0.0) + goldfinger (2.0.1) addressable (~> 2.5) http (~> 2.2) nokogiri (~> 1.8) @@ -179,7 +192,9 @@ GEM activesupport (>= 4.0.1) hamlit (>= 1.2.0) railties (>= 4.0.1) - hashdiff (0.3.4) + hamster (3.0.0) + concurrent-ruby (~> 1.0) + hashdiff (0.3.5) highline (1.7.8) hiredis (0.6.1) hkdf (0.3.0) @@ -194,11 +209,11 @@ GEM http-form_data (1.0.3) http_accept_language (2.1.1) http_parser.rb (0.6.0) - httplog (0.99.4) + httplog (0.99.7) colorize rack - i18n (0.8.4) - i18n-tasks (0.9.15) + i18n (0.8.6) + i18n-tasks (0.9.16) activesupport (>= 4.0.2) ast (>= 2.1.0) easy_translate (>= 0.5.0) @@ -209,9 +224,18 @@ GEM rainbow (~> 2.2) terminal-table (>= 1.5.1) idn-ruby (0.1.0) + ipaddress (0.8.3) + iso-639 (0.2.8) jmespath (1.3.1) json (2.1.0) - jsonapi-renderer (0.1.2) + json-ld (2.1.5) + multi_json (~> 1.12) + rdf (~> 2.2) + json-ld-preloaded (2.2.1) + json-ld (~> 2.1, >= 2.1.5) + multi_json (~> 1.11) + rdf (~> 2.2) + jsonapi-renderer (0.1.3) jwt (1.5.6) kaminari (1.0.1) activesupport (>= 4.1.0) @@ -253,7 +277,7 @@ GEM mime-types-data (3.2016.0521) mimemagic (0.3.2) mini_portile2 (2.2.0) - minitest (5.10.2) + minitest (5.10.3) msgpack (1.1.0) multi_json (1.12.1) net-scp (1.2.1) @@ -264,7 +288,7 @@ GEM mini_portile2 (~> 2.2.0) nokogumbo (1.4.13) nokogiri - oj (3.2.0) + oj (3.3.4) openssl (2.0.4) orm_adapter (0.5.0) ostatus2 (2.0.1) @@ -283,14 +307,14 @@ GEM av (~> 0.9.0) paperclip (>= 2.5.2) parallel (1.11.2) - parallel_tests (2.14.1) + parallel_tests (2.14.2) parallel parser (2.4.0.0) ast (~> 2.2) pg (0.21.0) pghero (1.7.0) activerecord - pkg-config (1.2.3) + pkg-config (1.2.4) powerpack (0.1.1) pry (0.10.4) coderay (~> 1.1.0) @@ -298,7 +322,7 @@ GEM slop (~> 3.4) pry-rails (0.3.6) pry (>= 0.10.4) - public_suffix (2.0.5) + public_suffix (3.0.0) puma (3.9.1) pundit (1.1.0) activesupport (>= 3.0.0) @@ -313,17 +337,17 @@ GEM rack-test (0.6.3) rack (>= 1.0) rack-timeout (0.4.2) - rails (5.1.2) - actioncable (= 5.1.2) - actionmailer (= 5.1.2) - actionpack (= 5.1.2) - actionview (= 5.1.2) - activejob (= 5.1.2) - activemodel (= 5.1.2) - activerecord (= 5.1.2) - activesupport (= 5.1.2) - bundler (>= 1.3.0, < 2.0) - railties (= 5.1.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) + bundler (>= 1.3.0) + railties (= 5.1.3) sprockets-rails (>= 2.0.0) rails-controller-testing (1.0.2) actionpack (~> 5.x, >= 5.0.1) @@ -337,23 +361,28 @@ GEM rails-i18n (5.0.4) i18n (~> 0.7) railties (~> 5.0) - rails-settings-cached (0.6.5) + rails-settings-cached (0.6.6) rails (>= 4.2.0) - railties (5.1.2) - actionpack (= 5.1.2) - activesupport (= 5.1.2) + railties (5.1.3) + actionpack (= 5.1.3) + activesupport (= 5.1.3) 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) + hamster (~> 3.0) + link_header (~> 0.0, >= 0.0.8) + rdf-normalize (0.3.2) + rdf (~> 2.0) redis (3.3.3) redis-actionpack (5.0.1) actionpack (>= 4.0, < 6) redis-rack (>= 1, < 3) redis-store (>= 1.1.0, < 1.4.0) - redis-activesupport (5.0.2) + redis-activesupport (5.0.3) activesupport (>= 3, < 6) redis-store (~> 1.3.0) redis-namespace (1.5.3) @@ -413,7 +442,7 @@ GEM scss_lint (0.54.0) rake (>= 0.9, < 13) sass (~> 3.4.20) - sidekiq (5.0.3) + sidekiq (5.0.4) concurrent-ruby (~> 1.0) connection_pool (~> 2.2, >= 2.2.0) rack-protection (>= 1.5.0) @@ -421,12 +450,12 @@ GEM sidekiq-bulk (0.1.1) activesupport sidekiq - sidekiq-scheduler (2.1.7) + sidekiq-scheduler (2.1.8) redis (~> 3) rufus-scheduler (~> 3.2) sidekiq (>= 3) tilt (>= 1.4.0) - sidekiq-unique-jobs (5.0.8) + sidekiq-unique-jobs (5.0.9) sidekiq (>= 4.0, <= 6.0) thor (~> 0) simple-navigation (4.0.5) @@ -450,15 +479,15 @@ GEM sshkit (1.13.1) net-scp (>= 1.1.2) net-ssh (>= 2.8.0) - statsd-instrument (2.1.2) + statsd-instrument (2.1.4) temple (0.8.0) terminal-table (1.8.0) unicode-display_width (~> 1.1, >= 1.1.1) - thor (0.19.4) + thor (0.20.0) thread (0.2.2) thread_safe (0.3.6) - tilt (2.0.7) - twitter-text (1.14.6) + tilt (2.0.8) + twitter-text (1.14.7) unf (~> 0.1.0) tzinfo (1.2.3) thread_safe (~> 0.1) @@ -511,7 +540,7 @@ DEPENDENCIES capistrano-rbenv (~> 2.1) capistrano-yarn (~> 2.0) capybara (~> 2.14) - charlock_holmes (~> 0.7.3) + charlock_holmes (~> 0.7.5) cld3 (~> 3.1) climate_control (~> 0.2) devise (~> 4.2) @@ -521,6 +550,7 @@ DEPENDENCIES fabrication (~> 2.16) faker (~> 1.7) fast_blank (~> 1.0) + fog-openstack (~> 0.1) fuubar (~> 2.2) goldfinger (~> 2.0) hamlit-rails (~> 0.2) @@ -531,6 +561,8 @@ DEPENDENCIES httplog (~> 0.99) i18n-tasks (~> 0.9) idn-ruby + iso-639 + json-ld-preloaded (~> 2.2.1) kaminari (~> 1.0) letter_opener (~> 1.4) letter_opener_web (~> 1.3) @@ -560,6 +592,7 @@ DEPENDENCIES rails-controller-testing (~> 1.0) rails-i18n (~> 5.0) rails-settings-cached (~> 0.6) + rdf-normalize (~> 0.3.1) redis (~> 3.3) redis-namespace (~> 1.5) redis-rails (~> 5.0) @@ -590,4 +623,4 @@ RUBY VERSION ruby 2.4.1p111 BUNDLED WITH - 1.15.2 + 1.15.4 diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb index c270eb000..26ab6636b 100644 --- a/app/controllers/accounts_controller.rb +++ b/app/controllers/accounts_controller.rb @@ -7,24 +7,78 @@ class AccountsController < ApplicationController def show respond_to do |format| format.html do - @statuses = @account.statuses.permitted_for(@account, current_account).paginate_by_max_id(20, params[:max_id], params[:since_id]) - @statuses = cache_collection(@statuses, Status) + @pinned_statuses = [] + + if current_account && @account.blocking?(current_account) + @statuses = [] + return + end + + @pinned_statuses = cache_collection(@account.pinned_statuses, Status) if show_pinned_statuses? + @statuses = filtered_statuses.paginate_by_max_id(20, params[:max_id], params[:since_id]) + @statuses = cache_collection(@statuses, Status) + @next_url = next_url unless @statuses.empty? end format.atom do @entries = @account.stream_entries.where(hidden: false).with_includes.paginate_by_max_id(20, params[:max_id], params[:since_id]) - render xml: OStatus::AtomSerializer.render(OStatus::AtomSerializer.new.feed(@account, @entries.to_a)) + render xml: OStatus::AtomSerializer.render(OStatus::AtomSerializer.new.feed(@account, @entries.reject { |entry| entry.status.nil? })) end format.json do - render json: @account, serializer: ActivityPub::ActorSerializer, adapter: ActivityPub::Adapter + render json: @account, serializer: ActivityPub::ActorSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json' end end end private + def show_pinned_statuses? + [replies_requested?, media_requested?, params[:max_id].present?, params[:since_id].present?].none? + end + + def filtered_statuses + default_statuses.tap do |statuses| + statuses.merge!(only_media_scope) if media_requested? + statuses.merge!(no_replies_scope) unless replies_requested? + end + end + + def default_statuses + @account.statuses.where(visibility: [:public, :unlisted]) + end + + def only_media_scope + Status.where(id: account_media_status_ids) + end + + def account_media_status_ids + @account.media_attachments.attached.reorder(nil).select(:status_id).distinct + end + + def no_replies_scope + Status.without_replies + end + def set_account @account = Account.find_local!(params[:username]) end + + def next_url + if media_requested? + short_account_media_url(@account, max_id: @statuses.last.id) + elsif replies_requested? + short_account_with_replies_url(@account, max_id: @statuses.last.id) + else + short_account_url(@account, max_id: @statuses.last.id) + end + end + + def media_requested? + request.path.ends_with?('/media') + end + + def replies_requested? + request.path.ends_with?('/with_replies') + end end diff --git a/app/controllers/activitypub/inboxes_controller.rb b/app/controllers/activitypub/inboxes_controller.rb new file mode 100644 index 000000000..b37910b36 --- /dev/null +++ b/app/controllers/activitypub/inboxes_controller.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +class ActivityPub::InboxesController < Api::BaseController + include SignatureVerification + + before_action :set_account + + def create + if signed_request_account + upgrade_account + process_payload + head 201 + else + head 202 + end + end + + private + + def set_account + @account = Account.find_local!(params[:account_username]) if params[:account_username] + end + + def body + @body ||= request.body.read + end + + def upgrade_account + if signed_request_account.ostatus? + signed_request_account.update(last_webfingered_at: nil) + ResolveRemoteAccountWorker.perform_async(signed_request_account.acct) + end + + Pubsubhubbub::UnsubscribeWorker.perform_async(signed_request_account.id) if signed_request_account.subscribed? + end + + def process_payload + ActivityPub::ProcessingWorker.perform_async(signed_request_account.id, body.force_encoding('UTF-8')) + end +end diff --git a/app/controllers/activitypub/outboxes_controller.rb b/app/controllers/activitypub/outboxes_controller.rb index 30b91f370..9f97ff622 100644 --- a/app/controllers/activitypub/outboxes_controller.rb +++ b/app/controllers/activitypub/outboxes_controller.rb @@ -7,7 +7,7 @@ class ActivityPub::OutboxesController < Api::BaseController @statuses = @account.statuses.permitted_for(@account, current_account).paginate_by_max_id(20, params[:max_id], params[:since_id]) @statuses = cache_collection(@statuses, Status) - render json: outbox_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter + render json: outbox_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json' end private diff --git a/app/controllers/admin/accounts_controller.rb b/app/controllers/admin/accounts_controller.rb index 7bceee2cd..54c659e1b 100644 --- a/app/controllers/admin/accounts_controller.rb +++ b/app/controllers/admin/accounts_controller.rb @@ -17,7 +17,7 @@ module Admin end def unsubscribe - UnsubscribeService.new.call(@account) + Pubsubhubbub::UnsubscribeWorker.perform_async(@account.id) redirect_to admin_account_path(@account.id) end diff --git a/app/controllers/admin/settings_controller.rb b/app/controllers/admin/settings_controller.rb index 5985d6282..c5e6fe4e5 100644 --- a/app/controllers/admin/settings_controller.rb +++ b/app/controllers/admin/settings_controller.rb @@ -13,6 +13,7 @@ module Admin closed_registrations_message open_deletion timeline_preview + bootstrap_timeline_accounts ).freeze BOOLEAN_SETTINGS = %w( diff --git a/app/controllers/admin/statuses_controller.rb b/app/controllers/admin/statuses_controller.rb index 50712f0dd..b05000b16 100644 --- a/app/controllers/admin/statuses_controller.rb +++ b/app/controllers/admin/statuses_controller.rb @@ -9,7 +9,7 @@ module Admin before_action :set_account before_action :set_status, only: [:update, :destroy] - PAR_PAGE = 20 + PER_PAGE = 20 def index @statuses = @account.statuses @@ -17,7 +17,7 @@ module Admin account_media_status_ids = @account.media_attachments.attached.reorder(nil).select(:status_id).distinct @statuses.merge!(Status.where(id: account_media_status_ids)) end - @statuses = @statuses.preload(:media_attachments, :mentions).page(params[:page]).per(PAR_PAGE) + @statuses = @statuses.preload(:media_attachments, :mentions).page(params[:page]).per(PER_PAGE) @form = Form::StatusBatch.new end diff --git a/app/controllers/api/base_controller.rb b/app/controllers/api/base_controller.rb index 105a2859d..7cfe8fe71 100644 --- a/app/controllers/api/base_controller.rb +++ b/app/controllers/api/base_controller.rb @@ -43,7 +43,7 @@ class Api::BaseController < ApplicationController links = [] links << [next_path, [%w(rel next)]] if next_path links << [prev_path, [%w(rel prev)]] if prev_path - response.headers['Link'] = LinkHeader.new(links) + response.headers['Link'] = LinkHeader.new(links) unless links.empty? end def limit_param(default_limit) @@ -62,10 +62,11 @@ class Api::BaseController < ApplicationController end def require_user! - current_resource_owner - set_user_activity - rescue ActiveRecord::RecordNotFound - render json: { error: 'This method requires an authenticated user' }, status: 422 + if current_user + set_user_activity + else + render json: { error: 'This method requires an authenticated user' }, status: 422 + end end def render_empty diff --git a/app/controllers/api/oembed_controller.rb b/app/controllers/api/oembed_controller.rb index f8c87dd16..37a163cd3 100644 --- a/app/controllers/api/oembed_controller.rb +++ b/app/controllers/api/oembed_controller.rb @@ -4,14 +4,14 @@ class Api::OEmbedController < Api::BaseController respond_to :json def show - @stream_entry = find_stream_entry.stream_entry - render json: @stream_entry, serializer: OEmbedSerializer, width: maxwidth_or_default, height: maxheight_or_default + @status = status_finder.status + render json: @status, serializer: OEmbedSerializer, width: maxwidth_or_default, height: maxheight_or_default end private - def find_stream_entry - StreamEntryFinder.new(params[:url]) + def status_finder + StatusFinder.new(params[:url]) end def maxwidth_or_default diff --git a/app/controllers/api/v1/accounts/credentials_controller.rb b/app/controllers/api/v1/accounts/credentials_controller.rb index 073808532..da534d960 100644 --- a/app/controllers/api/v1/accounts/credentials_controller.rb +++ b/app/controllers/api/v1/accounts/credentials_controller.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true class Api::V1::Accounts::CredentialsController < Api::BaseController + before_action -> { doorkeeper_authorize! :read }, except: [:update] before_action -> { doorkeeper_authorize! :write }, only: [:update] before_action :require_user! @@ -10,8 +11,9 @@ class Api::V1::Accounts::CredentialsController < Api::BaseController end def update - current_account.update!(account_params) @account = current_account + UpdateAccountService.new.call(@account, account_params, raise_error: true) + ActivityPub::UpdateDistributionWorker.perform_async(@account.id) render json: @account, serializer: REST::CredentialAccountSerializer end diff --git a/app/controllers/api/v1/accounts/statuses_controller.rb b/app/controllers/api/v1/accounts/statuses_controller.rb index d9ae5c089..095f6937b 100644 --- a/app/controllers/api/v1/accounts/statuses_controller.rb +++ b/app/controllers/api/v1/accounts/statuses_controller.rb @@ -29,6 +29,7 @@ class Api::V1::Accounts::StatusesController < Api::BaseController def account_statuses default_statuses.tap do |statuses| statuses.merge!(only_media_scope) if params[:only_media] + statuses.merge!(pinned_scope) if params[:pinned] statuses.merge!(no_replies_scope) if params[:exclude_replies] end end @@ -53,6 +54,10 @@ class Api::V1::Accounts::StatusesController < Api::BaseController @account.media_attachments.attached.reorder(nil).select(:status_id).distinct end + def pinned_scope + @account.pinned_statuses + end + def no_replies_scope Status.without_replies end diff --git a/app/controllers/api/v1/accounts_controller.rb b/app/controllers/api/v1/accounts_controller.rb index f621aa245..656cacd8a 100644 --- a/app/controllers/api/v1/accounts_controller.rb +++ b/app/controllers/api/v1/accounts_controller.rb @@ -14,6 +14,16 @@ class Api::V1::AccountsController < Api::BaseController def follow FollowService.new.call(current_user.account, @account.acct) + + unless @account.locked? + relationships = AccountRelationshipsPresenter.new( + [@account.id], + current_user.account_id, + following_map: { @account.id => true }, + requested_map: { @account.id => false } + ) + end + render json: @account, serializer: REST::RelationshipSerializer, relationships: relationships end diff --git a/app/controllers/api/v1/follows_controller.rb b/app/controllers/api/v1/follows_controller.rb index e01ae5c01..5a2b2f32f 100644 --- a/app/controllers/api/v1/follows_controller.rb +++ b/app/controllers/api/v1/follows_controller.rb @@ -10,6 +10,12 @@ class Api::V1::FollowsController < Api::BaseController raise ActiveRecord::RecordNotFound if follow_params[:uri].blank? @account = FollowService.new.call(current_user.account, target_uri).try(:target_account) + + if @account.nil? + username, domain = target_uri.split('@') + @account = Account.find_remote!(username, domain) + end + render json: @account, serializer: REST::AccountSerializer end diff --git a/app/controllers/api/v1/statuses/pins_controller.rb b/app/controllers/api/v1/statuses/pins_controller.rb new file mode 100644 index 000000000..3de1009b8 --- /dev/null +++ b/app/controllers/api/v1/statuses/pins_controller.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +class Api::V1::Statuses::PinsController < Api::BaseController + include Authorization + + before_action -> { doorkeeper_authorize! :write } + before_action :require_user! + before_action :set_status + + respond_to :json + + def create + StatusPin.create!(account: current_account, status: @status) + render json: @status, serializer: REST::StatusSerializer + end + + def destroy + pin = StatusPin.find_by(account: current_account, status: @status) + pin&.destroy! + render json: @status, serializer: REST::StatusSerializer + end + + private + + def set_status + @status = Status.find(params[:status_id]) + end +end diff --git a/app/controllers/api/v1/statuses_controller.rb b/app/controllers/api/v1/statuses_controller.rb index 9c7124d0f..544a4ce21 100644 --- a/app/controllers/api/v1/statuses_controller.rb +++ b/app/controllers/api/v1/statuses_controller.rb @@ -29,7 +29,7 @@ class Api::V1::StatusesController < Api::BaseController end def card - @card = PreviewCard.find_by(status: @status) + @card = @status.preview_cards.first if @card.nil? render_empty diff --git a/app/controllers/api/web/embeds_controller.rb b/app/controllers/api/web/embeds_controller.rb new file mode 100644 index 000000000..2ed516161 --- /dev/null +++ b/app/controllers/api/web/embeds_controller.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class Api::Web::EmbedsController < Api::BaseController + respond_to :json + + before_action :require_user! + + def create + status = StatusFinder.new(params[:url]).status + render json: status, serializer: OEmbedSerializer, width: 400 + rescue ActiveRecord::RecordNotFound + oembed = OEmbed::Providers.get(params[:url]) + render json: Oj.dump(oembed.fields) + rescue OEmbed::NotFound + render json: {}, status: :not_found + end +end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index b3c2db02b..0b40fb05b 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -43,6 +43,10 @@ class ApplicationController < ActionController::Base forbidden if current_user.account.suspended? end + def after_sign_out_path_for(_resource_or_scope) + new_user_session_path + end + protected def forbidden diff --git a/app/controllers/auth/confirmations_controller.rb b/app/controllers/auth/confirmations_controller.rb index 2fdb281f4..d5e8e58ed 100644 --- a/app/controllers/auth/confirmations_controller.rb +++ b/app/controllers/auth/confirmations_controller.rb @@ -2,4 +2,10 @@ class Auth::ConfirmationsController < Devise::ConfirmationsController layout 'auth' + + def show + super do |user| + BootstrapTimelineWorker.perform_async(user.account_id) if user.errors.empty? + end + end end diff --git a/app/controllers/auth/passwords_controller.rb b/app/controllers/auth/passwords_controller.rb index 54ee1c39c..171b997dc 100644 --- a/app/controllers/auth/passwords_controller.rb +++ b/app/controllers/auth/passwords_controller.rb @@ -1,5 +1,20 @@ # frozen_string_literal: true class Auth::PasswordsController < Devise::PasswordsController + before_action :check_validity_of_reset_password_token, only: :edit + layout 'auth' + + private + + def check_validity_of_reset_password_token + unless reset_password_token_is_valid? + flash[:error] = I18n.t('auth.invalid_reset_password_token') + redirect_to new_password_path(resource_name) + end + end + + def reset_password_token_is_valid? + resource_class.with_reset_password_token(params[:reset_password_token]).present? + end end diff --git a/app/controllers/authorize_follows_controller.rb b/app/controllers/authorize_follows_controller.rb index dccd1c209..78b564183 100644 --- a/app/controllers/authorize_follows_controller.rb +++ b/app/controllers/authorize_follows_controller.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class AuthorizeFollowsController < ApplicationController - layout 'public' + layout 'modal' before_action :authenticate_user! diff --git a/app/controllers/concerns/account_controller_concern.rb b/app/controllers/concerns/account_controller_concern.rb index d36fc8c93..5b9981aa2 100644 --- a/app/controllers/concerns/account_controller_concern.rb +++ b/app/controllers/concerns/account_controller_concern.rb @@ -23,6 +23,7 @@ module AccountControllerConcern [ webfinger_account_link, atom_account_url_link, + actor_url_link, ] ) end @@ -41,6 +42,13 @@ module AccountControllerConcern ] end + def actor_url_link + [ + ActivityPub::TagManager.instance.uri_for(@account), + [%w(rel alternate), %w(type application/activity+json)], + ] + end + def webfinger_account_url webfinger_url(resource: @account.to_webfinger_s) end diff --git a/app/controllers/concerns/signature_verification.rb b/app/controllers/concerns/signature_verification.rb index abe845d93..4211283ed 100644 --- a/app/controllers/concerns/signature_verification.rb +++ b/app/controllers/concerns/signature_verification.rb @@ -31,7 +31,7 @@ module SignatureVerification return end - account = ResolveRemoteAccountService.new.call(signature_params['keyId'].gsub(/\Aacct:/, '')) + account = account_from_key_id(signature_params['keyId']) if account.nil? @signed_request_account = nil @@ -49,6 +49,10 @@ module SignatureVerification end end + def request_body + @request_body ||= request.raw_post + end + private def build_signed_string(signed_headers) @@ -57,6 +61,8 @@ module SignatureVerification signed_headers.split(' ').map do |signed_header| if signed_header == Request::REQUEST_TARGET "#{Request::REQUEST_TARGET}: #{request.method.downcase} #{request.path}" + elsif signed_header == 'digest' + "digest: #{body_digest}" else "#{signed_header}: #{request.headers[to_header_name(signed_header)]}" end @@ -73,6 +79,10 @@ module SignatureVerification (Time.now.utc - time_sent).abs <= 30 end + def body_digest + "SHA-256=#{Digest::SHA256.base64digest(request_body)}" + end + def to_header_name(name) name.split(/-/).map(&:capitalize).join('-') end @@ -81,7 +91,16 @@ module SignatureVerification signature_params['keyId'].blank? || signature_params['signature'].blank? || signature_params['algorithm'].blank? || - signature_params['algorithm'] != 'rsa-sha256' || - !signature_params['keyId'].start_with?('acct:') + signature_params['algorithm'] != 'rsa-sha256' + end + + def account_from_key_id(key_id) + if key_id.start_with?('acct:') + ResolveRemoteAccountService.new.call(key_id.gsub(/\Aacct:/, '')) + elsif !ActivityPub::TagManager.instance.local_uri?(key_id) + account = ActivityPub::TagManager.instance.uri_to_resource(key_id, Account) + account ||= ActivityPub::FetchRemoteKeyService.new.call(key_id) + account + end end end diff --git a/app/controllers/follower_accounts_controller.rb b/app/controllers/follower_accounts_controller.rb index 5edb4d67c..0e1949897 100644 --- a/app/controllers/follower_accounts_controller.rb +++ b/app/controllers/follower_accounts_controller.rb @@ -10,7 +10,7 @@ class FollowerAccountsController < ApplicationController format.html format.json do - render json: collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter + render json: collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json' end end end diff --git a/app/controllers/following_accounts_controller.rb b/app/controllers/following_accounts_controller.rb index 7cafe5fda..d4593093f 100644 --- a/app/controllers/following_accounts_controller.rb +++ b/app/controllers/following_accounts_controller.rb @@ -10,7 +10,7 @@ class FollowingAccountsController < ApplicationController format.html format.json do - render json: collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter + render json: collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json' end end end diff --git a/app/controllers/intents_controller.rb b/app/controllers/intents_controller.rb new file mode 100644 index 000000000..504befd1f --- /dev/null +++ b/app/controllers/intents_controller.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +class IntentsController < ApplicationController + def show + uri = Addressable::URI.parse(params[:uri]) + + if uri.scheme == 'web+mastodon' + case uri.host + when 'follow' + return redirect_to authorize_follow_path(acct: uri.query_values['uri'].gsub(/\Aacct:/, '')) + when 'share' + return redirect_to share_path(text: uri.query_values['text']) + end + end + + not_found + end +end diff --git a/app/controllers/remote_follow_controller.rb b/app/controllers/remote_follow_controller.rb index 2988231b1..48b026aa5 100644 --- a/app/controllers/remote_follow_controller.rb +++ b/app/controllers/remote_follow_controller.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class RemoteFollowController < ApplicationController - layout 'public' + layout 'modal' before_action :set_account before_action :gone, if: :suspended_account? diff --git a/app/controllers/settings/applications_controller.rb b/app/controllers/settings/applications_controller.rb new file mode 100644 index 000000000..8fc9a0fa9 --- /dev/null +++ b/app/controllers/settings/applications_controller.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +class Settings::ApplicationsController < ApplicationController + layout 'admin' + + before_action :authenticate_user! + before_action :set_application, only: [:show, :update, :destroy, :regenerate] + before_action :prepare_scopes, only: [:create, :update] + + def index + @applications = current_user.applications.page(params[:page]) + end + + def new + @application = Doorkeeper::Application.new( + redirect_uri: Doorkeeper.configuration.native_redirect_uri, + scopes: 'read write follow' + ) + end + + def show; end + + def create + @application = current_user.applications.build(application_params) + + if @application.save + redirect_to settings_applications_path, notice: I18n.t('applications.created') + else + render :new + end + end + + def update + if @application.update(application_params) + redirect_to settings_applications_path, notice: I18n.t('generic.changes_saved_msg') + else + render :show + end + end + + def destroy + @application.destroy + redirect_to settings_applications_path, notice: I18n.t('applications.destroyed') + end + + def regenerate + @access_token = current_user.token_for_app(@application) + @access_token.destroy + + redirect_to settings_application_path(@application), notice: I18n.t('applications.token_regenerated') + end + + private + + def set_application + @application = current_user.applications.find(params[:id]) + end + + def application_params + params.require(:doorkeeper_application).permit( + :name, + :redirect_uri, + :scopes, + :website + ) + end + + def prepare_scopes + scopes = params.fetch(:doorkeeper_application, {}).fetch(:scopes, nil) + params[:doorkeeper_application][:scopes] = scopes.join(' ') if scopes.is_a? Array + end +end diff --git a/app/controllers/settings/profiles_controller.rb b/app/controllers/settings/profiles_controller.rb index 0367e3593..28f78a4fb 100644 --- a/app/controllers/settings/profiles_controller.rb +++ b/app/controllers/settings/profiles_controller.rb @@ -14,7 +14,8 @@ class Settings::ProfilesController < ApplicationController def show; end def update - if @account.update(account_params) + if UpdateAccountService.new.call(@account, account_params) + ActivityPub::UpdateDistributionWorker.perform_async(@account.id) redirect_to settings_profile_path, notice: I18n.t('generic.changes_saved_msg') else render :show diff --git a/app/controllers/shares_controller.rb b/app/controllers/shares_controller.rb new file mode 100644 index 000000000..994742c3d --- /dev/null +++ b/app/controllers/shares_controller.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +class SharesController < ApplicationController + layout 'modal' + + before_action :authenticate_user! + before_action :set_body_classes + + def show + serializable_resource = ActiveModelSerializers::SerializableResource.new(InitialStatePresenter.new(initial_state_params), serializer: InitialStateSerializer) + @initial_state_json = serializable_resource.to_json + end + + private + + def initial_state_params + { + settings: Web::Setting.find_by(user: current_user)&.data || {}, + push_subscription: current_account.user.web_push_subscription(current_session), + current_account: current_account, + token: current_session.token, + admin: Account.find_local(Setting.site_contact_username), + text: params[:text], + } + end + + def set_body_classes + @body_classes = 'compose-standalone' + end +end diff --git a/app/controllers/statuses_controller.rb b/app/controllers/statuses_controller.rb index 8e0ce0ec3..65206ea96 100644 --- a/app/controllers/statuses_controller.rb +++ b/app/controllers/statuses_controller.rb @@ -9,6 +9,7 @@ class StatusesController < ApplicationController before_action :set_status before_action :set_link_headers before_action :check_account_suspension + before_action :redirect_to_original, only: [:show] def show respond_to do |format| @@ -20,13 +21,18 @@ class StatusesController < ApplicationController end format.json do - render json: @status, serializer: ActivityPub::NoteSerializer, adapter: ActivityPub::Adapter + render json: @status, serializer: ActivityPub::NoteSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json' end end end def activity - render json: @status, serializer: ActivityPub::ActivitySerializer, adapter: ActivityPub::Adapter + render json: @status, serializer: ActivityPub::ActivitySerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json' + end + + def embed + response.headers['X-Frame-Options'] = 'ALLOWALL' + render 'stream_entries/embed', layout: 'embedded' end private @@ -36,7 +42,12 @@ class StatusesController < ApplicationController end def set_link_headers - response.headers['Link'] = LinkHeader.new([[account_stream_entry_url(@account, @status.stream_entry, format: 'atom'), [%w(rel alternate), %w(type application/atom+xml)]]]) + response.headers['Link'] = LinkHeader.new( + [ + [account_stream_entry_url(@account, @status.stream_entry, format: 'atom'), [%w(rel alternate), %w(type application/atom+xml)]], + [ActivityPub::TagManager.instance.uri_for(@status), [%w(rel alternate), %w(type application/activity+json)]], + ] + ) end def set_status @@ -53,4 +64,8 @@ class StatusesController < ApplicationController def check_account_suspension gone if @account.suspended? end + + def redirect_to_original + redirect_to ::TagManager.instance.url_for(@status.reblog) if @status.reblog? + end end diff --git a/app/controllers/stream_entries_controller.rb b/app/controllers/stream_entries_controller.rb index 3eb91d830..cc579dbc8 100644 --- a/app/controllers/stream_entries_controller.rb +++ b/app/controllers/stream_entries_controller.rb @@ -25,10 +25,7 @@ class StreamEntriesController < ApplicationController end def embed - response.headers['X-Frame-Options'] = 'ALLOWALL' - return gone if @stream_entry.activity.nil? - - render layout: 'embedded' + redirect_to embed_short_account_status_url(@account, @stream_entry.activity), status: 301 end private @@ -38,7 +35,12 @@ class StreamEntriesController < ApplicationController end def set_link_headers - response.headers['Link'] = LinkHeader.new([[account_stream_entry_url(@account, @stream_entry, format: 'atom'), [%w(rel alternate), %w(type application/atom+xml)]]]) + response.headers['Link'] = LinkHeader.new( + [ + [account_stream_entry_url(@account, @stream_entry, format: 'atom'), [%w(rel alternate), %w(type application/atom+xml)]], + [ActivityPub::TagManager.instance.uri_for(@stream_entry.activity), [%w(rel alternate), %w(type application/activity+json)]], + ] + ) end def set_stream_entry diff --git a/app/controllers/tags_controller.rb b/app/controllers/tags_controller.rb index 2cd85e185..3001b2ee3 100644 --- a/app/controllers/tags_controller.rb +++ b/app/controllers/tags_controller.rb @@ -12,7 +12,7 @@ class TagsController < ApplicationController format.html format.json do - render json: collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter + render json: collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json' end end end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 9f50d8bdb..61d4442c1 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -5,6 +5,10 @@ module ApplicationHelper current_page?(path) ? 'active' : '' end + def active_link_to(label, path, options = {}) + link_to label, path, options.merge(class: active_nav_class(path)) + end + def show_landing_strip? !user_signed_in? && !single_user_mode? end diff --git a/app/helpers/jsonld_helper.rb b/app/helpers/jsonld_helper.rb new file mode 100644 index 000000000..d82a07332 --- /dev/null +++ b/app/helpers/jsonld_helper.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +module JsonLdHelper + def equals_or_includes?(haystack, needle) + haystack.is_a?(Array) ? haystack.include?(needle) : haystack == needle + end + + def first_of_value(value) + value.is_a?(Array) ? value.first : value + end + + def value_or_id(value) + value.is_a?(String) || value.nil? ? value : value['id'] + end + + def supported_context?(json) + !json.nil? && equals_or_includes?(json['@context'], ActivityPub::TagManager::CONTEXT) + end + + def canonicalize(json) + graph = RDF::Graph.new << JSON::LD::API.toRdf(json) + graph.dump(:normalize) + end + + def fetch_resource(uri) + response = build_request(uri).perform + return if response.code != 200 + body_to_json(response.to_s) + end + + def body_to_json(body) + body.is_a?(String) ? Oj.load(body, mode: :strict) : body + rescue Oj::ParseError + nil + end + + def merge_context(context, new_context) + if context.is_a?(Array) + context << new_context + else + [context, new_context] + end + end + + private + + def build_request(uri) + request = Request.new(:get, uri) + request.add_headers('Accept' => 'application/activity+json, application/ld+json') + request + end +end diff --git a/app/helpers/routing_helper.rb b/app/helpers/routing_helper.rb index 8126176ba..f4693358c 100644 --- a/app/helpers/routing_helper.rb +++ b/app/helpers/routing_helper.rb @@ -12,6 +12,14 @@ module RoutingHelper end def full_asset_url(source, options = {}) - Rails.configuration.x.use_s3 ? source : URI.join(root_url, ActionController::Base.helpers.asset_url(source, options)).to_s + source = ActionController::Base.helpers.asset_url(source, options) unless use_storage? + + URI.join(root_url, source).to_s + end + + private + + def use_storage? + Rails.configuration.x.use_s3 || Rails.configuration.x.use_swift end end diff --git a/app/helpers/settings_helper.rb b/app/helpers/settings_helper.rb index af950aa63..369a45680 100644 --- a/app/helpers/settings_helper.rb +++ b/app/helpers/settings_helper.rb @@ -30,6 +30,7 @@ module SettingsHelper th: 'ภาษาไทย', tr: 'Türkçe', uk: 'Українська', + zh: '中文', 'zh-CN': '简体中文', 'zh-HK': '繁體中文(香港)', 'zh-TW': '繁體中文(臺灣)', @@ -39,6 +40,10 @@ module SettingsHelper HUMAN_LOCALES[locale] end + def filterable_languages + I18n.available_locales.map { |locale| locale.to_s.split('-').first.to_sym }.uniq + end + def hash_to_object(hash) HashObject.new(hash) end diff --git a/app/helpers/stream_entries_helper.rb b/app/helpers/stream_entries_helper.rb index 4ef7cffb0..445114985 100644 --- a/app/helpers/stream_entries_helper.rb +++ b/app/helpers/stream_entries_helper.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module StreamEntriesHelper - EMBEDDED_CONTROLLER = 'stream_entries' + EMBEDDED_CONTROLLER = 'statuses' EMBEDDED_ACTION = 'embed' def display_name(account) diff --git a/app/javascript/mastodon/actions/compose.js b/app/javascript/mastodon/actions/compose.js index ebb75f36e..1f26907f2 100644 --- a/app/javascript/mastodon/actions/compose.js +++ b/app/javascript/mastodon/actions/compose.js @@ -1,6 +1,11 @@ import api from '../api'; -import { updateTimeline } from './timelines'; +import { + updateTimeline, + refreshHomeTimeline, + refreshCommunityTimeline, + refreshPublicTimeline, +} from './timelines'; export const COMPOSE_CHANGE = 'COMPOSE_CHANGE'; export const COMPOSE_SUBMIT_REQUEST = 'COMPOSE_SUBMIT_REQUEST'; @@ -95,16 +100,20 @@ export function submitCompose() { dispatch(submitComposeSuccess({ ...response.data })); // To make the app more responsive, immediately get the status into the columns - dispatch(updateTimeline('home', { ...response.data })); + + const insertOrRefresh = (timelineId, refreshAction) => { + if (getState().getIn(['timelines', timelineId, 'online'])) { + dispatch(updateTimeline(timelineId, { ...response.data })); + } else if (getState().getIn(['timelines', timelineId, 'loaded'])) { + dispatch(refreshAction()); + } + }; + + insertOrRefresh('home', refreshHomeTimeline); if (response.data.in_reply_to_id === null && response.data.visibility === 'public') { - if (getState().getIn(['timelines', 'community', 'loaded'])) { - dispatch(updateTimeline('community', { ...response.data })); - } - - if (getState().getIn(['timelines', 'public', 'loaded'])) { - dispatch(updateTimeline('public', { ...response.data })); - } + insertOrRefresh('community', refreshCommunityTimeline); + insertOrRefresh('public', refreshPublicTimeline); } }).catch(function (error) { dispatch(submitComposeFail(error)); diff --git a/app/javascript/mastodon/actions/interactions.js b/app/javascript/mastodon/actions/interactions.js index 36eec4934..7b5f4bd9c 100644 --- a/app/javascript/mastodon/actions/interactions.js +++ b/app/javascript/mastodon/actions/interactions.js @@ -24,6 +24,14 @@ export const FAVOURITES_FETCH_REQUEST = 'FAVOURITES_FETCH_REQUEST'; export const FAVOURITES_FETCH_SUCCESS = 'FAVOURITES_FETCH_SUCCESS'; export const FAVOURITES_FETCH_FAIL = 'FAVOURITES_FETCH_FAIL'; +export const PIN_REQUEST = 'PIN_REQUEST'; +export const PIN_SUCCESS = 'PIN_SUCCESS'; +export const PIN_FAIL = 'PIN_FAIL'; + +export const UNPIN_REQUEST = 'UNPIN_REQUEST'; +export const UNPIN_SUCCESS = 'UNPIN_SUCCESS'; +export const UNPIN_FAIL = 'UNPIN_FAIL'; + export function reblog(status) { return function (dispatch, getState) { dispatch(reblogRequest(status)); @@ -233,3 +241,73 @@ export function fetchFavouritesFail(id, error) { error, }; }; + +export function pin(status) { + return (dispatch, getState) => { + dispatch(pinRequest(status)); + + api(getState).post(`/api/v1/statuses/${status.get('id')}/pin`).then(response => { + dispatch(pinSuccess(status, response.data)); + }).catch(error => { + dispatch(pinFail(status, error)); + }); + }; +}; + +export function pinRequest(status) { + return { + type: PIN_REQUEST, + status, + }; +}; + +export function pinSuccess(status, response) { + return { + type: PIN_SUCCESS, + status, + response, + }; +}; + +export function pinFail(status, error) { + return { + type: PIN_FAIL, + status, + error, + }; +}; + +export function unpin (status) { + return (dispatch, getState) => { + dispatch(unpinRequest(status)); + + api(getState).post(`/api/v1/statuses/${status.get('id')}/unpin`).then(response => { + dispatch(unpinSuccess(status, response.data)); + }).catch(error => { + dispatch(unpinFail(status, error)); + }); + }; +}; + +export function unpinRequest(status) { + return { + type: UNPIN_REQUEST, + status, + }; +}; + +export function unpinSuccess(status, response) { + return { + type: UNPIN_SUCCESS, + status, + response, + }; +}; + +export function unpinFail(status, error) { + return { + type: UNPIN_FAIL, + status, + error, + }; +}; diff --git a/app/javascript/mastodon/actions/pin_statuses.js b/app/javascript/mastodon/actions/pin_statuses.js new file mode 100644 index 000000000..01bf8930b --- /dev/null +++ b/app/javascript/mastodon/actions/pin_statuses.js @@ -0,0 +1,39 @@ +import api from '../api'; + +export const PINNED_STATUSES_FETCH_REQUEST = 'PINNED_STATUSES_FETCH_REQUEST'; +export const PINNED_STATUSES_FETCH_SUCCESS = 'PINNED_STATUSES_FETCH_SUCCESS'; +export const PINNED_STATUSES_FETCH_FAIL = 'PINNED_STATUSES_FETCH_FAIL'; + +export function fetchPinnedStatuses() { + return (dispatch, getState) => { + dispatch(fetchPinnedStatusesRequest()); + + const accountId = getState().getIn(['meta', 'me']); + api(getState).get(`/api/v1/accounts/${accountId}/statuses`, { params: { pinned: true } }).then(response => { + dispatch(fetchPinnedStatusesSuccess(response.data, null)); + }).catch(error => { + dispatch(fetchPinnedStatusesFail(error)); + }); + }; +}; + +export function fetchPinnedStatusesRequest() { + return { + type: PINNED_STATUSES_FETCH_REQUEST, + }; +}; + +export function fetchPinnedStatusesSuccess(statuses, next) { + return { + type: PINNED_STATUSES_FETCH_SUCCESS, + statuses, + next, + }; +}; + +export function fetchPinnedStatusesFail(error) { + return { + type: PINNED_STATUSES_FETCH_FAIL, + error, + }; +}; diff --git a/app/javascript/mastodon/actions/statuses.js b/app/javascript/mastodon/actions/statuses.js index 2204e0b14..0b5e72c17 100644 --- a/app/javascript/mastodon/actions/statuses.js +++ b/app/javascript/mastodon/actions/statuses.js @@ -23,6 +23,9 @@ 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, @@ -215,3 +218,17 @@ 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/actions/streaming.js b/app/javascript/mastodon/actions/streaming.js new file mode 100644 index 000000000..7802694a3 --- /dev/null +++ b/app/javascript/mastodon/actions/streaming.js @@ -0,0 +1,94 @@ +import createStream from '../stream'; +import { + updateTimeline, + deleteFromTimelines, + refreshHomeTimeline, + connectTimeline, + disconnectTimeline, +} from './timelines'; +import { updateNotifications, refreshNotifications } from './notifications'; +import { getLocale } from '../locales'; + +const { messages } = getLocale(); + +export function connectTimelineStream (timelineId, path, pollingRefresh = null) { + return (dispatch, getState) => { + const streamingAPIBaseURL = getState().getIn(['meta', 'streaming_api_base_url']); + const accessToken = getState().getIn(['meta', 'access_token']); + const locale = getState().getIn(['meta', 'locale']); + let polling = null; + + const setupPolling = () => { + polling = setInterval(() => { + pollingRefresh(dispatch); + }, 20000); + }; + + const clearPolling = () => { + if (polling) { + clearInterval(polling); + polling = null; + } + }; + + const subscription = createStream(streamingAPIBaseURL, accessToken, path, { + + connected () { + if (pollingRefresh) { + clearPolling(); + } + dispatch(connectTimeline(timelineId)); + }, + + disconnected () { + if (pollingRefresh) { + setupPolling(); + } + dispatch(disconnectTimeline(timelineId)); + }, + + received (data) { + switch(data.event) { + case 'update': + dispatch(updateTimeline(timelineId, JSON.parse(data.payload))); + break; + case 'delete': + dispatch(deleteFromTimelines(data.payload)); + break; + case 'notification': + dispatch(updateNotifications(JSON.parse(data.payload), messages, locale)); + break; + } + }, + + reconnected () { + if (pollingRefresh) { + clearPolling(); + pollingRefresh(dispatch); + } + dispatch(connectTimeline(timelineId)); + }, + + }); + + const disconnect = () => { + if (subscription) { + subscription.close(); + } + clearPolling(); + }; + + return disconnect; + }; +} + +function refreshHomeTimelineAndNotification (dispatch) { + dispatch(refreshHomeTimeline()); + dispatch(refreshNotifications()); +} + +export const connectUserStream = () => connectTimelineStream('home', 'user', refreshHomeTimelineAndNotification); +export const connectCommunityStream = () => connectTimelineStream('community', 'public:local'); +export const connectMediaStream = () => connectTimelineStream('community', 'public:local'); +export const connectPublicStream = () => connectTimelineStream('public', 'public'); +export const connectHashtagStream = (tag) => connectTimelineStream(`hashtag:${tag}`, `hashtag&tag=${tag}`); diff --git a/app/javascript/mastodon/components/account.js b/app/javascript/mastodon/components/account.js index b6ca0661f..6456c12ba 100644 --- a/app/javascript/mastodon/components/account.js +++ b/app/javascript/mastodon/components/account.js @@ -26,6 +26,7 @@ export default class Account extends ImmutablePureComponent { onBlock: PropTypes.func.isRequired, onMute: PropTypes.func.isRequired, intl: PropTypes.object.isRequired, + hidden: PropTypes.bool, }; handleFollow = () => { @@ -41,12 +42,21 @@ export default class Account extends ImmutablePureComponent { } render () { - const { account, me, intl } = this.props; + const { account, me, intl, hidden } = this.props; if (!account) { return
; } + if (hidden) { + return ( +
+ {account.get('display_name')} + {account.get('username')} +
+ ); + } + let buttons; if (account.get('id') !== me && account.get('relationship', null) !== null) { @@ -70,7 +80,7 @@ export default class Account extends ImmutablePureComponent {
-
+
diff --git a/app/javascript/mastodon/components/avatar.js b/app/javascript/mastodon/components/avatar.js index 4f8170657..f7c484ee3 100644 --- a/app/javascript/mastodon/components/avatar.js +++ b/app/javascript/mastodon/components/avatar.js @@ -1,11 +1,11 @@ import React from 'react'; import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; export default class Avatar extends React.PureComponent { static propTypes = { - src: PropTypes.string.isRequired, - staticSrc: PropTypes.string, + account: ImmutablePropTypes.map.isRequired, size: PropTypes.number.isRequired, style: PropTypes.object, animate: PropTypes.bool, @@ -33,9 +33,12 @@ export default class Avatar extends React.PureComponent { } render () { - const { src, size, staticSrc, animate, inline } = this.props; + const { account, size, animate, inline } = this.props; const { hovering } = this.state; + const src = account.get('avatar'); + const staticSrc = account.get('avatar_static'); + let className = 'account__avatar'; if (inline) { diff --git a/app/javascript/mastodon/components/avatar_overlay.js b/app/javascript/mastodon/components/avatar_overlay.js index de43e0ef5..f5d67b34e 100644 --- a/app/javascript/mastodon/components/avatar_overlay.js +++ b/app/javascript/mastodon/components/avatar_overlay.js @@ -1,22 +1,22 @@ import React from 'react'; -import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; export default class AvatarOverlay extends React.PureComponent { static propTypes = { - staticSrc: PropTypes.string.isRequired, - overlaySrc: PropTypes.string.isRequired, + account: ImmutablePropTypes.map.isRequired, + friend: ImmutablePropTypes.map.isRequired, }; render() { - const { staticSrc, overlaySrc } = this.props; + const { account, friend } = this.props; const baseStyle = { - backgroundImage: `url(${staticSrc})`, + backgroundImage: `url(${account.get('avatar_static')})`, }; const overlayStyle = { - backgroundImage: `url(${overlaySrc})`, + backgroundImage: `url(${friend.get('avatar_static')})`, }; return ( diff --git a/app/javascript/mastodon/components/column.js b/app/javascript/mastodon/components/column.js index 93f1d6260..e81236d26 100644 --- a/app/javascript/mastodon/components/column.js +++ b/app/javascript/mastodon/components/column.js @@ -1,7 +1,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import detectPassiveEvents from 'detect-passive-events'; -import scrollTop from '../scroll'; +import { scrollTop } from '../scroll'; export default class Column extends React.PureComponent { @@ -32,7 +32,7 @@ export default class Column extends React.PureComponent { } componentDidMount () { - this.node.addEventListener('wheel', this.handleWheel, detectPassiveEvents ? { passive: true } : false); + this.node.addEventListener('wheel', this.handleWheel, detectPassiveEvents.hasSupport ? { passive: true } : false); } componentWillUnmount () { diff --git a/app/javascript/mastodon/components/display_name.js b/app/javascript/mastodon/components/display_name.js index dc3665a2b..2cf84f8f4 100644 --- a/app/javascript/mastodon/components/display_name.js +++ b/app/javascript/mastodon/components/display_name.js @@ -1,7 +1,5 @@ import React from 'react'; import ImmutablePropTypes from 'react-immutable-proptypes'; -import escapeTextContentForBrowser from 'escape-html'; -import emojify from '../emoji'; export default class DisplayName extends React.PureComponent { @@ -10,12 +8,11 @@ export default class DisplayName extends React.PureComponent { }; render () { - const displayName = this.props.account.get('display_name').length === 0 ? this.props.account.get('username') : this.props.account.get('display_name'); - const displayNameHTML = { __html: emojify(escapeTextContentForBrowser(displayName)) }; + const displayNameHtml = { __html: this.props.account.get('display_name_html') }; return ( - @{this.props.account.get('acct')} + @{this.props.account.get('acct')} ); } diff --git a/app/javascript/mastodon/components/intersection_observer_article.js b/app/javascript/mastodon/components/intersection_observer_article.js new file mode 100644 index 000000000..347767818 --- /dev/null +++ b/app/javascript/mastodon/components/intersection_observer_article.js @@ -0,0 +1,122 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import scheduleIdleTask from '../features/ui/util/schedule_idle_task'; +import getRectFromEntry from '../features/ui/util/get_rect_from_entry'; + +export default class IntersectionObserverArticle extends ImmutablePureComponent { + + static propTypes = { + intersectionObserverWrapper: PropTypes.object, + id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + index: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + listLength: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + children: PropTypes.node, + }; + + state = { + isHidden: false, // set to true in requestIdleCallback to trigger un-render + } + + shouldComponentUpdate (nextProps, nextState) { + if (!nextState.isIntersecting && nextState.isHidden) { + // It's only if we're not intersecting (i.e. offscreen) and isHidden is true + // that either "isIntersecting" or "isHidden" matter, and then they're + // the only things that matter (and updated ARIA attributes). + return this.state.isIntersecting || !this.state.isHidden || nextProps.listLength !== this.props.listLength; + } else if (nextState.isIntersecting && !this.state.isIntersecting) { + // If we're going from a non-intersecting state to an intersecting state, + // (i.e. offscreen to onscreen), then we definitely need to re-render + return true; + } + // Otherwise, diff based on "updateOnProps" and "updateOnStates" + return super.shouldComponentUpdate(nextProps, nextState); + } + + 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, + this.node, + this.handleIntersection + ); + + this.componentMounted = true; + } + + componentWillUnmount () { + if (this.props.intersectionObserverWrapper) { + this.props.intersectionObserverWrapper.unobserve(this.props.id, this.node); + } + + this.componentMounted = false; + } + + handleIntersection = (entry) => { + 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); + } + } + + this.setState((prevState) => { + if (prevState.isIntersecting && !entry.isIntersecting) { + scheduleIdleTask(this.hideIfNotIntersecting); + } + return { + isIntersecting: entry.isIntersecting, + isHidden: false, + }; + }); + } + + hideIfNotIntersecting = () => { + if (!this.componentMounted) { + return; + } + + // When the browser gets a chance, test if we're still not intersecting, + // and if so, set our isHidden to true to trigger an unrender. The point of + // this is to save DOM nodes and avoid using up too much memory. + // See: https://github.com/tootsuite/mastodon/issues/2900 + this.setState((prevState) => ({ isHidden: !prevState.isIntersecting })); + } + + handleRef = (node) => { + this.node = node; + } + + render () { + const { children, id, index, listLength } = this.props; + const { isIntersecting, isHidden } = this.state; + + if (!isIntersecting && isHidden) { + return ( +
+ {children && React.cloneElement(children, { hidden: true })} +
+ ); + } + + return ( +
+ {children && React.cloneElement(children, { hidden: false })} +
+ ); + } + +} diff --git a/app/javascript/mastodon/components/scrollable_list.js b/app/javascript/mastodon/components/scrollable_list.js new file mode 100644 index 000000000..723dd322b --- /dev/null +++ b/app/javascript/mastodon/components/scrollable_list.js @@ -0,0 +1,204 @@ +import React, { PureComponent } from 'react'; +import { ScrollContainer } from 'react-router-scroll'; +import PropTypes from 'prop-types'; +import IntersectionObserverArticle from './intersection_observer_article'; +import LoadMore from './load_more'; +import IntersectionObserverWrapper from '../features/ui/util/intersection_observer_wrapper'; +import { throttle } from 'lodash'; +import { List as ImmutableList } from 'immutable'; + +export default class ScrollableList extends PureComponent { + + static propTypes = { + scrollKey: PropTypes.string.isRequired, + onScrollToBottom: PropTypes.func, + onScrollToTop: PropTypes.func, + onScroll: PropTypes.func, + trackScroll: PropTypes.bool, + shouldUpdateScroll: PropTypes.func, + isLoading: PropTypes.bool, + hasMore: PropTypes.bool, + prepend: PropTypes.node, + emptyMessage: PropTypes.node, + children: PropTypes.node, + }; + + static defaultProps = { + trackScroll: true, + }; + + state = { + lastMouseMove: null, + }; + + intersectionObserverWrapper = new IntersectionObserverWrapper(); + + handleScroll = throttle(() => { + if (this.node) { + const { scrollTop, scrollHeight, clientHeight } = this.node; + const offset = scrollHeight - scrollTop - clientHeight; + this._oldScrollPosition = scrollHeight - scrollTop; + + if (400 > offset && this.props.onScrollToBottom && !this.props.isLoading) { + this.props.onScrollToBottom(); + } else if (scrollTop < 100 && this.props.onScrollToTop) { + this.props.onScrollToTop(); + } else if (this.props.onScroll) { + this.props.onScroll(); + } + } + }, 150, { + trailing: true, + }); + + handleMouseMove = throttle(() => { + this._lastMouseMove = new Date(); + }, 300); + + handleMouseLeave = () => { + this._lastMouseMove = null; + } + + componentDidMount () { + this.attachScrollListener(); + this.attachIntersectionObserver(); + + // Handle initial scroll posiiton + this.handleScroll(); + } + + componentDidUpdate (prevProps) { + const someItemInserted = React.Children.count(prevProps.children) > 0 && + React.Children.count(prevProps.children) < React.Children.count(this.props.children) && + this.getFirstChildKey(prevProps) !== this.getFirstChildKey(this.props); + + // Reset the scroll position when a new child comes in in order not to + // jerk the scrollbar around if you're already scrolled down the page. + if (someItemInserted && this._oldScrollPosition && this.node.scrollTop > 0) { + const newScrollTop = this.node.scrollHeight - this._oldScrollPosition; + + if (this.node.scrollTop !== newScrollTop) { + this.node.scrollTop = newScrollTop; + } + } else { + this._oldScrollPosition = this.node.scrollHeight - this.node.scrollTop; + } + } + + componentWillUnmount () { + this.detachScrollListener(); + this.detachIntersectionObserver(); + } + + attachIntersectionObserver () { + this.intersectionObserverWrapper.connect({ + root: this.node, + rootMargin: '300% 0px', + }); + } + + detachIntersectionObserver () { + this.intersectionObserverWrapper.disconnect(); + } + + attachScrollListener () { + this.node.addEventListener('scroll', this.handleScroll); + } + + detachScrollListener () { + this.node.removeEventListener('scroll', this.handleScroll); + } + + getFirstChildKey (props) { + const { children } = props; + let firstChild = children; + if (children instanceof ImmutableList) { + firstChild = children.get(0); + } else if (Array.isArray(children)) { + firstChild = children[0]; + } + return firstChild && firstChild.key; + } + + setRef = (c) => { + this.node = c; + } + + handleLoadMore = (e) => { + e.preventDefault(); + this.props.onScrollToBottom(); + } + + _recentlyMoved () { + return this._lastMouseMove !== null && ((new Date()) - this._lastMouseMove < 600); + } + + handleKeyDown = (e) => { + if (['PageDown', 'PageUp'].includes(e.key) || (e.ctrlKey && ['End', 'Home'].includes(e.key))) { + const article = (() => { + switch (e.key) { + case 'PageDown': + return e.target.nodeName === 'ARTICLE' && e.target.nextElementSibling; + case 'PageUp': + return e.target.nodeName === 'ARTICLE' && e.target.previousElementSibling; + case 'End': + return this.node.querySelector('[role="feed"] > article:last-of-type'); + case 'Home': + return this.node.querySelector('[role="feed"] > article:first-of-type'); + default: + return null; + } + })(); + + + if (article) { + e.preventDefault(); + article.focus(); + article.scrollIntoView(); + } + } + } + + render () { + const { children, scrollKey, trackScroll, shouldUpdateScroll, isLoading, hasMore, prepend, emptyMessage } = this.props; + const childrenCount = React.Children.count(children); + + const loadMore = 0 && hasMore} onClick={this.handleLoadMore} />; + let scrollableArea = null; + + if (isLoading || childrenCount > 0 || !emptyMessage) { + scrollableArea = ( +
+
+ {prepend} + + {React.Children.map(this.props.children, (child, index) => ( + + {child} + + ))} + + {loadMore} +
+
+ ); + } else { + scrollableArea = ( +
+ {emptyMessage} +
+ ); + } + + if (trackScroll) { + return ( + + {scrollableArea} + + ); + } else { + return scrollableArea; + } + } + +} diff --git a/app/javascript/mastodon/components/status.js b/app/javascript/mastodon/components/status.js index ceb512d96..30a0c10cb 100644 --- a/app/javascript/mastodon/components/status.js +++ b/app/javascript/mastodon/components/status.js @@ -8,16 +8,12 @@ import DisplayName from './display_name'; import StatusContent from './status_content'; import StatusActionBar from './status_action_bar'; import { FormattedMessage } from 'react-intl'; -import emojify from '../emoji'; -import escapeTextContentForBrowser from 'escape-html'; import ImmutablePureComponent from 'react-immutable-pure-component'; -import scheduleIdleTask from '../features/ui/util/schedule_idle_task'; import { MediaGallery, VideoPlayer } 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 import Bundle from '../features/ui/components/bundle'; -import getRectFromEntry from '../features/ui/util/get_rect_from_entry'; export default class Status extends ImmutablePureComponent { @@ -28,27 +24,25 @@ export default class Status extends ImmutablePureComponent { static propTypes = { status: ImmutablePropTypes.map, account: ImmutablePropTypes.map, - wrapped: PropTypes.bool, onReply: PropTypes.func, onFavourite: PropTypes.func, onReblog: PropTypes.func, onDelete: PropTypes.func, + onPin: PropTypes.func, onOpenMedia: PropTypes.func, onOpenVideo: PropTypes.func, onBlock: PropTypes.func, + onEmbed: PropTypes.func, + onHeightChange: PropTypes.func, me: PropTypes.number, boostModal: PropTypes.bool, autoPlayGif: PropTypes.bool, muted: PropTypes.bool, - intersectionObserverWrapper: PropTypes.object, - index: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), - listLength: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + hidden: PropTypes.bool, }; state = { isExpanded: false, - isIntersecting: true, // assume intersecting until told otherwise - isHidden: false, // set to true in requestIdleCallback to trigger un-render } // Avoid checking props that are functions (and whose equality will always @@ -56,87 +50,15 @@ export default class Status extends ImmutablePureComponent { updateOnProps = [ 'status', 'account', - 'wrapped', 'me', 'boostModal', 'autoPlayGif', 'muted', - 'listLength', + 'hidden', ] updateOnStates = ['isExpanded'] - shouldComponentUpdate (nextProps, nextState) { - if (!nextState.isIntersecting && nextState.isHidden) { - // It's only if we're not intersecting (i.e. offscreen) and isHidden is true - // that either "isIntersecting" or "isHidden" matter, and then they're - // the only things that matter (and updated ARIA attributes). - return this.state.isIntersecting || !this.state.isHidden || nextProps.listLength !== this.props.listLength; - } else if (nextState.isIntersecting && !this.state.isIntersecting) { - // If we're going from a non-intersecting state to an intersecting state, - // (i.e. offscreen to onscreen), then we definitely need to re-render - return true; - } - // Otherwise, diff based on "updateOnProps" and "updateOnStates" - return super.shouldComponentUpdate(nextProps, nextState); - } - - 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, - this.node, - this.handleIntersection - ); - - this.componentMounted = true; - } - - componentWillUnmount () { - if (this.props.intersectionObserverWrapper) { - this.props.intersectionObserverWrapper.unobserve(this.props.id, this.node); - } - - this.componentMounted = false; - } - - handleIntersection = (entry) => { - if (this.node && this.node.children.length !== 0) { - // save the height of the fully-rendered element - this.height = getRectFromEntry(entry).height; - } - - this.setState((prevState) => { - if (prevState.isIntersecting && !entry.isIntersecting) { - scheduleIdleTask(this.hideIfNotIntersecting); - } - return { - isIntersecting: entry.isIntersecting, - isHidden: false, - }; - }); - } - - hideIfNotIntersecting = () => { - if (!this.componentMounted) { - return; - } - - // When the browser gets a chance, test if we're still not intersecting, - // and if so, set our isHidden to true to trigger an unrender. The point of - // this is to save DOM nodes and avoid using up too much memory. - // See: https://github.com/tootsuite/mastodon/issues/2900 - this.setState((prevState) => ({ isHidden: !prevState.isIntersecting })); - } - - handleRef = (node) => { - this.node = node; - } - handleClick = () => { if (!this.context.router) { return; @@ -170,42 +92,34 @@ export default class Status extends ImmutablePureComponent { let media = null; let statusAvatar; - // Exclude intersectionObserverWrapper from `other` variable - // because intersection is managed in here. - const { status, account, intersectionObserverWrapper, index, listLength, wrapped, ...other } = this.props; - const { isExpanded, isIntersecting, isHidden } = this.state; + const { status, account, hidden, ...other } = this.props; + const { isExpanded } = this.state; if (status === null) { return null; } - if (!isIntersecting && isHidden) { + if (hidden) { return ( -
+
{status.getIn(['account', 'display_name']) || status.getIn(['account', 'username'])} {status.get('content')} -
+
); } if (status.get('reblog', null) !== null && typeof status.get('reblog') === 'object') { - let displayName = status.getIn(['account', 'display_name']); - - if (displayName.length === 0) { - displayName = status.getIn(['account', 'username']); - } - - const displayNameHTML = { __html: emojify(escapeTextContentForBrowser(displayName)) }; + const display_name_html = { __html: status.getIn(['account', 'display_name_html']) }; return ( -
+
- }} /> + }} />
- -
+ +
); } @@ -228,13 +142,13 @@ export default class Status extends ImmutablePureComponent { } if (account === undefined || account === null) { - statusAvatar = ; + statusAvatar = ; }else{ - statusAvatar = ; + statusAvatar = ; } return ( -
+
@@ -252,7 +166,7 @@ export default class Status extends ImmutablePureComponent { {media} -
+
); } diff --git a/app/javascript/mastodon/components/status_action_bar.js b/app/javascript/mastodon/components/status_action_bar.js index 0d8c9add4..692b1ef2b 100644 --- a/app/javascript/mastodon/components/status_action_bar.js +++ b/app/javascript/mastodon/components/status_action_bar.js @@ -21,6 +21,9 @@ const messages = defineMessages({ report: { id: 'status.report', defaultMessage: 'Report @{name}' }, muteConversation: { id: 'status.mute_conversation', defaultMessage: 'Mute conversation' }, unmuteConversation: { id: 'status.unmute_conversation', defaultMessage: 'Unmute conversation' }, + pin: { id: 'status.pin', defaultMessage: 'Pin on profile' }, + unpin: { id: 'status.unpin', defaultMessage: 'Unpin from profile' }, + embed: { id: 'status.embed', defaultMessage: 'Embed' }, }); @injectIntl @@ -40,7 +43,9 @@ export default class StatusActionBar extends ImmutablePureComponent { onMute: PropTypes.func, onBlock: PropTypes.func, onReport: PropTypes.func, + onEmbed: PropTypes.func, onMuteConversation: PropTypes.func, + onPin: PropTypes.func, me: PropTypes.number, withDismiss: PropTypes.bool, intl: PropTypes.object.isRequired, @@ -77,6 +82,10 @@ export default class StatusActionBar extends ImmutablePureComponent { this.props.onDelete(this.props.status); } + handlePinClick = () => { + this.props.onPin(this.props.status); + } + handleMentionClick = () => { this.props.onMention(this.props.status.get('account'), this.context.router.history); } @@ -93,6 +102,10 @@ export default class StatusActionBar extends ImmutablePureComponent { this.context.router.history.push(`/statuses/${this.props.status.get('id')}`); } + handleEmbed = () => { + this.props.onEmbed(this.props.status); + } + handleReport = () => { this.props.onReport(this.props.status); } @@ -103,9 +116,10 @@ export default class StatusActionBar extends ImmutablePureComponent { render () { const { status, me, intl, withDismiss } = this.props; - const reblogDisabled = status.get('visibility') === 'private' || status.get('visibility') === 'direct'; + const mutingConversation = status.get('muted'); - const anonymousAccess = !me; + const anonymousAccess = !me; + const publicStatus = ['public', 'unlisted'].includes(status.get('visibility')); let menu = []; let reblogIcon = 'retweet'; @@ -113,14 +127,23 @@ export default class StatusActionBar extends ImmutablePureComponent { let replyTitle; menu.push({ text: intl.formatMessage(messages.open), action: this.handleOpen }); + + if (publicStatus) { + menu.push({ text: intl.formatMessage(messages.embed), action: this.handleEmbed }); + } + menu.push(null); - if (withDismiss) { + if (status.getIn(['account', 'id']) === me || withDismiss) { menu.push({ text: intl.formatMessage(mutingConversation ? messages.unmuteConversation : messages.muteConversation), action: this.handleConversationMuteClick }); menu.push(null); } if (status.getIn(['account', 'id']) === me) { + if (publicStatus) { + menu.push({ text: intl.formatMessage(status.get('pinned') ? messages.unpin : messages.pin), action: this.handlePinClick }); + } + menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick }); } else { menu.push({ text: intl.formatMessage(messages.mention, { name: status.getIn(['account', 'username']) }), action: this.handleMentionClick }); @@ -151,7 +174,7 @@ export default class StatusActionBar extends ImmutablePureComponent { return (
- + {shareButton} diff --git a/app/javascript/mastodon/components/status_content.js b/app/javascript/mastodon/components/status_content.js index fdf7aa531..2069f971c 100644 --- a/app/javascript/mastodon/components/status_content.js +++ b/app/javascript/mastodon/components/status_content.js @@ -1,8 +1,6 @@ import React from 'react'; import ImmutablePropTypes from 'react-immutable-proptypes'; -import escapeTextContentForBrowser from 'escape-html'; import PropTypes from 'prop-types'; -import emojify from '../emoji'; import { isRtl } from '../rtl'; import { FormattedMessage } from 'react-intl'; import Permalink from './permalink'; @@ -119,8 +117,8 @@ export default class StatusContent extends React.PureComponent { const hidden = this.props.onExpandedToggle ? !this.props.expanded : this.state.hidden; - const content = { __html: emojify(status.get('content')) }; - const spoilerContent = { __html: emojify(escapeTextContentForBrowser(status.get('spoiler_text', ''))) }; + const content = { __html: status.get('contentHtml') }; + const spoilerContent = { __html: status.get('spoilerHtml') }; const directionStyle = { direction: 'ltr' }; const classNames = classnames('status__content', { 'status__content--with-action': this.props.onClick && this.context.router, diff --git a/app/javascript/mastodon/components/status_list.js b/app/javascript/mastodon/components/status_list.js index e945e878c..cbae28afe 100644 --- a/app/javascript/mastodon/components/status_list.js +++ b/app/javascript/mastodon/components/status_list.js @@ -1,12 +1,9 @@ import React from 'react'; import ImmutablePropTypes from 'react-immutable-proptypes'; -import { ScrollContainer } from 'react-router-scroll'; import PropTypes from 'prop-types'; import StatusContainer from '../containers/status_container'; -import LoadMore from './load_more'; import ImmutablePureComponent from 'react-immutable-pure-component'; -import IntersectionObserverWrapper from '../features/ui/util/intersection_observer_wrapper'; -import { throttle } from 'lodash'; +import ScrollableList from './scrollable_list'; export default class StatusList extends ImmutablePureComponent { @@ -28,145 +25,21 @@ export default class StatusList extends ImmutablePureComponent { trackScroll: true, }; - intersectionObserverWrapper = new IntersectionObserverWrapper(); - - handleScroll = throttle(() => { - if (this.node) { - const { scrollTop, scrollHeight, clientHeight } = this.node; - const offset = scrollHeight - scrollTop - clientHeight; - this._oldScrollPosition = scrollHeight - scrollTop; - - if (400 > offset && this.props.onScrollToBottom && !this.props.isLoading) { - this.props.onScrollToBottom(); - } else if (scrollTop < 100 && this.props.onScrollToTop) { - this.props.onScrollToTop(); - } else if (this.props.onScroll) { - this.props.onScroll(); - } - } - }, 150, { - trailing: true, - }); - - componentDidMount () { - this.attachScrollListener(); - this.attachIntersectionObserver(); - - // Handle initial scroll posiiton - this.handleScroll(); - } - - componentDidUpdate (prevProps) { - // Reset the scroll position when a new toot comes in in order not to - // jerk the scrollbar around if you're already scrolled down the page. - if (prevProps.statusIds.size < this.props.statusIds.size && this._oldScrollPosition && this.node.scrollTop > 0) { - if (prevProps.statusIds.first() !== this.props.statusIds.first()) { - let newScrollTop = this.node.scrollHeight - this._oldScrollPosition; - if (this.node.scrollTop !== newScrollTop) { - this.node.scrollTop = newScrollTop; - } - } else { - this._oldScrollPosition = this.node.scrollHeight - this.node.scrollTop; - } - } - } - - componentWillUnmount () { - this.detachScrollListener(); - this.detachIntersectionObserver(); - } - - attachIntersectionObserver () { - this.intersectionObserverWrapper.connect({ - root: this.node, - rootMargin: '300% 0px', - }); - } - - detachIntersectionObserver () { - this.intersectionObserverWrapper.disconnect(); - } - - attachScrollListener () { - this.node.addEventListener('scroll', this.handleScroll); - } - - detachScrollListener () { - this.node.removeEventListener('scroll', this.handleScroll); - } - - setRef = (c) => { - this.node = c; - } - - handleLoadMore = (e) => { - e.preventDefault(); - this.props.onScrollToBottom(); - } - - handleKeyDown = (e) => { - if (['PageDown', 'PageUp', 'End', 'Home'].includes(e.key)) { - const article = (() => { - switch (e.key) { - case 'PageDown': - return e.target.nodeName === 'ARTICLE' && e.target.nextElementSibling; - case 'PageUp': - return e.target.nodeName === 'ARTICLE' && e.target.previousElementSibling; - case 'End': - return this.node.querySelector('[role="feed"] > article:last-of-type'); - case 'Home': - return this.node.querySelector('[role="feed"] > article:first-of-type'); - default: - return null; - } - })(); - - - if (article) { - e.preventDefault(); - article.focus(); - article.scrollIntoView(); - } - } - } - render () { - const { statusIds, scrollKey, trackScroll, shouldUpdateScroll, isLoading, hasMore, prepend, emptyMessage } = this.props; + const { statusIds, ...other } = this.props; + const { isLoading } = other; - const loadMore = 0 && hasMore} onClick={this.handleLoadMore} />; - let scrollableArea = null; + const scrollableContent = (isLoading || statusIds.size > 0) ? ( + statusIds.map((statusId) => ( + + )) + ) : null; - if (isLoading || statusIds.size > 0 || !emptyMessage) { - scrollableArea = ( -
-
- {prepend} - - {statusIds.map((statusId, index) => { - return ; - })} - - {loadMore} -
-
- ); - } else { - scrollableArea = ( -
- {emptyMessage} -
- ); - } - - if (trackScroll) { - return ( - - {scrollableArea} - - ); - } else { - return scrollableArea; - } + return ( + + {scrollableContent} + + ); } } diff --git a/app/javascript/mastodon/components/video_player.js b/app/javascript/mastodon/components/video_player.js index 999cf42d9..2a2d91c33 100644 --- a/app/javascript/mastodon/components/video_player.js +++ b/app/javascript/mastodon/components/video_player.js @@ -146,29 +146,29 @@ export default class VideoPlayer extends React.PureComponent { if (!this.state.visible) { if (sensitive) { return ( -
+
+ ); } else { return ( -
+
+ ); } } if (this.state.preview && !autoplay) { return ( -
+
+ ); } diff --git a/app/javascript/mastodon/containers/account_container.js b/app/javascript/mastodon/containers/account_container.js index ca1efd0e5..7c77cb764 100644 --- a/app/javascript/mastodon/containers/account_container.js +++ b/app/javascript/mastodon/containers/account_container.js @@ -32,7 +32,7 @@ const makeMapStateToProps = () => { const mapDispatchToProps = (dispatch, { intl }) => ({ onFollow (account) { - if (account.getIn(['relationship', 'following'])) { + if (account.getIn(['relationship', 'following']) || account.getIn(['relationship', 'requested'])) { if (this.unfollowModal) { dispatch(openModal('CONFIRM', { message: @{account.get('acct')}
}} />, diff --git a/app/javascript/mastodon/containers/compose_container.js b/app/javascript/mastodon/containers/compose_container.js new file mode 100644 index 000000000..db452d03a --- /dev/null +++ b/app/javascript/mastodon/containers/compose_container.js @@ -0,0 +1,39 @@ +import React from 'react'; +import { Provider } from 'react-redux'; +import PropTypes from 'prop-types'; +import configureStore from '../store/configureStore'; +import { hydrateStore } from '../actions/store'; +import { IntlProvider, addLocaleData } from 'react-intl'; +import { getLocale } from '../locales'; +import Compose from '../features/standalone/compose'; + +const { localeData, messages } = getLocale(); +addLocaleData(localeData); + +const store = configureStore(); +const initialStateContainer = document.getElementById('initial-state'); + +if (initialStateContainer !== null) { + const initialState = JSON.parse(initialStateContainer.textContent); + store.dispatch(hydrateStore(initialState)); +} + +export default class TimelineContainer extends React.PureComponent { + + static propTypes = { + locale: PropTypes.string.isRequired, + }; + + render () { + const { locale } = this.props; + + return ( + + + + + + ); + } + +} diff --git a/app/javascript/mastodon/containers/mastodon.js b/app/javascript/mastodon/containers/mastodon.js index 87ab6023c..884fc161a 100644 --- a/app/javascript/mastodon/containers/mastodon.js +++ b/app/javascript/mastodon/containers/mastodon.js @@ -2,21 +2,13 @@ import React from 'react'; import { Provider } from 'react-redux'; import PropTypes from 'prop-types'; import configureStore from '../store/configureStore'; -import { - updateTimeline, - deleteFromTimelines, - refreshHomeTimeline, - connectTimeline, - disconnectTimeline, -} from '../actions/timelines'; import { showOnboardingOnce } from '../actions/onboarding'; -import { updateNotifications, refreshNotifications } from '../actions/notifications'; import BrowserRouter from 'react-router-dom/BrowserRouter'; import Route from 'react-router-dom/Route'; import ScrollContext from 'react-router-scroll/lib/ScrollBehaviorContext'; import UI from '../features/ui'; import { hydrateStore } from '../actions/store'; -import createStream from '../stream'; +import { connectUserStream } from '../actions/streaming'; import { IntlProvider, addLocaleData } from 'react-intl'; import { getLocale } from '../locales'; const { localeData, messages } = getLocale(); @@ -33,74 +25,28 @@ export default class Mastodon extends React.PureComponent { }; componentDidMount() { - const { locale } = this.props; - const streamingAPIBaseURL = store.getState().getIn(['meta', 'streaming_api_base_url']); - const accessToken = store.getState().getIn(['meta', 'access_token']); - - const setupPolling = () => { - this.polling = setInterval(() => { - store.dispatch(refreshHomeTimeline()); - store.dispatch(refreshNotifications()); - }, 20000); - }; - - const clearPolling = () => { - clearInterval(this.polling); - this.polling = undefined; - }; - - this.subscription = createStream(streamingAPIBaseURL, accessToken, 'user', { - - connected () { - clearPolling(); - store.dispatch(connectTimeline('home')); - }, - - disconnected () { - setupPolling(); - store.dispatch(disconnectTimeline('home')); - }, - - received (data) { - switch(data.event) { - case 'update': - store.dispatch(updateTimeline('home', JSON.parse(data.payload))); - break; - case 'delete': - store.dispatch(deleteFromTimelines(data.payload)); - break; - case 'notification': - store.dispatch(updateNotifications(JSON.parse(data.payload), messages, locale)); - break; - } - }, - - reconnected () { - clearPolling(); - store.dispatch(connectTimeline('home')); - store.dispatch(refreshHomeTimeline()); - store.dispatch(refreshNotifications()); - }, - - }); + this.disconnect = store.dispatch(connectUserStream()); // Desktop notifications + // Ask after 1 minute if (typeof window.Notification !== 'undefined' && Notification.permission === 'default') { - Notification.requestPermission(); + window.setTimeout(() => Notification.requestPermission(), 60 * 1000); + } + + // Protocol handler + // Ask after 5 minutes + if (typeof navigator.registerProtocolHandler !== 'undefined') { + const handlerUrl = window.location.protocol + '//' + window.location.host + '/intent?uri=%s'; + window.setTimeout(() => navigator.registerProtocolHandler('web+mastodon', handlerUrl, 'Mastodon'), 5 * 60 * 1000); } store.dispatch(showOnboardingOnce()); } componentWillUnmount () { - if (typeof this.subscription !== 'undefined') { - this.subscription.close(); - this.subscription = null; - } - - if (typeof this.polling !== 'undefined') { - clearInterval(this.polling); - this.polling = null; + if (this.disconnect) { + this.disconnect(); + this.disconnect = null; } } diff --git a/app/javascript/mastodon/containers/status_container.js b/app/javascript/mastodon/containers/status_container.js index 438ecfe43..a727a87d1 100644 --- a/app/javascript/mastodon/containers/status_container.js +++ b/app/javascript/mastodon/containers/status_container.js @@ -11,12 +11,14 @@ import { favourite, unreblog, unfavourite, + pin, + unpin, } from '../actions/interactions'; import { blockAccount, muteAccount, } from '../actions/accounts'; -import { muteStatus, unmuteStatus, deleteStatus } from '../actions/statuses'; +import { muteStatus, unmuteStatus, deleteStatus, setStatusHeight } from '../actions/statuses'; import { initReport } from '../actions/reports'; import { openModal } from '../actions/modal'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; @@ -72,6 +74,18 @@ const mapDispatchToProps = (dispatch, { intl }) => ({ } }, + onPin (status) { + if (status.get('pinned')) { + dispatch(unpin(status)); + } else { + dispatch(pin(status)); + } + }, + + onEmbed (status) { + dispatch(openModal('EMBED', { url: status.get('url') })); + }, + onDelete (status) { if (!this.deleteModal) { dispatch(deleteStatus(status.get('id'))); @@ -124,6 +138,10 @@ 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/emoji.js b/app/javascript/mastodon/emoji.js index 5695c86dd..a41dfdd1d 100644 --- a/app/javascript/mastodon/emoji.js +++ b/app/javascript/mastodon/emoji.js @@ -3,34 +3,28 @@ import Trie from 'substring-trie'; const trie = new Trie(Object.keys(unicodeMapping)); -const excluded = ['™', '©', '®']; - -function emojify(str) { - // 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 match; - while (++i < str.length) { - const char = str.charAt(i); - if (insideTag && char === '>') { - insideTag = false; - } else if (char === '<') { - insideTag = true; - } else if (!insideTag && (match = trie.search(str.substring(i)))) { - const unicodeStr = match; - if (unicodeStr in unicodeMapping && excluded.indexOf(unicodeStr) === -1) { - 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 - } +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); } } - return str; -} + return rtn + str; +}; export default emojify; diff --git a/app/javascript/mastodon/emojione_light.js b/app/javascript/mastodon/emojione_light.js index 985e9dbcb..0d07d012f 100644 --- a/app/javascript/mastodon/emojione_light.js +++ b/app/javascript/mastodon/emojione_light.js @@ -4,8 +4,10 @@ const emojione = require('emojione'); const mappedUnicode = emojione.mapUnicodeToShort(); +const excluded = ['®', '©', '™']; module.exports.unicodeMapping = Object.keys(emojione.jsEscapeMap) + .filter(c => !excluded.includes(c)) .map(unicodeStr => [unicodeStr, mappedUnicode[emojione.jsEscapeMap[unicodeStr]]]) .map(([unicodeStr, shortCode]) => ({ [unicodeStr]: [emojione.emojioneList[shortCode].fname, shortCode.slice(1, shortCode.length - 1)] })) .reduce((x, y) => Object.assign(x, y), { }); diff --git a/app/javascript/mastodon/features/account/components/header.js b/app/javascript/mastodon/features/account/components/header.js index 40567d17c..6eb51a5c7 100644 --- a/app/javascript/mastodon/features/account/components/header.js +++ b/app/javascript/mastodon/features/account/components/header.js @@ -1,8 +1,6 @@ import React from 'react'; import ImmutablePropTypes from 'react-immutable-proptypes'; import PropTypes from 'prop-types'; -import emojify from '../../../emoji'; -import escapeTextContentForBrowser from 'escape-html'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import IconButton from '../../../components/icon_button'; import Motion from 'react-motion/lib/Motion'; @@ -13,7 +11,7 @@ import ImmutablePureComponent from 'react-immutable-pure-component'; const messages = defineMessages({ unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' }, follow: { id: 'account.follow', defaultMessage: 'Follow' }, - requested: { id: 'account.requested', defaultMessage: 'Awaiting approval' }, + requested: { id: 'account.requested', defaultMessage: 'Awaiting approval. Click to cancel follow request' }, }); const makeMapStateToProps = () => { @@ -92,15 +90,10 @@ export default class Header extends ImmutablePureComponent { return null; } - let displayName = account.get('display_name'); let info = ''; let actionBtn = ''; let lockedIcon = ''; - if (displayName.length === 0) { - displayName = account.get('username'); - } - if (me !== account.get('id') && account.getIn(['relationship', 'followed_by'])) { info = ; } @@ -109,7 +102,7 @@ export default class Header extends ImmutablePureComponent { if (account.getIn(['relationship', 'requested'])) { actionBtn = (

- +
); } else if (!account.getIn(['relationship', 'blocking'])) { @@ -125,15 +118,15 @@ export default class Header extends ImmutablePureComponent { lockedIcon = ; } - const content = { __html: emojify(account.get('note')) }; - const displayNameHTML = { __html: emojify(escapeTextContentForBrowser(displayName)) }; + const content = { __html: account.get('note_emojified') }; + const displayNameHtml = { __html: account.get('display_name_html') }; return (
- + @{account.get('acct')} {lockedIcon}
diff --git a/app/javascript/mastodon/features/account_timeline/containers/header_container.js b/app/javascript/mastodon/features/account_timeline/containers/header_container.js index baa81bbc2..dcee78b3e 100644 --- a/app/javascript/mastodon/features/account_timeline/containers/header_container.js +++ b/app/javascript/mastodon/features/account_timeline/containers/header_container.js @@ -38,7 +38,7 @@ const makeMapStateToProps = () => { const mapDispatchToProps = (dispatch, { intl }) => ({ onFollow (account) { - if (account.getIn(['relationship', 'following'])) { + if (account.getIn(['relationship', 'following']) || account.getIn(['relationship', 'requested'])) { if (this.unfollowModal) { dispatch(openModal('CONFIRM', { message: @{account.get('acct')} }} />, diff --git a/app/javascript/mastodon/features/community_timeline/index.js b/app/javascript/mastodon/features/community_timeline/index.js index 0e2300f8c..596a89412 100644 --- a/app/javascript/mastodon/features/community_timeline/index.js +++ b/app/javascript/mastodon/features/community_timeline/index.js @@ -7,15 +7,11 @@ import ColumnHeader from '../../components/column_header'; import { refreshCommunityTimeline, expandCommunityTimeline, - updateTimeline, - deleteFromTimelines, - connectTimeline, - disconnectTimeline, } from '../../actions/timelines'; import { addColumn, removeColumn, moveColumn } from '../../actions/columns'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import ColumnSettingsContainer from './containers/column_settings_container'; -import createStream from '../../stream'; +import { connectCommunityStream } from '../../actions/streaming'; const messages = defineMessages({ title: { id: 'column.community', defaultMessage: 'Local timeline' }, @@ -23,8 +19,6 @@ const messages = defineMessages({ const mapStateToProps = state => ({ hasUnread: state.getIn(['timelines', 'community', 'unread']) > 0, - streamingAPIBaseURL: state.getIn(['meta', 'streaming_api_base_url']), - accessToken: state.getIn(['meta', 'access_token']), }); @connect(mapStateToProps) @@ -35,8 +29,6 @@ export default class CommunityTimeline extends React.PureComponent { dispatch: PropTypes.func.isRequired, columnId: PropTypes.string, intl: PropTypes.object.isRequired, - streamingAPIBaseURL: PropTypes.string.isRequired, - accessToken: PropTypes.string.isRequired, hasUnread: PropTypes.bool, multiColumn: PropTypes.bool, }; @@ -61,46 +53,16 @@ export default class CommunityTimeline extends React.PureComponent { } componentDidMount () { - const { dispatch, streamingAPIBaseURL, accessToken } = this.props; + const { dispatch } = this.props; dispatch(refreshCommunityTimeline()); - - if (typeof this._subscription !== 'undefined') { - return; - } - - this._subscription = createStream(streamingAPIBaseURL, accessToken, 'public:local', { - - connected () { - dispatch(connectTimeline('community')); - }, - - reconnected () { - dispatch(connectTimeline('community')); - }, - - disconnected () { - dispatch(disconnectTimeline('community')); - }, - - received (data) { - switch(data.event) { - case 'update': - dispatch(updateTimeline('community', JSON.parse(data.payload))); - break; - case 'delete': - dispatch(deleteFromTimelines(data.payload)); - break; - } - }, - - }); + this.disconnect = dispatch(connectCommunityStream()); } componentWillUnmount () { - if (typeof this._subscription !== 'undefined') { - this._subscription.close(); - this._subscription = null; + if (this.disconnect) { + this.disconnect(); + this.disconnect = null; } } diff --git a/app/javascript/mastodon/features/compose/components/autosuggest_account.js b/app/javascript/mastodon/features/compose/components/autosuggest_account.js index ebfa3c247..e7de3716b 100644 --- a/app/javascript/mastodon/features/compose/components/autosuggest_account.js +++ b/app/javascript/mastodon/features/compose/components/autosuggest_account.js @@ -15,7 +15,7 @@ export default class AutosuggestAccount extends ImmutablePureComponent { return (
-
+
); diff --git a/app/javascript/mastodon/features/compose/components/navigation_bar.js b/app/javascript/mastodon/features/compose/components/navigation_bar.js index cd2dd8f61..7f346854c 100644 --- a/app/javascript/mastodon/features/compose/components/navigation_bar.js +++ b/app/javascript/mastodon/features/compose/components/navigation_bar.js @@ -19,7 +19,7 @@ export default class NavigationBar extends ImmutablePureComponent {
{this.props.account.get('acct')} - +
diff --git a/app/javascript/mastodon/features/compose/components/reply_indicator.js b/app/javascript/mastodon/features/compose/components/reply_indicator.js index da00e46c5..7672440b4 100644 --- a/app/javascript/mastodon/features/compose/components/reply_indicator.js +++ b/app/javascript/mastodon/features/compose/components/reply_indicator.js @@ -4,7 +4,6 @@ import PropTypes from 'prop-types'; import Avatar from '../../../components/avatar'; import IconButton from '../../../components/icon_button'; import DisplayName from '../../../components/display_name'; -import emojify from '../../../emoji'; import { defineMessages, injectIntl } from 'react-intl'; import ImmutablePureComponent from 'react-immutable-pure-component'; @@ -43,7 +42,7 @@ export default class ReplyIndicator extends ImmutablePureComponent { return null; } - const content = { __html: emojify(status.get('content')) }; + const content = { __html: status.get('contentHtml') }; return (
@@ -51,7 +50,7 @@ export default class ReplyIndicator extends ImmutablePureComponent {
-
+
diff --git a/app/javascript/mastodon/features/favourited_statuses/index.js b/app/javascript/mastodon/features/favourited_statuses/index.js index d9ad9bc1f..1e1f5873c 100644 --- a/app/javascript/mastodon/features/favourited_statuses/index.js +++ b/app/javascript/mastodon/features/favourited_statuses/index.js @@ -16,6 +16,7 @@ const messages = defineMessages({ const mapStateToProps = state => ({ statusIds: state.getIn(['status_lists', 'favourites', 'items']), + hasMore: !!state.getIn(['status_lists', 'favourites', 'next']), }); @connect(mapStateToProps) @@ -28,6 +29,7 @@ export default class Favourites extends ImmutablePureComponent { intl: PropTypes.object.isRequired, columnId: PropTypes.string, multiColumn: PropTypes.bool, + hasMore: PropTypes.bool, }; componentWillMount () { @@ -62,7 +64,7 @@ export default class Favourites extends ImmutablePureComponent { } render () { - const { intl, statusIds, columnId, multiColumn } = this.props; + const { intl, statusIds, columnId, multiColumn, hasMore } = this.props; const pinned = !!columnId; return ( @@ -75,12 +77,14 @@ export default class Favourites extends ImmutablePureComponent { onClick={this.handleHeaderClick} pinned={pinned} multiColumn={multiColumn} + showBackButton /> diff --git a/app/javascript/mastodon/features/follow_requests/components/account_authorize.js b/app/javascript/mastodon/features/follow_requests/components/account_authorize.js index 566953ddd..4fc5638d9 100644 --- a/app/javascript/mastodon/features/follow_requests/components/account_authorize.js +++ b/app/javascript/mastodon/features/follow_requests/components/account_authorize.js @@ -4,7 +4,6 @@ import ImmutablePropTypes from 'react-immutable-proptypes'; import Permalink from '../../../components/permalink'; import Avatar from '../../../components/avatar'; import DisplayName from '../../../components/display_name'; -import emojify from '../../../emoji'; import IconButton from '../../../components/icon_button'; import { defineMessages, injectIntl } from 'react-intl'; import ImmutablePureComponent from 'react-immutable-pure-component'; @@ -26,13 +25,13 @@ export default class AccountAuthorize extends ImmutablePureComponent { render () { const { intl, account, onAuthorize, onReject } = this.props; - const content = { __html: emojify(account.get('note')) }; + const content = { __html: account.get('note_emojified') }; return (
-
+
diff --git a/app/javascript/mastodon/features/getting_started/index.js b/app/javascript/mastodon/features/getting_started/index.js index f8ea01024..973c8a4ae 100644 --- a/app/javascript/mastodon/features/getting_started/index.js +++ b/app/javascript/mastodon/features/getting_started/index.js @@ -23,6 +23,7 @@ const messages = defineMessages({ blocks: { id: 'navigation_bar.blocks', defaultMessage: 'Blocked users' }, mutes: { id: 'navigation_bar.mutes', defaultMessage: 'Muted users' }, info: { id: 'navigation_bar.info', defaultMessage: 'Extended information' }, + pins: { id: 'navigation_bar.pins', defaultMessage: 'Pinned toots' }, }); const mapStateToProps = state => ({ @@ -66,15 +67,16 @@ export default class GettingStarted extends ImmutablePureComponent { navItems = navItems.concat([ , + , ]); if (me.get('locked')) { - navItems.push(); + navItems.push(); } navItems = navItems.concat([ - , - , + , + , ]); return ( diff --git a/app/javascript/mastodon/features/hashtag_timeline/index.js b/app/javascript/mastodon/features/hashtag_timeline/index.js index b17e8e1a5..5fe21ce90 100644 --- a/app/javascript/mastodon/features/hashtag_timeline/index.js +++ b/app/javascript/mastodon/features/hashtag_timeline/index.js @@ -7,17 +7,13 @@ import ColumnHeader from '../../components/column_header'; import { refreshHashtagTimeline, expandHashtagTimeline, - updateTimeline, - deleteFromTimelines, } from '../../actions/timelines'; import { addColumn, removeColumn, moveColumn } from '../../actions/columns'; import { FormattedMessage } from 'react-intl'; -import createStream from '../../stream'; +import { connectHashtagStream } from '../../actions/streaming'; -const mapStateToProps = state => ({ - hasUnread: state.getIn(['timelines', 'tag', 'unread']) > 0, - streamingAPIBaseURL: state.getIn(['meta', 'streaming_api_base_url']), - accessToken: state.getIn(['meta', 'access_token']), +const mapStateToProps = (state, props) => ({ + hasUnread: state.getIn(['timelines', `hashtag:${props.params.id}`, 'unread']) > 0, }); @connect(mapStateToProps) @@ -27,8 +23,6 @@ export default class HashtagTimeline extends React.PureComponent { params: PropTypes.object.isRequired, columnId: PropTypes.string, dispatch: PropTypes.func.isRequired, - streamingAPIBaseURL: PropTypes.string.isRequired, - accessToken: PropTypes.string.isRequired, hasUnread: PropTypes.bool, multiColumn: PropTypes.bool, }; @@ -53,28 +47,13 @@ export default class HashtagTimeline extends React.PureComponent { } _subscribe (dispatch, id) { - const { streamingAPIBaseURL, accessToken } = this.props; - - this.subscription = createStream(streamingAPIBaseURL, accessToken, `hashtag&tag=${id}`, { - - received (data) { - switch(data.event) { - case 'update': - dispatch(updateTimeline(`hashtag:${id}`, JSON.parse(data.payload))); - break; - case 'delete': - dispatch(deleteFromTimelines(data.payload)); - break; - } - }, - - }); + this.disconnect = dispatch(connectHashtagStream(id)); } _unsubscribe () { - if (typeof this.subscription !== 'undefined') { - this.subscription.close(); - this.subscription = null; + if (this.disconnect) { + this.disconnect(); + this.disconnect = null; } } diff --git a/app/javascript/mastodon/features/notifications/components/notification.js b/app/javascript/mastodon/features/notifications/components/notification.js index 9d631644a..a608a5223 100644 --- a/app/javascript/mastodon/features/notifications/components/notification.js +++ b/app/javascript/mastodon/features/notifications/components/notification.js @@ -1,17 +1,17 @@ import React from 'react'; +import PropTypes from 'prop-types'; import ImmutablePropTypes from 'react-immutable-proptypes'; import StatusContainer from '../../../containers/status_container'; import AccountContainer from '../../../containers/account_container'; import { FormattedMessage } from 'react-intl'; import Permalink from '../../../components/permalink'; -import emojify from '../../../emoji'; -import escapeTextContentForBrowser from 'escape-html'; import ImmutablePureComponent from 'react-immutable-pure-component'; export default class Notification extends ImmutablePureComponent { static propTypes = { notification: ImmutablePropTypes.map.isRequired, + hidden: PropTypes.bool, }; renderFollow (account, link) { @@ -25,13 +25,13 @@ export default class Notification extends ImmutablePureComponent {
- +
); } renderMention (notification) { - return ; + return
- +
); } @@ -59,7 +59,7 @@ export default class Notification extends ImmutablePureComponent {
- +
); } @@ -67,9 +67,8 @@ export default class Notification extends ImmutablePureComponent { render () { const { notification } = this.props; const account = notification.get('account'); - const displayName = account.get('display_name').length > 0 ? account.get('display_name') : account.get('username'); - const displayNameHTML = { __html: emojify(escapeTextContentForBrowser(displayName)) }; - const link = ; + const displayNameHtml = { __html: account.get('display_name_html') }; + const link = ; switch(notification.get('type')) { case 'follow': diff --git a/app/javascript/mastodon/features/notifications/components/setting_toggle.js b/app/javascript/mastodon/features/notifications/components/setting_toggle.js index a20e7ca51..281359d2a 100644 --- a/app/javascript/mastodon/features/notifications/components/setting_toggle.js +++ b/app/javascript/mastodon/features/notifications/components/setting_toggle.js @@ -18,12 +18,6 @@ export default class SettingToggle extends React.PureComponent { this.props.onChange(this.props.settingKey, target.checked); } - onKeyDown = e => { - if (e.key === ' ') { - this.props.onChange(this.props.settingKey, !e.target.checked); - } - } - render () { const { prefix, settings, settingKey, label, meta } = this.props; const id = ['setting-toggle', prefix, ...settingKey].filter(Boolean).join('-'); diff --git a/app/javascript/mastodon/features/notifications/index.js b/app/javascript/mastodon/features/notifications/index.js index c5853d3ba..b74473b9f 100644 --- a/app/javascript/mastodon/features/notifications/index.js +++ b/app/javascript/mastodon/features/notifications/index.js @@ -7,13 +7,12 @@ import ColumnHeader from '../../components/column_header'; import { expandNotifications, scrollTopNotifications } from '../../actions/notifications'; import { addColumn, removeColumn, moveColumn } from '../../actions/columns'; import NotificationContainer from './containers/notification_container'; -import { ScrollContainer } from 'react-router-scroll'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import ColumnSettingsContainer from './containers/column_settings_container'; import { createSelector } from 'reselect'; import { List as ImmutableList } from 'immutable'; -import LoadMore from '../../components/load_more'; import { debounce } from 'lodash'; +import ScrollableList from '../../components/scrollable_list'; const messages = defineMessages({ title: { id: 'column.notifications', defaultMessage: 'Notifications' }, @@ -51,40 +50,18 @@ export default class Notifications extends React.PureComponent { trackScroll: true, }; - dispatchExpandNotifications = debounce(() => { + handleScrollToBottom = debounce(() => { + this.props.dispatch(scrollTopNotifications(false)); this.props.dispatch(expandNotifications()); }, 300, { leading: true }); - dispatchScrollToTop = debounce((top) => { - this.props.dispatch(scrollTopNotifications(top)); + handleScrollToTop = debounce(() => { + this.props.dispatch(scrollTopNotifications(true)); }, 100); - handleScroll = (e) => { - const { scrollTop, scrollHeight, clientHeight } = e.target; - const offset = scrollHeight - scrollTop - clientHeight; - this._oldScrollPosition = scrollHeight - scrollTop; - - if (250 > offset && this.props.hasMore && !this.props.isLoading) { - this.dispatchExpandNotifications(); - } - - if (scrollTop < 100) { - this.dispatchScrollToTop(true); - } else { - this.dispatchScrollToTop(false); - } - } - - componentDidUpdate (prevProps) { - if (this.node.scrollTop > 0 && (prevProps.notifications.size < this.props.notifications.size && prevProps.notifications.first() !== this.props.notifications.first() && !!this._oldScrollPosition)) { - this.node.scrollTop = this.node.scrollHeight - this._oldScrollPosition; - } - } - - handleLoadMore = (e) => { - e.preventDefault(); - this.dispatchExpandNotifications(); - } + handleScroll = debounce(() => { + this.props.dispatch(scrollTopNotifications(false)); + }, 100); handlePin = () => { const { columnId, dispatch } = this.props; @@ -105,10 +82,6 @@ export default class Notifications extends React.PureComponent { this.column.scrollTop(); } - setRef = (c) => { - this.node = c; - } - setColumnRef = c => { this.column = c; } @@ -116,52 +89,35 @@ export default class Notifications extends React.PureComponent { render () { const { intl, notifications, shouldUpdateScroll, isLoading, isUnread, columnId, multiColumn, hasMore } = this.props; const pinned = !!columnId; + const emptyMessage = ; - let loadMore = ''; - let scrollableArea = ''; - let unread = ''; - let scrollContainer = ''; + let scrollableContent = null; - if (!isLoading && hasMore) { - loadMore = ; - } - - if (isUnread) { - unread =
; - } - - if (isLoading && this.scrollableArea) { - scrollableArea = this.scrollableArea; + if (isLoading && this.scrollableContent) { + scrollableContent = this.scrollableContent; } else if (notifications.size > 0 || hasMore) { - scrollableArea = ( -
- {unread} - -
- {notifications.map(item => )} - {loadMore} -
-
- ); + scrollableContent = notifications.map((item) => ); } else { - scrollableArea = ( -
- -
- ); + scrollableContent = null; } - if (pinned) { - scrollContainer = scrollableArea; - } else { - scrollContainer = ( - - {scrollableArea} - - ); - } + this.scrollableContent = scrollableContent; - this.scrollableArea = scrollableArea; + const scrollContainer = ( + + {scrollableContent} + + ); return ( diff --git a/app/javascript/mastodon/features/pinned_statuses/index.js b/app/javascript/mastodon/features/pinned_statuses/index.js new file mode 100644 index 000000000..b4a6c1e52 --- /dev/null +++ b/app/javascript/mastodon/features/pinned_statuses/index.js @@ -0,0 +1,59 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { fetchPinnedStatuses } from '../../actions/pin_statuses'; +import Column from '../ui/components/column'; +import ColumnBackButtonSlim from '../../components/column_back_button_slim'; +import StatusList from '../../components/status_list'; +import { defineMessages, injectIntl } from 'react-intl'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +const messages = defineMessages({ + heading: { id: 'column.pins', defaultMessage: 'Pinned toot' }, +}); + +const mapStateToProps = state => ({ + statusIds: state.getIn(['status_lists', 'pins', 'items']), + hasMore: !!state.getIn(['status_lists', 'pins', 'next']), +}); + +@connect(mapStateToProps) +@injectIntl +export default class PinnedStatuses extends ImmutablePureComponent { + + static propTypes = { + dispatch: PropTypes.func.isRequired, + statusIds: ImmutablePropTypes.list.isRequired, + intl: PropTypes.object.isRequired, + hasMore: PropTypes.bool.isRequired, + }; + + componentWillMount () { + this.props.dispatch(fetchPinnedStatuses()); + } + + handleHeaderClick = () => { + this.column.scrollTop(); + } + + setRef = c => { + this.column = c; + } + + render () { + const { intl, statusIds, hasMore } = this.props; + + return ( + + + + + ); + } + +} diff --git a/app/javascript/mastodon/features/public_timeline/index.js b/app/javascript/mastodon/features/public_timeline/index.js index c6cad02d6..193489c63 100644 --- a/app/javascript/mastodon/features/public_timeline/index.js +++ b/app/javascript/mastodon/features/public_timeline/index.js @@ -7,15 +7,11 @@ import ColumnHeader from '../../components/column_header'; import { refreshPublicTimeline, expandPublicTimeline, - updateTimeline, - deleteFromTimelines, - connectTimeline, - disconnectTimeline, } from '../../actions/timelines'; import { addColumn, removeColumn, moveColumn } from '../../actions/columns'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import ColumnSettingsContainer from './containers/column_settings_container'; -import createStream from '../../stream'; +import { connectPublicStream } from '../../actions/streaming'; const messages = defineMessages({ title: { id: 'column.public', defaultMessage: 'Federated timeline' }, @@ -23,8 +19,6 @@ const messages = defineMessages({ const mapStateToProps = state => ({ hasUnread: state.getIn(['timelines', 'public', 'unread']) > 0, - streamingAPIBaseURL: state.getIn(['meta', 'streaming_api_base_url']), - accessToken: state.getIn(['meta', 'access_token']), }); @connect(mapStateToProps) @@ -36,8 +30,6 @@ export default class PublicTimeline extends React.PureComponent { intl: PropTypes.object.isRequired, columnId: PropTypes.string, multiColumn: PropTypes.bool, - streamingAPIBaseURL: PropTypes.string.isRequired, - accessToken: PropTypes.string.isRequired, hasUnread: PropTypes.bool, }; @@ -61,46 +53,16 @@ export default class PublicTimeline extends React.PureComponent { } componentDidMount () { - const { dispatch, streamingAPIBaseURL, accessToken } = this.props; + const { dispatch } = this.props; dispatch(refreshPublicTimeline()); - - if (typeof this._subscription !== 'undefined') { - return; - } - - this._subscription = createStream(streamingAPIBaseURL, accessToken, 'public', { - - connected () { - dispatch(connectTimeline('public')); - }, - - reconnected () { - dispatch(connectTimeline('public')); - }, - - disconnected () { - dispatch(disconnectTimeline('public')); - }, - - received (data) { - switch(data.event) { - case 'update': - dispatch(updateTimeline('public', JSON.parse(data.payload))); - break; - case 'delete': - dispatch(deleteFromTimelines(data.payload)); - break; - } - }, - - }); + this.disconnect = dispatch(connectPublicStream()); } componentWillUnmount () { - if (typeof this._subscription !== 'undefined') { - this._subscription.close(); - this._subscription = null; + if (this.disconnect) { + this.disconnect(); + this.disconnect = null; } } diff --git a/app/javascript/mastodon/features/report/components/status_check_box.js b/app/javascript/mastodon/features/report/components/status_check_box.js index 6a1a84c28..cc9232201 100644 --- a/app/javascript/mastodon/features/report/components/status_check_box.js +++ b/app/javascript/mastodon/features/report/components/status_check_box.js @@ -1,7 +1,6 @@ import React from 'react'; import PropTypes from 'prop-types'; import ImmutablePropTypes from 'react-immutable-proptypes'; -import emojify from '../../../emoji'; import Toggle from 'react-toggle'; export default class StatusCheckBox extends React.PureComponent { @@ -15,7 +14,7 @@ export default class StatusCheckBox extends React.PureComponent { render () { const { status, checked, onToggle, disabled } = this.props; - const content = { __html: emojify(status.get('content')) }; + const content = { __html: status.get('contentHtml') }; if (status.get('reblog')) { return null; diff --git a/app/javascript/mastodon/features/standalone/compose/index.js b/app/javascript/mastodon/features/standalone/compose/index.js new file mode 100644 index 000000000..96d07fefb --- /dev/null +++ b/app/javascript/mastodon/features/standalone/compose/index.js @@ -0,0 +1,18 @@ +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'; + +export default class Compose extends React.PureComponent { + + render () { + return ( +
+ + + +
+ ); + } + +} diff --git a/app/javascript/mastodon/features/status/components/action_bar.js b/app/javascript/mastodon/features/status/components/action_bar.js index 91ac64de2..c303caf10 100644 --- a/app/javascript/mastodon/features/status/components/action_bar.js +++ b/app/javascript/mastodon/features/status/components/action_bar.js @@ -14,6 +14,9 @@ const messages = defineMessages({ favourite: { id: 'status.favourite', defaultMessage: 'Favourite' }, report: { id: 'status.report', defaultMessage: 'Report @{name}' }, share: { id: 'status.share', defaultMessage: 'Share' }, + pin: { id: 'status.pin', defaultMessage: 'Pin on profile' }, + unpin: { id: 'status.unpin', defaultMessage: 'Unpin from profile' }, + embed: { id: 'status.embed', defaultMessage: 'Embed' }, }); @injectIntl @@ -31,6 +34,8 @@ export default class ActionBar extends React.PureComponent { onDelete: PropTypes.func.isRequired, onMention: PropTypes.func.isRequired, onReport: PropTypes.func, + onPin: PropTypes.func, + onEmbed: PropTypes.func, me: PropTypes.number.isRequired, intl: PropTypes.object.isRequired, }; @@ -59,6 +64,10 @@ export default class ActionBar extends React.PureComponent { this.props.onReport(this.props.status); } + handlePinClick = () => { + this.props.onPin(this.props.status); + } + handleShare = () => { navigator.share({ text: this.props.status.get('search_index'), @@ -66,12 +75,26 @@ export default class ActionBar extends React.PureComponent { }); } + handleEmbed = () => { + this.props.onEmbed(this.props.status); + } + render () { const { status, me, intl } = this.props; + const publicStatus = ['public', 'unlisted'].includes(status.get('visibility')); + let menu = []; + if (publicStatus) { + menu.push({ text: intl.formatMessage(messages.embed), action: this.handleEmbed }); + } + if (me === status.getIn(['account', 'id'])) { + if (publicStatus) { + menu.push({ text: intl.formatMessage(status.get('pinned') ? messages.unpin : messages.pin), action: this.handlePinClick }); + } + menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick }); } else { menu.push({ text: intl.formatMessage(messages.mention, { name: status.getIn(['account', 'username']) }), action: this.handleMentionClick }); diff --git a/app/javascript/mastodon/features/status/components/card.js b/app/javascript/mastodon/features/status/components/card.js index bfb40468b..6b13e15cc 100644 --- a/app/javascript/mastodon/features/status/components/card.js +++ b/app/javascript/mastodon/features/status/components/card.js @@ -1,6 +1,7 @@ import React from 'react'; import ImmutablePropTypes from 'react-immutable-proptypes'; import punycode from 'punycode'; +import classnames from 'classnames'; const IDNA_PREFIX = 'xn--'; @@ -32,7 +33,7 @@ export default class Card extends React.PureComponent { if (card.get('image')) { image = (
- {card.get('title')} + {card.get('title')}
); } @@ -41,8 +42,12 @@ export default class Card extends React.PureComponent { provider = decodeIDNA(getHostname(card.get('url'))); } + const className = classnames('status-card', { + 'horizontal': card.get('width') > card.get('height'), + }); + return ( - + {image}
diff --git a/app/javascript/mastodon/features/status/components/detailed_status.js b/app/javascript/mastodon/features/status/components/detailed_status.js index 619957dbe..940a2699b 100644 --- a/app/javascript/mastodon/features/status/components/detailed_status.js +++ b/app/javascript/mastodon/features/status/components/detailed_status.js @@ -59,7 +59,7 @@ export default class DetailedStatus extends ImmutablePureComponent { return (
-
+
diff --git a/app/javascript/mastodon/features/status/index.js b/app/javascript/mastodon/features/status/index.js index cbabdd5bc..c614f6acb 100644 --- a/app/javascript/mastodon/features/status/index.js +++ b/app/javascript/mastodon/features/status/index.js @@ -12,6 +12,8 @@ import { unfavourite, reblog, unreblog, + pin, + unpin, } from '../../actions/interactions'; import { replyCompose, @@ -87,6 +89,14 @@ export default class Status extends ImmutablePureComponent { } } + handlePin = (status) => { + if (status.get('pinned')) { + this.props.dispatch(unpin(status)); + } else { + this.props.dispatch(pin(status)); + } + } + handleReplyClick = (status) => { this.props.dispatch(replyCompose(status, this.context.router.history)); } @@ -137,6 +147,10 @@ export default class Status extends ImmutablePureComponent { this.props.dispatch(initReport(status.get('account'), status)); } + handleEmbed = (status) => { + this.props.dispatch(openModal('EMBED', { url: status.get('url') })); + } + renderChildren (list) { return list.map(id => ); } @@ -187,6 +201,8 @@ export default class Status extends ImmutablePureComponent { onDelete={this.handleDeleteClick} onMention={this.handleMentionClick} onReport={this.handleReport} + onPin={this.handlePin} + onEmbed={this.handleEmbed} /> {descendants} diff --git a/app/javascript/mastodon/features/ui/components/actions_modal.js b/app/javascript/mastodon/features/ui/components/actions_modal.js index cc0620d1c..3d40033be 100644 --- a/app/javascript/mastodon/features/ui/components/actions_modal.js +++ b/app/javascript/mastodon/features/ui/components/actions_modal.js @@ -46,7 +46,7 @@ export default class ActionsModal extends ImmutablePureComponent {
- +
diff --git a/app/javascript/mastodon/features/ui/components/boost_modal.js b/app/javascript/mastodon/features/ui/components/boost_modal.js index 6c80a1084..0e9592c97 100644 --- a/app/javascript/mastodon/features/ui/components/boost_modal.js +++ b/app/javascript/mastodon/features/ui/components/boost_modal.js @@ -62,7 +62,7 @@ export default class BoostModal extends ImmutablePureComponent {
- +
diff --git a/app/javascript/mastodon/features/ui/components/column.js b/app/javascript/mastodon/features/ui/components/column.js index aea102aac..15538ea38 100644 --- a/app/javascript/mastodon/features/ui/components/column.js +++ b/app/javascript/mastodon/features/ui/components/column.js @@ -2,7 +2,7 @@ import React from 'react'; import ColumnHeader from './column_header'; import PropTypes from 'prop-types'; import { debounce } from 'lodash'; -import scrollTop from '../../../scroll'; +import { scrollTop } from '../../../scroll'; import { isMobile } from '../../../is_mobile'; export default class Column extends React.PureComponent { @@ -25,6 +25,17 @@ export default class Column extends React.PureComponent { this._interruptScrollAnimation = scrollTop(scrollable); } + scrollTop () { + const scrollable = this.node.querySelector('.scrollable'); + + if (!scrollable) { + return; + } + + this._interruptScrollAnimation = scrollTop(scrollable); + } + + handleScroll = debounce(() => { if (typeof this._interruptScrollAnimation !== 'undefined') { this._interruptScrollAnimation(); diff --git a/app/javascript/mastodon/features/ui/components/columns_area.js b/app/javascript/mastodon/features/ui/components/columns_area.js index 63bd1b021..539af8ce3 100644 --- a/app/javascript/mastodon/features/ui/components/columns_area.js +++ b/app/javascript/mastodon/features/ui/components/columns_area.js @@ -9,9 +9,13 @@ import { links, getIndex, getLink } from './tabs_bar'; import BundleContainer from '../containers/bundle_container'; import ColumnLoading from './column_loading'; +import DrawerLoading from './drawer_loading'; import BundleColumnError from './bundle_column_error'; import { Compose, Notifications, HomeTimeline, CommunityTimeline, PublicTimeline, HashtagTimeline, FavouritedStatuses } from '../../ui/util/async-components'; +import detectPassiveEvents from 'detect-passive-events'; +import { scrollRight } from '../../../scroll'; + const componentMap = { 'COMPOSE': Compose, 'HOME': HomeTimeline, @@ -22,7 +26,7 @@ const componentMap = { 'FAVOURITES': FavouritedStatuses, }; -@injectIntl +@component => injectIntl(component, { withRef: true }) export default class ColumnsArea extends ImmutablePureComponent { static contextTypes = { @@ -45,15 +49,39 @@ export default class ColumnsArea extends ImmutablePureComponent { } componentDidMount() { + if (!this.props.singleColumn) { + this.node.addEventListener('wheel', this.handleWheel, detectPassiveEvents.hasSupport ? { passive: true } : false); + } this.lastIndex = getIndex(this.context.router.history.location.pathname); this.setState({ shouldAnimate: true }); } - componentDidUpdate() { + componentWillUpdate(nextProps) { + if (this.props.singleColumn !== nextProps.singleColumn && nextProps.singleColumn) { + this.node.removeEventListener('wheel', this.handleWheel); + } + } + + componentDidUpdate(prevProps) { + if (this.props.singleColumn !== prevProps.singleColumn && !this.props.singleColumn) { + this.node.addEventListener('wheel', this.handleWheel, detectPassiveEvents.hasSupport ? { passive: true } : false); + } this.lastIndex = getIndex(this.context.router.history.location.pathname); this.setState({ shouldAnimate: true }); } + componentWillUnmount () { + if (!this.props.singleColumn) { + this.node.removeEventListener('wheel', this.handleWheel); + } + } + + handleChildrenContentChange() { + if (!this.props.singleColumn) { + scrollRight(this.node, this.node.scrollWidth - window.innerWidth); + } + } + handleSwipe = (index) => { this.pendingIndex = index; @@ -74,6 +102,18 @@ export default class ColumnsArea extends ImmutablePureComponent { } } + handleWheel = () => { + if (typeof this._interruptScrollAnimation !== 'function') { + return; + } + + this._interruptScrollAnimation(); + } + + setRef = (node) => { + this.node = node; + } + renderView = (link, index) => { const columnIndex = getIndex(this.context.router.history.location.pathname); const title = this.props.intl.formatMessage({ id: link.props['data-preview-title-id'] }); @@ -90,8 +130,8 @@ export default class ColumnsArea extends ImmutablePureComponent { ); } - renderLoading = () => { - return ; + renderLoading = columnId => () => { + return columnId === 'COMPOSE' ? : ; } renderError = (props) => { @@ -114,12 +154,12 @@ export default class ColumnsArea extends ImmutablePureComponent { } return ( -
+
{columns.map(column => { const params = column.get('params', null) === null ? null : column.get('params').toJS(); return ( - + {SpecificComponent => } ); diff --git a/app/javascript/mastodon/features/ui/components/drawer_loading.js b/app/javascript/mastodon/features/ui/components/drawer_loading.js new file mode 100644 index 000000000..08b0d2347 --- /dev/null +++ b/app/javascript/mastodon/features/ui/components/drawer_loading.js @@ -0,0 +1,11 @@ +import React from 'react'; + +const DrawerLoading = () => ( +
+
+
+
+
+); + +export default DrawerLoading; diff --git a/app/javascript/mastodon/features/ui/components/embed_modal.js b/app/javascript/mastodon/features/ui/components/embed_modal.js new file mode 100644 index 000000000..1afffb51b --- /dev/null +++ b/app/javascript/mastodon/features/ui/components/embed_modal.js @@ -0,0 +1,84 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import { FormattedMessage, injectIntl } from 'react-intl'; +import axios from 'axios'; + +@injectIntl +export default class EmbedModal extends ImmutablePureComponent { + + static propTypes = { + url: PropTypes.string.isRequired, + onClose: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + } + + state = { + loading: false, + oembed: null, + }; + + componentDidMount () { + const { url } = this.props; + + this.setState({ loading: true }); + + axios.post('/api/web/embed', { url }).then(res => { + this.setState({ loading: false, oembed: res.data }); + + const iframeDocument = this.iframe.contentWindow.document; + + iframeDocument.open(); + iframeDocument.write(res.data.html); + iframeDocument.close(); + + iframeDocument.body.style.margin = 0; + this.iframe.width = iframeDocument.body.scrollWidth; + this.iframe.height = iframeDocument.body.scrollHeight; + }); + } + + setIframeRef = c => { + this.iframe = c; + } + + handleTextareaClick = (e) => { + e.target.select(); + } + + render () { + const { oembed } = this.state; + + return ( +
+

+ +
+

+ +

+ + + +

+ +

+ +