diff --git a/.babelrc b/.babelrc index 081c4f963..de922f389 100644 --- a/.babelrc +++ b/.babelrc @@ -15,13 +15,15 @@ "plugins": [ "syntax-dynamic-import", ["transform-object-rest-spread", { "useBuiltIns": true }], + "transform-decorators-legacy", "transform-class-properties", [ "react-intl", { "messagesDir": "./build/messages" } - ] + ], + "preval" ], "env": { "development": { @@ -43,6 +45,7 @@ ] } ], + "transform-react-inline-elements", [ "transform-runtime", { diff --git a/.env.nanobox b/.env.nanobox index 73abefdc6..7920c47b9 100644 --- a/.env.nanobox +++ b/.env.nanobox @@ -69,7 +69,7 @@ SMTP_FROM_ADDRESS=notifications@${APP_NAME}.nanoapp.io # PAPERCLIP_ROOT_URL=/system # Optional asset host for multi-server setups -# CDN_HOST=assets.example.com +# CDN_HOST=https://assets.example.com # S3 (optional) # S3_ENABLED=true diff --git a/.env.production.sample b/.env.production.sample index 394cdedfe..eb1c5a48f 100644 --- a/.env.production.sample +++ b/.env.production.sample @@ -31,6 +31,17 @@ PAPERCLIP_SECRET= SECRET_KEY_BASE= OTP_SECRET= +# VAPID keys (used for push notifications +# You can generate the keys using the following command (first is the private key, second is the public one) +# 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) +# +# For more information visit https://rossta.net/blog/using-the-web-push-api-with-vapid.html +VAPID_PRIVATE_KEY= +VAPID_PUBLIC_KEY= + # Registrations # Single user mode will disable registrations and redirect frontpage to the first profile # SINGLE_USER_MODE=true diff --git a/.eslintrc.yml b/.eslintrc.yml index 2fb54ae66..a816bffef 100644 --- a/.eslintrc.yml +++ b/.eslintrc.yml @@ -1,7 +1,9 @@ --- +root: true + env: browser: true - node: false + node: true es6: true parser: babel-eslint @@ -52,8 +54,14 @@ rules: no-mixed-spaces-and-tabs: warn no-nested-ternary: warn no-trailing-spaces: warn + no-undef: error no-unreachable: error no-unused-expressions: error + no-unused-vars: + - error + - vars: all + args: after-used + ignoreRestSiblings: true object-curly-spacing: - error - always @@ -81,7 +89,10 @@ rules: - 2 react/jsx-no-bind: error react/jsx-no-duplicate-props: error + react/jsx-no-undef: error react/jsx-tag-spacing: error + react/jsx-uses-react: error + react/jsx-uses-vars: error react/jsx-wrap-multilines: error react/no-multi-comp: off react/no-string-refs: error diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..e69f2a0ad --- /dev/null +++ b/.gitattributes @@ -0,0 +1,14 @@ +* text=auto eol=lf +*.eot -text +*.gif -text +*.gz -text +*.ico -text +*.jpg -text +*.mp3 -text +*.ogg -text +*.png -text +*.ttf -text +*.webm -text +*.woff -text +*.woff2 -text +spec/fixtures/requests/** -text !eol diff --git a/.gitignore b/.gitignore index 31743fccf..868a84368 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,8 @@ coverage public/system public/assets public/packs +public/packs-test +public/sw.js .env .env.production node_modules/ diff --git a/.postcssrc.yml b/.postcssrc.yml index 220fe0bb9..efffb39ba 100644 --- a/.postcssrc.yml +++ b/.postcssrc.yml @@ -6,3 +6,4 @@ plugins: - last 2 versions - IE >= 11 - iOS >= 9 + postcss-object-fit-images: {} diff --git a/.travis.yml b/.travis.yml index a855aa31c..4d4dc0893 100644 --- a/.travis.yml +++ b/.travis.yml @@ -5,9 +5,9 @@ cache: directories: - node_modules - public/assets - - public/packs + - public/packs-test dist: trusty -sudo: false +sudo: required notifications: email: false @@ -32,6 +32,7 @@ addons: - g++-6 - libprotobuf-dev - protobuf-compiler + - libicu-dev rvm: - 2.3.4 diff --git a/Aptfile b/Aptfile index 0456343ef..f89f74bd4 100644 --- a/Aptfile +++ b/Aptfile @@ -3,3 +3,5 @@ libprotobuf-dev ffmpeg libxdamage1 libxfixes3 +libicu-dev +libidn11-dev diff --git a/Dockerfile b/Dockerfile index 7033cddd4..ef139dcec 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,9 +12,12 @@ 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 \ && apk add -t build-dependencies \ build-base \ + icu-dev \ + libidn-dev \ libxml2-dev \ libxslt-dev \ postgresql-dev \ @@ -25,7 +28,9 @@ RUN echo "@edge https://nl.alpinelinux.org/alpine/edge/main" >> /etc/apk/reposit ffmpeg \ file \ git \ + icu-libs \ imagemagick@edge \ + libidn \ libpq \ libxml2 \ libxslt \ @@ -34,7 +39,7 @@ RUN echo "@edge https://nl.alpinelinux.org/alpine/edge/main" >> /etc/apk/reposit protobuf \ su-exec \ tini \ - && npm install -g npm@3 && npm install -g yarn \ + yarn@edge \ && update-ca-certificates \ && rm -rf /tmp/* /var/cache/apk/* diff --git a/Gemfile b/Gemfile index b014ba03c..f4182bff5 100644 --- a/Gemfile +++ b/Gemfile @@ -18,22 +18,27 @@ gem 'aws-sdk', '~> 2.9' gem 'paperclip', '~> 5.1' gem 'paperclip-av-transcoder', '~> 0.6' +gem 'active_model_serializers', '~> 0.10' gem 'addressable', '~> 2.5' gem 'bootsnap' +gem 'browser' +gem 'charlock_holmes', '~> 0.7.3' gem 'cld3', '~> 3.1' gem 'devise', '~> 4.2' gem 'devise-two-factor', '~> 3.0' gem 'doorkeeper', '~> 4.2' gem 'fast_blank', '~> 1.0' -gem 'goldfinger', '~> 1.2' +gem 'goldfinger', '~> 2.0' gem 'hiredis', '~> 0.6' gem 'redis-namespace', '~> 1.5' gem 'htmlentities', '~> 4.3' gem 'http', '~> 2.2' gem 'http_accept_language', '~> 2.1' gem 'httplog', '~> 0.99' +gem 'idn-ruby', require: 'idn' gem 'kaminari', '~> 1.0' gem 'link_header', '~> 0.0' +gem 'mime-types', '~> 3.1' gem 'nokogiri', '~> 1.7' gem 'oj', '~> 3.0' gem 'ostatus2', '~> 2.0' @@ -46,6 +51,7 @@ gem 'rack-timeout', '~> 0.4' gem 'rails-i18n', '~> 5.0' gem 'rails-settings-cached', '~> 0.6' gem 'redis', '~> 3.3', require: ['redis', 'redis/connection/hiredis'] +gem 'mario-redis-lock', '~> 1.2', require: 'redis_lock' gem 'rqrcode', '~> 0.10' gem 'ruby-oembed', '~> 0.12', require: 'oembed' gem 'sanitize', '~> 4.4' @@ -59,7 +65,8 @@ gem 'sprockets-rails', '~> 3.2', require: 'sprockets/railtie' gem 'statsd-instrument', '~> 2.1' gem 'twitter-text', '~> 1.14' gem 'tzinfo-data', '~> 1.2017' -gem 'webpacker', '~> 1.2' +gem 'webpacker', '~> 2.0' +gem 'webpush' group :development, :test do gem 'fabrication', '~> 2.16' @@ -73,7 +80,7 @@ group :test do gem 'capybara', '~> 2.14' gem 'climate_control', '~> 0.2' gem 'faker', '~> 1.7' - gem 'microformats2', '~> 3.0' + gem 'microformats', '~> 4.0' gem 'rails-controller-testing', '~> 1.0' gem 'rspec-sidekiq', '~> 3.0' gem 'simplecov', '~> 0.14', require: false diff --git a/Gemfile.lock b/Gemfile.lock index ef7e40376..e5fd3506a 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,47 +1,52 @@ GEM remote: https://rubygems.org/ specs: - actioncable (5.1.1) - actionpack (= 5.1.1) + actioncable (5.1.2) + actionpack (= 5.1.2) nio4r (~> 2.0) websocket-driver (~> 0.6.1) - actionmailer (5.1.1) - actionpack (= 5.1.1) - actionview (= 5.1.1) - activejob (= 5.1.1) + actionmailer (5.1.2) + actionpack (= 5.1.2) + actionview (= 5.1.2) + activejob (= 5.1.2) mail (~> 2.5, >= 2.5.4) rails-dom-testing (~> 2.0) - actionpack (5.1.1) - actionview (= 5.1.1) - activesupport (= 5.1.1) + actionpack (5.1.2) + actionview (= 5.1.2) + activesupport (= 5.1.2) rack (~> 2.0) rack-test (~> 0.6.3) rails-dom-testing (~> 2.0) rails-html-sanitizer (~> 1.0, >= 1.0.2) - actionview (5.1.1) - activesupport (= 5.1.1) + actionview (5.1.2) + activesupport (= 5.1.2) builder (~> 3.1) erubi (~> 1.4) rails-dom-testing (~> 2.0) rails-html-sanitizer (~> 1.0, >= 1.0.3) + active_model_serializers (0.10.6) + actionpack (>= 4.1, < 6) + activemodel (>= 4.1, < 6) + case_transform (>= 0.2) + jsonapi-renderer (>= 0.1.1.beta1, < 0.2) active_record_query_trace (1.5.4) - activejob (5.1.1) - activesupport (= 5.1.1) + activejob (5.1.2) + activesupport (= 5.1.2) globalid (>= 0.3.6) - activemodel (5.1.1) - activesupport (= 5.1.1) - activerecord (5.1.1) - activemodel (= 5.1.1) - activesupport (= 5.1.1) + activemodel (5.1.2) + activesupport (= 5.1.2) + activerecord (5.1.2) + activemodel (= 5.1.2) + activesupport (= 5.1.2) arel (~> 8.0) - activesupport (5.1.1) + activesupport (5.1.2) 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) - airbrussh (1.2.0) + airbrussh (1.3.0) sshkit (>= 1.6.1, != 1.7.0) annotate (2.7.2) activerecord (>= 3.2, < 6.0) @@ -52,13 +57,13 @@ GEM encryptor (~> 3.0.0) av (0.9.0) cocaine (~> 0.5.3) - aws-sdk (2.9.37) - aws-sdk-resources (= 2.9.37) - aws-sdk-core (2.9.37) + aws-sdk (2.10.6) + aws-sdk-resources (= 2.10.6) + aws-sdk-core (2.10.6) aws-sigv4 (~> 1.0) jmespath (~> 1.0) - aws-sdk-resources (2.9.37) - aws-sdk-core (= 2.9.37) + aws-sdk-resources (2.10.6) + aws-sdk-core (= 2.10.6) aws-sigv4 (1.0.0) bcrypt (3.1.11) better_errors (2.1.1) @@ -67,9 +72,10 @@ GEM rack (>= 0.9.0) binding_of_caller (0.7.2) debug_inspector (>= 0.0.1) - bootsnap (1.0.0) + bootsnap (1.1.1) msgpack (~> 1.0) brakeman (3.6.2) + browser (2.4.0) builder (3.2.3) bullet (5.5.1) activesupport (>= 3.0.0) @@ -77,7 +83,7 @@ GEM bundler-audit (0.5.0) bundler (~> 1.2) thor (~> 0.18) - capistrano (3.8.1) + capistrano (3.8.2) airbrussh (>= 1.0.0) i18n rake (>= 10.0.0) @@ -93,15 +99,18 @@ GEM sshkit (~> 1.3) capistrano-yarn (2.0.2) capistrano (~> 3.0) - capybara (2.14.2) + capybara (2.14.4) addressable mime-types (>= 1.16) nokogiri (>= 1.3.3) rack (>= 1.0.0) rack-test (>= 0.5.4) xpath (~> 2.0) + case_transform (0.2) + activesupport + charlock_holmes (0.7.3) chunky_png (1.3.8) - cld3 (3.1.2) + cld3 (3.1.3) ffi (>= 1.1.0, < 1.10.0) climate_control (0.2.0) cocaine (0.5.8) @@ -141,9 +150,9 @@ GEM thread thread_safe encryptor (3.0.0) - erubi (1.6.0) + erubi (1.6.1) erubis (2.7.0) - et-orbi (1.0.4) + et-orbi (1.0.5) tzinfo execjs (2.7.0) fabrication (2.16.1) @@ -156,11 +165,12 @@ GEM ruby-progressbar (~> 1.4) globalid (0.4.0) activesupport (>= 4.2.0) - goldfinger (1.2.0) - addressable (~> 2.4) - http (~> 2.0) - nokogiri (~> 1.6) - hamlit (2.8.1) + goldfinger (2.0.0) + addressable (~> 2.5) + http (~> 2.2) + nokogiri (~> 1.8) + oj (~> 3.0) + hamlit (2.8.4) temple (>= 0.8.0) thor tilt @@ -172,6 +182,7 @@ GEM hashdiff (0.3.4) highline (1.7.8) hiredis (0.6.1) + hkdf (0.3.0) htmlentities (4.3.4) http (2.2.2) addressable (~> 2.3) @@ -181,9 +192,9 @@ GEM http-cookie (1.0.3) domain_name (~> 0.5) http-form_data (1.0.3) - http_accept_language (2.1.0) + http_accept_language (2.1.1) http_parser.rb (0.6.0) - httplog (0.99.3) + httplog (0.99.4) colorize rack i18n (0.8.4) @@ -197,8 +208,11 @@ GEM parser (>= 2.2.3.0) rainbow (~> 2.2) terminal-table (>= 1.5.1) + idn-ruby (0.1.0) jmespath (1.3.1) json (2.1.0) + jsonapi-renderer (0.1.2) + jwt (1.5.6) kaminari (1.0.1) activesupport (>= 4.1.0) kaminari-actionview (= 1.0.1) @@ -228,8 +242,10 @@ GEM nokogiri (>= 1.5.9) mail (2.6.6) mime-types (>= 1.16, < 4) + mario-redis-lock (1.2.0) + redis (~> 3, >= 3.0.5) method_source (0.8.2) - microformats2 (3.1.0) + microformats (4.0.7) json nokogiri mime-types (3.1) @@ -248,8 +264,8 @@ GEM mini_portile2 (~> 2.2.0) nokogumbo (1.4.13) nokogiri - oj (3.1.0) - openssl (2.0.3) + oj (3.2.0) + openssl (2.0.4) orm_adapter (0.5.0) ostatus2 (2.0.1) addressable (~> 2.4) @@ -271,7 +287,7 @@ GEM parallel parser (2.4.0.0) ast (~> 2.2) - pg (0.20.0) + pg (0.21.0) pghero (1.7.0) activerecord pkg-config (1.2.3) @@ -297,17 +313,17 @@ GEM rack-test (0.6.3) rack (>= 1.0) rack-timeout (0.4.2) - rails (5.1.1) - actioncable (= 5.1.1) - actionmailer (= 5.1.1) - actionpack (= 5.1.1) - actionview (= 5.1.1) - activejob (= 5.1.1) - activemodel (= 5.1.1) - activerecord (= 5.1.1) - activesupport (= 5.1.1) + 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.1) + railties (= 5.1.2) sprockets-rails (>= 2.0.0) rails-controller-testing (1.0.2) actionpack (~> 5.x, >= 5.0.1) @@ -323,9 +339,9 @@ GEM railties (~> 5.0) rails-settings-cached (0.6.5) rails (>= 4.2.0) - railties (5.1.1) - actionpack (= 5.1.1) - activesupport (= 5.1.1) + railties (5.1.2) + actionpack (= 5.1.2) + activesupport (= 5.1.2) method_source rake (>= 0.8.7) thor (>= 0.18.1, < 2.0) @@ -373,7 +389,7 @@ GEM rspec-expectations (~> 3.6.0) rspec-mocks (~> 3.6.0) rspec-support (~> 3.6.0) - rspec-sidekiq (3.0.1) + rspec-sidekiq (3.0.3) rspec-core (~> 3.0, >= 3.0.0) sidekiq (>= 2.4.0) rspec-support (3.6.0) @@ -394,10 +410,10 @@ GEM nokogiri (>= 1.4.4) nokogumbo (~> 1.4.1) sass (3.4.24) - scss_lint (0.53.0) + scss_lint (0.54.0) rake (>= 0.9, < 13) sass (~> 3.4.20) - sidekiq (5.0.2) + sidekiq (5.0.3) concurrent-ruby (~> 1.0) connection_pool (~> 2.2, >= 2.2.0) rack-protection (>= 1.5.0) @@ -405,7 +421,7 @@ GEM sidekiq-bulk (0.1.1) activesupport sidekiq - sidekiq-scheduler (2.1.5) + sidekiq-scheduler (2.1.7) redis (~> 3) rufus-scheduler (~> 3.2) sidekiq (>= 3) @@ -442,7 +458,7 @@ GEM thread (0.2.2) thread_safe (0.3.6) tilt (2.0.7) - twitter-text (1.14.5) + twitter-text (1.14.6) unf (~> 0.1.0) tzinfo (1.2.3) thread_safe (~> 0.1) @@ -453,7 +469,7 @@ GEM unf (0.1.4) unf_ext unf_ext (0.0.7.4) - unicode-display_width (1.2.1) + unicode-display_width (1.3.0) uniform_notifier (1.10.0) warden (1.2.7) rack (>= 1.0) @@ -461,10 +477,13 @@ GEM addressable (>= 2.3.6) crack (>= 0.3.2) hashdiff - webpacker (1.2) + webpacker (2.0) activesupport (>= 4.2) multi_json (~> 1.2) railties (>= 4.2) + webpush (0.3.2) + hkdf (~> 0.2) + jwt websocket-driver (0.6.5) websocket-extensions (>= 0.1.0) websocket-extensions (0.1.2) @@ -475,6 +494,7 @@ PLATFORMS ruby DEPENDENCIES + active_model_serializers (~> 0.10) active_record_query_trace (~> 1.5) addressable (~> 2.5) annotate (~> 2.7) @@ -483,6 +503,7 @@ DEPENDENCIES binding_of_caller (~> 0.7) bootsnap brakeman (~> 3.6) + browser bullet (~> 5.5) bundler-audit (~> 0.5) capistrano (~> 3.8) @@ -490,6 +511,7 @@ DEPENDENCIES capistrano-rbenv (~> 2.1) capistrano-yarn (~> 2.0) capybara (~> 2.14) + charlock_holmes (~> 0.7.3) cld3 (~> 3.1) climate_control (~> 0.2) devise (~> 4.2) @@ -500,7 +522,7 @@ DEPENDENCIES faker (~> 1.7) fast_blank (~> 1.0) fuubar (~> 2.2) - goldfinger (~> 1.2) + goldfinger (~> 2.0) hamlit-rails (~> 0.2) hiredis (~> 0.6) htmlentities (~> 4.3) @@ -508,12 +530,15 @@ DEPENDENCIES http_accept_language (~> 2.1) httplog (~> 0.99) i18n-tasks (~> 0.9) + idn-ruby kaminari (~> 1.0) letter_opener (~> 1.4) letter_opener_web (~> 1.3) link_header (~> 0.0) lograge (~> 0.5) - microformats2 (~> 3.0) + mario-redis-lock (~> 1.2) + microformats (~> 4.0) + mime-types (~> 3.1) nokogiri (~> 1.7) oj (~> 3.0) ostatus2 (~> 2.0) @@ -558,10 +583,11 @@ DEPENDENCIES tzinfo-data (~> 1.2017) uglifier (~> 3.2) webmock (~> 3.0) - webpacker (~> 1.2) + webpacker (~> 2.0) + webpush RUBY VERSION ruby 2.4.1p111 BUNDLED WITH - 1.15.1 + 1.15.2 diff --git a/README.md b/README.md index 8b902b7b4..13e580e3f 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -Mastodon +![Mastodon](https://i.imgur.com/NhZc40l.png) ======== [![Build Status](http://img.shields.io/travis/tootsuite/mastodon.svg)][travis] @@ -9,7 +9,7 @@ Mastodon Mastodon is a free, open-source social network server. A decentralized solution to commercial platforms, it avoids the risks of a single company monopolizing your communication. Anyone can run Mastodon and participate in the social network seamlessly. -An alternative implementation of the GNU social project. Based on [ActivityStreams](https://en.wikipedia.org/wiki/Activity_Streams_(format)), [Webfinger](https://en.wikipedia.org/wiki/WebFinger), [PubsubHubbub](https://en.wikipedia.org/wiki/PubSubHubbub) and [Salmon](https://en.wikipedia.org/wiki/Salmon_(protocol)). +An alternative implementation of the GNU social project. Based on [ActivityStreams](https://en.wikipedia.org/wiki/Activity_Streams_(format)), [Webfinger](https://en.wikipedia.org/wiki/WebFinger), [WebSub](https://en.wikipedia.org/wiki/WebSub) and [Salmon](https://en.wikipedia.org/wiki/Salmon_(protocol)). Click on the screenshot to watch a demo of the UI: @@ -34,7 +34,7 @@ If you would like, you can [support the development of this project on Patreon][ ## Features - **Fully interoperable with GNU social and any OStatus platform** - Whatever implements Atom feeds, ActivityStreams, Salmon, PubSubHubbub and Webfinger is part of the network + Whatever implements Atom feeds, ActivityStreams, Salmon, WebSub and Webfinger is part of the network - **Real-time timeline updates** See the updates of people you're following appear in real-time in the UI via WebSockets - **Federated thread resolving** diff --git a/Vagrantfile b/Vagrantfile index f2302b24f..0c21bed68 100644 --- a/Vagrantfile +++ b/Vagrantfile @@ -35,6 +35,8 @@ sudo apt-get install \ postgresql-contrib \ protobuf-compiler \ yarn \ + libicu-dev \ + libidn11-dev \ libprotobuf-dev \ libreadline-dev \ -y @@ -42,9 +44,12 @@ sudo apt-get install \ # Install rvm read RUBY_VERSION < .ruby-version gpg --keyserver hkp://keys.gnupg.net --recv-keys 409B6B1796C275462A1703113804BB82D39DC0E3 -curl -sSL https://get.rvm.io | bash -s stable --ruby=$RUBY_VERSION +curl -sSL https://raw.githubusercontent.com/rvm/rvm/stable/binscripts/rvm-installer | bash -s stable --ruby=$RUBY_VERSION source /home/vagrant/.rvm/scripts/rvm +# Install Ruby +rvm install ruby-$RUBY_VERSION + # Configure database sudo -u postgres createuser -U postgres vagrant -s sudo -u postgres createdb -U postgres mastodon_development diff --git a/app.json b/app.json index 049f63a9e..a935b8232 100644 --- a/app.json +++ b/app.json @@ -2,7 +2,7 @@ "name": "Mastodon", "description": "A GNU Social-compatible microblogging server", "repository": "https://github.com/tootsuite/mastodon", - "logo": "https://github.com/tootsuite/mastodon/raw/master/app/assets/images/logo.png", + "logo": "https://github.com/tootsuite/mastodon/raw/master/app/javascript/images/logo.svg", "env": { "HEROKU": { "description": "Leave this as true", diff --git a/app/controllers/about_controller.rb b/app/controllers/about_controller.rb index 04e7ddacf..47690e81e 100644 --- a/app/controllers/about_controller.rb +++ b/app/controllers/about_controller.rb @@ -2,9 +2,12 @@ class AboutController < ApplicationController before_action :set_body_classes - before_action :set_instance_presenter, only: [:show, :more] + before_action :set_instance_presenter, only: [:show, :more, :terms] - def show; end + def show + serializable_resource = ActiveModelSerializers::SerializableResource.new(InitialStatePresenter.new(initial_state_params), serializer: InitialStateSerializer) + @initial_state_json = serializable_resource.to_json + end def more; end @@ -15,6 +18,7 @@ class AboutController < ApplicationController def new_user User.new.tap(&:build_account) end + helper_method :new_user def set_instance_presenter @@ -24,4 +28,11 @@ class AboutController < ApplicationController def set_body_classes @body_classes = 'about-body' end + + def initial_state_params + { + settings: {}, + token: current_session&.token, + } + end end diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb index 11402ab79..c270eb000 100644 --- a/app/controllers/accounts_controller.rb +++ b/app/controllers/accounts_controller.rb @@ -2,6 +2,7 @@ class AccountsController < ApplicationController include AccountControllerConcern + include SignatureVerification def show respond_to do |format| @@ -12,10 +13,12 @@ class AccountsController < ApplicationController 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: AtomSerializer.render(AtomSerializer.new.feed(@account, @entries.to_a)) + render xml: OStatus::AtomSerializer.render(OStatus::AtomSerializer.new.feed(@account, @entries.to_a)) end - format.activitystreams2 + format.json do + render json: @account, serializer: ActivityPub::ActorSerializer, adapter: ActivityPub::Adapter + end end end diff --git a/app/controllers/activitypub/outboxes_controller.rb b/app/controllers/activitypub/outboxes_controller.rb new file mode 100644 index 000000000..30b91f370 --- /dev/null +++ b/app/controllers/activitypub/outboxes_controller.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +class ActivityPub::OutboxesController < Api::BaseController + before_action :set_account + + def show + @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 + end + + private + + def set_account + @account = Account.find_local!(params[:account_username]) + end + + def outbox_presenter + ActivityPub::CollectionPresenter.new( + id: account_outbox_url(@account), + type: :ordered, + size: @account.statuses_count, + items: @statuses + ) + end +end diff --git a/app/controllers/admin/accounts_controller.rb b/app/controllers/admin/accounts_controller.rb index ef2f8c4c2..7bceee2cd 100644 --- a/app/controllers/admin/accounts_controller.rb +++ b/app/controllers/admin/accounts_controller.rb @@ -22,8 +22,8 @@ module Admin end def redownload - @account.avatar = @account.avatar_remote_url - @account.header = @account.header_remote_url + @account.reset_avatar! + @account.reset_header! @account.save! redirect_to admin_account_path(@account.id) diff --git a/app/controllers/admin/instances_controller.rb b/app/controllers/admin/instances_controller.rb index ac93248a8..3296e08db 100644 --- a/app/controllers/admin/instances_controller.rb +++ b/app/controllers/admin/instances_controller.rb @@ -6,15 +6,26 @@ module Admin @instances = ordered_instances end + def resubscribe + params.require(:by_domain) + Pubsubhubbub::SubscribeWorker.push_bulk(subscribeable_accounts.pluck(:id)) + redirect_to admin_instances_path + end + private def paginated_instances Account.remote.by_domain_accounts.page(params[:page]) end + helper_method :paginated_instances def ordered_instances paginated_instances.map { |account| Instance.new(account) } end + + def subscribeable_accounts + Account.with_followers.remote.where(domain: params[:by_domain]) + end end end diff --git a/app/controllers/admin/reported_statuses_controller.rb b/app/controllers/admin/reported_statuses_controller.rb index 32434d30f..5a31adecf 100644 --- a/app/controllers/admin/reported_statuses_controller.rb +++ b/app/controllers/admin/reported_statuses_controller.rb @@ -5,7 +5,14 @@ module Admin include Authorization before_action :set_report - before_action :set_status + before_action :set_status, only: [:update, :destroy] + + def create + @form = Form::StatusBatch.new(form_status_batch_params) + flash[:alert] = t('admin.statuses.failed_to_execute') unless @form.save + + redirect_to admin_report_path(@report) + end def update @status.update(status_params) @@ -15,7 +22,7 @@ module Admin def destroy authorize @status, :destroy? RemovalWorker.perform_async(@status.id) - redirect_to admin_report_path(@report) + render json: @status end private @@ -24,6 +31,10 @@ module Admin params.require(:status).permit(:sensitive) end + def form_status_batch_params + params.require(:form_status_batch).permit(:action, status_ids: []) + end + def set_report @report = Report.find(params[:report_id]) end diff --git a/app/controllers/admin/reports_controller.rb b/app/controllers/admin/reports_controller.rb index 2d8c3c820..226467739 100644 --- a/app/controllers/admin/reports_controller.rb +++ b/app/controllers/admin/reports_controller.rb @@ -8,7 +8,9 @@ module Admin @reports = filtered_reports.page(params[:page]) end - def show; end + def show + @form = Form::StatusBatch.new + end def update process_report diff --git a/app/controllers/admin/settings_controller.rb b/app/controllers/admin/settings_controller.rb index fcd42c79c..5985d6282 100644 --- a/app/controllers/admin/settings_controller.rb +++ b/app/controllers/admin/settings_controller.rb @@ -8,13 +8,21 @@ module Admin site_title site_description site_extended_description + site_terms open_registrations closed_registrations_message + open_deletion + timeline_preview + ).freeze + + BOOLEAN_SETTINGS = %w( + open_registrations + open_deletion + timeline_preview ).freeze - BOOLEAN_SETTINGS = %w(open_registrations).freeze def edit - @settings = Setting.all_as_records + @admin_settings = Form::AdminSettings.new end def update @@ -23,19 +31,19 @@ module Admin setting.update(value: value_for_update(key, value)) end - flash[:notice] = 'Success!' + flash[:notice] = I18n.t('generic.changes_saved_msg') redirect_to edit_admin_settings_path end private def settings_params - params.permit(ADMIN_SETTINGS) + params.require(:form_admin_settings).permit(ADMIN_SETTINGS) end def value_for_update(key, value) if BOOLEAN_SETTINGS.include?(key) - value == 'true' + value == '1' else value end diff --git a/app/controllers/admin/statuses_controller.rb b/app/controllers/admin/statuses_controller.rb new file mode 100644 index 000000000..50712f0dd --- /dev/null +++ b/app/controllers/admin/statuses_controller.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +module Admin + class StatusesController < BaseController + include Authorization + + helper_method :current_params + + before_action :set_account + before_action :set_status, only: [:update, :destroy] + + PAR_PAGE = 20 + + def index + @statuses = @account.statuses + if params[:media] + 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) + + @form = Form::StatusBatch.new + end + + def create + @form = Form::StatusBatch.new(form_status_batch_params) + flash[:alert] = t('admin.statuses.failed_to_execute') unless @form.save + + redirect_to admin_account_statuses_path(@account.id, current_params) + end + + def update + @status.update(status_params) + redirect_to admin_account_statuses_path(@account.id, current_params) + end + + def destroy + authorize @status, :destroy? + RemovalWorker.perform_async(@status.id) + render json: @status + end + + private + + def status_params + params.require(:status).permit(:sensitive) + end + + def form_status_batch_params + params.require(:form_status_batch).permit(:action, status_ids: []) + end + + def set_status + @status = @account.statuses.find(params[:id]) + end + + def set_account + @account = Account.find(params[:account_id]) + end + + def current_params + page = (params[:page] || 1).to_i + { + media: params[:media], + page: page > 1 && page, + }.select { |_, value| value.present? } + end + end +end diff --git a/app/controllers/api/activitypub/activities_controller.rb b/app/controllers/api/activitypub/activities_controller.rb deleted file mode 100644 index 740c8589a..000000000 --- a/app/controllers/api/activitypub/activities_controller.rb +++ /dev/null @@ -1,27 +0,0 @@ -# frozen_string_literal: true - -class Api::Activitypub::ActivitiesController < Api::BaseController - include Authorization - - # before_action :set_follow, only: [:show_follow] - before_action :set_status, only: [:show_status] - - respond_to :activitystreams2 - - # Show a status in AS2 format, as either an Announce (reblog) or a Create (post) activity. - def show_status - authorize @status, :show? - - if @status.reblog? - render :show_status_announce - else - render :show_status_create - end - end - - private - - def set_status - @status = Status.find(params[:id]) - end -end diff --git a/app/controllers/api/activitypub/notes_controller.rb b/app/controllers/api/activitypub/notes_controller.rb deleted file mode 100644 index 783c1c4ed..000000000 --- a/app/controllers/api/activitypub/notes_controller.rb +++ /dev/null @@ -1,19 +0,0 @@ -# frozen_string_literal: true - -class Api::Activitypub::NotesController < Api::BaseController - include Authorization - - before_action :set_status - - respond_to :activitystreams2 - - def show - authorize @status, :show? - end - - private - - def set_status - @status = Status.find(params[:id]) - end -end diff --git a/app/controllers/api/activitypub/outbox_controller.rb b/app/controllers/api/activitypub/outbox_controller.rb deleted file mode 100644 index 0738d7dee..000000000 --- a/app/controllers/api/activitypub/outbox_controller.rb +++ /dev/null @@ -1,69 +0,0 @@ -# frozen_string_literal: true - -class Api::Activitypub::OutboxController < Api::BaseController - before_action :set_account - - respond_to :activitystreams2 - - def show - if params[:max_id] || params[:since_id] - show_outbox_page - else - show_base_outbox - end - end - - private - - def show_base_outbox - @statuses = Status.as_outbox_timeline(@account) - @statuses = cache_collection(@statuses) - - set_maps(@statuses) - - set_first_last_page(@statuses) - - render :show - end - - def show_outbox_page - all_statuses = Status.as_outbox_timeline(@account) - @statuses = all_statuses.paginate_by_max_id(limit_param(DEFAULT_STATUSES_LIMIT), params[:max_id], params[:since_id]) - - all_statuses = cache_collection(all_statuses) - @statuses = cache_collection(@statuses) - - set_maps(@statuses) - - set_first_last_page(all_statuses) - - @next_page_url = api_activitypub_outbox_url(pagination_params(max_id: @statuses.last.id)) unless @statuses.empty? - @prev_page_url = api_activitypub_outbox_url(pagination_params(since_id: @statuses.first.id)) unless @statuses.empty? - - @paginated = @next_page_url || @prev_page_url - @part_of_url = api_activitypub_outbox_url - - set_pagination_headers(@next_page_url, @prev_page_url) - - render :show_page - end - - def cache_collection(raw) - super(raw, Status) - end - - def set_account - @account = Account.find(params[:id]) - end - - def set_first_last_page(statuses) # rubocop:disable Style/AccessorMethodName - return if statuses.empty? - - @first_page_url = api_activitypub_outbox_url(max_id: statuses.first.id + 1) - @last_page_url = api_activitypub_outbox_url(since_id: statuses.last.id - 1) - end - - def pagination_params(core_params) - params.permit(:local, :limit).merge(core_params) - end -end diff --git a/app/controllers/api/base_controller.rb b/app/controllers/api/base_controller.rb index c1b2ec3cf..105a2859d 100644 --- a/app/controllers/api/base_controller.rb +++ b/app/controllers/api/base_controller.rb @@ -17,11 +17,7 @@ class Api::BaseController < ApplicationController render json: { error: 'Record not found' }, status: 404 end - rescue_from Goldfinger::Error do - render json: { error: 'Remote account could not be resolved' }, status: 422 - end - - rescue_from HTTP::Error do + rescue_from HTTP::Error, Mastodon::UnexpectedResponseError do render json: { error: 'Remote data could not be fetched' }, status: 503 end diff --git a/app/controllers/api/oembed_controller.rb b/app/controllers/api/oembed_controller.rb index 6e3e34d96..f8c87dd16 100644 --- a/app/controllers/api/oembed_controller.rb +++ b/app/controllers/api/oembed_controller.rb @@ -5,8 +5,7 @@ class Api::OEmbedController < Api::BaseController def show @stream_entry = find_stream_entry.stream_entry - @width = maxwidth_or_default - @height = maxheight_or_default + render json: @stream_entry, serializer: OEmbedSerializer, width: maxwidth_or_default, height: maxheight_or_default end private diff --git a/app/controllers/api/push_controller.rb b/app/controllers/api/push_controller.rb index 951867140..e04d19125 100644 --- a/app/controllers/api/push_controller.rb +++ b/app/controllers/api/push_controller.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class Api::PushController < Api::BaseController + include SignatureVerification + def update response, status = process_push_request render plain: response, status: status @@ -11,7 +13,7 @@ class Api::PushController < Api::BaseController def process_push_request case hub_mode when 'subscribe' - Pubsubhubbub::SubscribeService.new.call(account_from_topic, hub_callback, hub_secret, hub_lease_seconds) + Pubsubhubbub::SubscribeService.new.call(account_from_topic, hub_callback, hub_secret, hub_lease_seconds, verified_domain) when 'unsubscribe' Pubsubhubbub::UnsubscribeService.new.call(account_from_topic, hub_callback) else @@ -57,6 +59,10 @@ class Api::PushController < Api::BaseController TagManager.instance.web_domain?(hub_topic_domain) end + def verified_domain + return signed_request_account.domain if signed_request_account + end + def hub_topic_domain hub_topic_uri.host + (hub_topic_uri.port ? ":#{hub_topic_uri.port}" : '') end diff --git a/app/controllers/api/subscriptions_controller.rb b/app/controllers/api/subscriptions_controller.rb index d3ea98676..89007f3d6 100644 --- a/app/controllers/api/subscriptions_controller.rb +++ b/app/controllers/api/subscriptions_controller.rb @@ -42,7 +42,7 @@ class Api::SubscriptionsController < Api::BaseController end def lease_seconds_or_default - (params['hub.lease_seconds'] || 86_400).to_i.seconds + (params['hub.lease_seconds'] || 1.day).to_i.seconds end def set_account diff --git a/app/controllers/api/v1/accounts/credentials_controller.rb b/app/controllers/api/v1/accounts/credentials_controller.rb index 1cf52ff10..073808532 100644 --- a/app/controllers/api/v1/accounts/credentials_controller.rb +++ b/app/controllers/api/v1/accounts/credentials_controller.rb @@ -6,13 +6,13 @@ class Api::V1::Accounts::CredentialsController < Api::BaseController def show @account = current_account - render 'api/v1/accounts/show' + render json: @account, serializer: REST::CredentialAccountSerializer end def update current_account.update!(account_params) @account = current_account - render 'api/v1/accounts/show' + render json: @account, serializer: REST::CredentialAccountSerializer end private diff --git a/app/controllers/api/v1/accounts/follower_accounts_controller.rb b/app/controllers/api/v1/accounts/follower_accounts_controller.rb index 81aae56d3..80b0bef40 100644 --- a/app/controllers/api/v1/accounts/follower_accounts_controller.rb +++ b/app/controllers/api/v1/accounts/follower_accounts_controller.rb @@ -9,7 +9,7 @@ class Api::V1::Accounts::FollowerAccountsController < Api::BaseController def index @accounts = load_accounts - render 'api/v1/accounts/index' + render json: @accounts, each_serializer: REST::AccountSerializer end private diff --git a/app/controllers/api/v1/accounts/following_accounts_controller.rb b/app/controllers/api/v1/accounts/following_accounts_controller.rb index 63c6d54b2..55cffdf37 100644 --- a/app/controllers/api/v1/accounts/following_accounts_controller.rb +++ b/app/controllers/api/v1/accounts/following_accounts_controller.rb @@ -9,7 +9,7 @@ class Api::V1::Accounts::FollowingAccountsController < Api::BaseController def index @accounts = load_accounts - render 'api/v1/accounts/index' + render json: @accounts, each_serializer: REST::AccountSerializer end private diff --git a/app/controllers/api/v1/accounts/relationships_controller.rb b/app/controllers/api/v1/accounts/relationships_controller.rb index cb923ab91..a88cf2021 100644 --- a/app/controllers/api/v1/accounts/relationships_controller.rb +++ b/app/controllers/api/v1/accounts/relationships_controller.rb @@ -8,16 +8,15 @@ class Api::V1::Accounts::RelationshipsController < Api::BaseController def index @accounts = Account.where(id: account_ids).select('id') - @following = Account.following_map(account_ids, current_user.account_id) - @followed_by = Account.followed_by_map(account_ids, current_user.account_id) - @blocking = Account.blocking_map(account_ids, current_user.account_id) - @muting = Account.muting_map(account_ids, current_user.account_id) - @requested = Account.requested_map(account_ids, current_user.account_id) - @domain_blocking = Account.domain_blocking_map(account_ids, current_user.account_id) + render json: @accounts, each_serializer: REST::RelationshipSerializer, relationships: relationships end private + def relationships + AccountRelationshipsPresenter.new(@accounts, current_user.account_id) + end + def account_ids @_account_ids ||= Array(params[:id]).map(&:to_i) end diff --git a/app/controllers/api/v1/accounts/search_controller.rb b/app/controllers/api/v1/accounts/search_controller.rb index c4a8f97f2..2a5cac547 100644 --- a/app/controllers/api/v1/accounts/search_controller.rb +++ b/app/controllers/api/v1/accounts/search_controller.rb @@ -8,8 +8,7 @@ class Api::V1::Accounts::SearchController < Api::BaseController def show @accounts = account_search - - render 'api/v1/accounts/index' + render json: @accounts, each_serializer: REST::AccountSerializer end private diff --git a/app/controllers/api/v1/accounts/statuses_controller.rb b/app/controllers/api/v1/accounts/statuses_controller.rb index 504ed8c07..d9ae5c089 100644 --- a/app/controllers/api/v1/accounts/statuses_controller.rb +++ b/app/controllers/api/v1/accounts/statuses_controller.rb @@ -9,6 +9,7 @@ class Api::V1::Accounts::StatusesController < Api::BaseController def index @statuses = load_statuses + render json: @statuses, each_serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id) end private @@ -18,9 +19,7 @@ class Api::V1::Accounts::StatusesController < Api::BaseController end def load_statuses - cached_account_statuses.tap do |statuses| - set_maps(statuses) - end + cached_account_statuses end def cached_account_statuses diff --git a/app/controllers/api/v1/accounts_controller.rb b/app/controllers/api/v1/accounts_controller.rb index 8fc0dd36f..f621aa245 100644 --- a/app/controllers/api/v1/accounts_controller.rb +++ b/app/controllers/api/v1/accounts_controller.rb @@ -8,49 +8,38 @@ class Api::V1::AccountsController < Api::BaseController respond_to :json - def show; end + def show + render json: @account, serializer: REST::AccountSerializer + end def follow FollowService.new.call(current_user.account, @account.acct) - set_relationship - render :relationship + render json: @account, serializer: REST::RelationshipSerializer, relationships: relationships end def block BlockService.new.call(current_user.account, @account) - - @following = { @account.id => false } - @followed_by = { @account.id => false } - @blocking = { @account.id => true } - @requested = { @account.id => false } - @muting = { @account.id => current_account.muting?(@account.id) } - @domain_blocking = { @account.id => current_account.domain_blocking?(@account.domain) } - - render :relationship + render json: @account, serializer: REST::RelationshipSerializer, relationships: relationships end def mute MuteService.new.call(current_user.account, @account) - set_relationship - render :relationship + render json: @account, serializer: REST::RelationshipSerializer, relationships: relationships end def unfollow UnfollowService.new.call(current_user.account, @account) - set_relationship - render :relationship + render json: @account, serializer: REST::RelationshipSerializer, relationships: relationships end def unblock UnblockService.new.call(current_user.account, @account) - set_relationship - render :relationship + render json: @account, serializer: REST::RelationshipSerializer, relationships: relationships end def unmute UnmuteService.new.call(current_user.account, @account) - set_relationship - render :relationship + render json: @account, serializer: REST::RelationshipSerializer, relationships: relationships end private @@ -59,12 +48,7 @@ class Api::V1::AccountsController < Api::BaseController @account = Account.find(params[:id]) end - def set_relationship - @following = Account.following_map([@account.id], current_user.account_id) - @followed_by = Account.followed_by_map([@account.id], current_user.account_id) - @blocking = Account.blocking_map([@account.id], current_user.account_id) - @muting = Account.muting_map([@account.id], current_user.account_id) - @requested = Account.requested_map([@account.id], current_user.account_id) - @domain_blocking = Account.domain_blocking_map([@account.id], current_user.account_id) + def relationships + AccountRelationshipsPresenter.new([@account.id], current_user.account_id) end end diff --git a/app/controllers/api/v1/apps_controller.rb b/app/controllers/api/v1/apps_controller.rb index 98e908948..44a27b20a 100644 --- a/app/controllers/api/v1/apps_controller.rb +++ b/app/controllers/api/v1/apps_controller.rb @@ -5,6 +5,7 @@ class Api::V1::AppsController < Api::BaseController def create @app = Doorkeeper::Application.create!(application_options) + render json: @app, serializer: REST::ApplicationSerializer end private diff --git a/app/controllers/api/v1/blocks_controller.rb b/app/controllers/api/v1/blocks_controller.rb index 1702953cf..a412e4341 100644 --- a/app/controllers/api/v1/blocks_controller.rb +++ b/app/controllers/api/v1/blocks_controller.rb @@ -9,6 +9,7 @@ class Api::V1::BlocksController < Api::BaseController def index @accounts = load_accounts + render json: @accounts, each_serializer: REST::AccountSerializer end private diff --git a/app/controllers/api/v1/favourites_controller.rb b/app/controllers/api/v1/favourites_controller.rb index fe0819a3f..9d73bb337 100644 --- a/app/controllers/api/v1/favourites_controller.rb +++ b/app/controllers/api/v1/favourites_controller.rb @@ -9,21 +9,18 @@ class Api::V1::FavouritesController < Api::BaseController def index @statuses = load_statuses + render json: @statuses, each_serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id) end private def load_statuses - cached_favourites.tap do |statuses| - set_maps(statuses) - end + cached_favourites end def cached_favourites cache_collection( - Status.where( - id: results.map(&:status_id) - ), + Status.reorder(nil).joins(:favourites).merge(results), Status ) end diff --git a/app/controllers/api/v1/follow_requests_controller.rb b/app/controllers/api/v1/follow_requests_controller.rb index eed22ef4f..b9f50d784 100644 --- a/app/controllers/api/v1/follow_requests_controller.rb +++ b/app/controllers/api/v1/follow_requests_controller.rb @@ -7,6 +7,7 @@ class Api::V1::FollowRequestsController < Api::BaseController def index @accounts = load_accounts + render json: @accounts, each_serializer: REST::AccountSerializer end def authorize diff --git a/app/controllers/api/v1/follows_controller.rb b/app/controllers/api/v1/follows_controller.rb index bcdb4e177..e01ae5c01 100644 --- a/app/controllers/api/v1/follows_controller.rb +++ b/app/controllers/api/v1/follows_controller.rb @@ -10,7 +10,7 @@ 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) - render :show + render json: @account, serializer: REST::AccountSerializer end private diff --git a/app/controllers/api/v1/instances_controller.rb b/app/controllers/api/v1/instances_controller.rb index ce2181879..1c6971c18 100644 --- a/app/controllers/api/v1/instances_controller.rb +++ b/app/controllers/api/v1/instances_controller.rb @@ -3,5 +3,7 @@ class Api::V1::InstancesController < Api::BaseController respond_to :json - def show; end + def show + render json: {}, serializer: REST::InstanceSerializer + end end diff --git a/app/controllers/api/v1/media_controller.rb b/app/controllers/api/v1/media_controller.rb index 25a331319..8a1992fca 100644 --- a/app/controllers/api/v1/media_controller.rb +++ b/app/controllers/api/v1/media_controller.rb @@ -11,6 +11,7 @@ class Api::V1::MediaController < Api::BaseController def create @media = current_account.media_attachments.create!(file: media_params[:file]) + render json: @media, serializer: REST::MediaAttachmentSerializer rescue Paperclip::Errors::NotIdentifiedByImageMagickError render json: file_type_error, status: 422 rescue Paperclip::Error diff --git a/app/controllers/api/v1/mutes_controller.rb b/app/controllers/api/v1/mutes_controller.rb index 2a353df03..0c43cb943 100644 --- a/app/controllers/api/v1/mutes_controller.rb +++ b/app/controllers/api/v1/mutes_controller.rb @@ -9,6 +9,7 @@ class Api::V1::MutesController < Api::BaseController def index @accounts = load_accounts + render json: @accounts, each_serializer: REST::AccountSerializer end private diff --git a/app/controllers/api/v1/notifications_controller.rb b/app/controllers/api/v1/notifications_controller.rb index a28e99f2f..8910b77e9 100644 --- a/app/controllers/api/v1/notifications_controller.rb +++ b/app/controllers/api/v1/notifications_controller.rb @@ -11,11 +11,12 @@ class Api::V1::NotificationsController < Api::BaseController def index @notifications = load_notifications - set_maps_for_notification_target_statuses + render json: @notifications, each_serializer: REST::NotificationSerializer, relationships: StatusRelationshipsPresenter.new(target_statuses_from_notifications, current_user&.account_id) end def show @notification = current_account.notifications.find(params[:id]) + render json: @notification, serializer: REST::NotificationSerializer end def clear @@ -46,10 +47,6 @@ class Api::V1::NotificationsController < Api::BaseController current_account.notifications.browserable(exclude_types) end - def set_maps_for_notification_target_statuses - set_maps target_statuses_from_notifications - end - def target_statuses_from_notifications @notifications.reject { |notification| notification.target_status.nil? }.map(&:target_status) end diff --git a/app/controllers/api/v1/reports_controller.rb b/app/controllers/api/v1/reports_controller.rb index 71df76e92..9592cd4bd 100644 --- a/app/controllers/api/v1/reports_controller.rb +++ b/app/controllers/api/v1/reports_controller.rb @@ -9,6 +9,7 @@ class Api::V1::ReportsController < Api::BaseController def index @reports = current_account.reports + render json: @reports, each_serializer: REST::ReportSerializer end def create @@ -17,7 +18,10 @@ class Api::V1::ReportsController < Api::BaseController status_ids: reported_status_ids, comment: report_params[:comment] ) - render :show + + User.admins.includes(:account).each { |u| AdminMailer.new_report(u.account, @report).deliver_later } + + render json: @report, serializer: REST::ReportSerializer end private diff --git a/app/controllers/api/v1/search_controller.rb b/app/controllers/api/v1/search_controller.rb index 8b832148c..bc5b8e5d4 100644 --- a/app/controllers/api/v1/search_controller.rb +++ b/app/controllers/api/v1/search_controller.rb @@ -3,10 +3,14 @@ class Api::V1::SearchController < Api::BaseController RESULTS_LIMIT = 5 + before_action -> { doorkeeper_authorize! :read } + before_action :require_user! + respond_to :json def index - @search = OpenStruct.new(search_results) + @search = Search.new(search_results) + render json: @search, serializer: REST::SearchSerializer end private diff --git a/app/controllers/api/v1/statuses/favourited_by_accounts_controller.rb b/app/controllers/api/v1/statuses/favourited_by_accounts_controller.rb index e58184939..f95cf9457 100644 --- a/app/controllers/api/v1/statuses/favourited_by_accounts_controller.rb +++ b/app/controllers/api/v1/statuses/favourited_by_accounts_controller.rb @@ -11,7 +11,7 @@ class Api::V1::Statuses::FavouritedByAccountsController < Api::BaseController def index @accounts = load_accounts - render 'api/v1/statuses/accounts' + render json: @accounts, each_serializer: REST::AccountSerializer end private diff --git a/app/controllers/api/v1/statuses/favourites_controller.rb b/app/controllers/api/v1/statuses/favourites_controller.rb index b6fb13cc0..35f8a48cd 100644 --- a/app/controllers/api/v1/statuses/favourites_controller.rb +++ b/app/controllers/api/v1/statuses/favourites_controller.rb @@ -10,7 +10,7 @@ class Api::V1::Statuses::FavouritesController < Api::BaseController def create @status = favourited_status - render 'api/v1/statuses/show' + render json: @status, serializer: REST::StatusSerializer end def destroy @@ -19,7 +19,7 @@ class Api::V1::Statuses::FavouritesController < Api::BaseController UnfavouriteWorker.perform_async(current_user.account_id, @status.id) - render 'api/v1/statuses/show' + render json: @status, serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new([@status], current_user&.account_id, favourites_map: @favourites_map) end private diff --git a/app/controllers/api/v1/statuses/mutes_controller.rb b/app/controllers/api/v1/statuses/mutes_controller.rb index eab88f2ef..a4bf0acdd 100644 --- a/app/controllers/api/v1/statuses/mutes_controller.rb +++ b/app/controllers/api/v1/statuses/mutes_controller.rb @@ -14,14 +14,14 @@ class Api::V1::Statuses::MutesController < Api::BaseController current_account.mute_conversation!(@conversation) @mutes_map = { @conversation.id => true } - render 'api/v1/statuses/show' + render json: @status, serializer: REST::StatusSerializer end def destroy current_account.unmute_conversation!(@conversation) @mutes_map = { @conversation.id => false } - render 'api/v1/statuses/show' + render json: @status, serializer: REST::StatusSerializer end private diff --git a/app/controllers/api/v1/statuses/reblogged_by_accounts_controller.rb b/app/controllers/api/v1/statuses/reblogged_by_accounts_controller.rb index 43593d3c5..175217e6e 100644 --- a/app/controllers/api/v1/statuses/reblogged_by_accounts_controller.rb +++ b/app/controllers/api/v1/statuses/reblogged_by_accounts_controller.rb @@ -11,7 +11,7 @@ class Api::V1::Statuses::RebloggedByAccountsController < Api::BaseController def index @accounts = load_accounts - render 'api/v1/statuses/accounts' + render json: @accounts, each_serializer: REST::AccountSerializer end private diff --git a/app/controllers/api/v1/statuses/reblogs_controller.rb b/app/controllers/api/v1/statuses/reblogs_controller.rb index ee9c5b3a6..634af474f 100644 --- a/app/controllers/api/v1/statuses/reblogs_controller.rb +++ b/app/controllers/api/v1/statuses/reblogs_controller.rb @@ -10,7 +10,7 @@ class Api::V1::Statuses::ReblogsController < Api::BaseController def create @status = ReblogService.new.call(current_user.account, status_for_reblog) - render 'api/v1/statuses/show' + render json: @status, serializer: REST::StatusSerializer end def destroy @@ -20,7 +20,7 @@ class Api::V1::Statuses::ReblogsController < Api::BaseController authorize status_for_destroy, :unreblog? RemovalWorker.perform_async(status_for_destroy.id) - render 'api/v1/statuses/show' + render json: @status, serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new([@status], current_user&.account_id, reblogs_map: @reblogs_map) end private diff --git a/app/controllers/api/v1/statuses_controller.rb b/app/controllers/api/v1/statuses_controller.rb index 9aa1cbc4d..9c7124d0f 100644 --- a/app/controllers/api/v1/statuses_controller.rb +++ b/app/controllers/api/v1/statuses_controller.rb @@ -13,6 +13,7 @@ class Api::V1::StatusesController < Api::BaseController def show cached = Rails.cache.read(@status.cache_key) @status = cached unless cached.nil? + render json: @status, serializer: REST::StatusSerializer end def context @@ -21,15 +22,20 @@ class Api::V1::StatusesController < Api::BaseController loaded_ancestors = cache_collection(ancestors_results, Status) loaded_descendants = cache_collection(descendants_results, Status) - @context = OpenStruct.new(ancestors: loaded_ancestors, descendants: loaded_descendants) - statuses = [@status] + @context[:ancestors] + @context[:descendants] + @context = Context.new(ancestors: loaded_ancestors, descendants: loaded_descendants) + statuses = [@status] + @context.ancestors + @context.descendants - set_maps(statuses) + render json: @context, serializer: REST::ContextSerializer, relationships: StatusRelationshipsPresenter.new(statuses, current_user&.account_id) end def card @card = PreviewCard.find_by(status: @status) - render_empty if @card.nil? + + if @card.nil? + render_empty + else + render json: @card, serializer: REST::PreviewCardSerializer + end end def create @@ -43,7 +49,7 @@ class Api::V1::StatusesController < Api::BaseController application: doorkeeper_token.application, idempotency: request.headers['Idempotency-Key']) - render :show + render json: @status, serializer: REST::StatusSerializer end def destroy diff --git a/app/controllers/api/v1/timelines/home_controller.rb b/app/controllers/api/v1/timelines/home_controller.rb index 511d2f65d..3dd27710c 100644 --- a/app/controllers/api/v1/timelines/home_controller.rb +++ b/app/controllers/api/v1/timelines/home_controller.rb @@ -9,15 +9,13 @@ class Api::V1::Timelines::HomeController < Api::BaseController def show @statuses = load_statuses - render 'api/v1/timelines/show' + render json: @statuses, each_serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id) end private def load_statuses - cached_home_statuses.tap do |statuses| - set_maps(statuses) - end + cached_home_statuses end def cached_home_statuses diff --git a/app/controllers/api/v1/timelines/public_controller.rb b/app/controllers/api/v1/timelines/public_controller.rb index 305451cc7..49887778e 100644 --- a/app/controllers/api/v1/timelines/public_controller.rb +++ b/app/controllers/api/v1/timelines/public_controller.rb @@ -7,15 +7,13 @@ class Api::V1::Timelines::PublicController < Api::BaseController def show @statuses = load_statuses - render 'api/v1/timelines/show' + render json: @statuses, each_serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id) end private def load_statuses - cached_public_statuses.tap do |statuses| - set_maps(statuses) - end + cached_public_statuses end def cached_public_statuses diff --git a/app/controllers/api/v1/timelines/tag_controller.rb b/app/controllers/api/v1/timelines/tag_controller.rb index 50afca7c7..08db04a39 100644 --- a/app/controllers/api/v1/timelines/tag_controller.rb +++ b/app/controllers/api/v1/timelines/tag_controller.rb @@ -8,7 +8,7 @@ class Api::V1::Timelines::TagController < Api::BaseController def show @statuses = load_statuses - render 'api/v1/timelines/show' + render json: @statuses, each_serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id) end private @@ -18,9 +18,7 @@ class Api::V1::Timelines::TagController < Api::BaseController end def load_statuses - cached_tagged_statuses.tap do |statuses| - set_maps(statuses) - end + cached_tagged_statuses end def cached_tagged_statuses diff --git a/app/controllers/api/web/push_subscriptions_controller.rb b/app/controllers/api/web/push_subscriptions_controller.rb new file mode 100644 index 000000000..d66237feb --- /dev/null +++ b/app/controllers/api/web/push_subscriptions_controller.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +class Api::Web::PushSubscriptionsController < Api::BaseController + respond_to :json + + before_action :require_user! + + def create + params.require(:subscription).require(:endpoint) + params.require(:subscription).require(:keys).require([:auth, :p256dh]) + + active_session = current_session + + unless active_session.web_push_subscription.nil? + active_session.web_push_subscription.destroy! + active_session.update!(web_push_subscription: nil) + end + + # Mobile devices do not support regular notifications, so we enable push notifications by default + alerts_enabled = active_session.detection.device.mobile? || active_session.detection.device.tablet? + + data = { + alerts: { + follow: alerts_enabled, + favourite: alerts_enabled, + reblog: alerts_enabled, + mention: alerts_enabled, + }, + } + + web_subscription = ::Web::PushSubscription.create!( + endpoint: params[:subscription][:endpoint], + key_p256dh: params[:subscription][:keys][:p256dh], + key_auth: params[:subscription][:keys][:auth], + data: data + ) + + active_session.update!(web_push_subscription: web_subscription) + + render json: web_subscription.as_payload + end + + def update + params.require([:id, :data]) + + web_subscription = ::Web::PushSubscription.find(params[:id]) + + web_subscription.update!(data: params[:data]) + + render json: web_subscription.as_payload + end +end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 9cb397aa8..b3c2db02b 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -11,6 +11,7 @@ class ApplicationController < ActionController::Base include UserTrackingConcern helper_method :current_account + helper_method :current_session helper_method :single_user_mode? rescue_from ActionController::RoutingError, with: :not_found @@ -68,6 +69,10 @@ class ApplicationController < ActionController::Base @current_account ||= current_user.try(:account) end + def current_session + @current_session ||= SessionActivation.find_by(session_id: cookies.signed['_session_id']) + end + def cache_collection(raw, klass) return raw unless klass.respond_to?(:with_includes) diff --git a/app/controllers/auth/registrations_controller.rb b/app/controllers/auth/registrations_controller.rb index d385c08e1..60ace04d7 100644 --- a/app/controllers/auth/registrations_controller.rb +++ b/app/controllers/auth/registrations_controller.rb @@ -5,6 +5,7 @@ class Auth::RegistrationsController < Devise::RegistrationsController before_action :check_enabled_registrations, only: [:new, :create] before_action :configure_sign_up_params, only: [:create] + before_action :set_sessions, only: [:edit, :update] def destroy not_found @@ -41,4 +42,8 @@ class Auth::RegistrationsController < Devise::RegistrationsController def determine_layout %w(edit update).include?(action_name) ? 'admin' : 'auth' end + + def set_sessions + @sessions = current_user.session_activations + end end diff --git a/app/controllers/authorize_follows_controller.rb b/app/controllers/authorize_follows_controller.rb index b15883d58..dccd1c209 100644 --- a/app/controllers/authorize_follows_controller.rb +++ b/app/controllers/authorize_follows_controller.rb @@ -15,7 +15,7 @@ class AuthorizeFollowsController < ApplicationController if @account.nil? render :error else - redirect_to web_url("accounts/#{@account.id}") + render :success end rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError render :error @@ -40,7 +40,7 @@ class AuthorizeFollowsController < ApplicationController end def account_from_remote_follow - FollowRemoteAccountService.new.call(acct_without_prefix) + ResolveRemoteAccountService.new.call(acct_without_prefix) end def acct_param_is_url? diff --git a/app/controllers/concerns/signature_verification.rb b/app/controllers/concerns/signature_verification.rb new file mode 100644 index 000000000..abe845d93 --- /dev/null +++ b/app/controllers/concerns/signature_verification.rb @@ -0,0 +1,87 @@ +# frozen_string_literal: true + +# Implemented according to HTTP signatures (Draft 6) +# +module SignatureVerification + extend ActiveSupport::Concern + + def signed_request? + request.headers['Signature'].present? + end + + def signed_request_account + return @signed_request_account if defined?(@signed_request_account) + + unless signed_request? + @signed_request_account = nil + return + end + + raw_signature = request.headers['Signature'] + signature_params = {} + + raw_signature.split(',').each do |part| + parsed_parts = part.match(/([a-z]+)="([^"]+)"/i) + next if parsed_parts.nil? || parsed_parts.size != 3 + signature_params[parsed_parts[1]] = parsed_parts[2] + end + + if incompatible_signature?(signature_params) + @signed_request_account = nil + return + end + + account = ResolveRemoteAccountService.new.call(signature_params['keyId'].gsub(/\Aacct:/, '')) + + if account.nil? + @signed_request_account = nil + return + end + + signature = Base64.decode64(signature_params['signature']) + compare_signed_string = build_signed_string(signature_params['headers']) + + if account.keypair.public_key.verify(OpenSSL::Digest::SHA256.new, signature, compare_signed_string) + @signed_request_account = account + @signed_request_account + else + @signed_request_account = nil + end + end + + private + + def build_signed_string(signed_headers) + signed_headers = 'date' if signed_headers.blank? + + signed_headers.split(' ').map do |signed_header| + if signed_header == Request::REQUEST_TARGET + "#{Request::REQUEST_TARGET}: #{request.method.downcase} #{request.path}" + else + "#{signed_header}: #{request.headers[to_header_name(signed_header)]}" + end + end.join("\n") + end + + def matches_time_window? + begin + time_sent = DateTime.httpdate(request.headers['Date']) + rescue ArgumentError + return false + end + + (Time.now.utc - time_sent).abs <= 30 + end + + def to_header_name(name) + name.split(/-/).map(&:capitalize).join('-') + end + + def incompatible_signature?(signature_params) + signature_params['keyId'].blank? || + signature_params['signature'].blank? || + signature_params['algorithm'].blank? || + signature_params['algorithm'] != 'rsa-sha256' || + !signature_params['keyId'].start_with?('acct:') + end +end diff --git a/app/controllers/follower_accounts_controller.rb b/app/controllers/follower_accounts_controller.rb index 1e7c7c406..5edb4d67c 100644 --- a/app/controllers/follower_accounts_controller.rb +++ b/app/controllers/follower_accounts_controller.rb @@ -5,5 +5,24 @@ class FollowerAccountsController < ApplicationController def index @follows = Follow.where(target_account: @account).recent.page(params[:page]).per(FOLLOW_PER_PAGE).preload(:account) + + respond_to do |format| + format.html + + format.json do + render json: collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter + end + end + end + + private + + def collection_presenter + ActivityPub::CollectionPresenter.new( + id: account_followers_url(@account), + type: :ordered, + size: @account.followers_count, + items: @follows.map { |f| ActivityPub::TagManager.instance.uri_for(f.account) } + ) end end diff --git a/app/controllers/following_accounts_controller.rb b/app/controllers/following_accounts_controller.rb index f4488eef5..7cafe5fda 100644 --- a/app/controllers/following_accounts_controller.rb +++ b/app/controllers/following_accounts_controller.rb @@ -5,5 +5,24 @@ class FollowingAccountsController < ApplicationController def index @follows = Follow.where(account: @account).recent.page(params[:page]).per(FOLLOW_PER_PAGE).preload(:target_account) + + respond_to do |format| + format.html + + format.json do + render json: collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter + end + end + end + + private + + def collection_presenter + ActivityPub::CollectionPresenter.new( + id: account_following_index_url(@account), + type: :ordered, + size: @account.following_count, + items: @follows.map { |f| ActivityPub::TagManager.instance.uri_for(f.target_account) } + ) end end diff --git a/app/controllers/home_controller.rb b/app/controllers/home_controller.rb index 1d41892cd..1585bc810 100644 --- a/app/controllers/home_controller.rb +++ b/app/controllers/home_controller.rb @@ -2,13 +2,10 @@ class HomeController < ApplicationController before_action :authenticate_user! + before_action :set_initial_state_json def index - @body_classes = 'app-body' - @token = find_or_create_access_token.token - @web_settings = Web::Setting.find_by(user: current_user)&.data || {} - @admin = Account.find_local(Setting.site_contact_username) - @streaming_api_base_url = Rails.configuration.x.streaming_api_base_url + @body_classes = 'app-body' end private @@ -17,13 +14,18 @@ class HomeController < ApplicationController redirect_to(single_user_mode? ? account_path(Account.first) : about_path) unless user_signed_in? end - def find_or_create_access_token - Doorkeeper::AccessToken.find_or_create_for( - Doorkeeper::Application.where(superapp: true).first, - current_user.id, - Doorkeeper::OAuth::Scopes.from_string('read write follow'), - Doorkeeper.configuration.access_token_expires_in, - Doorkeeper.configuration.refresh_token_enabled? - ) + def set_initial_state_json + serializable_resource = ActiveModelSerializers::SerializableResource.new(InitialStatePresenter.new(initial_state_params), serializer: InitialStateSerializer) + @initial_state_json = serializable_resource.to_json + end + + 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), + } end end diff --git a/app/controllers/settings/deletes_controller.rb b/app/controllers/settings/deletes_controller.rb index dd18b4c2f..80002b995 100644 --- a/app/controllers/settings/deletes_controller.rb +++ b/app/controllers/settings/deletes_controller.rb @@ -3,6 +3,7 @@ class Settings::DeletesController < ApplicationController layout 'admin' + before_action :check_enabled_deletion before_action :authenticate_user! def show @@ -21,6 +22,10 @@ class Settings::DeletesController < ApplicationController private + def check_enabled_deletion + redirect_to root_path unless Setting.open_deletion + end + def delete_params params.require(:form_delete_confirmation).permit(:password) end diff --git a/app/controllers/settings/preferences_controller.rb b/app/controllers/settings/preferences_controller.rb index 71f5a7c04..f107f2b16 100644 --- a/app/controllers/settings/preferences_controller.rb +++ b/app/controllers/settings/preferences_controller.rb @@ -34,9 +34,13 @@ class Settings::PreferencesController < ApplicationController def user_settings_params params.require(:user).permit( :setting_default_privacy, + :setting_default_sensitive, + :setting_unfollow_modal, :setting_boost_modal, :setting_delete_modal, :setting_auto_play_gif, + :setting_system_font_ui, + :setting_noindex, notification_emails: %i(follow follow_request reblog favourite mention digest), interactions: %i(must_be_follower must_be_following) ) diff --git a/app/controllers/settings/sessions_controller.rb b/app/controllers/settings/sessions_controller.rb new file mode 100644 index 000000000..0da1b027b --- /dev/null +++ b/app/controllers/settings/sessions_controller.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class Settings::SessionsController < ApplicationController + before_action :set_session, only: :destroy + + def destroy + @session.destroy! + flash[:notice] = I18n.t('sessions.revoke_success') + redirect_to edit_user_registration_path + end + + private + + def set_session + @session = current_user.session_activations.find(params[:id]) + end +end diff --git a/app/controllers/settings/two_factor_authentications_controller.rb b/app/controllers/settings/two_factor_authentications_controller.rb index f66c3a908..863cc7351 100644 --- a/app/controllers/settings/two_factor_authentications_controller.rb +++ b/app/controllers/settings/two_factor_authentications_controller.rb @@ -7,7 +7,9 @@ module Settings before_action :authenticate_user! before_action :verify_otp_required, only: [:create] - def show; end + def show + @confirmation = Form::TwoFactorConfirmation.new + end def create current_user.otp_secret = User.generate_otp_secret(32) @@ -16,15 +18,30 @@ module Settings end def destroy - current_user.otp_required_for_login = false - current_user.save! - redirect_to settings_two_factor_authentication_path + if acceptable_code? + current_user.otp_required_for_login = false + current_user.save! + redirect_to settings_two_factor_authentication_path + else + flash.now[:alert] = I18n.t('two_factor_authentication.wrong_code') + @confirmation = Form::TwoFactorConfirmation.new + render :show + end end private + def confirmation_params + params.require(:form_two_factor_confirmation).permit(:code) + end + def verify_otp_required redirect_to settings_two_factor_authentication_path if current_user.otp_required_for_login? end + + def acceptable_code? + current_user.validate_and_consume_otp!(confirmation_params[:code]) || + current_user.invalidate_otp_backup_code!(confirmation_params[:code]) + end end end diff --git a/app/controllers/statuses_controller.rb b/app/controllers/statuses_controller.rb index 59c9d0a87..8e0ce0ec3 100644 --- a/app/controllers/statuses_controller.rb +++ b/app/controllers/statuses_controller.rb @@ -11,10 +11,22 @@ class StatusesController < ApplicationController before_action :check_account_suspension def show - @ancestors = @status.reply? ? cache_collection(@status.ancestors(current_account), Status) : [] - @descendants = cache_collection(@status.descendants(current_account), Status) + respond_to do |format| + format.html do + @ancestors = @status.reply? ? cache_collection(@status.ancestors(current_account), Status) : [] + @descendants = cache_collection(@status.descendants(current_account), Status) - render 'stream_entries/show' + render 'stream_entries/show' + end + + format.json do + render json: @status, serializer: ActivityPub::NoteSerializer, adapter: ActivityPub::Adapter + end + end + end + + def activity + render json: @status, serializer: ActivityPub::ActivitySerializer, adapter: ActivityPub::Adapter end private diff --git a/app/controllers/stream_entries_controller.rb b/app/controllers/stream_entries_controller.rb index 314d59619..3eb91d830 100644 --- a/app/controllers/stream_entries_controller.rb +++ b/app/controllers/stream_entries_controller.rb @@ -2,6 +2,7 @@ class StreamEntriesController < ApplicationController include Authorization + include SignatureVerification layout 'public' @@ -18,7 +19,7 @@ class StreamEntriesController < ApplicationController end format.atom do - render xml: AtomSerializer.render(AtomSerializer.new.entry(@stream_entry, true)) + render xml: OStatus::AtomSerializer.render(OStatus::AtomSerializer.new.entry(@stream_entry, true)) end end end diff --git a/app/controllers/tags_controller.rb b/app/controllers/tags_controller.rb index 53149edf0..2cd85e185 100644 --- a/app/controllers/tags_controller.rb +++ b/app/controllers/tags_controller.rb @@ -5,7 +5,26 @@ class TagsController < ApplicationController def show @tag = Tag.find_by!(name: params[:id].downcase) - @statuses = @tag.nil? ? [] : Status.as_tag_timeline(@tag, current_account, params[:local]).paginate_by_max_id(20, params[:max_id]) + @statuses = Status.as_tag_timeline(@tag, current_account, params[:local]).paginate_by_max_id(20, params[:max_id]) @statuses = cache_collection(@statuses, Status) + + respond_to do |format| + format.html + + format.json do + render json: collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter + end + end + end + + private + + def collection_presenter + ActivityPub::CollectionPresenter.new( + id: tag_url(@tag), + type: :ordered, + size: @tag.statuses.count, + items: @statuses.map { |s| ActivityPub::TagManager.instance.uri_for(s) } + ) end end diff --git a/app/helpers/activitystreams2_builder_helper.rb b/app/helpers/activitystreams2_builder_helper.rb deleted file mode 100644 index 717b470f0..000000000 --- a/app/helpers/activitystreams2_builder_helper.rb +++ /dev/null @@ -1,8 +0,0 @@ -# frozen_string_literal: true - -module Activitystreams2BuilderHelper - # Gets a usable name for an account, using display name or username. - def account_name(account) - account.display_name.presence || account.username - end -end diff --git a/app/helpers/admin/filter_helper.rb b/app/helpers/admin/filter_helper.rb index 0dfa30e56..6a57b3d63 100644 --- a/app/helpers/admin/filter_helper.rb +++ b/app/helpers/admin/filter_helper.rb @@ -6,15 +6,21 @@ module Admin::FilterHelper FILTERS = ACCOUNT_FILTERS + REPORT_FILTERS - def filter_link_to(text, more_params) - new_url = filtered_url_for(more_params) - link_to text, new_url, class: filter_link_class(new_url) + def filter_link_to(text, link_to_params, link_class_params = link_to_params) + new_url = filtered_url_for(link_to_params) + new_class = filtered_url_for(link_class_params) + link_to text, new_url, class: filter_link_class(new_class) end def table_link_to(icon, text, path, options = {}) link_to safe_join([fa_icon(icon), text]), path, options.merge(class: 'table-action-link') end + def selected?(more_params) + new_url = filtered_url_for(more_params) + filter_link_class(new_url) == 'selected' ? true : false + end + private def filter_params(more_params) diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 42f6ab3db..9f50d8bdb 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -13,6 +13,10 @@ module ApplicationHelper Setting.open_registrations end + def open_deletion? + Setting.open_deletion + end + def add_rtl_body_class(other_classes) other_classes = "#{other_classes} rtl" if [:ar, :fa, :he].include?(I18n.locale) other_classes @@ -27,7 +31,11 @@ module ApplicationHelper Rails.env.production? ? site_title : "#{site_title} (Dev)" end - def fa_icon(icon) - content_tag(:i, nil, class: 'fa ' + icon.split(' ').map { |cl| "fa-#{cl}" }.join(' ')) + def fa_icon(icon, attributes = {}) + class_names = attributes[:class]&.split(' ') || [] + class_names << 'fa' + class_names += icon.split(' ').map { |cl| "fa-#{cl}" } + + content_tag(:i, nil, attributes.merge(class: class_names.join(' '))) end end diff --git a/app/helpers/emoji_helper.rb b/app/helpers/emoji_helper.rb new file mode 100644 index 000000000..848c03fce --- /dev/null +++ b/app/helpers/emoji_helper.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module EmojiHelper + def emojify(text) + return text if text.blank? + + text.gsub(emoji_pattern) do |match| + emoji = Emoji.instance.unicode($1) # rubocop:disable Style/PerlBackrefs + + if emoji + emoji + else + match + end + end + end + + def emoji_pattern + @emoji_pattern ||= + /(?<=[^[:alnum:]:]|\n|^) + (#{Emoji.instance.names.map { |name| Regexp.escape(name) }.join('|')}) + (?=[^[:alnum:]:]|$)/x + end +end diff --git a/app/helpers/http_helper.rb b/app/helpers/http_helper.rb deleted file mode 100644 index e39a52da0..000000000 --- a/app/helpers/http_helper.rb +++ /dev/null @@ -1,17 +0,0 @@ -# frozen_string_literal: true - -module HttpHelper - def http_client(options = {}) - timeout = { write: 10, connect: 10, read: 10 }.merge(options) - - HTTP.headers(user_agent: user_agent) - .timeout(:per_operation, timeout) - .follow - end - - private - - def user_agent - @user_agent ||= "#{HTTP::Request::USER_AGENT} (Mastodon/#{Mastodon::Version}; +http://#{Rails.configuration.x.local_domain}/)" - end -end diff --git a/app/helpers/routing_helper.rb b/app/helpers/routing_helper.rb index 9650ee286..8126176ba 100644 --- a/app/helpers/routing_helper.rb +++ b/app/helpers/routing_helper.rb @@ -11,7 +11,7 @@ module RoutingHelper end end - def full_asset_url(source) - Rails.configuration.x.use_s3 ? source : URI.join(root_url, ActionController::Base.helpers.asset_url(source)).to_s + 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 end end diff --git a/app/helpers/settings_helper.rb b/app/helpers/settings_helper.rb index 172ef33ca..af950aa63 100644 --- a/app/helpers/settings_helper.rb +++ b/app/helpers/settings_helper.rb @@ -19,6 +19,7 @@ module SettingsHelper io: 'Ido', it: 'Italiano', ja: '日本語', + ko: '한국어', nl: 'Nederlands', no: 'Norsk', oc: 'Occitan', @@ -41,4 +42,16 @@ module SettingsHelper def hash_to_object(hash) HashObject.new(hash) end + + def session_device_icon(session) + device = session.detection.device + + if device.mobile? + 'mobile' + elsif device.tablet? + 'tablet' + else + 'desktop' + end + end end diff --git a/app/helpers/stream_entries_helper.rb b/app/helpers/stream_entries_helper.rb index a17b02128..4ef7cffb0 100644 --- a/app/helpers/stream_entries_helper.rb +++ b/app/helpers/stream_entries_helper.rb @@ -53,11 +53,11 @@ module StreamEntriesHelper def rtl?(text) text = simplified_text(text) - rtl_characters = /[\p{Hebrew}|\p{Arabic}|\p{Syriac}|\p{Thaana}|\p{Nko}]+/m.match(text) + rtl_words = text.scan(/[\p{Hebrew}\p{Arabic}\p{Syriac}\p{Thaana}\p{Nko}]+/m) - if rtl_characters.present? + if rtl_words.present? total_size = text.size.to_f - rtl_size(rtl_characters.to_a) / total_size > 0.3 + rtl_size(rtl_words) / total_size > 0.3 else false end @@ -77,8 +77,8 @@ module StreamEntriesHelper end end - def rtl_size(characters) - characters.reduce(0) { |acc, elem| acc + elem.size }.to_f + def rtl_size(words) + words.reduce(0) { |acc, elem| acc + elem.size }.to_f end def embedded_view? diff --git a/app/javascript/fonts/montserrat/Montserrat-Medium.ttf b/app/javascript/fonts/montserrat/Montserrat-Medium.ttf new file mode 100644 index 000000000..88d70b89c Binary files /dev/null and b/app/javascript/fonts/montserrat/Montserrat-Medium.ttf differ diff --git a/app/javascript/images/background-photo.jpg b/app/javascript/images/background-photo.jpg deleted file mode 100644 index 03341b8ec..000000000 Binary files a/app/javascript/images/background-photo.jpg and /dev/null differ diff --git a/app/javascript/images/boost_sprite.png b/app/javascript/images/boost_sprite.png deleted file mode 100644 index 564bf2646..000000000 Binary files a/app/javascript/images/boost_sprite.png and /dev/null differ diff --git a/app/javascript/images/elephant-fren.png b/app/javascript/images/elephant-fren.png new file mode 100644 index 000000000..3b64edf08 Binary files /dev/null and b/app/javascript/images/elephant-fren.png differ diff --git a/app/javascript/images/fluffy-elephant-friend.png b/app/javascript/images/fluffy-elephant-friend.png deleted file mode 100644 index f0df29927..000000000 Binary files a/app/javascript/images/fluffy-elephant-friend.png and /dev/null differ diff --git a/app/javascript/images/logo.png b/app/javascript/images/logo.png deleted file mode 100644 index f0c1c46c3..000000000 Binary files a/app/javascript/images/logo.png and /dev/null differ diff --git a/app/javascript/images/logo.svg b/app/javascript/images/logo.svg index c233db842..4b72b3ac8 100644 --- a/app/javascript/images/logo.svg +++ b/app/javascript/images/logo.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/app/javascript/images/logo_alt.svg b/app/javascript/images/logo_alt.svg new file mode 100644 index 000000000..e88ca7418 --- /dev/null +++ b/app/javascript/images/logo_alt.svg @@ -0,0 +1 @@ + diff --git a/app/javascript/images/logo_full.svg b/app/javascript/images/logo_full.svg new file mode 100644 index 000000000..8b1328e8c --- /dev/null +++ b/app/javascript/images/logo_full.svg @@ -0,0 +1 @@ + diff --git a/app/javascript/images/mastodon.jpg b/app/javascript/images/mastodon.jpg deleted file mode 100644 index 2dfeb879f..000000000 Binary files a/app/javascript/images/mastodon.jpg and /dev/null differ diff --git a/app/javascript/mastodon/actions/accounts.js b/app/javascript/mastodon/actions/accounts.js index a862798f9..03e3d3d9f 100644 --- a/app/javascript/mastodon/actions/accounts.js +++ b/app/javascript/mastodon/actions/accounts.js @@ -1,5 +1,4 @@ import api, { getLinks } from '../api'; -import Immutable from 'immutable'; export const ACCOUNT_FETCH_REQUEST = 'ACCOUNT_FETCH_REQUEST'; export const ACCOUNT_FETCH_SUCCESS = 'ACCOUNT_FETCH_SUCCESS'; @@ -597,7 +596,7 @@ export function authorizeFollowRequest(id) { api(getState) .post(`/api/v1/follow_requests/${id}/authorize`) - .then(response => dispatch(authorizeFollowRequestSuccess(id))) + .then(() => dispatch(authorizeFollowRequestSuccess(id))) .catch(error => dispatch(authorizeFollowRequestFail(id, error))); }; }; @@ -631,7 +630,7 @@ export function rejectFollowRequest(id) { api(getState) .post(`/api/v1/follow_requests/${id}/reject`) - .then(response => dispatch(rejectFollowRequestSuccess(id))) + .then(() => dispatch(rejectFollowRequestSuccess(id))) .catch(error => dispatch(rejectFollowRequestFail(id, error))); }; }; diff --git a/app/javascript/mastodon/actions/bundles.js b/app/javascript/mastodon/actions/bundles.js new file mode 100644 index 000000000..ecc9c8f7d --- /dev/null +++ b/app/javascript/mastodon/actions/bundles.js @@ -0,0 +1,25 @@ +export const BUNDLE_FETCH_REQUEST = 'BUNDLE_FETCH_REQUEST'; +export const BUNDLE_FETCH_SUCCESS = 'BUNDLE_FETCH_SUCCESS'; +export const BUNDLE_FETCH_FAIL = 'BUNDLE_FETCH_FAIL'; + +export function fetchBundleRequest(skipLoading) { + return { + type: BUNDLE_FETCH_REQUEST, + skipLoading, + }; +} + +export function fetchBundleSuccess(skipLoading) { + return { + type: BUNDLE_FETCH_SUCCESS, + skipLoading, + }; +} + +export function fetchBundleFail(error, skipLoading) { + return { + type: BUNDLE_FETCH_FAIL, + error, + skipLoading, + }; +} diff --git a/app/javascript/mastodon/actions/compose.js b/app/javascript/mastodon/actions/compose.js index 647a52b93..ebb75f36e 100644 --- a/app/javascript/mastodon/actions/compose.js +++ b/app/javascript/mastodon/actions/compose.js @@ -2,8 +2,6 @@ import api from '../api'; import { updateTimeline } from './timelines'; -import * as emojione from 'emojione'; - export const COMPOSE_CHANGE = 'COMPOSE_CHANGE'; export const COMPOSE_SUBMIT_REQUEST = 'COMPOSE_SUBMIT_REQUEST'; export const COMPOSE_SUBMIT_SUCCESS = 'COMPOSE_SUBMIT_SUCCESS'; @@ -29,6 +27,7 @@ export const COMPOSE_SPOILERNESS_CHANGE = 'COMPOSE_SPOILERNESS_CHANGE'; export const COMPOSE_SPOILER_TEXT_CHANGE = 'COMPOSE_SPOILER_TEXT_CHANGE'; export const COMPOSE_VISIBILITY_CHANGE = 'COMPOSE_VISIBILITY_CHANGE'; export const COMPOSE_LISTABILITY_CHANGE = 'COMPOSE_LISTABILITY_CHANGE'; +export const COMPOSE_COMPOSING_CHANGE = 'COMPOSE_COMPOSING_CHANGE'; export const COMPOSE_EMOJI_INSERT = 'COMPOSE_EMOJI_INSERT'; @@ -73,11 +72,14 @@ export function mentionCompose(account, router) { export function submitCompose() { return function (dispatch, getState) { - const status = emojione.shortnameToUnicode(getState().getIn(['compose', 'text'], '')); + const status = getState().getIn(['compose', 'text'], ''); + if (!status || !status.length) { return; } + dispatch(submitComposeRequest()); + api(getState).post('/api/v1/statuses', { status, in_reply_to_id: getState().getIn(['compose', 'in_reply_to'], null), @@ -277,3 +279,10 @@ export function insertEmojiCompose(position, emoji) { emoji, }; }; + +export function changeComposing(value) { + return { + type: COMPOSE_COMPOSING_CHANGE, + value, + }; +} diff --git a/app/javascript/mastodon/actions/domain_blocks.js b/app/javascript/mastodon/actions/domain_blocks.js index 530ba9cf1..44363697a 100644 --- a/app/javascript/mastodon/actions/domain_blocks.js +++ b/app/javascript/mastodon/actions/domain_blocks.js @@ -16,7 +16,7 @@ export function blockDomain(domain, accountId) { return (dispatch, getState) => { dispatch(blockDomainRequest(domain)); - api(getState).post('/api/v1/domain_blocks', { domain }).then(response => { + api(getState).post('/api/v1/domain_blocks', { domain }).then(() => { dispatch(blockDomainSuccess(domain, accountId)); }).catch(err => { dispatch(blockDomainFail(domain, err)); @@ -51,7 +51,7 @@ export function unblockDomain(domain, accountId) { return (dispatch, getState) => { dispatch(unblockDomainRequest(domain)); - api(getState).delete('/api/v1/domain_blocks', { params: { domain } }).then(response => { + api(getState).delete('/api/v1/domain_blocks', { params: { domain } }).then(() => { dispatch(unblockDomainSuccess(domain, accountId)); }).catch(err => { dispatch(unblockDomainFail(domain, err)); diff --git a/app/javascript/mastodon/actions/notifications.js b/app/javascript/mastodon/actions/notifications.js index d3de2d871..c7d248122 100644 --- a/app/javascript/mastodon/actions/notifications.js +++ b/app/javascript/mastodon/actions/notifications.js @@ -1,5 +1,5 @@ import api, { getLinks } from '../api'; -import Immutable from 'immutable'; +import { List as ImmutableList } from 'immutable'; import IntlMessageFormat from 'intl-messageformat'; import { fetchRelationships } from './accounts'; import { defineMessages } from 'react-intl'; @@ -17,7 +17,7 @@ export const NOTIFICATIONS_EXPAND_FAIL = 'NOTIFICATIONS_EXPAND_FAIL'; export const NOTIFICATIONS_CLEAR = 'NOTIFICATIONS_CLEAR'; export const NOTIFICATIONS_SCROLL_TOP = 'NOTIFICATIONS_SCROLL_TOP'; -const messages = defineMessages({ +defineMessages({ mention: { id: 'notification.mention', defaultMessage: '{name} mentioned you' }, }); @@ -124,7 +124,7 @@ export function refreshNotificationsFail(error, skipLoading) { export function expandNotifications() { return (dispatch, getState) => { - const items = getState().getIn(['notifications', 'items'], Immutable.List()); + const items = getState().getIn(['notifications', 'items'], ImmutableList()); if (getState().getIn(['notifications', 'isLoading']) || items.size === 0) { return; diff --git a/app/javascript/mastodon/actions/push_notifications.js b/app/javascript/mastodon/actions/push_notifications.js new file mode 100644 index 000000000..55661d2b0 --- /dev/null +++ b/app/javascript/mastodon/actions/push_notifications.js @@ -0,0 +1,52 @@ +import axios from 'axios'; + +export const SET_BROWSER_SUPPORT = 'PUSH_NOTIFICATIONS_SET_BROWSER_SUPPORT'; +export const SET_SUBSCRIPTION = 'PUSH_NOTIFICATIONS_SET_SUBSCRIPTION'; +export const CLEAR_SUBSCRIPTION = 'PUSH_NOTIFICATIONS_CLEAR_SUBSCRIPTION'; +export const ALERTS_CHANGE = 'PUSH_NOTIFICATIONS_ALERTS_CHANGE'; + +export function setBrowserSupport (value) { + return { + type: SET_BROWSER_SUPPORT, + value, + }; +} + +export function setSubscription (subscription) { + return { + type: SET_SUBSCRIPTION, + subscription, + }; +} + +export function clearSubscription () { + return { + type: CLEAR_SUBSCRIPTION, + }; +} + +export function changeAlerts(key, value) { + return dispatch => { + dispatch({ + type: ALERTS_CHANGE, + key, + value, + }); + + dispatch(saveSettings()); + }; +} + +export function saveSettings() { + return (_, getState) => { + const state = getState().get('push_notifications'); + const subscription = state.get('subscription'); + const alerts = state.get('alerts'); + + axios.put(`/api/web/push_subscriptions/${subscription.get('id')}`, { + data: { + alerts, + }, + }); + }; +} diff --git a/app/javascript/mastodon/actions/reports.js b/app/javascript/mastodon/actions/reports.js index 9b632be74..b19a07285 100644 --- a/app/javascript/mastodon/actions/reports.js +++ b/app/javascript/mastodon/actions/reports.js @@ -1,4 +1,5 @@ import api from '../api'; +import { openModal, closeModal } from './modal'; export const REPORT_INIT = 'REPORT_INIT'; export const REPORT_CANCEL = 'REPORT_CANCEL'; @@ -11,10 +12,14 @@ export const REPORT_STATUS_TOGGLE = 'REPORT_STATUS_TOGGLE'; export const REPORT_COMMENT_CHANGE = 'REPORT_COMMENT_CHANGE'; export function initReport(account, status) { - return { - type: REPORT_INIT, - account, - status, + return dispatch => { + dispatch({ + type: REPORT_INIT, + account, + status, + }); + + dispatch(openModal('REPORT')); }; }; @@ -40,7 +45,10 @@ export function submitReport() { account_id: getState().getIn(['reports', 'new', 'account_id']), status_ids: getState().getIn(['reports', 'new', 'status_ids']), comment: getState().getIn(['reports', 'new', 'comment']), - }).then(response => dispatch(submitReportSuccess(response.data))).catch(error => dispatch(submitReportFail(error))); + }).then(response => { + dispatch(closeModal()); + dispatch(submitReportSuccess(response.data)); + }).catch(error => dispatch(submitReportFail(error))); }; }; diff --git a/app/javascript/mastodon/actions/statuses.js b/app/javascript/mastodon/actions/statuses.js index 6956447ba..2204e0b14 100644 --- a/app/javascript/mastodon/actions/statuses.js +++ b/app/javascript/mastodon/actions/statuses.js @@ -74,7 +74,7 @@ export function deleteStatus(id) { return (dispatch, getState) => { dispatch(deleteStatusRequest(id)); - api(getState).delete(`/api/v1/statuses/${id}`).then(response => { + api(getState).delete(`/api/v1/statuses/${id}`).then(() => { dispatch(deleteStatusSuccess(id)); dispatch(deleteFromTimelines(id)); }).catch(error => { @@ -113,7 +113,7 @@ export function fetchContext(id) { dispatch(fetchContextSuccess(id, response.data.ancestors, response.data.descendants)); }).catch(error => { - if (error.response.status === 404) { + if (error.response && error.response.status === 404) { dispatch(deleteFromTimelines(id)); } @@ -152,7 +152,7 @@ export function muteStatus(id) { return (dispatch, getState) => { dispatch(muteStatusRequest(id)); - api(getState).post(`/api/v1/statuses/${id}/mute`).then(response => { + api(getState).post(`/api/v1/statuses/${id}/mute`).then(() => { dispatch(muteStatusSuccess(id)); }).catch(error => { dispatch(muteStatusFail(id, error)); @@ -186,7 +186,7 @@ export function unmuteStatus(id) { return (dispatch, getState) => { dispatch(unmuteStatusRequest(id)); - api(getState).post(`/api/v1/statuses/${id}/unmute`).then(response => { + api(getState).post(`/api/v1/statuses/${id}/unmute`).then(() => { dispatch(unmuteStatusSuccess(id)); }).catch(error => { dispatch(unmuteStatusFail(id, error)); diff --git a/app/javascript/mastodon/actions/store.js b/app/javascript/mastodon/actions/store.js index 601cea001..0597d265e 100644 --- a/app/javascript/mastodon/actions/store.js +++ b/app/javascript/mastodon/actions/store.js @@ -1,10 +1,11 @@ -import Immutable from 'immutable'; +import { Iterable, fromJS } from 'immutable'; export const STORE_HYDRATE = 'STORE_HYDRATE'; +export const STORE_HYDRATE_LAZY = 'STORE_HYDRATE_LAZY'; const convertState = rawState => - Immutable.fromJS(rawState, (k, v) => - Immutable.Iterable.isIndexed(v) ? v.toList() : v.toMap().mapKeys(x => + fromJS(rawState, (k, v) => + Iterable.isIndexed(v) ? v.toList() : v.toMap().mapKeys(x => Number.isNaN(x * 1) ? x : x * 1)); export function hydrateStore(rawState) { diff --git a/app/javascript/mastodon/actions/timelines.js b/app/javascript/mastodon/actions/timelines.js index cb4410eba..5c0cd93c7 100644 --- a/app/javascript/mastodon/actions/timelines.js +++ b/app/javascript/mastodon/actions/timelines.js @@ -1,5 +1,5 @@ import api, { getLinks } from '../api'; -import Immutable from 'immutable'; +import { Map as ImmutableMap, List as ImmutableList } from 'immutable'; export const TIMELINE_UPDATE = 'TIMELINE_UPDATE'; export const TIMELINE_DELETE = 'TIMELINE_DELETE'; @@ -66,13 +66,13 @@ export function refreshTimelineRequest(timeline, skipLoading) { export function refreshTimeline(timelineId, path, params = {}) { return function (dispatch, getState) { - const timeline = getState().getIn(['timelines', timelineId], Immutable.Map()); + const timeline = getState().getIn(['timelines', timelineId], ImmutableMap()); if (timeline.get('isLoading') || timeline.get('online')) { return; } - const ids = timeline.get('items', Immutable.List()); + const ids = timeline.get('items', ImmutableList()); const newestId = ids.size > 0 ? ids.first() : null; let skipLoading = timeline.get('loaded'); @@ -105,14 +105,14 @@ export function refreshTimelineFail(timeline, error, skipLoading) { timeline, error, skipLoading, - skipAlert: error.response.status === 404, + skipAlert: error.response && error.response.status === 404, }; }; export function expandTimeline(timelineId, path, params = {}) { return (dispatch, getState) => { - const timeline = getState().getIn(['timelines', timelineId], Immutable.Map()); - const ids = timeline.get('items', Immutable.List()); + const timeline = getState().getIn(['timelines', timelineId], ImmutableMap()); + const ids = timeline.get('items', ImmutableList()); if (timeline.get('isLoading') || ids.size === 0) { return; diff --git a/app/javascript/mastodon/components/account.js b/app/javascript/mastodon/components/account.js index 960d136d3..b6ca0661f 100644 --- a/app/javascript/mastodon/components/account.js +++ b/app/javascript/mastodon/components/account.js @@ -16,7 +16,8 @@ const messages = defineMessages({ unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' }, }); -class Account extends ImmutablePureComponent { +@injectIntl +export default class Account extends ImmutablePureComponent { static propTypes = { account: ImmutablePropTypes.map.isRequired, @@ -82,5 +83,3 @@ class Account extends ImmutablePureComponent { } } - -export default injectIntl(Account); diff --git a/app/javascript/mastodon/components/attachment_list.js b/app/javascript/mastodon/components/attachment_list.js index a57c25ad0..b3d00b335 100644 --- a/app/javascript/mastodon/components/attachment_list.js +++ b/app/javascript/mastodon/components/attachment_list.js @@ -4,7 +4,7 @@ import ImmutablePureComponent from 'react-immutable-pure-component'; const filename = url => url.split('/').pop().split('#')[0].split('?')[0]; -class AttachmentList extends ImmutablePureComponent { +export default class AttachmentList extends ImmutablePureComponent { static propTypes = { media: ImmutablePropTypes.list.isRequired, @@ -31,5 +31,3 @@ class AttachmentList extends ImmutablePureComponent { } } - -export default AttachmentList; diff --git a/app/javascript/mastodon/components/autosuggest_textarea.js b/app/javascript/mastodon/components/autosuggest_textarea.js index 9a5760a2c..fa41e59e1 100644 --- a/app/javascript/mastodon/components/autosuggest_textarea.js +++ b/app/javascript/mastodon/components/autosuggest_textarea.js @@ -31,7 +31,7 @@ const textAtCursorMatchesToken = (str, caretPosition) => { } }; -class AutosuggestTextarea extends ImmutablePureComponent { +export default class AutosuggestTextarea extends ImmutablePureComponent { static propTypes = { value: PropTypes.string, @@ -196,5 +196,3 @@ class AutosuggestTextarea extends ImmutablePureComponent { } } - -export default AutosuggestTextarea; diff --git a/app/javascript/mastodon/components/avatar.js b/app/javascript/mastodon/components/avatar.js index 3531a42b5..4f8170657 100644 --- a/app/javascript/mastodon/components/avatar.js +++ b/app/javascript/mastodon/components/avatar.js @@ -1,7 +1,7 @@ import React from 'react'; import PropTypes from 'prop-types'; -class Avatar extends React.PureComponent { +export default class Avatar extends React.PureComponent { static propTypes = { src: PropTypes.string.isRequired, @@ -66,5 +66,3 @@ class Avatar extends React.PureComponent { } } - -export default Avatar; diff --git a/app/javascript/mastodon/components/avatar_overlay.js b/app/javascript/mastodon/components/avatar_overlay.js index c82c89637..de43e0ef5 100644 --- a/app/javascript/mastodon/components/avatar_overlay.js +++ b/app/javascript/mastodon/components/avatar_overlay.js @@ -1,7 +1,7 @@ import React from 'react'; import PropTypes from 'prop-types'; -class AvatarOverlay extends React.PureComponent { +export default class AvatarOverlay extends React.PureComponent { static propTypes = { staticSrc: PropTypes.string.isRequired, @@ -28,5 +28,3 @@ class AvatarOverlay extends React.PureComponent { } } - -export default AvatarOverlay; diff --git a/app/javascript/mastodon/components/button.js b/app/javascript/mastodon/components/button.js index 52af193e7..51e2e6a7a 100644 --- a/app/javascript/mastodon/components/button.js +++ b/app/javascript/mastodon/components/button.js @@ -2,7 +2,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import classNames from 'classnames'; -class Button extends React.PureComponent { +export default class Button extends React.PureComponent { static propTypes = { text: PropTypes.node, @@ -61,5 +61,3 @@ class Button extends React.PureComponent { } } - -export default Button; diff --git a/app/javascript/mastodon/components/column.js b/app/javascript/mastodon/components/column.js index 157a89c0e..93f1d6260 100644 --- a/app/javascript/mastodon/components/column.js +++ b/app/javascript/mastodon/components/column.js @@ -1,8 +1,9 @@ import React from 'react'; import PropTypes from 'prop-types'; +import detectPassiveEvents from 'detect-passive-events'; import scrollTop from '../scroll'; -class Column extends React.PureComponent { +export default class Column extends React.PureComponent { static propTypes = { children: PropTypes.node, @@ -30,16 +31,22 @@ class Column extends React.PureComponent { this.node = c; } + componentDidMount () { + this.node.addEventListener('wheel', this.handleWheel, detectPassiveEvents ? { passive: true } : false); + } + + componentWillUnmount () { + this.node.removeEventListener('wheel', this.handleWheel); + } + render () { const { children } = this.props; return ( -
+
{children}
); } } - -export default Column; diff --git a/app/javascript/mastodon/components/column_back_button.js b/app/javascript/mastodon/components/column_back_button.js index 3add65968..ba2736d7a 100644 --- a/app/javascript/mastodon/components/column_back_button.js +++ b/app/javascript/mastodon/components/column_back_button.js @@ -2,15 +2,15 @@ import React from 'react'; import { FormattedMessage } from 'react-intl'; import PropTypes from 'prop-types'; -class ColumnBackButton extends React.PureComponent { +export default class ColumnBackButton extends React.PureComponent { static contextTypes = { router: PropTypes.object, }; handleClick = () => { - if (window.history && window.history.length === 1) this.context.router.push('/'); - else this.context.router.goBack(); + if (window.history && window.history.length === 1) this.context.router.history.push('/'); + else this.context.router.history.goBack(); } render () { @@ -23,5 +23,3 @@ class ColumnBackButton extends React.PureComponent { } } - -export default ColumnBackButton; diff --git a/app/javascript/mastodon/components/column_back_button_slim.js b/app/javascript/mastodon/components/column_back_button_slim.js index 2d3f1b57a..3b4f46d99 100644 --- a/app/javascript/mastodon/components/column_back_button_slim.js +++ b/app/javascript/mastodon/components/column_back_button_slim.js @@ -2,15 +2,15 @@ import React from 'react'; import { FormattedMessage } from 'react-intl'; import PropTypes from 'prop-types'; -class ColumnBackButtonSlim extends React.PureComponent { +export default class ColumnBackButtonSlim extends React.PureComponent { static contextTypes = { router: PropTypes.object, }; handleClick = () => { - if (window.history && window.history.length === 1) this.context.router.push('/'); - else this.context.router.goBack(); + if (window.history && window.history.length === 1) this.context.router.history.push('/'); + else this.context.router.history.goBack(); } render () { @@ -25,5 +25,3 @@ class ColumnBackButtonSlim extends React.PureComponent { } } - -export default ColumnBackButtonSlim; diff --git a/app/javascript/mastodon/components/column_collapsable.js b/app/javascript/mastodon/components/column_collapsable.js deleted file mode 100644 index c7c953acd..000000000 --- a/app/javascript/mastodon/components/column_collapsable.js +++ /dev/null @@ -1,52 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; - -class ColumnCollapsable extends React.PureComponent { - - static propTypes = { - icon: PropTypes.string.isRequired, - title: PropTypes.string, - fullHeight: PropTypes.number.isRequired, - children: PropTypes.node, - onCollapse: PropTypes.func, - }; - - state = { - collapsed: true, - animating: false, - }; - - handleToggleCollapsed = () => { - const currentState = this.state.collapsed; - - this.setState({ collapsed: !currentState, animating: true }); - - if (!currentState && this.props.onCollapse) { - this.props.onCollapse(); - } - } - - handleTransitionEnd = () => { - this.setState({ animating: false }); - } - - render () { - const { icon, title, fullHeight, children } = this.props; - const { collapsed, animating } = this.state; - - return ( -
-
- -
- -
- {(!collapsed || animating) && children} -
-
- ); - } - -} - -export default ColumnCollapsable; diff --git a/app/javascript/mastodon/components/column_header.js b/app/javascript/mastodon/components/column_header.js index 175ee800f..d3256fbec 100644 --- a/app/javascript/mastodon/components/column_header.js +++ b/app/javascript/mastodon/components/column_header.js @@ -1,19 +1,27 @@ import React from 'react'; import PropTypes from 'prop-types'; import classNames from 'classnames'; -import { FormattedMessage } from 'react-intl'; +import { FormattedMessage, injectIntl, defineMessages } from 'react-intl'; -class ColumnHeader extends React.PureComponent { +const messages = defineMessages({ + show: { id: 'column_header.show_settings', defaultMessage: 'Show settings' }, + hide: { id: 'column_header.hide_settings', defaultMessage: 'Hide settings' }, +}); + +@injectIntl +export default class ColumnHeader extends React.PureComponent { static contextTypes = { router: PropTypes.object, }; static propTypes = { - title: PropTypes.string.isRequired, + intl: PropTypes.object.isRequired, + title: PropTypes.node.isRequired, icon: PropTypes.string.isRequired, active: PropTypes.bool, multiColumn: PropTypes.bool, + focusable: PropTypes.bool, showBackButton: PropTypes.bool, children: PropTypes.node, pinned: PropTypes.bool, @@ -22,6 +30,10 @@ class ColumnHeader extends React.PureComponent { onClick: PropTypes.func, }; + static defaultProps = { + focusable: true, + } + state = { collapsed: true, animating: false, @@ -45,8 +57,8 @@ class ColumnHeader extends React.PureComponent { } handleBackClick = () => { - if (window.history && window.history.length === 1) this.context.router.push('/'); - else this.context.router.goBack(); + if (window.history && window.history.length === 1) this.context.router.history.push('/'); + else this.context.router.history.goBack(); } handleTransitionEnd = () => { @@ -54,7 +66,7 @@ class ColumnHeader extends React.PureComponent { } render () { - const { title, icon, active, children, pinned, onPin, multiColumn, showBackButton } = this.props; + const { title, icon, active, children, pinned, onPin, multiColumn, focusable, showBackButton, intl: { formatMessage } } = this.props; const { collapsed, animating } = this.state; const wrapperClassName = classNames('column-header__wrapper', { @@ -116,12 +128,12 @@ class ColumnHeader extends React.PureComponent { } if (children || multiColumn) { - collapseButton = ; + collapseButton = ; } return (
-
+
{title} @@ -131,8 +143,8 @@ class ColumnHeader extends React.PureComponent {
-
-
+
+
{(!collapsed || animating) && collapsedContent}
@@ -141,5 +153,3 @@ class ColumnHeader extends React.PureComponent { } } - -export default ColumnHeader; diff --git a/app/javascript/mastodon/components/display_name.js b/app/javascript/mastodon/components/display_name.js index 6fbc1dfc0..dc3665a2b 100644 --- a/app/javascript/mastodon/components/display_name.js +++ b/app/javascript/mastodon/components/display_name.js @@ -3,7 +3,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes'; import escapeTextContentForBrowser from 'escape-html'; import emojify from '../emoji'; -class DisplayName extends React.PureComponent { +export default class DisplayName extends React.PureComponent { static propTypes = { account: ImmutablePropTypes.map.isRequired, @@ -21,5 +21,3 @@ class DisplayName extends React.PureComponent { } } - -export default DisplayName; diff --git a/app/javascript/mastodon/components/dropdown_menu.js b/app/javascript/mastodon/components/dropdown_menu.js index 5712cffab..98323b069 100644 --- a/app/javascript/mastodon/components/dropdown_menu.js +++ b/app/javascript/mastodon/components/dropdown_menu.js @@ -2,7 +2,7 @@ import React from 'react'; import Dropdown, { DropdownTrigger, DropdownContent } from 'react-simple-dropdown'; import PropTypes from 'prop-types'; -class DropdownMenu extends React.PureComponent { +export default class DropdownMenu extends React.PureComponent { static contextTypes = { router: PropTypes.object, @@ -14,6 +14,7 @@ class DropdownMenu extends React.PureComponent { size: PropTypes.number.isRequired, direction: PropTypes.string, ariaLabel: PropTypes.string, + disabled: PropTypes.bool, }; static defaultProps = { @@ -41,7 +42,7 @@ class DropdownMenu extends React.PureComponent { action(); } else if (to) { e.preventDefault(); - this.context.router.push(to); + this.context.router.history.push(to); } this.dropdown.hide(); @@ -56,7 +57,7 @@ class DropdownMenu extends React.PureComponent { return
  • ; } - const { text, action, href = '#' } = item; + const { text, href = '#' } = item; return (
  • @@ -68,9 +69,19 @@ class DropdownMenu extends React.PureComponent { } render () { - const { icon, items, size, direction, ariaLabel } = this.props; - const { expanded } = this.state; + const { icon, items, size, direction, ariaLabel, disabled } = this.props; + const { expanded } = this.state; const directionClass = (direction === 'left') ? 'dropdown__left' : 'dropdown__right'; + const iconStyle = { fontSize: `${size}px`, width: `${size}px`, lineHeight: `${size}px` }; + const iconClassname = `fa fa-fw fa-${icon} dropdown__icon`; + + if (disabled) { + return ( +
    + +
    + ); + } const dropdownItems = expanded && (
      @@ -80,8 +91,8 @@ class DropdownMenu extends React.PureComponent { return ( - - + + @@ -92,5 +103,3 @@ class DropdownMenu extends React.PureComponent { } } - -export default DropdownMenu; diff --git a/app/javascript/mastodon/components/extended_video_player.js b/app/javascript/mastodon/components/extended_video_player.js index 4d92bd779..5ab5e9e58 100644 --- a/app/javascript/mastodon/components/extended_video_player.js +++ b/app/javascript/mastodon/components/extended_video_player.js @@ -1,10 +1,12 @@ import React from 'react'; import PropTypes from 'prop-types'; -class ExtendedVideoPlayer extends React.PureComponent { +export default class ExtendedVideoPlayer extends React.PureComponent { static propTypes = { src: PropTypes.string.isRequired, + width: PropTypes.number, + height: PropTypes.number, time: PropTypes.number, controls: PropTypes.bool.isRequired, muted: PropTypes.bool.isRequired, @@ -44,5 +46,3 @@ class ExtendedVideoPlayer extends React.PureComponent { } } - -export default ExtendedVideoPlayer; diff --git a/app/javascript/mastodon/components/icon_button.js b/app/javascript/mastodon/components/icon_button.js index 302e63df5..ac734f5ad 100644 --- a/app/javascript/mastodon/components/icon_button.js +++ b/app/javascript/mastodon/components/icon_button.js @@ -3,7 +3,7 @@ import Motion from 'react-motion/lib/Motion'; import spring from 'react-motion/lib/spring'; import PropTypes from 'prop-types'; -class IconButton extends React.PureComponent { +export default class IconButton extends React.PureComponent { static propTypes = { className: PropTypes.string, @@ -86,5 +86,3 @@ class IconButton extends React.PureComponent { } } - -export default IconButton; diff --git a/app/javascript/mastodon/components/load_more.js b/app/javascript/mastodon/components/load_more.js index fa0caaae9..e2fe1fed7 100644 --- a/app/javascript/mastodon/components/load_more.js +++ b/app/javascript/mastodon/components/load_more.js @@ -2,20 +2,25 @@ import React from 'react'; import { FormattedMessage } from 'react-intl'; import PropTypes from 'prop-types'; -class LoadMore extends React.PureComponent { +export default class LoadMore extends React.PureComponent { static propTypes = { onClick: PropTypes.func, + visible: PropTypes.bool, + } + + static defaultProps = { + visible: true, } render() { + const { visible } = this.props; + return ( - ); } } - -export default LoadMore; diff --git a/app/javascript/mastodon/components/loading_indicator.js b/app/javascript/mastodon/components/loading_indicator.js index c09244834..d6a5adb6f 100644 --- a/app/javascript/mastodon/components/loading_indicator.js +++ b/app/javascript/mastodon/components/loading_indicator.js @@ -3,6 +3,7 @@ import { FormattedMessage } from 'react-intl'; const LoadingIndicator = () => (
      +
      ); diff --git a/app/javascript/mastodon/components/media_gallery.js b/app/javascript/mastodon/components/media_gallery.js index 465130cec..92d7d494e 100644 --- a/app/javascript/mastodon/components/media_gallery.js +++ b/app/javascript/mastodon/components/media_gallery.js @@ -11,18 +11,44 @@ const messages = defineMessages({ class Item extends React.PureComponent { + static contextTypes = { + router: PropTypes.object, + }; + static propTypes = { attachment: ImmutablePropTypes.map.isRequired, index: PropTypes.number.isRequired, size: PropTypes.number.isRequired, onClick: PropTypes.func.isRequired, - autoPlayGif: PropTypes.bool.isRequired, + autoPlayGif: PropTypes.bool, }; + static defaultProps = { + autoPlayGif: false, + }; + + handleMouseEnter = (e) => { + if (this.hoverToPlay()) { + e.target.play(); + } + } + + handleMouseLeave = (e) => { + if (this.hoverToPlay()) { + e.target.pause(); + e.target.currentTime = 0; + } + } + + hoverToPlay () { + const { attachment, autoPlayGif } = this.props; + return !autoPlayGif && attachment.get('type') === 'gifv'; + } + handleClick = (e) => { const { index, onClick } = this.props; - if (e.button === 0) { + if (this.context.router && e.button === 0) { e.preventDefault(); onClick(index); } @@ -85,14 +111,26 @@ class Item extends React.PureComponent { let thumbnail = ''; if (attachment.get('type') === 'image') { + const previewUrl = attachment.get('preview_url'); + const previewWidth = attachment.getIn(['meta', 'small', 'width']); + + const originalUrl = attachment.get('url'); + const originalWidth = attachment.getIn(['meta', 'original', 'width']); + + const hasSize = typeof originalWidth === 'number' && typeof previewWidth === 'number'; + + const srcSet = hasSize && `${originalUrl} ${originalWidth}w, ${previewUrl} ${previewWidth}w`; + const sizes = hasSize && `(min-width: 1025px) ${320 * (width / 100)}px, ${width}vw`; + thumbnail = ( - + > + + ); } else if (attachment.get('type') === 'gifv') { const autoPlay = !isIOS() && this.props.autoPlayGif; @@ -104,6 +142,8 @@ class Item extends React.PureComponent { role='application' src={attachment.get('url')} onClick={this.handleClick} + onMouseEnter={this.handleMouseEnter} + onMouseLeave={this.handleMouseLeave} autoPlay={autoPlay} loop muted @@ -123,7 +163,8 @@ class Item extends React.PureComponent { } -class MediaGallery extends React.PureComponent { +@injectIntl +export default class MediaGallery extends React.PureComponent { static propTypes = { sensitive: PropTypes.bool, @@ -131,14 +172,24 @@ class MediaGallery extends React.PureComponent { height: PropTypes.number.isRequired, onOpenMedia: PropTypes.func.isRequired, intl: PropTypes.object.isRequired, - autoPlayGif: PropTypes.bool.isRequired, + autoPlayGif: PropTypes.bool, + }; + + static defaultProps = { + autoPlayGif: false, }; state = { visible: !this.props.sensitive, }; - handleOpen = (e) => { + componentWillReceiveProps (nextProps) { + if (nextProps.sensitive !== this.props.sensitive) { + this.setState({ visible: !nextProps.sensitive }); + } + } + + handleOpen = () => { this.setState({ visible: !this.state.visible }); } @@ -183,5 +234,3 @@ class MediaGallery extends React.PureComponent { } } - -export default injectIntl(MediaGallery); diff --git a/app/javascript/mastodon/components/permalink.js b/app/javascript/mastodon/components/permalink.js index b45969d85..d726d37a2 100644 --- a/app/javascript/mastodon/components/permalink.js +++ b/app/javascript/mastodon/components/permalink.js @@ -1,7 +1,7 @@ import React from 'react'; import PropTypes from 'prop-types'; -class Permalink extends React.PureComponent { +export default class Permalink extends React.PureComponent { static contextTypes = { router: PropTypes.object, @@ -15,9 +15,9 @@ class Permalink extends React.PureComponent { }; handleClick = (e) => { - if (e.button === 0 && !(e.ctrlKey || e.metaKey)) { + if (this.context.router && e.button === 0 && !(e.ctrlKey || e.metaKey)) { e.preventDefault(); - this.context.router.push(this.props.to); + this.context.router.history.push(this.props.to); } } @@ -25,12 +25,10 @@ class Permalink extends React.PureComponent { const { href, children, className, ...other } = this.props; return ( - + {children} ); } } - -export default Permalink; diff --git a/app/javascript/mastodon/components/relative_timestamp.js b/app/javascript/mastodon/components/relative_timestamp.js index 3eed88df8..2717d2326 100644 --- a/app/javascript/mastodon/components/relative_timestamp.js +++ b/app/javascript/mastodon/components/relative_timestamp.js @@ -11,7 +11,8 @@ const dateFormatOptions = { minute: '2-digit', }; -class RelativeTimestamp extends React.Component { +@injectIntl +export default class RelativeTimestamp extends React.Component { static propTypes = { intl: PropTypes.object.isRequired, @@ -37,5 +38,3 @@ class RelativeTimestamp extends React.Component { } } - -export default injectIntl(RelativeTimestamp); diff --git a/app/javascript/mastodon/components/setting_text.js b/app/javascript/mastodon/components/setting_text.js index d4f177f8a..dd975bc99 100644 --- a/app/javascript/mastodon/components/setting_text.js +++ b/app/javascript/mastodon/components/setting_text.js @@ -2,7 +2,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import ImmutablePropTypes from 'react-immutable-proptypes'; -class SettingText extends React.PureComponent { +export default class SettingText extends React.PureComponent { static propTypes = { settings: ImmutablePropTypes.map.isRequired, @@ -29,5 +29,3 @@ class SettingText extends React.PureComponent { } } - -export default SettingText; diff --git a/app/javascript/mastodon/components/status.js b/app/javascript/mastodon/components/status.js index 7ca898c39..6b9fdd2af 100644 --- a/app/javascript/mastodon/components/status.js +++ b/app/javascript/mastodon/components/status.js @@ -5,9 +5,6 @@ import Avatar from './avatar'; import AvatarOverlay from './avatar_overlay'; import RelativeTimestamp from './relative_timestamp'; import DisplayName from './display_name'; -import MediaGallery from './media_gallery'; -import VideoPlayer from './video_player'; -import AttachmentList from './attachment_list'; import StatusContent from './status_content'; import StatusActionBar from './status_action_bar'; import { FormattedMessage } from 'react-intl'; @@ -15,8 +12,14 @@ 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'; -class Status extends ImmutablePureComponent { +// 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 { static contextTypes = { router: PropTypes.object, @@ -86,9 +89,24 @@ class Status extends ImmutablePureComponent { 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; + } + // Edge 15 doesn't support isIntersecting, but we can infer it // https://developer.microsoft.com/en-us/microsoft-edge/platform/issues/12156111/ // https://github.com/WICG/IntersectionObserver/issues/211 @@ -106,6 +124,10 @@ class Status extends ImmutablePureComponent { } 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. @@ -113,27 +135,24 @@ class Status extends ImmutablePureComponent { this.setState((prevState) => ({ isHidden: !prevState.isIntersecting })); } - saveHeight = () => { - if (this.node && this.node.children.length !== 0) { - this.height = this.node.clientHeight; - } - } - handleRef = (node) => { this.node = node; - this.saveHeight(); } handleClick = () => { + if (!this.context.router) { + return; + } + const { status } = this.props; - this.context.router.push(`/statuses/${status.getIn(['reblog', 'id'], status.get('id'))}`); + this.context.router.history.push(`/statuses/${status.getIn(['reblog', 'id'], status.get('id'))}`); } handleAccountClick = (e) => { - if (e.button === 0) { + if (this.context.router && e.button === 0) { const id = Number(e.currentTarget.getAttribute('data-id')); e.preventDefault(); - this.context.router.push(`/accounts/${id}`); + this.context.router.history.push(`/accounts/${id}`); } } @@ -141,10 +160,21 @@ class Status extends ImmutablePureComponent { this.setState({ isExpanded: !this.state.isExpanded }); }; + renderLoadingMediaGallery () { + return
      ; + } + + renderLoadingVideoPlayer () { + return
      ; + } + render () { let media = null; let statusAvatar; - const { status, account, ...other } = this.props; + + // Exclude intersectionObserverWrapper from `other` variable + // because intersection is managed in here. + const { status, account, intersectionObserverWrapper, ...other } = this.props; const { isExpanded, isIntersecting, isHidden } = this.state; if (status === null) { @@ -185,9 +215,17 @@ class Status extends ImmutablePureComponent { if (status.get('media_attachments').some(item => item.get('type') === 'unknown')) { } else if (status.getIn(['media_attachments', 0, 'type']) === 'video') { - media = ; + media = ( + + {Component => } + + ); } else { - media = ; + media = ( + + {Component => } + + ); } } @@ -202,7 +240,7 @@ class Status extends ImmutablePureComponent { - + {media} @@ -221,5 +259,3 @@ class Status extends ImmutablePureComponent { } } - -export default Status; diff --git a/app/javascript/mastodon/components/status_action_bar.js b/app/javascript/mastodon/components/status_action_bar.js index 99e01d972..4e02e6fad 100644 --- a/app/javascript/mastodon/components/status_action_bar.js +++ b/app/javascript/mastodon/components/status_action_bar.js @@ -12,6 +12,7 @@ const messages = defineMessages({ mute: { id: 'account.mute', defaultMessage: 'Mute @{name}' }, block: { id: 'account.block', defaultMessage: 'Block @{name}' }, reply: { id: 'status.reply', defaultMessage: 'Reply' }, + share: { id: 'status.share', defaultMessage: 'Share' }, replyAll: { id: 'status.replyAll', defaultMessage: 'Reply to thread' }, reblog: { id: 'status.reblog', defaultMessage: 'Boost' }, cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be boosted' }, @@ -22,7 +23,8 @@ const messages = defineMessages({ unmuteConversation: { id: 'status.unmute_conversation', defaultMessage: 'Unmute conversation' }, }); -class StatusActionBar extends ImmutablePureComponent { +@injectIntl +export default class StatusActionBar extends ImmutablePureComponent { static contextTypes = { router: PropTypes.object, @@ -39,7 +41,7 @@ class StatusActionBar extends ImmutablePureComponent { onBlock: PropTypes.func, onReport: PropTypes.func, onMuteConversation: PropTypes.func, - me: PropTypes.number.isRequired, + me: PropTypes.number, withDismiss: PropTypes.bool, intl: PropTypes.object.isRequired, }; @@ -53,7 +55,14 @@ class StatusActionBar extends ImmutablePureComponent { ] handleReplyClick = () => { - this.props.onReply(this.props.status, this.context.router); + this.props.onReply(this.props.status, this.context.router.history); + } + + handleShareClick = () => { + navigator.share({ + text: this.props.status.get('search_index'), + url: this.props.status.get('url'), + }); } handleFavouriteClick = () => { @@ -69,7 +78,7 @@ class StatusActionBar extends ImmutablePureComponent { } handleMentionClick = () => { - this.props.onMention(this.props.status.get('account'), this.context.router); + this.props.onMention(this.props.status.get('account'), this.context.router.history); } handleMuteClick = () => { @@ -81,12 +90,11 @@ class StatusActionBar extends ImmutablePureComponent { } handleOpen = () => { - this.context.router.push(`/statuses/${this.props.status.get('id')}`); + this.context.router.history.push(`/statuses/${this.props.status.get('id')}`); } handleReport = () => { this.props.onReport(this.props.status); - this.context.router.push('/report'); } handleConversationMuteClick = () => { @@ -97,6 +105,7 @@ class StatusActionBar extends ImmutablePureComponent { 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; let menu = []; let reblogIcon = 'retweet'; @@ -135,19 +144,22 @@ class StatusActionBar extends ImmutablePureComponent { replyTitle = intl.formatMessage(messages.replyAll); } + const shareButton = ('share' in navigator) && status.get('visibility') === 'public' && ( + + ); + return (
      - - - + + + + {shareButton}
      - +
      ); } } - -export default injectIntl(StatusActionBar); diff --git a/app/javascript/mastodon/components/status_content.js b/app/javascript/mastodon/components/status_content.js index 89031b3dc..1b803a22e 100644 --- a/app/javascript/mastodon/components/status_content.js +++ b/app/javascript/mastodon/components/status_content.js @@ -6,8 +6,9 @@ import emojify from '../emoji'; import { isRtl } from '../rtl'; import { FormattedMessage } from 'react-intl'; import Permalink from './permalink'; +import classnames from 'classnames'; -class StatusContent extends React.PureComponent { +export default class StatusContent extends React.PureComponent { static contextTypes = { router: PropTypes.object, @@ -17,7 +18,6 @@ class StatusContent extends React.PureComponent { status: ImmutablePropTypes.map.isRequired, expanded: PropTypes.bool, onExpandedToggle: PropTypes.func, - onHeightUpdate: PropTypes.func, onClick: PropTypes.func, }; @@ -25,14 +25,18 @@ class StatusContent extends React.PureComponent { hidden: true, }; - componentDidMount () { + _updateStatusLinks () { const node = this.node; const links = node.querySelectorAll('a'); for (var i = 0; i < links.length; ++i) { - let link = links[i]; + let link = links[i]; + if (link.classList.contains('status-link')) { + continue; + } + link.classList.add('status-link'); + let mention = this.props.status.get('mentions').find(item => link.href === item.get('url')); - let media = this.props.status.get('media_attachments').find(item => link.href === item.get('text_url') || (item.get('remote_url').length > 0 && link.href === item.get('remote_url'))); if (mention) { link.addEventListener('click', this.onMentionClick.bind(this, mention), false); @@ -40,32 +44,35 @@ class StatusContent extends React.PureComponent { } else if (link.textContent[0] === '#' || (link.previousSibling && link.previousSibling.textContent && link.previousSibling.textContent[link.previousSibling.textContent.length - 1] === '#')) { link.addEventListener('click', this.onHashtagClick.bind(this, link.text), false); } else { - link.setAttribute('target', '_blank'); - link.setAttribute('rel', 'noopener'); link.setAttribute('title', link.href); } + + link.setAttribute('target', '_blank'); + link.setAttribute('rel', 'noopener'); } } + componentDidMount () { + this._updateStatusLinks(); + } + componentDidUpdate () { - if (this.props.onHeightUpdate) { - this.props.onHeightUpdate(); - } + this._updateStatusLinks(); } onMentionClick = (mention, e) => { - if (e.button === 0) { + if (this.context.router && e.button === 0) { e.preventDefault(); - this.context.router.push(`/accounts/${mention.get('id')}`); + this.context.router.history.push(`/accounts/${mention.get('id')}`); } } onHashtagClick = (hashtag, e) => { hashtag = hashtag.replace(/^#/, '').toLowerCase(); - if (e.button === 0) { + if (this.context.router && e.button === 0) { e.preventDefault(); - this.context.router.push(`/timelines/tag/${hashtag}`); + this.context.router.history.push(`/timelines/tag/${hashtag}`); } } @@ -81,7 +88,7 @@ class StatusContent extends React.PureComponent { const [ startX, startY ] = this.startXY; const [ deltaX, deltaY ] = [Math.abs(e.clientX - startX), Math.abs(e.clientY - startY)]; - if (e.target.localName === 'button' || e.target.localName === 'span' || e.target.localName === 'a' || (e.target.parentNode && e.target.parentNode.localName === 'a')) { + if (e.target.localName === 'button' || e.target.localName === 'a' || (e.target.parentNode && (e.target.parentNode.localName === 'button' || e.target.parentNode.localName === 'a'))) { return; } @@ -115,6 +122,9 @@ class StatusContent extends React.PureComponent { const content = { __html: emojify(status.get('content')) }; const spoilerContent = { __html: emojify(escapeTextContentForBrowser(status.get('spoiler_text', ''))) }; const directionStyle = { direction: 'ltr' }; + const classNames = classnames('status__content', { + 'status__content--with-action': this.props.onClick && this.context.router, + }); if (isRtl(status.get('search_index'))) { directionStyle.direction = 'rtl'; @@ -136,7 +146,7 @@ class StatusContent extends React.PureComponent { } return ( -
      +
      @@ -172,5 +182,3 @@ class StatusContent extends React.PureComponent { } } - -export default StatusContent; diff --git a/app/javascript/mastodon/components/status_list.js b/app/javascript/mastodon/components/status_list.js index b5a853589..48858cf13 100644 --- a/app/javascript/mastodon/components/status_list.js +++ b/app/javascript/mastodon/components/status_list.js @@ -6,8 +6,9 @@ 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'; -class StatusList extends ImmutablePureComponent { +export default class StatusList extends ImmutablePureComponent { static propTypes = { scrollKey: PropTypes.string.isRequired, @@ -29,28 +30,44 @@ class StatusList extends ImmutablePureComponent { intersectionObserverWrapper = new IntersectionObserverWrapper(); - handleScroll = (e) => { - const { scrollTop, scrollHeight, clientHeight } = e.target; - const offset = scrollHeight - scrollTop - clientHeight; - this._oldScrollPosition = scrollHeight - scrollTop; + handleScroll = throttle(() => { + if (this.node) { + const { scrollTop, scrollHeight, clientHeight } = this.node; + const offset = scrollHeight - scrollTop - clientHeight; + this._oldScrollPosition = scrollHeight - scrollTop; - if (250 > 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(); + 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) { - if ((prevProps.statusIds.size < this.props.statusIds.size && prevProps.statusIds.first() !== this.props.statusIds.first() && !!this._oldScrollPosition) && this.node.scrollTop > 0) { - this.node.scrollTop = this.node.scrollHeight - this._oldScrollPosition; + // 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; + } } } @@ -88,15 +105,11 @@ class StatusList extends ImmutablePureComponent { } render () { - const { statusIds, onScrollToBottom, scrollKey, trackScroll, shouldUpdateScroll, isLoading, hasMore, prepend, emptyMessage } = this.props; + const { statusIds, scrollKey, trackScroll, shouldUpdateScroll, isLoading, hasMore, prepend, emptyMessage } = this.props; - let loadMore = null; + const loadMore = 0 && hasMore} onClick={this.handleLoadMore} />; let scrollableArea = null; - if (!isLoading && statusIds.size > 0 && hasMore) { - loadMore = ; - } - if (isLoading || statusIds.size > 0 || !emptyMessage) { scrollableArea = (
      @@ -131,5 +144,3 @@ class StatusList extends ImmutablePureComponent { } } - -export default StatusList; diff --git a/app/javascript/mastodon/components/video_player.js b/app/javascript/mastodon/components/video_player.js index 66c3a64bc..999cf42d9 100644 --- a/app/javascript/mastodon/components/video_player.js +++ b/app/javascript/mastodon/components/video_player.js @@ -11,7 +11,12 @@ const messages = defineMessages({ expand_video: { id: 'video_player.expand', defaultMessage: 'Expand video' }, }); -class VideoPlayer extends React.PureComponent { +@injectIntl +export default class VideoPlayer extends React.PureComponent { + + static contextTypes = { + router: PropTypes.object, + }; static propTypes = { media: ImmutablePropTypes.map.isRequired, @@ -118,11 +123,15 @@ class VideoPlayer extends React.PureComponent {
      ); - let expandButton = ( -
      - -
      - ); + let expandButton = ''; + + if (this.context.router) { + expandButton = ( +
      + +
      + ); + } let muteButton = ''; @@ -137,7 +146,7 @@ class VideoPlayer extends React.PureComponent { if (!this.state.visible) { if (sensitive) { return ( -
      +
      {spoilerButton} @@ -145,7 +154,7 @@ class VideoPlayer extends React.PureComponent { ); } else { return ( -
      +
      {spoilerButton} @@ -193,5 +202,3 @@ class VideoPlayer extends React.PureComponent { } } - -export default injectIntl(VideoPlayer); diff --git a/app/javascript/mastodon/containers/account_container.js b/app/javascript/mastodon/containers/account_container.js index 1426bcaa4..ca1efd0e5 100644 --- a/app/javascript/mastodon/containers/account_container.js +++ b/app/javascript/mastodon/containers/account_container.js @@ -1,4 +1,6 @@ +import React from 'react'; import { connect } from 'react-redux'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import { makeGetAccount } from '../selectors'; import Account from '../components/account'; import { @@ -9,6 +11,11 @@ import { muteAccount, unmuteAccount, } from '../actions/accounts'; +import { openModal } from '../actions/modal'; + +const messages = defineMessages({ + unfollowConfirm: { id: 'confirmations.unfollow.confirm', defaultMessage: 'Unfollow' }, +}); const makeMapStateToProps = () => { const getAccount = makeGetAccount(); @@ -16,15 +23,25 @@ const makeMapStateToProps = () => { const mapStateToProps = (state, props) => ({ account: getAccount(state, props.id), me: state.getIn(['meta', 'me']), + unfollowModal: state.getIn(['meta', 'unfollow_modal']), }); return mapStateToProps; }; -const mapDispatchToProps = (dispatch) => ({ +const mapDispatchToProps = (dispatch, { intl }) => ({ + onFollow (account) { if (account.getIn(['relationship', 'following'])) { - dispatch(unfollowAccount(account.get('id'))); + if (this.unfollowModal) { + dispatch(openModal('CONFIRM', { + message: @{account.get('acct')} }} />, + confirm: intl.formatMessage(messages.unfollowConfirm), + onConfirm: () => dispatch(unfollowAccount(account.get('id'))), + })); + } else { + dispatch(unfollowAccount(account.get('id'))); + } } else { dispatch(followAccount(account.get('id'))); } @@ -45,6 +62,7 @@ const mapDispatchToProps = (dispatch) => ({ dispatch(muteAccount(account.get('id'))); } }, + }); -export default connect(makeMapStateToProps, mapDispatchToProps)(Account); +export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Account)); diff --git a/app/javascript/mastodon/containers/mastodon.js b/app/javascript/mastodon/containers/mastodon.js index 5e009cfa3..87ab6023c 100644 --- a/app/javascript/mastodon/containers/mastodon.js +++ b/app/javascript/mastodon/containers/mastodon.js @@ -3,7 +3,6 @@ import { Provider } from 'react-redux'; import PropTypes from 'prop-types'; import configureStore from '../store/configureStore'; import { - refreshTimelineSuccess, updateTimeline, deleteFromTimelines, refreshHomeTimeline, @@ -12,35 +11,10 @@ import { } from '../actions/timelines'; import { showOnboardingOnce } from '../actions/onboarding'; import { updateNotifications, refreshNotifications } from '../actions/notifications'; -import createBrowserHistory from 'history/lib/createBrowserHistory'; -import applyRouterMiddleware from 'react-router/lib/applyRouterMiddleware'; -import useRouterHistory from 'react-router/lib/useRouterHistory'; -import Router from 'react-router/lib/Router'; -import Route from 'react-router/lib/Route'; -import IndexRedirect from 'react-router/lib/IndexRedirect'; -import IndexRoute from 'react-router/lib/IndexRoute'; -import { useScroll } from 'react-router-scroll'; +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 Status from '../features/status'; -import GettingStarted from '../features/getting_started'; -import PublicTimeline from '../features/public_timeline'; -import CommunityTimeline from '../features/community_timeline'; -import AccountTimeline from '../features/account_timeline'; -import AccountGallery from '../features/account_gallery'; -import HomeTimeline from '../features/home_timeline'; -import Compose from '../features/compose'; -import Followers from '../features/followers'; -import Following from '../features/following'; -import Reblogs from '../features/reblogs'; -import Favourites from '../features/favourites'; -import HashtagTimeline from '../features/hashtag_timeline'; -import Notifications from '../features/notifications'; -import FollowRequests from '../features/follow_requests'; -import GenericNotFound from '../features/generic_not_found'; -import FavouritedStatuses from '../features/favourited_statuses'; -import Blocks from '../features/blocks'; -import Mutes from '../features/mutes'; -import Report from '../features/report'; import { hydrateStore } from '../actions/store'; import createStream from '../stream'; import { IntlProvider, addLocaleData } from 'react-intl'; @@ -48,15 +22,15 @@ import { getLocale } from '../locales'; const { localeData, messages } = getLocale(); addLocaleData(localeData); -const store = configureStore(); -const initialState = JSON.parse(document.getElementById('initial-state').textContent); -store.dispatch(hydrateStore(initialState)); +export const store = configureStore(); +const hydrateAction = hydrateStore(JSON.parse(document.getElementById('initial-state').textContent)); +store.dispatch(hydrateAction); -const browserHistory = useRouterHistory(createBrowserHistory)({ - basename: '/web', -}); +export default class Mastodon extends React.PureComponent { -class Mastodon extends React.PureComponent { + static propTypes = { + locale: PropTypes.string.isRequired, + }; componentDidMount() { const { locale } = this.props; @@ -136,45 +110,14 @@ class Mastodon extends React.PureComponent { return ( - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + ); } } - -Mastodon.propTypes = { - locale: PropTypes.string.isRequired, -}; - -export default Mastodon; diff --git a/app/javascript/mastodon/containers/status_container.js b/app/javascript/mastodon/containers/status_container.js index 2592e9a69..438ecfe43 100644 --- a/app/javascript/mastodon/containers/status_container.js +++ b/app/javascript/mastodon/containers/status_container.js @@ -19,8 +19,6 @@ import { import { muteStatus, unmuteStatus, deleteStatus } from '../actions/statuses'; import { initReport } from '../actions/reports'; import { openModal } from '../actions/modal'; -import { createSelector } from 'reselect'; -import { isMobile } from '../is_mobile'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; const messages = defineMessages({ diff --git a/app/javascript/mastodon/containers/timeline_container.js b/app/javascript/mastodon/containers/timeline_container.js new file mode 100644 index 000000000..6b545ef09 --- /dev/null +++ b/app/javascript/mastodon/containers/timeline_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 PublicTimeline from '../features/standalone/public_timeline'; + +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/emoji.js b/app/javascript/mastodon/emoji.js index 01d01fb72..9b58cacf5 100644 --- a/app/javascript/mastodon/emoji.js +++ b/app/javascript/mastodon/emoji.js @@ -1,35 +1,34 @@ -import emojione from 'emojione'; +import { unicodeMapping } from './emojione_light'; +import Trie from 'substring-trie'; -const toImage = str => shortnameToImage(unicodeToImage(str)); +const trie = new Trie(Object.keys(unicodeMapping)); -const unicodeToImage = str => { - const mappedUnicode = emojione.mapUnicodeToShort(); - - return str.replace(emojione.regUnicode, unicodeChar => { - if (typeof unicodeChar === 'undefined' || unicodeChar === '' || !(unicodeChar in emojione.jsEscapeMap)) { - return unicodeChar; +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) { + 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 unicode = emojione.jsEscapeMap[unicodeChar]; - const short = mappedUnicode[unicode]; - const filename = emojione.emojioneList[short].fname; - const alt = emojione.convert(unicode.toUpperCase()); - - return `${alt}`; - }); -}; - -const shortnameToImage = str => str.replace(emojione.regShortNames, shortname => { - if (typeof shortname === 'undefined' || shortname === '' || !(shortname in emojione.emojioneList)) { - return shortname; } + return str; +} - const unicode = emojione.emojioneList[shortname].unicode[emojione.emojioneList[shortname].unicode.length - 1]; - const alt = emojione.convert(unicode.toUpperCase()); - - return `${alt}`; -}); - -export default function emojify(text) { - return toImage(text); -}; +export default emojify; diff --git a/app/javascript/mastodon/emojione_light.js b/app/javascript/mastodon/emojione_light.js new file mode 100644 index 000000000..985e9dbcb --- /dev/null +++ b/app/javascript/mastodon/emojione_light.js @@ -0,0 +1,11 @@ +// @preval +// Force tree shaking on emojione by exposing just a subset of its functionality + +const emojione = require('emojione'); + +const mappedUnicode = emojione.mapUnicodeToShort(); + +module.exports.unicodeMapping = Object.keys(emojione.jsEscapeMap) + .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/extra_polyfills.js b/app/javascript/mastodon/extra_polyfills.js index 546b693b1..3acc55abd 100644 --- a/app/javascript/mastodon/extra_polyfills.js +++ b/app/javascript/mastodon/extra_polyfills.js @@ -1,2 +1,5 @@ import 'intersection-observer'; import 'requestidlecallback'; +import objectFitImages from 'object-fit-images'; + +objectFitImages(); diff --git a/app/javascript/mastodon/features/account/components/action_bar.js b/app/javascript/mastodon/features/account/components/action_bar.js index 15fdd1a50..b8df724c6 100644 --- a/app/javascript/mastodon/features/account/components/action_bar.js +++ b/app/javascript/mastodon/features/account/components/action_bar.js @@ -2,7 +2,7 @@ import React from 'react'; import ImmutablePropTypes from 'react-immutable-proptypes'; import PropTypes from 'prop-types'; import DropdownMenu from '../../../components/dropdown_menu'; -import Link from 'react-router/lib/Link'; +import Link from 'react-router-dom/Link'; import { defineMessages, injectIntl, FormattedMessage, FormattedNumber } from 'react-intl'; const messages = defineMessages({ @@ -16,12 +16,12 @@ const messages = defineMessages({ follow: { id: 'account.follow', defaultMessage: 'Follow' }, report: { id: 'account.report', defaultMessage: 'Report @{name}' }, media: { id: 'account.media', defaultMessage: 'Media' }, - disclaimer: { id: 'account.disclaimer', defaultMessage: 'This user is from another instance. This number may be larger.' }, blockDomain: { id: 'account.block_domain', defaultMessage: 'Hide everything from {domain}' }, unblockDomain: { id: 'account.unblock_domain', defaultMessage: 'Unhide {domain}' }, }); -class ActionBar extends React.PureComponent { +@injectIntl +export default class ActionBar extends React.PureComponent { static propTypes = { account: ImmutablePropTypes.map.isRequired, @@ -67,7 +67,19 @@ class ActionBar extends React.PureComponent { if (account.get('acct') !== account.get('username')) { const domain = account.get('acct').split('@')[1]; - extraInfo = *; + + extraInfo = ( +

      + + {' '} + + + +
      + ); menu.push(null); @@ -79,31 +91,33 @@ class ActionBar extends React.PureComponent { } return ( -
      -
      - -
      +
      + {extraInfo} -
      - - - {extraInfo} - +
      +
      + +
      - - - {extraInfo} - +
      + + + + - - - {extraInfo} - + + + + + + + + + +
      ); } } - -export default injectIntl(ActionBar); diff --git a/app/javascript/mastodon/features/account/components/header.js b/app/javascript/mastodon/features/account/components/header.js index 653023cba..3239b1085 100644 --- a/app/javascript/mastodon/features/account/components/header.js +++ b/app/javascript/mastodon/features/account/components/header.js @@ -17,7 +17,7 @@ const messages = defineMessages({ }); const makeMapStateToProps = () => { - const mapStateToProps = (state, props) => ({ + const mapStateToProps = state => ({ autoPlayGif: state.getIn(['meta', 'auto_play_gif']), }); @@ -70,7 +70,9 @@ class Avatar extends ImmutablePureComponent { } -class Header extends ImmutablePureComponent { +@connect(makeMapStateToProps) +@injectIntl +export default class Header extends ImmutablePureComponent { static propTypes = { account: ImmutablePropTypes.map, @@ -140,5 +142,3 @@ class Header extends ImmutablePureComponent { } } - -export default connect(makeMapStateToProps)(injectIntl(Header)); diff --git a/app/javascript/mastodon/features/account_gallery/components/media_item.js b/app/javascript/mastodon/features/account_gallery/components/media_item.js index 31c05c866..dda3d4e37 100644 --- a/app/javascript/mastodon/features/account_gallery/components/media_item.js +++ b/app/javascript/mastodon/features/account_gallery/components/media_item.js @@ -3,7 +3,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePureComponent from 'react-immutable-pure-component'; import Permalink from '../../../components/permalink'; -class MediaItem extends ImmutablePureComponent { +export default class MediaItem extends ImmutablePureComponent { static propTypes = { media: ImmutablePropTypes.map.isRequired, @@ -37,5 +37,3 @@ class MediaItem extends ImmutablePureComponent { } } - -export default MediaItem; diff --git a/app/javascript/mastodon/features/account_gallery/index.js b/app/javascript/mastodon/features/account_gallery/index.js index fcbee3c89..0cfd98f23 100644 --- a/app/javascript/mastodon/features/account_gallery/index.js +++ b/app/javascript/mastodon/features/account_gallery/index.js @@ -7,7 +7,6 @@ import { refreshAccountMediaTimeline, expandAccountMediaTimeline } from '../../a import LoadingIndicator from '../../components/loading_indicator'; import Column from '../ui/components/column'; import ColumnBackButton from '../../components/column_back_button'; -import Immutable from 'immutable'; import ImmutablePureComponent from 'react-immutable-pure-component'; import { getAccountGallery } from '../../selectors'; import MediaItem from './components/media_item'; @@ -23,7 +22,8 @@ const mapStateToProps = (state, props) => ({ autoPlayGif: state.getIn(['meta', 'auto_play_gif']), }); -class AccountGallery extends ImmutablePureComponent { +@connect(mapStateToProps) +export default class AccountGallery extends ImmutablePureComponent { static propTypes = { params: PropTypes.object.isRequired, @@ -112,5 +112,3 @@ class AccountGallery extends ImmutablePureComponent { } } - -export default connect(mapStateToProps)(AccountGallery); diff --git a/app/javascript/mastodon/features/account_timeline/components/header.js b/app/javascript/mastodon/features/account_timeline/components/header.js index d2fe86476..167a2097e 100644 --- a/app/javascript/mastodon/features/account_timeline/components/header.js +++ b/app/javascript/mastodon/features/account_timeline/components/header.js @@ -6,7 +6,7 @@ import ActionBar from '../../account/components/action_bar'; import MissingIndicator from '../../../components/missing_indicator'; import ImmutablePureComponent from 'react-immutable-pure-component'; -class Header extends ImmutablePureComponent { +export default class Header extends ImmutablePureComponent { static propTypes = { account: ImmutablePropTypes.map, @@ -33,12 +33,11 @@ class Header extends ImmutablePureComponent { } handleMention = () => { - this.props.onMention(this.props.account, this.context.router); + this.props.onMention(this.props.account, this.context.router.history); } handleReport = () => { this.props.onReport(this.props.account); - this.context.router.push('/report'); } handleMute = () => { @@ -91,5 +90,3 @@ class Header extends ImmutablePureComponent { } } - -export default Header; 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 19dd64699..baa81bbc2 100644 --- a/app/javascript/mastodon/features/account_timeline/containers/header_container.js +++ b/app/javascript/mastodon/features/account_timeline/containers/header_container.js @@ -17,6 +17,7 @@ import { blockDomain, unblockDomain } from '../../../actions/domain_blocks'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; const messages = defineMessages({ + unfollowConfirm: { id: 'confirmations.unfollow.confirm', defaultMessage: 'Unfollow' }, blockConfirm: { id: 'confirmations.block.confirm', defaultMessage: 'Block' }, muteConfirm: { id: 'confirmations.mute.confirm', defaultMessage: 'Mute' }, blockDomainConfirm: { id: 'confirmations.domain_block.confirm', defaultMessage: 'Hide entire domain' }, @@ -28,15 +29,25 @@ const makeMapStateToProps = () => { const mapStateToProps = (state, { accountId }) => ({ account: getAccount(state, Number(accountId)), me: state.getIn(['meta', 'me']), + unfollowModal: state.getIn(['meta', 'unfollow_modal']), }); return mapStateToProps; }; const mapDispatchToProps = (dispatch, { intl }) => ({ + onFollow (account) { if (account.getIn(['relationship', 'following'])) { - dispatch(unfollowAccount(account.get('id'))); + if (this.unfollowModal) { + dispatch(openModal('CONFIRM', { + message: @{account.get('acct')} }} />, + confirm: intl.formatMessage(messages.unfollowConfirm), + onConfirm: () => dispatch(unfollowAccount(account.get('id'))), + })); + } else { + dispatch(unfollowAccount(account.get('id'))); + } } else { dispatch(followAccount(account.get('id'))); } @@ -85,6 +96,7 @@ const mapDispatchToProps = (dispatch, { intl }) => ({ onUnblockDomain (domain, accountId) { dispatch(unblockDomain(domain, accountId)); }, + }); export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Header)); diff --git a/app/javascript/mastodon/features/account_timeline/index.js b/app/javascript/mastodon/features/account_timeline/index.js index 1aab8f130..3c8b63114 100644 --- a/app/javascript/mastodon/features/account_timeline/index.js +++ b/app/javascript/mastodon/features/account_timeline/index.js @@ -9,17 +9,18 @@ import LoadingIndicator from '../../components/loading_indicator'; import Column from '../ui/components/column'; import HeaderContainer from './containers/header_container'; import ColumnBackButton from '../../components/column_back_button'; -import Immutable from 'immutable'; +import { List as ImmutableList } from 'immutable'; import ImmutablePureComponent from 'react-immutable-pure-component'; const mapStateToProps = (state, props) => ({ - statusIds: state.getIn(['timelines', `account:${Number(props.params.accountId)}`, 'items'], Immutable.List()), + statusIds: state.getIn(['timelines', `account:${Number(props.params.accountId)}`, 'items'], ImmutableList()), isLoading: state.getIn(['timelines', `account:${Number(props.params.accountId)}`, 'isLoading']), hasMore: !!state.getIn(['timelines', `account:${Number(props.params.accountId)}`, 'next']), me: state.getIn(['meta', 'me']), }); -class AccountTimeline extends ImmutablePureComponent { +@connect(mapStateToProps) +export default class AccountTimeline extends ImmutablePureComponent { static propTypes = { params: PropTypes.object.isRequired, @@ -77,5 +78,3 @@ class AccountTimeline extends ImmutablePureComponent { } } - -export default connect(mapStateToProps)(AccountTimeline); diff --git a/app/javascript/mastodon/features/blocks/index.js b/app/javascript/mastodon/features/blocks/index.js index de02e53cc..b16af4b28 100644 --- a/app/javascript/mastodon/features/blocks/index.js +++ b/app/javascript/mastodon/features/blocks/index.js @@ -19,7 +19,9 @@ const mapStateToProps = state => ({ accountIds: state.getIn(['user_lists', 'blocks', 'items']), }); -class Blocks extends ImmutablePureComponent { +@connect(mapStateToProps) +@injectIntl +export default class Blocks extends ImmutablePureComponent { static propTypes = { params: PropTypes.object.isRequired, @@ -66,5 +68,3 @@ class Blocks extends ImmutablePureComponent { } } - -export default connect(mapStateToProps)(injectIntl(Blocks)); diff --git a/app/javascript/mastodon/features/community_timeline/components/column_settings.js b/app/javascript/mastodon/features/community_timeline/components/column_settings.js index dbbe8ceaa..a992b27bb 100644 --- a/app/javascript/mastodon/features/community_timeline/components/column_settings.js +++ b/app/javascript/mastodon/features/community_timeline/components/column_settings.js @@ -2,8 +2,6 @@ import React from 'react'; import PropTypes from 'prop-types'; import ImmutablePropTypes from 'react-immutable-proptypes'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; -import ColumnCollapsable from '../../../components/column_collapsable'; -import SettingToggle from '../../notifications/components/setting_toggle'; import SettingText from '../../../components/setting_text'; const messages = defineMessages({ @@ -11,17 +9,17 @@ const messages = defineMessages({ settings: { id: 'home.settings', defaultMessage: 'Column settings' }, }); -class ColumnSettings extends React.PureComponent { +@injectIntl +export default class ColumnSettings extends React.PureComponent { static propTypes = { settings: ImmutablePropTypes.map.isRequired, onChange: PropTypes.func.isRequired, - onSave: PropTypes.func.isRequired, intl: PropTypes.object.isRequired, }; render () { - const { settings, onChange, onSave, intl } = this.props; + const { settings, onChange, intl } = this.props; return (
      @@ -35,5 +33,3 @@ class ColumnSettings extends React.PureComponent { } } - -export default injectIntl(ColumnSettings); diff --git a/app/javascript/mastodon/features/community_timeline/containers/column_settings_container.js b/app/javascript/mastodon/features/community_timeline/containers/column_settings_container.js index 1efc2ef33..f3489b409 100644 --- a/app/javascript/mastodon/features/community_timeline/containers/column_settings_container.js +++ b/app/javascript/mastodon/features/community_timeline/containers/column_settings_container.js @@ -1,6 +1,6 @@ import { connect } from 'react-redux'; import ColumnSettings from '../components/column_settings'; -import { changeSetting, saveSettings } from '../../../actions/settings'; +import { changeSetting } from '../../../actions/settings'; const mapStateToProps = state => ({ settings: state.getIn(['settings', 'community']), @@ -12,10 +12,6 @@ const mapDispatchToProps = dispatch => ({ dispatch(changeSetting(['community', ...key], checked)); }, - onSave () { - dispatch(saveSettings()); - }, - }); export default connect(mapStateToProps, mapDispatchToProps)(ColumnSettings); diff --git a/app/javascript/mastodon/features/community_timeline/index.js b/app/javascript/mastodon/features/community_timeline/index.js index 4fbe67038..0e2300f8c 100644 --- a/app/javascript/mastodon/features/community_timeline/index.js +++ b/app/javascript/mastodon/features/community_timeline/index.js @@ -14,7 +14,6 @@ import { } from '../../actions/timelines'; import { addColumn, removeColumn, moveColumn } from '../../actions/columns'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; -import ColumnBackButtonSlim from '../../components/column_back_button_slim'; import ColumnSettingsContainer from './containers/column_settings_container'; import createStream from '../../stream'; @@ -28,7 +27,9 @@ const mapStateToProps = state => ({ accessToken: state.getIn(['meta', 'access_token']), }); -class CommunityTimeline extends React.PureComponent { +@connect(mapStateToProps) +@injectIntl +export default class CommunityTimeline extends React.PureComponent { static propTypes = { dispatch: PropTypes.func.isRequired, @@ -142,5 +143,3 @@ class CommunityTimeline extends React.PureComponent { } } - -export default connect(mapStateToProps)(injectIntl(CommunityTimeline)); diff --git a/app/javascript/mastodon/features/compose/components/autosuggest_account.js b/app/javascript/mastodon/features/compose/components/autosuggest_account.js index 23665811e..ebfa3c247 100644 --- a/app/javascript/mastodon/features/compose/components/autosuggest_account.js +++ b/app/javascript/mastodon/features/compose/components/autosuggest_account.js @@ -4,7 +4,7 @@ import DisplayName from '../../../components/display_name'; import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePureComponent from 'react-immutable-pure-component'; -class AutosuggestAccount extends ImmutablePureComponent { +export default class AutosuggestAccount extends ImmutablePureComponent { static propTypes = { account: ImmutablePropTypes.map.isRequired, @@ -22,5 +22,3 @@ class AutosuggestAccount extends ImmutablePureComponent { } } - -export default AutosuggestAccount; diff --git a/app/javascript/mastodon/features/compose/components/character_counter.js b/app/javascript/mastodon/features/compose/components/character_counter.js index e35f2b879..6c488b661 100644 --- a/app/javascript/mastodon/features/compose/components/character_counter.js +++ b/app/javascript/mastodon/features/compose/components/character_counter.js @@ -2,7 +2,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import { length } from 'stringz'; -class CharacterCounter extends React.PureComponent { +export default class CharacterCounter extends React.PureComponent { static propTypes = { text: PropTypes.string.isRequired, @@ -23,5 +23,3 @@ class CharacterCounter extends React.PureComponent { } } - -export default CharacterCounter; diff --git a/app/javascript/mastodon/features/compose/components/compose_form.js b/app/javascript/mastodon/features/compose/components/compose_form.js index 621ec43ab..7a7a20bb8 100644 --- a/app/javascript/mastodon/features/compose/components/compose_form.js +++ b/app/javascript/mastodon/features/compose/components/compose_form.js @@ -7,26 +7,27 @@ import ReplyIndicatorContainer from '../containers/reply_indicator_container'; import AutosuggestTextarea from '../../../components/autosuggest_textarea'; import { debounce } from 'lodash'; import UploadButtonContainer from '../containers/upload_button_container'; -import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; -import Toggle from 'react-toggle'; +import { defineMessages, injectIntl } from 'react-intl'; import Collapsable from '../../../components/collapsable'; import SpoilerButtonContainer from '../containers/spoiler_button_container'; import PrivacyDropdownContainer from '../containers/privacy_dropdown_container'; import SensitiveButtonContainer from '../containers/sensitive_button_container'; import EmojiPickerDropdown from './emoji_picker_dropdown'; import UploadFormContainer from '../containers/upload_form_container'; -import TextIconButton from './text_icon_button'; import WarningContainer from '../containers/warning_container'; +import { isMobile } from '../../../is_mobile'; import ImmutablePureComponent from 'react-immutable-pure-component'; +import { length } from 'stringz'; const messages = defineMessages({ placeholder: { id: 'compose_form.placeholder', defaultMessage: 'What is on your mind?' }, - spoiler_placeholder: { id: 'compose_form.spoiler_placeholder', defaultMessage: 'Content warning' }, + spoiler_placeholder: { id: 'compose_form.spoiler_placeholder', defaultMessage: 'Write your warning here' }, publish: { id: 'compose_form.publish', defaultMessage: 'Toot' }, publishLoud: { id: 'compose_form.publish_loud', defaultMessage: '{publish}!' }, }); -class ComposeForm extends ImmutablePureComponent { +@injectIntl +export default class ComposeForm extends ImmutablePureComponent { static propTypes = { intl: PropTypes.object.isRequired, @@ -67,6 +68,12 @@ class ComposeForm extends ImmutablePureComponent { } handleSubmit = () => { + if (this.props.text !== this.autosuggestTextarea.textarea.value) { + // Something changed the text inside the textarea (e.g. browser extensions like Grammarly) + // Update the state to match the current text + this.props.onChange(this.autosuggestTextarea.textarea.value); + } + this.props.onSubmit(); } @@ -74,9 +81,9 @@ class ComposeForm extends ImmutablePureComponent { this.props.onClearSuggestions(); } - onSuggestionsFetchRequested = (token) => { + onSuggestionsFetchRequested = debounce((token) => { this.props.onFetchSuggestions(token); - } + }, 500, { trailing: true }) onSuggestionSelected = (tokenStart, token, value) => { this._restoreCaret = null; @@ -130,7 +137,8 @@ class ComposeForm extends ImmutablePureComponent { handleEmojiPick = (data) => { const position = this.autosuggestTextarea.textarea.selectionStart; - this._restoreCaret = position + data.shortname.length + 1; + const emojiChar = data.unicode.split('-').map(code => String.fromCodePoint(parseInt(code, 16))).join(''); + this._restoreCaret = position + emojiChar.length + 1; this.props.onPickEmoji(position, data); } @@ -140,7 +148,6 @@ class ComposeForm extends ImmutablePureComponent { const text = [this.props.spoiler_text, this.props.text].join(''); let publishText = ''; - let reply_to_other = false; if (this.props.privacy === 'private' || this.props.privacy === 'direct') { publishText = {intl.formatMessage(messages.publish)}; @@ -173,7 +180,7 @@ class ComposeForm extends ImmutablePureComponent { onSuggestionsClearRequested={this.onSuggestionsClearRequested} onSuggestionSelected={this.onSuggestionSelected} onPaste={onPaste} - autoFocus={!showSearch} + autoFocus={!showSearch && !isMobile(window.innerWidth)} /> @@ -193,7 +200,7 @@ class ComposeForm extends ImmutablePureComponent {
      -
      +
      @@ -201,5 +208,3 @@ class ComposeForm extends ImmutablePureComponent { } } - -export default injectIntl(ComposeForm); diff --git a/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js b/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js index 9ac674bb3..acc584f20 100644 --- a/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js +++ b/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js @@ -2,6 +2,7 @@ import React from 'react'; import Dropdown, { DropdownTrigger, DropdownContent } from 'react-simple-dropdown'; import PropTypes from 'prop-types'; import { defineMessages, injectIntl } from 'react-intl'; +import { EmojiPicker as EmojiPickerAsync } from '../../ui/util/async-components'; const messages = defineMessages({ emoji: { id: 'emoji_button.label', defaultMessage: 'Insert emoji' }, @@ -24,7 +25,8 @@ const settings = { let EmojiPicker; // load asynchronously -class EmojiPickerDropdown extends React.PureComponent { +@injectIntl +export default class EmojiPickerDropdown extends React.PureComponent { static propTypes = { intl: PropTypes.object.isRequired, @@ -49,10 +51,10 @@ class EmojiPickerDropdown extends React.PureComponent { this.setState({ active: true }); if (!EmojiPicker) { this.setState({ loading: true }); - import(/* webpackChunkName: "emojione_picker" */ 'emojione-picker').then(TheEmojiPicker => { + EmojiPickerAsync().then(TheEmojiPicker => { EmojiPicker = TheEmojiPicker.default; this.setState({ loading: false }); - }).catch(err => { + }).catch(() => { // TODO: show the user an error? this.setState({ loading: false }); }); @@ -107,11 +109,12 @@ class EmojiPickerDropdown extends React.PureComponent { 🙂 + { this.state.active && !this.state.loading && @@ -123,5 +126,3 @@ class EmojiPickerDropdown extends React.PureComponent { } } - -export default injectIntl(EmojiPickerDropdown); diff --git a/app/javascript/mastodon/features/compose/components/navigation_bar.js b/app/javascript/mastodon/features/compose/components/navigation_bar.js index 6f3dbc5af..c76210c85 100644 --- a/app/javascript/mastodon/features/compose/components/navigation_bar.js +++ b/app/javascript/mastodon/features/compose/components/navigation_bar.js @@ -1,17 +1,17 @@ import React from 'react'; +import PropTypes from 'prop-types'; import ImmutablePropTypes from 'react-immutable-proptypes'; import Avatar from '../../../components/avatar'; import IconButton from '../../../components/icon_button'; -import DisplayName from '../../../components/display_name'; import Permalink from '../../../components/permalink'; import { FormattedMessage } from 'react-intl'; -import Link from 'react-router/lib/Link'; import ImmutablePureComponent from 'react-immutable-pure-component'; -class NavigationBar extends ImmutablePureComponent { +export default class NavigationBar extends ImmutablePureComponent { static propTypes = { account: ImmutablePropTypes.map.isRequired, + onClose: PropTypes.func.isRequired, }; render () { @@ -28,10 +28,10 @@ class NavigationBar extends ImmutablePureComponent {
      + +
      ); } } - -export default NavigationBar; diff --git a/app/javascript/mastodon/features/compose/components/privacy_dropdown.js b/app/javascript/mastodon/features/compose/components/privacy_dropdown.js index 49f1179a0..9524f7501 100644 --- a/app/javascript/mastodon/features/compose/components/privacy_dropdown.js +++ b/app/javascript/mastodon/features/compose/components/privacy_dropdown.js @@ -20,7 +20,8 @@ const iconStyle = { lineHeight: '27px', }; -class PrivacyDropdown extends React.PureComponent { +@injectIntl +export default class PrivacyDropdown extends React.PureComponent { static propTypes = { value: PropTypes.string.isRequired, @@ -64,7 +65,7 @@ class PrivacyDropdown extends React.PureComponent { } render () { - const { value, onChange, intl } = this.props; + const { value, intl } = this.props; const { open } = this.state; const options = [ @@ -95,5 +96,3 @@ class PrivacyDropdown extends React.PureComponent { } } - -export default injectIntl(PrivacyDropdown); diff --git a/app/javascript/mastodon/features/compose/components/reply_indicator.js b/app/javascript/mastodon/features/compose/components/reply_indicator.js index 8ad401121..da00e46c5 100644 --- a/app/javascript/mastodon/features/compose/components/reply_indicator.js +++ b/app/javascript/mastodon/features/compose/components/reply_indicator.js @@ -12,7 +12,8 @@ const messages = defineMessages({ cancel: { id: 'reply_indicator.cancel', defaultMessage: 'Cancel' }, }); -class ReplyIndicator extends ImmutablePureComponent { +@injectIntl +export default class ReplyIndicator extends ImmutablePureComponent { static contextTypes = { router: PropTypes.object, @@ -31,7 +32,7 @@ class ReplyIndicator extends ImmutablePureComponent { handleAccountClick = (e) => { if (e.button === 0) { e.preventDefault(); - this.context.router.push(`/accounts/${this.props.status.getIn(['account', 'id'])}`); + this.context.router.history.push(`/accounts/${this.props.status.getIn(['account', 'id'])}`); } } @@ -61,5 +62,3 @@ class ReplyIndicator extends ImmutablePureComponent { } } - -export default injectIntl(ReplyIndicator); diff --git a/app/javascript/mastodon/features/compose/components/search.js b/app/javascript/mastodon/features/compose/components/search.js index 800080a7d..cdc7952c0 100644 --- a/app/javascript/mastodon/features/compose/components/search.js +++ b/app/javascript/mastodon/features/compose/components/search.js @@ -1,12 +1,13 @@ import React from 'react'; import PropTypes from 'prop-types'; -import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import { defineMessages, injectIntl } from 'react-intl'; const messages = defineMessages({ placeholder: { id: 'search.placeholder', defaultMessage: 'Search' }, }); -class Search extends React.PureComponent { +@injectIntl +export default class Search extends React.PureComponent { static propTypes = { value: PropTypes.string.isRequired, @@ -70,5 +71,3 @@ class Search extends React.PureComponent { } } - -export default injectIntl(Search); diff --git a/app/javascript/mastodon/features/compose/components/search_results.js b/app/javascript/mastodon/features/compose/components/search_results.js index a553a8280..ae4d1e86a 100644 --- a/app/javascript/mastodon/features/compose/components/search_results.js +++ b/app/javascript/mastodon/features/compose/components/search_results.js @@ -1,12 +1,12 @@ import React from 'react'; import ImmutablePropTypes from 'react-immutable-proptypes'; -import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import { FormattedMessage } from 'react-intl'; import AccountContainer from '../../../containers/account_container'; import StatusContainer from '../../../containers/status_container'; -import Link from 'react-router/lib/Link'; +import Link from 'react-router-dom/Link'; import ImmutablePureComponent from 'react-immutable-pure-component'; -class SearchResults extends ImmutablePureComponent { +export default class SearchResults extends ImmutablePureComponent { static propTypes = { results: ImmutablePropTypes.map.isRequired, @@ -63,5 +63,3 @@ class SearchResults extends ImmutablePureComponent { } } - -export default SearchResults; diff --git a/app/javascript/mastodon/features/compose/components/text_icon_button.js b/app/javascript/mastodon/features/compose/components/text_icon_button.js index cc0fbd11a..9c8ffab1f 100644 --- a/app/javascript/mastodon/features/compose/components/text_icon_button.js +++ b/app/javascript/mastodon/features/compose/components/text_icon_button.js @@ -1,7 +1,7 @@ import React from 'react'; import PropTypes from 'prop-types'; -class TextIconButton extends React.PureComponent { +export default class TextIconButton extends React.PureComponent { static propTypes = { label: PropTypes.string.isRequired, @@ -27,5 +27,3 @@ class TextIconButton extends React.PureComponent { } } - -export default TextIconButton; diff --git a/app/javascript/mastodon/features/compose/components/upload_button.js b/app/javascript/mastodon/features/compose/components/upload_button.js index 326b9851a..badd6cfc5 100644 --- a/app/javascript/mastodon/features/compose/components/upload_button.js +++ b/app/javascript/mastodon/features/compose/components/upload_button.js @@ -11,7 +11,7 @@ const messages = defineMessages({ }); const makeMapStateToProps = () => { - const mapStateToProps = (state, props) => ({ + const mapStateToProps = state => ({ acceptContentTypes: state.getIn(['media_attachments', 'accept_content_types']), }); @@ -23,7 +23,9 @@ const iconStyle = { lineHeight: '27px', }; -class UploadButton extends ImmutablePureComponent { +@connect(makeMapStateToProps) +@injectIntl +export default class UploadButton extends ImmutablePureComponent { static propTypes = { disabled: PropTypes.bool, @@ -70,5 +72,3 @@ class UploadButton extends ImmutablePureComponent { } } - -export default connect(makeMapStateToProps)(injectIntl(UploadButton)); diff --git a/app/javascript/mastodon/features/compose/components/upload_form.js b/app/javascript/mastodon/features/compose/components/upload_form.js index 7e665683a..78473dab4 100644 --- a/app/javascript/mastodon/features/compose/components/upload_form.js +++ b/app/javascript/mastodon/features/compose/components/upload_form.js @@ -11,7 +11,8 @@ const messages = defineMessages({ undo: { id: 'upload_form.undo', defaultMessage: 'Undo' }, }); -class UploadForm extends React.PureComponent { +@injectIntl +export default class UploadForm extends React.PureComponent { static propTypes = { media: ImmutablePropTypes.list.isRequired, @@ -48,5 +49,3 @@ class UploadForm extends React.PureComponent { } } - -export default injectIntl(UploadForm); diff --git a/app/javascript/mastodon/features/compose/components/upload_progress.js b/app/javascript/mastodon/features/compose/components/upload_progress.js index 8c8ce3835..3e49098c7 100644 --- a/app/javascript/mastodon/features/compose/components/upload_progress.js +++ b/app/javascript/mastodon/features/compose/components/upload_progress.js @@ -4,7 +4,7 @@ import Motion from 'react-motion/lib/Motion'; import spring from 'react-motion/lib/spring'; import { FormattedMessage } from 'react-intl'; -class UploadProgress extends React.PureComponent { +export default class UploadProgress extends React.PureComponent { static propTypes = { active: PropTypes.bool, @@ -40,5 +40,3 @@ class UploadProgress extends React.PureComponent { } } - -export default UploadProgress; diff --git a/app/javascript/mastodon/features/compose/components/warning.js b/app/javascript/mastodon/features/compose/components/warning.js index d0e75a5c3..75f36b840 100644 --- a/app/javascript/mastodon/features/compose/components/warning.js +++ b/app/javascript/mastodon/features/compose/components/warning.js @@ -1,7 +1,7 @@ import React from 'react'; import PropTypes from 'prop-types'; -class Warning extends React.PureComponent { +export default class Warning extends React.PureComponent { static propTypes = { message: PropTypes.node.isRequired, @@ -18,5 +18,3 @@ class Warning extends React.PureComponent { } } - -export default Warning; diff --git a/app/javascript/mastodon/features/compose/containers/navigation_container.js b/app/javascript/mastodon/features/compose/containers/navigation_container.js index 75f288f18..8cc53c087 100644 --- a/app/javascript/mastodon/features/compose/containers/navigation_container.js +++ b/app/javascript/mastodon/features/compose/containers/navigation_container.js @@ -1,7 +1,7 @@ import { connect } from 'react-redux'; import NavigationBar from '../components/navigation_bar'; -const mapStateToProps = (state, props) => { +const mapStateToProps = state => { return { account: state.getIn(['accounts', state.getIn(['meta', 'me'])]), }; diff --git a/app/javascript/mastodon/features/compose/containers/reply_indicator_container.js b/app/javascript/mastodon/features/compose/containers/reply_indicator_container.js index 7f3eeb89c..73f394c1a 100644 --- a/app/javascript/mastodon/features/compose/containers/reply_indicator_container.js +++ b/app/javascript/mastodon/features/compose/containers/reply_indicator_container.js @@ -6,7 +6,7 @@ import ReplyIndicator from '../components/reply_indicator'; const makeMapStateToProps = () => { const getStatus = makeGetStatus(); - const mapStateToProps = (state, props) => ({ + const mapStateToProps = state => ({ status: getStatus(state, state.getIn(['compose', 'in_reply_to'])), }); diff --git a/app/javascript/mastodon/features/compose/containers/sensitive_button_container.js b/app/javascript/mastodon/features/compose/containers/sensitive_button_container.js index 761ae8c08..63c0e8ae4 100644 --- a/app/javascript/mastodon/features/compose/containers/sensitive_button_container.js +++ b/app/javascript/mastodon/features/compose/containers/sensitive_button_container.js @@ -1,7 +1,8 @@ import React from 'react'; import { connect } from 'react-redux'; import PropTypes from 'prop-types'; -import TextIconButton from '../components/text_icon_button'; +import classNames from 'classnames'; +import IconButton from '../../../components/icon_button'; import { changeComposeSensitivity } from '../../../actions/compose'; import Motion from 'react-motion/lib/Motion'; import spring from 'react-motion/lib/spring'; @@ -38,11 +39,26 @@ class SensitiveButton extends React.PureComponent { return ( - {({ scale }) => -
      - -
      - } + {({ scale }) => { + const icon = active ? 'eye-slash' : 'eye'; + const className = classNames('compose-form__sensitive-button', { + 'compose-form__sensitive-button--visible': visible, + }); + return ( +
      + +
      + ); + }}
      ); } diff --git a/app/javascript/mastodon/features/compose/containers/upload_form_container.js b/app/javascript/mastodon/features/compose/containers/upload_form_container.js index 3125564c2..4612599f1 100644 --- a/app/javascript/mastodon/features/compose/containers/upload_form_container.js +++ b/app/javascript/mastodon/features/compose/containers/upload_form_container.js @@ -2,7 +2,7 @@ import { connect } from 'react-redux'; import UploadForm from '../components/upload_form'; import { undoUploadCompose } from '../../../actions/compose'; -const mapStateToProps = (state, props) => ({ +const mapStateToProps = state => ({ media: state.getIn(['compose', 'media_attachments']), }); diff --git a/app/javascript/mastodon/features/compose/containers/upload_progress_container.js b/app/javascript/mastodon/features/compose/containers/upload_progress_container.js index 51af4440c..0cfee96da 100644 --- a/app/javascript/mastodon/features/compose/containers/upload_progress_container.js +++ b/app/javascript/mastodon/features/compose/containers/upload_progress_container.js @@ -1,7 +1,7 @@ import { connect } from 'react-redux'; import UploadProgress from '../components/upload_progress'; -const mapStateToProps = (state, props) => ({ +const mapStateToProps = state => ({ active: state.getIn(['compose', 'is_uploading']), progress: state.getIn(['compose', 'progress']), }); diff --git a/app/javascript/mastodon/features/compose/index.js b/app/javascript/mastodon/features/compose/index.js index e7d933e86..b3f410f3b 100644 --- a/app/javascript/mastodon/features/compose/index.js +++ b/app/javascript/mastodon/features/compose/index.js @@ -1,19 +1,22 @@ import React from 'react'; import ComposeFormContainer from './containers/compose_form_container'; -import UploadFormContainer from './containers/upload_form_container'; import NavigationContainer from './containers/navigation_container'; import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; import { connect } from 'react-redux'; import { mountCompose, unmountCompose } from '../../actions/compose'; -import Link from 'react-router/lib/Link'; +import Link from 'react-router-dom/Link'; import { injectIntl, defineMessages } from 'react-intl'; import SearchContainer from './containers/search_container'; import Motion from 'react-motion/lib/Motion'; import spring from 'react-motion/lib/spring'; import SearchResultsContainer from './containers/search_results_container'; +import { changeComposing } from '../../actions/compose'; const messages = defineMessages({ start: { id: 'getting_started.heading', defaultMessage: 'Getting started' }, + home_timeline: { id: 'tabs_bar.home', defaultMessage: 'Home' }, + notifications: { id: 'tabs_bar.notifications', defaultMessage: 'Notifications' }, public: { id: 'navigation_bar.public_timeline', defaultMessage: 'Federated timeline' }, community: { id: 'navigation_bar.community_timeline', defaultMessage: 'Local timeline' }, preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' }, @@ -21,13 +24,17 @@ const messages = defineMessages({ }); const mapStateToProps = state => ({ + columns: state.getIn(['settings', 'columns']), showSearch: state.getIn(['search', 'submitted']) && !state.getIn(['search', 'hidden']), }); -class Compose extends React.PureComponent { +@connect(mapStateToProps) +@injectIntl +export default class Compose extends React.PureComponent { static propTypes = { dispatch: PropTypes.func.isRequired, + columns: ImmutablePropTypes.list.isRequired, multiColumn: PropTypes.bool, showSearch: PropTypes.bool, intl: PropTypes.object.isRequired, @@ -41,20 +48,39 @@ class Compose extends React.PureComponent { this.props.dispatch(unmountCompose()); } + onFocus = () => { + this.props.dispatch(changeComposing(true)); + } + + onBlur = () => { + this.props.dispatch(changeComposing(false)); + } + render () { const { multiColumn, showSearch, intl } = this.props; let header = ''; if (multiColumn) { + const { columns } = this.props; header = ( -
      - - - - - -
      + ); } @@ -65,8 +91,8 @@ class Compose extends React.PureComponent {
      -
      - +
      +
      @@ -83,5 +109,3 @@ class Compose extends React.PureComponent { } } - -export default connect(mapStateToProps)(injectIntl(Compose)); diff --git a/app/javascript/mastodon/features/favourited_statuses/index.js b/app/javascript/mastodon/features/favourited_statuses/index.js index 2b343ba5a..d9ad9bc1f 100644 --- a/app/javascript/mastodon/features/favourited_statuses/index.js +++ b/app/javascript/mastodon/features/favourited_statuses/index.js @@ -2,11 +2,11 @@ import React from 'react'; import { connect } from 'react-redux'; import PropTypes from 'prop-types'; import ImmutablePropTypes from 'react-immutable-proptypes'; -import LoadingIndicator from '../../components/loading_indicator'; import { fetchFavouritedStatuses, expandFavouritedStatuses } from '../../actions/favourites'; import Column from '../ui/components/column'; +import ColumnHeader from '../../components/column_header'; +import { addColumn, removeColumn, moveColumn } from '../../actions/columns'; import StatusList from '../../components/status_list'; -import ColumnBackButtonSlim from '../../components/column_back_button_slim'; import { defineMessages, injectIntl } from 'react-intl'; import ImmutablePureComponent from 'react-immutable-pure-component'; @@ -16,47 +16,75 @@ const messages = defineMessages({ const mapStateToProps = state => ({ statusIds: state.getIn(['status_lists', 'favourites', 'items']), - loaded: state.getIn(['status_lists', 'favourites', 'loaded']), - me: state.getIn(['meta', 'me']), }); -class Favourites extends ImmutablePureComponent { +@connect(mapStateToProps) +@injectIntl +export default class Favourites extends ImmutablePureComponent { static propTypes = { dispatch: PropTypes.func.isRequired, statusIds: ImmutablePropTypes.list.isRequired, - loaded: PropTypes.bool, intl: PropTypes.object.isRequired, - me: PropTypes.number.isRequired, + columnId: PropTypes.string, + multiColumn: PropTypes.bool, }; componentWillMount () { this.props.dispatch(fetchFavouritedStatuses()); } + handlePin = () => { + const { columnId, dispatch } = this.props; + + if (columnId) { + dispatch(removeColumn(columnId)); + } else { + dispatch(addColumn('FAVOURITES', {})); + } + } + + handleMove = (dir) => { + const { columnId, dispatch } = this.props; + dispatch(moveColumn(columnId, dir)); + } + + handleHeaderClick = () => { + this.column.scrollTop(); + } + + setRef = c => { + this.column = c; + } + handleScrollToBottom = () => { this.props.dispatch(expandFavouritedStatuses()); } render () { - const { statusIds, loaded, intl, me } = this.props; - - if (!loaded) { - return ( - - - - ); - } + const { intl, statusIds, columnId, multiColumn } = this.props; + const pinned = !!columnId; return ( - - - + + + + ); } } - -export default connect(mapStateToProps)(injectIntl(Favourites)); diff --git a/app/javascript/mastodon/features/favourites/index.js b/app/javascript/mastodon/features/favourites/index.js index 94f9f268b..dc8109d16 100644 --- a/app/javascript/mastodon/features/favourites/index.js +++ b/app/javascript/mastodon/features/favourites/index.js @@ -14,7 +14,8 @@ const mapStateToProps = (state, props) => ({ accountIds: state.getIn(['user_lists', 'favourited_by', Number(props.params.statusId)]), }); -class Favourites extends ImmutablePureComponent { +@connect(mapStateToProps) +export default class Favourites extends ImmutablePureComponent { static propTypes = { params: PropTypes.object.isRequired, @@ -57,5 +58,3 @@ class Favourites extends ImmutablePureComponent { } } - -export default connect(mapStateToProps)(Favourites); 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 e41597c17..566953ddd 100644 --- a/app/javascript/mastodon/features/follow_requests/components/account_authorize.js +++ b/app/javascript/mastodon/features/follow_requests/components/account_authorize.js @@ -14,7 +14,8 @@ const messages = defineMessages({ reject: { id: 'follow_request.reject', defaultMessage: 'Reject' }, }); -class AccountAuthorize extends ImmutablePureComponent { +@injectIntl +export default class AccountAuthorize extends ImmutablePureComponent { static propTypes = { account: ImmutablePropTypes.map.isRequired, @@ -47,5 +48,3 @@ class AccountAuthorize extends ImmutablePureComponent { } } - -export default injectIntl(AccountAuthorize); diff --git a/app/javascript/mastodon/features/follow_requests/containers/account_authorize_container.js b/app/javascript/mastodon/features/follow_requests/containers/account_authorize_container.js index a423bc79b..8db471f73 100644 --- a/app/javascript/mastodon/features/follow_requests/containers/account_authorize_container.js +++ b/app/javascript/mastodon/features/follow_requests/containers/account_authorize_container.js @@ -14,11 +14,11 @@ const makeMapStateToProps = () => { }; const mapDispatchToProps = (dispatch, { id }) => ({ - onAuthorize (account) { + onAuthorize () { dispatch(authorizeFollowRequest(id)); }, - onReject (account) { + onReject () { dispatch(rejectFollowRequest(id)); }, }); diff --git a/app/javascript/mastodon/features/follow_requests/index.js b/app/javascript/mastodon/features/follow_requests/index.js index 446fdbc6e..4c9e514cb 100644 --- a/app/javascript/mastodon/features/follow_requests/index.js +++ b/app/javascript/mastodon/features/follow_requests/index.js @@ -19,7 +19,9 @@ const mapStateToProps = state => ({ accountIds: state.getIn(['user_lists', 'follow_requests', 'items']), }); -class FollowRequests extends ImmutablePureComponent { +@connect(mapStateToProps) +@injectIntl +export default class FollowRequests extends ImmutablePureComponent { static propTypes = { params: PropTypes.object.isRequired, @@ -54,6 +56,7 @@ class FollowRequests extends ImmutablePureComponent { return ( +
      {accountIds.map(id => @@ -66,5 +69,3 @@ class FollowRequests extends ImmutablePureComponent { } } - -export default connect(mapStateToProps)(injectIntl(FollowRequests)); diff --git a/app/javascript/mastodon/features/followers/index.js b/app/javascript/mastodon/features/followers/index.js index e9910dce4..2d85b9cc0 100644 --- a/app/javascript/mastodon/features/followers/index.js +++ b/app/javascript/mastodon/features/followers/index.js @@ -21,7 +21,8 @@ const mapStateToProps = (state, props) => ({ hasMore: !!state.getIn(['user_lists', 'followers', Number(props.params.accountId), 'next']), }); -class Followers extends ImmutablePureComponent { +@connect(mapStateToProps) +export default class Followers extends ImmutablePureComponent { static propTypes = { params: PropTypes.object.isRequired, @@ -90,5 +91,3 @@ class Followers extends ImmutablePureComponent { } } - -export default connect(mapStateToProps)(Followers); diff --git a/app/javascript/mastodon/features/following/index.js b/app/javascript/mastodon/features/following/index.js index 764f702ff..e4e2a4811 100644 --- a/app/javascript/mastodon/features/following/index.js +++ b/app/javascript/mastodon/features/following/index.js @@ -21,7 +21,8 @@ const mapStateToProps = (state, props) => ({ hasMore: !!state.getIn(['user_lists', 'following', Number(props.params.accountId), 'next']), }); -class Following extends ImmutablePureComponent { +@connect(mapStateToProps) +export default class Following extends ImmutablePureComponent { static propTypes = { params: PropTypes.object.isRequired, @@ -90,5 +91,3 @@ class Following extends ImmutablePureComponent { } } - -export default connect(mapStateToProps)(Following); diff --git a/app/javascript/mastodon/features/getting_started/index.js b/app/javascript/mastodon/features/getting_started/index.js index f30dea446..f8ea01024 100644 --- a/app/javascript/mastodon/features/getting_started/index.js +++ b/app/javascript/mastodon/features/getting_started/index.js @@ -2,7 +2,6 @@ import React from 'react'; import Column from '../ui/components/column'; import ColumnLink from '../ui/components/column_link'; import ColumnSubheading from '../ui/components/column_subheading'; -import Link from 'react-router/lib/Link'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import { connect } from 'react-redux'; import PropTypes from 'prop-types'; @@ -31,7 +30,9 @@ const mapStateToProps = state => ({ columns: state.getIn(['settings', 'columns']), }); -class GettingStarted extends ImmutablePureComponent { +@connect(mapStateToProps) +@injectIntl +export default class GettingStarted extends ImmutablePureComponent { static propTypes = { intl: PropTypes.object.isRequired, @@ -106,5 +107,3 @@ class GettingStarted extends ImmutablePureComponent { } } - -export default connect(mapStateToProps)(injectIntl(GettingStarted)); diff --git a/app/javascript/mastodon/features/hashtag_timeline/index.js b/app/javascript/mastodon/features/hashtag_timeline/index.js index ba6dbe6e5..b17e8e1a5 100644 --- a/app/javascript/mastodon/features/hashtag_timeline/index.js +++ b/app/javascript/mastodon/features/hashtag_timeline/index.js @@ -11,7 +11,6 @@ import { deleteFromTimelines, } from '../../actions/timelines'; import { addColumn, removeColumn, moveColumn } from '../../actions/columns'; -import ColumnBackButtonSlim from '../../components/column_back_button_slim'; import { FormattedMessage } from 'react-intl'; import createStream from '../../stream'; @@ -21,7 +20,8 @@ const mapStateToProps = state => ({ accessToken: state.getIn(['meta', 'access_token']), }); -class HashtagTimeline extends React.PureComponent { +@connect(mapStateToProps) +export default class HashtagTimeline extends React.PureComponent { static propTypes = { params: PropTypes.object.isRequired, @@ -60,7 +60,7 @@ class HashtagTimeline extends React.PureComponent { received (data) { switch(data.event) { case 'update': - dispatch(updateTimeline('tag', JSON.parse(data.payload))); + dispatch(updateTimeline(`hashtag:${id}`, JSON.parse(data.payload))); break; case 'delete': dispatch(deleteFromTimelines(data.payload)); @@ -137,5 +137,3 @@ class HashtagTimeline extends React.PureComponent { } } - -export default connect(mapStateToProps)(HashtagTimeline); diff --git a/app/javascript/mastodon/features/home_timeline/components/column_settings.js b/app/javascript/mastodon/features/home_timeline/components/column_settings.js index 104d8ff37..43172bd25 100644 --- a/app/javascript/mastodon/features/home_timeline/components/column_settings.js +++ b/app/javascript/mastodon/features/home_timeline/components/column_settings.js @@ -2,7 +2,6 @@ import React from 'react'; import PropTypes from 'prop-types'; import ImmutablePropTypes from 'react-immutable-proptypes'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; -import ColumnCollapsable from '../../../components/column_collapsable'; import SettingToggle from '../../notifications/components/setting_toggle'; import SettingText from '../../../components/setting_text'; @@ -11,39 +10,37 @@ const messages = defineMessages({ settings: { id: 'home.settings', defaultMessage: 'Column settings' }, }); -class ColumnSettings extends React.PureComponent { +@injectIntl +export default class ColumnSettings extends React.PureComponent { static propTypes = { settings: ImmutablePropTypes.map.isRequired, onChange: PropTypes.func.isRequired, - onSave: PropTypes.func.isRequired, intl: PropTypes.object.isRequired, }; render () { - const { settings, onChange, onSave, intl } = this.props; + const { settings, onChange, intl } = this.props; return (
      - } /> + } />
      - } /> + } />
      - +
      ); } } - -export default injectIntl(ColumnSettings); diff --git a/app/javascript/mastodon/features/home_timeline/index.js b/app/javascript/mastodon/features/home_timeline/index.js index 6d3968751..6021299d6 100644 --- a/app/javascript/mastodon/features/home_timeline/index.js +++ b/app/javascript/mastodon/features/home_timeline/index.js @@ -8,7 +8,7 @@ import ColumnHeader from '../../components/column_header'; import { addColumn, removeColumn, moveColumn } from '../../actions/columns'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import ColumnSettingsContainer from './containers/column_settings_container'; -import Link from 'react-router/lib/Link'; +import Link from 'react-router-dom/Link'; const messages = defineMessages({ title: { id: 'column.home', defaultMessage: 'Home' }, @@ -19,7 +19,9 @@ const mapStateToProps = state => ({ hasFollows: state.getIn(['accounts_counters', state.getIn(['meta', 'me']), 'following_count']) > 0, }); -class HomeTimeline extends React.PureComponent { +@connect(mapStateToProps) +@injectIntl +export default class HomeTimeline extends React.PureComponent { static propTypes = { dispatch: PropTypes.func.isRequired, @@ -96,5 +98,3 @@ class HomeTimeline extends React.PureComponent { } } - -export default connect(mapStateToProps)(injectIntl(HomeTimeline)); diff --git a/app/javascript/mastodon/features/mutes/index.js b/app/javascript/mastodon/features/mutes/index.js index f0d8856be..25ca921ae 100644 --- a/app/javascript/mastodon/features/mutes/index.js +++ b/app/javascript/mastodon/features/mutes/index.js @@ -19,7 +19,16 @@ const mapStateToProps = state => ({ accountIds: state.getIn(['user_lists', 'mutes', 'items']), }); -class Mutes extends ImmutablePureComponent { +@connect(mapStateToProps) +@injectIntl +export default class Mutes extends ImmutablePureComponent { + + static propTypes = { + params: PropTypes.object.isRequired, + dispatch: PropTypes.func.isRequired, + accountIds: ImmutablePropTypes.list, + intl: PropTypes.object.isRequired, + }; componentWillMount () { this.props.dispatch(fetchMutes()); @@ -59,12 +68,3 @@ class Mutes extends ImmutablePureComponent { } } - -Mutes.propTypes = { - params: PropTypes.object.isRequired, - dispatch: PropTypes.func.isRequired, - accountIds: ImmutablePropTypes.list, - intl: PropTypes.object.isRequired, -}; - -export default connect(mapStateToProps)(injectIntl(Mutes)); diff --git a/app/javascript/mastodon/features/notifications/components/clear_column_button.js b/app/javascript/mastodon/features/notifications/components/clear_column_button.js index 54beb1c4d..22a10753f 100644 --- a/app/javascript/mastodon/features/notifications/components/clear_column_button.js +++ b/app/javascript/mastodon/features/notifications/components/clear_column_button.js @@ -2,7 +2,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import { FormattedMessage } from 'react-intl'; -class ClearColumnButton extends React.Component { +export default class ClearColumnButton extends React.Component { static propTypes = { onClick: PropTypes.func.isRequired, @@ -15,5 +15,3 @@ class ClearColumnButton extends React.Component { } } - -export default ClearColumnButton; diff --git a/app/javascript/mastodon/features/notifications/components/column_settings.js b/app/javascript/mastodon/features/notifications/components/column_settings.js index 7bfd02f11..88a29d4d3 100644 --- a/app/javascript/mastodon/features/notifications/components/column_settings.js +++ b/app/javascript/mastodon/features/notifications/components/column_settings.js @@ -2,67 +2,85 @@ import React from 'react'; import PropTypes from 'prop-types'; import ImmutablePropTypes from 'react-immutable-proptypes'; import { FormattedMessage } from 'react-intl'; -import ColumnCollapsable from '../../../components/column_collapsable'; import ClearColumnButton from './clear_column_button'; import SettingToggle from './setting_toggle'; -class ColumnSettings extends React.PureComponent { +export default class ColumnSettings extends React.PureComponent { static propTypes = { settings: ImmutablePropTypes.map.isRequired, + pushSettings: ImmutablePropTypes.map.isRequired, onChange: PropTypes.func.isRequired, onSave: PropTypes.func.isRequired, onClear: PropTypes.func.isRequired, }; + onPushChange = (key, checked) => { + this.props.onChange(['push', ...key], checked); + } + render () { - const { settings, onChange, onSave, onClear } = this.props; + const { settings, pushSettings, onChange, onClear } = this.props; const alertStr = ; const showStr = ; const soundStr = ; + const showPushSettings = pushSettings.get('browserSupport') && pushSettings.get('isSubscribed'); + const pushStr = showPushSettings && ; + const pushMeta = showPushSettings && ; + return (
      - +
      + -
      - - - +
      + + {showPushSettings && } + + +
      - +
      + -
      - - - +
      + + {showPushSettings && } + + +
      - +
      + -
      - - - +
      + + {showPushSettings && } + + +
      - +
      + -
      - - - +
      + + {showPushSettings && } + + +
      ); } } - -export default ColumnSettings; diff --git a/app/javascript/mastodon/features/notifications/components/notification.js b/app/javascript/mastodon/features/notifications/components/notification.js index 6ec4d5dc6..9d631644a 100644 --- a/app/javascript/mastodon/features/notifications/components/notification.js +++ b/app/javascript/mastodon/features/notifications/components/notification.js @@ -2,14 +2,13 @@ import React from 'react'; import ImmutablePropTypes from 'react-immutable-proptypes'; import StatusContainer from '../../../containers/status_container'; import AccountContainer from '../../../containers/account_container'; -import Avatar from '../../../components/avatar'; 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'; -class Notification extends ImmutablePureComponent { +export default class Notification extends ImmutablePureComponent { static propTypes = { notification: ImmutablePropTypes.map.isRequired, @@ -87,5 +86,3 @@ class Notification extends ImmutablePureComponent { } } - -export default Notification; diff --git a/app/javascript/mastodon/features/notifications/components/setting_toggle.js b/app/javascript/mastodon/features/notifications/components/setting_toggle.js index a37abbd9c..be1ff91d6 100644 --- a/app/javascript/mastodon/features/notifications/components/setting_toggle.js +++ b/app/javascript/mastodon/features/notifications/components/setting_toggle.js @@ -3,31 +3,32 @@ import PropTypes from 'prop-types'; import ImmutablePropTypes from 'react-immutable-proptypes'; import Toggle from 'react-toggle'; -class SettingToggle extends React.PureComponent { +export default class SettingToggle extends React.PureComponent { static propTypes = { + prefix: PropTypes.string, settings: ImmutablePropTypes.map.isRequired, settingKey: PropTypes.array.isRequired, label: PropTypes.node.isRequired, + meta: PropTypes.node, onChange: PropTypes.func.isRequired, } - onChange = (e) => { - this.props.onChange(this.props.settingKey, e.target.checked); + onChange = ({ target }) => { + this.props.onChange(this.props.settingKey, target.checked); } render () { - const { settings, settingKey, label, onChange } = this.props; - const id = `setting-toggle-${settingKey.join('-')}`; + const { prefix, settings, settingKey, label, meta } = this.props; + const id = ['setting-toggle', prefix, ...settingKey].filter(Boolean).join('-'); return (
      + {meta && {meta}}
      ); } } - -export default SettingToggle; diff --git a/app/javascript/mastodon/features/notifications/containers/column_settings_container.js b/app/javascript/mastodon/features/notifications/containers/column_settings_container.js index b139d4615..d4ead7881 100644 --- a/app/javascript/mastodon/features/notifications/containers/column_settings_container.js +++ b/app/javascript/mastodon/features/notifications/containers/column_settings_container.js @@ -3,6 +3,7 @@ import { defineMessages, injectIntl } from 'react-intl'; import ColumnSettings from '../components/column_settings'; import { changeSetting, saveSettings } from '../../../actions/settings'; import { clearNotifications } from '../../../actions/notifications'; +import { changeAlerts as changePushNotifications, saveSettings as savePushNotificationSettings } from '../../../actions/push_notifications'; import { openModal } from '../../../actions/modal'; const messages = defineMessages({ @@ -12,16 +13,22 @@ const messages = defineMessages({ const mapStateToProps = state => ({ settings: state.getIn(['settings', 'notifications']), + pushSettings: state.get('push_notifications'), }); const mapDispatchToProps = (dispatch, { intl }) => ({ onChange (key, checked) { - dispatch(changeSetting(['notifications', ...key], checked)); + if (key[0] === 'push') { + dispatch(changePushNotifications(key.slice(1), checked)); + } else { + dispatch(changeSetting(['notifications', ...key], checked)); + } }, onSave () { dispatch(saveSettings()); + dispatch(savePushNotificationSettings()); }, onClear () { diff --git a/app/javascript/mastodon/features/notifications/index.js b/app/javascript/mastodon/features/notifications/index.js index b85d6d692..c5853d3ba 100644 --- a/app/javascript/mastodon/features/notifications/index.js +++ b/app/javascript/mastodon/features/notifications/index.js @@ -11,15 +11,16 @@ 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 Immutable from 'immutable'; +import { List as ImmutableList } from 'immutable'; import LoadMore from '../../components/load_more'; +import { debounce } from 'lodash'; const messages = defineMessages({ title: { id: 'column.notifications', defaultMessage: 'Notifications' }, }); const getNotifications = createSelector([ - state => Immutable.List(state.getIn(['settings', 'notifications', 'shows']).filter(item => !item).keys()), + state => ImmutableList(state.getIn(['settings', 'notifications', 'shows']).filter(item => !item).keys()), state => state.getIn(['notifications', 'items']), ], (excludedTypes, notifications) => notifications.filterNot(item => excludedTypes.includes(item.get('type')))); @@ -30,7 +31,9 @@ const mapStateToProps = state => ({ hasMore: !!state.getIn(['notifications', 'next']), }); -class Notifications extends React.PureComponent { +@connect(mapStateToProps) +@injectIntl +export default class Notifications extends React.PureComponent { static propTypes = { columnId: PropTypes.string, @@ -48,19 +51,27 @@ class Notifications extends React.PureComponent { trackScroll: true, }; + dispatchExpandNotifications = debounce(() => { + this.props.dispatch(expandNotifications()); + }, 300, { leading: true }); + + dispatchScrollToTop = debounce((top) => { + this.props.dispatch(scrollTopNotifications(top)); + }, 100); + handleScroll = (e) => { const { scrollTop, scrollHeight, clientHeight } = e.target; const offset = scrollHeight - scrollTop - clientHeight; this._oldScrollPosition = scrollHeight - scrollTop; - if (250 > offset && !this.props.isLoading) { - if (this.props.hasMore) { - this.props.dispatch(expandNotifications()); - } - } else if (scrollTop < 100) { - this.props.dispatch(scrollTopNotifications(true)); + if (250 > offset && this.props.hasMore && !this.props.isLoading) { + this.dispatchExpandNotifications(); + } + + if (scrollTop < 100) { + this.dispatchScrollToTop(true); } else { - this.props.dispatch(scrollTopNotifications(false)); + this.dispatchScrollToTop(false); } } @@ -72,7 +83,7 @@ class Notifications extends React.PureComponent { handleLoadMore = (e) => { e.preventDefault(); - this.props.dispatch(expandNotifications()); + this.dispatchExpandNotifications(); } handlePin = () => { @@ -111,7 +122,7 @@ class Notifications extends React.PureComponent { let unread = ''; let scrollContainer = ''; - if (!isLoading && notifications.size > 0 && hasMore) { + if (!isLoading && hasMore) { loadMore = ; } @@ -121,7 +132,7 @@ class Notifications extends React.PureComponent { if (isLoading && this.scrollableArea) { scrollableArea = this.scrollableArea; - } else if (notifications.size > 0) { + } else if (notifications.size > 0 || hasMore) { scrollableArea = (
      {unread} @@ -173,5 +184,3 @@ class Notifications extends React.PureComponent { } } - -export default connect(mapStateToProps)(injectIntl(Notifications)); diff --git a/app/javascript/mastodon/features/public_timeline/containers/column_settings_container.js b/app/javascript/mastodon/features/public_timeline/containers/column_settings_container.js index 62d4e7e5a..203e1da92 100644 --- a/app/javascript/mastodon/features/public_timeline/containers/column_settings_container.js +++ b/app/javascript/mastodon/features/public_timeline/containers/column_settings_container.js @@ -1,6 +1,6 @@ import { connect } from 'react-redux'; import ColumnSettings from '../../community_timeline/components/column_settings'; -import { changeSetting, saveSettings } from '../../../actions/settings'; +import { changeSetting } from '../../../actions/settings'; const mapStateToProps = state => ({ settings: state.getIn(['settings', 'public']), @@ -12,10 +12,6 @@ const mapDispatchToProps = dispatch => ({ dispatch(changeSetting(['public', ...key], checked)); }, - onSave () { - dispatch(saveSettings()); - }, - }); export default connect(mapStateToProps, mapDispatchToProps)(ColumnSettings); diff --git a/app/javascript/mastodon/features/public_timeline/index.js b/app/javascript/mastodon/features/public_timeline/index.js index 02ddb418f..c6cad02d6 100644 --- a/app/javascript/mastodon/features/public_timeline/index.js +++ b/app/javascript/mastodon/features/public_timeline/index.js @@ -14,7 +14,6 @@ import { } from '../../actions/timelines'; import { addColumn, removeColumn, moveColumn } from '../../actions/columns'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; -import ColumnBackButtonSlim from '../../components/column_back_button_slim'; import ColumnSettingsContainer from './containers/column_settings_container'; import createStream from '../../stream'; @@ -28,7 +27,9 @@ const mapStateToProps = state => ({ accessToken: state.getIn(['meta', 'access_token']), }); -class PublicTimeline extends React.PureComponent { +@connect(mapStateToProps) +@injectIntl +export default class PublicTimeline extends React.PureComponent { static propTypes = { dispatch: PropTypes.func.isRequired, @@ -142,5 +143,3 @@ class PublicTimeline extends React.PureComponent { } } - -export default connect(mapStateToProps)(injectIntl(PublicTimeline)); diff --git a/app/javascript/mastodon/features/reblogs/index.js b/app/javascript/mastodon/features/reblogs/index.js index 13fd1b20e..dc940ae01 100644 --- a/app/javascript/mastodon/features/reblogs/index.js +++ b/app/javascript/mastodon/features/reblogs/index.js @@ -14,7 +14,8 @@ const mapStateToProps = (state, props) => ({ accountIds: state.getIn(['user_lists', 'reblogged_by', Number(props.params.statusId)]), }); -class Reblogs extends ImmutablePureComponent { +@connect(mapStateToProps) +export default class Reblogs extends ImmutablePureComponent { static propTypes = { params: PropTypes.object.isRequired, @@ -57,5 +58,3 @@ class Reblogs extends ImmutablePureComponent { } } - -export default connect(mapStateToProps)(Reblogs); 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 a31eabc21..6a1a84c28 100644 --- a/app/javascript/mastodon/features/report/components/status_check_box.js +++ b/app/javascript/mastodon/features/report/components/status_check_box.js @@ -4,7 +4,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes'; import emojify from '../../../emoji'; import Toggle from 'react-toggle'; -class StatusCheckBox extends React.PureComponent { +export default class StatusCheckBox extends React.PureComponent { static propTypes = { status: ImmutablePropTypes.map.isRequired, @@ -36,5 +36,3 @@ class StatusCheckBox extends React.PureComponent { } } - -export default StatusCheckBox; diff --git a/app/javascript/mastodon/features/report/containers/status_check_box_container.js b/app/javascript/mastodon/features/report/containers/status_check_box_container.js index 8997718a2..48cd0319b 100644 --- a/app/javascript/mastodon/features/report/containers/status_check_box_container.js +++ b/app/javascript/mastodon/features/report/containers/status_check_box_container.js @@ -1,11 +1,11 @@ import { connect } from 'react-redux'; import StatusCheckBox from '../components/status_check_box'; import { toggleStatusReport } from '../../../actions/reports'; -import Immutable from 'immutable'; +import { Set as ImmutableSet } from 'immutable'; const mapStateToProps = (state, { id }) => ({ status: state.getIn(['statuses', id]), - checked: state.getIn(['reports', 'new', 'status_ids'], Immutable.Set()).includes(id), + checked: state.getIn(['reports', 'new', 'status_ids'], ImmutableSet()).includes(id), }); const mapDispatchToProps = (dispatch, { id }) => ({ diff --git a/app/javascript/mastodon/features/standalone/public_timeline/index.js b/app/javascript/mastodon/features/standalone/public_timeline/index.js new file mode 100644 index 000000000..de4b5320a --- /dev/null +++ b/app/javascript/mastodon/features/standalone/public_timeline/index.js @@ -0,0 +1,76 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import StatusListContainer from '../../ui/containers/status_list_container'; +import { + refreshPublicTimeline, + expandPublicTimeline, +} from '../../../actions/timelines'; +import Column from '../../../components/column'; +import ColumnHeader from '../../../components/column_header'; +import { defineMessages, injectIntl } from 'react-intl'; + +const messages = defineMessages({ + title: { id: 'standalone.public_title', defaultMessage: 'A look inside...' }, +}); + +@connect() +@injectIntl +export default class PublicTimeline extends React.PureComponent { + + static propTypes = { + dispatch: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + }; + + handleHeaderClick = () => { + this.column.scrollTop(); + } + + setRef = c => { + this.column = c; + } + + componentDidMount () { + const { dispatch } = this.props; + + dispatch(refreshPublicTimeline()); + + this.polling = setInterval(() => { + dispatch(refreshPublicTimeline()); + }, 3000); + } + + componentWillUnmount () { + if (typeof this.polling !== 'undefined') { + clearInterval(this.polling); + this.polling = null; + } + } + + handleLoadMore = () => { + this.props.dispatch(expandPublicTimeline()); + } + + render () { + const { intl } = this.props; + + return ( + + + + + + ); + } + +} diff --git a/app/javascript/mastodon/features/status/components/action_bar.js b/app/javascript/mastodon/features/status/components/action_bar.js index 16ea83e40..5e150842e 100644 --- a/app/javascript/mastodon/features/status/components/action_bar.js +++ b/app/javascript/mastodon/features/status/components/action_bar.js @@ -15,7 +15,8 @@ const messages = defineMessages({ report: { id: 'status.report', defaultMessage: 'Report @{name}' }, }); -class ActionBar extends React.PureComponent { +@injectIntl +export default class ActionBar extends React.PureComponent { static contextTypes = { router: PropTypes.object, @@ -50,12 +51,11 @@ class ActionBar extends React.PureComponent { } handleMentionClick = () => { - this.props.onMention(this.props.status.get('account'), this.context.router); + this.props.onMention(this.props.status.get('account'), this.context.router.history); } handleReport = () => { this.props.onReport(this.props.status); - this.context.router.push('/report'); } render () { @@ -91,5 +91,3 @@ class ActionBar extends React.PureComponent { } } - -export default injectIntl(ActionBar); diff --git a/app/javascript/mastodon/features/status/components/card.js b/app/javascript/mastodon/features/status/components/card.js index 415587d6e..bfb40468b 100644 --- a/app/javascript/mastodon/features/status/components/card.js +++ b/app/javascript/mastodon/features/status/components/card.js @@ -17,7 +17,7 @@ const getHostname = url => { return parser.hostname; }; -class Card extends React.PureComponent { +export default class Card extends React.PureComponent { static propTypes = { card: ImmutablePropTypes.map, @@ -97,5 +97,3 @@ class Card extends React.PureComponent { } } - -export default Card; diff --git a/app/javascript/mastodon/features/status/components/detailed_status.js b/app/javascript/mastodon/features/status/components/detailed_status.js index 6bbb8ca33..619957dbe 100644 --- a/app/javascript/mastodon/features/status/components/detailed_status.js +++ b/app/javascript/mastodon/features/status/components/detailed_status.js @@ -7,12 +7,12 @@ import StatusContent from '../../../components/status_content'; import MediaGallery from '../../../components/media_gallery'; import VideoPlayer from '../../../components/video_player'; import AttachmentList from '../../../components/attachment_list'; -import Link from 'react-router/lib/Link'; +import Link from 'react-router-dom/Link'; import { FormattedDate, FormattedNumber } from 'react-intl'; import CardContainer from '../containers/card_container'; import ImmutablePureComponent from 'react-immutable-pure-component'; -class DetailedStatus extends ImmutablePureComponent { +export default class DetailedStatus extends ImmutablePureComponent { static contextTypes = { router: PropTypes.object, @@ -28,7 +28,7 @@ class DetailedStatus extends ImmutablePureComponent { handleAccountClick = (e) => { if (e.button === 0) { e.preventDefault(); - this.context.router.push(`/accounts/${this.props.status.getIn(['account', 'id'])}`); + this.context.router.history.push(`/accounts/${this.props.status.getIn(['account', 'id'])}`); } e.stopPropagation(); @@ -87,5 +87,3 @@ class DetailedStatus extends ImmutablePureComponent { } } - -export default DetailedStatus; diff --git a/app/javascript/mastodon/features/status/index.js b/app/javascript/mastodon/features/status/index.js index f8a87ccb1..cbabdd5bc 100644 --- a/app/javascript/mastodon/features/status/index.js +++ b/app/javascript/mastodon/features/status/index.js @@ -3,8 +3,6 @@ import { connect } from 'react-redux'; import PropTypes from 'prop-types'; import ImmutablePropTypes from 'react-immutable-proptypes'; import { fetchStatus } from '../../actions/statuses'; -import Immutable from 'immutable'; -import EmbeddedStatus from '../../components/status'; import MissingIndicator from '../../components/missing_indicator'; import DetailedStatus from './components/detailed_status'; import ActionBar from './components/action_bar'; @@ -21,17 +19,12 @@ import { } from '../../actions/compose'; import { deleteStatus } from '../../actions/statuses'; import { initReport } from '../../actions/reports'; -import { - makeGetStatus, - getStatusAncestors, - getStatusDescendants, -} from '../../selectors'; +import { makeGetStatus } from '../../selectors'; import { ScrollContainer } from 'react-router-scroll'; import ColumnBackButton from '../../components/column_back_button'; import StatusContainer from '../../containers/status_container'; import { openModal } from '../../actions/modal'; -import { isMobile } from '../../is_mobile'; -import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import { defineMessages, injectIntl } from 'react-intl'; import ImmutablePureComponent from 'react-immutable-pure-component'; const messages = defineMessages({ @@ -55,7 +48,9 @@ const makeMapStateToProps = () => { return mapStateToProps; }; -class Status extends ImmutablePureComponent { +@injectIntl +@connect(makeMapStateToProps) +export default class Status extends ImmutablePureComponent { static contextTypes = { router: PropTypes.object, @@ -93,7 +88,7 @@ class Status extends ImmutablePureComponent { } handleReplyClick = (status) => { - this.props.dispatch(replyCompose(status, this.context.router)); + this.props.dispatch(replyCompose(status, this.context.router.history)); } handleModalReblog = (status) => { @@ -159,8 +154,6 @@ class Status extends ImmutablePureComponent { ); } - const account = status.get('account'); - if (ancestorsIds && ancestorsIds.size > 0) { ancestors =
      {this.renderChildren(ancestorsIds)}
      ; } @@ -204,5 +197,3 @@ class Status extends ImmutablePureComponent { } } - -export default injectIntl(connect(makeMapStateToProps)(Status)); diff --git a/app/javascript/mastodon/features/ui/components/boost_modal.js b/app/javascript/mastodon/features/ui/components/boost_modal.js index 9d99b5336..6c80a1084 100644 --- a/app/javascript/mastodon/features/ui/components/boost_modal.js +++ b/app/javascript/mastodon/features/ui/components/boost_modal.js @@ -2,7 +2,6 @@ import React from 'react'; import ImmutablePropTypes from 'react-immutable-proptypes'; import PropTypes from 'prop-types'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; -import IconButton from '../../../components/icon_button'; import Button from '../../../components/button'; import StatusContent from '../../../components/status_content'; import Avatar from '../../../components/avatar'; @@ -14,7 +13,8 @@ const messages = defineMessages({ reblog: { id: 'status.reblog', defaultMessage: 'Boost' }, }); -class BoostModal extends ImmutablePureComponent { +@injectIntl +export default class BoostModal extends ImmutablePureComponent { static contextTypes = { router: PropTypes.object, @@ -40,7 +40,7 @@ class BoostModal extends ImmutablePureComponent { if (e.button === 0) { e.preventDefault(); this.props.onClose(); - this.context.router.push(`/accounts/${this.props.status.getIn(['account', 'id'])}`); + this.context.router.history.push(`/accounts/${this.props.status.getIn(['account', 'id'])}`); } } @@ -49,7 +49,7 @@ class BoostModal extends ImmutablePureComponent { } render () { - const { status, intl, onClose } = this.props; + const { status, intl } = this.props; return (
      @@ -82,5 +82,3 @@ class BoostModal extends ImmutablePureComponent { } } - -export default injectIntl(BoostModal); diff --git a/app/javascript/mastodon/features/ui/components/bundle.js b/app/javascript/mastodon/features/ui/components/bundle.js new file mode 100644 index 000000000..fc88e0c70 --- /dev/null +++ b/app/javascript/mastodon/features/ui/components/bundle.js @@ -0,0 +1,102 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +const emptyComponent = () => null; +const noop = () => { }; + +class Bundle extends React.Component { + + static propTypes = { + fetchComponent: PropTypes.func.isRequired, + loading: PropTypes.func, + error: PropTypes.func, + children: PropTypes.func.isRequired, + renderDelay: PropTypes.number, + onFetch: PropTypes.func, + onFetchSuccess: PropTypes.func, + onFetchFail: PropTypes.func, + } + + static defaultProps = { + loading: emptyComponent, + error: emptyComponent, + renderDelay: 0, + onFetch: noop, + onFetchSuccess: noop, + onFetchFail: noop, + } + + static cache = {} + + state = { + mod: undefined, + forceRender: false, + } + + componentWillMount() { + this.load(this.props); + } + + componentWillReceiveProps(nextProps) { + if (nextProps.fetchComponent !== this.props.fetchComponent) { + this.load(nextProps); + } + } + + componentWillUnmount () { + if (this.timeout) { + clearTimeout(this.timeout); + } + } + + load = (props) => { + const { fetchComponent, onFetch, onFetchSuccess, onFetchFail, renderDelay } = props || this.props; + + onFetch(); + + if (Bundle.cache[fetchComponent.name]) { + const mod = Bundle.cache[fetchComponent.name]; + + this.setState({ mod: mod.default }); + onFetchSuccess(); + return Promise.resolve(); + } + + this.setState({ mod: undefined }); + + if (renderDelay !== 0) { + this.timestamp = new Date(); + this.timeout = setTimeout(() => this.setState({ forceRender: true }), renderDelay); + } + + return fetchComponent() + .then((mod) => { + Bundle.cache[fetchComponent.name] = mod; + this.setState({ mod: mod.default }); + onFetchSuccess(); + }) + .catch((error) => { + this.setState({ mod: null }); + onFetchFail(error); + }); + } + + render() { + const { loading: Loading, error: Error, children, renderDelay } = this.props; + const { mod, forceRender } = this.state; + const elapsed = this.timestamp ? (new Date() - this.timestamp) : renderDelay; + + if (mod === undefined) { + return (elapsed >= renderDelay || forceRender) ? : null; + } + + if (mod === null) { + return ; + } + + return children(mod); + } + +} + +export default Bundle; diff --git a/app/javascript/mastodon/features/ui/components/bundle_column_error.js b/app/javascript/mastodon/features/ui/components/bundle_column_error.js new file mode 100644 index 000000000..cd124746a --- /dev/null +++ b/app/javascript/mastodon/features/ui/components/bundle_column_error.js @@ -0,0 +1,44 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { defineMessages, injectIntl } from 'react-intl'; + +import Column from './column'; +import ColumnHeader from './column_header'; +import ColumnBackButtonSlim from '../../../components/column_back_button_slim'; +import IconButton from '../../../components/icon_button'; + +const messages = defineMessages({ + title: { id: 'bundle_column_error.title', defaultMessage: 'Network error' }, + body: { id: 'bundle_column_error.body', defaultMessage: 'Something went wrong while loading this component.' }, + retry: { id: 'bundle_column_error.retry', defaultMessage: 'Try again' }, +}); + +class BundleColumnError extends React.Component { + + static propTypes = { + onRetry: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + } + + handleRetry = () => { + this.props.onRetry(); + } + + render () { + const { intl: { formatMessage } } = this.props; + + return ( + + + +
      + + {formatMessage(messages.body)} +
      +
      + ); + } + +} + +export default injectIntl(BundleColumnError); diff --git a/app/javascript/mastodon/features/ui/components/bundle_modal_error.js b/app/javascript/mastodon/features/ui/components/bundle_modal_error.js new file mode 100644 index 000000000..928bfe1f7 --- /dev/null +++ b/app/javascript/mastodon/features/ui/components/bundle_modal_error.js @@ -0,0 +1,53 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { defineMessages, injectIntl } from 'react-intl'; + +import IconButton from '../../../components/icon_button'; + +const messages = defineMessages({ + error: { id: 'bundle_modal_error.message', defaultMessage: 'Something went wrong while loading this component.' }, + retry: { id: 'bundle_modal_error.retry', defaultMessage: 'Try again' }, + close: { id: 'bundle_modal_error.close', defaultMessage: 'Close' }, +}); + +class BundleModalError extends React.Component { + + static propTypes = { + onRetry: PropTypes.func.isRequired, + onClose: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + } + + handleRetry = () => { + this.props.onRetry(); + } + + render () { + const { onClose, intl: { formatMessage } } = this.props; + + // Keep the markup in sync with + // (make sure they have the same dimensions) + return ( +
      +
      + + {formatMessage(messages.error)} +
      + +
      +
      + +
      +
      +
      + ); + } + +} + +export default injectIntl(BundleModalError); diff --git a/app/javascript/mastodon/features/ui/components/column.js b/app/javascript/mastodon/features/ui/components/column.js index 970d625b0..aea102aac 100644 --- a/app/javascript/mastodon/features/ui/components/column.js +++ b/app/javascript/mastodon/features/ui/components/column.js @@ -3,8 +3,9 @@ import ColumnHeader from './column_header'; import PropTypes from 'prop-types'; import { debounce } from 'lodash'; import scrollTop from '../../../scroll'; +import { isMobile } from '../../../is_mobile'; -class Column extends React.PureComponent { +export default class Column extends React.PureComponent { static propTypes = { heading: PropTypes.string, @@ -37,13 +38,12 @@ class Column extends React.PureComponent { render () { const { heading, icon, children, active, hideHeadingOnMobile } = this.props; - let columnHeaderId = null; - let header = ''; + const showHeading = heading && (!hideHeadingOnMobile || (hideHeadingOnMobile && !isMobile(window.innerWidth))); - if (heading) { - columnHeaderId = heading.replace(/ /g, '-'); - header = ; - } + const columnHeaderId = showHeading && heading.replace(/ /g, '-'); + const header = showHeading && ( + + ); return (
      +
      {icon} {type}
      @@ -34,5 +33,3 @@ class ColumnHeader extends React.PureComponent { } } - -export default ColumnHeader; diff --git a/app/javascript/mastodon/features/ui/components/column_link.js b/app/javascript/mastodon/features/ui/components/column_link.js index 24387af57..ad7ec9318 100644 --- a/app/javascript/mastodon/features/ui/components/column_link.js +++ b/app/javascript/mastodon/features/ui/components/column_link.js @@ -1,18 +1,18 @@ import React from 'react'; import PropTypes from 'prop-types'; -import Link from 'react-router/lib/Link'; +import Link from 'react-router-dom/Link'; -const ColumnLink = ({ icon, text, to, href, method, hideOnMobile }) => { +const ColumnLink = ({ icon, text, to, href, method }) => { if (href) { return ( - + {text} ); } else { return ( - + {text} diff --git a/app/javascript/mastodon/features/ui/components/column_loading.js b/app/javascript/mastodon/features/ui/components/column_loading.js new file mode 100644 index 000000000..1c4058926 --- /dev/null +++ b/app/javascript/mastodon/features/ui/components/column_loading.js @@ -0,0 +1,19 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import Column from '../../../components/column'; +import ColumnHeader from '../../../components/column_header'; + +const ColumnLoading = ({ title = '', icon = ' ' }) => ( + + +
      + +); + +ColumnLoading.propTypes = { + title: PropTypes.node, + icon: PropTypes.string, +}; + +export default ColumnLoading; diff --git a/app/javascript/mastodon/features/ui/components/columns_area.js b/app/javascript/mastodon/features/ui/components/columns_area.js index 6ed8bc20d..63bd1b021 100644 --- a/app/javascript/mastodon/features/ui/components/columns_area.js +++ b/app/javascript/mastodon/features/ui/components/columns_area.js @@ -1,13 +1,16 @@ import React from 'react'; import PropTypes from 'prop-types'; +import { injectIntl } from 'react-intl'; import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePureComponent from 'react-immutable-pure-component'; -import HomeTimeline from '../../home_timeline'; -import Notifications from '../../notifications'; -import PublicTimeline from '../../public_timeline'; -import CommunityTimeline from '../../community_timeline'; -import HashtagTimeline from '../../hashtag_timeline'; -import Compose from '../../compose'; + +import ReactSwipeableViews from 'react-swipeable-views'; +import { links, getIndex, getLink } from './tabs_bar'; + +import BundleContainer from '../containers/bundle_container'; +import ColumnLoading from './column_loading'; +import BundleColumnError from './bundle_column_error'; +import { Compose, Notifications, HomeTimeline, CommunityTimeline, PublicTimeline, HashtagTimeline, FavouritedStatuses } from '../../ui/util/async-components'; const componentMap = { 'COMPOSE': Compose, @@ -16,33 +19,110 @@ const componentMap = { 'PUBLIC': PublicTimeline, 'COMMUNITY': CommunityTimeline, 'HASHTAG': HashtagTimeline, + 'FAVOURITES': FavouritedStatuses, }; -class ColumnsArea extends ImmutablePureComponent { +@injectIntl +export default class ColumnsArea extends ImmutablePureComponent { + + static contextTypes = { + router: PropTypes.object.isRequired, + }; static propTypes = { + intl: PropTypes.object.isRequired, columns: ImmutablePropTypes.list.isRequired, singleColumn: PropTypes.bool, children: PropTypes.node, }; + state = { + shouldAnimate: false, + } + + componentWillReceiveProps() { + this.setState({ shouldAnimate: false }); + } + + componentDidMount() { + this.lastIndex = getIndex(this.context.router.history.location.pathname); + this.setState({ shouldAnimate: true }); + } + + componentDidUpdate() { + this.lastIndex = getIndex(this.context.router.history.location.pathname); + this.setState({ shouldAnimate: true }); + } + + handleSwipe = (index) => { + this.pendingIndex = index; + + const nextLinkTranslationId = links[index].props['data-preview-title-id']; + const currentLinkSelector = '.tabs-bar__link.active'; + const nextLinkSelector = `.tabs-bar__link[data-preview-title-id="${nextLinkTranslationId}"]`; + + // HACK: Remove the active class from the current link and set it to the next one + // React-router does this for us, but too late, feeling laggy. + document.querySelector(currentLinkSelector).classList.remove('active'); + document.querySelector(nextLinkSelector).classList.add('active'); + } + + handleAnimationEnd = () => { + if (typeof this.pendingIndex === 'number') { + this.context.router.history.push(getLink(this.pendingIndex)); + this.pendingIndex = null; + } + } + + 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'] }); + const icon = link.props['data-preview-icon']; + + const view = (index === columnIndex) ? + React.cloneElement(this.props.children) : + ; + + return ( +
      + {view} +
      + ); + } + + renderLoading = () => { + return ; + } + + renderError = (props) => { + return ; + } + render () { const { columns, children, singleColumn } = this.props; + const { shouldAnimate } = this.state; + + const columnIndex = getIndex(this.context.router.history.location.pathname); + this.pendingIndex = null; if (singleColumn) { - return ( -
      - {children} -
      - ); + return columnIndex !== -1 ? ( + + {links.map(this.renderView)} + + ) :
      {children}
      ; } return (
      {columns.map(column => { - const SpecificComponent = componentMap[column.get('id')]; const params = column.get('params', null) === null ? null : column.get('params').toJS(); - return ; + + return ( + + {SpecificComponent => } + + ); })} {React.Children.map(children, child => React.cloneElement(child, { multiColumn: true }))} @@ -51,5 +131,3 @@ class ColumnsArea extends ImmutablePureComponent { } } - -export default ColumnsArea; diff --git a/app/javascript/mastodon/features/ui/components/confirmation_modal.js b/app/javascript/mastodon/features/ui/components/confirmation_modal.js index f33bfd445..86588c46a 100644 --- a/app/javascript/mastodon/features/ui/components/confirmation_modal.js +++ b/app/javascript/mastodon/features/ui/components/confirmation_modal.js @@ -1,9 +1,10 @@ import React from 'react'; import PropTypes from 'prop-types'; -import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import { injectIntl, FormattedMessage } from 'react-intl'; import Button from '../../../components/button'; -class ConfirmationModal extends React.PureComponent { +@injectIntl +export default class ConfirmationModal extends React.PureComponent { static propTypes = { message: PropTypes.node.isRequired, @@ -50,5 +51,3 @@ class ConfirmationModal extends React.PureComponent { } } - -export default injectIntl(ConfirmationModal); diff --git a/app/javascript/mastodon/features/ui/components/image_loader.js b/app/javascript/mastodon/features/ui/components/image_loader.js index a2514d6be..aad594380 100644 --- a/app/javascript/mastodon/features/ui/components/image_loader.js +++ b/app/javascript/mastodon/features/ui/components/image_loader.js @@ -1,18 +1,21 @@ import React from 'react'; import PropTypes from 'prop-types'; +import classNames from 'classnames'; -class ImageLoader extends React.PureComponent { +export default class ImageLoader extends React.PureComponent { static propTypes = { alt: PropTypes.string, src: PropTypes.string.isRequired, previewSrc: PropTypes.string.isRequired, - width: PropTypes.number.isRequired, - height: PropTypes.number.isRequired, + width: PropTypes.number, + height: PropTypes.number, } static defaultProps = { alt: '', + width: null, + height: null, }; state = { @@ -20,50 +23,130 @@ class ImageLoader extends React.PureComponent { error: false, } - componentWillMount() { - this._loadImage(this.props.src); + removers = []; + + get canvasContext() { + if (!this.canvas) { + return null; + } + this._canvasContext = this._canvasContext || this.canvas.getContext('2d'); + return this._canvasContext; } - componentWillReceiveProps(props) { - this._loadImage(props.src); + componentDidMount () { + this.loadImage(this.props); } - _loadImage(src) { + componentWillReceiveProps (nextProps) { + if (this.props.src !== nextProps.src) { + this.loadImage(nextProps); + } + } + + loadImage (props) { + this.removeEventListeners(); + this.setState({ loading: true, error: false }); + Promise.all([ + this.loadPreviewCanvas(props), + this.hasSize() && this.loadOriginalImage(props), + ].filter(Boolean)) + .then(() => { + this.setState({ loading: false, error: false }); + this.clearPreviewCanvas(); + }) + .catch(() => this.setState({ loading: false, error: true })); + } + + loadPreviewCanvas = ({ previewSrc, width, height }) => new Promise((resolve, reject) => { const image = new Image(); + const removeEventListeners = () => { + image.removeEventListener('error', handleError); + image.removeEventListener('load', handleLoad); + }; + const handleError = () => { + removeEventListeners(); + reject(); + }; + const handleLoad = () => { + removeEventListeners(); + this.canvasContext.drawImage(image, 0, 0, width, height); + resolve(); + }; + image.addEventListener('error', handleError); + image.addEventListener('load', handleLoad); + image.src = previewSrc; + this.removers.push(removeEventListeners); + }) - image.onerror = () => this.setState({ loading: false, error: true }); - image.onload = () => this.setState({ loading: false, error: false }); - - image.src = src; - - this.setState({ loading: true }); + clearPreviewCanvas () { + const { width, height } = this.canvas; + this.canvasContext.clearRect(0, 0, width, height); } - render() { - const { alt, src, previewSrc, width, height } = this.props; - const { loading, error } = this.state; + loadOriginalImage = ({ src }) => new Promise((resolve, reject) => { + const image = new Image(); + const removeEventListeners = () => { + image.removeEventListener('error', handleError); + image.removeEventListener('load', handleLoad); + }; + const handleError = () => { + removeEventListeners(); + reject(); + }; + const handleLoad = () => { + removeEventListeners(); + resolve(); + }; + image.addEventListener('error', handleError); + image.addEventListener('load', handleLoad); + image.src = src; + this.removers.push(removeEventListeners); + }); + + removeEventListeners () { + this.removers.forEach(listeners => listeners()); + this.removers = []; + } + + hasSize () { + const { width, height } = this.props; + return typeof width === 'number' && typeof height === 'number'; + } + + setCanvasRef = c => { + this.canvas = c; + } + + render () { + const { alt, src, width, height } = this.props; + const { loading } = this.state; + + const className = classNames('image-loader', { + 'image-loader--loading': loading, + 'image-loader--amorphous': !this.hasSize(), + }); return ( -
      - {alt} + - {loading && + {!loading && ( - } + )}
      ); } } - -export default ImageLoader; diff --git a/app/javascript/mastodon/features/ui/components/media_modal.js b/app/javascript/mastodon/features/ui/components/media_modal.js index cff1a0cf5..dcc9becd3 100644 --- a/app/javascript/mastodon/features/ui/components/media_modal.js +++ b/app/javascript/mastodon/features/ui/components/media_modal.js @@ -1,5 +1,5 @@ import React from 'react'; -import LoadingIndicator from '../../../components/loading_indicator'; +import ReactSwipeableViews from 'react-swipeable-views'; import ImmutablePropTypes from 'react-immutable-proptypes'; import PropTypes from 'prop-types'; import ExtendedVideoPlayer from '../../../components/extended_video_player'; @@ -12,7 +12,8 @@ const messages = defineMessages({ close: { id: 'lightbox.close', defaultMessage: 'Close' }, }); -class MediaModal extends ImmutablePureComponent { +@injectIntl +export default class MediaModal extends ImmutablePureComponent { static propTypes = { media: ImmutablePropTypes.list.isRequired, @@ -25,12 +26,16 @@ class MediaModal extends ImmutablePureComponent { index: null, }; + handleSwipe = (index) => { + this.setState({ index: (index) % this.props.media.size }); + } + handleNextClick = () => { this.setState({ index: (this.getIndex() + 1) % this.props.media.size }); } handlePrevClick = () => { - this.setState({ index: (this.getIndex() - 1) % this.props.media.size }); + this.setState({ index: (this.props.media.size + this.getIndex() - 1) % this.props.media.size }); } handleKeyUp = (e) => { @@ -60,8 +65,6 @@ class MediaModal extends ImmutablePureComponent { const { media, intl, onClose } = this.props; const index = this.getIndex(); - const attachment = media.get(index); - const url = attachment.get('url'); let leftNav, rightNav, content; @@ -72,11 +75,18 @@ class MediaModal extends ImmutablePureComponent { rightNav =
      ; } - if (attachment.get('type') === 'image') { - content = ; - } else if (attachment.get('type') === 'gifv') { - content = ; - } + content = media.map((image) => { + const width = image.getIn(['meta', 'original', 'width']) || null; + const height = image.getIn(['meta', 'original', 'height']) || null; + + if (image.get('type') === 'image') { + return ; + } else if (image.get('type') === 'gifv') { + return ; + } + + return null; + }).toArray(); return (
      @@ -84,7 +94,9 @@ class MediaModal extends ImmutablePureComponent {
      - {content} + + {content} +
      {rightNav} @@ -93,5 +105,3 @@ class MediaModal extends ImmutablePureComponent { } } - -export default injectIntl(MediaModal); diff --git a/app/javascript/mastodon/features/ui/components/modal_loading.js b/app/javascript/mastodon/features/ui/components/modal_loading.js new file mode 100644 index 000000000..f403ca4c9 --- /dev/null +++ b/app/javascript/mastodon/features/ui/components/modal_loading.js @@ -0,0 +1,20 @@ +import React from 'react'; + +import LoadingIndicator from '../../../components/loading_indicator'; + +// Keep the markup in sync with +// (make sure they have the same dimensions) +const ModalLoading = () => ( +
      +
      + +
      +
      +
      +
      +
      +
      +); + +export default ModalLoading; diff --git a/app/javascript/mastodon/features/ui/components/modal_root.js b/app/javascript/mastodon/features/ui/components/modal_root.js index 0f68cfbdf..f303088d7 100644 --- a/app/javascript/mastodon/features/ui/components/modal_root.js +++ b/app/javascript/mastodon/features/ui/components/modal_root.js @@ -1,12 +1,18 @@ import React from 'react'; import PropTypes from 'prop-types'; -import MediaModal from './media_modal'; -import OnboardingModal from './onboarding_modal'; -import VideoModal from './video_modal'; -import BoostModal from './boost_modal'; -import ConfirmationModal from './confirmation_modal'; import TransitionMotion from 'react-motion/lib/TransitionMotion'; import spring from 'react-motion/lib/spring'; +import BundleContainer from '../containers/bundle_container'; +import BundleModalError from './bundle_modal_error'; +import ModalLoading from './modal_loading'; +import { + MediaModal, + OnboardingModal, + VideoModal, + BoostModal, + ConfirmationModal, + ReportModal, +} from '../../../features/ui/util/async-components'; const MODAL_COMPONENTS = { 'MEDIA': MediaModal, @@ -14,9 +20,10 @@ const MODAL_COMPONENTS = { 'VIDEO': VideoModal, 'BOOST': BoostModal, 'CONFIRM': ConfirmationModal, + 'REPORT': ReportModal, }; -class ModalRoot extends React.PureComponent { +export default class ModalRoot extends React.PureComponent { static propTypes = { type: PropTypes.string, @@ -47,6 +54,16 @@ class ModalRoot extends React.PureComponent { return { opacity: spring(0), scale: spring(0.98) }; } + renderLoading = () => { + return ; + } + + renderError = (props) => { + const { onClose } = this.props; + + return ; + } + render () { const { type, props, onClose } = this.props; const visible = !!type; @@ -68,18 +85,16 @@ class ModalRoot extends React.PureComponent { > {interpolatedStyles =>
      - {interpolatedStyles.map(({ key, data: { type, props }, style }) => { - const SpecificComponent = MODAL_COMPONENTS[type]; - - return ( -
      -
      -
      - -
      + {interpolatedStyles.map(({ key, data: { type, props }, style }) => ( +
      +
      +
      + + {(SpecificComponent) => } +
      - ); - })} +
      + ))}
      } @@ -87,5 +102,3 @@ class ModalRoot extends React.PureComponent { } } - -export default ModalRoot; diff --git a/app/javascript/mastodon/features/ui/components/onboarding_modal.js b/app/javascript/mastodon/features/ui/components/onboarding_modal.js index c8985dc83..3d59785e2 100644 --- a/app/javascript/mastodon/features/ui/components/onboarding_modal.js +++ b/app/javascript/mastodon/features/ui/components/onboarding_modal.js @@ -3,15 +3,14 @@ import { connect } from 'react-redux'; import PropTypes from 'prop-types'; import ImmutablePropTypes from 'react-immutable-proptypes'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import ReactSwipeableViews from 'react-swipeable-views'; import classNames from 'classnames'; import Permalink from '../../../components/permalink'; -import TransitionMotion from 'react-motion/lib/TransitionMotion'; -import spring from 'react-motion/lib/spring'; import ComposeForm from '../../compose/components/compose_form'; import Search from '../../compose/components/search'; import NavigationBar from '../../compose/components/navigation_bar'; import ColumnHeader from './column_header'; -import Immutable from 'immutable'; +import { List as ImmutableList } from 'immutable'; const noop = () => { }; @@ -49,7 +48,7 @@ const PageTwo = ({ me }) => (
      ( +const PageThree = ({ me }) => (
      ( PageThree.propTypes = { me: ImmutablePropTypes.map.isRequired, - domain: PropTypes.string.isRequired, }; const PageFour = ({ domain, intl }) => ( @@ -168,7 +166,9 @@ const mapStateToProps = state => ({ domain: state.getIn(['meta', 'domain']), }); -class OnboardingModal extends React.PureComponent { +@connect(mapStateToProps) +@injectIntl +export default class OnboardingModal extends React.PureComponent { static propTypes = { onClose: PropTypes.func.isRequired, @@ -187,7 +187,7 @@ class OnboardingModal extends React.PureComponent { this.pages = [ , , - , + , , , ]; @@ -225,6 +225,10 @@ class OnboardingModal extends React.PureComponent { })); } + handleSwipe = (index) => { + this.setState({ currentIndex: index }); + } + handleKeyUp = ({ key }) => { switch (key) { case 'ArrowLeft': @@ -261,30 +265,18 @@ class OnboardingModal extends React.PureComponent { ); - const styles = pages.map((data, i) => ({ - key: `page-${i}`, - data, - style: { - opacity: spring(i === currentIndex ? 1 : 0), - }, - })); - return (
      - - {interpolatedStyles => ( -
      - {interpolatedStyles.map(({ key, data, style }, i) => { - const className = classNames('onboarding-modal__page__wrapper', { - 'onboarding-modal__page__wrapper--active': i === currentIndex, - }); - return ( -
      {data}
      - ); - })} -
      - )} -
      + + {pages.map((page, i) => { + const className = classNames('onboarding-modal__page__wrapper', { + 'onboarding-modal__page__wrapper--active': i === currentIndex, + }); + return ( +
      {page}
      + ); + })} +
      @@ -323,5 +315,3 @@ class OnboardingModal extends React.PureComponent { } } - -export default connect(mapStateToProps)(injectIntl(OnboardingModal)); diff --git a/app/javascript/mastodon/features/report/index.js b/app/javascript/mastodon/features/ui/components/report_modal.js similarity index 54% rename from app/javascript/mastodon/features/report/index.js rename to app/javascript/mastodon/features/ui/components/report_modal.js index 217802b5c..b5dfa422e 100644 --- a/app/javascript/mastodon/features/report/index.js +++ b/app/javascript/mastodon/features/ui/components/report_modal.js @@ -1,19 +1,17 @@ import React from 'react'; import { connect } from 'react-redux'; -import { cancelReport, changeReportComment, submitReport } from '../../actions/reports'; -import { refreshAccountTimeline } from '../../actions/timelines'; +import { changeReportComment, submitReport } from '../../../actions/reports'; +import { refreshAccountTimeline } from '../../../actions/timelines'; import PropTypes from 'prop-types'; import ImmutablePropTypes from 'react-immutable-proptypes'; -import Column from '../ui/components/column'; -import Button from '../../components/button'; -import { makeGetAccount } from '../../selectors'; +import { makeGetAccount } from '../../../selectors'; import { defineMessages, FormattedMessage, injectIntl } from 'react-intl'; -import StatusCheckBox from './containers/status_check_box_container'; -import Immutable from 'immutable'; -import ColumnBackButtonSlim from '../../components/column_back_button_slim'; +import StatusCheckBox from '../../report/containers/status_check_box_container'; +import { OrderedSet } from 'immutable'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import Button from '../../../components/button'; const messages = defineMessages({ - heading: { id: 'report.heading', defaultMessage: 'New report' }, placeholder: { id: 'report.placeholder', defaultMessage: 'Additional comments' }, submit: { id: 'report.submit', defaultMessage: 'Submit' }, }); @@ -28,18 +26,16 @@ const makeMapStateToProps = () => { isSubmitting: state.getIn(['reports', 'new', 'isSubmitting']), account: getAccount(state, accountId), comment: state.getIn(['reports', 'new', 'comment']), - statusIds: Immutable.OrderedSet(state.getIn(['timelines', `account:${accountId}`, 'items'])).union(state.getIn(['reports', 'new', 'status_ids'])), + statusIds: OrderedSet(state.getIn(['timelines', `account:${accountId}`, 'items'])).union(state.getIn(['reports', 'new', 'status_ids'])), }; }; return mapStateToProps; }; -class Report extends React.PureComponent { - - static contextTypes = { - router: PropTypes.object, - }; +@connect(makeMapStateToProps) +@injectIntl +export default class ReportModal extends ImmutablePureComponent { static propTypes = { isSubmitting: PropTypes.bool, @@ -50,17 +46,15 @@ class Report extends React.PureComponent { intl: PropTypes.object.isRequired, }; - componentWillMount () { - if (!this.props.account) { - this.context.router.replace('/'); - } + handleCommentChange = (e) => { + this.props.dispatch(changeReportComment(e.target.value)); + } + + handleSubmit = () => { + this.props.dispatch(submitReport()); } componentDidMount () { - if (!this.props.account) { - return; - } - this.props.dispatch(refreshAccountTimeline(this.props.account.get('id'))); } @@ -70,15 +64,6 @@ class Report extends React.PureComponent { } } - handleCommentChange = (e) => { - this.props.dispatch(changeReportComment(e.target.value)); - } - - handleSubmit = () => { - this.props.dispatch(submitReport()); - this.context.router.replace('/'); - } - render () { const { account, comment, intl, statusIds, isSubmitting } = this.props; @@ -87,39 +72,34 @@ class Report extends React.PureComponent { } return ( - - +
      +
      + {account.get('acct')} }} /> +
      -
      -
      - - {account.get('acct')} -
      - -
      +
      +
      {statusIds.map(statusId => )}
      -
      +