about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--.env.test4
-rw-r--r--.travis.yml5
-rw-r--r--Gemfile62
-rw-r--r--Gemfile.lock382
-rw-r--r--app/controllers/api/v1/statuses_controller.rb2
-rw-r--r--app/controllers/api/web/push_subscriptions_controller.rb25
-rw-r--r--app/controllers/settings/follower_domains_controller.rb2
-rw-r--r--app/controllers/statuses_controller.rb7
-rw-r--r--app/javascript/flavours/glitch/components/extended_video_player.js8
-rw-r--r--app/javascript/flavours/glitch/components/media_gallery.js5
-rw-r--r--app/javascript/flavours/glitch/components/status.js1
-rw-r--r--app/javascript/flavours/glitch/features/composer/options/dropdown/content/index.js12
-rw-r--r--app/javascript/flavours/glitch/features/composer/options/dropdown/index.js11
-rw-r--r--app/javascript/flavours/glitch/features/status/components/card.js36
-rw-r--r--app/javascript/flavours/glitch/features/status/components/detailed_status.js3
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/image_loader.js36
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/media_modal.js81
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/video_modal.js2
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/zoomable_image.js151
-rw-r--r--app/javascript/flavours/glitch/features/video/index.js42
-rw-r--r--app/javascript/flavours/glitch/locales/ja.js4
-rw-r--r--app/javascript/flavours/glitch/styles/admin.scss35
-rw-r--r--app/javascript/flavours/glitch/styles/components/index.scss51
-rw-r--r--app/javascript/flavours/glitch/styles/components/media.scss107
-rw-r--r--app/javascript/flavours/glitch/styles/components/modal.scss23
-rw-r--r--app/javascript/flavours/glitch/styles/variables.scss5
-rw-r--r--app/javascript/flavours/glitch/util/initial_state.js1
-rw-r--r--app/javascript/mastodon/features/compose/components/privacy_dropdown.js22
-rw-r--r--app/javascript/mastodon/locales/ja.json1
-rw-r--r--app/javascript/styles/mastodon/stream_entries.scss14
-rw-r--r--app/models/account.rb4
-rw-r--r--app/models/concerns/status_threading_concern.rb24
-rw-r--r--app/models/notification.rb2
-rw-r--r--app/models/status.rb2
-rw-r--r--app/views/stream_entries/_status.html.haml4
-rwxr-xr-xbin/bundle2
-rwxr-xr-xbin/setup3
-rwxr-xr-xbin/update6
-rwxr-xr-xbin/webpack14
-rwxr-xr-xbin/webpack-dev-server14
-rwxr-xr-xbin/yarn11
-rw-r--r--config/application.rb16
-rw-r--r--config/boot.rb2
-rw-r--r--config/deploy.rb2
-rw-r--r--config/environments/development.rb3
-rw-r--r--config/environments/production.rb4
-rw-r--r--config/environments/test.rb13
-rw-r--r--config/initializers/content_security_policy.rb20
-rw-r--r--config/initializers/cors.rb30
-rw-r--r--db/schema.rb1
-rw-r--r--spec/controllers/auth/sessions_controller_spec.rb51
-rw-r--r--spec/models/concerns/status_threading_concern_spec.rb38
-rw-r--r--spec/views/stream_entries/show.html.haml_spec.rb2
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