diff options
53 files changed, 977 insertions, 431 deletions
diff --git a/.env.test b/.env.test index b57f52e30..7da76f8ef 100644 --- a/.env.test +++ b/.env.test @@ -1,3 +1,7 @@ # Federation LOCAL_DOMAIN=cb6e6126.ngrok.io LOCAL_HTTPS=true +# test pam authentication +PAM_ENABLED=true +PAM_DEFAULT_SERVICE=pam_test +PAM_CONTROLLED_SERVICE=pam_test_controlled diff --git a/.travis.yml b/.travis.yml index 3ac83e0e3..238b9a3f6 100644 --- a/.travis.yml +++ b/.travis.yml @@ -19,6 +19,7 @@ env: - LOCAL_HTTPS=true - RAILS_ENV=test - PARALLEL_TEST_PROCESSORS=2 + - ALLOW_NOPAM=true addons: postgresql: 9.4 @@ -43,11 +44,11 @@ services: install: - nvm install - - bundle install --path=vendor/bundle --without development production --retry=3 --jobs=16 + - bundle install --path=vendor/bundle --with pam_authentication --without development production --retry=3 --jobs=16 - yarn install before_script: - - ./bin/rails parallel:create parallel:load_schema parallel:prepare assets:precompile + - travis_wait ./bin/rails parallel:create parallel:load_schema parallel:prepare assets:precompile script: - travis_retry bundle exec parallel_test spec/ --group-by filesize --type rspec diff --git a/Gemfile b/Gemfile index 03ffd49ec..068b4874d 100644 --- a/Gemfile +++ b/Gemfile @@ -5,12 +5,12 @@ ruby '>= 2.3.0', '< 2.6.0' gem 'pkg-config', '~> 1.2' -gem 'puma', '~> 3.10' -gem 'rails', '~> 5.1.4' +gem 'puma', '~> 3.11' +gem 'rails', '~> 5.2.0' gem 'hamlit-rails', '~> 0.2' -gem 'pg', '~> 0.20' -gem 'pghero', '~> 1.7' +gem 'pg', '~> 1.0' +gem 'pghero', '~> 2.1' gem 'dotenv-rails', '~> 2.2' gem 'aws-sdk-s3', '~> 1.8', require: false @@ -24,17 +24,17 @@ gem 'streamio-ffmpeg', '~> 3.0' gem 'active_model_serializers', '~> 0.10' gem 'addressable', '~> 2.5' -gem 'bootsnap' +gem 'bootsnap', '~> 1.3' gem 'browser' gem 'charlock_holmes', '~> 0.7.6' gem 'iso-639' gem 'chewy', '~> 5.0' gem 'cld3', '~> 3.2.0' gem 'devise', '~> 4.4' -gem 'devise-two-factor', '~> 3.0' +gem 'devise-two-factor', '~> 3.0', git: 'https://github.com/ykzts/devise-two-factor.git', branch: 'rails-5.2' group :pam_authentication, optional: true do - gem 'devise_pam_authenticatable2', '~> 9.0' + gem 'devise_pam_authenticatable2', '~> 9.1' end gem 'net-ldap', '~> 0.10' @@ -42,7 +42,7 @@ gem 'omniauth-cas', '~> 1.1' gem 'omniauth-saml', '~> 1.10' gem 'omniauth', '~> 1.2' -gem 'doorkeeper', '~> 4.2' +gem 'doorkeeper', '~> 4.3' gem 'fast_blank', '~> 1.0' gem 'fastimage' gem 'goldfinger', '~> 2.1' @@ -52,50 +52,50 @@ gem 'html2text' gem 'htmlentities', '~> 4.3' gem 'http', '~> 3.0' gem 'http_accept_language', '~> 2.1' -gem 'httplog', '~> 0.99' +gem 'httplog', '~> 1.0' gem 'idn-ruby', require: 'idn' gem 'kaminari', '~> 1.1' gem 'link_header', '~> 0.0' gem 'mime-types', '~> 3.1' gem 'nokogiri', '~> 1.8' gem 'nsa', '~> 0.2' -gem 'oj', '~> 3.3' +gem 'oj', '~> 3.4' gem 'ostatus2', '~> 2.0' gem 'ox', '~> 2.8' gem 'pundit', '~> 1.1' gem 'premailer-rails' -gem 'rack-attack', '~> 5.0' -gem 'rack-cors', '~> 0.4', require: 'rack/cors' +gem 'rack-attack', '~> 5.2' +gem 'rack-cors', '~> 1.0', require: 'rack/cors' gem 'rack-timeout', '~> 0.4' -gem 'rails-i18n', '~> 5.0' +gem 'rails-i18n', '~> 5.1' gem 'rails-settings-cached', '~> 0.6' -gem 'redis', '~> 3.3', require: ['redis', 'redis/connection/hiredis'] +gem 'redis', '~> 4.0', 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 'ruby-progressbar', '~> 1.4' -gem 'sanitize', '~> 4.6.4' -gem 'sidekiq', '~> 5.0' -gem 'sidekiq-scheduler', '~> 2.1' +gem 'sanitize', '~> 4.6' +gem 'sidekiq', '~> 5.1' +gem 'sidekiq-scheduler', '~> 2.2' gem 'sidekiq-unique-jobs', '~> 5.0' gem 'sidekiq-bulk', '~>0.1.1' gem 'simple-navigation', '~> 4.0' -gem 'simple_form', '~> 3.4' +gem 'simple_form', '~> 4.0' gem 'sprockets-rails', '~> 3.2', require: 'sprockets/railtie' gem 'stoplight', '~> 2.1.3' -gem 'strong_migrations' +gem 'strong_migrations', '~> 0.2' gem 'tty-command' gem 'tty-prompt' gem 'twitter-text', '~> 1.14' -gem 'tzinfo-data', '~> 1.2017' -gem 'webpacker', '~> 3.0' +gem 'tzinfo-data', '~> 1.2018' +gem 'webpacker', '~> 3.4' gem 'webpush' -gem 'json-ld-preloaded', '~> 2.2.1' -gem 'rdf-normalize', '~> 0.3.1' +gem 'json-ld-preloaded', '~> 2.2' +gem 'rdf-normalize', '~> 0.3' group :development, :test do - gem 'fabrication', '~> 2.18' + gem 'fabrication', '~> 2.20' gem 'fuubar', '~> 2.2' gem 'i18n-tasks', '~> 0.9', require: false gem 'pry-rails', '~> 0.3' @@ -107,15 +107,15 @@ group :production, :test do end group :test do - gem 'capybara', '~> 2.15' + gem 'capybara', '~> 2.18' gem 'climate_control', '~> 0.2' - gem 'faker', '~> 1.7' + gem 'faker', '~> 1.8' gem 'microformats', '~> 4.0' gem 'rails-controller-testing', '~> 1.0' gem 'rspec-sidekiq', '~> 3.0' gem 'simplecov', '~> 0.14', require: false - gem 'webmock', '~> 3.0' - gem 'parallel_tests', '~> 2.17' + gem 'webmock', '~> 3.3' + gem 'parallel_tests', '~> 2.21' end group :development do @@ -123,12 +123,12 @@ group :development do gem 'annotate', '~> 2.7' gem 'better_errors', '~> 2.4' gem 'binding_of_caller', '~> 0.7' - gem 'bullet', '~> 5.5' + gem 'bullet', '~> 5.7' gem 'letter_opener', '~> 1.4' gem 'letter_opener_web', '~> 1.3' gem 'memory_profiler' gem 'rubocop', require: false - gem 'brakeman', '~> 4.0', require: false + gem 'brakeman', '~> 4.2', require: false gem 'bundler-audit', '~> 0.6', require: false gem 'scss_lint', '~> 0.55', require: false @@ -139,6 +139,6 @@ group :development do end group :production do - gem 'lograge', '~> 0.7' + gem 'lograge', '~> 0.9' gem 'redis-rails', '~> 5.0' end diff --git a/Gemfile.lock b/Gemfile.lock index c92b40d6c..09ee34f89 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,25 +1,37 @@ +GIT + remote: https://github.com/ykzts/devise-two-factor.git + revision: f60492b29c174d4c959ac02406392f8eb9c4d374 + branch: rails-5.2 + specs: + devise-two-factor (3.0.2) + activesupport (< 5.3) + attr_encrypted (>= 1.3, < 4, != 2) + devise (~> 4.0) + railties (< 5.3) + rotp (~> 2.0) + GEM remote: https://rubygems.org/ specs: - actioncable (5.1.4) - actionpack (= 5.1.4) + actioncable (5.2.0) + actionpack (= 5.2.0) nio4r (~> 2.0) - websocket-driver (~> 0.6.1) - actionmailer (5.1.4) - actionpack (= 5.1.4) - actionview (= 5.1.4) - activejob (= 5.1.4) + websocket-driver (>= 0.6.1) + actionmailer (5.2.0) + actionpack (= 5.2.0) + actionview (= 5.2.0) + activejob (= 5.2.0) mail (~> 2.5, >= 2.5.4) rails-dom-testing (~> 2.0) - actionpack (5.1.4) - actionview (= 5.1.4) - activesupport (= 5.1.4) + actionpack (5.2.0) + actionview (= 5.2.0) + activesupport (= 5.2.0) rack (~> 2.0) rack-test (>= 0.6.3) rails-dom-testing (~> 2.0) rails-html-sanitizer (~> 1.0, >= 1.0.2) - actionview (5.1.4) - activesupport (= 5.1.4) + actionview (5.2.0) + activesupport (= 5.2.0) builder (~> 3.1) erubi (~> 1.4) rails-dom-testing (~> 2.0) @@ -30,18 +42,22 @@ GEM case_transform (>= 0.2) jsonapi-renderer (>= 0.1.1.beta1, < 0.3) active_record_query_trace (1.5.4) - activejob (5.1.4) - activesupport (= 5.1.4) + activejob (5.2.0) + activesupport (= 5.2.0) globalid (>= 0.3.6) - activemodel (5.1.4) - activesupport (= 5.1.4) - activerecord (5.1.4) - activemodel (= 5.1.4) - activesupport (= 5.1.4) - arel (~> 8.0) - activesupport (5.1.4) + activemodel (5.2.0) + activesupport (= 5.2.0) + activerecord (5.2.0) + activemodel (= 5.2.0) + activesupport (= 5.2.0) + arel (>= 9.0) + activestorage (5.2.0) + actionpack (= 5.2.0) + activerecord (= 5.2.0) + marcel (~> 0.3.1) + activesupport (5.2.0) concurrent-ruby (~> 1.0, >= 1.0.2) - i18n (~> 0.7) + i18n (>= 0.7, < 2) minitest (~> 5.1) tzinfo (~> 1.1) addressable (2.5.2) @@ -51,9 +67,9 @@ GEM annotate (2.7.2) activerecord (>= 3.2, < 6.0) rake (>= 10.4, < 13.0) - arel (8.0.0) - ast (2.3.0) - attr_encrypted (3.0.3) + arel (9.0.0) + ast (2.4.0) + attr_encrypted (3.1.0) encryptor (~> 3.0.0) av (0.9.0) cocaine (~> 0.5.3) @@ -77,18 +93,18 @@ GEM rack (>= 0.9.0) binding_of_caller (0.8.0) debug_inspector (>= 0.0.1) - bootsnap (1.1.5) + bootsnap (1.3.0) msgpack (~> 1.0) - brakeman (4.0.1) + brakeman (4.2.1) browser (2.5.2) builder (3.2.3) - bullet (5.6.1) + bullet (5.7.5) activesupport (>= 3.0.0) - uniform_notifier (~> 1.10.0) + uniform_notifier (~> 1.11.0) bundler-audit (0.6.0) bundler (~> 1.2) thor (~> 0.18) - capistrano (3.10.0) + capistrano (3.10.1) airbrussh (>= 1.0.0) i18n rake (>= 10.0.0) @@ -104,13 +120,13 @@ GEM sshkit (~> 1.3) capistrano-yarn (2.0.2) capistrano (~> 3.0) - capybara (2.16.1) + capybara (2.18.0) addressable mini_mime (>= 0.1.3) nokogiri (>= 1.3.3) rack (>= 1.0.0) rack-test (>= 0.5.4) - xpath (~> 2.0) + xpath (>= 2.0, < 4.0) case_transform (0.2) activesupport charlock_holmes (0.7.6) @@ -118,7 +134,7 @@ GEM activesupport (>= 4.0) elasticsearch (>= 2.0.0) elasticsearch-dsl - chunky_png (1.3.8) + chunky_png (1.3.10) cld3 (3.2.2) ffi (>= 1.1.0, < 1.10.0) climate_control (0.2.0) @@ -130,37 +146,30 @@ GEM connection_pool (2.2.1) crack (0.4.3) safe_yaml (~> 1.0.0) - crass (1.0.3) + crass (1.0.4) css_parser (1.6.0) addressable debug_inspector (0.0.3) - devise (4.4.0) + devise (4.4.3) bcrypt (~> 3.0) orm_adapter (~> 0.1) - railties (>= 4.1.0, < 5.2) + railties (>= 4.1.0, < 6.0) responders warden (~> 1.2.3) - devise-two-factor (3.0.2) - activesupport (< 5.2) - attr_encrypted (>= 1.3, < 4, != 2) - devise (~> 4.0) - railties (< 5.2) - rotp (~> 2.0) - devise_pam_authenticatable2 (9.0.0) + devise_pam_authenticatable2 (9.1.0) devise (>= 4.0.0) - rpam2 (~> 3.0) + rpam2 (~> 4.0) diff-lcs (1.3) docile (1.1.5) domain_name (0.5.20170404) unf (>= 0.0.5, < 1.0.0) - doorkeeper (4.2.6) + doorkeeper (4.3.2) railties (>= 4.2) - dotenv (2.2.1) - dotenv-rails (2.2.1) - dotenv (= 2.2.1) - railties (>= 3.2, < 5.2) - easy_translate (0.5.0) - json + dotenv (2.2.2) + dotenv-rails (2.2.2) + dotenv (= 2.2.2) + railties (>= 3.2, < 6.0) + easy_translate (0.5.1) thread thread_safe elasticsearch (6.0.1) @@ -174,18 +183,18 @@ GEM multi_json encryptor (3.0.0) equatable (0.5.0) - erubi (1.7.0) - et-orbi (1.0.8) + erubi (1.7.1) + et-orbi (1.0.9) tzinfo - excon (0.59.0) - fabrication (2.18.0) - faker (1.8.4) - i18n (~> 0.5) + excon (0.60.0) + fabrication (2.20.1) + faker (1.8.7) + i18n (>= 0.7) faraday (0.14.0) multipart-post (>= 1.2, < 3) fast_blank (1.0.0) fastimage (2.1.1) - ffi (1.9.18) + ffi (1.9.21) fog-core (1.45.0) builder excon (~> 0.58) @@ -195,12 +204,12 @@ GEM multi_json (~> 1.10) fog-local (0.4.0) fog-core (~> 1.27) - fog-openstack (0.1.22) - fog-core (>= 1.40) + fog-openstack (0.1.23) + fog-core (~> 1.40) fog-json (>= 1.0) ipaddress (>= 0.8) formatador (0.2.5) - fuubar (2.2.0) + fuubar (2.3.1) rspec-core (~> 3.0) ruby-progressbar (~> 1.4) globalid (0.4.1) @@ -210,7 +219,7 @@ GEM http (~> 3.0) nokogiri (~> 1.8) oj (~> 3.0) - hamlit (2.8.5) + hamlit (2.8.8) temple (>= 0.8.0) thor tilt @@ -240,33 +249,33 @@ GEM http-form_data (2.0.0) http_accept_language (2.1.1) http_parser.rb (0.6.0) - httplog (0.99.7) - colorize - rack - i18n (0.9.5) + httplog (1.0.2) + colorize (~> 0.8) + rack (>= 1.0) + i18n (1.0.0) concurrent-ruby (~> 1.0) - i18n-tasks (0.9.19) + i18n-tasks (0.9.21) activesupport (>= 4.0.2) ast (>= 2.1.0) - easy_translate (>= 0.5.0) + easy_translate (>= 0.5.1) erubi highline (>= 1.7.3) i18n parser (>= 2.2.3.0) - rainbow (~> 2.2) + rainbow (>= 2.2.2, < 4.0) terminal-table (>= 1.5.1) idn-ruby (0.1.0) ipaddress (0.8.3) iso-639 (0.2.8) jmespath (1.3.1) json (2.1.0) - json-ld (2.1.7) + json-ld (2.2.1) + multi_json (~> 1.12) + rdf (>= 2.2.8, < 4.0) + json-ld-preloaded (2.2.3) + json-ld (>= 2.2, < 4.0) multi_json (~> 1.12) - rdf (~> 2.2, >= 2.2.8) - json-ld-preloaded (2.2.2) - json-ld (~> 2.1, >= 2.1.5) - multi_json (~> 1.11) - rdf (~> 2.2) + rdf (>= 2.2, < 4.0) jsonapi-renderer (0.2.0) jwt (2.1.0) kaminari (1.1.1) @@ -283,25 +292,27 @@ GEM kaminari-core (1.1.1) launchy (2.4.3) addressable (~> 2.3) - letter_opener (1.4.1) + letter_opener (1.6.0) launchy (~> 2.2) - letter_opener_web (1.3.1) + letter_opener_web (1.3.4) actionmailer (>= 3.2) letter_opener (~> 1.0) railties (>= 3.2) link_header (0.0.8) - lograge (0.7.1) - actionpack (>= 4, < 5.2) - activesupport (>= 4, < 5.2) - railties (>= 4, < 5.2) + lograge (0.9.0) + actionpack (>= 4) + activesupport (>= 4) + railties (>= 4) request_store (~> 1.0) - loofah (2.2.1) + loofah (2.2.2) crass (~> 1.0.2) nokogiri (>= 1.5.9) mail (2.7.0) mini_mime (>= 0.1.1) - mario-redis-lock (1.2.0) - redis (~> 3, >= 3.0.5) + marcel (0.3.2) + mimemagic (~> 0.3.2) + mario-redis-lock (1.2.1) + redis (>= 3.0.5) memory_profiler (0.9.10) method_source (0.9.0) microformats (4.0.7) @@ -314,15 +325,15 @@ GEM mini_mime (1.0.0) mini_portile2 (2.3.0) minitest (5.11.3) - msgpack (1.1.0) - multi_json (1.12.2) + msgpack (1.2.4) + multi_json (1.13.1) multipart-post (2.0.0) necromancer (0.4.0) net-ldap (0.16.1) net-scp (1.2.1) net-ssh (>= 2.6.5) net-ssh (4.2.0) - nio4r (2.1.0) + nio4r (2.3.0) nokogiri (1.8.2) mini_portile2 (~> 2.3.0) nokogumbo (1.5.0) @@ -332,7 +343,7 @@ GEM concurrent-ruby (~> 1.0.0) sidekiq (>= 3.5.0) statsd-ruby (~> 1.2.0) - oj (3.3.10) + oj (3.4.0) omniauth (1.8.1) hashie (>= 3.4.6, < 3.6.0) rack (>= 1.6.2, < 3) @@ -358,25 +369,25 @@ GEM paperclip-av-transcoder (0.6.4) av (~> 0.9.0) paperclip (>= 2.5.2) - parallel (1.12.0) - parallel_tests (2.19.0) + parallel (1.12.1) + parallel_tests (2.21.1) parallel - parser (2.4.0.2) - ast (~> 2.3) + parser (2.5.1.0) + ast (~> 2.4.0) pastel (0.7.2) equatable (~> 0.5.0) tty-color (~> 0.4.0) - pg (0.21.0) - pghero (1.7.0) + pg (1.0.0) + pghero (2.1.0) activerecord - pkg-config (1.2.8) + pkg-config (1.2.9) posix-spawn (0.3.13) powerpack (0.1.1) premailer (1.11.1) addressable css_parser (>= 1.6.0) htmlentities (>= 4.0.0) - premailer-rails (1.10.1) + premailer-rails (1.10.2) actionmailer (>= 3, < 6) premailer (~> 1.7, >= 1.7.9) private_address_check (0.4.1) @@ -385,32 +396,33 @@ GEM method_source (~> 0.9.0) pry-rails (0.3.6) pry (>= 0.10.4) - public_suffix (3.0.1) - puma (3.11.0) + public_suffix (3.0.2) + puma (3.11.3) pundit (1.1.0) activesupport (>= 3.0.0) - rack (2.0.3) - rack-attack (5.0.1) + rack (2.0.4) + rack-attack (5.2.0) rack - rack-cors (0.4.1) - rack-protection (2.0.0) + rack-cors (1.0.2) + rack-protection (2.0.1) rack - rack-proxy (0.6.2) + rack-proxy (0.6.4) rack - rack-test (0.8.2) + rack-test (1.0.0) rack (>= 1.0, < 3) rack-timeout (0.4.2) - rails (5.1.4) - actioncable (= 5.1.4) - actionmailer (= 5.1.4) - actionpack (= 5.1.4) - actionview (= 5.1.4) - activejob (= 5.1.4) - activemodel (= 5.1.4) - activerecord (= 5.1.4) - activesupport (= 5.1.4) + rails (5.2.0) + actioncable (= 5.2.0) + actionmailer (= 5.2.0) + actionpack (= 5.2.0) + actionview (= 5.2.0) + activejob (= 5.2.0) + activemodel (= 5.2.0) + activerecord (= 5.2.0) + activestorage (= 5.2.0) + activesupport (= 5.2.0) bundler (>= 1.3.0) - railties (= 5.1.4) + railties (= 5.2.0) sprockets-rails (>= 2.0.0) rails-controller-testing (1.0.2) actionpack (~> 5.x, >= 5.0.1) @@ -419,31 +431,30 @@ GEM rails-dom-testing (2.0.3) activesupport (>= 4.2.0) nokogiri (>= 1.6) - rails-html-sanitizer (1.0.3) - loofah (~> 2.0) - rails-i18n (5.0.4) - i18n (~> 0.7) - railties (~> 5.0) + rails-html-sanitizer (1.0.4) + loofah (~> 2.2, >= 2.2.2) + rails-i18n (5.1.1) + i18n (>= 0.7, < 2) + railties (>= 5.0, < 6) rails-settings-cached (0.6.6) rails (>= 4.2.0) - railties (5.1.4) - actionpack (= 5.1.4) - activesupport (= 5.1.4) + railties (5.2.0) + actionpack (= 5.2.0) + activesupport (= 5.2.0) method_source rake (>= 0.8.7) thor (>= 0.18.1, < 2.0) - rainbow (2.2.2) - rake - rake (12.3.0) + rainbow (3.0.0) + rake (12.3.1) rb-fsevent (0.10.2) rb-inotify (0.9.10) ffi (>= 0.5.0, < 2) - rdf (2.2.12) + rdf (3.0.1) hamster (~> 3.0) link_header (~> 0.0, >= 0.0.8) - rdf-normalize (0.3.2) - rdf (~> 2.0) - redis (3.3.5) + rdf-normalize (0.3.3) + rdf (>= 2.2, < 4.0) + redis (4.0.1) redis-actionpack (5.0.2) actionpack (>= 4.0, < 6) redis-rack (>= 1, < 3) @@ -453,7 +464,7 @@ GEM redis-store (>= 1.3, < 2) redis-namespace (1.6.0) redis (>= 3.0.4) - redis-rack (2.0.3) + redis-rack (2.0.4) rack (>= 1.5, < 3) redis-store (>= 1.2, < 2) redis-rails (5.0.2) @@ -462,15 +473,16 @@ GEM redis-store (>= 1.2, < 2) redis-store (1.4.1) redis (>= 2.2, < 5) - request_store (1.3.2) + request_store (1.4.0) + rack (>= 1.4) responders (2.4.0) actionpack (>= 4.2.0, < 5.3) railties (>= 4.2.0, < 5.3) rotp (2.1.2) - rpam2 (3.1.0) + rpam2 (4.0.2) rqrcode (0.10.1) chunky_png (~> 1.0) - rspec-core (3.7.0) + rspec-core (3.7.1) rspec-support (~> 3.7.0) rspec-expectations (3.7.0) diff-lcs (>= 1.2.0, < 2.0) @@ -489,12 +501,12 @@ GEM rspec-sidekiq (3.0.3) rspec-core (~> 3.0, >= 3.0.0) sidekiq (>= 2.4.0) - rspec-support (3.7.0) - rubocop (0.51.0) + rspec-support (3.7.1) + rubocop (0.52.1) parallel (~> 1.10) - parser (>= 2.3.3.1, < 3.0) + parser (>= 2.4.0.2, < 3.0) powerpack (~> 0.1) - rainbow (>= 2.2.2, < 3.0) + rainbow (>= 2.2.2, < 4.0) ruby-progressbar (~> 1.7) unicode-display_width (~> 1.0, >= 1.0.1) ruby-oembed (0.12.0) @@ -508,7 +520,7 @@ GEM crass (~> 1.0.2) nokogiri (>= 1.4.4) nokogumbo (~> 1.4) - sass (3.5.3) + sass (3.5.5) sass-listen (~> 4.0.0) sass-listen (4.0.0) rb-fsevent (~> 0.9, >= 0.9.4) @@ -516,15 +528,15 @@ GEM scss_lint (0.56.0) rake (>= 0.9, < 13) sass (~> 3.5.3) - sidekiq (5.0.5) + sidekiq (5.1.3) concurrent-ruby (~> 1.0) connection_pool (~> 2.2, >= 2.2.0) rack-protection (>= 1.5.0) - redis (>= 3.3.4, < 5) + redis (>= 3.3.5, < 5) sidekiq-bulk (0.1.1) activesupport sidekiq - sidekiq-scheduler (2.1.10) + sidekiq-scheduler (2.2.1) redis (>= 3, < 5) rufus-scheduler (~> 3.2) sidekiq (>= 3) @@ -534,9 +546,9 @@ GEM thor (~> 0) simple-navigation (4.0.5) activesupport (>= 2.3.2) - simple_form (3.5.0) - actionpack (> 4, < 5.2) - activemodel (> 4, < 5.2) + simple_form (4.0.0) + actionpack (> 4) + activemodel (> 4) simplecov (0.15.1) docile (~> 1.1.0) json (>= 1.8, < 3) @@ -549,14 +561,14 @@ GEM actionpack (>= 4.0) activesupport (>= 4.0) sprockets (>= 3.0.0) - sshkit (1.15.1) + sshkit (1.16.0) net-scp (>= 1.1.2) net-ssh (>= 2.8.0) statsd-ruby (1.2.1) stoplight (2.1.3) streamio-ffmpeg (3.0.2) multi_json (~> 1.8) - strong_migrations (0.1.9) + strong_migrations (0.2.2) activerecord (>= 3.2.0) temple (0.8.0) terminal-table (1.8.0) @@ -588,32 +600,32 @@ GEM unf (~> 0.1.0) tzinfo (1.2.5) thread_safe (~> 0.1) - tzinfo-data (1.2017.3) + tzinfo-data (1.2018.4) tzinfo (>= 1.0.0) unf (0.1.4) unf_ext - unf_ext (0.0.7.4) + unf_ext (0.0.7.5) unicode-display_width (1.3.0) - uniform_notifier (1.10.0) + uniform_notifier (1.11.0) warden (1.2.7) rack (>= 1.0) - webmock (3.1.1) + webmock (3.3.0) addressable (>= 2.3.6) crack (>= 0.3.2) hashdiff - webpacker (3.0.2) + webpacker (3.4.3) activesupport (>= 4.2) rack-proxy (>= 0.6.1) railties (>= 4.2) webpush (0.3.3) hkdf (~> 0.2) jwt (~> 2.0) - websocket-driver (0.6.5) + websocket-driver (0.7.0) websocket-extensions (>= 0.1.0) websocket-extensions (0.1.3) wisper (2.0.0) - xpath (2.1.0) - nokogiri (~> 1.3) + xpath (3.0.0) + nokogiri (~> 1.8) PLATFORMS ruby @@ -626,27 +638,27 @@ DEPENDENCIES aws-sdk-s3 (~> 1.8) better_errors (~> 2.4) binding_of_caller (~> 0.7) - bootsnap - brakeman (~> 4.0) + bootsnap (~> 1.3) + brakeman (~> 4.2) browser - bullet (~> 5.5) + bullet (~> 5.7) bundler-audit (~> 0.6) capistrano (~> 3.10) capistrano-rails (~> 1.3) capistrano-rbenv (~> 2.1) capistrano-yarn (~> 2.0) - capybara (~> 2.15) + capybara (~> 2.18) charlock_holmes (~> 0.7.6) chewy (~> 5.0) cld3 (~> 3.2.0) climate_control (~> 0.2) devise (~> 4.4) - devise-two-factor (~> 3.0) - devise_pam_authenticatable2 (~> 9.0) - doorkeeper (~> 4.2) + devise-two-factor (~> 3.0)! + devise_pam_authenticatable2 (~> 9.1) + doorkeeper (~> 4.3) dotenv-rails (~> 2.2) - fabrication (~> 2.18) - faker (~> 1.7) + fabrication (~> 2.20) + faker (~> 1.8) fast_blank (~> 1.0) fastimage fog-core (~> 1.45) @@ -660,16 +672,16 @@ DEPENDENCIES htmlentities (~> 4.3) http (~> 3.0) http_accept_language (~> 2.1) - httplog (~> 0.99) + httplog (~> 1.0) i18n-tasks (~> 0.9) idn-ruby iso-639 - json-ld-preloaded (~> 2.2.1) + json-ld-preloaded (~> 2.2) kaminari (~> 1.1) letter_opener (~> 1.4) letter_opener_web (~> 1.3) link_header (~> 0.0) - lograge (~> 0.7) + lograge (~> 0.9) mario-redis-lock (~> 1.2) memory_profiler microformats (~> 4.0) @@ -677,7 +689,7 @@ DEPENDENCIES net-ldap (~> 0.10) nokogiri (~> 1.8) nsa (~> 0.2) - oj (~> 3.3) + oj (~> 3.4) omniauth (~> 1.2) omniauth-cas (~> 1.1) omniauth-saml (~> 1.10) @@ -685,25 +697,25 @@ DEPENDENCIES ox (~> 2.8) paperclip (~> 6.0) paperclip-av-transcoder (~> 0.6) - parallel_tests (~> 2.17) - pg (~> 0.20) - pghero (~> 1.7) + parallel_tests (~> 2.21) + pg (~> 1.0) + pghero (~> 2.1) pkg-config (~> 1.2) posix-spawn premailer-rails private_address_check (~> 0.4.1) pry-rails (~> 0.3) - puma (~> 3.10) + puma (~> 3.11) pundit (~> 1.1) - rack-attack (~> 5.0) - rack-cors (~> 0.4) + rack-attack (~> 5.2) + rack-cors (~> 1.0) rack-timeout (~> 0.4) - rails (~> 5.1.4) + rails (~> 5.2.0) rails-controller-testing (~> 1.0) - rails-i18n (~> 5.0) + rails-i18n (~> 5.1) rails-settings-cached (~> 0.6) - rdf-normalize (~> 0.3.1) - redis (~> 3.3) + rdf-normalize (~> 0.3) + redis (~> 4.0) redis-namespace (~> 1.5) redis-rails (~> 5.0) rqrcode (~> 0.10) @@ -712,25 +724,25 @@ DEPENDENCIES rubocop ruby-oembed (~> 0.12) ruby-progressbar (~> 1.4) - sanitize (~> 4.6.4) + sanitize (~> 4.6) scss_lint (~> 0.55) - sidekiq (~> 5.0) + sidekiq (~> 5.1) sidekiq-bulk (~> 0.1.1) - sidekiq-scheduler (~> 2.1) + sidekiq-scheduler (~> 2.2) sidekiq-unique-jobs (~> 5.0) simple-navigation (~> 4.0) - simple_form (~> 3.4) + simple_form (~> 4.0) simplecov (~> 0.14) sprockets-rails (~> 3.2) stoplight (~> 2.1.3) streamio-ffmpeg (~> 3.0) - strong_migrations + strong_migrations (~> 0.2) tty-command tty-prompt twitter-text (~> 1.14) - tzinfo-data (~> 1.2017) - webmock (~> 3.0) - webpacker (~> 3.0) + tzinfo-data (~> 1.2018) + webmock (~> 3.3) + webpacker (~> 3.4) webpush RUBY VERSION diff --git a/app/controllers/api/v1/statuses_controller.rb b/app/controllers/api/v1/statuses_controller.rb index 28c28592a..e98241323 100644 --- a/app/controllers/api/v1/statuses_controller.rb +++ b/app/controllers/api/v1/statuses_controller.rb @@ -17,7 +17,7 @@ class Api::V1::StatusesController < Api::BaseController end def context - ancestors_results = @status.in_reply_to_id.nil? ? [] : @status.ancestors(current_account) + ancestors_results = @status.in_reply_to_id.nil? ? [] : @status.ancestors(DEFAULT_STATUSES_LIMIT, current_account) descendants_results = @status.descendants(current_account) loaded_ancestors = cache_collection(ancestors_results, Status) loaded_descendants = cache_collection(descendants_results, Status) diff --git a/app/controllers/api/web/push_subscriptions_controller.rb b/app/controllers/api/web/push_subscriptions_controller.rb index 68ccbd5e2..c611031ab 100644 --- a/app/controllers/api/web/push_subscriptions_controller.rb +++ b/app/controllers/api/web/push_subscriptions_controller.rb @@ -7,9 +7,6 @@ class Api::Web::PushSubscriptionsController < Api::BaseController protect_from_forgery with: :exception 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? @@ -29,12 +26,12 @@ class Api::Web::PushSubscriptionsController < Api::BaseController }, } - data.deep_merge!(params[:data]) if params[:data] + data.deep_merge!(data_params) if params[:data] web_subscription = ::Web::PushSubscription.create!( - endpoint: params[:subscription][:endpoint], - key_p256dh: params[:subscription][:keys][:p256dh], - key_auth: params[:subscription][:keys][:auth], + endpoint: subscription_params[:endpoint], + key_p256dh: subscription_params[:keys][:p256dh], + key_auth: subscription_params[:keys][:auth], data: data ) @@ -44,12 +41,22 @@ class Api::Web::PushSubscriptionsController < Api::BaseController end def update - params.require([:id, :data]) + params.require([:id]) web_subscription = ::Web::PushSubscription.find(params[:id]) - web_subscription.update!(data: params[:data]) + web_subscription.update!(data: data_params) render json: web_subscription.as_payload end + + private + + def subscription_params + @subscription_params ||= params.require(:subscription).permit(:endpoint, keys: [:auth, :p256dh]) + end + + def data_params + @data_params ||= params.require(:data).permit(:alerts) + end end diff --git a/app/controllers/settings/follower_domains_controller.rb b/app/controllers/settings/follower_domains_controller.rb index 141b2270d..02533b81a 100644 --- a/app/controllers/settings/follower_domains_controller.rb +++ b/app/controllers/settings/follower_domains_controller.rb @@ -5,7 +5,7 @@ require 'sidekiq-bulk' class Settings::FollowerDomainsController < Settings::BaseController def show @account = current_account - @domains = current_account.followers.reorder('MIN(follows.id) DESC').group('accounts.domain').select('accounts.domain, count(accounts.id) as accounts_from_domain').page(params[:page]).per(10) + @domains = current_account.followers.reorder(Arel.sql('MIN(follows.id) DESC')).group('accounts.domain').select('accounts.domain, count(accounts.id) as accounts_from_domain').page(params[:page]).per(10) end def update diff --git a/app/controllers/statuses_controller.rb b/app/controllers/statuses_controller.rb index 61ffb97d9..17fbaa62c 100644 --- a/app/controllers/statuses_controller.rb +++ b/app/controllers/statuses_controller.rb @@ -4,6 +4,8 @@ class StatusesController < ApplicationController include SignatureAuthentication include Authorization + ANCESTORS_LIMIT = 20 + layout 'public' before_action :set_account @@ -17,8 +19,9 @@ class StatusesController < ApplicationController respond_to do |format| format.html do use_pack 'public' - @ancestors = @status.reply? ? cache_collection(@status.ancestors(current_account), Status) : [] - @descendants = cache_collection(@status.descendants(current_account), Status) + @ancestors = @status.reply? ? cache_collection(@status.ancestors(ANCESTORS_LIMIT, current_account), Status) : [] + @next_ancestor = @ancestors.size < ANCESTORS_LIMIT ? nil : @ancestors.shift + @descendants = cache_collection(@status.descendants(current_account), Status) render 'stream_entries/show' end diff --git a/app/javascript/flavours/glitch/components/extended_video_player.js b/app/javascript/flavours/glitch/components/extended_video_player.js index f8bd067e8..9e2f6835a 100644 --- a/app/javascript/flavours/glitch/components/extended_video_player.js +++ b/app/javascript/flavours/glitch/components/extended_video_player.js @@ -11,6 +11,7 @@ export default class ExtendedVideoPlayer extends React.PureComponent { time: PropTypes.number, controls: PropTypes.bool.isRequired, muted: PropTypes.bool.isRequired, + onClick: PropTypes.func, }; handleLoadedData = () => { @@ -31,6 +32,12 @@ export default class ExtendedVideoPlayer extends React.PureComponent { this.video = c; } + handleClick = e => { + e.stopPropagation(); + const handler = this.props.onClick; + if (handler) handler(); + } + render () { const { src, muted, controls, alt } = this.props; @@ -46,6 +53,7 @@ export default class ExtendedVideoPlayer extends React.PureComponent { muted={muted} controls={controls} loop={!controls} + onClick={this.handleClick} /> </div> ); diff --git a/app/javascript/flavours/glitch/components/media_gallery.js b/app/javascript/flavours/glitch/components/media_gallery.js index 6928af6d6..309308d25 100644 --- a/app/javascript/flavours/glitch/components/media_gallery.js +++ b/app/javascript/flavours/glitch/components/media_gallery.js @@ -6,7 +6,7 @@ import IconButton from './icon_button'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import { isIOS } from 'flavours/glitch/util/is_mobile'; import classNames from 'classnames'; -import { autoPlayGif } from 'flavours/glitch/util/initial_state'; +import { autoPlayGif, displaySensitiveMedia } from 'flavours/glitch/util/initial_state'; const messages = defineMessages({ hidden: { @@ -208,7 +208,7 @@ export default class MediaGallery extends React.PureComponent { }; state = { - visible: !this.props.sensitive, + visible: !this.props.sensitive || displaySensitiveMedia, }; componentWillReceiveProps (nextProps) { @@ -265,6 +265,7 @@ export default class MediaGallery extends React.PureComponent { return ( <button className='media-spoiler' + type='button' onClick={handleOpen} > <span className='media-spoiler__warning'> diff --git a/app/javascript/flavours/glitch/components/status.js b/app/javascript/flavours/glitch/components/status.js index 2fcc44882..eb621d5d7 100644 --- a/app/javascript/flavours/glitch/components/status.js +++ b/app/javascript/flavours/glitch/components/status.js @@ -326,6 +326,7 @@ export default class Status extends ImmutablePureComponent { {Component => (<Component preview={video.get('preview_url')} src={video.get('url')} + inline sensitive={status.get('sensitive')} letterbox={settings.getIn(['media', 'letterbox'])} fullwidth={settings.getIn(['media', 'fullwidth'])} diff --git a/app/javascript/flavours/glitch/features/composer/options/dropdown/content/index.js b/app/javascript/flavours/glitch/features/composer/options/dropdown/content/index.js index b3a472999..b76410561 100644 --- a/app/javascript/flavours/glitch/features/composer/options/dropdown/content/index.js +++ b/app/javascript/flavours/glitch/features/composer/options/dropdown/content/index.js @@ -13,7 +13,6 @@ import { assignHandlers } from 'flavours/glitch/util/react_helpers'; // Handlers. const handlers = { - // When the document is clicked elsewhere, we close the dropdown. handleDocumentClick ({ target }) { const { node } = this; @@ -45,6 +44,10 @@ export default class ComposerOptionsDropdownContent extends React.PureComponent // Instance variables. this.node = null; + + this.state = { + mounted: false, + }; } // On mounting, we add our listeners. @@ -52,6 +55,7 @@ export default class ComposerOptionsDropdownContent extends React.PureComponent const { handleDocumentClick } = this.handlers; document.addEventListener('click', handleDocumentClick, false); document.addEventListener('touchend', handleDocumentClick, withPassive); + this.setState({ mounted: true }); } // On unmounting, we remove our listeners. @@ -63,6 +67,7 @@ export default class ComposerOptionsDropdownContent extends React.PureComponent // Rendering. render () { + const { mounted } = this.state; const { handleRef } = this.handlers; const { items, @@ -87,13 +92,16 @@ export default class ComposerOptionsDropdownContent extends React.PureComponent }} > {({ opacity, scaleX, scaleY }) => ( + // It should not be transformed when mounting because the resulting + // size will be used to determine the coordinate of the menu by + // react-overlays <div className='composer--options--dropdown--content' ref={handleRef} style={{ ...style, opacity: opacity, - transform: `scale(${scaleX}, ${scaleY})`, + transform: mounted ? `scale(${scaleX}, ${scaleY})` : null, }} > {items ? items.map( diff --git a/app/javascript/flavours/glitch/features/composer/options/dropdown/index.js b/app/javascript/flavours/glitch/features/composer/options/dropdown/index.js index d63d90a9f..b3462e25a 100644 --- a/app/javascript/flavours/glitch/features/composer/options/dropdown/index.js +++ b/app/javascript/flavours/glitch/features/composer/options/dropdown/index.js @@ -29,7 +29,7 @@ const handlers = { } = this.handlers; switch (key) { case 'Enter': - handleToggle(); + handleToggle(key); break; case 'Escape': handleClose(); @@ -79,7 +79,7 @@ const handlers = { }, // Toggles opening and closing the dropdown. - handleToggle () { + handleToggle ({ target }) { const { handleMakeModal } = this.handlers; const { onModalOpen } = this.props; const { open } = this.state; @@ -98,6 +98,8 @@ const handlers = { } } + const { top } = target.getBoundingClientRect(); + this.setState({ placement: top * 2 < innerHeight ? 'bottom' : 'top' }); // Otherwise, we just set our state to open. this.setState({ open: !open }); }, @@ -129,6 +131,7 @@ export default class ComposerOptionsDropdown extends React.PureComponent { this.state = { needsModalUpdate: false, open: false, + placement: null, }; } @@ -161,7 +164,7 @@ export default class ComposerOptionsDropdown extends React.PureComponent { onChange, value, } = this.props; - const { open } = this.state; + const { open, placement } = this.state; const computedClass = classNames('composer--options--dropdown', { active, open, @@ -188,7 +191,7 @@ export default class ComposerOptionsDropdown extends React.PureComponent { /> <Overlay containerPadding={20} - placement='bottom' + placement={placement} show={open} target={this} > diff --git a/app/javascript/flavours/glitch/features/status/components/card.js b/app/javascript/flavours/glitch/features/status/components/card.js index bb83374b9..680bf63ab 100644 --- a/app/javascript/flavours/glitch/features/status/components/card.js +++ b/app/javascript/flavours/glitch/features/status/components/card.js @@ -1,5 +1,6 @@ import React from 'react'; import PropTypes from 'prop-types'; +import Immutable from 'immutable'; import ImmutablePropTypes from 'react-immutable-proptypes'; import punycode from 'punycode'; import classnames from 'classnames'; @@ -24,6 +25,7 @@ export default class Card extends React.PureComponent { static propTypes = { card: ImmutablePropTypes.map, maxDescription: PropTypes.number, + onOpenMedia: PropTypes.func.isRequired, }; static defaultProps = { @@ -34,6 +36,27 @@ export default class Card extends React.PureComponent { width: 0, }; + handlePhotoClick = () => { + const { card, onOpenMedia } = this.props; + + onOpenMedia( + Immutable.fromJS([ + { + type: 'image', + url: card.get('url'), + description: card.get('title'), + meta: { + original: { + width: card.get('width'), + height: card.get('height'), + }, + }, + }, + ]), + 0 + ); + }; + renderLink () { const { card, maxDescription } = this.props; @@ -73,9 +96,16 @@ export default class Card extends React.PureComponent { const { card } = this.props; return ( - <a href={card.get('url')} className='status-card-photo' target='_blank' rel='noopener'> - <img src={card.get('url')} alt={card.get('title')} width={card.get('width')} height={card.get('height')} /> - </a> + <img + className='status-card-photo' + onClick={this.handlePhotoClick} + role='button' + tabIndex='0' + src={card.get('url')} + alt={card.get('title')} + width={card.get('width')} + height={card.get('height')} + /> ); } diff --git a/app/javascript/flavours/glitch/features/status/components/detailed_status.js b/app/javascript/flavours/glitch/features/status/components/detailed_status.js index 9e42481c5..e7c26d013 100644 --- a/app/javascript/flavours/glitch/features/status/components/detailed_status.js +++ b/app/javascript/flavours/glitch/features/status/components/detailed_status.js @@ -58,6 +58,7 @@ export default class DetailedStatus extends ImmutablePureComponent { <Video preview={video.get('preview_url')} src={video.get('url')} + inline sensitive={status.get('sensitive')} letterbox={settings.getIn(['media', 'letterbox'])} fullwidth={settings.getIn(['media', 'fullwidth'])} @@ -77,7 +78,7 @@ export default class DetailedStatus extends ImmutablePureComponent { ); mediaIcon = 'picture-o'; } - } else media = <CardContainer statusId={status.get('id')} />; + } else media = <CardContainer onOpenMedia={this.props.onOpenMedia} statusId={status.get('id')} />; if (status.get('application')) { applicationLink = <span> · <a className='detailed-status__application' href={status.getIn(['application', 'website'])} target='_blank' rel='noopener'>{status.getIn(['application', 'name'])}</a></span>; diff --git a/app/javascript/flavours/glitch/features/ui/components/image_loader.js b/app/javascript/flavours/glitch/features/ui/components/image_loader.js index aad594380..c7360a726 100644 --- a/app/javascript/flavours/glitch/features/ui/components/image_loader.js +++ b/app/javascript/flavours/glitch/features/ui/components/image_loader.js @@ -1,15 +1,17 @@ import React from 'react'; import PropTypes from 'prop-types'; import classNames from 'classnames'; +import ZoomableImage from './zoomable_image'; export default class ImageLoader extends React.PureComponent { static propTypes = { alt: PropTypes.string, src: PropTypes.string.isRequired, - previewSrc: PropTypes.string.isRequired, + previewSrc: PropTypes.string, width: PropTypes.number, height: PropTypes.number, + onClick: PropTypes.func, } static defaultProps = { @@ -24,6 +26,7 @@ export default class ImageLoader extends React.PureComponent { } removers = []; + canvas = null; get canvasContext() { if (!this.canvas) { @@ -43,11 +46,15 @@ export default class ImageLoader extends React.PureComponent { } } + componentWillUnmount () { + this.removeEventListeners(); + } + loadImage (props) { this.removeEventListeners(); this.setState({ loading: true, error: false }); Promise.all([ - this.loadPreviewCanvas(props), + props.previewSrc && this.loadPreviewCanvas(props), this.hasSize() && this.loadOriginalImage(props), ].filter(Boolean)) .then(() => { @@ -118,7 +125,7 @@ export default class ImageLoader extends React.PureComponent { } render () { - const { alt, src, width, height } = this.props; + const { alt, src, width, height, onClick } = this.props; const { loading } = this.state; const className = classNames('image-loader', { @@ -128,22 +135,19 @@ export default class ImageLoader extends React.PureComponent { return ( <div className={className}> - <canvas - className='image-loader__preview-canvas' - width={width} - height={height} - ref={this.setCanvasRef} - style={{ opacity: loading ? 1 : 0 }} - /> - - {!loading && ( - <img - alt={alt} - className='image-loader__img' - src={src} + {loading ? ( + <canvas + className='image-loader__preview-canvas' + ref={this.setCanvasRef} width={width} height={height} /> + ) : ( + <ZoomableImage + alt={alt} + src={src} + onClick={onClick} + /> )} </div> ); diff --git a/app/javascript/flavours/glitch/features/ui/components/media_modal.js b/app/javascript/flavours/glitch/features/ui/components/media_modal.js index e56147c5b..6ab6770ed 100644 --- a/app/javascript/flavours/glitch/features/ui/components/media_modal.js +++ b/app/javascript/flavours/glitch/features/ui/components/media_modal.js @@ -3,6 +3,7 @@ import ReactSwipeableViews from 'react-swipeable-views'; import ImmutablePropTypes from 'react-immutable-proptypes'; import PropTypes from 'prop-types'; import ExtendedVideoPlayer from 'flavours/glitch/components/extended_video_player'; +import classNames from 'classnames'; import { defineMessages, injectIntl } from 'react-intl'; import IconButton from 'flavours/glitch/components/icon_button'; import ImmutablePureComponent from 'react-immutable-pure-component'; @@ -26,6 +27,7 @@ export default class MediaModal extends ImmutablePureComponent { state = { index: null, + navigationHidden: false, }; handleSwipe = (index) => { @@ -68,14 +70,21 @@ export default class MediaModal extends ImmutablePureComponent { return this.state.index !== null ? this.state.index : this.props.index; } + toggleNavigation = () => { + this.setState(prevState => ({ + navigationHidden: !prevState.navigationHidden, + })); + }; + render () { const { media, intl, onClose } = this.props; + const { navigationHidden } = this.state; const index = this.getIndex(); let pagination = []; - const leftNav = media.size > 1 && <button tabIndex='0' className='modal-container__nav modal-container__nav--left' onClick={this.handlePrevClick} aria-label={intl.formatMessage(messages.previous)}><i className='fa fa-fw fa-chevron-left' /></button>; - const rightNav = media.size > 1 && <button tabIndex='0' className='modal-container__nav modal-container__nav--right' onClick={this.handleNextClick} aria-label={intl.formatMessage(messages.next)}><i className='fa fa-fw fa-chevron-right' /></button>; + const leftNav = media.size > 1 && <button tabIndex='0' className='media-modal__nav media-modal__nav--left' onClick={this.handlePrevClick} aria-label={intl.formatMessage(messages.previous)}><i className='fa fa-fw fa-chevron-left' /></button>; + const rightNav = media.size > 1 && <button tabIndex='0' className='media-modal__nav media-modal__nav--right' onClick={this.handleNextClick} aria-label={intl.formatMessage(messages.next)}><i className='fa fa-fw fa-chevron-right' /></button>; if (media.size > 1) { pagination = media.map((item, i) => { @@ -92,33 +101,77 @@ export default class MediaModal extends ImmutablePureComponent { const height = image.getIn(['meta', 'original', 'height']) || null; if (image.get('type') === 'image') { - return <ImageLoader previewSrc={image.get('preview_url')} src={image.get('url')} width={width} height={height} alt={image.get('description')} key={image.get('preview_url')} />; + return ( + <ImageLoader + previewSrc={image.get('preview_url')} + src={image.get('url')} + width={width} + height={height} + alt={image.get('description')} + key={image.get('url')} + onClick={this.toggleNavigation} + /> + ); } else if (image.get('type') === 'gifv') { - return <ExtendedVideoPlayer src={image.get('url')} muted controls={false} width={width} height={height} key={image.get('preview_url')} alt={image.get('description')} />; + return ( + <ExtendedVideoPlayer + src={image.get('url')} + muted + controls={false} + width={width} + height={height} + key={image.get('preview_url')} + alt={image.get('description')} + onClick={this.toggleNavigation} + /> + ); } return null; }).toArray(); + // you can't use 100vh, because the viewport height is taller + // than the visible part of the document in some mobile + // browsers when it's address bar is visible. + // https://developers.google.com/web/updates/2016/12/url-bar-resizing + const swipeableViewsStyle = { + width: '100%', + height: '100%', + }; + const containerStyle = { alignItems: 'center', // center vertically }; + const navigationClassName = classNames('media-modal__navigation', { + 'media-modal__navigation--hidden': navigationHidden, + }); + return ( <div className='modal-root__modal media-modal'> - {leftNav} - - <div className='media-modal__content'> - <IconButton className='media-modal__close' title={intl.formatMessage(messages.close)} icon='times' onClick={onClose} size={16} /> - <ReactSwipeableViews containerStyle={containerStyle} onChangeIndex={this.handleSwipe} index={index}> + <div + className='media-modal__closer' + role='presentation' + onClick={onClose} + > + <ReactSwipeableViews + style={swipeableViewsStyle} + containerStyle={containerStyle} + onChangeIndex={this.handleSwipe} + onSwitching={this.handleSwitching} + index={index} + > {content} </ReactSwipeableViews> </div> - <ul className='media-modal__pagination'> - {pagination} - </ul> - - {rightNav} + <div className={navigationClassName}> + <IconButton className='media-modal__close' title={intl.formatMessage(messages.close)} icon='times' onClick={onClose} size={40} /> + {leftNav} + {rightNav} + <ul className='media-modal__pagination'> + {pagination} + </ul> + </div> </div> ); } diff --git a/app/javascript/flavours/glitch/features/ui/components/video_modal.js b/app/javascript/flavours/glitch/features/ui/components/video_modal.js index 4412fd0f7..e0cb7fc09 100644 --- a/app/javascript/flavours/glitch/features/ui/components/video_modal.js +++ b/app/javascript/flavours/glitch/features/ui/components/video_modal.js @@ -16,7 +16,7 @@ export default class VideoModal extends ImmutablePureComponent { const { media, time, onClose } = this.props; return ( - <div className='modal-root__modal media-modal'> + <div className='modal-root__modal video-modal'> <div> <Video preview={media.get('preview_url')} diff --git a/app/javascript/flavours/glitch/features/ui/components/zoomable_image.js b/app/javascript/flavours/glitch/features/ui/components/zoomable_image.js new file mode 100644 index 000000000..0a0a4d41a --- /dev/null +++ b/app/javascript/flavours/glitch/features/ui/components/zoomable_image.js @@ -0,0 +1,151 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +const MIN_SCALE = 1; +const MAX_SCALE = 4; + +const getMidpoint = (p1, p2) => ({ + x: (p1.clientX + p2.clientX) / 2, + y: (p1.clientY + p2.clientY) / 2, +}); + +const getDistance = (p1, p2) => + Math.sqrt(Math.pow(p1.clientX - p2.clientX, 2) + Math.pow(p1.clientY - p2.clientY, 2)); + +const clamp = (min, max, value) => Math.min(max, Math.max(min, value)); + +export default class ZoomableImage extends React.PureComponent { + + static propTypes = { + alt: PropTypes.string, + src: PropTypes.string.isRequired, + width: PropTypes.number, + height: PropTypes.number, + onClick: PropTypes.func, + } + + static defaultProps = { + alt: '', + width: null, + height: null, + }; + + state = { + scale: MIN_SCALE, + } + + removers = []; + container = null; + image = null; + lastTouchEndTime = 0; + lastDistance = 0; + + componentDidMount () { + let handler = this.handleTouchStart; + this.container.addEventListener('touchstart', handler); + this.removers.push(() => this.container.removeEventListener('touchstart', handler)); + handler = this.handleTouchMove; + // on Chrome 56+, touch event listeners will default to passive + // https://www.chromestatus.com/features/5093566007214080 + this.container.addEventListener('touchmove', handler, { passive: false }); + this.removers.push(() => this.container.removeEventListener('touchend', handler)); + } + + componentWillUnmount () { + this.removeEventListeners(); + } + + removeEventListeners () { + this.removers.forEach(listeners => listeners()); + this.removers = []; + } + + handleTouchStart = e => { + if (e.touches.length !== 2) return; + + this.lastDistance = getDistance(...e.touches); + } + + handleTouchMove = e => { + const { scrollTop, scrollHeight, clientHeight } = this.container; + if (e.touches.length === 1 && scrollTop !== scrollHeight - clientHeight) { + // prevent propagating event to MediaModal + e.stopPropagation(); + return; + } + if (e.touches.length !== 2) return; + + e.preventDefault(); + e.stopPropagation(); + + const distance = getDistance(...e.touches); + const midpoint = getMidpoint(...e.touches); + const scale = clamp(MIN_SCALE, MAX_SCALE, this.state.scale * distance / this.lastDistance); + + this.zoom(scale, midpoint); + + this.lastMidpoint = midpoint; + this.lastDistance = distance; + } + + zoom(nextScale, midpoint) { + const { scale } = this.state; + const { scrollLeft, scrollTop } = this.container; + + // math memo: + // x = (scrollLeft + midpoint.x) / scrollWidth + // x' = (nextScrollLeft + midpoint.x) / nextScrollWidth + // scrollWidth = clientWidth * scale + // scrollWidth' = clientWidth * nextScale + // Solve x = x' for nextScrollLeft + const nextScrollLeft = (scrollLeft + midpoint.x) * nextScale / scale - midpoint.x; + const nextScrollTop = (scrollTop + midpoint.y) * nextScale / scale - midpoint.y; + + this.setState({ scale: nextScale }, () => { + this.container.scrollLeft = nextScrollLeft; + this.container.scrollTop = nextScrollTop; + }); + } + + handleClick = e => { + // don't propagate event to MediaModal + e.stopPropagation(); + const handler = this.props.onClick; + if (handler) handler(); + } + + setContainerRef = c => { + this.container = c; + } + + setImageRef = c => { + this.image = c; + } + + render () { + const { alt, src } = this.props; + const { scale } = this.state; + const overflow = scale === 1 ? 'hidden' : 'scroll'; + + return ( + <div + className='zoomable-image' + ref={this.setContainerRef} + style={{ overflow }} + > + <img + role='presentation' + ref={this.setImageRef} + alt={alt} + src={src} + style={{ + transform: `scale(${scale})`, + transformOrigin: '0 0', + }} + onClick={this.handleClick} + /> + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/video/index.js b/app/javascript/flavours/glitch/features/video/index.js index ef19a85ec..56ee9c20c 100644 --- a/app/javascript/flavours/glitch/features/video/index.js +++ b/app/javascript/flavours/glitch/features/video/index.js @@ -4,6 +4,7 @@ import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import { throttle } from 'lodash'; import classNames from 'classnames'; import { isFullscreen, requestFullscreen, exitFullscreen } from 'flavours/glitch/util/fullscreen'; +import { displaySensitiveMedia } from 'flavours/glitch/util/initial_state'; const messages = defineMessages({ play: { id: 'video.play', defaultMessage: 'Play' }, @@ -97,6 +98,7 @@ export default class Video extends React.PureComponent { letterbox: PropTypes.bool, fullwidth: PropTypes.bool, detailed: PropTypes.bool, + inline: PropTypes.bool, intl: PropTypes.object.isRequired, }; @@ -105,14 +107,21 @@ export default class Video extends React.PureComponent { duration: 0, paused: true, dragging: false, + containerWidth: false, fullscreen: false, hovered: false, muted: false, - revealed: !this.props.sensitive, + revealed: !this.props.sensitive || displaySensitiveMedia, }; setPlayerRef = c => { this.player = c; + + if (c) { + this.setState({ + containerWidth: c.offsetWidth, + }); + } } setVideoRef = c => { @@ -246,12 +255,23 @@ export default class Video extends React.PureComponent { } render () { - const { preview, src, width, height, startTime, onOpenVideo, onCloseVideo, intl, alt, letterbox, fullwidth, detailed } = this.props; - const { currentTime, duration, buffer, dragging, paused, fullscreen, hovered, muted, revealed } = this.state; + const { preview, src, inline, startTime, onOpenVideo, onCloseVideo, intl, alt, letterbox, fullwidth, detailed } = this.props; + const { containerWidth, currentTime, duration, buffer, dragging, paused, fullscreen, hovered, muted, revealed } = this.state; const progress = (currentTime / duration) * 100; + const playerStyle = {}; + + let { width, height } = this.props; + + if (inline && containerWidth) { + width = containerWidth; + height = containerWidth / (16/9); + + playerStyle.width = width; + playerStyle.height = height; + } return ( - <div className={classNames('video-player', { inactive: !revealed, detailed, inline: width && height && !fullscreen, fullscreen, letterbox, 'full-width': fullwidth })} style={{ width, height }} ref={this.setPlayerRef} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}> + <div className={classNames('video-player', { inactive: !revealed, detailed, inline: width && height && !fullscreen, fullscreen, letterbox, 'full-width': fullwidth })} style={playerStyle} ref={this.setPlayerRef} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}> <video ref={this.setVideoRef} src={src} @@ -271,7 +291,7 @@ export default class Video extends React.PureComponent { onProgress={this.handleProgress} /> - <button className={classNames('video-player__spoiler', { active: !revealed })} onClick={this.toggleReveal}> + <button type='button' className={classNames('video-player__spoiler', { active: !revealed })} onClick={this.toggleReveal}> <span className='video-player__spoiler__title'><FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' /></span> <span className='video-player__spoiler__subtitle'><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span> </button> @@ -290,10 +310,10 @@ export default class Video extends React.PureComponent { <div className='video-player__buttons-bar'> <div className='video-player__buttons left'> - <button aria-label={intl.formatMessage(paused ? messages.play : messages.pause)} onClick={this.togglePlay}><i className={classNames('fa fa-fw', { 'fa-play': paused, 'fa-pause': !paused })} /></button> - <button aria-label={intl.formatMessage(muted ? messages.unmute : messages.mute)} onClick={this.toggleMute}><i className={classNames('fa fa-fw', { 'fa-volume-off': muted, 'fa-volume-up': !muted })} /></button> + <button type='button' aria-label={intl.formatMessage(paused ? messages.play : messages.pause)} onClick={this.togglePlay}><i className={classNames('fa fa-fw', { 'fa-play': paused, 'fa-pause': !paused })} /></button> + <button type='button' aria-label={intl.formatMessage(muted ? messages.unmute : messages.mute)} onClick={this.toggleMute}><i className={classNames('fa fa-fw', { 'fa-volume-off': muted, 'fa-volume-up': !muted })} /></button> - {!onCloseVideo && <button aria-label={intl.formatMessage(messages.hide)} onClick={this.toggleReveal}><i className='fa fa-fw fa-eye' /></button>} + {!onCloseVideo && <button type='button' aria-label={intl.formatMessage(messages.hide)} onClick={this.toggleReveal}><i className='fa fa-fw fa-eye' /></button>} {(detailed || fullscreen) && <span> @@ -305,9 +325,9 @@ export default class Video extends React.PureComponent { </div> <div className='video-player__buttons right'> - {(!fullscreen && onOpenVideo) && <button aria-label={intl.formatMessage(messages.expand)} onClick={this.handleOpenVideo}><i className='fa fa-fw fa-expand' /></button>} - {onCloseVideo && <button aria-label={intl.formatMessage(messages.close)} onClick={this.handleCloseVideo}><i className='fa fa-fw fa-compress' /></button>} - <button aria-label={intl.formatMessage(fullscreen ? messages.exit_fullscreen : messages.fullscreen)} onClick={this.toggleFullscreen}><i className={classNames('fa fa-fw', { 'fa-arrows-alt': !fullscreen, 'fa-compress': fullscreen })} /></button> + {(!fullscreen && onOpenVideo) && <button type='button' aria-label={intl.formatMessage(messages.expand)} onClick={this.handleOpenVideo}><i className='fa fa-fw fa-expand' /></button>} + {onCloseVideo && <button type='button' aria-label={intl.formatMessage(messages.close)} onClick={this.handleCloseVideo}><i className='fa fa-fw fa-compress' /></button>} + <button type='button' aria-label={intl.formatMessage(fullscreen ? messages.exit_fullscreen : messages.fullscreen)} onClick={this.toggleFullscreen}><i className={classNames('fa fa-fw', { 'fa-arrows-alt': !fullscreen, 'fa-compress': fullscreen })} /></button> </div> </div> </div> diff --git a/app/javascript/flavours/glitch/locales/ja.js b/app/javascript/flavours/glitch/locales/ja.js index 38f11d3f7..f558d7ab7 100644 --- a/app/javascript/flavours/glitch/locales/ja.js +++ b/app/javascript/flavours/glitch/locales/ja.js @@ -62,6 +62,10 @@ const messages = { 'advanced_options.threaded_mode.short': 'スレッドモード', 'advanced_options.threaded_mode.long': '投稿時に自動的に返信するように設定します', 'advanced_options.threaded_mode.tooltip': 'スレッドモードを有効にする', + + 'navigation_bar.direct': 'ダイレクトメッセージ', + 'navigation_bar.bookmarks': 'ブックマーク', + 'column.bookmarks': 'ブックマーク' }; export default Object.assign({}, inherited, messages); diff --git a/app/javascript/flavours/glitch/styles/admin.scss b/app/javascript/flavours/glitch/styles/admin.scss index f9245e134..3146a343d 100644 --- a/app/javascript/flavours/glitch/styles/admin.scss +++ b/app/javascript/flavours/glitch/styles/admin.scss @@ -135,6 +135,11 @@ border: 0; background: transparent; border-bottom: 1px solid $ui-base-color; + + &.section-break { + margin: 30px 0; + border-bottom: 2px solid $ui-base-lighter-color; + } } .muted-hint { @@ -336,6 +341,36 @@ } } +.report-note__comment { + margin-bottom: 20px; +} + +.report-note__form { + margin-bottom: 20px; + + .report-note__textarea { + box-sizing: border-box; + border: 0; + padding: 7px 4px; + margin-bottom: 10px; + font-size: 16px; + color: $ui-base-color; + display: block; + width: 100%; + outline: 0; + font-family: inherit; + resize: vertical; + } + + .report-note__buttons { + text-align: right; + } + + .report-note__button { + margin: 0 0 5px 5px; + } +} + .batch-form-box { display: flex; flex-wrap: wrap; diff --git a/app/javascript/flavours/glitch/styles/components/index.scss b/app/javascript/flavours/glitch/styles/components/index.scss index aa33c9333..afb54056c 100644 --- a/app/javascript/flavours/glitch/styles/components/index.scss +++ b/app/javascript/flavours/glitch/styles/components/index.scss @@ -353,35 +353,42 @@ .image-loader { position: relative; + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; - &.image-loader--loading { - .image-loader__preview-canvas { - filter: blur(2px); - } + .image-loader__preview-canvas { + max-width: $media-modal-media-max-width; + max-height: $media-modal-media-max-height; + background: url('~images/void.png') repeat; + object-fit: contain; } - .image-loader__img { - position: absolute; - top: 0; - left: 0; - right: 0; - max-width: 100%; - max-height: 100%; - background-image: none; + &.image-loader--loading .image-loader__preview-canvas { + filter: blur(2px); } - &.image-loader--amorphous { - position: static; + &.image-loader--amorphous .image-loader__preview-canvas { + display: none; + } +} - .image-loader__preview-canvas { - display: none; - } +.zoomable-image { + position: relative; + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; - .image-loader__img { - position: static; - width: auto; - height: auto; - } + img { + max-width: $media-modal-media-max-width; + max-height: $media-modal-media-max-height; + width: auto; + height: auto; + object-fit: contain; } } diff --git a/app/javascript/flavours/glitch/styles/components/media.scss b/app/javascript/flavours/glitch/styles/components/media.scss index d7407cdaf..e62f64176 100644 --- a/app/javascript/flavours/glitch/styles/components/media.scss +++ b/app/javascript/flavours/glitch/styles/components/media.scss @@ -157,43 +157,85 @@ position: absolute; } -.media-modal { - max-width: 80vw; - max-height: 80vh; +.video-modal { + max-width: 100vw; + max-height: 100vh; position: relative; +} - .extended-video-player, - img, - canvas, - video { - max-width: 80vw; - max-height: 80vh; - width: auto; - height: auto; - margin: auto; - } +.media-modal { + width: 100%; + height: 100%; + position: relative; - .extended-video-player, - video { + .extended-video-player { + width: 100%; + height: 100%; display: flex; - width: 80vw; - height: 80vh; + align-items: center; + justify-content: center; + + video { + max-width: $media-modal-media-max-width; + max-height: $media-modal-media-max-height; + } } +} - img, - canvas { - display: block; - background: url('~images/void.png') repeat; - object-fit: contain; +.media-modal__closer { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; +} + +.media-modal__navigation { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + pointer-events: none; + transition: opacity 0.3s linear; + will-change: opacity; + + * { + pointer-events: auto; } - .react-swipeable-view-container { - max-width: 80vw; + &.media-modal__navigation--hidden { + opacity: 0; + + * { + pointer-events: none; + } } } -.media-modal__content { - background: $base-overlay-background; +.media-modal__nav { + background: rgba($base-overlay-background, 0.5); + box-sizing: border-box; + border: 0; + color: $primary-text-color; + cursor: pointer; + display: flex; + align-items: center; + font-size: 24px; + height: 20vmax; + margin: auto 0; + padding: 30px 15px; + position: absolute; + top: 0; + bottom: 0; +} + +.media-modal__nav--left { + left: 0; +} + +.media-modal__nav--right { + right: 0; } .media-modal__pagination { @@ -201,7 +243,8 @@ text-align: center; position: absolute; left: 0; - bottom: -40px; + bottom: 20px; + pointer-events: none; } .media-modal__page-dot { @@ -225,8 +268,8 @@ .media-modal__close { position: absolute; - right: 4px; - top: 4px; + right: 8px; + top: 8px; z-index: 100; } @@ -244,8 +287,8 @@ @include fullwidth-gallery; video { - height: 100%; - width: 100%; + max-width: 100vw; + max-height: 80vh; z-index: 1; object-fit: cover; position: relative; @@ -264,7 +307,7 @@ &.inline { video { - object-fit: cover; + object-fit: contain; position: relative; top: 50%; transform: translateY(-50%); diff --git a/app/javascript/flavours/glitch/styles/components/modal.scss b/app/javascript/flavours/glitch/styles/components/modal.scss index d424b1eda..4f0d6e1bc 100644 --- a/app/javascript/flavours/glitch/styles/components/modal.scss +++ b/app/javascript/flavours/glitch/styles/components/modal.scss @@ -2,29 +2,6 @@ background: lighten($ui-base-color, 8%); } -.modal-container__nav { - align-items: center; - background: rgba($base-overlay-background, 0.5); - box-sizing: border-box; - border: 0; - color: $primary-text-color; - cursor: pointer; - display: flex; - font-size: 24px; - height: 100%; - padding: 30px 15px; - position: absolute; - top: 0; -} - -.modal-container__nav--left { - left: -61px; -} - -.modal-container__nav--right { - right: -61px; -} - .modal-root { transition: opacity 0.3s linear; will-change: opacity; diff --git a/app/javascript/flavours/glitch/styles/variables.scss b/app/javascript/flavours/glitch/styles/variables.scss index e8e2bc9e3..e3ba725c4 100644 --- a/app/javascript/flavours/glitch/styles/variables.scss +++ b/app/javascript/flavours/glitch/styles/variables.scss @@ -31,6 +31,11 @@ $ui-highlight-color: $classic-highlight-color !default; // Vibrant // Language codes that uses CJK fonts $cjk-langs: ja, ko, zh-CN, zh-HK, zh-TW; +// Variables for components +$media-modal-media-max-width: 100%; +// put margins on top and bottom of image to avoid the screen covered by image. +$media-modal-media-max-height: 80%; + // Avatar border size (8% default, 100% for rounded avatars) $ui-avatar-border-size: 8%; diff --git a/app/javascript/flavours/glitch/util/initial_state.js b/app/javascript/flavours/glitch/util/initial_state.js index ab502f9d4..2c4ab9091 100644 --- a/app/javascript/flavours/glitch/util/initial_state.js +++ b/app/javascript/flavours/glitch/util/initial_state.js @@ -13,6 +13,7 @@ const getMeta = (prop) => initialState && initialState.meta && initialState.meta export const reduceMotion = getMeta('reduce_motion'); export const autoPlayGif = getMeta('auto_play_gif'); +export const displaySensitiveMedia = getMeta('display_sensitive_media'); export const unfollowModal = getMeta('unfollow_modal'); export const boostModal = getMeta('boost_modal'); export const favouriteModal = getMeta('favourite_modal'); diff --git a/app/javascript/mastodon/features/compose/components/privacy_dropdown.js b/app/javascript/mastodon/features/compose/components/privacy_dropdown.js index e5de13178..6b22ba84a 100644 --- a/app/javascript/mastodon/features/compose/components/privacy_dropdown.js +++ b/app/javascript/mastodon/features/compose/components/privacy_dropdown.js @@ -32,6 +32,10 @@ class PrivacyDropdownMenu extends React.PureComponent { onChange: PropTypes.func.isRequired, }; + state = { + mounted: false, + }; + handleDocumentClick = e => { if (this.node && !this.node.contains(e.target)) { this.props.onClose(); @@ -54,6 +58,7 @@ class PrivacyDropdownMenu extends React.PureComponent { componentDidMount () { document.addEventListener('click', this.handleDocumentClick, false); document.addEventListener('touchend', this.handleDocumentClick, listenerOptions); + this.setState({ mounted: true }); } componentWillUnmount () { @@ -66,12 +71,16 @@ class PrivacyDropdownMenu extends React.PureComponent { } render () { + const { mounted } = this.state; const { style, items, value } = this.props; return ( <Motion defaultStyle={{ opacity: 0, scaleX: 0.85, scaleY: 0.75 }} style={{ opacity: spring(1, { damping: 35, stiffness: 400 }), scaleX: spring(1, { damping: 35, stiffness: 400 }), scaleY: spring(1, { damping: 35, stiffness: 400 }) }}> {({ opacity, scaleX, scaleY }) => ( - <div className='privacy-dropdown__dropdown' style={{ ...style, opacity: opacity, transform: `scale(${scaleX}, ${scaleY})` }} ref={this.setRef}> + // It should not be transformed when mounting because the resulting + // size will be used to determine the coordinate of the menu by + // react-overlays + <div className='privacy-dropdown__dropdown' style={{ ...style, opacity: opacity, transform: mounted ? `scale(${scaleX}, ${scaleY})` : null }} ref={this.setRef}> {items.map(item => ( <div role='button' tabIndex='0' key={item.value} data-index={item.value} onKeyDown={this.handleClick} onClick={this.handleClick} className={classNames('privacy-dropdown__option', { active: item.value === value })}> <div className='privacy-dropdown__option__icon'> @@ -107,9 +116,10 @@ export default class PrivacyDropdown extends React.PureComponent { state = { open: false, + placement: null, }; - handleToggle = () => { + handleToggle = ({ target }) => { if (this.props.isUserTouching()) { if (this.state.open) { this.props.onModalClose(); @@ -120,6 +130,8 @@ export default class PrivacyDropdown extends React.PureComponent { }); } } else { + const { top } = target.getBoundingClientRect(); + this.setState({ placement: top * 2 < innerHeight ? 'bottom' : 'top' }); this.setState({ open: !this.state.open }); } } @@ -136,7 +148,7 @@ export default class PrivacyDropdown extends React.PureComponent { handleKeyDown = e => { switch(e.key) { case 'Enter': - this.handleToggle(); + this.handleToggle(e); break; case 'Escape': this.handleClose(); @@ -165,7 +177,7 @@ export default class PrivacyDropdown extends React.PureComponent { render () { const { value, intl } = this.props; - const { open } = this.state; + const { open, placement } = this.state; const valueOption = this.options.find(item => item.value === value); @@ -185,7 +197,7 @@ export default class PrivacyDropdown extends React.PureComponent { /> </div> - <Overlay show={open} placement='bottom' target={this}> + <Overlay show={open} placement={placement} target={this}> <PrivacyDropdownMenu items={this.options} value={value} diff --git a/app/javascript/mastodon/locales/ja.json b/app/javascript/mastodon/locales/ja.json index dbea02989..0efd367e9 100644 --- a/app/javascript/mastodon/locales/ja.json +++ b/app/javascript/mastodon/locales/ja.json @@ -159,7 +159,6 @@ "mute_modal.hide_notifications": "このユーザーからの通知を隠しますか?", "navigation_bar.blocks": "ブロックしたユーザー", "navigation_bar.community_timeline": "ローカルタイムライン", - "navigation_bar.direct": "ダイレクトメッセージ", "navigation_bar.domain_blocks": "非表示にしたドメイン", "navigation_bar.edit_profile": "プロフィールを編集", "navigation_bar.favourites": "お気に入り", diff --git a/app/javascript/styles/mastodon/stream_entries.scss b/app/javascript/styles/mastodon/stream_entries.scss index 442b143a0..dfdc48d06 100644 --- a/app/javascript/styles/mastodon/stream_entries.scss +++ b/app/javascript/styles/mastodon/stream_entries.scss @@ -6,7 +6,8 @@ background: $simple-background-color; .detailed-status.light, - .status.light { + .status.light, + .more.light { border-bottom: 1px solid $ui-secondary-color; animation: none; } @@ -290,6 +291,17 @@ text-decoration: underline; } } + + .more { + color: $classic-primary-color; + display: block; + padding: 14px; + text-align: center; + + &:not(:hover) { + text-decoration: none; + } + } } .embed { diff --git a/app/models/account.rb b/app/models/account.rb index 1be2f2da6..dd8bad585 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -247,11 +247,11 @@ class Account < ApplicationRecord end def domains - reorder(nil).pluck('distinct accounts.domain') + reorder(nil).pluck(Arel.sql('distinct accounts.domain')) end def inboxes - urls = reorder(nil).where(protocol: :activitypub).pluck("distinct coalesce(nullif(accounts.shared_inbox_url, ''), accounts.inbox_url)") + urls = reorder(nil).where(protocol: :activitypub).pluck(Arel.sql("distinct coalesce(nullif(accounts.shared_inbox_url, ''), accounts.inbox_url)")) DeliveryFailureTracker.filter(urls) end diff --git a/app/models/concerns/status_threading_concern.rb b/app/models/concerns/status_threading_concern.rb index b539ba10e..fffc095ee 100644 --- a/app/models/concerns/status_threading_concern.rb +++ b/app/models/concerns/status_threading_concern.rb @@ -3,8 +3,8 @@ module StatusThreadingConcern extend ActiveSupport::Concern - def ancestors(account = nil) - find_statuses_from_tree_path(ancestor_ids, account) + def ancestors(limit, account = nil) + find_statuses_from_tree_path(ancestor_ids(limit), account) end def descendants(account = nil) @@ -13,14 +13,21 @@ module StatusThreadingConcern private - def ancestor_ids - Rails.cache.fetch("ancestors:#{id}") do - ancestor_statuses.pluck(:id) + def ancestor_ids(limit) + key = "ancestors:#{id}" + ancestors = Rails.cache.fetch(key) + + if ancestors.nil? || ancestors[:limit] < limit + ids = ancestor_statuses(limit).pluck(:id).reverse! + Rails.cache.write key, limit: limit, ids: ids + ids + else + ancestors[:ids].last(limit) end end - def ancestor_statuses - Status.find_by_sql([<<-SQL.squish, id: in_reply_to_id]) + def ancestor_statuses(limit) + Status.find_by_sql([<<-SQL.squish, id: in_reply_to_id, limit: limit]) WITH RECURSIVE search_tree(id, in_reply_to_id, path) AS ( SELECT id, in_reply_to_id, ARRAY[id] @@ -34,7 +41,8 @@ module StatusThreadingConcern ) SELECT id FROM search_tree - ORDER BY path DESC + ORDER BY path + LIMIT :limit SQL end diff --git a/app/models/notification.rb b/app/models/notification.rb index be9964087..0b0f01aa8 100644 --- a/app/models/notification.rb +++ b/app/models/notification.rb @@ -81,8 +81,6 @@ class Notification < ApplicationRecord end end - private - def activity_types_from_types(types) types.map { |type| TYPE_CLASS_MAP[type.to_sym] }.compact end diff --git a/app/models/status.rb b/app/models/status.rb index 34b41d347..5d309546f 100644 --- a/app/models/status.rb +++ b/app/models/status.rb @@ -355,7 +355,7 @@ class Status < ApplicationRecord self.in_reply_to_account_id = carried_over_reply_to_account_id self.conversation_id = thread.conversation_id if conversation_id.nil? elsif conversation_id.nil? - create_conversation + self.conversation = Conversation.new end end diff --git a/app/views/stream_entries/_status.html.haml b/app/views/stream_entries/_status.html.haml index e2e1fdd12..2d0dafcb7 100644 --- a/app/views/stream_entries/_status.html.haml +++ b/app/views/stream_entries/_status.html.haml @@ -14,6 +14,10 @@ entry_classes = h_class + ' ' + mf_classes + ' ' + style_classes - if status.reply? && include_threads + - if @next_ancestor + .entry{ class: entry_classes } + = link_to short_account_status_url(@next_ancestor.account.username, @next_ancestor), class: 'more light' do + = t('statuses.show_more') = render partial: 'stream_entries/status', collection: @ancestors, as: :status, locals: { is_predecessor: true, direct_reply_id: status.in_reply_to_id } .entry{ class: entry_classes } diff --git a/bin/bundle b/bin/bundle index 66e9889e8..f19acf5b5 100755 --- a/bin/bundle +++ b/bin/bundle @@ -1,3 +1,3 @@ #!/usr/bin/env ruby -ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../../Gemfile', __FILE__) +ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../Gemfile', __dir__) load Gem.bin_path('bundler', 'bundle') diff --git a/bin/setup b/bin/setup index 72b62a028..fc77b0809 100755 --- a/bin/setup +++ b/bin/setup @@ -1,10 +1,9 @@ #!/usr/bin/env ruby -require 'pathname' require 'fileutils' include FileUtils # path to your application root. -APP_ROOT = Pathname.new File.expand_path('../../', __FILE__) +APP_ROOT = File.expand_path('..', __dir__) def system!(*args) system(*args) || abort("\n== Command #{args} failed ==") diff --git a/bin/update b/bin/update index a8e4462f2..6d73559a3 100755 --- a/bin/update +++ b/bin/update @@ -1,10 +1,9 @@ #!/usr/bin/env ruby -require 'pathname' require 'fileutils' include FileUtils # path to your application root. -APP_ROOT = Pathname.new File.expand_path('../../', __FILE__) +APP_ROOT = File.expand_path('..', __dir__) def system!(*args) system(*args) || abort("\n== Command #{args} failed ==") @@ -18,6 +17,9 @@ chdir APP_ROOT do system! 'gem install bundler --conservative' system('bundle check') || system!('bundle install') + # Install JavaScript dependencies if using Yarn + system('bin/yarn') + puts "\n== Updating database ==" system! 'bin/rails db:migrate' diff --git a/bin/webpack b/bin/webpack index 9d3800c74..0869ad277 100755 --- a/bin/webpack +++ b/bin/webpack @@ -1,11 +1,7 @@ #!/usr/bin/env ruby -# frozen_string_literal: true -# -# This file was generated by Bundler. -# -# The application 'webpack' is installed as part of a gem, and -# this file is here to facilitate running it. -# + +ENV["RAILS_ENV"] ||= ENV["RACK_ENV"] || "development" +ENV["NODE_ENV"] ||= ENV["NODE_ENV"] || "development" require "pathname" ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile", @@ -14,4 +10,6 @@ ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile", require "rubygems" require "bundler/setup" -load Gem.bin_path("webpacker", "webpack") +require "webpacker" +require "webpacker/webpack_runner" +Webpacker::WebpackRunner.run(ARGV) diff --git a/bin/webpack-dev-server b/bin/webpack-dev-server index cf701102a..251f65e8e 100755 --- a/bin/webpack-dev-server +++ b/bin/webpack-dev-server @@ -1,11 +1,7 @@ #!/usr/bin/env ruby -# frozen_string_literal: true -# -# This file was generated by Bundler. -# -# The application 'webpack-dev-server' is installed as part of a gem, and -# this file is here to facilitate running it. -# + +ENV["RAILS_ENV"] ||= ENV["RACK_ENV"] || "development" +ENV["NODE_ENV"] ||= ENV["NODE_ENV"] || "development" require "pathname" ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile", @@ -14,4 +10,6 @@ ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile", require "rubygems" require "bundler/setup" -load Gem.bin_path("webpacker", "webpack-dev-server") +require "webpacker" +require "webpacker/dev_server_runner" +Webpacker::DevServerRunner.run(ARGV) diff --git a/bin/yarn b/bin/yarn new file mode 100755 index 000000000..8c1535a78 --- /dev/null +++ b/bin/yarn @@ -0,0 +1,11 @@ +#!/usr/bin/env ruby +APP_ROOT = File.expand_path('..', __dir__) +Dir.chdir(APP_ROOT) do + begin + exec "yarnpkg #{ARGV.join(' ')}" unless Dir.exist?('node_modules') + rescue Errno::ENOENT + $stderr.puts "Yarn executable was not detected in the system." + $stderr.puts "Download Yarn at https://yarnpkg.com/en/docs/install" + exit 1 + end +end diff --git a/config/application.rb b/config/application.rb index c0899ad70..fdb534343 100644 --- a/config/application.rb +++ b/config/application.rb @@ -24,7 +24,7 @@ require_relative '../lib/mastodon/redis_config' module Mastodon class Application < Rails::Application # Initialize configuration defaults for originally generated Rails version. - config.load_defaults 5.1 + config.load_defaults 5.2 # Settings in config/environments/* take precedence over those specified here. # Application configuration should go into files in config/initializers @@ -86,20 +86,6 @@ module Mastodon config.active_job.queue_adapter = :sidekiq - #config.middleware.insert_before 0, Rack::Cors, debug: true, logger: (-> { Rails.logger }) do - config.middleware.insert_before 0, Rack::Cors do - allow do - origins '*' - resource '/@:username', headers: :any, methods: [:get], credentials: false - resource '/api/*', headers: :any, methods: [:post, :put, :delete, :get, :patch, :options], credentials: false, expose: ['Link', 'X-RateLimit-Reset', 'X-RateLimit-Limit', 'X-RateLimit-Remaining', 'X-Request-Id'] - resource '/oauth/token', headers: :any, methods: [:post], credentials: false - resource '/assets/*', headers: :any, methods: [:get, :head, :options] - resource '/stylesheets/*', headers: :any, methods: [:get, :head, :options] - resource '/javascripts/*', headers: :any, methods: [:get, :head, :options] - resource '/packs/*', headers: :any, methods: [:get, :head, :options] - end - end - config.middleware.use Rack::Attack config.middleware.use Rack::Deflater diff --git a/config/boot.rb b/config/boot.rb index 703738b76..0a3cd4ebe 100644 --- a/config/boot.rb +++ b/config/boot.rb @@ -1,7 +1,7 @@ ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../Gemfile', __dir__) require 'bundler/setup' # Set up gems listed in the Gemfile. -require 'bootsnap' +require 'bootsnap' # Speed up boot time by caching expensive operations. Bootsnap.setup( cache_dir: 'tmp/cache', diff --git a/config/deploy.rb b/config/deploy.rb index 3fd149f21..180dd1c2a 100644 --- a/config/deploy.rb +++ b/config/deploy.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -lock '3.10.0' +lock '3.10.1' set :repo_url, ENV.fetch('REPO', 'https://github.com/tootsuite/mastodon.git') set :branch, ENV.fetch('BRANCH', 'master') diff --git a/config/environments/development.rb b/config/environments/development.rb index 285fea8b8..b6478f16e 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -13,13 +13,14 @@ Rails.application.configure do config.consider_all_requests_local = true # Enable/disable caching. By default caching is disabled. + # Run rails dev:cache to toggle caching. if Rails.root.join('tmp/caching-dev.txt').exist? config.action_controller.perform_caching = true config.cache_store = :redis_store, ENV['REDIS_URL'], REDIS_CACHE_PARAMS config.public_file_server.headers = { - 'Cache-Control' => "public, max-age=#{2.days.seconds.to_i}", + 'Cache-Control' => "public, max-age=#{2.days.to_i}", } else config.action_controller.perform_caching = false diff --git a/config/environments/production.rb b/config/environments/production.rb index 7a800db19..2c8471ddd 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -15,6 +15,10 @@ Rails.application.configure do config.action_controller.perform_caching = true config.action_controller.asset_host = ENV['CDN_HOST'] if ENV.key?('CDN_HOST') + # Ensures that a master key has been made available in either ENV["RAILS_MASTER_KEY"] + # or in config/master.key. This key is used to decrypt credentials (and other encrypted files). + # config.require_master_key = true + # Disable serving static files from the `/public` folder by default since # Apache or NGINX already handles this. config.public_file_server.enabled = ENV['RAILS_SERVE_STATIC_FILES'].present? diff --git a/config/environments/test.rb b/config/environments/test.rb index 7d77a170e..1c1891561 100644 --- a/config/environments/test.rb +++ b/config/environments/test.rb @@ -15,7 +15,7 @@ Rails.application.configure do # Configure public file server for tests with Cache-Control for performance. config.public_file_server.enabled = true config.public_file_server.headers = { - 'Cache-Control' => "public, max-age=#{1.hour.seconds.to_i}" + 'Cache-Control' => "public, max-age=#{1.hour.to_i}" } config.assets.digest = false @@ -59,3 +59,14 @@ Rails.application.configure do end Paperclip::Attachment.default_options[:path] = "#{Rails.root}/spec/test_files/:class/:id_partition/:style.:extension" + +# set fake_data for pam, don't do real calls, just use fake data +if ENV['PAM_ENABLED'] == 'true' + Rpam2.fake_data = + { + usernames: Set['pam_user1', 'pam_user2'], + servicenames: Set['pam_test', 'pam_test_controlled'], + password: '123456', + env: { email: 'pam@example.com' } + } +end diff --git a/config/initializers/content_security_policy.rb b/config/initializers/content_security_policy.rb new file mode 100644 index 000000000..37f2c0d45 --- /dev/null +++ b/config/initializers/content_security_policy.rb @@ -0,0 +1,20 @@ +# Define an application-wide content security policy +# For further information see the following documentation +# https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy + +# Rails.application.config.content_security_policy do |p| +# p.default_src :self, :https +# p.font_src :self, :https, :data +# p.img_src :self, :https, :data +# p.object_src :none +# p.script_src :self, :https +# p.style_src :self, :https, :unsafe_inline +# +# # Specify URI for violation reports +# # p.report_uri "/csp-violation-report-endpoint" +# end + +# Report CSP violations to a specified URI +# For further information see the following documentation: +# https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy-Report-Only +# Rails.application.config.content_security_policy_report_only = true diff --git a/config/initializers/cors.rb b/config/initializers/cors.rb new file mode 100644 index 000000000..36e2694e3 --- /dev/null +++ b/config/initializers/cors.rb @@ -0,0 +1,30 @@ +# Be sure to restart your server when you modify this file. + +# Avoid CORS issues when API is called from the frontend app. +# Handle Cross-Origin Resource Sharing (CORS) in order to accept cross-origin AJAX requests. + +# Read more: https://github.com/cyu/rack-cors + +Rails.application.config.middleware.insert_before 0, Rack::Cors do + allow do + origins '*' + + resource '/@:username', + headers: :any, + methods: [:get], + credentials: false + resource '/api/*', + headers: :any, + methods: [:post, :put, :delete, :get, :patch, :options], + credentials: false, + expose: ['Link', 'X-RateLimit-Reset', 'X-RateLimit-Limit', 'X-RateLimit-Remaining', 'X-Request-Id'] + resource '/oauth/token', + headers: :any, + methods: [:post], + credentials: false + resource '/assets/*', headers: :any, methods: [:get, :head, :options] + resource '/stylesheets/*', headers: :any, methods: [:get, :head, :options] + resource '/javascripts/*', headers: :any, methods: [:get, :head, :options] + resource '/packs/*', headers: :any, methods: [:get, :head, :options] + end +end diff --git a/db/schema.rb b/db/schema.rb index 2cf6db773..7796600d7 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -13,6 +13,7 @@ ActiveRecord::Schema.define(version: 20180410220657) do # These are extensions that must be enabled in order to support this database + enable_extension "pg_stat_statements" enable_extension "plpgsql" create_table "account_domain_blocks", force: :cascade do |t| diff --git a/spec/controllers/auth/sessions_controller_spec.rb b/spec/controllers/auth/sessions_controller_spec.rb index 88f0a4734..d5fed17d6 100644 --- a/spec/controllers/auth/sessions_controller_spec.rb +++ b/spec/controllers/auth/sessions_controller_spec.rb @@ -48,6 +48,57 @@ RSpec.describe Auth::SessionsController, type: :controller do request.env['devise.mapping'] = Devise.mappings[:user] end + context 'using PAM authentication' do + context 'using a valid password' do + before do + post :create, params: { user: { email: "pam_user1", password: '123456' } } + end + + it 'redirects to home' do + expect(response).to redirect_to(root_path) + end + + it 'logs the user in' do + expect(controller.current_user).to be_instance_of(User) + end + end + + context 'using an invalid password' do + before do + post :create, params: { user: { email: "pam_user1", password: 'WRONGPW' } } + end + + it 'shows a login error' do + expect(flash[:alert]).to match I18n.t('devise.failure.invalid', authentication_keys: 'Email') + end + + it "doesn't log the user in" do + expect(controller.current_user).to be_nil + end + end + + context 'using a valid email and existing user' do + let(:user) do + account = Fabricate.build(:account, username: 'pam_user1') + account.save!(validate: false) + user = Fabricate(:user, email: 'pam@example.com', password: nil, account: account) + user + end + + before do + post :create, params: { user: { email: user.email, password: '123456' } } + end + + it 'redirects to home' do + expect(response).to redirect_to(root_path) + end + + it 'logs the user in' do + expect(controller.current_user).to eq user + end + end + end + context 'using password authentication' do let(:user) { Fabricate(:user, email: 'foo@bar.com', password: 'abcdefgh') } diff --git a/spec/models/concerns/status_threading_concern_spec.rb b/spec/models/concerns/status_threading_concern_spec.rb index 62f5f6e31..b8ebdd58c 100644 --- a/spec/models/concerns/status_threading_concern_spec.rb +++ b/spec/models/concerns/status_threading_concern_spec.rb @@ -14,34 +14,34 @@ describe StatusThreadingConcern do let!(:viewer) { Fabricate(:account, username: 'viewer') } it 'returns conversation history' do - expect(reply3.ancestors).to include(status, reply1, reply2) + expect(reply3.ancestors(4)).to include(status, reply1, reply2) end it 'does not return conversation history user is not allowed to see' do reply1.update(visibility: :private) status.update(visibility: :direct) - expect(reply3.ancestors(viewer)).to_not include(reply1, status) + expect(reply3.ancestors(4, viewer)).to_not include(reply1, status) end it 'does not return conversation history from blocked users' do viewer.block!(jeff) - expect(reply3.ancestors(viewer)).to_not include(reply1) + expect(reply3.ancestors(4, viewer)).to_not include(reply1) end it 'does not return conversation history from muted users' do viewer.mute!(jeff) - expect(reply3.ancestors(viewer)).to_not include(reply1) + expect(reply3.ancestors(4, viewer)).to_not include(reply1) end it 'does not return conversation history from silenced and not followed users' do jeff.update(silenced: true) - expect(reply3.ancestors(viewer)).to_not include(reply1) + expect(reply3.ancestors(4, viewer)).to_not include(reply1) end it 'does not return conversation history from blocked domains' do viewer.block_domain!('example.com') - expect(reply3.ancestors(viewer)).to_not include(reply2) + expect(reply3.ancestors(4, viewer)).to_not include(reply2) end it 'ignores deleted records' do @@ -49,10 +49,32 @@ describe StatusThreadingConcern do second_status = Fabricate(:status, thread: first_status, account: alice) # Create cache and delete cached record - second_status.ancestors + second_status.ancestors(4) first_status.destroy - expect(second_status.ancestors).to eq([]) + expect(second_status.ancestors(4)).to eq([]) + end + + it 'can return more records than previously requested' do + first_status = Fabricate(:status, account: bob) + second_status = Fabricate(:status, thread: first_status, account: alice) + third_status = Fabricate(:status, thread: second_status, account: alice) + + # Create cache + second_status.ancestors(1) + + expect(third_status.ancestors(2)).to eq([first_status, second_status]) + end + + it 'can return fewer records than previously requested' do + first_status = Fabricate(:status, account: bob) + second_status = Fabricate(:status, thread: first_status, account: alice) + third_status = Fabricate(:status, thread: second_status, account: alice) + + # Create cache + second_status.ancestors(2) + + expect(third_status.ancestors(1)).to eq([second_status]) end end diff --git a/spec/views/stream_entries/show.html.haml_spec.rb b/spec/views/stream_entries/show.html.haml_spec.rb index 59ea40990..6074bbc2e 100644 --- a/spec/views/stream_entries/show.html.haml_spec.rb +++ b/spec/views/stream_entries/show.html.haml_spec.rb @@ -48,7 +48,7 @@ describe 'stream_entries/show.html.haml', without_verify_partial_doubles: true d assign(:stream_entry, reply.stream_entry) assign(:account, alice) assign(:type, reply.stream_entry.activity_type.downcase) - assign(:ancestors, reply.stream_entry.activity.ancestors(bob) ) + assign(:ancestors, reply.stream_entry.activity.ancestors(1, bob) ) assign(:descendants, reply.stream_entry.activity.descendants(bob)) render |