about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--Gemfile18
-rw-r--r--Gemfile.lock144
-rw-r--r--app/controllers/accounts_controller.rb37
-rw-r--r--app/controllers/activitypub/outboxes_controller.rb1
-rw-r--r--app/controllers/admin/accounts_controller.rb31
-rw-r--r--app/controllers/api/base_controller.rb4
-rw-r--r--app/controllers/api/v1/accounts/featured_tags_controller.rb2
-rw-r--r--app/controllers/api/v1/accounts/follower_accounts_controller.rb2
-rw-r--r--app/controllers/api/v1/accounts/following_accounts_controller.rb2
-rw-r--r--app/controllers/api/v1/accounts/identity_proofs_controller.rb2
-rw-r--r--app/controllers/api/v1/accounts/lists_controller.rb2
-rw-r--r--app/controllers/api/v1/accounts/statuses_controller.rb2
-rw-r--r--app/controllers/api/v1/accounts_controller.rb10
-rw-r--r--app/controllers/api/v1/admin/accounts_controller.rb9
-rw-r--r--app/controllers/api/v1/blocks_controller.rb2
-rw-r--r--app/controllers/api/v1/endorsements_controller.rb2
-rw-r--r--app/controllers/api/v1/follow_requests_controller.rb4
-rw-r--r--app/controllers/api/v1/lists/accounts_controller.rb4
-rw-r--r--app/controllers/api/v1/mutes_controller.rb2
-rw-r--r--app/controllers/api/v1/notifications_controller.rb4
-rw-r--r--app/controllers/api/v1/push/subscriptions_controller.rb2
-rw-r--r--app/controllers/api/v1/statuses/favourited_by_accounts_controller.rb1
-rw-r--r--app/controllers/api/v1/statuses/reblogged_by_accounts_controller.rb2
-rw-r--r--app/controllers/api/web/push_subscriptions_controller.rb3
-rw-r--r--app/controllers/concerns/export_controller_concern.rb5
-rw-r--r--app/controllers/oauth/authorized_applications_controller.rb5
-rw-r--r--app/controllers/settings/aliases_controller.rb4
-rw-r--r--app/controllers/settings/applications_controller.rb3
-rw-r--r--app/controllers/settings/base_controller.rb7
-rw-r--r--app/controllers/settings/deletes_controller.rb9
-rw-r--r--app/controllers/settings/exports/blocked_accounts_controller.rb2
-rw-r--r--app/controllers/settings/exports/blocked_domains_controller.rb2
-rw-r--r--app/controllers/settings/exports/following_accounts_controller.rb2
-rw-r--r--app/controllers/settings/exports/lists_controller.rb2
-rw-r--r--app/controllers/settings/exports/muted_accounts_controller.rb2
-rw-r--r--app/controllers/settings/exports_controller.rb11
-rw-r--r--app/controllers/settings/featured_tags_controller.rb3
-rw-r--r--app/controllers/settings/identity_proofs_controller.rb3
-rw-r--r--app/controllers/settings/imports_controller.rb3
-rw-r--r--app/controllers/settings/migration/redirects_controller.rb9
-rw-r--r--app/controllers/settings/migrations_controller.rb9
-rw-r--r--app/controllers/settings/pictures_controller.rb1
-rw-r--r--app/controllers/settings/preferences_controller.rb4
-rw-r--r--app/controllers/settings/profiles_controller.rb3
-rw-r--r--app/controllers/settings/sessions_controller.rb9
-rw-r--r--app/controllers/settings/two_factor_authentication/confirmations_controller.rb5
-rw-r--r--app/controllers/settings/two_factor_authentication/otp_authentication_controller.rb5
-rw-r--r--app/controllers/settings/two_factor_authentication/recovery_codes_controller.rb5
-rw-r--r--app/controllers/settings/two_factor_authentication/webauthn_credentials_controller.rb3
-rw-r--r--app/controllers/settings/two_factor_authentication_methods_controller.rb5
-rw-r--r--app/javascript/flavours/glitch/actions/accounts.js22
-rw-r--r--app/javascript/flavours/glitch/actions/markers.js2
-rw-r--r--app/javascript/flavours/glitch/actions/notifications.js2
-rw-r--r--app/javascript/flavours/glitch/components/error_boundary.js10
-rw-r--r--app/javascript/flavours/glitch/components/status.js1
-rw-r--r--app/javascript/flavours/glitch/components/status_prepend.js31
-rw-r--r--app/javascript/flavours/glitch/features/account/components/action_bar.js13
-rw-r--r--app/javascript/flavours/glitch/features/account/components/header.js88
-rw-r--r--app/javascript/flavours/glitch/features/account_gallery/index.js28
-rw-r--r--app/javascript/flavours/glitch/features/account_timeline/components/header.js5
-rw-r--r--app/javascript/flavours/glitch/features/account_timeline/containers/header_container.js12
-rw-r--r--app/javascript/flavours/glitch/features/account_timeline/index.js12
-rw-r--r--app/javascript/flavours/glitch/features/notifications/components/filter_bar.js8
-rw-r--r--app/javascript/flavours/glitch/features/notifications/components/notification.js22
-rw-r--r--app/javascript/flavours/glitch/features/notifications/index.js2
-rw-r--r--app/javascript/flavours/glitch/features/ui/index.js31
-rw-r--r--app/javascript/flavours/glitch/reducers/relationships.js12
-rw-r--r--app/javascript/flavours/glitch/styles/components/accounts.scss4
-rw-r--r--app/javascript/flavours/glitch/styles/contrast/diff.scss9
-rw-r--r--app/javascript/flavours/glitch/util/emoji/index.js2
-rw-r--r--app/javascript/mastodon/actions/accounts.js4
-rw-r--r--app/javascript/mastodon/actions/markers.js43
-rw-r--r--app/javascript/mastodon/actions/notifications.js4
-rw-r--r--app/javascript/mastodon/actions/picture_in_picture.js38
-rw-r--r--app/javascript/mastodon/components/animated_number.js17
-rw-r--r--app/javascript/mastodon/components/error_boundary.js20
-rw-r--r--app/javascript/mastodon/components/icon_button.js11
-rw-r--r--app/javascript/mastodon/components/intersection_observer_article.js12
-rw-r--r--app/javascript/mastodon/components/picture_in_picture_placeholder.js69
-rw-r--r--app/javascript/mastodon/components/status.js24
-rw-r--r--app/javascript/mastodon/components/status_action_bar.js13
-rw-r--r--app/javascript/mastodon/containers/status_container.js6
-rw-r--r--app/javascript/mastodon/features/account/components/header.js76
-rw-r--r--app/javascript/mastodon/features/account_gallery/index.js31
-rw-r--r--app/javascript/mastodon/features/account_timeline/components/header.js5
-rw-r--r--app/javascript/mastodon/features/account_timeline/containers/header_container.js12
-rw-r--r--app/javascript/mastodon/features/account_timeline/index.js8
-rw-r--r--app/javascript/mastodon/features/audio/index.js47
-rw-r--r--app/javascript/mastodon/features/emoji/emoji.js2
-rw-r--r--app/javascript/mastodon/features/notifications/components/filter_bar.js8
-rw-r--r--app/javascript/mastodon/features/notifications/components/notification.js58
-rw-r--r--app/javascript/mastodon/features/notifications/index.js41
-rw-r--r--app/javascript/mastodon/features/picture_in_picture/components/footer.js137
-rw-r--r--app/javascript/mastodon/features/picture_in_picture/components/header.js40
-rw-r--r--app/javascript/mastodon/features/picture_in_picture/index.js85
-rw-r--r--app/javascript/mastodon/features/status/components/detailed_status.js8
-rw-r--r--app/javascript/mastodon/features/status/index.js5
-rw-r--r--app/javascript/mastodon/features/ui/components/media_modal.js2
-rw-r--r--app/javascript/mastodon/features/ui/components/video_modal.js4
-rw-r--r--app/javascript/mastodon/features/ui/index.js6
-rw-r--r--app/javascript/mastodon/features/video/index.js53
-rw-r--r--app/javascript/mastodon/reducers/index.js2
-rw-r--r--app/javascript/mastodon/reducers/notifications.js111
-rw-r--r--app/javascript/mastodon/reducers/picture_in_picture.js22
-rw-r--r--app/javascript/styles/contrast/diff.scss5
-rw-r--r--app/javascript/styles/mastodon/components.scss149
-rw-r--r--app/lib/activitypub/activity.rb4
-rw-r--r--app/lib/activitypub/activity/delete.rb2
-rw-r--r--app/lib/activitypub/activity/follow.rb4
-rw-r--r--app/lib/activitypub/activity/like.rb2
-rw-r--r--app/lib/request.rb10
-rw-r--r--app/mailers/notification_mailer.rb16
-rw-r--r--app/mailers/user_mailer.rb28
-rw-r--r--app/models/account.rb9
-rw-r--r--app/models/account_conversation.rb5
-rw-r--r--app/models/account_deletion_request.rb20
-rw-r--r--app/models/admin/account_action.rb4
-rw-r--r--app/models/concerns/account_associations.rb3
-rw-r--r--app/models/concerns/account_interactions.rb26
-rw-r--r--app/models/concerns/paginable.rb5
-rw-r--r--app/models/feed.rb4
-rw-r--r--app/models/follow.rb3
-rw-r--r--app/models/follow_request.rb3
-rw-r--r--app/models/form/account_batch.rb2
-rw-r--r--app/models/invite.rb2
-rw-r--r--app/models/notification.rb47
-rw-r--r--app/models/status.rb17
-rw-r--r--app/models/tag_feed.rb2
-rw-r--r--app/models/user.rb4
-rw-r--r--app/models/webauthn_credential.rb2
-rw-r--r--app/policies/account_policy.rb4
-rw-r--r--app/policies/status_policy.rb21
-rw-r--r--app/serializers/rest/account_serializer.rb55
-rw-r--r--app/serializers/rest/notification_serializer.rb2
-rw-r--r--app/serializers/rest/relationship_serializer.rb12
-rw-r--r--app/services/after_unallow_domain_service.rb2
-rw-r--r--app/services/block_domain_service.rb2
-rw-r--r--app/services/delete_account_service.rb180
-rw-r--r--app/services/favourite_service.rb2
-rw-r--r--app/services/follow_service.rb15
-rw-r--r--app/services/import_service.rb6
-rw-r--r--app/services/notify_service.rb8
-rw-r--r--app/services/process_mentions_service.rb2
-rw-r--r--app/services/reblog_service.rb2
-rw-r--r--app/services/suspend_account_service.rb183
-rw-r--r--app/services/unsuspend_account_service.rb52
-rw-r--r--app/views/admin/accounts/show.html.haml114
-rw-r--r--app/views/auth/registrations/_sessions.html.haml2
-rw-r--r--app/views/auth/registrations/edit.html.haml21
-rw-r--r--app/views/oauth/authorized_applications/index.html.haml2
-rw-r--r--app/views/statuses/show.html.haml1
-rw-r--r--app/workers/account_deletion_worker.rb13
-rw-r--r--app/workers/admin/account_deletion_worker.rb13
-rw-r--r--app/workers/admin/suspension_worker.rb6
-rw-r--r--app/workers/admin/unsuspension_worker.rb13
-rw-r--r--app/workers/feed_insert_worker.rb15
-rw-r--r--app/workers/local_notification_worker.rb4
-rw-r--r--app/workers/poll_expiration_notify_worker.rb4
-rw-r--r--app/workers/refollow_worker.rb3
-rw-r--r--app/workers/scheduler/user_cleanup_scheduler.rb13
-rw-r--r--app/workers/unfollow_follow_worker.rb5
-rw-r--r--config/locales/en.yml31
-rw-r--r--config/locales/simple_form.en.yml8
-rw-r--r--config/navigation.rb2
-rw-r--r--config/routes.rb4
-rw-r--r--db/migrate/20200908193330_create_account_deletion_requests.rb8
-rw-r--r--db/migrate/20200917192924_add_notify_to_follows.rb19
-rw-r--r--db/migrate/20200917193034_add_type_to_notifications.rb5
-rw-r--r--db/migrate/20200917222316_add_index_notifications_on_type.rb7
-rw-r--r--db/post_migrate/20200917193528_migrate_notifications_type.rb22
-rw-r--r--db/post_migrate/20200917222734_remove_index_notifications_on_account_activity.rb15
-rw-r--r--db/schema.rb14
-rw-r--r--lib/mastodon/accounts_cli.rb4
-rw-r--r--lib/mastodon/domains_cli.rb2
-rw-r--r--lib/paperclip/attachment_extensions.rb4
-rw-r--r--lib/paperclip/response_with_limit_adapter.rb2
-rw-r--r--lib/paperclip/url_generator_extensions.rb4
-rw-r--r--lib/tasks/emojis.rake2
-rw-r--r--package.json18
-rw-r--r--public/emoji/1f57a_border.svg31
-rw-r--r--spec/controllers/accounts_controller_spec.rb20
-rw-r--r--spec/controllers/api/v1/accounts_controller_spec.rb84
-rw-r--r--spec/controllers/auth/registrations_controller_spec.rb3
-rw-r--r--spec/controllers/concerns/export_controller_concern_spec.rb1
-rw-r--r--spec/controllers/settings/deletes_controller_spec.rb28
-rw-r--r--spec/fabricators/account_deletion_request_fabricator.rb3
-rw-r--r--spec/models/account_deletion_request_spec.rb4
-rw-r--r--spec/models/concerns/account_interactions_spec.rb4
-rw-r--r--spec/models/follow_request_spec.rb2
-rw-r--r--spec/models/invite_spec.rb2
-rw-r--r--spec/models/webauthn_credentials_spec.rb4
-rw-r--r--spec/services/delete_account_service_spec.rb (renamed from spec/services/suspend_account_service_spec.rb)2
-rw-r--r--spec/services/import_service_spec.rb1
-rw-r--r--spec/services/notify_service_spec.rb6
-rw-r--r--spec/workers/refollow_worker_spec.rb4
-rw-r--r--streaming/index.js21
-rw-r--r--yarn.lock325
197 files changed, 2561 insertions, 1063 deletions
diff --git a/Gemfile b/Gemfile
index 12cc251c3..70c54d359 100644
--- a/Gemfile
+++ b/Gemfile
@@ -5,10 +5,10 @@ ruby '>= 2.5.0', '< 3.0.0'
 
 gem 'pkg-config', '~> 1.4'
 
-gem 'puma', '~> 4.3'
-gem 'rails', '~> 5.2.4.3'
+gem 'puma', '~> 5.0'
+gem 'rails', '~> 5.2.4.4'
 gem 'sprockets', '~> 3.7.2'
-gem 'thor', '~> 0.20'
+gem 'thor', '~> 1.0'
 gem 'rack', '~> 2.2.3'
 
 gem 'thwait', '~> 0.2.0'
@@ -20,7 +20,7 @@ gem 'makara', '~> 0.4'
 gem 'pghero', '~> 2.7'
 gem 'dotenv-rails', '~> 2.7'
 
-gem 'aws-sdk-s3', '~> 1.79', require: false
+gem 'aws-sdk-s3', '~> 1.81', require: false
 gem 'fog-core', '<= 2.1.0'
 gem 'fog-openstack', '~> 0.3', require: false
 gem 'paperclip', '~> 6.0'
@@ -123,26 +123,26 @@ end
 group :test do
   gem 'capybara', '~> 3.33'
   gem 'climate_control', '~> 0.2'
-  gem 'faker', '~> 2.13'
+  gem 'faker', '~> 2.14'
   gem 'microformats', '~> 4.2'
   gem 'rails-controller-testing', '~> 1.0'
   gem 'rspec-sidekiq', '~> 3.1'
   gem 'simplecov', '~> 0.19', require: false
-  gem 'webmock', '~> 3.8'
-  gem 'parallel_tests', '~> 3.2'
+  gem 'webmock', '~> 3.9'
+  gem 'parallel_tests', '~> 3.3'
   gem 'rspec_junit_formatter', '~> 0.4'
 end
 
 group :development do
   gem 'active_record_query_trace', '~> 1.7'
   gem 'annotate', '~> 3.1'
-  gem 'better_errors', '~> 2.7'
+  gem 'better_errors', '~> 2.8'
   gem 'binding_of_caller', '~> 0.7'
   gem 'bullet', '~> 6.1'
   gem 'letter_opener', '~> 1.7'
   gem 'letter_opener_web', '~> 1.4'
   gem 'memory_profiler'
-  gem 'rubocop', '~> 0.90', require: false
+  gem 'rubocop', '~> 0.91', require: false
   gem 'rubocop-rails', '~> 2.8', require: false
   gem 'brakeman', '~> 4.9', require: false
   gem 'bundler-audit', '~> 0.7', require: false
diff --git a/Gemfile.lock b/Gemfile.lock
index 7a1192329..66d907880 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -16,25 +16,25 @@ GIT
 GEM
   remote: https://rubygems.org/
   specs:
-    actioncable (5.2.4.3)
-      actionpack (= 5.2.4.3)
+    actioncable (5.2.4.4)
+      actionpack (= 5.2.4.4)
       nio4r (~> 2.0)
       websocket-driver (>= 0.6.1)
-    actionmailer (5.2.4.3)
-      actionpack (= 5.2.4.3)
-      actionview (= 5.2.4.3)
-      activejob (= 5.2.4.3)
+    actionmailer (5.2.4.4)
+      actionpack (= 5.2.4.4)
+      actionview (= 5.2.4.4)
+      activejob (= 5.2.4.4)
       mail (~> 2.5, >= 2.5.4)
       rails-dom-testing (~> 2.0)
-    actionpack (5.2.4.3)
-      actionview (= 5.2.4.3)
-      activesupport (= 5.2.4.3)
+    actionpack (5.2.4.4)
+      actionview (= 5.2.4.4)
+      activesupport (= 5.2.4.4)
       rack (~> 2.0, >= 2.0.8)
       rack-test (>= 0.6.3)
       rails-dom-testing (~> 2.0)
       rails-html-sanitizer (~> 1.0, >= 1.0.2)
-    actionview (5.2.4.3)
-      activesupport (= 5.2.4.3)
+    actionview (5.2.4.4)
+      activesupport (= 5.2.4.4)
       builder (~> 3.1)
       erubi (~> 1.4)
       rails-dom-testing (~> 2.0)
@@ -45,22 +45,22 @@ GEM
       case_transform (>= 0.2)
       jsonapi-renderer (>= 0.1.1.beta1, < 0.3)
     active_record_query_trace (1.7)
-    activejob (5.2.4.3)
-      activesupport (= 5.2.4.3)
+    activejob (5.2.4.4)
+      activesupport (= 5.2.4.4)
       globalid (>= 0.3.6)
-    activemodel (5.2.4.3)
-      activesupport (= 5.2.4.3)
-    activerecord (5.2.4.3)
-      activemodel (= 5.2.4.3)
-      activesupport (= 5.2.4.3)
+    activemodel (5.2.4.4)
+      activesupport (= 5.2.4.4)
+    activerecord (5.2.4.4)
+      activemodel (= 5.2.4.4)
+      activesupport (= 5.2.4.4)
       arel (>= 9.0)
     activerecord-import (1.0.6)
       activerecord (>= 3.2)
-    activestorage (5.2.4.3)
-      actionpack (= 5.2.4.3)
-      activerecord (= 5.2.4.3)
+    activestorage (5.2.4.4)
+      actionpack (= 5.2.4.4)
+      activerecord (= 5.2.4.4)
       marcel (~> 0.3.1)
-    activesupport (5.2.4.3)
+    activesupport (5.2.4.4)
       concurrent-ruby (~> 1.0, >= 1.0.2)
       i18n (>= 0.7, < 2)
       minitest (~> 5.1)
@@ -81,23 +81,23 @@ GEM
       cocaine (~> 0.5.3)
     awrence (1.1.1)
     aws-eventstream (1.1.0)
-    aws-partitions (1.365.0)
-    aws-sdk-core (3.105.0)
+    aws-partitions (1.373.0)
+    aws-sdk-core (3.107.0)
       aws-eventstream (~> 1, >= 1.0.2)
       aws-partitions (~> 1, >= 1.239.0)
       aws-sigv4 (~> 1.1)
       jmespath (~> 1.0)
-    aws-sdk-kms (1.37.0)
+    aws-sdk-kms (1.38.0)
       aws-sdk-core (~> 3, >= 3.99.0)
       aws-sigv4 (~> 1.1)
-    aws-sdk-s3 (1.79.1)
+    aws-sdk-s3 (1.81.0)
       aws-sdk-core (~> 3, >= 3.104.3)
       aws-sdk-kms (~> 1)
       aws-sigv4 (~> 1.1)
     aws-sigv4 (1.2.2)
       aws-eventstream (~> 1, >= 1.0.2)
     bcrypt (3.1.16)
-    better_errors (2.7.1)
+    better_errors (2.8.1)
       coderay (>= 1.0.0)
       erubi (>= 1.0.0)
       rack (>= 0.9.0)
@@ -162,13 +162,12 @@ GEM
     cose (1.0.0)
       cbor (~> 0.5.9)
       openssl-signature_algorithm (~> 0.4.0)
-    crack (0.4.3)
-      safe_yaml (~> 1.0.0)
+    crack (0.4.4)
     crass (1.0.6)
     css_parser (1.7.1)
       addressable
     debug_inspector (0.0.3)
-    devise (4.7.2)
+    devise (4.7.3)
       bcrypt (~> 3.0)
       orm_adapter (~> 0.1)
       railties (>= 4.1.0)
@@ -212,7 +211,7 @@ GEM
       tzinfo
     excon (0.76.0)
     fabrication (2.21.1)
-    faker (2.13.0)
+    faker (2.14.0)
       i18n (>= 1.6, < 2)
     faraday (1.0.1)
       multipart-post (>= 1.2, < 3)
@@ -235,7 +234,7 @@ GEM
       fog-json (>= 1.0)
       ipaddress (>= 0.8)
     formatador (0.2.5)
-    fugit (1.3.8)
+    fugit (1.3.9)
       et-orbi (~> 1.1, >= 1.1.8)
       raabro (~> 1.3)
     fuubar (2.5.0)
@@ -366,7 +365,7 @@ GEM
     net-scp (3.0.0)
       net-ssh (>= 2.6.5, < 7.0.0)
     net-ssh (6.1.0)
-    nio4r (2.5.3)
+    nio4r (2.5.4)
     nokogiri (1.10.10)
       mini_portile2 (~> 2.4.0)
     nokogumbo (2.0.2)
@@ -390,7 +389,7 @@ GEM
     openssl (2.2.0)
     openssl-signature_algorithm (0.4.0)
     orm_adapter (0.5.0)
-    ox (2.13.3)
+    ox (2.13.4)
     paperclip (6.0.0)
       activemodel (>= 4.2.0)
       activesupport (>= 4.2.0)
@@ -401,7 +400,7 @@ GEM
       av (~> 0.9.0)
       paperclip (>= 2.5.2)
     parallel (1.19.2)
-    parallel_tests (3.2.0)
+    parallel_tests (3.3.0)
       parallel
     parser (2.7.1.4)
       ast (~> 2.4.1)
@@ -409,11 +408,11 @@ GEM
     pastel (0.8.0)
       tty-color (~> 0.5)
     pg (1.2.3)
-    pghero (2.7.0)
+    pghero (2.7.2)
       activerecord (>= 5)
-    pkg-config (1.4.2)
+    pkg-config (1.4.3)
     posix-spawn (0.3.15)
-    premailer (1.13.1)
+    premailer (1.14.2)
       addressable
       css_parser (>= 1.6.0)
       htmlentities (>= 4.0.0)
@@ -431,7 +430,7 @@ GEM
       pry (>= 0.10.4)
     psych (3.1.0)
     public_suffix (4.0.6)
-    puma (4.3.6)
+    puma (5.0.1)
       nio4r (~> 2.0)
     pundit (2.1.0)
       activesupport (>= 3.0.0)
@@ -445,18 +444,18 @@ GEM
       rack
     rack-test (1.1.0)
       rack (>= 1.0, < 3)
-    rails (5.2.4.3)
-      actioncable (= 5.2.4.3)
-      actionmailer (= 5.2.4.3)
-      actionpack (= 5.2.4.3)
-      actionview (= 5.2.4.3)
-      activejob (= 5.2.4.3)
-      activemodel (= 5.2.4.3)
-      activerecord (= 5.2.4.3)
-      activestorage (= 5.2.4.3)
-      activesupport (= 5.2.4.3)
+    rails (5.2.4.4)
+      actioncable (= 5.2.4.4)
+      actionmailer (= 5.2.4.4)
+      actionpack (= 5.2.4.4)
+      actionview (= 5.2.4.4)
+      activejob (= 5.2.4.4)
+      activemodel (= 5.2.4.4)
+      activerecord (= 5.2.4.4)
+      activestorage (= 5.2.4.4)
+      activesupport (= 5.2.4.4)
       bundler (>= 1.3.0)
-      railties (= 5.2.4.3)
+      railties (= 5.2.4.4)
       sprockets-rails (>= 2.0.0)
     rails-controller-testing (1.0.5)
       actionpack (>= 5.0.1.rc1)
@@ -472,9 +471,9 @@ GEM
       railties (>= 5.0, < 6)
     rails-settings-cached (0.6.6)
       rails (>= 4.2.0)
-    railties (5.2.4.3)
-      actionpack (= 5.2.4.3)
-      activesupport (= 5.2.4.3)
+    railties (5.2.4.4)
+      actionpack (= 5.2.4.4)
+      activesupport (= 5.2.4.4)
       method_source
       rake (>= 0.8.7)
       thor (>= 0.19.0, < 2.0)
@@ -486,7 +485,7 @@ GEM
     rdf-normalize (0.4.0)
       rdf (~> 3.1)
     redcarpet (3.5.0)
-    redis (4.2.1)
+    redis (4.2.2)
     redis-actionpack (5.2.0)
       actionpack (>= 5, < 7)
       redis-rack (>= 2.1.0, < 3)
@@ -510,7 +509,7 @@ GEM
       parser (>= 2.5.0.0, < 2.8, != 2.5.1.1)
       psych (~> 3.1.0)
       rainbow (>= 2.0, < 4.0)
-    regexp_parser (1.7.1)
+    regexp_parser (1.8.0)
     request_store (1.5.0)
       rack (>= 1.4)
     responders (3.0.1)
@@ -545,18 +544,18 @@ GEM
     rspec-support (3.9.3)
     rspec_junit_formatter (0.4.1)
       rspec-core (>= 2, < 4, != 2.12.0)
-    rubocop (0.90.0)
+    rubocop (0.91.0)
       parallel (~> 1.10)
       parser (>= 2.7.1.1)
       rainbow (>= 2.2.2, < 4.0)
       regexp_parser (>= 1.7)
       rexml
-      rubocop-ast (>= 0.3.0, < 1.0)
+      rubocop-ast (>= 0.4.0, < 1.0)
       ruby-progressbar (~> 1.7)
       unicode-display_width (>= 1.4.0, < 2.0)
-    rubocop-ast (0.3.0)
+    rubocop-ast (0.4.2)
       parser (>= 2.7.1.4)
-    rubocop-rails (2.8.0)
+    rubocop-rails (2.8.1)
       activesupport (>= 4.2.0)
       rack (>= 1.1)
       rubocop (>= 0.87.0)
@@ -565,7 +564,6 @@ GEM
       nokogiri (>= 1.5.10)
     rufus-scheduler (3.6.0)
       fugit (~> 1.1, >= 1.1.6)
-    safe_yaml (1.0.5)
     safety_net_attestation (0.4.0)
       jwt (~> 2.0)
     sanitize (5.2.1)
@@ -574,7 +572,7 @@ GEM
       nokogumbo (~> 2.0)
     securecompare (1.0.0)
     semantic_range (2.3.0)
-    sidekiq (6.1.1)
+    sidekiq (6.1.2)
       connection_pool (>= 2.2.2)
       rack (~> 2.0)
       redis (>= 4.2.0)
@@ -603,7 +601,7 @@ GEM
     sprockets (3.7.2)
       concurrent-ruby (~> 1.0)
       rack (> 1, < 3)
-    sprockets-rails (3.2.1)
+    sprockets-rails (3.2.2)
       actionpack (>= 4.0)
       activesupport (>= 4.0)
       sprockets (>= 3.0.0)
@@ -622,7 +620,7 @@ GEM
       unicode-display_width (~> 1.1, >= 1.1.1)
     terrapin (0.6.0)
       climate_control (>= 0.0.3, < 1.0)
-    thor (0.20.3)
+    thor (1.0.1)
     thread_safe (0.3.6)
     thwait (0.2.0)
       e2mmap
@@ -666,7 +664,7 @@ GEM
       safety_net_attestation (~> 0.4.0)
       securecompare (~> 1.0)
       tpm-key_attestation (~> 0.9.0)
-    webmock (3.8.3)
+    webmock (3.9.1)
       addressable (>= 2.3.6)
       crack (>= 0.3.2)
       hashdiff (>= 0.4.0, < 2.0.0)
@@ -694,8 +692,8 @@ DEPENDENCIES
   activerecord-import (~> 1.0)
   addressable (~> 2.7)
   annotate (~> 3.1)
-  aws-sdk-s3 (~> 1.79)
-  better_errors (~> 2.7)
+  aws-sdk-s3 (~> 1.81)
+  better_errors (~> 2.8)
   binding_of_caller (~> 0.7)
   blurhash (~> 0.1)
   bootsnap (~> 1.4)
@@ -724,7 +722,7 @@ DEPENDENCIES
   e2mmap (~> 0.1.0)
   ed25519 (~> 1.2)
   fabrication (~> 2.21)
-  faker (~> 2.13)
+  faker (~> 2.14)
   fast_blank (~> 1.0)
   fastimage
   fog-core (<= 2.1.0)
@@ -765,7 +763,7 @@ DEPENDENCIES
   paperclip (~> 6.0)
   paperclip-av-transcoder (~> 0.6)
   parallel (~> 1.19)
-  parallel_tests (~> 3.2)
+  parallel_tests (~> 3.3)
   parslet
   pg (~> 1.2)
   pghero (~> 2.7)
@@ -775,12 +773,12 @@ DEPENDENCIES
   private_address_check (~> 0.5)
   pry-byebug (~> 3.9)
   pry-rails (~> 0.3)
-  puma (~> 4.3)
+  puma (~> 5.0)
   pundit (~> 2.1)
   rack (~> 2.2.3)
   rack-attack (~> 6.3)
   rack-cors (~> 1.1)
-  rails (~> 5.2.4.3)
+  rails (~> 5.2.4.4)
   rails-controller-testing (~> 1.0)
   rails-i18n (~> 5.1)
   rails-settings-cached (~> 0.6)
@@ -794,7 +792,7 @@ DEPENDENCIES
   rspec-rails (~> 4.0)
   rspec-sidekiq (~> 3.1)
   rspec_junit_formatter (~> 0.4)
-  rubocop (~> 0.90)
+  rubocop (~> 0.91)
   rubocop-rails (~> 2.8)
   ruby-progressbar (~> 1.10)
   sanitize (~> 5.2)
@@ -811,14 +809,14 @@ DEPENDENCIES
   stoplight (~> 2.2.1)
   streamio-ffmpeg (~> 3.0)
   strong_migrations (~> 0.7)
-  thor (~> 0.20)
+  thor (~> 1.0)
   thwait (~> 0.2.0)
   tty-prompt (~> 0.22)
   twitter-text (~> 1.14)
   tzinfo-data (~> 1.2020)
   w3c_validators (~> 1.3)
   webauthn (~> 3.0.0.alpha1)
-  webmock (~> 3.8)
+  webmock (~> 3.9)
   webpacker (~> 5.2)
   webpush
 
diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb
index ead63d2ee..352f84ea7 100644
--- a/app/controllers/accounts_controller.rb
+++ b/app/controllers/accounts_controller.rb
@@ -7,6 +7,7 @@ class AccountsController < ApplicationController
   include AccountControllerConcern
   include SignatureAuthentication
 
+  before_action :require_signature!, if: -> { request.format == :json && authorized_fetch_mode? }
   before_action :set_cache_headers
   before_action :set_body_classes
 
@@ -56,7 +57,7 @@ class AccountsController < ApplicationController
 
       format.json do
         expires_in 3.minutes, public: !current_account?
-        render_with_cache json: @account, content_type: 'application/activity+json', serializer: ActivityPub::ActorSerializer, adapter: ActivityPub::Adapter, fields: restrict_fields_to
+        render_with_cache json: @account, content_type: 'application/activity+json', serializer: ActivityPub::ActorSerializer, adapter: ActivityPub::Adapter
       end
     end
   end
@@ -161,15 +162,6 @@ class AccountsController < ApplicationController
     request.path.split('.').first.ends_with?(Addressable::URI.parse("/tagged/#{params[:tag]}").normalize)
   end
 
-  def cached_filtered_status_page
-    cache_collection_paginated_by_id(
-      filtered_statuses,
-      Status,
-      PAGE_SIZE,
-      params_slice(:max_id, :min_id, :since_id)
-    )
-  end
-
   def reblogs_requested?
     request.path.split('.').first.ends_with?('/reblogs') && !tag_requested?
   end
@@ -178,18 +170,6 @@ class AccountsController < ApplicationController
     request.path.split('.').first.ends_with?('/mentions') && !tag_requested?
   end
 
-  def params_slice(*keys)
-    params.slice(*keys).permit(*keys)
-  end
-
-  def restrict_fields_to
-    if current_account&.id == @account.id || (signed_request_account.present? && !blocked?)
-      # Return all fields
-    else
-      %i(id type preferred_username inbox public_key endpoints)
-    end
-  end
-
   def blocked?
     @blocked ||= current_account && @account.blocking?(current_account)
   end
@@ -201,4 +181,17 @@ class AccountsController < ApplicationController
   def rss_disabled?
     @account.user&.setting_rss_disabled
   end
+
+  def cached_filtered_status_page
+    cache_collection_paginated_by_id(
+      filtered_statuses,
+      Status,
+      PAGE_SIZE,
+      params_slice(:max_id, :min_id, :since_id)
+    )
+  end
+
+  def params_slice(*keys)
+    params.slice(*keys).permit(*keys)
+  end
 end
diff --git a/app/controllers/activitypub/outboxes_controller.rb b/app/controllers/activitypub/outboxes_controller.rb
index 4f2ed4db4..7c914298b 100644
--- a/app/controllers/activitypub/outboxes_controller.rb
+++ b/app/controllers/activitypub/outboxes_controller.rb
@@ -34,6 +34,7 @@ class ActivityPub::OutboxesController < ActivityPub::BaseController
       ActivityPub::CollectionPresenter.new(
         id: account_outbox_url(@account),
         type: :ordered,
+        size: @account.statuses_count,
         first: outbox_url(page: true),
         last: outbox_url(page: true, min_id: 0)
       )
diff --git a/app/controllers/admin/accounts_controller.rb b/app/controllers/admin/accounts_controller.rb
index 7b1783542..b9b75727d 100644
--- a/app/controllers/admin/accounts_controller.rb
+++ b/app/controllers/admin/accounts_controller.rb
@@ -2,7 +2,7 @@
 
 module Admin
   class AccountsController < BaseController
-    before_action :set_account, only: [:show, :redownload, :remove_avatar, :remove_header, :enable, :unsilence, :unsuspend, :memorialize, :approve, :reject]
+    before_action :set_account, except: [:index]
     before_action :require_remote_account!, only: [:redownload]
     before_action :require_local_account!, only: [:enable, :memorialize, :approve, :reject]
 
@@ -14,49 +14,58 @@ module Admin
     def show
       authorize @account, :show?
 
+      @deletion_request        = @account.deletion_request
       @account_moderation_note = current_account.account_moderation_notes.new(target_account: @account)
       @moderation_notes        = @account.targeted_moderation_notes.latest
       @warnings                = @account.targeted_account_warnings.latest.custom
+      @domain_block            = DomainBlock.rule_for(@account.domain)
     end
 
     def memorialize
       authorize @account, :memorialize?
       @account.memorialize!
       log_action :memorialize, @account
-      redirect_to admin_account_path(@account.id)
+      redirect_to admin_account_path(@account.id), notice: I18n.t('admin.accounts.memorialized_msg', username: @account.acct)
     end
 
     def enable
       authorize @account.user, :enable?
       @account.user.enable!
       log_action :enable, @account.user
-      redirect_to admin_account_path(@account.id)
+      redirect_to admin_account_path(@account.id), notice: I18n.t('admin.accounts.enabled_msg', username: @account.acct)
     end
 
     def approve
       authorize @account.user, :approve?
       @account.user.approve!
-      redirect_to admin_pending_accounts_path
+      redirect_to admin_pending_accounts_path, notice: I18n.t('admin.accounts.approved_msg', username: @account.acct)
     end
 
     def reject
       authorize @account.user, :reject?
-      SuspendAccountService.new.call(@account, reserve_email: false, reserve_username: false)
-      redirect_to admin_pending_accounts_path
+      DeleteAccountService.new.call(@account, reserve_email: false, reserve_username: false)
+      redirect_to admin_pending_accounts_path, notice: I18n.t('admin.accounts.rejected_msg', username: @account.acct)
+    end
+
+    def destroy
+      authorize @account, :destroy?
+      Admin::AccountDeletionWorker.perform_async(@account.id)
+      redirect_to admin_account_path(@account.id), notice: I18n.t('admin.accounts.destroyed_msg', username: @account.acct)
     end
 
     def unsilence
       authorize @account, :unsilence?
       @account.unsilence!
       log_action :unsilence, @account
-      redirect_to admin_account_path(@account.id)
+      redirect_to admin_account_path(@account.id), notice: I18n.t('admin.accounts.unsilenced_msg', username: @account.acct)
     end
 
     def unsuspend
       authorize @account, :unsuspend?
       @account.unsuspend!
+      Admin::UnsuspensionWorker.perform_async(@account.id)
       log_action :unsuspend, @account
-      redirect_to admin_account_path(@account.id)
+      redirect_to admin_account_path(@account.id), notice: I18n.t('admin.accounts.unsuspended_msg', username: @account.acct)
     end
 
     def redownload
@@ -65,7 +74,7 @@ module Admin
       @account.update!(last_webfingered_at: nil)
       ResolveAccountService.new.call(@account)
 
-      redirect_to admin_account_path(@account.id)
+      redirect_to admin_account_path(@account.id), notice: I18n.t('admin.accounts.redownloaded_msg', username: @account.acct)
     end
 
     def remove_avatar
@@ -76,7 +85,7 @@ module Admin
 
       log_action :remove_avatar, @account.user
 
-      redirect_to admin_account_path(@account.id)
+      redirect_to admin_account_path(@account.id), notice: I18n.t('admin.accounts.removed_avatar_msg', username: @account.acct)
     end
 
     def remove_header
@@ -87,7 +96,7 @@ module Admin
 
       log_action :remove_header, @account.user
 
-      redirect_to admin_account_path(@account.id)
+      redirect_to admin_account_path(@account.id), notice: I18n.t('admin.accounts.removed_header_msg', username: @account.acct)
     end
 
     private
diff --git a/app/controllers/api/base_controller.rb b/app/controllers/api/base_controller.rb
index ac49a4dca..818819a3f 100644
--- a/app/controllers/api/base_controller.rb
+++ b/app/controllers/api/base_controller.rb
@@ -96,12 +96,12 @@ class Api::BaseController < ApplicationController
   def require_user!
     if !current_user
       render json: { error: 'This method requires an authenticated user' }, status: 422
-    elsif current_user.disabled?
-      render json: { error: 'Your login is currently disabled' }, status: 403
     elsif !current_user.confirmed?
       render json: { error: 'Your login is missing a confirmed e-mail address' }, status: 403
     elsif !current_user.approved?
       render json: { error: 'Your login is currently pending approval' }, status: 403
+    elsif !current_user.functional?
+      render json: { error: 'Your login is currently disabled' }, status: 403
     else
       set_user_activity
     end
diff --git a/app/controllers/api/v1/accounts/featured_tags_controller.rb b/app/controllers/api/v1/accounts/featured_tags_controller.rb
index d6277261d..014d71956 100644
--- a/app/controllers/api/v1/accounts/featured_tags_controller.rb
+++ b/app/controllers/api/v1/accounts/featured_tags_controller.rb
@@ -17,6 +17,6 @@ class Api::V1::Accounts::FeaturedTagsController < Api::BaseController
   end
 
   def set_featured_tags
-    @featured_tags = @account.featured_tags
+    @featured_tags = @account.suspended? ? @account.featured_tags : []
   end
 end
diff --git a/app/controllers/api/v1/accounts/follower_accounts_controller.rb b/app/controllers/api/v1/accounts/follower_accounts_controller.rb
index 2277067c9..a665863eb 100644
--- a/app/controllers/api/v1/accounts/follower_accounts_controller.rb
+++ b/app/controllers/api/v1/accounts/follower_accounts_controller.rb
@@ -25,7 +25,7 @@ class Api::V1::Accounts::FollowerAccountsController < Api::BaseController
   end
 
   def hide_results?
-    (@account.hides_followers? && current_account&.id != @account.id) || (current_account && @account.blocking?(current_account))
+    @account.suspended? || (@account.hides_followers? && current_account&.id != @account.id) || (current_account && @account.blocking?(current_account))
   end
 
   def default_accounts
diff --git a/app/controllers/api/v1/accounts/following_accounts_controller.rb b/app/controllers/api/v1/accounts/following_accounts_controller.rb
index 93d4bd3a4..7d885a212 100644
--- a/app/controllers/api/v1/accounts/following_accounts_controller.rb
+++ b/app/controllers/api/v1/accounts/following_accounts_controller.rb
@@ -25,7 +25,7 @@ class Api::V1::Accounts::FollowingAccountsController < Api::BaseController
   end
 
   def hide_results?
-    (@account.hides_following? && current_account&.id != @account.id) || (current_account && @account.blocking?(current_account))
+    @account.suspended? || (@account.hides_following? && current_account&.id != @account.id) || (current_account && @account.blocking?(current_account))
   end
 
   def default_accounts
diff --git a/app/controllers/api/v1/accounts/identity_proofs_controller.rb b/app/controllers/api/v1/accounts/identity_proofs_controller.rb
index 8dad6fee9..4b5f6902c 100644
--- a/app/controllers/api/v1/accounts/identity_proofs_controller.rb
+++ b/app/controllers/api/v1/accounts/identity_proofs_controller.rb
@@ -5,7 +5,7 @@ class Api::V1::Accounts::IdentityProofsController < Api::BaseController
   before_action :set_account
 
   def index
-    @proofs = @account.identity_proofs.active
+    @proofs = @account.suspended? ? [] : @account.identity_proofs.active
     render json: @proofs, each_serializer: REST::IdentityProofSerializer
   end
 
diff --git a/app/controllers/api/v1/accounts/lists_controller.rb b/app/controllers/api/v1/accounts/lists_controller.rb
index ccb751f8f..c92f1f8a0 100644
--- a/app/controllers/api/v1/accounts/lists_controller.rb
+++ b/app/controllers/api/v1/accounts/lists_controller.rb
@@ -6,7 +6,7 @@ class Api::V1::Accounts::ListsController < Api::BaseController
   before_action :set_account
 
   def index
-    @lists = @account.lists.where(account: current_account)
+    @lists = @account.suspended? ? [] : @account.lists.where(account: current_account)
     render json: @lists, each_serializer: REST::ListSerializer
   end
 
diff --git a/app/controllers/api/v1/accounts/statuses_controller.rb b/app/controllers/api/v1/accounts/statuses_controller.rb
index 099334cfe..a0ce810ad 100644
--- a/app/controllers/api/v1/accounts/statuses_controller.rb
+++ b/app/controllers/api/v1/accounts/statuses_controller.rb
@@ -22,7 +22,7 @@ class Api::V1::Accounts::StatusesController < Api::BaseController
   end
 
   def load_statuses
-    cached_account_statuses
+    @account.suspended? ? [] : cached_account_statuses
   end
 
   def cached_account_statuses
diff --git a/app/controllers/api/v1/accounts_controller.rb b/app/controllers/api/v1/accounts_controller.rb
index e9f848ac9..453929afe 100644
--- a/app/controllers/api/v1/accounts_controller.rb
+++ b/app/controllers/api/v1/accounts_controller.rb
@@ -9,7 +9,6 @@ class Api::V1::AccountsController < Api::BaseController
 
   before_action :require_user!, except: [:show, :create]
   before_action :set_account, except: [:create]
-  before_action :check_account_suspension, only: [:show]
   before_action :check_enabled_registrations, only: [:create]
 
   skip_before_action :require_authenticated_user!, only: :create
@@ -31,9 +30,8 @@ class Api::V1::AccountsController < Api::BaseController
   end
 
   def follow
-    FollowService.new.call(current_user.account, @account, reblogs: truthy_param?(:reblogs), with_rate_limit: true)
-
-    options = @account.locked? || current_user.account.silenced? ? {} : { following_map: { @account.id => { reblogs: truthy_param?(:reblogs) } }, requested_map: { @account.id => false } }
+    follow  = FollowService.new.call(current_user.account, @account, reblogs: params.key?(:reblogs) ? truthy_param?(:reblogs) : nil, notify: params.key?(:notify) ? truthy_param?(:notify) : nil, with_rate_limit: true)
+    options = @account.locked? || current_user.account.silenced? ? {} : { following_map: { @account.id => { reblogs: follow.show_reblogs?, notify: follow.notify? } }, requested_map: { @account.id => false } }
 
     render json: @account, serializer: REST::RelationshipSerializer, relationships: relationships(options)
   end
@@ -73,10 +71,6 @@ class Api::V1::AccountsController < Api::BaseController
     AccountRelationshipsPresenter.new([@account.id], current_user.account_id, options)
   end
 
-  def check_account_suspension
-    gone if @account.suspended?
-  end
-
   def account_params
     params.permit(:username, :email, :password, :agreement, :locale, :reason)
   end
diff --git a/app/controllers/api/v1/admin/accounts_controller.rb b/app/controllers/api/v1/admin/accounts_controller.rb
index 24c7fbef1..3af572f25 100644
--- a/app/controllers/api/v1/admin/accounts_controller.rb
+++ b/app/controllers/api/v1/admin/accounts_controller.rb
@@ -58,7 +58,13 @@ class Api::V1::Admin::AccountsController < Api::BaseController
 
   def reject
     authorize @account.user, :reject?
-    SuspendAccountService.new.call(@account, reserve_email: false, reserve_username: false)
+    DeleteAccountService.new.call(@account, reserve_email: false, reserve_username: false)
+    render json: @account, serializer: REST::Admin::AccountSerializer
+  end
+
+  def destroy
+    authorize @account, :destroy?
+    Admin::AccountDeletionWorker.perform_async(@account.id)
     render json: @account, serializer: REST::Admin::AccountSerializer
   end
 
@@ -72,6 +78,7 @@ class Api::V1::Admin::AccountsController < Api::BaseController
   def unsuspend
     authorize @account, :unsuspend?
     @account.unsuspend!
+    Admin::UnsuspensionWorker.perform_async(@account.id)
     log_action :unsuspend, @account
     render json: @account, serializer: REST::Admin::AccountSerializer
   end
diff --git a/app/controllers/api/v1/blocks_controller.rb b/app/controllers/api/v1/blocks_controller.rb
index a2baeef90..586cdfca9 100644
--- a/app/controllers/api/v1/blocks_controller.rb
+++ b/app/controllers/api/v1/blocks_controller.rb
@@ -18,6 +18,8 @@ class Api::V1::BlocksController < Api::BaseController
 
   def paginated_blocks
     @paginated_blocks ||= Block.eager_load(target_account: :account_stat)
+                               .joins(:target_account)
+                               .merge(Account.without_suspended)
                                .where(account: current_account)
                                .paginate_by_max_id(
                                  limit_param(DEFAULT_ACCOUNTS_LIMIT),
diff --git a/app/controllers/api/v1/endorsements_controller.rb b/app/controllers/api/v1/endorsements_controller.rb
index c87dbc4ce..9e80f468a 100644
--- a/app/controllers/api/v1/endorsements_controller.rb
+++ b/app/controllers/api/v1/endorsements_controller.rb
@@ -25,7 +25,7 @@ class Api::V1::EndorsementsController < Api::BaseController
   end
 
   def endorsed_accounts
-    current_account.endorsed_accounts.includes(:account_stat)
+    current_account.endorsed_accounts.includes(:account_stat).without_suspended
   end
 
   def insert_pagination_headers
diff --git a/app/controllers/api/v1/follow_requests_controller.rb b/app/controllers/api/v1/follow_requests_controller.rb
index 0ee6e531f..b34c76f29 100644
--- a/app/controllers/api/v1/follow_requests_controller.rb
+++ b/app/controllers/api/v1/follow_requests_controller.rb
@@ -13,7 +13,7 @@ class Api::V1::FollowRequestsController < Api::BaseController
 
   def authorize
     AuthorizeFollowService.new.call(account, current_account)
-    NotifyService.new.call(current_account, Follow.find_by(account: account, target_account: current_account))
+    NotifyService.new.call(current_account, :follow, Follow.find_by(account: account, target_account: current_account))
     render json: account, serializer: REST::RelationshipSerializer, relationships: relationships
   end
 
@@ -37,7 +37,7 @@ class Api::V1::FollowRequestsController < Api::BaseController
   end
 
   def default_accounts
-    Account.includes(:follow_requests, :account_stat).references(:follow_requests)
+    Account.without_suspended.includes(:follow_requests, :account_stat).references(:follow_requests)
   end
 
   def paginated_follow_requests
diff --git a/app/controllers/api/v1/lists/accounts_controller.rb b/app/controllers/api/v1/lists/accounts_controller.rb
index 23078263e..b66ea9bfe 100644
--- a/app/controllers/api/v1/lists/accounts_controller.rb
+++ b/app/controllers/api/v1/lists/accounts_controller.rb
@@ -37,9 +37,9 @@ class Api::V1::Lists::AccountsController < Api::BaseController
 
   def load_accounts
     if unlimited?
-      @list.accounts.includes(:account_stat).all
+      @list.accounts.without_suspended.includes(:account_stat).all
     else
-      @list.accounts.includes(:account_stat).paginate_by_max_id(limit_param(DEFAULT_ACCOUNTS_LIMIT), params[:max_id], params[:since_id])
+      @list.accounts.without_suspended.includes(:account_stat).paginate_by_max_id(limit_param(DEFAULT_ACCOUNTS_LIMIT), params[:max_id], params[:since_id])
     end
   end
 
diff --git a/app/controllers/api/v1/mutes_controller.rb b/app/controllers/api/v1/mutes_controller.rb
index 5dc047b43..a89f3d700 100644
--- a/app/controllers/api/v1/mutes_controller.rb
+++ b/app/controllers/api/v1/mutes_controller.rb
@@ -27,6 +27,8 @@ class Api::V1::MutesController < Api::BaseController
 
   def paginated_mutes
     @paginated_mutes ||= Mute.eager_load(:target_account)
+                             .joins(:target_account)
+                             .merge(Account.without_suspended)
                              .where(account: current_account)
                              .paginate_by_max_id(
                                limit_param(DEFAULT_ACCOUNTS_LIMIT),
diff --git a/app/controllers/api/v1/notifications_controller.rb b/app/controllers/api/v1/notifications_controller.rb
index 9ff168367..fda348265 100644
--- a/app/controllers/api/v1/notifications_controller.rb
+++ b/app/controllers/api/v1/notifications_controller.rb
@@ -14,7 +14,7 @@ class Api::V1::NotificationsController < Api::BaseController
   end
 
   def show
-    @notification = current_account.notifications.find(params[:id])
+    @notification = current_account.notifications.without_suspended.find(params[:id])
     render json: @notification, serializer: REST::NotificationSerializer
   end
 
@@ -49,7 +49,7 @@ class Api::V1::NotificationsController < Api::BaseController
   end
 
   def browserable_account_notifications
-    current_account.notifications.browserable(exclude_types, from_account)
+    current_account.notifications.without_suspended.browserable(exclude_types, from_account)
   end
 
   def target_statuses_from_notifications
diff --git a/app/controllers/api/v1/push/subscriptions_controller.rb b/app/controllers/api/v1/push/subscriptions_controller.rb
index d34b333eb..0918c61e9 100644
--- a/app/controllers/api/v1/push/subscriptions_controller.rb
+++ b/app/controllers/api/v1/push/subscriptions_controller.rb
@@ -52,6 +52,6 @@ class Api::V1::Push::SubscriptionsController < Api::BaseController
   def data_params
     return {} if params[:data].blank?
 
-    params.require(:data).permit(alerts: [:follow, :follow_request, :favourite, :reblog, :mention, :poll])
+    params.require(:data).permit(alerts: [:follow, :follow_request, :favourite, :reblog, :mention, :poll, :status])
   end
 end
diff --git a/app/controllers/api/v1/statuses/favourited_by_accounts_controller.rb b/app/controllers/api/v1/statuses/favourited_by_accounts_controller.rb
index 8229786d6..2b614a837 100644
--- a/app/controllers/api/v1/statuses/favourited_by_accounts_controller.rb
+++ b/app/controllers/api/v1/statuses/favourited_by_accounts_controller.rb
@@ -22,6 +22,7 @@ class Api::V1::Statuses::FavouritedByAccountsController < Api::BaseController
 
   def default_accounts
     Account
+      .without_suspended
       .includes(:favourites, :account_stat)
       .references(:favourites)
       .where(favourites: { status_id: @status.id })
diff --git a/app/controllers/api/v1/statuses/reblogged_by_accounts_controller.rb b/app/controllers/api/v1/statuses/reblogged_by_accounts_controller.rb
index 6c9e49d90..24db30fcc 100644
--- a/app/controllers/api/v1/statuses/reblogged_by_accounts_controller.rb
+++ b/app/controllers/api/v1/statuses/reblogged_by_accounts_controller.rb
@@ -21,7 +21,7 @@ class Api::V1::Statuses::RebloggedByAccountsController < Api::BaseController
   end
 
   def default_accounts
-    Account.includes(:statuses, :account_stat).references(:statuses)
+    Account.without_suspended.includes(:statuses, :account_stat).references(:statuses)
   end
 
   def paginated_statuses
diff --git a/app/controllers/api/web/push_subscriptions_controller.rb b/app/controllers/api/web/push_subscriptions_controller.rb
index 7916b82fa..1dce3e70f 100644
--- a/app/controllers/api/web/push_subscriptions_controller.rb
+++ b/app/controllers/api/web/push_subscriptions_controller.rb
@@ -22,6 +22,7 @@ class Api::Web::PushSubscriptionsController < Api::Web::BaseController
         reblog: alerts_enabled,
         mention: alerts_enabled,
         poll: alerts_enabled,
+        status: alerts_enabled,
       },
     }
 
@@ -57,6 +58,6 @@ class Api::Web::PushSubscriptionsController < Api::Web::BaseController
   end
 
   def data_params
-    @data_params ||= params.require(:data).permit(alerts: [:follow, :follow_request, :favourite, :reblog, :mention, :poll])
+    @data_params ||= params.require(:data).permit(alerts: [:follow, :follow_request, :favourite, :reblog, :mention, :poll, :status])
   end
 end
diff --git a/app/controllers/concerns/export_controller_concern.rb b/app/controllers/concerns/export_controller_concern.rb
index bfe990c82..24cfc7a01 100644
--- a/app/controllers/concerns/export_controller_concern.rb
+++ b/app/controllers/concerns/export_controller_concern.rb
@@ -5,7 +5,6 @@ module ExportControllerConcern
 
   included do
     before_action :authenticate_user!
-    before_action :require_not_suspended!
     before_action :load_export
 
     skip_before_action :require_functional!
@@ -30,8 +29,4 @@ module ExportControllerConcern
   def export_filename
     "#{controller_name}.csv"
   end
-
-  def require_not_suspended!
-    forbidden if current_account.suspended?
-  end
 end
diff --git a/app/controllers/oauth/authorized_applications_controller.rb b/app/controllers/oauth/authorized_applications_controller.rb
index c5ccece13..b2564a791 100644
--- a/app/controllers/oauth/authorized_applications_controller.rb
+++ b/app/controllers/oauth/authorized_applications_controller.rb
@@ -6,6 +6,7 @@ class Oauth::AuthorizedApplicationsController < Doorkeeper::AuthorizedApplicatio
   before_action :store_current_location
   before_action :authenticate_resource_owner!
   before_action :set_pack
+  before_action :require_not_suspended!, only: :destroy
   before_action :set_body_classes
 
   skip_before_action :require_functional!
@@ -30,4 +31,8 @@ class Oauth::AuthorizedApplicationsController < Doorkeeper::AuthorizedApplicatio
   def set_pack
     use_pack 'settings'
   end
+
+  def require_not_suspended!
+    forbidden if current_account.suspended?
+  end
 end
diff --git a/app/controllers/settings/aliases_controller.rb b/app/controllers/settings/aliases_controller.rb
index b7c9a409d..a421b8ede 100644
--- a/app/controllers/settings/aliases_controller.rb
+++ b/app/controllers/settings/aliases_controller.rb
@@ -1,9 +1,9 @@
 # frozen_string_literal: true
 
 class Settings::AliasesController < Settings::BaseController
-  layout 'admin'
+  skip_before_action :require_functional!
 
-  before_action :authenticate_user!
+  before_action :require_not_suspended!
   before_action :set_aliases, except: :destroy
   before_action :set_alias, only: :destroy
 
diff --git a/app/controllers/settings/applications_controller.rb b/app/controllers/settings/applications_controller.rb
index ed3f82a8e..d3ac268d8 100644
--- a/app/controllers/settings/applications_controller.rb
+++ b/app/controllers/settings/applications_controller.rb
@@ -1,9 +1,6 @@
 # frozen_string_literal: true
 
 class Settings::ApplicationsController < Settings::BaseController
-  layout 'admin'
-
-  before_action :authenticate_user!
   before_action :set_application, only: [:show, :update, :destroy, :regenerate]
   before_action :prepare_scopes, only: [:create, :update]
 
diff --git a/app/controllers/settings/base_controller.rb b/app/controllers/settings/base_controller.rb
index b97603af6..dee3922d8 100644
--- a/app/controllers/settings/base_controller.rb
+++ b/app/controllers/settings/base_controller.rb
@@ -2,6 +2,9 @@
 
 class Settings::BaseController < ApplicationController
   before_action :set_pack
+  layout 'admin'
+
+  before_action :authenticate_user!
   before_action :set_body_classes
   before_action :set_cache_headers
 
@@ -18,4 +21,8 @@ class Settings::BaseController < ApplicationController
   def set_cache_headers
     response.headers['Cache-Control'] = 'no-cache, no-store, max-age=0, must-revalidate'
   end
+
+  def require_not_suspended!
+    forbidden if current_account.suspended?
+  end
 end
diff --git a/app/controllers/settings/deletes_controller.rb b/app/controllers/settings/deletes_controller.rb
index 15a59c999..f96c83b80 100644
--- a/app/controllers/settings/deletes_controller.rb
+++ b/app/controllers/settings/deletes_controller.rb
@@ -1,13 +1,10 @@
 # frozen_string_literal: true
 
 class Settings::DeletesController < Settings::BaseController
-  layout 'admin'
+  skip_before_action :require_functional!
 
-  before_action :check_enabled_deletion
-  before_action :authenticate_user!
   before_action :require_not_suspended!
-
-  skip_before_action :require_functional!
+  before_action :check_enabled_deletion
 
   def show
     @confirmation = Form::DeleteConfirmation.new
@@ -46,7 +43,7 @@ class Settings::DeletesController < Settings::BaseController
 
   def destroy_account!
     current_account.suspend!
-    Admin::SuspensionWorker.perform_async(current_user.account_id, true)
+    AccountDeletionWorker.perform_async(current_user.account_id)
     sign_out
   end
 end
diff --git a/app/controllers/settings/exports/blocked_accounts_controller.rb b/app/controllers/settings/exports/blocked_accounts_controller.rb
index 2092104e0..2190caa36 100644
--- a/app/controllers/settings/exports/blocked_accounts_controller.rb
+++ b/app/controllers/settings/exports/blocked_accounts_controller.rb
@@ -2,7 +2,7 @@
 
 module Settings
   module Exports
-    class BlockedAccountsController < ApplicationController
+    class BlockedAccountsController < BaseController
       include ExportControllerConcern
 
       def index
diff --git a/app/controllers/settings/exports/blocked_domains_controller.rb b/app/controllers/settings/exports/blocked_domains_controller.rb
index 6676ce340..bee4b2431 100644
--- a/app/controllers/settings/exports/blocked_domains_controller.rb
+++ b/app/controllers/settings/exports/blocked_domains_controller.rb
@@ -2,7 +2,7 @@
 
 module Settings
   module Exports
-    class BlockedDomainsController < ApplicationController
+    class BlockedDomainsController < BaseController
       include ExportControllerConcern
 
       def index
diff --git a/app/controllers/settings/exports/following_accounts_controller.rb b/app/controllers/settings/exports/following_accounts_controller.rb
index 74281ddca..acefcb15d 100644
--- a/app/controllers/settings/exports/following_accounts_controller.rb
+++ b/app/controllers/settings/exports/following_accounts_controller.rb
@@ -2,7 +2,7 @@
 
 module Settings
   module Exports
-    class FollowingAccountsController < ApplicationController
+    class FollowingAccountsController < BaseController
       include ExportControllerConcern
 
       def index
diff --git a/app/controllers/settings/exports/lists_controller.rb b/app/controllers/settings/exports/lists_controller.rb
index cf5a9de44..bc65f56a0 100644
--- a/app/controllers/settings/exports/lists_controller.rb
+++ b/app/controllers/settings/exports/lists_controller.rb
@@ -2,7 +2,7 @@
 
 module Settings
   module Exports
-    class ListsController < ApplicationController
+    class ListsController < BaseController
       include ExportControllerConcern
 
       def index
diff --git a/app/controllers/settings/exports/muted_accounts_controller.rb b/app/controllers/settings/exports/muted_accounts_controller.rb
index e511619ca..50b7bf1f7 100644
--- a/app/controllers/settings/exports/muted_accounts_controller.rb
+++ b/app/controllers/settings/exports/muted_accounts_controller.rb
@@ -2,7 +2,7 @@
 
 module Settings
   module Exports
-    class MutedAccountsController < ApplicationController
+    class MutedAccountsController < BaseController
       include ExportControllerConcern
 
       def index
diff --git a/app/controllers/settings/exports_controller.rb b/app/controllers/settings/exports_controller.rb
index 0e93d07a9..30138d29e 100644
--- a/app/controllers/settings/exports_controller.rb
+++ b/app/controllers/settings/exports_controller.rb
@@ -3,11 +3,6 @@
 class Settings::ExportsController < Settings::BaseController
   include Authorization
 
-  layout 'admin'
-
-  before_action :authenticate_user!
-  before_action :require_not_suspended!
-
   skip_before_action :require_functional!
 
   def show
@@ -16,8 +11,6 @@ class Settings::ExportsController < Settings::BaseController
   end
 
   def create
-    raise Mastodon::NotPermittedError unless user_signed_in?
-
     backup = nil
 
     RedisLock.acquire(lock_options) do |lock|
@@ -37,8 +30,4 @@ class Settings::ExportsController < Settings::BaseController
   def lock_options
     { redis: Redis.current, key: "backup:#{current_user.id}" }
   end
-
-  def require_not_suspended!
-    forbidden if current_account.suspended?
-  end
 end
diff --git a/app/controllers/settings/featured_tags_controller.rb b/app/controllers/settings/featured_tags_controller.rb
index e9861da56..e805527d0 100644
--- a/app/controllers/settings/featured_tags_controller.rb
+++ b/app/controllers/settings/featured_tags_controller.rb
@@ -1,9 +1,6 @@
 # frozen_string_literal: true
 
 class Settings::FeaturedTagsController < Settings::BaseController
-  layout 'admin'
-
-  before_action :authenticate_user!
   before_action :set_featured_tags, only: :index
   before_action :set_featured_tag, except: [:index, :create]
   before_action :set_recently_used_tags, only: :index
diff --git a/app/controllers/settings/identity_proofs_controller.rb b/app/controllers/settings/identity_proofs_controller.rb
index b217b3c3b..4618c7883 100644
--- a/app/controllers/settings/identity_proofs_controller.rb
+++ b/app/controllers/settings/identity_proofs_controller.rb
@@ -1,9 +1,6 @@
 # frozen_string_literal: true
 
 class Settings::IdentityProofsController < Settings::BaseController
-  layout 'admin'
-
-  before_action :authenticate_user!
   before_action :check_required_params, only: :new
   before_action :check_enabled, only: :new
 
diff --git a/app/controllers/settings/imports_controller.rb b/app/controllers/settings/imports_controller.rb
index 7b8c4ae23..d4516526e 100644
--- a/app/controllers/settings/imports_controller.rb
+++ b/app/controllers/settings/imports_controller.rb
@@ -1,9 +1,6 @@
 # frozen_string_literal: true
 
 class Settings::ImportsController < Settings::BaseController
-  layout 'admin'
-
-  before_action :authenticate_user!
   before_action :set_account
 
   def show
diff --git a/app/controllers/settings/migration/redirects_controller.rb b/app/controllers/settings/migration/redirects_controller.rb
index 97193ade0..6d469f384 100644
--- a/app/controllers/settings/migration/redirects_controller.rb
+++ b/app/controllers/settings/migration/redirects_controller.rb
@@ -1,13 +1,10 @@
 # frozen_string_literal: true
 
 class Settings::Migration::RedirectsController < Settings::BaseController
-  layout 'admin'
+  skip_before_action :require_functional!
 
-  before_action :authenticate_user!
   before_action :require_not_suspended!
 
-  skip_before_action :require_functional!
-
   def new
     @redirect = Form::Redirect.new
   end
@@ -38,8 +35,4 @@ class Settings::Migration::RedirectsController < Settings::BaseController
   def resource_params
     params.require(:form_redirect).permit(:acct, :current_password, :current_username)
   end
-
-  def require_not_suspended!
-    forbidden if current_account.suspended?
-  end
 end
diff --git a/app/controllers/settings/migrations_controller.rb b/app/controllers/settings/migrations_controller.rb
index 68304bb51..62603aba8 100644
--- a/app/controllers/settings/migrations_controller.rb
+++ b/app/controllers/settings/migrations_controller.rb
@@ -1,15 +1,12 @@
 # frozen_string_literal: true
 
 class Settings::MigrationsController < Settings::BaseController
-  layout 'admin'
+  skip_before_action :require_functional!
 
-  before_action :authenticate_user!
   before_action :require_not_suspended!
   before_action :set_migrations
   before_action :set_cooldown
 
-  skip_before_action :require_functional!
-
   def show
     @migration = current_account.migrations.build
   end
@@ -44,8 +41,4 @@ class Settings::MigrationsController < Settings::BaseController
   def on_cooldown?
     @cooldown.present?
   end
-
-  def require_not_suspended!
-    forbidden if current_account.suspended?
-  end
 end
diff --git a/app/controllers/settings/pictures_controller.rb b/app/controllers/settings/pictures_controller.rb
index df2a6eed3..28df65f8f 100644
--- a/app/controllers/settings/pictures_controller.rb
+++ b/app/controllers/settings/pictures_controller.rb
@@ -2,7 +2,6 @@
 
 module Settings
   class PicturesController < BaseController
-    before_action :authenticate_user!
     before_action :set_account
     before_action :set_picture
 
diff --git a/app/controllers/settings/preferences_controller.rb b/app/controllers/settings/preferences_controller.rb
index 01ee2ea47..f3fbd9654 100644
--- a/app/controllers/settings/preferences_controller.rb
+++ b/app/controllers/settings/preferences_controller.rb
@@ -1,10 +1,6 @@
 # frozen_string_literal: true
 
 class Settings::PreferencesController < Settings::BaseController
-  layout 'admin'
-
-  before_action :authenticate_user!
-
   def show; end
 
   def update
diff --git a/app/controllers/settings/profiles_controller.rb b/app/controllers/settings/profiles_controller.rb
index 8c4efa21d..541ba2d5d 100644
--- a/app/controllers/settings/profiles_controller.rb
+++ b/app/controllers/settings/profiles_controller.rb
@@ -1,9 +1,6 @@
 # frozen_string_literal: true
 
 class Settings::ProfilesController < Settings::BaseController
-  layout 'admin'
-
-  before_action :authenticate_user!
   before_action :set_account
 
   def show
diff --git a/app/controllers/settings/sessions_controller.rb b/app/controllers/settings/sessions_controller.rb
index f8fb4036e..ee2fc5dc8 100644
--- a/app/controllers/settings/sessions_controller.rb
+++ b/app/controllers/settings/sessions_controller.rb
@@ -1,12 +1,11 @@
 # frozen_string_literal: true
 
-#  Intentionally does not inherit from BaseController
-class Settings::SessionsController < ApplicationController
-  before_action :authenticate_user!
-  before_action :set_session, only: :destroy
-
+class Settings::SessionsController < Settings::BaseController
   skip_before_action :require_functional!
 
+  before_action :require_not_suspended!
+  before_action :set_session, only: :destroy
+
   def destroy
     @session.destroy!
     flash[:notice] = I18n.t('sessions.revoke_success')
diff --git a/app/controllers/settings/two_factor_authentication/confirmations_controller.rb b/app/controllers/settings/two_factor_authentication/confirmations_controller.rb
index 9f23011a7..1a0afe58b 100644
--- a/app/controllers/settings/two_factor_authentication/confirmations_controller.rb
+++ b/app/controllers/settings/two_factor_authentication/confirmations_controller.rb
@@ -5,14 +5,11 @@ module Settings
     class ConfirmationsController < BaseController
       include ChallengableConcern
 
-      layout 'admin'
+      skip_before_action :require_functional!
 
-      before_action :authenticate_user!
       before_action :require_challenge!
       before_action :ensure_otp_secret
 
-      skip_before_action :require_functional!
-
       def new
         prepare_two_factor_form
       end
diff --git a/app/controllers/settings/two_factor_authentication/otp_authentication_controller.rb b/app/controllers/settings/two_factor_authentication/otp_authentication_controller.rb
index 6836f7ef6..cbba842a9 100644
--- a/app/controllers/settings/two_factor_authentication/otp_authentication_controller.rb
+++ b/app/controllers/settings/two_factor_authentication/otp_authentication_controller.rb
@@ -5,14 +5,11 @@ module Settings
     class OtpAuthenticationController < BaseController
       include ChallengableConcern
 
-      layout 'admin'
+      skip_before_action :require_functional!
 
-      before_action :authenticate_user!
       before_action :verify_otp_not_enabled, only: [:show]
       before_action :require_challenge!, only: [:create]
 
-      skip_before_action :require_functional!
-
       def show
         @confirmation = Form::TwoFactorConfirmation.new
       end
diff --git a/app/controllers/settings/two_factor_authentication/recovery_codes_controller.rb b/app/controllers/settings/two_factor_authentication/recovery_codes_controller.rb
index 0c4f5bff7..6ec53224d 100644
--- a/app/controllers/settings/two_factor_authentication/recovery_codes_controller.rb
+++ b/app/controllers/settings/two_factor_authentication/recovery_codes_controller.rb
@@ -5,13 +5,10 @@ module Settings
     class RecoveryCodesController < BaseController
       include ChallengableConcern
 
-      layout 'admin'
+      skip_before_action :require_functional!
 
-      before_action :authenticate_user!
       before_action :require_challenge!, on: :create
 
-      skip_before_action :require_functional!
-
       def create
         @recovery_codes = current_user.generate_otp_backup_codes!
         current_user.save!
diff --git a/app/controllers/settings/two_factor_authentication/webauthn_credentials_controller.rb b/app/controllers/settings/two_factor_authentication/webauthn_credentials_controller.rb
index ee5392785..bd6f83134 100644
--- a/app/controllers/settings/two_factor_authentication/webauthn_credentials_controller.rb
+++ b/app/controllers/settings/two_factor_authentication/webauthn_credentials_controller.rb
@@ -3,9 +3,8 @@
 module Settings
   module TwoFactorAuthentication
     class WebauthnCredentialsController < BaseController
-      layout 'admin'
+      skip_before_action :require_functional!
 
-      before_action :authenticate_user!
       before_action :require_otp_enabled
       before_action :require_webauthn_enabled, only: [:index, :destroy]
 
diff --git a/app/controllers/settings/two_factor_authentication_methods_controller.rb b/app/controllers/settings/two_factor_authentication_methods_controller.rb
index 224d3a45c..205933ea8 100644
--- a/app/controllers/settings/two_factor_authentication_methods_controller.rb
+++ b/app/controllers/settings/two_factor_authentication_methods_controller.rb
@@ -4,14 +4,11 @@ module Settings
   class TwoFactorAuthenticationMethodsController < BaseController
     include ChallengableConcern
 
-    layout 'admin'
+    skip_before_action :require_functional!
 
-    before_action :authenticate_user!
     before_action :require_challenge!, only: :disable
     before_action :require_otp_enabled
 
-    skip_before_action :require_functional!
-
     def index; end
 
     def disable
diff --git a/app/javascript/flavours/glitch/actions/accounts.js b/app/javascript/flavours/glitch/actions/accounts.js
index 32e533bd0..24606231c 100644
--- a/app/javascript/flavours/glitch/actions/accounts.js
+++ b/app/javascript/flavours/glitch/actions/accounts.js
@@ -126,15 +126,17 @@ export function fetchAccountFail(id, error) {
   };
 };
 
-export function followAccount(id, reblogs = true) {
+export function followAccount(id, options = { reblogs: true }) {
   return (dispatch, getState) => {
     const alreadyFollowing = getState().getIn(['relationships', id, 'following']);
-    dispatch(followAccountRequest(id));
+    const locked = getState().getIn(['accounts', id, 'locked'], false);
 
-    api(getState).post(`/api/v1/accounts/${id}/follow`, { reblogs }).then(response => {
+    dispatch(followAccountRequest(id, locked));
+
+    api(getState).post(`/api/v1/accounts/${id}/follow`, options).then(response => {
       dispatch(followAccountSuccess(response.data, alreadyFollowing));
     }).catch(error => {
-      dispatch(followAccountFail(error));
+      dispatch(followAccountFail(error, locked));
     });
   };
 };
@@ -151,10 +153,12 @@ export function unfollowAccount(id) {
   };
 };
 
-export function followAccountRequest(id) {
+export function followAccountRequest(id, locked) {
   return {
     type: ACCOUNT_FOLLOW_REQUEST,
     id,
+    locked,
+    skipLoading: true,
   };
 };
 
@@ -163,13 +167,16 @@ export function followAccountSuccess(relationship, alreadyFollowing) {
     type: ACCOUNT_FOLLOW_SUCCESS,
     relationship,
     alreadyFollowing,
+    skipLoading: true,
   };
 };
 
-export function followAccountFail(error) {
+export function followAccountFail(error, locked) {
   return {
     type: ACCOUNT_FOLLOW_FAIL,
     error,
+    locked,
+    skipLoading: true,
   };
 };
 
@@ -177,6 +184,7 @@ export function unfollowAccountRequest(id) {
   return {
     type: ACCOUNT_UNFOLLOW_REQUEST,
     id,
+    skipLoading: true,
   };
 };
 
@@ -185,6 +193,7 @@ export function unfollowAccountSuccess(relationship, statuses) {
     type: ACCOUNT_UNFOLLOW_SUCCESS,
     relationship,
     statuses,
+    skipLoading: true,
   };
 };
 
@@ -192,6 +201,7 @@ export function unfollowAccountFail(error) {
   return {
     type: ACCOUNT_UNFOLLOW_FAIL,
     error,
+    skipLoading: true,
   };
 };
 
diff --git a/app/javascript/flavours/glitch/actions/markers.js b/app/javascript/flavours/glitch/actions/markers.js
index 6b49ebf88..80bcada6e 100644
--- a/app/javascript/flavours/glitch/actions/markers.js
+++ b/app/javascript/flavours/glitch/actions/markers.js
@@ -60,7 +60,7 @@ export const synchronouslySubmitMarkers = () => (dispatch, getState) => {
 const _buildParams = (state) => {
   const params = {};
 
-  const lastHomeId         = state.getIn(['timelines', 'home', 'items', 0]);
+  const lastHomeId         = state.getIn(['timelines', 'home', 'items']).find(item => item !== null);
   const lastNotificationId = state.getIn(['notifications', 'lastReadId']);
 
   if (lastHomeId && compareId(lastHomeId, state.getIn(['markers', 'home'])) > 0) {
diff --git a/app/javascript/flavours/glitch/actions/notifications.js b/app/javascript/flavours/glitch/actions/notifications.js
index ccc427c29..7f311153b 100644
--- a/app/javascript/flavours/glitch/actions/notifications.js
+++ b/app/javascript/flavours/glitch/actions/notifications.js
@@ -73,7 +73,7 @@ export function updateNotifications(notification, intlMessages, intlLocale) {
 
     let filtered = false;
 
-    if (notification.type === 'mention') {
+    if (['mention', 'status'].includes(notification.type)) {
       const dropRegex   = filters[0];
       const regex       = filters[1];
       const searchIndex = searchTextFromRawStatus(notification.status);
diff --git a/app/javascript/flavours/glitch/components/error_boundary.js b/app/javascript/flavours/glitch/components/error_boundary.js
index 8998802b1..8e6cd1461 100644
--- a/app/javascript/flavours/glitch/components/error_boundary.js
+++ b/app/javascript/flavours/glitch/components/error_boundary.js
@@ -48,6 +48,8 @@ export default class ErrorBoundary extends React.PureComponent {
 
     if (!hasError) return this.props.children;
 
+    const likelyBrowserAddonIssue = errorMessage && errorMessage.includes('NotFoundError');
+
     let debugInfo = '';
     if (stackTrace) {
       debugInfo += 'Stack trace\n-----------\n\n```\n' + errorMessage + '\n' + stackTrace.toString() + '\n```';
@@ -70,6 +72,14 @@ export default class ErrorBoundary extends React.PureComponent {
             <FormattedMessage id='web_app_crash.content' defaultMessage='You could try any of the following:' />
           </p>
           <ul>
+            { likelyBrowserAddonIssue && (
+              <li>
+                <FormattedMessage
+                  id='web_app_crash.disable_addons'
+                  defaultMessage='Disable browser add-ons or built-in translation tools'
+                />
+              </li>
+            ) }
             <li>
               <FormattedMessage
                 id='web_app_crash.report_issue'
diff --git a/app/javascript/flavours/glitch/components/status.js b/app/javascript/flavours/glitch/components/status.js
index 25d98554a..cb0e12de6 100644
--- a/app/javascript/flavours/glitch/components/status.js
+++ b/app/javascript/flavours/glitch/components/status.js
@@ -685,6 +685,7 @@ class Status extends ImmutablePureComponent {
         favourite: 'favourited',
         reblog: 'boosted',
         reblogged_by: 'boosted',
+        status: 'posted',
       }[prepend];
 
       selectorAttribs[`data-${notifKind}-by`] = `@${account.get('acct')}`;
diff --git a/app/javascript/flavours/glitch/components/status_prepend.js b/app/javascript/flavours/glitch/components/status_prepend.js
index 637c4f23a..af6acdef9 100644
--- a/app/javascript/flavours/glitch/components/status_prepend.js
+++ b/app/javascript/flavours/glitch/components/status_prepend.js
@@ -64,6 +64,14 @@ export default class StatusPrepend extends React.PureComponent {
           values={{ name : link }}
         />
       );
+    case 'status':
+      return (
+        <FormattedMessage
+          id='notification.status'
+          defaultMessage='{name} just posted'
+          values={{ name: link }}
+        />
+      );
     case 'poll':
       if (me === account.get('id')) {
         return (
@@ -88,12 +96,33 @@ export default class StatusPrepend extends React.PureComponent {
     const { Message } = this;
     const { type } = this.props;
 
+    let iconId;
+
+    switch(type) {
+    case 'favourite':
+      iconId = 'star';
+      break;
+    case 'featured':
+      iconId = 'thumb-tack';
+      break;
+    case 'poll':
+      iconId = 'tasks';
+      break;
+    case 'reblog':
+    case 'reblogged_by':
+      iconId = 'retweet';
+      break;
+    case 'status':
+      iconId = 'bell';
+      break;
+    };
+
     return !type ? null : (
       <aside className={type === 'reblogged_by' || type === 'featured' ? 'status__prepend' : 'notification__message'}>
         <div className={type === 'reblogged_by' || type === 'featured' ? 'status__prepend-icon-wrapper' : 'notification__favourite-icon-wrapper'}>
           <Icon
             className={`status__prepend-icon ${type === 'favourite' ? 'star-icon' : ''}`}
-            id={type === 'favourite' ? 'star' : (type === 'featured' ? 'thumb-tack' : (type === 'poll' ? 'tasks' : 'retweet'))}
+            id={iconId}
           />
         </div>
         <Message />
diff --git a/app/javascript/flavours/glitch/features/account/components/action_bar.js b/app/javascript/flavours/glitch/features/account/components/action_bar.js
index 2f5a943fd..0f1b83b2d 100644
--- a/app/javascript/flavours/glitch/features/account/components/action_bar.js
+++ b/app/javascript/flavours/glitch/features/account/components/action_bar.js
@@ -26,6 +26,19 @@ class ActionBar extends React.PureComponent {
   render () {
     const { account, intl } = this.props;
 
+    if (account.get('suspended')) {
+      return (
+        <div>
+          <div className='account__disclaimer'>
+            <Icon id='info-circle' fixedWidth /> <FormattedMessage
+              id='account.suspended_disclaimer_full'
+              defaultMessage="This user has been suspended by a moderator."
+            />
+          </div>
+        </div>
+      );
+    }
+
     let extraInfo = '';
 
     if (account.get('acct') !== account.get('username')) {
diff --git a/app/javascript/flavours/glitch/features/account/components/header.js b/app/javascript/flavours/glitch/features/account/components/header.js
index 0af0935e6..9b080a14e 100644
--- a/app/javascript/flavours/glitch/features/account/components/header.js
+++ b/app/javascript/flavours/glitch/features/account/components/header.js
@@ -7,6 +7,7 @@ import { autoPlayGif, me, isStaff } from 'flavours/glitch/util/initial_state';
 import { preferencesLink, profileLink, accountAdminLink } from 'flavours/glitch/util/backend_links';
 import classNames from 'classnames';
 import Icon from 'flavours/glitch/components/icon';
+import IconButton from 'flavours/glitch/components/icon_button';
 import Avatar from 'flavours/glitch/components/avatar';
 import Button from 'flavours/glitch/components/button';
 import { NavLink } from 'react-router-dom';
@@ -34,6 +35,8 @@ const messages = defineMessages({
   unblockDomain: { id: 'account.unblock_domain', defaultMessage: 'Unblock domain {domain}' },
   hideReblogs: { id: 'account.hide_reblogs', defaultMessage: 'Hide boosts from @{name}' },
   showReblogs: { id: 'account.show_reblogs', defaultMessage: 'Show boosts from @{name}' },
+  enableNotifications: { id: 'account.enable_notifications', defaultMessage: 'Notify me when @{name} posts' },
+  disableNotifications: { id: 'account.disable_notifications', defaultMessage: 'Stop notifying me when @{name} posts' },
   pins: { id: 'navigation_bar.pins', defaultMessage: 'Pinned toots' },
   preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' },
   follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' },
@@ -68,8 +71,9 @@ class Header extends ImmutablePureComponent {
     onBlock: PropTypes.func.isRequired,
     onMention: PropTypes.func.isRequired,
     onDirect: PropTypes.func.isRequired,
-    onReport: PropTypes.func.isRequired,
     onReblogToggle: PropTypes.func.isRequired,
+    onNotifyToggle: PropTypes.func.isRequired,
+    onReport: PropTypes.func.isRequired,
     onMute: PropTypes.func.isRequired,
     onBlockDomain: PropTypes.func.isRequired,
     onUnblockDomain: PropTypes.func.isRequired,
@@ -134,8 +138,11 @@ class Header extends ImmutablePureComponent {
 
     const accountNote = account.getIn(['relationship', 'note']);
 
+    const suspended = account.get('suspended');
+
     let info        = [];
     let actionBtn   = '';
+    let bellBtn     = '';
     let lockedIcon  = '';
     let menu        = [];
 
@@ -166,21 +173,29 @@ class Header extends ImmutablePureComponent {
       actionBtn = <Button className='logo-button' text={intl.formatMessage(messages.edit_profile)} onClick={this.openEditProfile} />;
     }
 
+    if (account.getIn(['relationship', 'requested']) || account.getIn(['relationship', 'following'])) {
+      bellBtn = <IconButton icon='bell-o' size={24} active={account.getIn(['relationship', 'notifying'])} title={intl.formatMessage(account.getIn(['relationship', 'notifying']) ? messages.disableNotifications : messages.enableNotifications, { name: account.get('username') })} onClick={this.props.onNotifyToggle} />;
+    }
+
     if (account.get('moved') && !account.getIn(['relationship', 'following'])) {
       actionBtn = '';
     }
 
+    if (suspended && !account.getIn(['relationship', 'following'])) {
+      actionBtn = '';
+    }
+
     if (account.get('locked')) {
       lockedIcon = <Icon id='lock' title={intl.formatMessage(messages.account_locked)} />;
     }
 
-    if (account.get('id') !== me) {
+    if (account.get('id') !== me && !suspended) {
       menu.push({ text: intl.formatMessage(messages.mention, { name: account.get('username') }), action: this.props.onMention });
       menu.push({ text: intl.formatMessage(messages.direct, { name: account.get('username') }), action: this.props.onDirect });
       menu.push(null);
     }
 
-    if ('share' in navigator) {
+    if ('share' in navigator && !suspended) {
       menu.push({ text: intl.formatMessage(messages.share, { name: account.get('username') }), action: this.handleShare });
       menu.push(null);
     }
@@ -283,6 +298,7 @@ class Header extends ImmutablePureComponent {
 
             <div className='account__header__tabs__buttons'>
               {actionBtn}
+              {bellBtn}
 
               <DropdownMenuContainer items={menu} icon='ellipsis-v' size={24} direction='right' />
             </div>
@@ -297,39 +313,41 @@ class Header extends ImmutablePureComponent {
 
           <AccountNoteContainer account={account} />
 
-          <div className='account__header__extra'>
-            <div className='account__header__bio'>
-              { (fields.size > 0 || identity_proofs.size > 0) && (
-                <div className='account__header__fields'>
-                  {identity_proofs.map((proof, i) => (
-                    <dl key={i}>
-                      <dt dangerouslySetInnerHTML={{ __html: proof.get('provider') }} />
-
-                      <dd className='verified'>
-                        <a href={proof.get('proof_url')} target='_blank' rel='noopener noreferrer'><span title={intl.formatMessage(messages.linkVerifiedOn, { date: intl.formatDate(proof.get('updated_at'), dateFormatOptions) })}>
-                          <Icon id='check' className='verified__mark' />
-                        </span></a>
-                        <a href={proof.get('profile_url')} target='_blank' rel='noopener noreferrer'><span dangerouslySetInnerHTML={{ __html: ' '+proof.get('provider_username') }} /></a>
-                      </dd>
-                    </dl>
-                  ))}
-                  {fields.map((pair, i) => (
-                    <dl key={i}>
-                      <dt dangerouslySetInnerHTML={{ __html: pair.get('name_emojified') }} title={pair.get('name')} />
- 
-                      <dd className={pair.get('verified_at') && 'verified'} title={pair.get('value_plain')}>
-                        {pair.get('verified_at') && <span title={intl.formatMessage(messages.linkVerifiedOn, { date: intl.formatDate(pair.get('verified_at'), dateFormatOptions) })}><Icon id='check' className='verified__mark' /></span>} <span dangerouslySetInnerHTML={{ __html: pair.get('value_emojified') }} />
-                      </dd>
-                    </dl>
-                  ))}
-                </div>
-              )}
-
-              {account.get('note').length > 0 && account.get('note') !== '<p></p>' && <div className='account__header__content' dangerouslySetInnerHTML={content} />}
+          {!suspended && (
+            <div className='account__header__extra'>
+              <div className='account__header__bio'>
+                { (fields.size > 0 || identity_proofs.size > 0) && (
+                  <div className='account__header__fields'>
+                    {identity_proofs.map((proof, i) => (
+                      <dl key={i}>
+                        <dt dangerouslySetInnerHTML={{ __html: proof.get('provider') }} />
+
+                        <dd className='verified'>
+                          <a href={proof.get('proof_url')} target='_blank' rel='noopener noreferrer'><span title={intl.formatMessage(messages.linkVerifiedOn, { date: intl.formatDate(proof.get('updated_at'), dateFormatOptions) })}>
+                            <Icon id='check' className='verified__mark' />
+                          </span></a>
+                          <a href={proof.get('profile_url')} target='_blank' rel='noopener noreferrer'><span dangerouslySetInnerHTML={{ __html: ' '+proof.get('provider_username') }} /></a>
+                        </dd>
+                      </dl>
+                    ))}
+                    {fields.map((pair, i) => (
+                      <dl key={i}>
+                        <dt dangerouslySetInnerHTML={{ __html: pair.get('name_emojified') }} title={pair.get('name')} />
+   
+                        <dd className={pair.get('verified_at') && 'verified'} title={pair.get('value_plain')}>
+                          {pair.get('verified_at') && <span title={intl.formatMessage(messages.linkVerifiedOn, { date: intl.formatDate(pair.get('verified_at'), dateFormatOptions) })}><Icon id='check' className='verified__mark' /></span>} <span dangerouslySetInnerHTML={{ __html: pair.get('value_emojified') }} />
+                        </dd>
+                      </dl>
+                    ))}
+                  </div>
+                )}
+
+                {account.get('note').length > 0 && account.get('note') !== '<p></p>' && <div className='account__header__content' dangerouslySetInnerHTML={content} />}
+              </div>
             </div>
-         </div>
-       </div>
-     </div>
+          )}
+        </div>
+      </div>
     );
   }
 
diff --git a/app/javascript/flavours/glitch/features/account_gallery/index.js b/app/javascript/flavours/glitch/features/account_gallery/index.js
index 040741c2a..fda8082cc 100644
--- a/app/javascript/flavours/glitch/features/account_gallery/index.js
+++ b/app/javascript/flavours/glitch/features/account_gallery/index.js
@@ -21,6 +21,7 @@ const mapStateToProps = (state, props) => ({
   attachments: getAccountGallery(state, props.params.accountId),
   isLoading: state.getIn(['timelines', `account:${props.params.accountId}:media`, 'isLoading']),
   hasMore: state.getIn(['timelines', `account:${props.params.accountId}:media`, 'hasMore']),
+  suspended: state.getIn(['accounts', props.params.accountId, 'suspended'], false),
 });
 
 class LoadMoreMedia extends ImmutablePureComponent {
@@ -56,6 +57,7 @@ class AccountGallery extends ImmutablePureComponent {
     hasMore: PropTypes.bool,
     isAccount: PropTypes.bool,
     multiColumn: PropTypes.bool,
+    suspended: PropTypes.bool,
   };
 
   state = {
@@ -131,7 +133,7 @@ class AccountGallery extends ImmutablePureComponent {
   }
 
   render () {
-    const { attachments, isLoading, hasMore, isAccount, multiColumn } = this.props;
+    const { attachments, isLoading, hasMore, isAccount, multiColumn, suspended } = this.props;
     const { width } = this.state;
 
     if (!isAccount) {
@@ -164,15 +166,21 @@ class AccountGallery extends ImmutablePureComponent {
           <div className='scrollable scrollable--flex' onScroll={this.handleScroll}>
             <HeaderContainer accountId={this.props.params.accountId} />
 
-            <div role='feed' className='account-gallery__container' ref={this.handleRef}>
-              {attachments.map((attachment, index) => attachment === null ? (
-                <LoadMoreMedia key={'more:' + attachments.getIn(index + 1, 'id')} maxId={index > 0 ? attachments.getIn(index - 1, 'id') : null} onLoadMore={this.handleLoadMore} />
-              ) : (
-                <MediaItem key={attachment.get('id')} attachment={attachment} displayWidth={width} onOpenMedia={this.handleOpenMedia} />
-              ))}
-
-              {loadOlder}
-            </div>
+            {suspended ? (
+              <div className='empty-column-indicator'>
+                <FormattedMessage id='empty_column.account_unavailable' defaultMessage='Profile unavailable' />
+              </div>
+            ) : (
+              <div role='feed' className='account-gallery__container' ref={this.handleRef}>
+                {attachments.map((attachment, index) => attachment === null ? (
+                  <LoadMoreMedia key={'more:' + attachments.getIn(index + 1, 'id')} maxId={index > 0 ? attachments.getIn(index - 1, 'id') : null} onLoadMore={this.handleLoadMore} />
+                ) : (
+                  <MediaItem key={attachment.get('id')} attachment={attachment} displayWidth={width} onOpenMedia={this.handleOpenMedia} />
+                ))}
+
+                {loadOlder}
+              </div>
+            )}
 
             {isLoading && attachments.size === 0 && (
               <div className='scrollable__append'>
diff --git a/app/javascript/flavours/glitch/features/account_timeline/components/header.js b/app/javascript/flavours/glitch/features/account_timeline/components/header.js
index 591f8dffc..d399d4aa9 100644
--- a/app/javascript/flavours/glitch/features/account_timeline/components/header.js
+++ b/app/javascript/flavours/glitch/features/account_timeline/components/header.js
@@ -57,6 +57,10 @@ export default class Header extends ImmutablePureComponent {
     this.props.onReblogToggle(this.props.account);
   }
 
+  handleNotifyToggle = () => {
+    this.props.onNotifyToggle(this.props.account);
+  }
+
   handleMute = () => {
     this.props.onMute(this.props.account);
   }
@@ -108,6 +112,7 @@ export default class Header extends ImmutablePureComponent {
           onMention={this.handleMention}
           onDirect={this.handleDirect}
           onReblogToggle={this.handleReblogToggle}
+          onNotifyToggle={this.handleNotifyToggle}
           onReport={this.handleReport}
           onMute={this.handleMute}
           onBlockDomain={this.handleBlockDomain}
diff --git a/app/javascript/flavours/glitch/features/account_timeline/containers/header_container.js b/app/javascript/flavours/glitch/features/account_timeline/containers/header_container.js
index 225910292..90e746679 100644
--- a/app/javascript/flavours/glitch/features/account_timeline/containers/header_container.js
+++ b/app/javascript/flavours/glitch/features/account_timeline/containers/header_container.js
@@ -81,9 +81,9 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
 
   onReblogToggle (account) {
     if (account.getIn(['relationship', 'showing_reblogs'])) {
-      dispatch(followAccount(account.get('id'), false));
+      dispatch(followAccount(account.get('id'), { reblogs: false }));
     } else {
-      dispatch(followAccount(account.get('id'), true));
+      dispatch(followAccount(account.get('id'), { reblogs: true }));
     }
   },
 
@@ -95,6 +95,14 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
     }
   },
 
+  onNotifyToggle (account) {
+    if (account.getIn(['relationship', 'notifying'])) {
+      dispatch(followAccount(account.get('id'), { notify: false }));
+    } else {
+      dispatch(followAccount(account.get('id'), { notify: true }));
+    }
+  },
+
   onReport (account) {
     dispatch(initReport(account));
   },
diff --git a/app/javascript/flavours/glitch/features/account_timeline/index.js b/app/javascript/flavours/glitch/features/account_timeline/index.js
index 66bf55ec4..c88f6ac89 100644
--- a/app/javascript/flavours/glitch/features/account_timeline/index.js
+++ b/app/javascript/flavours/glitch/features/account_timeline/index.js
@@ -17,6 +17,8 @@ import { fetchAccountIdentityProofs } from '../../actions/identity_proofs';
 import MissingIndicator from 'flavours/glitch/components/missing_indicator';
 import TimelineHint from 'flavours/glitch/components/timeline_hint';
 
+const emptyList = ImmutableList();
+
 const mapStateToProps = (state, { params: { accountId }, filter = '' }) => {
   const path = `${accountId}${filter}`;
 
@@ -28,6 +30,7 @@ const mapStateToProps = (state, { params: { accountId }, filter = '' }) => {
     featuredStatusIds: !filter ? state.getIn(['timelines', `account:${accountId}:pinned`, 'items'], ImmutableList()) : ImmutableList(),
     isLoading: state.getIn(['timelines', `account:${path}`, 'isLoading']),
     hasMore:   state.getIn(['timelines', `account:${path}`, 'hasMore']),
+    suspended: state.getIn(['accounts', accountId, 'suspended'], false),
   };
 };
 
@@ -51,6 +54,7 @@ class AccountTimeline extends ImmutablePureComponent {
     hasMore: PropTypes.bool,
     filter: PropTypes.string,
     isAccount: PropTypes.bool,
+    suspended: PropTypes.bool,
     remote: PropTypes.bool,
     remoteUrl: PropTypes.string,
     multiColumn: PropTypes.bool,
@@ -91,7 +95,7 @@ class AccountTimeline extends ImmutablePureComponent {
   }
 
   render () {
-    const { statusIds, featuredStatusIds, isLoading, hasMore, isAccount, multiColumn, remote, remoteUrl } = this.props;
+    const { statusIds, featuredStatusIds, isLoading, hasMore, suspended, isAccount, multiColumn, remote, remoteUrl } = this.props;
 
     if (!isAccount) {
       return (
@@ -112,7 +116,9 @@ class AccountTimeline extends ImmutablePureComponent {
 
     let emptyMessage;
 
-    if (remote && statusIds.isEmpty()) {
+    if (suspended) {
+      emptyMessage = <FormattedMessage id='empty_column.account_unavailable' defaultMessage='Profile unavailable' />;
+    } else if (remote && statusIds.isEmpty()) {
       emptyMessage = <RemoteHint url={remoteUrl} />;
     } else {
       emptyMessage = <FormattedMessage id='empty_column.account_timeline' defaultMessage='No toots here!' />;
@@ -129,7 +135,7 @@ class AccountTimeline extends ImmutablePureComponent {
           alwaysPrepend
           append={remoteMessage}
           scrollKey='account_timeline'
-          statusIds={statusIds}
+          statusIds={suspended ? emptyList : statusIds}
           featuredStatusIds={featuredStatusIds}
           isLoading={isLoading}
           hasMore={hasMore}
diff --git a/app/javascript/flavours/glitch/features/notifications/components/filter_bar.js b/app/javascript/flavours/glitch/features/notifications/components/filter_bar.js
index 6118305d6..c1de0f90e 100644
--- a/app/javascript/flavours/glitch/features/notifications/components/filter_bar.js
+++ b/app/javascript/flavours/glitch/features/notifications/components/filter_bar.js
@@ -9,6 +9,7 @@ const tooltips = defineMessages({
   boosts: { id: 'notifications.filter.boosts', defaultMessage: 'Boosts' },
   polls: { id: 'notifications.filter.polls', defaultMessage: 'Poll results' },
   follows: { id: 'notifications.filter.follows', defaultMessage: 'Follows' },
+  statuses: { id: 'notifications.filter.statuses', defaultMessage: 'Updates from people you follow' },
 });
 
 export default @injectIntl
@@ -88,6 +89,13 @@ class FilterBar extends React.PureComponent {
           <Icon id='tasks' fixedWidth />
         </button>
         <button
+          className={selectedFilter === 'status' ? 'active' : ''}
+          onClick={this.onClick('status')}
+          title={intl.formatMessage(tooltips.statuses)}
+        >
+          <Icon id='home' fixedWidth />
+        </button>
+        <button
           className={selectedFilter === 'follow' ? 'active' : ''}
           onClick={this.onClick('follow')}
           title={intl.formatMessage(tooltips.follows)}
diff --git a/app/javascript/flavours/glitch/features/notifications/components/notification.js b/app/javascript/flavours/glitch/features/notifications/components/notification.js
index bd415856c..e1d9fbd0a 100644
--- a/app/javascript/flavours/glitch/features/notifications/components/notification.js
+++ b/app/javascript/flavours/glitch/features/notifications/components/notification.js
@@ -83,6 +83,28 @@ export default class Notification extends ImmutablePureComponent {
           unread={this.props.unread}
         />
       );
+    case 'status':
+      return (
+        <StatusContainer
+          containerId={notification.get('id')}
+          hidden={hidden}
+          id={notification.get('status')}
+          account={notification.get('account')}
+          prepend='status'
+          muted
+          notification={notification}
+          onMoveDown={onMoveDown}
+          onMoveUp={onMoveUp}
+          onMention={onMention}
+          getScrollPosition={getScrollPosition}
+          updateScrollBottom={updateScrollBottom}
+          cachedMediaWidth={this.props.cachedMediaWidth}
+          cacheMediaWidth={this.props.cacheMediaWidth}
+          onUnmount={this.props.onUnmount}
+          withDismiss
+          unread={this.props.unread}
+        />
+      );
     case 'favourite':
       return (
         <StatusContainer
diff --git a/app/javascript/flavours/glitch/features/notifications/index.js b/app/javascript/flavours/glitch/features/notifications/index.js
index 681323860..475968caa 100644
--- a/app/javascript/flavours/glitch/features/notifications/index.js
+++ b/app/javascript/flavours/glitch/features/notifications/index.js
@@ -48,7 +48,7 @@ const getNotifications = createSelector([
     // we need to turn it off for FilterBar in order not to block ourselves from seeing a specific category
     return notifications.filterNot(item => item !== null && excludedTypes.includes(item.get('type')));
   }
-  return notifications.filter(item => item !== null && allowedType === item.get('type'));
+  return notifications.filter(item => item === null || allowedType === item.get('type'));
 });
 
 const mapStateToProps = state => ({
diff --git a/app/javascript/flavours/glitch/features/ui/index.js b/app/javascript/flavours/glitch/features/ui/index.js
index 2366226ac..9b0ff2426 100644
--- a/app/javascript/flavours/glitch/features/ui/index.js
+++ b/app/javascript/flavours/glitch/features/ui/index.js
@@ -366,21 +366,6 @@ class UI extends React.Component {
   }
 
   componentWillMount () {
-    if (typeof document.hidden !== 'undefined') { // Opera 12.10 and Firefox 18 and later support
-      this.visibilityHiddenProp = 'hidden';
-      this.visibilityChange = 'visibilitychange';
-    } else if (typeof document.msHidden !== 'undefined') {
-      this.visibilityHiddenProp = 'msHidden';
-      this.visibilityChange = 'msvisibilitychange';
-    } else if (typeof document.webkitHidden !== 'undefined') {
-      this.visibilityHiddenProp = 'webkitHidden';
-      this.visibilityChange = 'webkitvisibilitychange';
-    }
-    if (this.visibilityChange !== undefined) {
-      document.addEventListener(this.visibilityChange, this.handleVisibilityChange, false);
-      this.handleVisibilityChange();
-    }
-
     window.addEventListener('beforeunload', this.handleBeforeUnload, false);
     document.addEventListener('dragenter', this.handleDragEnter, false);
     document.addEventListener('dragover', this.handleDragOver, false);
@@ -404,6 +389,22 @@ class UI extends React.Component {
     this.hotkeys.__mousetrap__.stopCallback = (e, element) => {
       return ['TEXTAREA', 'SELECT', 'INPUT'].includes(element.tagName) && !e.altKey;
     };
+
+    if (typeof document.hidden !== 'undefined') { // Opera 12.10 and Firefox 18 and later support
+      this.visibilityHiddenProp = 'hidden';
+      this.visibilityChange = 'visibilitychange';
+    } else if (typeof document.msHidden !== 'undefined') {
+      this.visibilityHiddenProp = 'msHidden';
+      this.visibilityChange = 'msvisibilitychange';
+    } else if (typeof document.webkitHidden !== 'undefined') {
+      this.visibilityHiddenProp = 'webkitHidden';
+      this.visibilityChange = 'webkitvisibilitychange';
+    }
+
+    if (this.visibilityChange !== undefined) {
+      document.addEventListener(this.visibilityChange, this.handleVisibilityChange, false);
+      this.handleVisibilityChange();
+    }
   }
 
   componentDidUpdate (prevProps) {
diff --git a/app/javascript/flavours/glitch/reducers/relationships.js b/app/javascript/flavours/glitch/reducers/relationships.js
index dcaeefcae..33eb5b425 100644
--- a/app/javascript/flavours/glitch/reducers/relationships.js
+++ b/app/javascript/flavours/glitch/reducers/relationships.js
@@ -1,6 +1,10 @@
 import {
   ACCOUNT_FOLLOW_SUCCESS,
+  ACCOUNT_FOLLOW_REQUEST,
+  ACCOUNT_FOLLOW_FAIL,
   ACCOUNT_UNFOLLOW_SUCCESS,
+  ACCOUNT_UNFOLLOW_REQUEST,
+  ACCOUNT_UNFOLLOW_FAIL,
   ACCOUNT_BLOCK_SUCCESS,
   ACCOUNT_UNBLOCK_SUCCESS,
   ACCOUNT_MUTE_SUCCESS,
@@ -40,6 +44,14 @@ const initialState = ImmutableMap();
 
 export default function relationships(state = initialState, action) {
   switch(action.type) {
+  case ACCOUNT_FOLLOW_REQUEST:
+    return state.setIn([action.id, action.locked ? 'requested' : 'following'], true);
+  case ACCOUNT_FOLLOW_FAIL:
+    return state.setIn([action.id, action.locked ? 'requested' : 'following'], false);
+  case ACCOUNT_UNFOLLOW_REQUEST:
+    return state.setIn([action.id, 'following'], false);
+  case ACCOUNT_UNFOLLOW_FAIL:
+    return state.setIn([action.id, 'following'], true);
   case ACCOUNT_FOLLOW_SUCCESS:
   case ACCOUNT_UNFOLLOW_SUCCESS:
   case ACCOUNT_BLOCK_SUCCESS:
diff --git a/app/javascript/flavours/glitch/styles/components/accounts.scss b/app/javascript/flavours/glitch/styles/components/accounts.scss
index 0fc2a11ff..145219fa7 100644
--- a/app/javascript/flavours/glitch/styles/components/accounts.scss
+++ b/app/javascript/flavours/glitch/styles/components/accounts.scss
@@ -620,6 +620,10 @@
         padding: 2px;
       }
 
+      & > .icon-button {
+        margin-right: 8px;
+      }
+
       .button {
         margin: 0 8px;
       }
diff --git a/app/javascript/flavours/glitch/styles/contrast/diff.scss b/app/javascript/flavours/glitch/styles/contrast/diff.scss
index f78e60597..0f3a6cc6d 100644
--- a/app/javascript/flavours/glitch/styles/contrast/diff.scss
+++ b/app/javascript/flavours/glitch/styles/contrast/diff.scss
@@ -75,3 +75,12 @@
 .public-layout .public-account-header__tabs__tabs .counter.active::after {
   border-bottom: 4px solid $ui-highlight-color;
 }
+
+.composer {
+  .composer--spoiler input,
+  .compose-form__autosuggest-wrapper textarea {
+    &::placeholder {
+      color: $inverted-text-color;
+    }
+  }
+}
diff --git a/app/javascript/flavours/glitch/util/emoji/index.js b/app/javascript/flavours/glitch/util/emoji/index.js
index a59e17ddb..233ec25e3 100644
--- a/app/javascript/flavours/glitch/util/emoji/index.js
+++ b/app/javascript/flavours/glitch/util/emoji/index.js
@@ -12,7 +12,7 @@ const emojiFilenames = (emojis) => {
 };
 
 // Emoji requiring extra borders depending on theme
-const darkEmoji = emojiFilenames(['🎱', '🐜', '⚫', '🖤', '⬛', '◼️', '◾', '◼️', '✒️', '▪️', '💣', '🎳', '📷', '📸', '♣️', '🕶️', '✴️', '🔌', '💂‍♀️', '📽️', '🍳', '🦍', '💂', '🔪', '🕳️', '🕹️', '🕋', '🖊️', '🖋️', '💂‍♂️', '🎤', '🎓', '🎥', '🎼', '♠️', '🎩', '🦃', '📼', '📹', '🎮', '🐃', '🏴', '🐞']);
+const darkEmoji = emojiFilenames(['🎱', '🐜', '⚫', '🖤', '⬛', '◼️', '◾', '◼️', '✒️', '▪️', '💣', '🎳', '📷', '📸', '♣️', '🕶️', '✴️', '🔌', '💂‍♀️', '📽️', '🍳', '🦍', '💂', '🔪', '🕳️', '🕹️', '🕋', '🖊️', '🖋️', '💂‍♂️', '🎤', '🎓', '🎥', '🎼', '♠️', '🎩', '🦃', '📼', '📹', '🎮', '🐃', '🏴', '🐞', '🕺']);
 const lightEmoji = emojiFilenames(['👽', '⚾', '🐔', '☁️', '💨', '🕊️', '👀', '🍥', '👻', '🐐', '❕', '❔', '⛸️', '🌩️', '🔊', '🔇', '📃', '🌧️', '🐏', '🍚', '🍙', '🐓', '🐑', '💀', '☠️', '🌨️', '🔉', '🔈', '💬', '💭', '🏐', '🏳️', '⚪', '⬜', '◽', '◻️', '▫️']);
 
 const emojiFilename = (filename) => {
diff --git a/app/javascript/mastodon/actions/accounts.js b/app/javascript/mastodon/actions/accounts.js
index d28f7dad8..723c04e55 100644
--- a/app/javascript/mastodon/actions/accounts.js
+++ b/app/javascript/mastodon/actions/accounts.js
@@ -109,14 +109,14 @@ export function fetchAccountFail(id, error) {
   };
 };
 
-export function followAccount(id, reblogs = true) {
+export function followAccount(id, options = { reblogs: true }) {
   return (dispatch, getState) => {
     const alreadyFollowing = getState().getIn(['relationships', id, 'following']);
     const locked = getState().getIn(['accounts', id, 'locked'], false);
 
     dispatch(followAccountRequest(id, locked));
 
-    api(getState).post(`/api/v1/accounts/${id}/follow`, { reblogs }).then(response => {
+    api(getState).post(`/api/v1/accounts/${id}/follow`, options).then(response => {
       dispatch(followAccountSuccess(response.data, alreadyFollowing));
     }).catch(error => {
       dispatch(followAccountFail(error, locked));
diff --git a/app/javascript/mastodon/actions/markers.js b/app/javascript/mastodon/actions/markers.js
index 37d1ddccf..41a95503e 100644
--- a/app/javascript/mastodon/actions/markers.js
+++ b/app/javascript/mastodon/actions/markers.js
@@ -3,6 +3,9 @@ import { debounce } from 'lodash';
 import compareId from '../compare_id';
 import { showAlertForError } from './alerts';
 
+export const MARKERS_FETCH_REQUEST = 'MARKERS_FETCH_REQUEST';
+export const MARKERS_FETCH_SUCCESS = 'MARKERS_FETCH_SUCCESS';
+export const MARKERS_FETCH_FAIL    = 'MARKERS_FETCH_FAIL';
 export const MARKERS_SUBMIT_SUCCESS = 'MARKERS_SUBMIT_SUCCESS';
 
 export const synchronouslySubmitMarkers = () => (dispatch, getState) => {
@@ -57,8 +60,8 @@ export const synchronouslySubmitMarkers = () => (dispatch, getState) => {
 const _buildParams = (state) => {
   const params = {};
 
-  const lastHomeId         = state.getIn(['timelines', 'home', 'items', 0]);
-  const lastNotificationId = state.getIn(['notifications', 'items', 0, 'id']);
+  const lastHomeId         = state.getIn(['timelines', 'home', 'items']).find(item => item !== null);
+  const lastNotificationId = state.getIn(['notifications', 'lastReadId']);
 
   if (lastHomeId && compareId(lastHomeId, state.getIn(['markers', 'home'])) > 0) {
     params.home = {
@@ -100,3 +103,39 @@ export function submitMarkersSuccess({ home, notifications }) {
 export function submitMarkers() {
   return (dispatch, getState) => debouncedSubmitMarkers(dispatch, getState);
 };
+
+export const fetchMarkers = () => (dispatch, getState) => {
+  const params = { timeline: ['notifications'] };
+
+  dispatch(fetchMarkersRequest());
+
+  api(getState).get('/api/v1/markers', { params }).then(response => {
+    dispatch(fetchMarkersSuccess(response.data));
+  }).catch(error => {
+    dispatch(fetchMarkersFail(error));
+  });
+};
+
+export function fetchMarkersRequest() {
+  return {
+    type: MARKERS_FETCH_REQUEST,
+    skipLoading: true,
+  };
+};
+
+export function fetchMarkersSuccess(markers) {
+  return {
+    type: MARKERS_FETCH_SUCCESS,
+    markers,
+    skipLoading: true,
+  };
+};
+
+export function fetchMarkersFail(error) {
+  return {
+    type: MARKERS_FETCH_FAIL,
+    error,
+    skipLoading: true,
+    skipAlert: true,
+  };
+};
diff --git a/app/javascript/mastodon/actions/notifications.js b/app/javascript/mastodon/actions/notifications.js
index a26844f84..552def63b 100644
--- a/app/javascript/mastodon/actions/notifications.js
+++ b/app/javascript/mastodon/actions/notifications.js
@@ -33,6 +33,8 @@ export const NOTIFICATIONS_LOAD_PENDING = 'NOTIFICATIONS_LOAD_PENDING';
 export const NOTIFICATIONS_MOUNT   = 'NOTIFICATIONS_MOUNT';
 export const NOTIFICATIONS_UNMOUNT = 'NOTIFICATIONS_UNMOUNT';
 
+export const NOTIFICATIONS_MARK_AS_READ = 'NOTIFICATIONS_MARK_AS_READ';
+
 defineMessages({
   mention: { id: 'notification.mention', defaultMessage: '{name} mentioned you' },
   group: { id: 'notifications.group', defaultMessage: '{count} notifications' },
@@ -59,7 +61,7 @@ export function updateNotifications(notification, intlMessages, intlLocale) {
 
     let filtered = false;
 
-    if (notification.type === 'mention') {
+    if (['mention', 'status'].includes(notification.type)) {
       const dropRegex   = filters[0];
       const regex       = filters[1];
       const searchIndex = searchTextFromRawStatus(notification.status);
diff --git a/app/javascript/mastodon/actions/picture_in_picture.js b/app/javascript/mastodon/actions/picture_in_picture.js
new file mode 100644
index 000000000..4085cb59e
--- /dev/null
+++ b/app/javascript/mastodon/actions/picture_in_picture.js
@@ -0,0 +1,38 @@
+// @ts-check
+
+export const PICTURE_IN_PICTURE_DEPLOY = 'PICTURE_IN_PICTURE_DEPLOY';
+export const PICTURE_IN_PICTURE_REMOVE = 'PICTURE_IN_PICTURE_REMOVE';
+
+/**
+ * @typedef MediaProps
+ * @property {string} src
+ * @property {boolean} muted
+ * @property {number} volume
+ * @property {number} currentTime
+ * @property {string} poster
+ * @property {string} backgroundColor
+ * @property {string} foregroundColor
+ * @property {string} accentColor
+ */
+
+/**
+ * @param {string} statusId
+ * @param {string} accountId
+ * @param {string} playerType
+ * @param {MediaProps} props
+ * @return {object}
+ */
+export const deployPictureInPicture = (statusId, accountId, playerType, props) => ({
+  type: PICTURE_IN_PICTURE_DEPLOY,
+  statusId,
+  accountId,
+  playerType,
+  props,
+});
+
+/*
+ * @return {object}
+ */
+export const removePictureInPicture = () => ({
+  type: PICTURE_IN_PICTURE_REMOVE,
+});
diff --git a/app/javascript/mastodon/components/animated_number.js b/app/javascript/mastodon/components/animated_number.js
index f3127c88e..fbe948c5b 100644
--- a/app/javascript/mastodon/components/animated_number.js
+++ b/app/javascript/mastodon/components/animated_number.js
@@ -5,10 +5,21 @@ import TransitionMotion from 'react-motion/lib/TransitionMotion';
 import spring from 'react-motion/lib/spring';
 import { reduceMotion } from 'mastodon/initial_state';
 
+const obfuscatedCount = count => {
+  if (count < 0) {
+    return 0;
+  } else if (count <= 1) {
+    return count;
+  } else {
+    return '1+';
+  }
+};
+
 export default class AnimatedNumber extends React.PureComponent {
 
   static propTypes = {
     value: PropTypes.number.isRequired,
+    obfuscate: PropTypes.bool,
   };
 
   state = {
@@ -36,11 +47,11 @@ export default class AnimatedNumber extends React.PureComponent {
   }
 
   render () {
-    const { value } = this.props;
+    const { value, obfuscate } = this.props;
     const { direction } = this.state;
 
     if (reduceMotion) {
-      return <FormattedNumber value={value} />;
+      return obfuscate ? obfuscatedCount(value) : <FormattedNumber value={value} />;
     }
 
     const styles = [{
@@ -54,7 +65,7 @@ export default class AnimatedNumber extends React.PureComponent {
         {items => (
           <span className='animated-number'>
             {items.map(({ key, data, style }) => (
-              <span key={key} style={{ position: (direction * style.y) > 0 ? 'absolute' : 'static', transform: `translateY(${style.y * 100}%)` }}><FormattedNumber value={data} /></span>
+              <span key={key} style={{ position: (direction * style.y) > 0 ? 'absolute' : 'static', transform: `translateY(${style.y * 100}%)` }}>{obfuscate ? obfuscatedCount(data) : <FormattedNumber value={data} />}</span>
             ))}
           </span>
         )}
diff --git a/app/javascript/mastodon/components/error_boundary.js b/app/javascript/mastodon/components/error_boundary.js
index ca3012276..ca4a2cfe1 100644
--- a/app/javascript/mastodon/components/error_boundary.js
+++ b/app/javascript/mastodon/components/error_boundary.js
@@ -66,17 +66,31 @@ export default class ErrorBoundary extends React.PureComponent {
   }
 
   render() {
-    const { hasError, copied } = this.state;
+    const { hasError, copied, errorMessage } = this.state;
 
     if (!hasError) {
       return this.props.children;
     }
 
+    const likelyBrowserAddonIssue = errorMessage && errorMessage.includes('NotFoundError');
+
     return (
       <div className='error-boundary'>
         <div>
-          <p className='error-boundary__error'><FormattedMessage id='error.unexpected_crash.explanation' defaultMessage='Due to a bug in our code or a browser compatibility issue, this page could not be displayed correctly.' /></p>
-          <p><FormattedMessage id='error.unexpected_crash.next_steps' defaultMessage='Try refreshing the page. If that does not help, you may still be able to use Mastodon through a different browser or native app.' /></p>
+          <p className='error-boundary__error'>
+            { likelyBrowserAddonIssue ? (
+              <FormattedMessage id='error.unexpected_crash.explanation_addons' defaultMessage='This page could not be displayed correctly. This error is likely caused by a browser add-on or automatic translation tools.' />
+            ) : (
+              <FormattedMessage id='error.unexpected_crash.explanation' defaultMessage='Due to a bug in our code or a browser compatibility issue, this page could not be displayed correctly.' />
+            )}
+          </p>
+          <p>
+            { likelyBrowserAddonIssue ? (
+              <FormattedMessage id='error.unexpected_crash.next_steps_addons' defaultMessage='Try disabling them and refreshing the page. If that does not help, you may still be able to use Mastodon through a different browser or native app.' />
+            ) : (
+              <FormattedMessage id='error.unexpected_crash.next_steps' defaultMessage='Try refreshing the page. If that does not help, you may still be able to use Mastodon through a different browser or native app.' />
+            )}
+          </p>
           <p className='error-boundary__footer'>Mastodon v{version} · <a href={source_url} rel='noopener noreferrer' target='_blank'><FormattedMessage id='errors.unexpected_crash.report_issue' defaultMessage='Report issue' /></a> · <button onClick={this.handleCopyStackTrace} className={copied ? 'copied' : ''}><FormattedMessage id='errors.unexpected_crash.copy_stacktrace' defaultMessage='Copy stacktrace to clipboard' /></button></p>
         </div>
       </div>
diff --git a/app/javascript/mastodon/components/icon_button.js b/app/javascript/mastodon/components/icon_button.js
index fd715bc3c..7f83dc1b9 100644
--- a/app/javascript/mastodon/components/icon_button.js
+++ b/app/javascript/mastodon/components/icon_button.js
@@ -2,6 +2,7 @@ import React from 'react';
 import PropTypes from 'prop-types';
 import classNames from 'classnames';
 import Icon from 'mastodon/components/icon';
+import AnimatedNumber from 'mastodon/components/animated_number';
 
 export default class IconButton extends React.PureComponent {
 
@@ -24,6 +25,8 @@ export default class IconButton extends React.PureComponent {
     animate: PropTypes.bool,
     overlay: PropTypes.bool,
     tabIndex: PropTypes.string,
+    counter: PropTypes.number,
+    obfuscateCount: PropTypes.bool,
   };
 
   static defaultProps = {
@@ -97,6 +100,8 @@ export default class IconButton extends React.PureComponent {
       pressed,
       tabIndex,
       title,
+      counter,
+      obfuscateCount,
     } = this.props;
 
     const {
@@ -113,6 +118,10 @@ export default class IconButton extends React.PureComponent {
       overlayed: overlay,
     });
 
+    if (typeof counter !== 'undefined') {
+      style.width = 'auto';
+    }
+
     return (
       <button
         aria-label={title}
@@ -128,7 +137,7 @@ export default class IconButton extends React.PureComponent {
         tabIndex={tabIndex}
         disabled={disabled}
       >
-        <Icon id={icon} fixedWidth aria-hidden='true' />
+        <Icon id={icon} fixedWidth aria-hidden='true' /> {typeof counter !== 'undefined' && <span className='icon-button__counter'><AnimatedNumber value={counter} obfuscate={obfuscateCount} /></span>}
       </button>
     );
   }
diff --git a/app/javascript/mastodon/components/intersection_observer_article.js b/app/javascript/mastodon/components/intersection_observer_article.js
index 124b34b02..2d87f19b5 100644
--- a/app/javascript/mastodon/components/intersection_observer_article.js
+++ b/app/javascript/mastodon/components/intersection_observer_article.js
@@ -2,10 +2,7 @@ import React from 'react';
 import PropTypes from 'prop-types';
 import scheduleIdleTask from '../features/ui/util/schedule_idle_task';
 import getRectFromEntry from '../features/ui/util/get_rect_from_entry';
-import { is } from 'immutable';
 
-// Diff these props in the "rendered" state
-const updateOnPropsForRendered = ['id', 'index', 'listLength'];
 // Diff these props in the "unrendered" state
 const updateOnPropsForUnrendered = ['id', 'index', 'listLength', 'cachedHeight'];
 
@@ -33,9 +30,12 @@ export default class IntersectionObserverArticle extends React.Component {
       // If we're going from rendered to unrendered (or vice versa) then update
       return true;
     }
-    // Otherwise, diff based on props
-    const propsToDiff = isUnrendered ? updateOnPropsForUnrendered : updateOnPropsForRendered;
-    return !propsToDiff.every(prop => is(nextProps[prop], this.props[prop]));
+    // If we are and remain hidden, diff based on props
+    if (isUnrendered) {
+      return !updateOnPropsForUnrendered.every(prop => nextProps[prop] === this.props[prop]);
+    }
+    // Else, assume the children have changed
+    return true;
   }
 
   componentDidMount () {
diff --git a/app/javascript/mastodon/components/picture_in_picture_placeholder.js b/app/javascript/mastodon/components/picture_in_picture_placeholder.js
new file mode 100644
index 000000000..19d15c18b
--- /dev/null
+++ b/app/javascript/mastodon/components/picture_in_picture_placeholder.js
@@ -0,0 +1,69 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import Icon from 'mastodon/components/icon';
+import { removePictureInPicture } from 'mastodon/actions/picture_in_picture';
+import { connect } from 'react-redux';
+import { debounce } from 'lodash';
+import { FormattedMessage } from 'react-intl';
+
+export default @connect()
+class PictureInPicturePlaceholder extends React.PureComponent {
+
+  static propTypes = {
+    width: PropTypes.number,
+    dispatch: PropTypes.func.isRequired,
+  };
+
+  state = {
+    width: this.props.width,
+    height: this.props.width && (this.props.width / (16/9)),
+  };
+
+  handleClick = () => {
+    const { dispatch } = this.props;
+    dispatch(removePictureInPicture());
+  }
+
+  setRef = c => {
+    this.node = c;
+
+    if (this.node) {
+      this._setDimensions();
+    }
+  }
+
+  _setDimensions () {
+    const width  = this.node.offsetWidth;
+    const height = width / (16/9);
+
+    this.setState({ width, height });
+  }
+
+  componentDidMount () {
+    window.addEventListener('resize', this.handleResize, { passive: true });
+  }
+
+  componentWillUnmount () {
+    window.removeEventListener('resize', this.handleResize);
+  }
+
+  handleResize = debounce(() => {
+    if (this.node) {
+      this._setDimensions();
+    }
+  }, 250, {
+    trailing: true,
+  });
+
+  render () {
+    const { height } = this.state;
+
+    return (
+      <div ref={this.setRef} className='picture-in-picture-placeholder' style={{ height }} role='button' tabIndex='0' onClick={this.handleClick}>
+        <Icon id='window-restore' />
+        <FormattedMessage id='picture_in_picture.restore' defaultMessage='Put it back' />
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/mastodon/components/status.js b/app/javascript/mastodon/components/status.js
index 174e401b7..c1e1cd172 100644
--- a/app/javascript/mastodon/components/status.js
+++ b/app/javascript/mastodon/components/status.js
@@ -17,6 +17,7 @@ import { HotKeys } from 'react-hotkeys';
 import classNames from 'classnames';
 import Icon from 'mastodon/components/icon';
 import { displayMedia } from '../initial_state';
+import PictureInPicturePlaceholder from 'mastodon/components/picture_in_picture_placeholder';
 
 // We use the component (and not the container) since we do not want
 // to use the progress bar to show download progress
@@ -95,6 +96,8 @@ class Status extends ImmutablePureComponent {
     cacheMediaWidth: PropTypes.func,
     cachedMediaWidth: PropTypes.number,
     scrollKey: PropTypes.string,
+    deployPictureInPicture: PropTypes.func,
+    usingPiP: PropTypes.bool,
   };
 
   // Avoid checking props that are functions (and whose equality will always
@@ -104,6 +107,8 @@ class Status extends ImmutablePureComponent {
     'account',
     'muted',
     'hidden',
+    'unread',
+    'usingPiP',
   ];
 
   state = {
@@ -205,6 +210,13 @@ class Status extends ImmutablePureComponent {
     }
   }
 
+  handleDeployPictureInPicture = (type, mediaProps) => {
+    const { deployPictureInPicture } = this.props;
+    const status = this._properStatus();
+
+    deployPictureInPicture(status, type, mediaProps);
+  }
+
   handleHotkeyReply = e => {
     e.preventDefault();
     this.props.onReply(this._properStatus(), this.context.router.history);
@@ -265,7 +277,7 @@ class Status extends ImmutablePureComponent {
     let media = null;
     let statusAvatar, prepend, rebloggedByText;
 
-    const { intl, hidden, featured, otherAccounts, unread, showThread, scrollKey } = this.props;
+    const { intl, hidden, featured, otherAccounts, unread, showThread, scrollKey, usingPiP } = this.props;
 
     let { status, account, ...other } = this.props;
 
@@ -336,7 +348,9 @@ class Status extends ImmutablePureComponent {
       status  = status.get('reblog');
     }
 
-    if (status.get('media_attachments').size > 0) {
+    if (usingPiP) {
+      media = <PictureInPicturePlaceholder width={this.props.cachedMediaWidth} />;
+    } else if (status.get('media_attachments').size > 0) {
       if (this.props.muted) {
         media = (
           <AttachmentList
@@ -361,6 +375,7 @@ class Status extends ImmutablePureComponent {
                 width={this.props.cachedMediaWidth}
                 height={110}
                 cacheWidth={this.props.cacheMediaWidth}
+                deployPictureInPicture={this.handleDeployPictureInPicture}
               />
             )}
           </Bundle>
@@ -382,6 +397,7 @@ class Status extends ImmutablePureComponent {
                 sensitive={status.get('sensitive')}
                 onOpenVideo={this.handleOpenVideo}
                 cacheWidth={this.props.cacheMediaWidth}
+                deployPictureInPicture={this.handleDeployPictureInPicture}
                 visible={this.state.showMedia}
                 onToggleVisibility={this.handleToggleMediaVisibility}
               />
@@ -438,10 +454,10 @@ class Status extends ImmutablePureComponent {
 
     return (
       <HotKeys handlers={handlers}>
-        <div className={classNames('status__wrapper', `status__wrapper-${status.get('visibility')}`, { 'status__wrapper-reply': !!status.get('in_reply_to_id'), read: unread === false, focusable: !this.props.muted })} tabIndex={this.props.muted ? null : 0} data-featured={featured ? 'true' : null} aria-label={textForScreenReader(intl, status, rebloggedByText)} ref={this.handleRef}>
+        <div className={classNames('status__wrapper', `status__wrapper-${status.get('visibility')}`, { 'status__wrapper-reply': !!status.get('in_reply_to_id'), unread, focusable: !this.props.muted })} tabIndex={this.props.muted ? null : 0} data-featured={featured ? 'true' : null} aria-label={textForScreenReader(intl, status, rebloggedByText)} ref={this.handleRef}>
           {prepend}
 
-          <div className={classNames('status', `status-${status.get('visibility')}`, { 'status-reply': !!status.get('in_reply_to_id'), muted: this.props.muted, read: unread === false })} data-id={status.get('id')}>
+          <div className={classNames('status', `status-${status.get('visibility')}`, { 'status-reply': !!status.get('in_reply_to_id'), muted: this.props.muted })} data-id={status.get('id')}>
             <div className='status__expand' onClick={this.handleExpandClick} role='presentation' />
             <div className='status__info'>
               <a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener noreferrer'><RelativeTimestamp timestamp={status.get('created_at')} /></a>
diff --git a/app/javascript/mastodon/components/status_action_bar.js b/app/javascript/mastodon/components/status_action_bar.js
index 88fde4ee0..adcdb8a4e 100644
--- a/app/javascript/mastodon/components/status_action_bar.js
+++ b/app/javascript/mastodon/components/status_action_bar.js
@@ -43,16 +43,6 @@ const messages = defineMessages({
   unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' },
 });
 
-const obfuscatedCount = count => {
-  if (count < 0) {
-    return 0;
-  } else if (count <= 1) {
-    return count;
-  } else {
-    return '1+';
-  }
-};
-
 const mapStateToProps = (state, { status }) => ({
   relationship: state.getIn(['relationships', status.getIn(['account', 'id'])]),
 });
@@ -327,9 +317,10 @@ class StatusActionBar extends ImmutablePureComponent {
 
     return (
       <div className='status__action-bar'>
-        <div className='status__action-bar__counter'><IconButton className='status__action-bar-button' title={replyTitle} icon={status.get('in_reply_to_account_id') === status.getIn(['account', 'id']) ? 'reply' : replyIcon} onClick={this.handleReplyClick} /><span className='status__action-bar__counter__label' >{obfuscatedCount(status.get('replies_count'))}</span></div>
+        <IconButton className='status__action-bar-button' title={replyTitle} icon={status.get('in_reply_to_account_id') === status.getIn(['account', 'id']) ? 'reply' : replyIcon} onClick={this.handleReplyClick} counter={status.get('replies_count')} obfuscateCount />
         <IconButton className={classNames('status__action-bar-button', { reblogPrivate })} disabled={!publicStatus && !reblogPrivate}  active={status.get('reblogged')} pressed={status.get('reblogged')} title={reblogTitle} icon='retweet' onClick={this.handleReblogClick} />
         <IconButton className='status__action-bar-button star-icon' animate active={status.get('favourited')} pressed={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} />
+
         {shareButton}
 
         <div className='status__action-bar-dropdown'>
diff --git a/app/javascript/mastodon/containers/status_container.js b/app/javascript/mastodon/containers/status_container.js
index decf7279f..7bfd66d3e 100644
--- a/app/javascript/mastodon/containers/status_container.js
+++ b/app/javascript/mastodon/containers/status_container.js
@@ -37,6 +37,7 @@ import { initMuteModal } from '../actions/mutes';
 import { initBlockModal } from '../actions/blocks';
 import { initReport } from '../actions/reports';
 import { openModal } from '../actions/modal';
+import { deployPictureInPicture } from '../actions/picture_in_picture';
 import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
 import { boostModal, deleteModal } from '../initial_state';
 import { showAlertForError } from '../actions/alerts';
@@ -56,6 +57,7 @@ const makeMapStateToProps = () => {
 
   const mapStateToProps = (state, props) => ({
     status: getStatus(state, props),
+    usingPiP: state.get('picture_in_picture').statusId === props.id,
   });
 
   return mapStateToProps;
@@ -207,6 +209,10 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
     dispatch(unblockDomain(domain));
   },
 
+  deployPictureInPicture (status, type, mediaProps) {
+    dispatch(deployPictureInPicture(status.get('id'), status.getIn(['account', 'id']), type, mediaProps));
+  },
+
 });
 
 export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Status));
diff --git a/app/javascript/mastodon/features/account/components/header.js b/app/javascript/mastodon/features/account/components/header.js
index 61ecf045d..2b97af4e6 100644
--- a/app/javascript/mastodon/features/account/components/header.js
+++ b/app/javascript/mastodon/features/account/components/header.js
@@ -7,6 +7,7 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
 import { autoPlayGif, me, isStaff } from 'mastodon/initial_state';
 import classNames from 'classnames';
 import Icon from 'mastodon/components/icon';
+import IconButton from 'mastodon/components/icon_button';
 import Avatar from 'mastodon/components/avatar';
 import { counterRenderer } from 'mastodon/components/common_counter';
 import ShortNumber from 'mastodon/components/short_number';
@@ -35,6 +36,8 @@ const messages = defineMessages({
   unblockDomain: { id: 'account.unblock_domain', defaultMessage: 'Unblock domain {domain}' },
   hideReblogs: { id: 'account.hide_reblogs', defaultMessage: 'Hide boosts from @{name}' },
   showReblogs: { id: 'account.show_reblogs', defaultMessage: 'Show boosts from @{name}' },
+  enableNotifications: { id: 'account.enable_notifications', defaultMessage: 'Notify me when @{name} posts' },
+  disableNotifications: { id: 'account.disable_notifications', defaultMessage: 'Stop notifying me when @{name} posts' },
   pins: { id: 'navigation_bar.pins', defaultMessage: 'Pinned toots' },
   preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' },
   follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' },
@@ -68,8 +71,9 @@ class Header extends ImmutablePureComponent {
     onBlock: PropTypes.func.isRequired,
     onMention: PropTypes.func.isRequired,
     onDirect: PropTypes.func.isRequired,
-    onReport: PropTypes.func.isRequired,
     onReblogToggle: PropTypes.func.isRequired,
+    onNotifyToggle: PropTypes.func.isRequired,
+    onReport: PropTypes.func.isRequired,
     onMute: PropTypes.func.isRequired,
     onBlockDomain: PropTypes.func.isRequired,
     onUnblockDomain: PropTypes.func.isRequired,
@@ -140,8 +144,11 @@ class Header extends ImmutablePureComponent {
       return null;
     }
 
+    const suspended = account.get('suspended');
+
     let info        = [];
     let actionBtn   = '';
+    let bellBtn     = '';
     let lockedIcon  = '';
     let menu        = [];
 
@@ -171,6 +178,10 @@ class Header extends ImmutablePureComponent {
       actionBtn = <Button className='logo-button' text={intl.formatMessage(messages.edit_profile)} onClick={this.openEditProfile} />;
     }
 
+    if (account.getIn(['relationship', 'requested']) || account.getIn(['relationship', 'following'])) {
+      bellBtn = <IconButton icon='bell-o' size={24} active={account.getIn(['relationship', 'notifying'])} title={intl.formatMessage(account.getIn(['relationship', 'notifying']) ? messages.disableNotifications : messages.enableNotifications, { name: account.get('username') })} onClick={this.props.onNotifyToggle} />;
+    }
+
     if (account.get('moved') && !account.getIn(['relationship', 'following'])) {
       actionBtn = '';
     }
@@ -268,7 +279,7 @@ class Header extends ImmutablePureComponent {
       <div className={classNames('account__header', { inactive: !!account.get('moved') })} ref={this.setRef}>
         <div className='account__header__image'>
           <div className='account__header__info'>
-            {info}
+            {!suspended && info}
           </div>
 
           <img src={autoPlayGif ? account.get('header') : account.get('header_static')} alt='' className='parallax' />
@@ -282,11 +293,14 @@ class Header extends ImmutablePureComponent {
 
             <div className='spacer' />
 
-            <div className='account__header__tabs__buttons'>
-              {actionBtn}
+            {!suspended && (
+              <div className='account__header__tabs__buttons'>
+                {actionBtn}
+                {bellBtn}
 
-              <DropdownMenuContainer items={menu} icon='ellipsis-v' size={24} direction='right' />
-            </div>
+                <DropdownMenuContainer items={menu} icon='ellipsis-v' size={24} direction='right' />
+              </div>
+            )}
           </div>
 
           <div className='account__header__tabs__name'>
@@ -298,7 +312,7 @@ class Header extends ImmutablePureComponent {
 
           <div className='account__header__extra'>
             <div className='account__header__bio'>
-              { (fields.size > 0 || identity_proofs.size > 0) && (
+              {(fields.size > 0 || identity_proofs.size > 0) && (
                 <div className='account__header__fields'>
                   {identity_proofs.map((proof, i) => (
                     <dl key={i}>
@@ -324,33 +338,35 @@ class Header extends ImmutablePureComponent {
                 </div>
               )}
 
-              {account.get('id') !== me && <AccountNoteContainer account={account} />}
+              {account.get('id') !== me && !suspended && <AccountNoteContainer account={account} />}
 
               {account.get('note').length > 0 && account.get('note') !== '<p></p>' && <div className='account__header__content' dangerouslySetInnerHTML={content} />}
             </div>
 
-            <div className='account__header__extra__links'>
-              <NavLink isActive={this.isStatusesPageActive} activeClassName='active' to={`/accounts/${account.get('id')}`} title={intl.formatNumber(account.get('statuses_count'))}>
-                <ShortNumber
-                  value={account.get('statuses_count')}
-                  renderer={counterRenderer('statuses')}
-                />
-              </NavLink>
-
-              <NavLink exact activeClassName='active' to={`/accounts/${account.get('id')}/following`} title={intl.formatNumber(account.get('following_count'))}>
-                <ShortNumber
-                  value={account.get('following_count')}
-                  renderer={counterRenderer('following')}
-                />
-              </NavLink>
-
-              <NavLink exact activeClassName='active' to={`/accounts/${account.get('id')}/followers`} title={intl.formatNumber(account.get('followers_count'))}>
-                <ShortNumber
-                  value={account.get('followers_count')}
-                  renderer={counterRenderer('followers')}
-                />
-              </NavLink>
-            </div>
+            {!suspended && (
+              <div className='account__header__extra__links'>
+                <NavLink isActive={this.isStatusesPageActive} activeClassName='active' to={`/accounts/${account.get('id')}`} title={intl.formatNumber(account.get('statuses_count'))}>
+                  <ShortNumber
+                    value={account.get('statuses_count')}
+                    renderer={counterRenderer('statuses')}
+                  />
+                </NavLink>
+
+                <NavLink exact activeClassName='active' to={`/accounts/${account.get('id')}/following`} title={intl.formatNumber(account.get('following_count'))}>
+                  <ShortNumber
+                    value={account.get('following_count')}
+                    renderer={counterRenderer('following')}
+                  />
+                </NavLink>
+
+                <NavLink exact activeClassName='active' to={`/accounts/${account.get('id')}/followers`} title={intl.formatNumber(account.get('followers_count'))}>
+                  <ShortNumber
+                    value={account.get('followers_count')}
+                    renderer={counterRenderer('followers')}
+                  />
+                </NavLink>
+              </div>
+            )}
           </div>
         </div>
       </div>
diff --git a/app/javascript/mastodon/features/account_gallery/index.js b/app/javascript/mastodon/features/account_gallery/index.js
index fc5aead48..e5caec0bc 100644
--- a/app/javascript/mastodon/features/account_gallery/index.js
+++ b/app/javascript/mastodon/features/account_gallery/index.js
@@ -15,12 +15,15 @@ import { ScrollContainer } from 'react-router-scroll-4';
 import LoadMore from 'mastodon/components/load_more';
 import MissingIndicator from 'mastodon/components/missing_indicator';
 import { openModal } from 'mastodon/actions/modal';
+import { FormattedMessage } from 'react-intl';
 
 const mapStateToProps = (state, props) => ({
   isAccount: !!state.getIn(['accounts', props.params.accountId]),
   attachments: getAccountGallery(state, props.params.accountId),
   isLoading: state.getIn(['timelines', `account:${props.params.accountId}:media`, 'isLoading']),
   hasMore: state.getIn(['timelines', `account:${props.params.accountId}:media`, 'hasMore']),
+  suspended: state.getIn(['accounts', props.params.accountId, 'suspended'], false),
+  blockedBy: state.getIn(['relationships', props.params.accountId, 'blocked_by'], false),
 });
 
 class LoadMoreMedia extends ImmutablePureComponent {
@@ -56,6 +59,8 @@ class AccountGallery extends ImmutablePureComponent {
     isLoading: PropTypes.bool,
     hasMore: PropTypes.bool,
     isAccount: PropTypes.bool,
+    blockedBy: PropTypes.bool,
+    suspended: PropTypes.bool,
     multiColumn: PropTypes.bool,
   };
 
@@ -119,7 +124,7 @@ class AccountGallery extends ImmutablePureComponent {
   }
 
   render () {
-    const { attachments, shouldUpdateScroll, isLoading, hasMore, isAccount, multiColumn } = this.props;
+    const { attachments, shouldUpdateScroll, isLoading, hasMore, isAccount, multiColumn, blockedBy, suspended } = this.props;
     const { width } = this.state;
 
     if (!isAccount) {
@@ -152,15 +157,21 @@ class AccountGallery extends ImmutablePureComponent {
           <div className='scrollable scrollable--flex' onScroll={this.handleScroll}>
             <HeaderContainer accountId={this.props.params.accountId} />
 
-            <div role='feed' className='account-gallery__container' ref={this.handleRef}>
-              {attachments.map((attachment, index) => attachment === null ? (
-                <LoadMoreMedia key={'more:' + attachments.getIn(index + 1, 'id')} maxId={index > 0 ? attachments.getIn(index - 1, 'id') : null} onLoadMore={this.handleLoadMore} />
-              ) : (
-                <MediaItem key={attachment.get('id')} attachment={attachment} displayWidth={width} onOpenMedia={this.handleOpenMedia} />
-              ))}
-
-              {loadOlder}
-            </div>
+            {(suspended || blockedBy) ? (
+              <div className='empty-column-indicator'>
+                <FormattedMessage id='empty_column.account_unavailable' defaultMessage='Profile unavailable' />
+              </div>
+            ) : (
+              <div role='feed' className='account-gallery__container' ref={this.handleRef}>
+                {attachments.map((attachment, index) => attachment === null ? (
+                  <LoadMoreMedia key={'more:' + attachments.getIn(index + 1, 'id')} maxId={index > 0 ? attachments.getIn(index - 1, 'id') : null} onLoadMore={this.handleLoadMore} />
+                ) : (
+                  <MediaItem key={attachment.get('id')} attachment={attachment} displayWidth={width} onOpenMedia={this.handleOpenMedia} />
+                ))}
+
+                {loadOlder}
+              </div>
+            )}
 
             {isLoading && attachments.size === 0 && (
               <div className='scrollable__append'>
diff --git a/app/javascript/mastodon/features/account_timeline/components/header.js b/app/javascript/mastodon/features/account_timeline/components/header.js
index abb15edcc..6b52defe4 100644
--- a/app/javascript/mastodon/features/account_timeline/components/header.js
+++ b/app/javascript/mastodon/features/account_timeline/components/header.js
@@ -55,6 +55,10 @@ export default class Header extends ImmutablePureComponent {
     this.props.onReblogToggle(this.props.account);
   }
 
+  handleNotifyToggle = () => {
+    this.props.onNotifyToggle(this.props.account);
+  }
+
   handleMute = () => {
     this.props.onMute(this.props.account);
   }
@@ -106,6 +110,7 @@ export default class Header extends ImmutablePureComponent {
           onMention={this.handleMention}
           onDirect={this.handleDirect}
           onReblogToggle={this.handleReblogToggle}
+          onNotifyToggle={this.handleNotifyToggle}
           onReport={this.handleReport}
           onMute={this.handleMute}
           onBlockDomain={this.handleBlockDomain}
diff --git a/app/javascript/mastodon/features/account_timeline/containers/header_container.js b/app/javascript/mastodon/features/account_timeline/containers/header_container.js
index 8728b4806..e12019547 100644
--- a/app/javascript/mastodon/features/account_timeline/containers/header_container.js
+++ b/app/javascript/mastodon/features/account_timeline/containers/header_container.js
@@ -76,9 +76,9 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
 
   onReblogToggle (account) {
     if (account.getIn(['relationship', 'showing_reblogs'])) {
-      dispatch(followAccount(account.get('id'), false));
+      dispatch(followAccount(account.get('id'), { reblogs: false }));
     } else {
-      dispatch(followAccount(account.get('id'), true));
+      dispatch(followAccount(account.get('id'), { reblogs: true }));
     }
   },
 
@@ -90,6 +90,14 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
     }
   },
 
+  onNotifyToggle (account) {
+    if (account.getIn(['relationship', 'notifying'])) {
+      dispatch(followAccount(account.get('id'), { notify: false }));
+    } else {
+      dispatch(followAccount(account.get('id'), { notify: true }));
+    }
+  },
+
   onReport (account) {
     dispatch(initReport(account));
   },
diff --git a/app/javascript/mastodon/features/account_timeline/index.js b/app/javascript/mastodon/features/account_timeline/index.js
index b9a616266..cbc859805 100644
--- a/app/javascript/mastodon/features/account_timeline/index.js
+++ b/app/javascript/mastodon/features/account_timeline/index.js
@@ -31,6 +31,7 @@ const mapStateToProps = (state, { params: { accountId }, withReplies = false })
     featuredStatusIds: withReplies ? ImmutableList() : state.getIn(['timelines', `account:${accountId}:pinned`, 'items'], emptyList),
     isLoading: state.getIn(['timelines', `account:${path}`, 'isLoading']),
     hasMore: state.getIn(['timelines', `account:${path}`, 'hasMore']),
+    suspended: state.getIn(['accounts', accountId, 'suspended'], false),
     blockedBy: state.getIn(['relationships', accountId, 'blocked_by'], false),
   };
 };
@@ -57,6 +58,7 @@ class AccountTimeline extends ImmutablePureComponent {
     withReplies: PropTypes.bool,
     blockedBy: PropTypes.bool,
     isAccount: PropTypes.bool,
+    suspended: PropTypes.bool,
     remote: PropTypes.bool,
     remoteUrl: PropTypes.string,
     multiColumn: PropTypes.bool,
@@ -113,7 +115,7 @@ class AccountTimeline extends ImmutablePureComponent {
   }
 
   render () {
-    const { shouldUpdateScroll, statusIds, featuredStatusIds, isLoading, hasMore, blockedBy, isAccount, multiColumn, remote, remoteUrl } = this.props;
+    const { shouldUpdateScroll, statusIds, featuredStatusIds, isLoading, hasMore, blockedBy, suspended, isAccount, multiColumn, remote, remoteUrl } = this.props;
 
     if (!isAccount) {
       return (
@@ -134,7 +136,7 @@ class AccountTimeline extends ImmutablePureComponent {
 
     let emptyMessage;
 
-    if (blockedBy) {
+    if (suspended || blockedBy) {
       emptyMessage = <FormattedMessage id='empty_column.account_unavailable' defaultMessage='Profile unavailable' />;
     } else if (remote && statusIds.isEmpty()) {
       emptyMessage = <RemoteHint url={remoteUrl} />;
@@ -153,7 +155,7 @@ class AccountTimeline extends ImmutablePureComponent {
           alwaysPrepend
           append={remoteMessage}
           scrollKey='account_timeline'
-          statusIds={blockedBy ? emptyList : statusIds}
+          statusIds={(suspended || blockedBy) ? emptyList : statusIds}
           featuredStatusIds={featuredStatusIds}
           isLoading={isLoading}
           hasMore={hasMore}
diff --git a/app/javascript/mastodon/features/audio/index.js b/app/javascript/mastodon/features/audio/index.js
index 5b8172694..6954d2a4c 100644
--- a/app/javascript/mastodon/features/audio/index.js
+++ b/app/javascript/mastodon/features/audio/index.js
@@ -37,7 +37,11 @@ class Audio extends React.PureComponent {
     backgroundColor: PropTypes.string,
     foregroundColor: PropTypes.string,
     accentColor: PropTypes.string,
+    currentTime: PropTypes.number,
     autoPlay: PropTypes.bool,
+    volume: PropTypes.number,
+    muted: PropTypes.bool,
+    deployPictureInPicture: PropTypes.func,
   };
 
   state = {
@@ -64,6 +68,19 @@ class Audio extends React.PureComponent {
     }
   }
 
+  _pack() {
+    return {
+      src: this.props.src,
+      volume: this.audio.volume,
+      muted: this.audio.muted,
+      currentTime: this.audio.currentTime,
+      poster: this.props.poster,
+      backgroundColor: this.props.backgroundColor,
+      foregroundColor: this.props.foregroundColor,
+      accentColor: this.props.accentColor,
+    };
+  }
+
   _setDimensions () {
     const width  = this.player.offsetWidth;
     const height = this.props.fullscreen ? this.player.offsetHeight : (width / (16/9));
@@ -112,6 +129,10 @@ class Audio extends React.PureComponent {
   componentWillUnmount () {
     window.removeEventListener('scroll', this.handleScroll);
     window.removeEventListener('resize', this.handleResize);
+
+    if (!this.state.paused && this.audio && this.props.deployPictureInPicture) {
+      this.props.deployPictureInPicture('audio', this._pack());
+    }
   }
 
   togglePlay = () => {
@@ -248,7 +269,13 @@ class Audio extends React.PureComponent {
     const inView = (top <= (window.innerHeight || document.documentElement.clientHeight)) && (top + height >= 0);
 
     if (!this.state.paused && !inView) {
-      this.setState({ paused: true }, () => this.audio.pause());
+      this.audio.pause();
+
+      if (this.props.deployPictureInPicture) {
+        this.props.deployPictureInPicture('audio', this._pack());
+      }
+
+      this.setState({ paused: true });
     }
   }, 150, { trailing: true });
 
@@ -261,10 +288,22 @@ class Audio extends React.PureComponent {
   }
 
   handleLoadedData = () => {
-    const { autoPlay } = this.props;
+    const { autoPlay, currentTime, volume, muted } = this.props;
+
+    if (currentTime) {
+      this.audio.currentTime = currentTime;
+    }
+
+    if (volume !== undefined) {
+      this.audio.volume = volume;
+    }
+
+    if (muted !== undefined) {
+      this.audio.muted = muted;
+    }
 
     if (autoPlay) {
-      this.audio.play();
+      this.togglePlay();
     }
   }
 
@@ -350,7 +389,7 @@ class Audio extends React.PureComponent {
   render () {
     const { src, intl, alt, editable, autoPlay } = this.props;
     const { paused, muted, volume, currentTime, duration, buffer, dragging } = this.state;
-    const progress = (currentTime / duration) * 100;
+    const progress = Math.min((currentTime / duration) * 100, 100);
 
     return (
       <div className={classNames('audio-player', { editable })} ref={this.setPlayerRef} style={{ backgroundColor: this._getBackgroundColor(), color: this._getForegroundColor(), width: '100%', height: this.props.fullscreen ? '100%' : (this.state.height || this.props.height) }} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}>
diff --git a/app/javascript/mastodon/features/emoji/emoji.js b/app/javascript/mastodon/features/emoji/emoji.js
index 5237b25f0..5d9dad097 100644
--- a/app/javascript/mastodon/features/emoji/emoji.js
+++ b/app/javascript/mastodon/features/emoji/emoji.js
@@ -12,7 +12,7 @@ const emojiFilenames = (emojis) => {
 };
 
 // Emoji requiring extra borders depending on theme
-const darkEmoji = emojiFilenames(['🎱', '🐜', '⚫', '🖤', '⬛', '◼️', '◾', '◼️', '✒️', '▪️', '💣', '🎳', '📷', '📸', '♣️', '🕶️', '✴️', '🔌', '💂‍♀️', '📽️', '🍳', '🦍', '💂', '🔪', '🕳️', '🕹️', '🕋', '🖊️', '🖋️', '💂‍♂️', '🎤', '🎓', '🎥', '🎼', '♠️', '🎩', '🦃', '📼', '📹', '🎮', '🐃', '🏴', '🐞']);
+const darkEmoji = emojiFilenames(['🎱', '🐜', '⚫', '🖤', '⬛', '◼️', '◾', '◼️', '✒️', '▪️', '💣', '🎳', '📷', '📸', '♣️', '🕶️', '✴️', '🔌', '💂‍♀️', '📽️', '🍳', '🦍', '💂', '🔪', '🕳️', '🕹️', '🕋', '🖊️', '🖋️', '💂‍♂️', '🎤', '🎓', '🎥', '🎼', '♠️', '🎩', '🦃', '📼', '📹', '🎮', '🐃', '🏴', '🐞', '🕺']);
 const lightEmoji = emojiFilenames(['👽', '⚾', '🐔', '☁️', '💨', '🕊️', '👀', '🍥', '👻', '🐐', '❕', '❔', '⛸️', '🌩️', '🔊', '🔇', '📃', '🌧️', '🐏', '🍚', '🍙', '🐓', '🐑', '💀', '☠️', '🌨️', '🔉', '🔈', '💬', '💭', '🏐', '🏳️', '⚪', '⬜', '◽', '◻️', '▫️']);
 
 const emojiFilename = (filename) => {
diff --git a/app/javascript/mastodon/features/notifications/components/filter_bar.js b/app/javascript/mastodon/features/notifications/components/filter_bar.js
index 2fd28d832..368eb0b7e 100644
--- a/app/javascript/mastodon/features/notifications/components/filter_bar.js
+++ b/app/javascript/mastodon/features/notifications/components/filter_bar.js
@@ -9,6 +9,7 @@ const tooltips = defineMessages({
   boosts: { id: 'notifications.filter.boosts', defaultMessage: 'Boosts' },
   polls: { id: 'notifications.filter.polls', defaultMessage: 'Poll results' },
   follows: { id: 'notifications.filter.follows', defaultMessage: 'Follows' },
+  statuses: { id: 'notifications.filter.statuses', defaultMessage: 'Updates from people you follow' },
 });
 
 export default @injectIntl
@@ -88,6 +89,13 @@ class FilterBar extends React.PureComponent {
           <Icon id='tasks' fixedWidth />
         </button>
         <button
+          className={selectedFilter === 'status' ? 'active' : ''}
+          onClick={this.onClick('status')}
+          title={intl.formatMessage(tooltips.statuses)}
+        >
+          <Icon id='home' fixedWidth />
+        </button>
+        <button
           className={selectedFilter === 'follow' ? 'active' : ''}
           onClick={this.onClick('follow')}
           title={intl.formatMessage(tooltips.follows)}
diff --git a/app/javascript/mastodon/features/notifications/components/notification.js b/app/javascript/mastodon/features/notifications/components/notification.js
index 74065e5e2..94fdbd6f4 100644
--- a/app/javascript/mastodon/features/notifications/components/notification.js
+++ b/app/javascript/mastodon/features/notifications/components/notification.js
@@ -10,6 +10,7 @@ import AccountContainer from 'mastodon/containers/account_container';
 import FollowRequestContainer from '../containers/follow_request_container';
 import Icon from 'mastodon/components/icon';
 import Permalink from 'mastodon/components/permalink';
+import classNames from 'classnames';
 
 const messages = defineMessages({
   favourite: { id: 'notification.favourite', defaultMessage: '{name} favourited your status' },
@@ -17,6 +18,7 @@ const messages = defineMessages({
   ownPoll: { id: 'notification.own_poll', defaultMessage: 'Your poll has ended' },
   poll: { id: 'notification.poll', defaultMessage: 'A poll you have voted in has ended' },
   reblog: { id: 'notification.reblog', defaultMessage: '{name} boosted your status' },
+  status: { id: 'notification.status', defaultMessage: '{name} just posted' },
 });
 
 const notificationForScreenReader = (intl, message, timestamp) => {
@@ -49,6 +51,7 @@ class Notification extends ImmutablePureComponent {
     updateScrollBottom: PropTypes.func,
     cacheMediaWidth: PropTypes.func,
     cachedMediaWidth: PropTypes.number,
+    unread: PropTypes.bool,
   };
 
   handleMoveUp = () => {
@@ -113,11 +116,11 @@ class Notification extends ImmutablePureComponent {
   }
 
   renderFollow (notification, account, link) {
-    const { intl } = this.props;
+    const { intl, unread } = this.props;
 
     return (
       <HotKeys handlers={this.getHandlers()}>
-        <div className='notification notification-follow focusable' tabIndex='0' aria-label={notificationForScreenReader(intl, intl.formatMessage(messages.follow, { name: account.get('acct') }), notification.get('created_at'))}>
+        <div className={classNames('notification notification-follow focusable', { unread })} tabIndex='0' aria-label={notificationForScreenReader(intl, intl.formatMessage(messages.follow, { name: account.get('acct') }), notification.get('created_at'))}>
           <div className='notification__message'>
             <div className='notification__favourite-icon-wrapper'>
               <Icon id='user-plus' fixedWidth />
@@ -135,11 +138,11 @@ class Notification extends ImmutablePureComponent {
   }
 
   renderFollowRequest (notification, account, link) {
-    const { intl } = this.props;
+    const { intl, unread } = this.props;
 
     return (
       <HotKeys handlers={this.getHandlers()}>
-        <div className='notification notification-follow-request focusable' tabIndex='0' aria-label={notificationForScreenReader(intl, intl.formatMessage({ id: 'notification.follow_request', defaultMessage: '{name} has requested to follow you' }, { name: account.get('acct') }), notification.get('created_at'))}>
+        <div className={classNames('notification notification-follow-request focusable', { unread })} tabIndex='0' aria-label={notificationForScreenReader(intl, intl.formatMessage({ id: 'notification.follow_request', defaultMessage: '{name} has requested to follow you' }, { name: account.get('acct') }), notification.get('created_at'))}>
           <div className='notification__message'>
             <div className='notification__favourite-icon-wrapper'>
               <Icon id='user' fixedWidth />
@@ -169,16 +172,17 @@ class Notification extends ImmutablePureComponent {
         updateScrollBottom={this.props.updateScrollBottom}
         cachedMediaWidth={this.props.cachedMediaWidth}
         cacheMediaWidth={this.props.cacheMediaWidth}
+        unread={this.props.unread}
       />
     );
   }
 
   renderFavourite (notification, link) {
-    const { intl } = this.props;
+    const { intl, unread } = this.props;
 
     return (
       <HotKeys handlers={this.getHandlers()}>
-        <div className='notification notification-favourite focusable' tabIndex='0' aria-label={notificationForScreenReader(intl, intl.formatMessage(messages.favourite, { name: notification.getIn(['account', 'acct']) }), notification.get('created_at'))}>
+        <div className={classNames('notification notification-favourite focusable', { unread })} tabIndex='0' aria-label={notificationForScreenReader(intl, intl.formatMessage(messages.favourite, { name: notification.getIn(['account', 'acct']) }), notification.get('created_at'))}>
           <div className='notification__message'>
             <div className='notification__favourite-icon-wrapper'>
               <Icon id='star' className='star-icon' fixedWidth />
@@ -206,11 +210,11 @@ class Notification extends ImmutablePureComponent {
   }
 
   renderReblog (notification, link) {
-    const { intl } = this.props;
+    const { intl, unread } = this.props;
 
     return (
       <HotKeys handlers={this.getHandlers()}>
-        <div className='notification notification-reblog focusable' tabIndex='0' aria-label={notificationForScreenReader(intl, intl.formatMessage(messages.reblog, { name: notification.getIn(['account', 'acct']) }), notification.get('created_at'))}>
+        <div className={classNames('notification notification-reblog focusable', { unread })} tabIndex='0' aria-label={notificationForScreenReader(intl, intl.formatMessage(messages.reblog, { name: notification.getIn(['account', 'acct']) }), notification.get('created_at'))}>
           <div className='notification__message'>
             <div className='notification__favourite-icon-wrapper'>
               <Icon id='retweet' fixedWidth />
@@ -237,14 +241,46 @@ class Notification extends ImmutablePureComponent {
     );
   }
 
+  renderStatus (notification, link) {
+    const { intl, unread } = this.props;
+
+    return (
+      <HotKeys handlers={this.getHandlers()}>
+        <div className={classNames('notification notification-status focusable', { unread })} tabIndex='0' aria-label={notificationForScreenReader(intl, intl.formatMessage(messages.status, { name: notification.getIn(['account', 'acct']) }), notification.get('created_at'))}>
+          <div className='notification__message'>
+            <div className='notification__favourite-icon-wrapper'>
+              <Icon id='home' fixedWidth />
+            </div>
+
+            <span title={notification.get('created_at')}>
+              <FormattedMessage id='notification.status' defaultMessage='{name} just posted' values={{ name: link }} />
+            </span>
+          </div>
+
+          <StatusContainer
+            id={notification.get('status')}
+            account={notification.get('account')}
+            muted
+            withDismiss
+            hidden={this.props.hidden}
+            getScrollPosition={this.props.getScrollPosition}
+            updateScrollBottom={this.props.updateScrollBottom}
+            cachedMediaWidth={this.props.cachedMediaWidth}
+            cacheMediaWidth={this.props.cacheMediaWidth}
+          />
+        </div>
+      </HotKeys>
+    );
+  }
+
   renderPoll (notification, account) {
-    const { intl } = this.props;
+    const { intl, unread } = this.props;
     const ownPoll  = me === account.get('id');
     const message  = ownPoll ? intl.formatMessage(messages.ownPoll) : intl.formatMessage(messages.poll);
 
     return (
       <HotKeys handlers={this.getHandlers()}>
-        <div className='notification notification-poll focusable' tabIndex='0' aria-label={notificationForScreenReader(intl, message, notification.get('created_at'))}>
+        <div className={classNames('notification notification-poll focusable', { unread })} tabIndex='0' aria-label={notificationForScreenReader(intl, message, notification.get('created_at'))}>
           <div className='notification__message'>
             <div className='notification__favourite-icon-wrapper'>
               <Icon id='tasks' fixedWidth />
@@ -292,6 +328,8 @@ class Notification extends ImmutablePureComponent {
       return this.renderFavourite(notification, link);
     case 'reblog':
       return this.renderReblog(notification, link);
+    case 'status':
+      return this.renderStatus(notification, link);
     case 'poll':
       return this.renderPoll(notification, account);
     }
diff --git a/app/javascript/mastodon/features/notifications/index.js b/app/javascript/mastodon/features/notifications/index.js
index d16a0f33a..41a45b2b6 100644
--- a/app/javascript/mastodon/features/notifications/index.js
+++ b/app/javascript/mastodon/features/notifications/index.js
@@ -4,7 +4,14 @@ import PropTypes from 'prop-types';
 import ImmutablePropTypes from 'react-immutable-proptypes';
 import Column from '../../components/column';
 import ColumnHeader from '../../components/column_header';
-import { expandNotifications, scrollTopNotifications, loadPending, mountNotifications, unmountNotifications } from '../../actions/notifications';
+import {
+  expandNotifications,
+  scrollTopNotifications,
+  loadPending,
+  mountNotifications,
+  unmountNotifications,
+  markNotificationsAsRead,
+} from '../../actions/notifications';
 import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
 import NotificationContainer from './containers/notification_container';
 import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
@@ -15,9 +22,12 @@ import { List as ImmutableList } from 'immutable';
 import { debounce } from 'lodash';
 import ScrollableList from '../../components/scrollable_list';
 import LoadGap from '../../components/load_gap';
+import Icon from 'mastodon/components/icon';
+import compareId from 'mastodon/compare_id';
 
 const messages = defineMessages({
   title: { id: 'column.notifications', defaultMessage: 'Notifications' },
+  markAsRead : { id: 'notifications.mark_as_read', defaultMessage: 'Mark every notification as read' },
 });
 
 const getNotifications = createSelector([
@@ -32,7 +42,7 @@ const getNotifications = createSelector([
     // we need to turn it off for FilterBar in order not to block ourselves from seeing a specific category
     return notifications.filterNot(item => item !== null && excludedTypes.includes(item.get('type')));
   }
-  return notifications.filter(item => item !== null && allowedType === item.get('type'));
+  return notifications.filter(item => item === null || allowedType === item.get('type'));
 });
 
 const mapStateToProps = state => ({
@@ -42,6 +52,8 @@ const mapStateToProps = state => ({
   isUnread: state.getIn(['notifications', 'unread']) > 0 || state.getIn(['notifications', 'pendingItems']).size > 0,
   hasMore: state.getIn(['notifications', 'hasMore']),
   numPending: state.getIn(['notifications', 'pendingItems'], ImmutableList()).size,
+  lastReadId: state.getIn(['notifications', 'readMarkerId']),
+  canMarkAsRead: state.getIn(['notifications', 'readMarkerId']) !== '0' && getNotifications(state).some(item => item !== null && compareId(item.get('id'), state.getIn(['notifications', 'readMarkerId'])) > 0),
 });
 
 export default @connect(mapStateToProps)
@@ -60,6 +72,8 @@ class Notifications extends React.PureComponent {
     multiColumn: PropTypes.bool,
     hasMore: PropTypes.bool,
     numPending: PropTypes.number,
+    lastReadId: PropTypes.string,
+    canMarkAsRead: PropTypes.bool,
   };
 
   static defaultProps = {
@@ -146,8 +160,12 @@ class Notifications extends React.PureComponent {
     }
   }
 
+  handleMarkAsRead = () => {
+    this.props.dispatch(markNotificationsAsRead());
+  };
+
   render () {
-    const { intl, notifications, shouldUpdateScroll, isLoading, isUnread, columnId, multiColumn, hasMore, numPending, showFilterBar } = this.props;
+    const { intl, notifications, shouldUpdateScroll, isLoading, isUnread, columnId, multiColumn, hasMore, numPending, showFilterBar, lastReadId, canMarkAsRead } = this.props;
     const pinned = !!columnId;
     const emptyMessage = <FormattedMessage id='empty_column.notifications' defaultMessage="You don't have any notifications yet. Interact with others to start the conversation." />;
 
@@ -174,6 +192,7 @@ class Notifications extends React.PureComponent {
           accountId={item.get('account')}
           onMoveUp={this.handleMoveUp}
           onMoveDown={this.handleMoveDown}
+          unread={lastReadId !== '0' && compareId(item.get('id'), lastReadId) > 0}
         />
       ));
     } else {
@@ -202,6 +221,21 @@ class Notifications extends React.PureComponent {
       </ScrollableList>
     );
 
+    let extraButton = null;
+
+    if (canMarkAsRead) {
+      extraButton = (
+        <button
+          aria-label={intl.formatMessage(messages.markAsRead)}
+          title={intl.formatMessage(messages.markAsRead)}
+          onClick={this.handleMarkAsRead}
+          className='column-header__button'
+        >
+          <Icon id='check' />
+        </button>
+      );
+    }
+
     return (
       <Column bindToDocument={!multiColumn} ref={this.setColumnRef} label={intl.formatMessage(messages.title)}>
         <ColumnHeader
@@ -213,6 +247,7 @@ class Notifications extends React.PureComponent {
           onClick={this.handleHeaderClick}
           pinned={pinned}
           multiColumn={multiColumn}
+          extraButton={extraButton}
         >
           <ColumnSettingsContainer />
         </ColumnHeader>
diff --git a/app/javascript/mastodon/features/picture_in_picture/components/footer.js b/app/javascript/mastodon/features/picture_in_picture/components/footer.js
new file mode 100644
index 000000000..086cda954
--- /dev/null
+++ b/app/javascript/mastodon/features/picture_in_picture/components/footer.js
@@ -0,0 +1,137 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import IconButton from 'mastodon/components/icon_button';
+import classNames from 'classnames';
+import { me, boostModal } from 'mastodon/initial_state';
+import { defineMessages, injectIntl } from 'react-intl';
+import { replyCompose } from 'mastodon/actions/compose';
+import { reblog, favourite, unreblog, unfavourite } from 'mastodon/actions/interactions';
+import { makeGetStatus } from 'mastodon/selectors';
+import { openModal } from 'mastodon/actions/modal';
+
+const messages = defineMessages({
+  reply: { id: 'status.reply', defaultMessage: 'Reply' },
+  replyAll: { id: 'status.replyAll', defaultMessage: 'Reply to thread' },
+  reblog: { id: 'status.reblog', defaultMessage: 'Boost' },
+  reblog_private: { id: 'status.reblog_private', defaultMessage: 'Boost with original visibility' },
+  cancel_reblog_private: { id: 'status.cancel_reblog_private', defaultMessage: 'Unboost' },
+  cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be boosted' },
+  favourite: { id: 'status.favourite', defaultMessage: 'Favourite' },
+  replyConfirm: { id: 'confirmations.reply.confirm', defaultMessage: 'Reply' },
+  replyMessage: { id: 'confirmations.reply.message', defaultMessage: 'Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?' },
+});
+
+const makeMapStateToProps = () => {
+  const getStatus = makeGetStatus();
+
+  const mapStateToProps = (state, { statusId }) => ({
+    status: getStatus(state, { id: statusId }),
+    askReplyConfirmation: state.getIn(['compose', 'text']).trim().length !== 0,
+  });
+
+  return mapStateToProps;
+};
+
+export default @connect(makeMapStateToProps)
+@injectIntl
+class Footer extends ImmutablePureComponent {
+
+  static contextTypes = {
+    router: PropTypes.object,
+  };
+
+  static propTypes = {
+    statusId: PropTypes.string.isRequired,
+    status: ImmutablePropTypes.map.isRequired,
+    intl: PropTypes.object.isRequired,
+    dispatch: PropTypes.func.isRequired,
+    askReplyConfirmation: PropTypes.bool,
+  };
+
+  _performReply = () => {
+    const { dispatch, status } = this.props;
+    dispatch(replyCompose(status, this.context.router.history));
+  };
+
+  handleReplyClick = () => {
+    const { dispatch, askReplyConfirmation, intl } = this.props;
+
+    if (askReplyConfirmation) {
+      dispatch(openModal('CONFIRM', {
+        message: intl.formatMessage(messages.replyMessage),
+        confirm: intl.formatMessage(messages.replyConfirm),
+        onConfirm: this._performReply,
+      }));
+    } else {
+      this._performReply();
+    }
+  };
+
+  handleFavouriteClick = () => {
+    const { dispatch, status } = this.props;
+
+    if (status.get('favourited')) {
+      dispatch(unfavourite(status));
+    } else {
+      dispatch(favourite(status));
+    }
+  };
+
+  _performReblog = () => {
+    const { dispatch, status } = this.props;
+    dispatch(reblog(status));
+  }
+
+  handleReblogClick = e => {
+    const { dispatch, status } = this.props;
+
+    if (status.get('reblogged')) {
+      dispatch(unreblog(status));
+    } else if ((e && e.shiftKey) || !boostModal) {
+      this._performReblog();
+    } else {
+      dispatch(openModal('BOOST', { status, onReblog: this._performReblog }));
+    }
+  };
+
+  render () {
+    const { status, intl } = this.props;
+
+    const publicStatus  = ['public', 'unlisted'].includes(status.get('visibility'));
+    const reblogPrivate = status.getIn(['account', 'id']) === me && status.get('visibility') === 'private';
+
+    let replyIcon, replyTitle;
+
+    if (status.get('in_reply_to_id', null) === null) {
+      replyIcon = 'reply';
+      replyTitle = intl.formatMessage(messages.reply);
+    } else {
+      replyIcon = 'reply-all';
+      replyTitle = intl.formatMessage(messages.replyAll);
+    }
+
+    let reblogTitle = '';
+
+    if (status.get('reblogged')) {
+      reblogTitle = intl.formatMessage(messages.cancel_reblog_private);
+    } else if (publicStatus) {
+      reblogTitle = intl.formatMessage(messages.reblog);
+    } else if (reblogPrivate) {
+      reblogTitle = intl.formatMessage(messages.reblog_private);
+    } else {
+      reblogTitle = intl.formatMessage(messages.cannot_reblog);
+    }
+
+    return (
+      <div className='picture-in-picture__footer'>
+        <IconButton className='status__action-bar-button' title={replyTitle} icon={status.get('in_reply_to_account_id') === status.getIn(['account', 'id']) ? 'reply' : replyIcon} onClick={this.handleReplyClick} counter={status.get('replies_count')} obfuscateCount />
+        <IconButton className={classNames('status__action-bar-button', { reblogPrivate })} disabled={!publicStatus && !reblogPrivate}  active={status.get('reblogged')} pressed={status.get('reblogged')} title={reblogTitle} icon='retweet' onClick={this.handleReblogClick} counter={status.get('reblogs_count')} />
+        <IconButton className='status__action-bar-button star-icon' animate active={status.get('favourited')} pressed={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} counter={status.get('favourites_count')} />
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/mastodon/features/picture_in_picture/components/header.js b/app/javascript/mastodon/features/picture_in_picture/components/header.js
new file mode 100644
index 000000000..4cb6de1a4
--- /dev/null
+++ b/app/javascript/mastodon/features/picture_in_picture/components/header.js
@@ -0,0 +1,40 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import IconButton from 'mastodon/components/icon_button';
+import { Link } from 'react-router-dom';
+import Avatar from 'mastodon/components/avatar';
+import DisplayName from 'mastodon/components/display_name';
+
+const mapStateToProps = (state, { accountId }) => ({
+  account: state.getIn(['accounts', accountId]),
+});
+
+export default @connect(mapStateToProps)
+class Header extends ImmutablePureComponent {
+
+  static propTypes = {
+    accountId: PropTypes.string.isRequired,
+    statusId: PropTypes.string.isRequired,
+    account: ImmutablePropTypes.map.isRequired,
+    onClose: PropTypes.func.isRequired,
+  };
+
+  render () {
+    const { account, statusId, onClose } = this.props;
+
+    return (
+      <div className='picture-in-picture__header'>
+        <Link to={`/statuses/${statusId}`} className='picture-in-picture__header__account'>
+          <Avatar account={account} size={36} />
+          <DisplayName account={account} />
+        </Link>
+
+        <IconButton icon='times' onClick={onClose} title='Close' />
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/mastodon/features/picture_in_picture/index.js b/app/javascript/mastodon/features/picture_in_picture/index.js
new file mode 100644
index 000000000..1e59fbcd3
--- /dev/null
+++ b/app/javascript/mastodon/features/picture_in_picture/index.js
@@ -0,0 +1,85 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import Video from 'mastodon/features/video';
+import Audio from 'mastodon/features/audio';
+import { removePictureInPicture } from 'mastodon/actions/picture_in_picture';
+import Header from './components/header';
+import Footer from './components/footer';
+
+const mapStateToProps = state => ({
+  ...state.get('picture_in_picture'),
+});
+
+export default @connect(mapStateToProps)
+class PictureInPicture extends React.Component {
+
+  static propTypes = {
+    statusId: PropTypes.string,
+    accountId: PropTypes.string,
+    type: PropTypes.string,
+    src: PropTypes.string,
+    muted: PropTypes.bool,
+    volume: PropTypes.number,
+    currentTime: PropTypes.number,
+    poster: PropTypes.string,
+    backgroundColor: PropTypes.string,
+    foregroundColor: PropTypes.string,
+    accentColor: PropTypes.string,
+    dispatch: PropTypes.func.isRequired,
+  };
+
+  handleClose = () => {
+    const { dispatch } = this.props;
+    dispatch(removePictureInPicture());
+  }
+
+  render () {
+    const { type, src, currentTime, accountId, statusId } = this.props;
+
+    if (!currentTime) {
+      return null;
+    }
+
+    let player;
+
+    if (type === 'video') {
+      player = (
+        <Video
+          src={src}
+          currentTime={this.props.currentTime}
+          volume={this.props.volume}
+          muted={this.props.muted}
+          autoPlay
+          inline
+          alwaysVisible
+        />
+      );
+    } else if (type === 'audio') {
+      player = (
+        <Audio
+          src={src}
+          currentTime={this.props.currentTime}
+          volume={this.props.volume}
+          muted={this.props.muted}
+          poster={this.props.poster}
+          backgroundColor={this.props.backgroundColor}
+          foregroundColor={this.props.foregroundColor}
+          accentColor={this.props.accentColor}
+          autoPlay
+        />
+      );
+    }
+
+    return (
+      <div className='picture-in-picture'>
+        <Header accountId={accountId} statusId={statusId} onClose={this.handleClose} />
+
+        {player}
+
+        <Footer statusId={statusId} />
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/mastodon/features/status/components/detailed_status.js b/app/javascript/mastodon/features/status/components/detailed_status.js
index b1ae0b2cc..c2b883f7f 100644
--- a/app/javascript/mastodon/features/status/components/detailed_status.js
+++ b/app/javascript/mastodon/features/status/components/detailed_status.js
@@ -15,6 +15,7 @@ import scheduleIdleTask from '../../ui/util/schedule_idle_task';
 import classNames from 'classnames';
 import Icon from 'mastodon/components/icon';
 import AnimatedNumber from 'mastodon/components/animated_number';
+import PictureInPicturePlaceholder from 'mastodon/components/picture_in_picture_placeholder';
 
 const messages = defineMessages({
   public_short: { id: 'privacy.public.short', defaultMessage: 'Public' },
@@ -40,6 +41,7 @@ class DetailedStatus extends ImmutablePureComponent {
     domain: PropTypes.string.isRequired,
     compact: PropTypes.bool,
     showMedia: PropTypes.bool,
+    usingPiP: PropTypes.bool,
     onToggleMediaVisibility: PropTypes.func,
   };
 
@@ -100,7 +102,7 @@ class DetailedStatus extends ImmutablePureComponent {
   render () {
     const status = (this.props.status && this.props.status.get('reblog')) ? this.props.status.get('reblog') : this.props.status;
     const outerStyle = { boxSizing: 'border-box' };
-    const { intl, compact } = this.props;
+    const { intl, compact, usingPiP } = this.props;
 
     if (!status) {
       return null;
@@ -116,7 +118,9 @@ class DetailedStatus extends ImmutablePureComponent {
       outerStyle.height = `${this.state.height}px`;
     }
 
-    if (status.get('media_attachments').size > 0) {
+    if (usingPiP) {
+      media = <PictureInPicturePlaceholder />;
+    } else if (status.get('media_attachments').size > 0) {
       if (status.getIn(['media_attachments', 0, 'type']) === 'audio') {
         const attachment = status.getIn(['media_attachments', 0]);
 
diff --git a/app/javascript/mastodon/features/status/index.js b/app/javascript/mastodon/features/status/index.js
index 179df53a1..cf3a5fa44 100644
--- a/app/javascript/mastodon/features/status/index.js
+++ b/app/javascript/mastodon/features/status/index.js
@@ -143,6 +143,7 @@ const makeMapStateToProps = () => {
       descendantsIds,
       askReplyConfirmation: state.getIn(['compose', 'text']).trim().length !== 0,
       domain: state.getIn(['meta', 'domain']),
+      usingPiP: state.get('picture_in_picture').statusId === props.params.statusId,
     };
   };
 
@@ -167,6 +168,7 @@ class Status extends ImmutablePureComponent {
     askReplyConfirmation: PropTypes.bool,
     multiColumn: PropTypes.bool,
     domain: PropTypes.string.isRequired,
+    usingPiP: PropTypes.bool,
   };
 
   state = {
@@ -492,7 +494,7 @@ class Status extends ImmutablePureComponent {
 
   render () {
     let ancestors, descendants;
-    const { shouldUpdateScroll, status, ancestorsIds, descendantsIds, intl, domain, multiColumn } = this.props;
+    const { shouldUpdateScroll, status, ancestorsIds, descendantsIds, intl, domain, multiColumn, usingPiP } = this.props;
     const { fullscreen } = this.state;
 
     if (status === null) {
@@ -550,6 +552,7 @@ class Status extends ImmutablePureComponent {
                   domain={domain}
                   showMedia={this.state.showMedia}
                   onToggleMediaVisibility={this.handleToggleMediaVisibility}
+                  usingPiP={usingPiP}
                 />
 
                 <ActionBar
diff --git a/app/javascript/mastodon/features/ui/components/media_modal.js b/app/javascript/mastodon/features/ui/components/media_modal.js
index d7f97f210..a02e3bbd7 100644
--- a/app/javascript/mastodon/features/ui/components/media_modal.js
+++ b/app/javascript/mastodon/features/ui/components/media_modal.js
@@ -160,7 +160,7 @@ class MediaModal extends ImmutablePureComponent {
             src={image.get('url')}
             width={image.get('width')}
             height={image.get('height')}
-            startTime={time || 0}
+            currentTime={time || 0}
             onCloseVideo={onClose}
             detailed
             alt={image.get('description')}
diff --git a/app/javascript/mastodon/features/ui/components/video_modal.js b/app/javascript/mastodon/features/ui/components/video_modal.js
index e28bd5b49..9a07e7c4d 100644
--- a/app/javascript/mastodon/features/ui/components/video_modal.js
+++ b/app/javascript/mastodon/features/ui/components/video_modal.js
@@ -66,9 +66,9 @@ export default class VideoModal extends ImmutablePureComponent {
             preview={media.get('preview_url')}
             blurhash={media.get('blurhash')}
             src={media.get('url')}
-            startTime={options.startTime}
+            currentTime={options.startTime}
             autoPlay={options.autoPlay}
-            defaultVolume={options.defaultVolume}
+            volume={options.defaultVolume}
             onCloseVideo={onClose}
             detailed
             alt={media.get('description')}
diff --git a/app/javascript/mastodon/features/ui/index.js b/app/javascript/mastodon/features/ui/index.js
index 553cb3365..d05133507 100644
--- a/app/javascript/mastodon/features/ui/index.js
+++ b/app/javascript/mastodon/features/ui/index.js
@@ -16,11 +16,12 @@ import { expandNotifications } from '../../actions/notifications';
 import { fetchFilters } from '../../actions/filters';
 import { clearHeight } from '../../actions/height_cache';
 import { focusApp, unfocusApp } from 'mastodon/actions/app';
-import { synchronouslySubmitMarkers } from 'mastodon/actions/markers';
+import { synchronouslySubmitMarkers, submitMarkers, fetchMarkers } from 'mastodon/actions/markers';
 import { WrappedSwitch, WrappedRoute } from './util/react_router_helpers';
 import UploadArea from './components/upload_area';
 import ColumnsAreaContainer from './containers/columns_area_container';
 import DocumentTitle from './components/document_title';
+import PictureInPicture from 'mastodon/features/picture_in_picture';
 import {
   Compose,
   Status,
@@ -265,6 +266,7 @@ class UI extends React.PureComponent {
 
   handleWindowFocus = () => {
     this.props.dispatch(focusApp());
+    this.props.dispatch(submitMarkers());
   }
 
   handleWindowBlur = () => {
@@ -368,6 +370,7 @@ class UI extends React.PureComponent {
       window.setTimeout(() => Notification.requestPermission(), 120 * 1000);
     }
 
+    this.props.dispatch(fetchMarkers());
     this.props.dispatch(expandHomeTimeline());
     this.props.dispatch(expandNotifications());
 
@@ -545,6 +548,7 @@ class UI extends React.PureComponent {
             {children}
           </SwitchingColumnsArea>
 
+          <PictureInPicture />
           <NotificationsContainer />
           <LoadingBarContainer className='loading-bar' />
           <ModalContainer />
diff --git a/app/javascript/mastodon/features/video/index.js b/app/javascript/mastodon/features/video/index.js
index 99dcdca22..54c3baf76 100644
--- a/app/javascript/mastodon/features/video/index.js
+++ b/app/javascript/mastodon/features/video/index.js
@@ -104,20 +104,23 @@ class Video extends React.PureComponent {
     width: PropTypes.number,
     height: PropTypes.number,
     sensitive: PropTypes.bool,
-    startTime: PropTypes.number,
+    currentTime: PropTypes.number,
     onOpenVideo: PropTypes.func,
     onCloseVideo: PropTypes.func,
     detailed: PropTypes.bool,
     inline: PropTypes.bool,
     editable: PropTypes.bool,
+    alwaysVisible: PropTypes.bool,
     cacheWidth: PropTypes.func,
     visible: PropTypes.bool,
     onToggleVisibility: PropTypes.func,
+    deployPictureInPicture: PropTypes.func,
     intl: PropTypes.object.isRequired,
     blurhash: PropTypes.string,
     link: PropTypes.node,
     autoPlay: PropTypes.bool,
-    defaultVolume: PropTypes.number,
+    volume: PropTypes.number,
+    muted: PropTypes.bool,
   };
 
   state = {
@@ -297,6 +300,15 @@ class Video extends React.PureComponent {
     document.removeEventListener('webkitfullscreenchange', this.handleFullscreenChange, true);
     document.removeEventListener('mozfullscreenchange', this.handleFullscreenChange, true);
     document.removeEventListener('MSFullscreenChange', this.handleFullscreenChange, true);
+
+    if (!this.state.paused && this.video && this.props.deployPictureInPicture) {
+      this.props.deployPictureInPicture('video', {
+        src: this.props.src,
+        currentTime: this.video.currentTime,
+        muted: this.video.muted,
+        volume: this.video.volume,
+      });
+    }
   }
 
   componentWillReceiveProps (nextProps) {
@@ -328,7 +340,18 @@ class Video extends React.PureComponent {
     const inView = (top <= (window.innerHeight || document.documentElement.clientHeight)) && (top + height >= 0);
 
     if (!this.state.paused && !inView) {
-      this.setState({ paused: true }, () => this.video.pause());
+      this.video.pause();
+
+      if (this.props.deployPictureInPicture) {
+        this.props.deployPictureInPicture('video', {
+          src: this.props.src,
+          currentTime: this.video.currentTime,
+          muted: this.video.muted,
+          volume: this.video.volume,
+        });
+      }
+
+      this.setState({ paused: true });
     }
   }, 150, { trailing: true })
 
@@ -361,15 +384,21 @@ class Video extends React.PureComponent {
   }
 
   handleLoadedData = () => {
-    if (this.props.startTime) {
-      this.video.currentTime = this.props.startTime;
+    const { currentTime, volume, muted, autoPlay } = this.props;
+
+    if (currentTime) {
+      this.video.currentTime = currentTime;
     }
 
-    if (this.props.defaultVolume !== undefined) {
-      this.video.volume = this.props.defaultVolume;
+    if (volume !== undefined) {
+      this.video.volume = volume;
+    }
+
+    if (muted !== undefined) {
+      this.video.muted = muted;
     }
 
-    if (this.props.autoPlay) {
+    if (autoPlay) {
       this.video.play();
     }
   }
@@ -414,9 +443,9 @@ class Video extends React.PureComponent {
   }
 
   render () {
-    const { preview, src, inline, startTime, onOpenVideo, onCloseVideo, intl, alt, detailed, sensitive, link, editable, blurhash } = this.props;
+    const { preview, src, inline, onOpenVideo, onCloseVideo, intl, alt, detailed, sensitive, link, editable, blurhash } = this.props;
     const { containerWidth, currentTime, duration, volume, buffer, dragging, paused, fullscreen, hovered, muted, revealed } = this.state;
-    const progress = (currentTime / duration) * 100;
+    const progress = Math.min((currentTime / duration) * 100, 100);
     const playerStyle = {};
 
     let { width, height } = this.props;
@@ -430,7 +459,7 @@ class Video extends React.PureComponent {
 
     let preload;
 
-    if (startTime || fullscreen || dragging) {
+    if (this.props.currentTime || fullscreen || dragging) {
       preload = 'auto';
     } else if (detailed) {
       preload = 'metadata';
@@ -530,7 +559,7 @@ class Video extends React.PureComponent {
             </div>
 
             <div className='video-player__buttons right'>
-              {(!onCloseVideo && !editable && !fullscreen) && <button type='button' title={intl.formatMessage(messages.hide)} aria-label={intl.formatMessage(messages.hide)} onClick={this.toggleReveal}><Icon id='eye-slash' fixedWidth /></button>}
+              {(!onCloseVideo && !editable && !fullscreen && !this.props.alwaysVisible) && <button type='button' title={intl.formatMessage(messages.hide)} aria-label={intl.formatMessage(messages.hide)} onClick={this.toggleReveal}><Icon id='eye-slash' fixedWidth /></button>}
               {(!fullscreen && onOpenVideo) && <button type='button' title={intl.formatMessage(messages.expand)} aria-label={intl.formatMessage(messages.expand)} onClick={this.handleOpenVideo}><Icon id='expand' fixedWidth /></button>}
               {onCloseVideo && <button type='button' title={intl.formatMessage(messages.close)} aria-label={intl.formatMessage(messages.close)} onClick={this.handleCloseVideo}><Icon id='compress' fixedWidth /></button>}
               <button type='button' title={intl.formatMessage(fullscreen ? messages.exit_fullscreen : messages.fullscreen)} aria-label={intl.formatMessage(fullscreen ? messages.exit_fullscreen : messages.fullscreen)} onClick={this.toggleFullscreen}><Icon id={fullscreen ? 'compress' : 'arrows-alt'} fixedWidth /></button>
diff --git a/app/javascript/mastodon/reducers/index.js b/app/javascript/mastodon/reducers/index.js
index 3823bb05e..a8fb69c27 100644
--- a/app/javascript/mastodon/reducers/index.js
+++ b/app/javascript/mastodon/reducers/index.js
@@ -36,6 +36,7 @@ import trends from './trends';
 import missed_updates from './missed_updates';
 import announcements from './announcements';
 import markers from './markers';
+import picture_in_picture from './picture_in_picture';
 
 const reducers = {
   announcements,
@@ -75,6 +76,7 @@ const reducers = {
   trends,
   missed_updates,
   markers,
+  picture_in_picture,
 };
 
 export default combineReducers(reducers);
diff --git a/app/javascript/mastodon/reducers/notifications.js b/app/javascript/mastodon/reducers/notifications.js
index ed1ba0272..b01db806f 100644
--- a/app/javascript/mastodon/reducers/notifications.js
+++ b/app/javascript/mastodon/reducers/notifications.js
@@ -9,6 +9,7 @@ import {
   NOTIFICATIONS_LOAD_PENDING,
   NOTIFICATIONS_MOUNT,
   NOTIFICATIONS_UNMOUNT,
+  NOTIFICATIONS_MARK_AS_READ,
 } from '../actions/notifications';
 import {
   ACCOUNT_BLOCK_SUCCESS,
@@ -16,6 +17,13 @@ import {
   FOLLOW_REQUEST_AUTHORIZE_SUCCESS,
   FOLLOW_REQUEST_REJECT_SUCCESS,
 } from '../actions/accounts';
+import {
+  MARKERS_FETCH_SUCCESS,
+} from '../actions/markers';
+import {
+  APP_FOCUS,
+  APP_UNFOCUS,
+} from '../actions/app';
 import { DOMAIN_BLOCK_SUCCESS } from 'mastodon/actions/domain_blocks';
 import { TIMELINE_DELETE, TIMELINE_DISCONNECT } from '../actions/timelines';
 import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
@@ -26,8 +34,11 @@ const initialState = ImmutableMap({
   items: ImmutableList(),
   hasMore: true,
   top: false,
-  mounted: false,
+  mounted: 0,
   unread: 0,
+  lastReadId: '0',
+  readMarkerId: '0',
+  isTabVisible: true,
   isLoading: false,
 });
 
@@ -46,8 +57,10 @@ const normalizeNotification = (state, notification, usePendingItems) => {
     return state.update('pendingItems', list => list.unshift(notificationToMap(notification))).update('unread', unread => unread + 1);
   }
 
-  if (!top) {
+  if (shouldCountUnreadNotifications(state)) {
     state = state.update('unread', unread => unread + 1);
+  } else {
+    state = state.set('lastReadId', notification.id);
   }
 
   return state.update('items', list => {
@@ -60,6 +73,7 @@ const normalizeNotification = (state, notification, usePendingItems) => {
 };
 
 const expandNormalizedNotifications = (state, notifications, next, isLoadingRecent, usePendingItems) => {
+  const lastReadId = state.get('lastReadId');
   let items = ImmutableList();
 
   notifications.forEach((n, i) => {
@@ -87,6 +101,15 @@ const expandNormalizedNotifications = (state, notifications, next, isLoadingRece
       mutable.set('hasMore', false);
     }
 
+    if (shouldCountUnreadNotifications(state)) {
+      mutable.update('unread', unread => unread + items.count(item => compareId(item.get('id'), lastReadId) > 0));
+    } else {
+      const mostRecent = items.find(item => item !== null);
+      if (mostRecent && compareId(lastReadId, mostRecent.get('id')) < 0) {
+        mutable.set('lastReadId', mostRecent.get('id'));
+      }
+    }
+
     mutable.set('isLoading', false);
   });
 };
@@ -96,21 +119,92 @@ const filterNotifications = (state, accountIds, type) => {
   return state.update('items', helper).update('pendingItems', helper);
 };
 
+const clearUnread = (state) => {
+  state = state.set('unread', state.get('pendingItems').size);
+  const lastNotification = state.get('items').find(item => item !== null);
+  return state.set('lastReadId', lastNotification ? lastNotification.get('id') : '0');
+};
+
 const updateTop = (state, top) => {
-  if (top) {
-    state = state.set('unread', state.get('pendingItems').size);
+  state = state.set('top', top);
+
+  if (!shouldCountUnreadNotifications(state)) {
+    state = clearUnread(state);
   }
 
-  return state.set('top', top);
+  return state;
 };
 
 const deleteByStatus = (state, statusId) => {
+  const lastReadId = state.get('lastReadId');
+
+  if (shouldCountUnreadNotifications(state)) {
+    const deletedUnread = state.get('items').filter(item => item !== null && item.get('status') === statusId && compareId(item.get('id'), lastReadId) > 0);
+    state = state.update('unread', unread => unread - deletedUnread.size);
+  }
+
   const helper = list => list.filterNot(item => item !== null && item.get('status') === statusId);
+  const deletedUnread = state.get('pendingItems').filter(item => item !== null && item.get('status') === statusId && compareId(item.get('id'), lastReadId) > 0);
+  state = state.update('unread', unread => unread - deletedUnread.size);
   return state.update('items', helper).update('pendingItems', helper);
 };
 
+const updateMounted = (state) => {
+  state = state.update('mounted', count => count + 1);
+  if (!shouldCountUnreadNotifications(state)) {
+    state = state.set('readMarkerId', state.get('lastReadId'));
+    state = clearUnread(state);
+  }
+  return state;
+};
+
+const updateVisibility = (state, visibility) => {
+  state = state.set('isTabVisible', visibility);
+  if (!shouldCountUnreadNotifications(state)) {
+    state = state.set('readMarkerId', state.get('lastReadId'));
+    state = clearUnread(state);
+  }
+  return state;
+};
+
+const shouldCountUnreadNotifications = (state) => {
+  const isTabVisible   = state.get('isTabVisible');
+  const isOnTop        = state.get('top');
+  const isMounted      = state.get('mounted') > 0;
+  const lastReadId     = state.get('lastReadId');
+  const lastItemReached = !state.get('hasMore') || lastReadId === '0' || (!state.get('items').isEmpty() && compareId(state.get('items').last().get('id'), lastReadId) <= 0);
+
+  return !(isTabVisible && isOnTop && isMounted && lastItemReached);
+};
+
+const recountUnread = (state, last_read_id) => {
+  return state.withMutations(mutable => {
+    if (compareId(last_read_id, mutable.get('lastReadId')) > 0) {
+      mutable.set('lastReadId', last_read_id);
+    }
+
+    if (compareId(last_read_id, mutable.get('readMarkerId')) > 0) {
+      mutable.set('readMarkerId', last_read_id);
+    }
+
+    if (state.get('unread') > 0 || shouldCountUnreadNotifications(state)) {
+      mutable.set('unread', mutable.get('pendingItems').count(item => item !== null) + mutable.get('items').count(item => item && compareId(item.get('id'), last_read_id) > 0));
+    }
+  });
+};
+
 export default function notifications(state = initialState, action) {
   switch(action.type) {
+  case MARKERS_FETCH_SUCCESS:
+    return action.markers.notifications ? recountUnread(state, action.markers.notifications.last_read_id) : state;
+  case NOTIFICATIONS_MOUNT:
+    return updateMounted(state);
+  case NOTIFICATIONS_UNMOUNT:
+    return state.update('mounted', count => count - 1);
+  case APP_FOCUS:
+    return updateVisibility(state, true);
+  case APP_UNFOCUS:
+    return updateVisibility(state, false);
   case NOTIFICATIONS_LOAD_PENDING:
     return state.update('items', list => state.get('pendingItems').concat(list.take(40))).set('pendingItems', ImmutableList()).set('unread', 0);
   case NOTIFICATIONS_EXPAND_REQUEST:
@@ -144,10 +238,9 @@ export default function notifications(state = initialState, action) {
     return action.timeline === 'home' ?
       state.update(action.usePendingItems ? 'pendingItems' : 'items', items => items.first() ? items.unshift(null) : items) :
       state;
-  case NOTIFICATIONS_MOUNT:
-    return state.set('mounted', true);
-  case NOTIFICATIONS_UNMOUNT:
-    return state.set('mounted', false);
+  case NOTIFICATIONS_MARK_AS_READ:
+    const lastNotification = state.get('items').find(item => item !== null);
+    return lastNotification ? recountUnread(state, lastNotification.get('id')) : state;
   default:
     return state;
   }
diff --git a/app/javascript/mastodon/reducers/picture_in_picture.js b/app/javascript/mastodon/reducers/picture_in_picture.js
new file mode 100644
index 000000000..06cd8c5e8
--- /dev/null
+++ b/app/javascript/mastodon/reducers/picture_in_picture.js
@@ -0,0 +1,22 @@
+import { PICTURE_IN_PICTURE_DEPLOY, PICTURE_IN_PICTURE_REMOVE } from 'mastodon/actions/picture_in_picture';
+
+const initialState = {
+  statusId: null,
+  accountId: null,
+  type: null,
+  src: null,
+  muted: false,
+  volume: 0,
+  currentTime: 0,
+};
+
+export default function pictureInPicture(state = initialState, action) {
+  switch(action.type) {
+  case PICTURE_IN_PICTURE_DEPLOY:
+    return { statusId: action.statusId, accountId: action.accountId, type: action.playerType, ...action.props };
+  case PICTURE_IN_PICTURE_REMOVE:
+    return { ...initialState };
+  default:
+    return state;
+  }
+};
diff --git a/app/javascript/styles/contrast/diff.scss b/app/javascript/styles/contrast/diff.scss
index 5a40e7d79..841ed6648 100644
--- a/app/javascript/styles/contrast/diff.scss
+++ b/app/javascript/styles/contrast/diff.scss
@@ -75,3 +75,8 @@
 .public-layout .public-account-header__tabs__tabs .counter.active::after {
   border-bottom: 4px solid $ui-highlight-color;
 }
+
+.compose-form .autosuggest-textarea__textarea::placeholder,
+.compose-form .spoiler-input__input::placeholder {
+  color: $inverted-text-color;
+}
diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss
index 4f4507b37..ff4bb3428 100644
--- a/app/javascript/styles/mastodon/components.scss
+++ b/app/javascript/styles/mastodon/components.scss
@@ -163,7 +163,8 @@
 }
 
 .icon-button {
-  display: inline-block;
+  display: inline-flex;
+  align-items: center;
   padding: 0;
   color: $action-button-color;
   border: 0;
@@ -245,6 +246,14 @@
       background: rgba($base-overlay-background, 0.9);
     }
   }
+
+  &__counter {
+    display: inline-block;
+    width: 14px;
+    margin-left: 4px;
+    font-size: 12px;
+    font-weight: 500;
+  }
 }
 
 .text-icon-button {
@@ -1139,24 +1148,6 @@
   align-items: center;
   display: flex;
   margin-top: 8px;
-
-  &__counter {
-    display: inline-flex;
-    margin-right: 11px;
-    align-items: center;
-
-    .status__action-bar-button {
-      margin-right: 4px;
-    }
-
-    &__label {
-      display: inline-block;
-      width: 14px;
-      font-size: 12px;
-      font-weight: 500;
-      color: $action-button-color;
-    }
-  }
 }
 
 .status__action-bar-button {
@@ -6502,6 +6493,10 @@ noscript {
         padding: 2px;
       }
 
+      & > .icon-button {
+        margin-right: 8px;
+      }
+
       .button {
         margin: 0 8px;
       }
@@ -7011,3 +7006,119 @@ noscript {
     }
   }
 }
+
+.notification,
+.status__wrapper {
+  position: relative;
+
+  &.unread {
+    &::before {
+      content: "";
+      position: absolute;
+      top: 0;
+      left: 0;
+      pointer-events: 0;
+      width: 100%;
+      height: 100%;
+      border-left: 2px solid $highlight-text-color;
+      pointer-events: none;
+    }
+  }
+}
+
+.picture-in-picture {
+  position: fixed;
+  bottom: 20px;
+  right: 20px;
+  width: 300px;
+
+  &__footer {
+    border-radius: 0 0 4px 4px;
+    background: lighten($ui-base-color, 4%);
+    padding: 10px;
+    padding-top: 12px;
+    display: flex;
+    justify-content: space-between;
+  }
+
+  &__header {
+    border-radius: 4px 4px 0 0;
+    background: lighten($ui-base-color, 4%);
+    padding: 10px;
+    display: flex;
+    justify-content: space-between;
+
+    &__account {
+      display: flex;
+      text-decoration: none;
+    }
+
+    .account__avatar {
+      margin-right: 10px;
+    }
+
+    .display-name {
+      color: $primary-text-color;
+      text-decoration: none;
+
+      strong,
+      span {
+        display: block;
+        text-overflow: ellipsis;
+        overflow: hidden;
+      }
+
+      span {
+        color: $darker-text-color;
+      }
+    }
+  }
+
+  .video-player,
+  .audio-player {
+    border-radius: 0;
+  }
+
+  @media screen and (max-width: 415px) {
+    width: 210px;
+    bottom: 10px;
+    right: 10px;
+
+    &__footer {
+      display: none;
+    }
+
+    .video-player,
+    .audio-player {
+      border-radius: 0 0 4px 4px;
+    }
+  }
+}
+
+.picture-in-picture-placeholder {
+  box-sizing: border-box;
+  border: 2px dashed lighten($ui-base-color, 8%);
+  background: $base-shadow-color;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  margin-top: 10px;
+  font-size: 16px;
+  font-weight: 500;
+  cursor: pointer;
+  color: $darker-text-color;
+
+  i {
+    display: block;
+    font-size: 24px;
+    font-weight: 400;
+    margin-bottom: 10px;
+  }
+
+  &:hover,
+  &:focus,
+  &:active {
+    border-color: lighten($ui-base-color, 12%);
+  }
+}
diff --git a/app/lib/activitypub/activity.rb b/app/lib/activitypub/activity.rb
index 337f64d53..7567b66d4 100644
--- a/app/lib/activitypub/activity.rb
+++ b/app/lib/activitypub/activity.rb
@@ -118,13 +118,13 @@ class ActivityPub::Activity
   end
 
   def notify_about_reblog(status)
-    NotifyService.new.call(status.reblog.account, status)
+    NotifyService.new.call(status.reblog.account, :reblog, status)
   end
 
   def notify_about_mentions(status)
     status.active_mentions.includes(:account).each do |mention|
       next unless mention.account.local? && audience_includes?(mention.account)
-      NotifyService.new.call(mention.account, mention)
+      NotifyService.new.call(mention.account, :mention, mention)
     end
   end
 
diff --git a/app/lib/activitypub/activity/delete.rb b/app/lib/activitypub/activity/delete.rb
index 1420c6aff..ab2c34cfd 100644
--- a/app/lib/activitypub/activity/delete.rb
+++ b/app/lib/activitypub/activity/delete.rb
@@ -13,7 +13,7 @@ class ActivityPub::Activity::Delete < ActivityPub::Activity
 
   def delete_person
     lock_or_return("delete_in_progress:#{@account.id}") do
-      SuspendAccountService.new.call(@account, reserve_username: false)
+      DeleteAccountService.new.call(@account, reserve_username: false)
     end
   end
 
diff --git a/app/lib/activitypub/activity/follow.rb b/app/lib/activitypub/activity/follow.rb
index ec92f4255..0beec68ab 100644
--- a/app/lib/activitypub/activity/follow.rb
+++ b/app/lib/activitypub/activity/follow.rb
@@ -22,10 +22,10 @@ class ActivityPub::Activity::Follow < ActivityPub::Activity
     follow_request = FollowRequest.create!(account: @account, target_account: target_account, uri: @json['id'])
 
     if target_account.locked? || @account.silenced?
-      NotifyService.new.call(target_account, follow_request)
+      NotifyService.new.call(target_account, :follow_request, follow_request)
     else
       AuthorizeFollowService.new.call(@account, target_account)
-      NotifyService.new.call(target_account, ::Follow.find_by(account: @account, target_account: target_account))
+      NotifyService.new.call(target_account, :follow, ::Follow.find_by(account: @account, target_account: target_account))
     end
   end
 
diff --git a/app/lib/activitypub/activity/like.rb b/app/lib/activitypub/activity/like.rb
index 674d5fe47..c065f01f8 100644
--- a/app/lib/activitypub/activity/like.rb
+++ b/app/lib/activitypub/activity/like.rb
@@ -7,6 +7,6 @@ class ActivityPub::Activity::Like < ActivityPub::Activity
     return if original_status.nil? || !original_status.account.local? || delete_arrived_first?(@json['id']) || @account.favourited?(original_status)
 
     favourite = original_status.favourites.create!(account: @account)
-    NotifyService.new.call(original_status.account, favourite)
+    NotifyService.new.call(original_status.account, :favourite, favourite)
   end
 end
diff --git a/app/lib/request.rb b/app/lib/request.rb
index 89fbeaf95..38048dad7 100644
--- a/app/lib/request.rb
+++ b/app/lib/request.rb
@@ -253,7 +253,15 @@ class Request
       alias new open
 
       def check_private_address(address)
-        raise Mastodon::HostValidationError if PrivateAddressCheck.private_address?(IPAddr.new(address.to_s))
+        addr = IPAddr.new(address.to_s)
+        return if private_address_exceptions.any? { |range| range.include?(addr) }
+        raise Mastodon::HostValidationError if PrivateAddressCheck.private_address?(addr)
+      end
+
+      def private_address_exceptions
+        @private_address_exceptions = begin
+          (ENV['ALLOWED_PRIVATE_ADDRESSES'] || '').split(',').map { |addr| IPAddr.new(addr) }
+        end
       end
     end
   end
diff --git a/app/mailers/notification_mailer.rb b/app/mailers/notification_mailer.rb
index 9d8a7886c..54db892cc 100644
--- a/app/mailers/notification_mailer.rb
+++ b/app/mailers/notification_mailer.rb
@@ -10,7 +10,7 @@ class NotificationMailer < ApplicationMailer
     @me     = recipient
     @status = notification.target_status
 
-    return if @me.user.disabled? || @status.nil?
+    return unless @me.user.functional? && @status.present?
 
     locale_for_account(@me) do
       thread_by_conversation(@status.conversation)
@@ -22,7 +22,7 @@ class NotificationMailer < ApplicationMailer
     @me      = recipient
     @account = notification.from_account
 
-    return if @me.user.disabled?
+    return unless @me.user.functional?
 
     locale_for_account(@me) do
       mail to: @me.user.email, subject: I18n.t('notification_mailer.follow.subject', name: @account.acct)
@@ -34,7 +34,7 @@ class NotificationMailer < ApplicationMailer
     @account = notification.from_account
     @status  = notification.target_status
 
-    return if @me.user.disabled? || @status.nil?
+    return unless @me.user.functional? && @status.present?
 
     locale_for_account(@me) do
       thread_by_conversation(@status.conversation)
@@ -47,7 +47,7 @@ class NotificationMailer < ApplicationMailer
     @account = notification.from_account
     @status  = notification.target_status
 
-    return if @me.user.disabled? || @status.nil?
+    return unless @me.user.functional? && @status.present?
 
     locale_for_account(@me) do
       thread_by_conversation(@status.conversation)
@@ -59,7 +59,7 @@ class NotificationMailer < ApplicationMailer
     @me      = recipient
     @account = notification.from_account
 
-    return if @me.user.disabled?
+    return unless @me.user.functional?
 
     locale_for_account(@me) do
       mail to: @me.user.email, subject: I18n.t('notification_mailer.follow_request.subject', name: @account.acct)
@@ -67,7 +67,7 @@ class NotificationMailer < ApplicationMailer
   end
 
   def digest(recipient, **opts)
-    return if recipient.user.disabled?
+    return unless recipient.user.functional?
 
     @me                  = recipient
     @since               = opts[:since] || [@me.user.last_emailed_at, (@me.user.current_sign_in_at + 1.day)].compact.max
@@ -88,8 +88,10 @@ class NotificationMailer < ApplicationMailer
 
   def thread_by_conversation(conversation)
     return if conversation.nil?
+
     msg_id = "<conversation-#{conversation.id}.#{conversation.created_at.strftime('%Y-%m-%d')}@#{Rails.configuration.x.local_domain}>"
+
     headers['In-Reply-To'] = msg_id
-    headers['References'] = msg_id
+    headers['References']  = msg_id
   end
 end
diff --git a/app/mailers/user_mailer.rb b/app/mailers/user_mailer.rb
index b55768551..95996ba3f 100644
--- a/app/mailers/user_mailer.rb
+++ b/app/mailers/user_mailer.rb
@@ -15,7 +15,7 @@ class UserMailer < Devise::Mailer
     @token    = token
     @instance = Rails.configuration.x.local_domain
 
-    return if @resource.disabled?
+    return unless @resource.active_for_authentication?
 
     I18n.with_locale(@resource.locale || I18n.default_locale) do
       mail to: @resource.unconfirmed_email.presence || @resource.email,
@@ -29,7 +29,7 @@ class UserMailer < Devise::Mailer
     @token    = token
     @instance = Rails.configuration.x.local_domain
 
-    return if @resource.disabled?
+    return unless @resource.active_for_authentication?
 
     I18n.with_locale(@resource.locale || I18n.default_locale) do
       mail to: @resource.email, subject: I18n.t('devise.mailer.reset_password_instructions.subject')
@@ -40,7 +40,7 @@ class UserMailer < Devise::Mailer
     @resource = user
     @instance = Rails.configuration.x.local_domain
 
-    return if @resource.disabled?
+    return unless @resource.active_for_authentication?
 
     I18n.with_locale(@resource.locale || I18n.default_locale) do
       mail to: @resource.email, subject: I18n.t('devise.mailer.password_change.subject')
@@ -51,7 +51,7 @@ class UserMailer < Devise::Mailer
     @resource = user
     @instance = Rails.configuration.x.local_domain
 
-    return if @resource.disabled?
+    return unless @resource.active_for_authentication?
 
     I18n.with_locale(@resource.locale || I18n.default_locale) do
       mail to: @resource.email, subject: I18n.t('devise.mailer.email_changed.subject')
@@ -62,7 +62,7 @@ class UserMailer < Devise::Mailer
     @resource = user
     @instance = Rails.configuration.x.local_domain
 
-    return if @resource.disabled?
+    return unless @resource.active_for_authentication?
 
     I18n.with_locale(@resource.locale || I18n.default_locale) do
       mail to: @resource.email, subject: I18n.t('devise.mailer.two_factor_enabled.subject')
@@ -73,7 +73,7 @@ class UserMailer < Devise::Mailer
     @resource = user
     @instance = Rails.configuration.x.local_domain
 
-    return if @resource.disabled?
+    return unless @resource.active_for_authentication?
 
     I18n.with_locale(@resource.locale || I18n.default_locale) do
       mail to: @resource.email, subject: I18n.t('devise.mailer.two_factor_disabled.subject')
@@ -84,7 +84,7 @@ class UserMailer < Devise::Mailer
     @resource = user
     @instance = Rails.configuration.x.local_domain
 
-    return if @resource.disabled?
+    return unless @resource.active_for_authentication?
 
     I18n.with_locale(@resource.locale || I18n.default_locale) do
       mail to: @resource.email, subject: I18n.t('devise.mailer.two_factor_recovery_codes_changed.subject')
@@ -95,7 +95,7 @@ class UserMailer < Devise::Mailer
     @resource = user
     @instance = Rails.configuration.x.local_domain
 
-    return if @resource.disabled?
+    return unless @resource.active_for_authentication?
 
     I18n.with_locale(@resource.locale || I18n.default_locale) do
       mail to: @resource.email, subject: I18n.t('devise.mailer.webauthn_enabled.subject')
@@ -106,7 +106,7 @@ class UserMailer < Devise::Mailer
     @resource = user
     @instance = Rails.configuration.x.local_domain
 
-    return if @resource.disabled?
+    return unless @resource.active_for_authentication?
 
     I18n.with_locale(@resource.locale || I18n.default_locale) do
       mail to: @resource.email, subject: I18n.t('devise.mailer.webauthn_disabled.subject')
@@ -118,7 +118,7 @@ class UserMailer < Devise::Mailer
     @instance = Rails.configuration.x.local_domain
     @webauthn_credential = webauthn_credential
 
-    return if @resource.disabled?
+    return unless @resource.active_for_authentication?
 
     I18n.with_locale(@resource.locale || I18n.default_locale) do
       mail to: @resource.email, subject: I18n.t('devise.mailer.webauthn_credential.added.subject')
@@ -130,7 +130,7 @@ class UserMailer < Devise::Mailer
     @instance = Rails.configuration.x.local_domain
     @webauthn_credential = webauthn_credential
 
-    return if @resource.disabled?
+    return unless @resource.active_for_authentication?
 
     I18n.with_locale(@resource.locale || I18n.default_locale) do
       mail to: @resource.email, subject: I18n.t('devise.mailer.webauthn_credential.deleted.subject')
@@ -141,7 +141,7 @@ class UserMailer < Devise::Mailer
     @resource = user
     @instance = Rails.configuration.x.local_domain
 
-    return if @resource.disabled?
+    return unless @resource.active_for_authentication?
 
     I18n.with_locale(@resource.locale || I18n.default_locale) do
       mail to: @resource.email, subject: I18n.t('user_mailer.welcome.subject')
@@ -153,7 +153,7 @@ class UserMailer < Devise::Mailer
     @instance = Rails.configuration.x.local_domain
     @backup   = backup
 
-    return if @resource.disabled?
+    return unless @resource.active_for_authentication?
 
     I18n.with_locale(@resource.locale || I18n.default_locale) do
       mail to: @resource.email, subject: I18n.t('user_mailer.backup_ready.subject')
@@ -181,7 +181,7 @@ class UserMailer < Devise::Mailer
     @detection  = Browser.new(user_agent)
     @timestamp  = timestamp.to_time.utc
 
-    return if @resource.disabled?
+    return unless @resource.active_for_authentication?
 
     I18n.with_locale(@resource.locale || I18n.default_locale) do
       mail to: @resource.email,
diff --git a/app/models/account.rb b/app/models/account.rb
index c7bf7bf80..228e36755 100644
--- a/app/models/account.rb
+++ b/app/models/account.rb
@@ -233,23 +233,20 @@ class Account < ApplicationRecord
 
   def suspend!(date = Time.now.utc)
     transaction do
-      user&.disable! if local?
+      create_deletion_request!
       update!(suspended_at: date)
     end
   end
 
   def unsuspend!
     transaction do
-      user&.enable! if local?
+      deletion_request&.destroy!
       update!(suspended_at: nil)
     end
   end
 
   def memorialize!
-    transaction do
-      user&.disable! if local?
-      update!(memorial: true)
-    end
+    update!(memorial: true)
   end
 
   def sign?
diff --git a/app/models/account_conversation.rb b/app/models/account_conversation.rb
index 5e2ddd083..56fd13543 100644
--- a/app/models/account_conversation.rb
+++ b/app/models/account_conversation.rb
@@ -38,15 +38,16 @@ class AccountConversation < ApplicationRecord
   class << self
     def to_a_paginated_by_id(limit, options = {})
       if options[:min_id]
-        paginate_by_min_id(limit, options[:min_id]).reverse
+        paginate_by_min_id(limit, options[:min_id], options[:max_id]).reverse
       else
         paginate_by_max_id(limit, options[:max_id], options[:since_id]).to_a
       end
     end
 
-    def paginate_by_min_id(limit, min_id = nil)
+    def paginate_by_min_id(limit, min_id = nil, max_id = nil)
       query = order(arel_table[:last_status_id].asc).limit(limit)
       query = query.where(arel_table[:last_status_id].gt(min_id)) if min_id.present?
+      query = query.where(arel_table[:last_status_id].lt(max_id)) if max_id.present?
       query
     end
 
diff --git a/app/models/account_deletion_request.rb b/app/models/account_deletion_request.rb
new file mode 100644
index 000000000..7d0c346cc
--- /dev/null
+++ b/app/models/account_deletion_request.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+# == Schema Information
+#
+# Table name: account_deletion_requests
+#
+#  id         :bigint(8)        not null, primary key
+#  account_id :bigint(8)
+#  created_at :datetime         not null
+#  updated_at :datetime         not null
+#
+class AccountDeletionRequest < ApplicationRecord
+  DELAY_TO_DELETION = 30.days.freeze
+
+  belongs_to :account
+
+  def due_at
+    created_at + DELAY_TO_DELETION
+  end
+end
diff --git a/app/models/admin/account_action.rb b/app/models/admin/account_action.rb
index b30a82369..c4ac09520 100644
--- a/app/models/admin/account_action.rb
+++ b/app/models/admin/account_action.rb
@@ -134,7 +134,7 @@ class Admin::AccountAction
   end
 
   def process_email!
-    UserMailer.warning(target_account.user, warning, status_ids).deliver_now! if warnable?
+    UserMailer.warning(target_account.user, warning, status_ids).deliver_later! if warnable?
   end
 
   def warnable?
@@ -142,7 +142,7 @@ class Admin::AccountAction
   end
 
   def status_ids
-    @report.status_ids if @report && include_statuses
+    report.status_ids if report && include_statuses
   end
 
   def reports
diff --git a/app/models/concerns/account_associations.rb b/app/models/concerns/account_associations.rb
index 3b5e106fb..ec4e18699 100644
--- a/app/models/concerns/account_associations.rb
+++ b/app/models/concerns/account_associations.rb
@@ -61,6 +61,9 @@ module AccountAssociations
     has_and_belongs_to_many :tags
     has_many :featured_tags, -> { includes(:tag) }, dependent: :destroy, inverse_of: :account
 
+    # Account deletion requests
+    has_one :deletion_request, class_name: 'AccountDeletionRequest', inverse_of: :account, dependent: :destroy
+
     # Domain permissions
     has_many :domain_permissions, class_name: 'AccountDomainPermission', inverse_of: :account, dependent: :destroy
 
diff --git a/app/models/concerns/account_interactions.rb b/app/models/concerns/account_interactions.rb
index cf8e638e7..184064e94 100644
--- a/app/models/concerns/account_interactions.rb
+++ b/app/models/concerns/account_interactions.rb
@@ -8,6 +8,7 @@ module AccountInteractions
       Follow.where(target_account_id: target_account_ids, account_id: account_id).each_with_object({}) do |follow, mapping|
         mapping[follow.target_account_id] = {
           reblogs: follow.show_reblogs?,
+          notify: follow.notify?,
         }
       end
     end
@@ -36,6 +37,7 @@ module AccountInteractions
       FollowRequest.where(target_account_id: target_account_ids, account_id: account_id).each_with_object({}) do |follow_request, mapping|
         mapping[follow_request.target_account_id] = {
           reblogs: follow_request.show_reblogs?,
+          notify: follow_request.notify?,
         }
       end
     end
@@ -96,25 +98,29 @@ module AccountInteractions
     has_many :status_mutes, inverse_of: :account, dependent: :destroy
   end
 
-  def follow!(other_account, reblogs: nil, uri: nil, rate_limit: false)
-    reblogs = true if reblogs.nil?
-
-    rel = active_relationships.create_with(show_reblogs: reblogs, uri: uri, rate_limit: rate_limit)
+  def follow!(other_account, reblogs: nil, notify: nil, uri: nil, rate_limit: false)
+    rel = active_relationships.create_with(show_reblogs: reblogs.nil? ? true : reblogs, notify: notify.nil? ? false : notify, uri: uri, rate_limit: rate_limit)
                               .find_or_create_by!(target_account: other_account)
 
-    rel.update!(show_reblogs: reblogs)
+    rel.show_reblogs = reblogs unless reblogs.nil?
+    rel.notify       = notify  unless notify.nil?
+
+    rel.save! if rel.changed?
+
     remove_potential_friendship(other_account)
 
     rel
   end
 
-  def request_follow!(other_account, reblogs: nil, uri: nil, rate_limit: false)
-    reblogs = true if reblogs.nil?
-
-    rel = follow_requests.create_with(show_reblogs: reblogs, uri: uri, rate_limit: rate_limit)
+  def request_follow!(other_account, reblogs: nil, notify: nil, uri: nil, rate_limit: false)
+    rel = follow_requests.create_with(show_reblogs: reblogs.nil? ? true : reblogs, notify: notify.nil? ? false : notify, uri: uri, rate_limit: rate_limit)
                          .find_or_create_by!(target_account: other_account)
 
-    rel.update!(show_reblogs: reblogs)
+    rel.show_reblogs = reblogs unless reblogs.nil?
+    rel.notify       = notify  unless notify.nil?
+
+    rel.save! if rel.changed?
+
     remove_potential_friendship(other_account)
 
     rel
diff --git a/app/models/concerns/paginable.rb b/app/models/concerns/paginable.rb
index 760cc3df4..62e39f671 100644
--- a/app/models/concerns/paginable.rb
+++ b/app/models/concerns/paginable.rb
@@ -14,15 +14,16 @@ module Paginable
     # Differs from :paginate_by_max_id in that it gives the results immediately following min_id,
     # whereas since_id gives the items with largest id, but with since_id as a cutoff.
     # Results will be in ascending order by id.
-    scope :paginate_by_min_id, ->(limit, min_id = nil) {
+    scope :paginate_by_min_id, ->(limit, min_id = nil, max_id = nil) {
       query = reorder(arel_table[:id]).limit(limit)
       query = query.where(arel_table[:id].gt(min_id)) if min_id.present?
+      query = query.where(arel_table[:id].lt(max_id)) if max_id.present?
       query
     }
 
     def self.to_a_paginated_by_id(limit, options = {})
       if options[:min_id].present?
-        paginate_by_min_id(limit, options[:min_id]).reverse
+        paginate_by_min_id(limit, options[:min_id], options[:max_id]).reverse
       else
         paginate_by_max_id(limit, options[:max_id], options[:since_id]).to_a
       end
diff --git a/app/models/feed.rb b/app/models/feed.rb
index 36e0c1e0a..f51dcfab1 100644
--- a/app/models/feed.rb
+++ b/app/models/feed.rb
@@ -20,12 +20,12 @@ class Feed
   protected
 
   def from_redis(limit, max_id, since_id, min_id)
+    max_id = '+inf' if max_id.blank?
     if min_id.blank?
-      max_id     = '+inf' if max_id.blank?
       since_id   = '-inf' if since_id.blank?
       unhydrated = redis.zrevrangebyscore(key, "(#{max_id}", "(#{since_id}", limit: [0, limit], with_scores: true).map(&:first).map(&:to_i)
     else
-      unhydrated = redis.zrangebyscore(key, "(#{min_id}", '+inf', limit: [0, limit], with_scores: true).map(&:first).map(&:to_i)
+      unhydrated = redis.zrangebyscore(key, "(#{min_id}", "(#{max_id}", limit: [0, limit], with_scores: true).map(&:first).map(&:to_i)
     end
 
     Status.where(id: unhydrated).cache_ids
diff --git a/app/models/follow.rb b/app/models/follow.rb
index f3e48a2ed..0b4ddbf3f 100644
--- a/app/models/follow.rb
+++ b/app/models/follow.rb
@@ -10,6 +10,7 @@
 #  target_account_id :bigint(8)        not null
 #  show_reblogs      :boolean          default(TRUE), not null
 #  uri               :string
+#  notify            :boolean          default(FALSE), not null
 #
 
 class Follow < ApplicationRecord
@@ -34,7 +35,7 @@ class Follow < ApplicationRecord
   end
 
   def revoke_request!
-    FollowRequest.create!(account: account, target_account: target_account, show_reblogs: show_reblogs, uri: uri)
+    FollowRequest.create!(account: account, target_account: target_account, show_reblogs: show_reblogs, notify: notify, uri: uri)
     destroy!
   end
 
diff --git a/app/models/follow_request.rb b/app/models/follow_request.rb
index cdf0f4bda..5899a7f0e 100644
--- a/app/models/follow_request.rb
+++ b/app/models/follow_request.rb
@@ -10,6 +10,7 @@
 #  target_account_id :bigint(8)        not null
 #  show_reblogs      :boolean          default(TRUE), not null
 #  uri               :string
+#  notify            :boolean          default(FALSE), not null
 #
 
 class FollowRequest < ApplicationRecord
@@ -28,7 +29,7 @@ class FollowRequest < ApplicationRecord
   validates_with FollowLimitValidator, on: :create
 
   def authorize!
-    account.follow!(target_account, reblogs: show_reblogs, uri: uri)
+    account.follow!(target_account, reblogs: show_reblogs, notify: notify, uri: uri)
     if account.local?
       MergeWorker.perform_async(target_account.id, account.id)
       ActivityPub::SyncAccountWorker.perform_async(target_account.id, every_page: true) unless target_account.local?
diff --git a/app/models/form/account_batch.rb b/app/models/form/account_batch.rb
index 0b285fde9..7b9e40f68 100644
--- a/app/models/form/account_batch.rb
+++ b/app/models/form/account_batch.rb
@@ -69,6 +69,6 @@ class Form::AccountBatch
     records = accounts.includes(:user)
 
     records.each { |account| authorize(account.user, :reject?) }
-           .each { |account| SuspendAccountService.new.call(account, reserve_email: false, reserve_username: false) }
+           .each { |account| DeleteAccountService.new.call(account, reserve_email: false, reserve_username: false) }
   end
 end
diff --git a/app/models/invite.rb b/app/models/invite.rb
index 4695b4ebb..d60866ad6 100644
--- a/app/models/invite.rb
+++ b/app/models/invite.rb
@@ -28,7 +28,7 @@ class Invite < ApplicationRecord
   before_validation :set_code
 
   def valid_for_use?
-    (max_uses.nil? || uses < max_uses) && !expired? && !(user.nil? || user.disabled?)
+    (max_uses.nil? || uses < max_uses) && !expired? && user&.functional?
   end
 
   private
diff --git a/app/models/notification.rb b/app/models/notification.rb
index ad7528f50..e83123c97 100644
--- a/app/models/notification.rb
+++ b/app/models/notification.rb
@@ -10,21 +10,34 @@
 #  updated_at      :datetime         not null
 #  account_id      :bigint(8)        not null
 #  from_account_id :bigint(8)        not null
+#  type            :string
 #
 
 class Notification < ApplicationRecord
+  self.inheritance_column = nil
+
   include Paginable
   include Cacheable
 
-  TYPE_CLASS_MAP = {
-    mention:        'Mention',
-    reblog:         'Status',
-    follow:         'Follow',
-    follow_request: 'FollowRequest',
-    favourite:      'Favourite',
-    poll:           'Poll',
+  LEGACY_TYPE_CLASS_MAP = {
+    'Mention'       => :mention,
+    'Status'        => :reblog,
+    'Follow'        => :follow,
+    'FollowRequest' => :follow_request,
+    'Favourite'     => :favourite,
+    'Poll'          => :poll,
   }.freeze
 
+  TYPES = %i(
+    mention
+    status
+    reblog
+    follow
+    follow_request
+    favourite
+    poll
+  ).freeze
+
   STATUS_INCLUDES = [:account, :application, :preloadable_poll, :media_attachments, :tags, active_mentions: :account, reblog: [:account, :application, :preloadable_poll, :media_attachments, :tags, active_mentions: :account]].freeze
 
   belongs_to :account, optional: true
@@ -38,26 +51,30 @@ class Notification < ApplicationRecord
   belongs_to :favourite,      foreign_type: 'Favourite',     foreign_key: 'activity_id', optional: true
   belongs_to :poll,           foreign_type: 'Poll',          foreign_key: 'activity_id', optional: true
 
-  validates :account_id, uniqueness: { scope: [:activity_type, :activity_id] }
-  validates :activity_type, inclusion: { in: TYPE_CLASS_MAP.values }
+  validates :type, inclusion: { in: TYPES }
+
+  scope :without_suspended, -> { joins(:from_account).merge(Account.without_suspended) }
 
   scope :browserable, ->(exclude_types = [], account_id = nil) {
-    types = TYPE_CLASS_MAP.values - activity_types_from_types(exclude_types)
+    types = TYPES - exclude_types.map(&:to_sym)
+
     if account_id.nil?
-      where(activity_type: types)
+      where(type: types)
     else
-      where(activity_type: types, from_account_id: account_id)
+      where(type: types, from_account_id: account_id)
     end
   }
 
   cache_associated :from_account, status: STATUS_INCLUDES, mention: [status: STATUS_INCLUDES], favourite: [:account, status: STATUS_INCLUDES], follow: :account, follow_request: :account, poll: [status: STATUS_INCLUDES]
 
   def type
-    @type ||= TYPE_CLASS_MAP.invert[activity_type].to_sym
+    @type ||= (super || LEGACY_TYPE_CLASS_MAP[activity_type]).to_sym
   end
 
   def target_status
     case type
+    when :status
+      status
     when :reblog
       status&.reblog
     when :favourite
@@ -86,10 +103,6 @@ class Notification < ApplicationRecord
         item.target_status.account = accounts[item.target_status.account_id] if item.target_status
       end
     end
-
-    def activity_types_from_types(types)
-      types.map { |type| TYPE_CLASS_MAP[type.to_sym] }.compact
-    end
   end
 
   after_initialize :set_from_account
diff --git a/app/models/status.rb b/app/models/status.rb
index 8c9a74902..dd7fad4fd 100644
--- a/app/models/status.rb
+++ b/app/models/status.rb
@@ -22,6 +22,7 @@
 #  application_id         :bigint(8)
 #  in_reply_to_account_id :bigint(8)
 #  local_only             :boolean          default(FALSE), not null
+#  full_status_text       :text             default(""), not null
 #  poll_id                :bigint(8)
 #  content_type           :string
 #  deleted_at             :datetime
@@ -62,7 +63,7 @@ class Status < ApplicationRecord
   belongs_to :application, class_name: 'Doorkeeper::Application', optional: true
 
   belongs_to :account, inverse_of: :statuses
-  belongs_to :in_reply_to_account, class_name: 'Account', optional: true
+  belongs_to :in_reply_to_account, foreign_key: 'in_reply_to_account_id', class_name: 'Account', optional: true
   belongs_to :conversation, optional: true
   belongs_to :preloadable_poll, class_name: 'Poll', foreign_key: 'poll_id', optional: true
 
@@ -111,19 +112,19 @@ class Status < ApplicationRecord
   scope :without_reblogs, -> { where('statuses.reblog_of_id IS NULL') }
   scope :with_public_visibility, -> { where(visibility: :public, published: true) }
   scope :distributable, -> { where(visibility: [:public, :unlisted], published: true) }
-  scope :tagged_with, ->(tag) { joins(:statuses_tags).where(statuses_tags: { tag_id: tag }) }
+  scope :tagged_with, ->(tag_ids) { joins(:statuses_tags).where(statuses_tags: { tag_id: tag_ids }) }
   scope :in_chosen_languages, ->(account) { where(language: nil).or where(language: account.chosen_languages) }
   scope :excluding_silenced_accounts, -> { left_outer_joins(:account).where(accounts: { silenced_at: nil }) }
   scope :including_silenced_accounts, -> { left_outer_joins(:account).where.not(accounts: { silenced_at: nil }) }
   scope :not_excluded_by_account, ->(account) { where.not(account_id: account.excluded_from_timeline_account_ids) }
   scope :not_domain_blocked_by_account, ->(account) { account.excluded_from_timeline_domains.blank? ? left_outer_joins(:account) : left_outer_joins(:account).where('accounts.domain IS NULL OR accounts.domain NOT IN (?)', account.excluded_from_timeline_domains) }
-  scope :tagged_with_all, ->(tags) {
-    Array(tags).map(&:id).map(&:to_i).reduce(self) do |result, id|
+  scope :tagged_with_all, ->(tag_ids) {
+    Array(tag_ids).reduce(self) do |result, id|
       result.joins("INNER JOIN statuses_tags t#{id} ON t#{id}.status_id = statuses.id AND t#{id}.tag_id = #{id}")
     end
   }
-  scope :tagged_with_none, ->(tags) {
-    Array(tags).map(&:id).map(&:to_i).reduce(self) do |result, id|
+  scope :tagged_with_none, ->(tag_ids) {
+    Array(tag_ids).reduce(self) do |result, id|
       result.joins("LEFT OUTER JOIN statuses_tags t#{id} ON t#{id}.status_id = statuses.id AND t#{id}.tag_id = #{id}")
             .where("t#{id}.tag_id IS NULL")
     end
@@ -249,7 +250,7 @@ class Status < ApplicationRecord
   end
 
   def hidden?
-    !published? || !distributable?
+    !(published? || distributable?)
   end
 
   def distributable?
@@ -486,7 +487,7 @@ class Status < ApplicationRecord
 
       return if account_ids.empty?
 
-      accounts = Account.where(id: account_ids).includes(:account_stat).index_by(&:id)
+      accounts = Account.where(id: account_ids).includes(:account_stat).each_with_object({}) { |a, h| h[a.id] = a }
 
       cached_items.each do |item|
         item.account = accounts[item.account_id]
diff --git a/app/models/tag_feed.rb b/app/models/tag_feed.rb
index baff55020..a7d583a7e 100644
--- a/app/models/tag_feed.rb
+++ b/app/models/tag_feed.rb
@@ -53,6 +53,6 @@ class TagFeed < PublicFeed
   end
 
   def tags_for(names)
-    Tag.matching_name(Array(names).take(LIMIT_PER_MODE)) if names.present?
+    Tag.matching_name(Array(names).take(LIMIT_PER_MODE)).pluck(:id) if names.present?
   end
 end
diff --git a/app/models/user.rb b/app/models/user.rb
index fa16f91b8..07fb9e1bb 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -178,7 +178,7 @@ class User < ApplicationRecord
   end
 
   def active_for_authentication?
-    true
+    !account.memorial?
   end
 
   def suspicious_sign_in?(ip)
@@ -186,7 +186,7 @@ class User < ApplicationRecord
   end
 
   def functional?
-    confirmed? && approved? && !disabled? && !account.suspended?
+    confirmed? && approved? && !disabled? && !account.suspended? && !account.memorial?
   end
 
   def unconfirmed_or_pending?
diff --git a/app/models/webauthn_credential.rb b/app/models/webauthn_credential.rb
index 4129ce539..7d423e38d 100644
--- a/app/models/webauthn_credential.rb
+++ b/app/models/webauthn_credential.rb
@@ -18,5 +18,5 @@ class WebauthnCredential < ApplicationRecord
   validates :external_id, uniqueness: true
   validates :nickname, uniqueness: { scope: :user_id }
   validates :sign_count,
-            numericality: { only_integer: true, greater_than_or_equal_to: 0, less_than_or_equal_to: 2**32 - 1 }
+            numericality: { only_integer: true, greater_than_or_equal_to: 0, less_than_or_equal_to: 2**63 - 1 }
 end
diff --git a/app/policies/account_policy.rb b/app/policies/account_policy.rb
index 9c145979d..1b105e92a 100644
--- a/app/policies/account_policy.rb
+++ b/app/policies/account_policy.rb
@@ -17,6 +17,10 @@ class AccountPolicy < ApplicationPolicy
     staff? && !record.user&.staff?
   end
 
+  def destroy?
+    record.suspended? && record.deletion_request.present? && admin?
+  end
+
   def unsuspend?
     staff?
   end
diff --git a/app/policies/status_policy.rb b/app/policies/status_policy.rb
index db600fe40..56c217cec 100644
--- a/app/policies/status_policy.rb
+++ b/app/policies/status_policy.rb
@@ -12,15 +12,16 @@ class StatusPolicy < ApplicationPolicy
   end
 
   def show?
+    return false if author.suspended?
     return false if local_only? && !current_account&.local?
     return false unless published? || owned?
 
     if requires_mention?
       owned? || mention_exists?
     elsif private?
-      owned? || following_owners? || mention_exists?
+      owned? || following_author? || mention_exists?
     else
-      current_account.nil? || !blocked_by_owners?
+      current_account.nil? || (!author_blocking? && !author_blocking_domain?)
     end
   end
 
@@ -84,24 +85,16 @@ class StatusPolicy < ApplicationPolicy
     @preloaded_relations[:blocked_by] ? @preloaded_relations[:blocked_by][author.id] : author.blocking?(current_account)
   end
 
-  def blocked_by_owners?
-    author_blocking? || author_blocking_domain?
-  end
-
   def following_author?
     return false if current_account.nil?
 
     @preloaded_relations[:following] ? @preloaded_relations[:following][author.id] : current_account.following?(author)
   end
 
-  def following_owners?
-    following_author?
-  end
-
   def author
-    @author ||= record.account
+    record.account
   end
-
+  
   def local_only?
     record.local_only?
   end
@@ -110,10 +103,6 @@ class StatusPolicy < ApplicationPolicy
     record.published?
   end
 
-  def reply?
-    record.reply? && record.in_reply_to_account_id != author.id
-  end
-
   def visibility_for_remote_domain
     @visibility_for_domain ||= record.visibility_for_domain(current_account&.domain)
   end
diff --git a/app/serializers/rest/account_serializer.rb b/app/serializers/rest/account_serializer.rb
index e425c34a0..133f66201 100644
--- a/app/serializers/rest/account_serializer.rb
+++ b/app/serializers/rest/account_serializer.rb
@@ -10,8 +10,11 @@ class REST::AccountSerializer < ActiveModel::Serializer
   attributes :require_dereference, :show_replies, :show_unlisted
 
   has_one :moved_to_account, key: :moved, serializer: REST::AccountSerializer, if: :moved_and_not_nested?
+
   has_many :emojis, serializer: REST::CustomEmojiSerializer
 
+  attribute :suspended, if: :suspended?
+
   class FieldSerializer < ActiveModel::Serializer
     attributes :name, :value, :verified_at
 
@@ -31,7 +34,7 @@ class REST::AccountSerializer < ActiveModel::Serializer
   end
 
   def note
-    Formatter.instance.simplified_format(object)
+    object.suspended? ? '' : Formatter.instance.simplified_format(object)
   end
 
   def url
@@ -39,23 +42,19 @@ class REST::AccountSerializer < ActiveModel::Serializer
   end
 
   def avatar
-    full_asset_url(object.avatar_original_url)
+    full_asset_url(object.suspended? ? object.avatar.default_url : object.avatar_original_url)
   end
 
   def avatar_static
-    full_asset_url(object.avatar_static_url)
+    full_asset_url(object.suspended? ? object.avatar.default_url : object.avatar_static_url)
   end
 
   def header
-    full_asset_url(object.header_original_url)
+    full_asset_url(object.suspended? ? object.header.default_url : object.header_original_url)
   end
 
   def header_static
-    full_asset_url(object.header_static_url)
-  end
-
-  def moved_and_not_nested?
-    object.moved? && object.moved_to_account.moved_to_account_id.nil?
+    full_asset_url(object.suspended? ? object.header.default_url : object.header_static_url)
   end
 
   def last_status_at
@@ -65,4 +64,42 @@ class REST::AccountSerializer < ActiveModel::Serializer
   def followers_count
     (Setting.hide_followers_count || object.user&.setting_hide_followers_count) ? -1 : object.followers_count
   end
+
+  def display_name
+    object.suspended? ? '' : object.display_name
+  end
+
+  def locked
+    object.suspended? ? false : object.locked
+  end
+
+  def bot
+    object.suspended? ? false : object.bot
+  end
+
+  def discoverable
+    object.suspended? ? false : object.discoverable
+  end
+
+  def moved_to_account
+    object.suspended? ? nil : object.moved_to_account
+  end
+
+  def emojis
+    object.suspended? ? [] : object.emojis
+  end
+
+  def fields
+    object.suspended? ? [] : object.fields
+  end
+
+  def suspended
+    object.suspended?
+  end
+
+  delegate :suspended?, to: :object
+
+  def moved_and_not_nested?
+    object.moved? && object.moved_to_account.moved_to_account_id.nil?
+  end
 end
diff --git a/app/serializers/rest/notification_serializer.rb b/app/serializers/rest/notification_serializer.rb
index 80812ad0d..27b031fcc 100644
--- a/app/serializers/rest/notification_serializer.rb
+++ b/app/serializers/rest/notification_serializer.rb
@@ -11,6 +11,6 @@ class REST::NotificationSerializer < ActiveModel::Serializer
   end
 
   def status_type?
-    [:favourite, :reblog, :mention, :poll].include?(object.type)
+    [:favourite, :reblog, :status, :mention, :poll].include?(object.type)
   end
 end
diff --git a/app/serializers/rest/relationship_serializer.rb b/app/serializers/rest/relationship_serializer.rb
index c2f3c9a11..afd4cddf9 100644
--- a/app/serializers/rest/relationship_serializer.rb
+++ b/app/serializers/rest/relationship_serializer.rb
@@ -1,9 +1,9 @@
 # frozen_string_literal: true
 
 class REST::RelationshipSerializer < ActiveModel::Serializer
-  attributes :id, :following, :showing_reblogs, :followed_by, :blocking, :blocked_by,
-             :muting, :muting_notifications, :requested, :domain_blocking,
-             :endorsed, :note
+  attributes :id, :following, :showing_reblogs, :notifying, :followed_by,
+             :blocking, :blocked_by, :muting, :muting_notifications, :requested,
+             :domain_blocking, :endorsed, :note
 
   def id
     object.id.to_s
@@ -19,6 +19,12 @@ class REST::RelationshipSerializer < ActiveModel::Serializer
       false
   end
 
+  def notifying
+    (instance_options[:relationships].following[object.id] || {})[:notify] ||
+      (instance_options[:relationships].requested[object.id] || {})[:notify] ||
+      false
+  end
+
   def followed_by
     instance_options[:relationships].followed_by[object.id] || false
   end
diff --git a/app/services/after_unallow_domain_service.rb b/app/services/after_unallow_domain_service.rb
index ccd0b8ae9..d3008a105 100644
--- a/app/services/after_unallow_domain_service.rb
+++ b/app/services/after_unallow_domain_service.rb
@@ -3,7 +3,7 @@
 class AfterUnallowDomainService < BaseService
   def call(domain)
     Account.where(domain: domain).find_each do |account|
-      SuspendAccountService.new.call(account, reserve_username: false)
+      DeleteAccountService.new.call(account, reserve_username: false)
     end
   end
 end
diff --git a/app/services/block_domain_service.rb b/app/services/block_domain_service.rb
index d3f7cbc4d..98af0fdee 100644
--- a/app/services/block_domain_service.rb
+++ b/app/services/block_domain_service.rb
@@ -37,7 +37,7 @@ class BlockDomainService < BaseService
   def suspend_accounts!
     blocked_domain_accounts.without_suspended.in_batches.update_all(suspended_at: @domain_block.created_at)
     blocked_domain_accounts.where(suspended_at: @domain_block.created_at).reorder(nil).find_each do |account|
-      SuspendAccountService.new.call(account, reserve_username: true, suspended_at: @domain_block.created_at)
+      DeleteAccountService.new.call(account, reserve_username: true, suspended_at: @domain_block.created_at)
     end
   end
 
diff --git a/app/services/delete_account_service.rb b/app/services/delete_account_service.rb
new file mode 100644
index 000000000..15bdd13e3
--- /dev/null
+++ b/app/services/delete_account_service.rb
@@ -0,0 +1,180 @@
+# frozen_string_literal: true
+
+class DeleteAccountService < BaseService
+  include Payloadable
+
+  ASSOCIATIONS_ON_SUSPEND = %w(
+    account_pins
+    active_relationships
+    block_relationships
+    blocked_by_relationships
+    conversation_mutes
+    conversations
+    custom_filters
+    domain_blocks
+    favourites
+    follow_requests
+    list_accounts
+    mute_relationships
+    muted_by_relationships
+    notifications
+    owned_lists
+    passive_relationships
+    report_notes
+    scheduled_statuses
+    status_pins
+  ).freeze
+
+  ASSOCIATIONS_ON_DESTROY = %w(
+    reports
+    targeted_moderation_notes
+    targeted_reports
+  ).freeze
+
+  # Suspend or remove an account and remove as much of its data
+  # as possible. If it's a local account and it has not been confirmed
+  # or never been approved, then side effects are skipped and both
+  # the user and account records are removed fully. Otherwise,
+  # it is controlled by options.
+  # @param [Account]
+  # @param [Hash] options
+  # @option [Boolean] :reserve_email Keep user record. Only applicable for local accounts
+  # @option [Boolean] :reserve_username Keep account record
+  # @option [Boolean] :skip_side_effects Side effects are ActivityPub and streaming API payloads
+  # @option [Time]    :suspended_at Only applicable when :reserve_username is true
+  def call(account, **options)
+    @account = account
+    @options = { reserve_username: true, reserve_email: true }.merge(options)
+
+    if @account.local? && @account.user_unconfirmed_or_pending?
+      @options[:reserve_email]     = false
+      @options[:reserve_username]  = false
+      @options[:skip_side_effects] = true
+    end
+
+    reject_follows!
+    purge_user!
+    purge_profile!
+    purge_content!
+    fulfill_deletion_request!
+  end
+
+  private
+
+  def reject_follows!
+    return if @account.local? || !@account.activitypub?
+
+    ActivityPub::DeliveryWorker.push_bulk(Follow.where(account: @account)) do |follow|
+      [build_reject_json(follow), follow.target_account_id, follow.account.inbox_url]
+    end
+  end
+
+  def purge_user!
+    return if !@account.local? || @account.user.nil?
+
+    if @options[:reserve_email]
+      @account.user.disable!
+      @account.user.invites.where(uses: 0).destroy_all
+    else
+      @account.user.destroy
+    end
+  end
+
+  def purge_content!
+    distribute_delete_actor! if @account.local? && !@options[:skip_side_effects]
+
+    @account.statuses.reorder(nil).find_in_batches do |statuses|
+      statuses.reject! { |status| reported_status_ids.include?(status.id) } if @options[:reserve_username]
+      BatchedRemoveStatusService.new.call(statuses, skip_side_effects: @options[:skip_side_effects])
+    end
+
+    @account.media_attachments.reorder(nil).find_each do |media_attachment|
+      next if @options[:reserve_username] && reported_status_ids.include?(media_attachment.status_id)
+
+      media_attachment.destroy
+    end
+
+    @account.polls.reorder(nil).find_each do |poll|
+      next if @options[:reserve_username] && reported_status_ids.include?(poll.status_id)
+
+      poll.destroy
+    end
+
+    associations_for_destruction.each do |association_name|
+      destroy_all(@account.public_send(association_name))
+    end
+
+    @account.destroy unless @options[:reserve_username]
+  end
+
+  def purge_profile!
+    # If the account is going to be destroyed
+    # there is no point wasting time updating
+    # its values first
+
+    return unless @options[:reserve_username]
+
+    @account.silenced_at      = nil
+    @account.suspended_at     = @options[:suspended_at] || Time.now.utc
+    @account.locked           = false
+    @account.memorial         = false
+    @account.discoverable     = false
+    @account.display_name     = ''
+    @account.note             = ''
+    @account.fields           = []
+    @account.statuses_count   = 0
+    @account.followers_count  = 0
+    @account.following_count  = 0
+    @account.moved_to_account = nil
+    @account.trust_level      = :untrusted
+    @account.avatar.destroy
+    @account.header.destroy
+    @account.save!
+  end
+
+  def fulfill_deletion_request!
+    @account.deletion_request&.destroy
+  end
+
+  def destroy_all(association)
+    association.in_batches.destroy_all
+  end
+
+  def distribute_delete_actor!
+    ActivityPub::DeliveryWorker.push_bulk(delivery_inboxes) do |inbox_url|
+      [delete_actor_json, @account.id, inbox_url]
+    end
+
+    ActivityPub::LowPriorityDeliveryWorker.push_bulk(low_priority_delivery_inboxes) do |inbox_url|
+      [delete_actor_json, @account.id, inbox_url]
+    end
+  end
+
+  def delete_actor_json
+    @delete_actor_json ||= Oj.dump(serialize_payload(@account, ActivityPub::DeleteActorSerializer, signer: @account))
+  end
+
+  def build_reject_json(follow)
+    Oj.dump(serialize_payload(follow, ActivityPub::RejectFollowSerializer))
+  end
+
+  def delivery_inboxes
+    @delivery_inboxes ||= @account.followers.inboxes + Relay.enabled.pluck(:inbox_url)
+  end
+
+  def low_priority_delivery_inboxes
+    Account.inboxes - delivery_inboxes
+  end
+
+  def reported_status_ids
+    @reported_status_ids ||= Report.where(target_account: @account).unresolved.pluck(:status_ids).flatten.uniq
+  end
+
+  def associations_for_destruction
+    if @options[:reserve_username]
+      ASSOCIATIONS_ON_SUSPEND
+    else
+      ASSOCIATIONS_ON_SUSPEND + ASSOCIATIONS_ON_DESTROY
+    end
+  end
+end
diff --git a/app/services/favourite_service.rb b/app/services/favourite_service.rb
index af08f5267..f3e89746b 100644
--- a/app/services/favourite_service.rb
+++ b/app/services/favourite_service.rb
@@ -30,7 +30,7 @@ class FavouriteService < BaseService
     status = favourite.status
 
     if status.account.local?
-      NotifyService.new.call(status.account, favourite)
+      NotifyService.new.call(status.account, :favourite, favourite)
     elsif status.account.activitypub?
       ActivityPub::DeliveryWorker.perform_async(build_json(favourite), favourite.account_id, status.account.inbox_url)
     end
diff --git a/app/services/follow_service.rb b/app/services/follow_service.rb
index 311ae7fa6..962572851 100644
--- a/app/services/follow_service.rb
+++ b/app/services/follow_service.rb
@@ -9,12 +9,13 @@ class FollowService < BaseService
   # @param [String, Account] uri User URI to follow in the form of username@domain (or account record)
   # @param [Hash] options
   # @option [Boolean] :reblogs Whether or not to show reblogs, defaults to true
+  # @option [Boolean] :notify Whether to create notifications about new posts, defaults to false
   # @option [Boolean] :bypass_locked
   # @option [Boolean] :with_rate_limit
   def call(source_account, target_account, options = {})
     @source_account = source_account
     @target_account = ResolveAccountService.new.call(target_account, skip_webfinger: true)
-    @options        = { reblogs: true, bypass_locked: false, with_rate_limit: false }.merge(options)
+    @options        = { bypass_locked: false, with_rate_limit: false }.merge(options)
 
     raise ActiveRecord::RecordNotFound if following_not_possible?
     raise Mastodon::NotPermittedError  if following_not_allowed?
@@ -45,18 +46,18 @@ class FollowService < BaseService
   end
 
   def change_follow_options!
-    @source_account.follow!(@target_account, reblogs: @options[:reblogs])
+    @source_account.follow!(@target_account, reblogs: @options[:reblogs], notify: @options[:notify])
   end
 
   def change_follow_request_options!
-    @source_account.request_follow!(@target_account, reblogs: @options[:reblogs])
+    @source_account.request_follow!(@target_account, reblogs: @options[:reblogs], notify: @options[:notify])
   end
 
   def request_follow!
-    follow_request = @source_account.request_follow!(@target_account, reblogs: @options[:reblogs], rate_limit: @options[:with_rate_limit])
+    follow_request = @source_account.request_follow!(@target_account, reblogs: @options[:reblogs], notify: @options[:notify], rate_limit: @options[:with_rate_limit])
 
     if @target_account.local?
-      LocalNotificationWorker.perform_async(@target_account.id, follow_request.id, follow_request.class.name)
+      LocalNotificationWorker.perform_async(@target_account.id, follow_request.id, follow_request.class.name, :follow_request)
     elsif @target_account.activitypub?
       ActivityPub::DeliveryWorker.perform_async(build_json(follow_request), @source_account.id, @target_account.inbox_url)
     end
@@ -65,9 +66,9 @@ class FollowService < BaseService
   end
 
   def direct_follow!
-    follow = @source_account.follow!(@target_account, reblogs: @options[:reblogs], rate_limit: @options[:with_rate_limit])
+    follow = @source_account.follow!(@target_account, reblogs: @options[:reblogs], notify: @options[:notify], rate_limit: @options[:with_rate_limit])
 
-    LocalNotificationWorker.perform_async(@target_account.id, follow.id, follow.class.name)
+    LocalNotificationWorker.perform_async(@target_account.id, follow.id, follow.class.name, :follow)
     MergeWorker.perform_async(@target_account.id, @source_account.id)
 
     follow
diff --git a/app/services/import_service.rb b/app/services/import_service.rb
index 4cad93767..7e55452de 100644
--- a/app/services/import_service.rb
+++ b/app/services/import_service.rb
@@ -25,7 +25,7 @@ class ImportService < BaseService
 
   def import_follows!
     parse_import_data!(['Account address'])
-    import_relationships!('follow', 'unfollow', @account.following, follow_limit, reblogs: 'Show boosts')
+    import_relationships!('follow', 'unfollow', @account.following, follow_limit, reblogs: { header: 'Show boosts', default: true })
   end
 
   def import_blocks!
@@ -35,7 +35,7 @@ class ImportService < BaseService
 
   def import_mutes!
     parse_import_data!(['Account address'])
-    import_relationships!('mute', 'unmute', @account.muting, ROWS_PROCESSING_LIMIT, notifications: 'Hide notifications')
+    import_relationships!('mute', 'unmute', @account.muting, ROWS_PROCESSING_LIMIT, notifications: { header: 'Hide notifications', default: true })
   end
 
   def import_domain_blocks!
@@ -65,7 +65,7 @@ class ImportService < BaseService
 
   def import_relationships!(action, undo_action, overwrite_scope, limit, extra_fields = {})
     local_domain_suffix = "@#{Rails.configuration.x.local_domain}"
-    items = @data.take(limit).map { |row| [row['Account address']&.strip&.delete_suffix(local_domain_suffix), Hash[extra_fields.map { |key, header| [key, row[header]&.strip] }]] }.reject { |(id, _)| id.blank? }
+    items = @data.take(limit).map { |row| [row['Account address']&.strip&.delete_suffix(local_domain_suffix), Hash[extra_fields.map { |key, field_settings| [key, row[field_settings[:header]]&.strip || field_settings[:default]] }]] }.reject { |(id, _)| id.blank? }
 
     if @import.overwrite?
       presence_hash = items.each_with_object({}) { |(id, extra), mapping| mapping[id] = [true, extra] }
diff --git a/app/services/notify_service.rb b/app/services/notify_service.rb
index 755fad768..c241c3ca0 100644
--- a/app/services/notify_service.rb
+++ b/app/services/notify_service.rb
@@ -1,10 +1,10 @@
 # frozen_string_literal: true
 
 class NotifyService < BaseService
-  def call(recipient, activity)
+  def call(recipient, type, activity)
     @recipient    = recipient
     @activity     = activity
-    @notification = Notification.new(account: @recipient, activity: @activity)
+    @notification = Notification.new(account: @recipient, type: type, activity: @activity)
 
     return if recipient.user.nil? || blocked?
 
@@ -22,6 +22,10 @@ class NotifyService < BaseService
     FeedManager.instance.filter?(:mentions, @notification.mention.status, @recipient)
   end
 
+  def blocked_status?
+    false
+  end
+
   def blocked_favourite?
     false
   end
diff --git a/app/services/process_mentions_service.rb b/app/services/process_mentions_service.rb
index b5134bf9c..e4aad7147 100644
--- a/app/services/process_mentions_service.rb
+++ b/app/services/process_mentions_service.rb
@@ -30,7 +30,7 @@ class ProcessMentionsService < BaseService
     mentioned_account = mention.account
 
     if mentioned_account.local?
-      LocalNotificationWorker.perform_async(mentioned_account.id, mention.id, mention.class.name) unless !@status.notify? || mention.silent?
+      LocalNotificationWorker.perform_async(mentioned_account.id, mention.id, mention.class.name, :mention) unless !@status.notify? || mention.silent?
     elsif mentioned_account.activitypub? && !@status.local_only?
       ActivityPub::DeliveryWorker.perform_async(activitypub_json(mentioned_account.domain), mention.status.account_id, mentioned_account.inbox_url)
     end
diff --git a/app/services/reblog_service.rb b/app/services/reblog_service.rb
index 86b37560a..3188bbb69 100644
--- a/app/services/reblog_service.rb
+++ b/app/services/reblog_service.rb
@@ -46,7 +46,7 @@ class ReblogService < BaseService
     reblogged_status = reblog.reblog
 
     if reblogged_status.account.local?
-      LocalNotificationWorker.perform_async(reblogged_status.account_id, reblog.id, reblog.class.name)
+      LocalNotificationWorker.perform_async(reblogged_status.account_id, reblog.id, reblog.class.name, :reblog)
     elsif reblogged_status.account.activitypub? && !reblogged_status.account.following?(reblog.account)
       ActivityPub::DeliveryWorker.perform_async(build_json(reblog), reblog.account_id, reblogged_status.account.inbox_url)
     end
diff --git a/app/services/suspend_account_service.rb b/app/services/suspend_account_service.rb
index ecc893931..5a079c3ac 100644
--- a/app/services/suspend_account_service.rb
+++ b/app/services/suspend_account_service.rb
@@ -1,175 +1,52 @@
 # frozen_string_literal: true
 
 class SuspendAccountService < BaseService
-  include Payloadable
-
-  ASSOCIATIONS_ON_SUSPEND = %w(
-    account_pins
-    active_relationships
-    block_relationships
-    blocked_by_relationships
-    conversation_mutes
-    conversations
-    custom_filters
-    domain_blocks
-    favourites
-    follow_requests
-    list_accounts
-    mute_relationships
-    muted_by_relationships
-    notifications
-    owned_lists
-    passive_relationships
-    report_notes
-    scheduled_statuses
-    status_pins
-  ).freeze
-
-  ASSOCIATIONS_ON_DESTROY = %w(
-    reports
-    targeted_moderation_notes
-    targeted_reports
-  ).freeze
-
-  # Suspend or remove an account and remove as much of its data
-  # as possible. If it's a local account and it has not been confirmed
-  # or never been approved, then side effects are skipped and both
-  # the user and account records are removed fully. Otherwise,
-  # it is controlled by options.
-  # @param [Account]
-  # @param [Hash] options
-  # @option [Boolean] :reserve_email Keep user record. Only applicable for local accounts
-  # @option [Boolean] :reserve_username Keep account record
-  # @option [Boolean] :skip_side_effects Side effects are ActivityPub and streaming API payloads
-  # @option [Time]    :suspended_at Only applicable when :reserve_username is true
-  def call(account, **options)
+  def call(account)
     @account = account
-    @options = { reserve_username: true, reserve_email: true }.merge(options)
-
-    if @account.local? && @account.user_unconfirmed_or_pending?
-      @options[:reserve_email]     = false
-      @options[:reserve_username]  = false
-      @options[:skip_side_effects] = true
-    end
 
-    reject_follows!
-    purge_user!
-    purge_profile!
-    purge_content!
+    suspend!
+    unmerge_from_home_timelines!
+    unmerge_from_list_timelines!
+    privatize_media_attachments!
   end
 
   private
 
-  def reject_follows!
-    return if @account.local? || !@account.activitypub?
-
-    ActivityPub::DeliveryWorker.push_bulk(Follow.where(account: @account)) do |follow|
-      [build_reject_json(follow), follow.target_account_id, follow.account.inbox_url]
-    end
+  def suspend!
+    @account.suspend! unless @account.suspended?
   end
 
-  def purge_user!
-    return if !@account.local? || @account.user.nil?
-
-    if @options[:reserve_email]
-      @account.user.disable!
-      @account.user.invites.where(uses: 0).destroy_all
-    else
-      @account.user.destroy
+  def unmerge_from_home_timelines!
+    @account.followers_for_local_distribution.find_each do |follower|
+      FeedManager.instance.unmerge_from_timeline(@account, follower)
     end
   end
 
-  def purge_content!
-    distribute_delete_actor! if @account.local? && !@options[:skip_side_effects]
-
-    @account.statuses.reorder(nil).find_in_batches do |statuses|
-      statuses.reject! { |status| reported_status_ids.include?(status.id) } if @options[:reserve_username]
-      BatchedRemoveStatusService.new.call(statuses, skip_side_effects: @options[:skip_side_effects])
+  def unmerge_from_list_timelines!
+    @account.lists_for_local_distribution.find_each do |list|
+      FeedManager.instance.unmerge_from_list(@account, list)
     end
-
-    @account.media_attachments.reorder(nil).find_each do |media_attachment|
-      next if @options[:reserve_username] && reported_status_ids.include?(media_attachment.status_id)
-
-      media_attachment.destroy
-    end
-
-    @account.polls.reorder(nil).find_each do |poll|
-      next if @options[:reserve_username] && reported_status_ids.include?(poll.status_id)
-
-      poll.destroy
-    end
-
-    associations_for_destruction.each do |association_name|
-      destroy_all(@account.public_send(association_name))
-    end
-
-    @account.destroy unless @options[:reserve_username]
   end
 
-  def purge_profile!
-    # If the account is going to be destroyed
-    # there is no point wasting time updating
-    # its values first
-
-    return unless @options[:reserve_username]
+  def privatize_media_attachments!
+    attachment_names = MediaAttachment.attachment_definitions.keys
 
-    @account.silenced_at      = nil
-    @account.suspended_at     = @options[:suspended_at] || Time.now.utc
-    @account.locked           = false
-    @account.memorial         = false
-    @account.discoverable     = false
-    @account.display_name     = ''
-    @account.note             = ''
-    @account.fields           = []
-    @account.statuses_count   = 0
-    @account.followers_count  = 0
-    @account.following_count  = 0
-    @account.moved_to_account = nil
-    @account.trust_level      = :untrusted
-    @account.avatar.destroy
-    @account.header.destroy
-    @account.save!
-  end
-
-  def destroy_all(association)
-    association.in_batches.destroy_all
-  end
-
-  def distribute_delete_actor!
-    ActivityPub::DeliveryWorker.push_bulk(delivery_inboxes) do |inbox_url|
-      [delete_actor_json, @account.id, inbox_url]
-    end
-
-    ActivityPub::LowPriorityDeliveryWorker.push_bulk(low_priority_delivery_inboxes) do |inbox_url|
-      [delete_actor_json, @account.id, inbox_url]
-    end
-  end
-
-  def delete_actor_json
-    @delete_actor_json ||= Oj.dump(serialize_payload(@account, ActivityPub::DeleteActorSerializer, signer: @account))
-  end
-
-  def build_reject_json(follow)
-    Oj.dump(serialize_payload(follow, ActivityPub::RejectFollowSerializer))
-  end
-
-  def delivery_inboxes
-    @delivery_inboxes ||= @account.followers.inboxes + Relay.enabled.pluck(:inbox_url)
-  end
-
-  def low_priority_delivery_inboxes
-    Account.inboxes - delivery_inboxes
-  end
-
-  def reported_status_ids
-    @reported_status_ids ||= Report.where(target_account: @account).unresolved.pluck(:status_ids).flatten.uniq
-  end
+    @account.media_attachments.find_each do |media_attachment|
+      attachment_names.each do |attachment_name|
+        attachment = media_attachment.public_send(attachment_name)
+        styles     = [:original] | attachment.styles.keys
 
-  def associations_for_destruction
-    if @options[:reserve_username]
-      ASSOCIATIONS_ON_SUSPEND
-    else
-      ASSOCIATIONS_ON_SUSPEND + ASSOCIATIONS_ON_DESTROY
+        styles.each do |style|
+          case Paperclip::Attachment.default_options[:storage]
+          when :s3
+            attachment.s3_object(style).acl.put(:private)
+          when :fog
+            # Not supported
+          when :filesystem
+            FileUtils.chmod(0o600 & ~File.umask, attachment.path(style))
+          end
+        end
+      end
     end
   end
 end
diff --git a/app/services/unsuspend_account_service.rb b/app/services/unsuspend_account_service.rb
new file mode 100644
index 000000000..3e731ddd9
--- /dev/null
+++ b/app/services/unsuspend_account_service.rb
@@ -0,0 +1,52 @@
+# frozen_string_literal: true
+
+class UnsuspendAccountService < BaseService
+  def call(account)
+    @account = account
+
+    unsuspend!
+    merge_into_home_timelines!
+    merge_into_list_timelines!
+    publish_media_attachments!
+  end
+
+  private
+
+  def unsuspend!
+    @account.unsuspend! if @account.suspended?
+  end
+
+  def merge_into_home_timelines!
+    @account.followers_for_local_distribution.find_each do |follower|
+      FeedManager.instance.merge_into_timeline(@account, follower)
+    end
+  end
+
+  def merge_into_list_timelines!
+    @account.lists_for_local_distribution.find_each do |list|
+      FeedManager.instance.merge_into_list(@account, list)
+    end
+  end
+
+  def publish_media_attachments!
+    attachment_names = MediaAttachment.attachment_definitions.keys
+
+    @account.media_attachments.find_each do |media_attachment|
+      attachment_names.each do |attachment_name|
+        attachment = media_attachment.public_send(attachment_name)
+        styles     = [:original] | attachment.styles.keys
+
+        styles.each do |style|
+          case Paperclip::Attachment.default_options[:storage]
+          when :s3
+            attachment.s3_object(style).acl.put(Paperclip::Attachment.default_options[:s3_permissions])
+          when :fog
+            # Not supported
+          when :filesystem
+            FileUtils.chmod(0o666 & ~File.umask, attachment.path(style))
+          end
+        end
+      end
+    end
+  end
+end
diff --git a/app/views/admin/accounts/show.html.haml b/app/views/admin/accounts/show.html.haml
index e6461aad0..f0a216f6b 100644
--- a/app/views/admin/accounts/show.html.haml
+++ b/app/views/admin/accounts/show.html.haml
@@ -56,19 +56,21 @@
     = link_to admin_action_logs_path(target_account_id: @account.id) do
       .dashboard__counters__text
         - if @account.local? && @account.user.nil?
-          %span.neutral= t('admin.accounts.deleted')
+          = t('admin.accounts.deleted')
+        - elsif @account.memorial?
+          = t('admin.accounts.memorialized')
         - elsif @account.suspended?
-          %span.red= t('admin.accounts.suspended')
+          = t('admin.accounts.suspended')
         - elsif @account.silenced?
-          %span.red= t('admin.accounts.silenced')
+          = t('admin.accounts.silenced')
         - elsif @account.local? && @account.user&.disabled?
-          %span.red= t('admin.accounts.disabled')
+          = t('admin.accounts.disabled')
         - elsif @account.local? && !@account.user&.confirmed?
-          %span.neutral= t('admin.accounts.confirming')
+          = t('admin.accounts.confirming')
         - elsif @account.local? && !@account.user_approved?
-          %span.neutral= t('admin.accounts.pending')
+          = t('admin.accounts.pending')
         - else
-          %span.neutral= t('admin.accounts.no_limits_imposed')
+          = t('admin.accounts.no_limits_imposed')
       .dashboard__counters__label= t 'admin.accounts.login_status'
 
 - unless @account.local? && @account.user.nil?
@@ -123,19 +125,6 @@
             %td= table_link_to 'refresh', t('admin.accounts.resend_confirmation.send'), resend_admin_account_confirmation_path(@account.id), method: :post if can?(:confirm, @account.user)
 
           %tr
-            %th= t('admin.accounts.login_status')
-            %td
-              - if @account.user&.disabled?
-                = t('admin.accounts.disabled')
-              - else
-                = t('admin.accounts.enabled')
-            %td
-              - if @account.user&.disabled?
-                = table_link_to 'unlock', t('admin.accounts.enable'), enable_admin_account_path(@account.id), method: :post if can?(:enable, @account.user)
-              - elsif @account.user_approved?
-                = table_link_to 'lock', t('admin.accounts.disable'), new_admin_account_action_path(@account.id, type: 'disable') if can?(:disable, @account.user)
-
-          %tr
             %th= t('simple_form.labels.defaults.locale')
             %td= @account.user_locale
             %td
@@ -172,49 +161,62 @@
             %td
               = @account.inbox_url
               = fa_icon DeliveryFailureTracker.available?(@account.inbox_url) ? 'check' : 'times'
+            %td
+              = table_link_to 'search', @domain_block.present? ? t('admin.domain_blocks.view') : t('admin.accounts.view_domain'), admin_instance_path(@account.domain)
           %tr
             %th= t('admin.accounts.shared_inbox_url')
             %td
               = @account.shared_inbox_url
               = fa_icon DeliveryFailureTracker.available?(@account.shared_inbox_url) ? 'check': 'times'
+            %td
+              - if @domain_block.nil?
+                = table_link_to 'ban', t('admin.domain_blocks.add_new'), new_admin_domain_block_path(_domain: @account.domain)
+
+  - if @account.suspended?
+    %hr.spacer/
+
+    %p.muted-hint= @deletion_request.present? ? t('admin.accounts.suspension_reversible_hint_html', date: content_tag(:strong, l(@deletion_request.due_at.to_date))) : t('admin.accounts.suspension_irreversible')
+
+    = link_to t('admin.accounts.undo_suspension'), unsuspend_admin_account_path(@account.id), method: :post, class: 'button' if can?(:unsuspend, @account)
 
-  %div.action-buttons
-    %div
-      - if @account.local? && @account.user_approved?
-        = link_to t('admin.accounts.warn'), new_admin_account_action_path(@account.id, type: 'none'), class: 'button' if can?(:warn, @account)
-      - if @account.silenced?
-        = link_to t('admin.accounts.undo_silenced'), unsilence_admin_account_path(@account.id), method: :post, class: 'button' if can?(:unsilence, @account)
-      - elsif !@account.local? || @account.user_approved?
-        = link_to t('admin.accounts.silence'), new_admin_account_action_path(@account.id, type: 'silence'), class: 'button button--destructive' if can?(:silence, @account)
-
-      - if @account.local?
-        - if @account.user_pending?
-          = link_to t('admin.accounts.approve'), approve_admin_account_path(@account.id), method: :post, data: { confirm: t('admin.accounts.are_you_sure') }, class: 'button' if can?(:approve, @account.user)
-          = link_to t('admin.accounts.reject'), reject_admin_account_path(@account.id), method: :post, data: { confirm: t('admin.accounts.are_you_sure') }, class: 'button button--destructive' if can?(:reject, @account.user)
-
-        - unless @account.user_confirmed?
-          = link_to t('admin.accounts.confirm'), admin_account_confirmation_path(@account.id), method: :post, class: 'button' if can?(:confirm, @account.user)
-
-      - if @account.suspended?
-        = link_to t('admin.accounts.undo_suspension'), unsuspend_admin_account_path(@account.id), method: :post, class: 'button' if can?(:unsuspend, @account)
-      - elsif !@account.local? || @account.user_approved?
-        = link_to t('admin.accounts.perform_full_suspension'), new_admin_account_action_path(@account.id, type: 'suspend'), class: 'button button--destructive' if can?(:suspend, @account)
-
-      - unless @account.local?
-        - if DomainBlock.rule_for(@account.domain)
-          = link_to t('admin.domain_blocks.view'), admin_instance_path(@account.domain), class: 'button'
+    - if @deletion_request.present?
+      = link_to t('admin.accounts.delete'), admin_account_path(@account.id), method: :delete, class: 'button button--destructive', data: { confirm: t('admin.accounts.are_you_sure') } if can?(:destroy, @account)
+  - else
+    %div.action-buttons
+      %div
+        - if @account.local? && @account.user_approved?
+          = link_to t('admin.accounts.warn'), new_admin_account_action_path(@account.id, type: 'none'), class: 'button' if can?(:warn, @account)
+
+          - if @account.user_disabled?
+            = link_to t('admin.accounts.enable'), enable_admin_account_path(@account.id), method: :post, class: 'button' if can?(:enable, @account.user)
+          - else
+            = link_to t('admin.accounts.disable'), new_admin_account_action_path(@account.id, type: 'disable'), class: 'button' if can?(:disable, @account.user)
+
+        - if @account.silenced?
+          = link_to t('admin.accounts.undo_silenced'), unsilence_admin_account_path(@account.id), method: :post, class: 'button' if can?(:unsilence, @account)
+        - elsif !@account.local? || @account.user_approved?
+          = link_to t('admin.accounts.silence'), new_admin_account_action_path(@account.id, type: 'silence'), class: 'button' if can?(:silence, @account)
+
+        - if @account.local?
+          - if @account.user_pending?
+            = link_to t('admin.accounts.approve'), approve_admin_account_path(@account.id), method: :post, data: { confirm: t('admin.accounts.are_you_sure') }, class: 'button' if can?(:approve, @account.user)
+            = link_to t('admin.accounts.reject'), reject_admin_account_path(@account.id), method: :post, data: { confirm: t('admin.accounts.are_you_sure') }, class: 'button button--destructive' if can?(:reject, @account.user)
+
+          - unless @account.user_confirmed?
+            = link_to t('admin.accounts.confirm'), admin_account_confirmation_path(@account.id), method: :post, class: 'button' if can?(:confirm, @account.user)
+
+        - if !@account.local? || @account.user_approved?
+          = link_to t('admin.accounts.perform_full_suspension'), new_admin_account_action_path(@account.id, type: 'suspend'), class: 'button' if can?(:suspend, @account)
+
+      %div
+        - if @account.local?
+          = link_to t('admin.accounts.reset_password'), admin_account_reset_path(@account.id), method: :create, class: 'button' if can?(:reset_password, @account.user)
+          - if @account.user&.otp_required_for_login?
+            = link_to t('admin.accounts.disable_two_factor_authentication'), admin_user_two_factor_authentication_path(@account.user.id), method: :delete, class: 'button' if can?(:disable_2fa, @account.user)
+          - if !@account.memorial? && @account.user_approved?
+            = link_to t('admin.accounts.memorialize'), memorialize_admin_account_path(@account.id), method: :post, data: { confirm: t('admin.accounts.are_you_sure') }, class: 'button button--destructive' if can?(:memorialize, @account)
         - else
-          = link_to t('admin.domain_blocks.add_new'), new_admin_domain_block_path(_domain: @account.domain), class: 'button button--destructive'
-
-    %div
-      - if @account.local?
-        = link_to t('admin.accounts.reset_password'), admin_account_reset_path(@account.id), method: :create, class: 'button' if can?(:reset_password, @account.user)
-        - if @account.user&.otp_required_for_login?
-          = link_to t('admin.accounts.disable_two_factor_authentication'), admin_user_two_factor_authentication_path(@account.user.id), method: :delete, class: 'button' if can?(:disable_2fa, @account.user)
-        - if !@account.memorial? && @account.user_approved?
-          = link_to t('admin.accounts.memorialize'), memorialize_admin_account_path(@account.id), method: :post, data: { confirm: t('admin.accounts.are_you_sure') }, class: 'button button--destructive' if can?(:memorialize, @account)
-      - else
-        = link_to t('admin.accounts.redownload'), redownload_admin_account_path(@account.id), method: :post, class: 'button' if can?(:redownload, @account)
+          = link_to t('admin.accounts.redownload'), redownload_admin_account_path(@account.id), method: :post, class: 'button' if can?(:redownload, @account)
 
   %hr.spacer/
 
diff --git a/app/views/auth/registrations/_sessions.html.haml b/app/views/auth/registrations/_sessions.html.haml
index 395e36a9f..d3a04c00e 100644
--- a/app/views/auth/registrations/_sessions.html.haml
+++ b/app/views/auth/registrations/_sessions.html.haml
@@ -27,5 +27,5 @@
             - else
               %time.time-ago{ datetime: session.updated_at.iso8601, title: l(session.updated_at) }= l(session.updated_at)
           %td
-            - if current_session.session_id != session.session_id
+            - if current_session.session_id != session.session_id && !current_account.suspended?
               = table_link_to 'times', t('sessions.revoke'), settings_session_path(session), method: :delete
diff --git a/app/views/auth/registrations/edit.html.haml b/app/views/auth/registrations/edit.html.haml
index 4a46b27a9..a3445b421 100644
--- a/app/views/auth/registrations/edit.html.haml
+++ b/app/views/auth/registrations/edit.html.haml
@@ -30,18 +30,19 @@
 
 = render 'sessions'
 
-%hr.spacer/
+- unless current_account.suspended?
+  %hr.spacer/
 
-%h3= t('auth.migrate_account')
-%p.muted-hint= t('auth.migrate_account_html', path: settings_migration_path)
+  %h3= t('auth.migrate_account')
+  %p.muted-hint= t('auth.migrate_account_html', path: settings_migration_path)
 
-%hr.spacer/
+  %hr.spacer/
 
-%h3= t('migrations.incoming_migrations')
-%p.muted-hint= t('migrations.incoming_migrations_html', path: settings_aliases_path)
+  %h3= t('migrations.incoming_migrations')
+  %p.muted-hint= t('migrations.incoming_migrations_html', path: settings_aliases_path)
 
-- if open_deletion? && !current_account.suspended?
-  %hr.spacer/
+  - if open_deletion?
+    %hr.spacer/
 
-  %h3= t('auth.delete_account')
-  %p.muted-hint= t('auth.delete_account_html', path: settings_delete_path)
+    %h3= t('auth.delete_account')
+    %p.muted-hint= t('auth.delete_account_html', path: settings_delete_path)
diff --git a/app/views/oauth/authorized_applications/index.html.haml b/app/views/oauth/authorized_applications/index.html.haml
index 7b77108a9..fbb733db4 100644
--- a/app/views/oauth/authorized_applications/index.html.haml
+++ b/app/views/oauth/authorized_applications/index.html.haml
@@ -20,5 +20,5 @@
           %th!= application.scopes.map { |scope| t(scope, scope: [:doorkeeper, :scopes]) }.join(', ')
           %td= l application.created_at
           %td
-            - unless application.superapp?
+            - unless application.superapp? || current_account.suspended?
               = table_link_to 'times', t('doorkeeper.authorized_applications.buttons.revoke'), oauth_authorized_application_path(application), method: :delete, data: { confirm: t('doorkeeper.authorized_applications.confirmations.revoke') }
diff --git a/app/views/statuses/show.html.haml b/app/views/statuses/show.html.haml
index c06f6ebce..b7c2ed68d 100644
--- a/app/views/statuses/show.html.haml
+++ b/app/views/statuses/show.html.haml
@@ -12,6 +12,7 @@
   = opengraph 'og:type', 'article'
   = opengraph 'og:title', "#{display_name(@account)} (#{acct(@account)})"
   = opengraph 'og:url', short_account_status_url(@account, @status)
+  = opengraph 'og:published_time', @status.created_at.iso8601
 
   = render 'og_description', activity: @status
   = render 'og_image', activity: @status, account: @account
diff --git a/app/workers/account_deletion_worker.rb b/app/workers/account_deletion_worker.rb
new file mode 100644
index 000000000..0f6be71e1
--- /dev/null
+++ b/app/workers/account_deletion_worker.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+class AccountDeletionWorker
+  include Sidekiq::Worker
+
+  sidekiq_options queue: 'pull'
+
+  def perform(account_id)
+    DeleteAccountService.new.call(Account.find(account_id), reserve_username: true, reserve_email: false)
+  rescue ActiveRecord::RecordNotFound
+    true
+  end
+end
diff --git a/app/workers/admin/account_deletion_worker.rb b/app/workers/admin/account_deletion_worker.rb
new file mode 100644
index 000000000..82f269ad6
--- /dev/null
+++ b/app/workers/admin/account_deletion_worker.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+class Admin::AccountDeletionWorker
+  include Sidekiq::Worker
+
+  sidekiq_options queue: 'pull'
+
+  def perform(account_id)
+    DeleteAccountService.new.call(Account.find(account_id), reserve_username: true, reserve_email: true)
+  rescue ActiveRecord::RecordNotFound
+    true
+  end
+end
diff --git a/app/workers/admin/suspension_worker.rb b/app/workers/admin/suspension_worker.rb
index 83c815efd..35c570336 100644
--- a/app/workers/admin/suspension_worker.rb
+++ b/app/workers/admin/suspension_worker.rb
@@ -5,7 +5,9 @@ class Admin::SuspensionWorker
 
   sidekiq_options queue: 'pull'
 
-  def perform(account_id, remove_user = false)
-    SuspendAccountService.new.call(Account.find(account_id), reserve_username: true, reserve_email: !remove_user)
+  def perform(account_id)
+    SuspendAccountService.new.call(Account.find(account_id))
+  rescue ActiveRecord::RecordNotFound
+    true
   end
 end
diff --git a/app/workers/admin/unsuspension_worker.rb b/app/workers/admin/unsuspension_worker.rb
new file mode 100644
index 000000000..7cb2349b1
--- /dev/null
+++ b/app/workers/admin/unsuspension_worker.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+class Admin::UnsuspensionWorker
+  include Sidekiq::Worker
+
+  sidekiq_options queue: 'pull'
+
+  def perform(account_id)
+    UnsuspendAccountService.new.call(Account.find(account_id))
+  rescue ActiveRecord::RecordNotFound
+    true
+  end
+end
diff --git a/app/workers/feed_insert_worker.rb b/app/workers/feed_insert_worker.rb
index fd35af562..45e6bb88d 100644
--- a/app/workers/feed_insert_worker.rb
+++ b/app/workers/feed_insert_worker.rb
@@ -25,7 +25,10 @@ class FeedInsertWorker
   private
 
   def check_and_insert
-    perform_push unless feed_filtered?
+    return if feed_filtered?
+
+    perform_push
+    perform_notify if notify?
   end
 
   def feed_filtered?
@@ -39,6 +42,12 @@ class FeedInsertWorker
     end
   end
 
+  def notify?
+    return false if @type != :home || @status.reblog? || (@status.reply? && @status.in_reply_to_account_id != @status.account_id)
+
+    Follow.find_by(account: @follower, target_account: @status.account)&.notify?
+  end
+
   def perform_push
     case @type
     when :home
@@ -49,4 +58,8 @@ class FeedInsertWorker
       FeedManager.instance.push_to_direct(@account, @status)
     end
   end
+
+  def perform_notify
+    NotifyService.new.call(@follower, :status, @status)
+  end
 end
diff --git a/app/workers/local_notification_worker.rb b/app/workers/local_notification_worker.rb
index 48635e498..6b08ca6fc 100644
--- a/app/workers/local_notification_worker.rb
+++ b/app/workers/local_notification_worker.rb
@@ -3,7 +3,7 @@
 class LocalNotificationWorker
   include Sidekiq::Worker
 
-  def perform(receiver_account_id, activity_id = nil, activity_class_name = nil)
+  def perform(receiver_account_id, activity_id = nil, activity_class_name = nil, type = nil)
     if activity_id.nil? && activity_class_name.nil?
       activity = Mention.find(receiver_account_id)
       receiver = activity.account
@@ -12,7 +12,7 @@ class LocalNotificationWorker
       activity = activity_class_name.constantize.find(activity_id)
     end
 
-    NotifyService.new.call(receiver, activity)
+    NotifyService.new.call(receiver, type || activity_class_name.underscore, activity)
   rescue ActiveRecord::RecordNotFound
     true
   end
diff --git a/app/workers/poll_expiration_notify_worker.rb b/app/workers/poll_expiration_notify_worker.rb
index 64b4cbd7e..8a12fc075 100644
--- a/app/workers/poll_expiration_notify_worker.rb
+++ b/app/workers/poll_expiration_notify_worker.rb
@@ -11,12 +11,12 @@ class PollExpirationNotifyWorker
     # Notify poll owner and remote voters
     if poll.local?
       ActivityPub::DistributePollUpdateWorker.perform_async(poll.status.id)
-      NotifyService.new.call(poll.account, poll)
+      NotifyService.new.call(poll.account, :poll, poll)
     end
 
     # Notify local voters
     poll.votes.includes(:account).map(&:account).select(&:local?).each do |account|
-      NotifyService.new.call(account, poll)
+      NotifyService.new.call(account, :poll, poll)
     end
   rescue ActiveRecord::RecordNotFound
     true
diff --git a/app/workers/refollow_worker.rb b/app/workers/refollow_worker.rb
index 9b07ce1b5..98940680d 100644
--- a/app/workers/refollow_worker.rb
+++ b/app/workers/refollow_worker.rb
@@ -11,6 +11,7 @@ class RefollowWorker
 
     target_account.passive_relationships.where(account: Account.where(domain: nil)).includes(:account).reorder(nil).find_each do |follow|
       reblogs = follow.show_reblogs?
+      notify  = follow.notify?
 
       # Locally unfollow remote account
       follower = follow.account
@@ -18,7 +19,7 @@ class RefollowWorker
 
       # Schedule re-follow
       begin
-        FollowService.new.call(follower, target_account, reblogs: reblogs)
+        FollowService.new.call(follower, target_account, reblogs: reblogs, notify: notify)
       rescue Mastodon::NotPermittedError, ActiveRecord::RecordNotFound, Mastodon::UnexpectedResponseError, HTTP::Error, OpenSSL::SSL::SSLError
         next
       end
diff --git a/app/workers/scheduler/user_cleanup_scheduler.rb b/app/workers/scheduler/user_cleanup_scheduler.rb
index dade63028..4213c243e 100644
--- a/app/workers/scheduler/user_cleanup_scheduler.rb
+++ b/app/workers/scheduler/user_cleanup_scheduler.rb
@@ -6,6 +6,13 @@ class Scheduler::UserCleanupScheduler
   sidekiq_options lock: :until_executed, retry: 0
 
   def perform
+    clean_unconfirmed_accounts!
+    clean_suspended_accounts!
+  end
+
+  private
+
+  def clean_unconfirmed_accounts!
     User.where('confirmed_at is NULL AND confirmation_sent_at <= ?', 2.days.ago).reorder(nil).find_in_batches do |batch|
       Account.where(id: batch.map(&:account_id)).delete_all
       User.where(id: batch.map(&:id)).delete_all
@@ -16,4 +23,10 @@ class Scheduler::UserCleanupScheduler
       User.where(id: batch.map(&:id)).delete_all
     end
   end
+
+  def clean_suspended_accounts!
+    AccountDeletionRequest.where('created_at <= ?', AccountDeletionRequest::DELAY_TO_DELETION.ago).reorder(nil).find_each do |deletion_request|
+      Admin::AccountDeletionWorker.perform_async(deletion_request.account_id)
+    end
+  end
 end
diff --git a/app/workers/unfollow_follow_worker.rb b/app/workers/unfollow_follow_worker.rb
index b6e665a41..71b5a0e3f 100644
--- a/app/workers/unfollow_follow_worker.rb
+++ b/app/workers/unfollow_follow_worker.rb
@@ -10,10 +10,11 @@ class UnfollowFollowWorker
     old_target_account = Account.find(old_target_account_id)
     new_target_account = Account.find(new_target_account_id)
 
-    follow = follower_account.active_relationships.find_by(target_account: old_target_account)
+    follow  = follower_account.active_relationships.find_by(target_account: old_target_account)
     reblogs = follow&.show_reblogs?
+    notify  = follow&.notify?
 
-    FollowService.new.call(follower_account, new_target_account, reblogs: reblogs, bypass_locked: bypass_locked)
+    FollowService.new.call(follower_account, new_target_account, reblogs: reblogs, notify: notify, bypass_locked: bypass_locked)
     UnfollowService.new.call(follower_account, old_target_account, skip_unmerge: true)
   rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError
     true
diff --git a/config/locales/en.yml b/config/locales/en.yml
index ab96074fd..427b2c3fc 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -98,6 +98,7 @@ en:
       add_email_domain_block: Block e-mail domain
       approve: Approve
       approve_all: Approve all
+      approved_msg: Successfully approved %{username}'s sign-up application
       are_you_sure: Are you sure?
       avatar: Avatar
       by_domain: Domain
@@ -111,18 +112,21 @@ en:
       confirm: Confirm
       confirmed: Confirmed
       confirming: Confirming
+      delete: Delete data
       deleted: Deleted
       demote: Demote
-      disable: Disable
+      destroyed_msg: "%{username}'s data is now queued to be deleted imminently"
+      disable: Freeze
       disable_two_factor_authentication: Disable 2FA
-      disabled: Disabled
+      disabled: Frozen
       display_name: Display name
       domain: Domain
       edit: Edit
       email: Email
       email_status: Email status
-      enable: Enable
+      enable: Unfreeze
       enabled: Enabled
+      enabled_msg: Successfully unfroze %{username}'s account
       followers: Followers
       follows: Follows
       header: Header
@@ -138,6 +142,8 @@ en:
       login_status: Login status
       media_attachments: Media attachments
       memorialize: Turn into memoriam
+      memorialized: Memorialized
+      memorialized_msg: Successfully turned %{username} into a memorial account
       moderation:
         active: Active
         all: All
@@ -158,10 +164,14 @@ en:
       public: Public
       push_subscription_expires: PuSH subscription expires
       redownload: Refresh profile
+      redownloaded_msg: Successfully refreshed %{username}'s profile from origin
       reject: Reject
       reject_all: Reject all
+      rejected_msg: Successfully rejected %{username}'s sign-up application
       remove_avatar: Remove avatar
       remove_header: Remove header
+      removed_avatar_msg: Successfully removed %{username}'s avatar image
+      removed_header_msg: Successfully removed %{username}'s header image
       resend_confirmation:
         already_confirmed: This user is already confirmed
         send: Resend confirmation email
@@ -182,18 +192,23 @@ en:
       show:
         created_reports: Made reports
         targeted_reports: Reported by others
-      silence: Silence
-      silenced: Silenced
+      silence: Limit
+      silenced: Limited
       statuses: Statuses
       subscribe: Subscribe
       suspended: Suspended
+      suspension_irreversible: The data of this account has been irreversibly deleted. You can unsuspend the account to make it usable but it will not recover any data it previously had.
+      suspension_reversible_hint_html: The account has been suspended, and the data will be fully removed on %{date}. Until then, the account can be restored without any ill effects. If you wish to remove all of the account's data immediately, you can do so below.
       time_in_queue: Waiting in queue %{time}
       title: Accounts
       unconfirmed_email: Unconfirmed email
       undo_silenced: Undo silence
       undo_suspension: Undo suspension
+      unsilenced_msg: Successfully unlimited %{username}'s account
       unsubscribe: Unsubscribe
+      unsuspended_msg: Successfully unsuspended %{username}'s account
       username: Username
+      view_domain: View summary for domain
       warn: Warn
       web: Web
       whitelisted: Allowed for federation
@@ -1304,9 +1319,9 @@ en:
       title: Sign in attempt
     warning:
       explanation:
-        disable: While your account is frozen, your account data remains intact, but you cannot perform any actions until it is unlocked.
-        silence: While your account is limited, only people who are already following you will see your toots on this server, and you may be excluded from various public listings. However, others may still manually follow you.
-        suspend: Your account has been suspended, and all of your toots and your uploaded media files have been irreversibly removed from this server, and servers where you had followers.
+        disable: You can no longer login to your account or use it in any other way, but your profile and other data remains intact.
+        silence: You can still use your account but only people who are already following you will see your toots on this server, and you may be excluded from various public listings. However, others may still manually follow you.
+        suspend: You can no longer use your account, and your profile and other data are no longer accessible. You can still login to request a backup of your data until the data is fully removed, but we will retain some data to prevent you from evading the suspension.
       get_in_touch: You can reply to this e-mail to get in touch with the staff of %{instance}.
       review_server_policies: Review server policies
       statuses: 'Specifically, for:'
diff --git a/config/locales/simple_form.en.yml b/config/locales/simple_form.en.yml
index 4ab0d1871..910e77ec2 100644
--- a/config/locales/simple_form.en.yml
+++ b/config/locales/simple_form.en.yml
@@ -90,10 +90,10 @@ en:
         text: Custom warning
         type: Action
         types:
-          disable: Disable login
-          none: Do nothing
-          silence: Silence
-          suspend: Suspend and irreversibly delete account data
+          disable: Freeze
+          none: Send a warning
+          silence: Limit
+          suspend: Suspend
         warning_preset_id: Use a warning preset
       announcement:
         all_day: All-day event
diff --git a/config/navigation.rb b/config/navigation.rb
index 538e2c8d3..5fcbd2fe4 100644
--- a/config/navigation.rb
+++ b/config/navigation.rb
@@ -30,7 +30,7 @@ SimpleNavigation::Configuration.run do |navigation|
 
     n.item :security, safe_join([fa_icon('lock fw'), t('settings.account')]), edit_user_registration_url do |s|
       s.item :password, safe_join([fa_icon('lock fw'), t('settings.account_settings')]), edit_user_registration_url, highlights_on: %r{/auth/edit|/settings/delete|/settings/migration|/settings/aliases}
-      s.item :two_factor_authentication, safe_join([fa_icon('mobile fw'), t('settings.two_factor_authentication')]), settings_two_factor_authentication_methods_url, highlights_on: %r{/settings/two_factor_authentication|/settings/security_keys}
+      s.item :two_factor_authentication, safe_join([fa_icon('mobile fw'), t('settings.two_factor_authentication')]), settings_two_factor_authentication_methods_url, highlights_on: %r{/settings/two_factor_authentication|/settings/otp_authentication|/settings/security_keys}
       s.item :authorized_apps, safe_join([fa_icon('list fw'), t('settings.authorized_apps')]), oauth_authorized_applications_url
     end
 
diff --git a/config/routes.rb b/config/routes.rb
index 9a862517a..13b744bf8 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -249,7 +249,7 @@ Rails.application.routes.draw do
 
     resources :report_notes, only: [:create, :destroy]
 
-    resources :accounts, only: [:index, :show] do
+    resources :accounts, only: [:index, :show, :destroy] do
       member do
         post :enable
         post :unsilence
@@ -490,7 +490,7 @@ Rails.application.routes.draw do
       end
 
       namespace :admin do
-        resources :accounts, only: [:index, :show] do
+        resources :accounts, only: [:index, :show, :destroy] do
           member do
             post :enable
             post :unsilence
diff --git a/db/migrate/20200908193330_create_account_deletion_requests.rb b/db/migrate/20200908193330_create_account_deletion_requests.rb
new file mode 100644
index 000000000..e03183ae4
--- /dev/null
+++ b/db/migrate/20200908193330_create_account_deletion_requests.rb
@@ -0,0 +1,8 @@
+class CreateAccountDeletionRequests < ActiveRecord::Migration[5.2]
+  def change
+    create_table :account_deletion_requests do |t|
+      t.references :account, foreign_key: { on_delete: :cascade }
+      t.timestamps
+    end
+  end
+end
diff --git a/db/migrate/20200917192924_add_notify_to_follows.rb b/db/migrate/20200917192924_add_notify_to_follows.rb
new file mode 100644
index 000000000..d27471c44
--- /dev/null
+++ b/db/migrate/20200917192924_add_notify_to_follows.rb
@@ -0,0 +1,19 @@
+require Rails.root.join('lib', 'mastodon', 'migration_helpers')
+
+class AddNotifyToFollows < ActiveRecord::Migration[5.1]
+  include Mastodon::MigrationHelpers
+
+  disable_ddl_transaction!
+
+  def up
+    safety_assured do
+      add_column_with_default :follows, :notify, :boolean, default: false, allow_null: false
+      add_column_with_default :follow_requests, :notify, :boolean, default: false, allow_null: false
+    end
+  end
+
+  def down
+    remove_column :follows, :notify
+    remove_column :follow_requests, :notify
+  end
+end
diff --git a/db/migrate/20200917193034_add_type_to_notifications.rb b/db/migrate/20200917193034_add_type_to_notifications.rb
new file mode 100644
index 000000000..002be3aa0
--- /dev/null
+++ b/db/migrate/20200917193034_add_type_to_notifications.rb
@@ -0,0 +1,5 @@
+class AddTypeToNotifications < ActiveRecord::Migration[5.2]
+  def change
+    add_column :notifications, :type, :string
+  end
+end
diff --git a/db/migrate/20200917222316_add_index_notifications_on_type.rb b/db/migrate/20200917222316_add_index_notifications_on_type.rb
new file mode 100644
index 000000000..9bd23c1d3
--- /dev/null
+++ b/db/migrate/20200917222316_add_index_notifications_on_type.rb
@@ -0,0 +1,7 @@
+class AddIndexNotificationsOnType < ActiveRecord::Migration[5.2]
+  disable_ddl_transaction!
+
+  def change
+    add_index :notifications, [:account_id, :id, :type], order: { id: :desc }, algorithm: :concurrently
+  end
+end
diff --git a/db/post_migrate/20200917193528_migrate_notifications_type.rb b/db/post_migrate/20200917193528_migrate_notifications_type.rb
new file mode 100644
index 000000000..88e423084
--- /dev/null
+++ b/db/post_migrate/20200917193528_migrate_notifications_type.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+class MigrateNotificationsType < ActiveRecord::Migration[5.2]
+  disable_ddl_transaction!
+
+  TYPES_TO_MIGRATE = {
+    'Mention'       => :mention,
+    'Status'        => :reblog,
+    'Follow'        => :follow,
+    'FollowRequest' => :follow_request,
+    'Favourite'     => :favourite,
+    'Poll'          => :poll,
+  }.freeze
+
+  def up
+    TYPES_TO_MIGRATE.each_pair do |activity_type, type|
+      Notification.where(activity_type: activity_type, type: nil).in_batches.update_all(type: type)
+    end
+  end
+
+  def down; end
+end
diff --git a/db/post_migrate/20200917222734_remove_index_notifications_on_account_activity.rb b/db/post_migrate/20200917222734_remove_index_notifications_on_account_activity.rb
new file mode 100644
index 000000000..cb7f78e53
--- /dev/null
+++ b/db/post_migrate/20200917222734_remove_index_notifications_on_account_activity.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+class RemoveIndexNotificationsOnAccountActivity < ActiveRecord::Migration[5.2]
+  disable_ddl_transaction!
+
+  def up
+    remove_index :notifications, name: :account_activity
+    remove_index :notifications, name: :index_notifications_on_account_id_and_id
+  end
+
+  def down
+    add_index :notifications, [:account_id, :activity_id, :activity_type], unique: true, name: 'account_activity', algorithm: :concurrently
+    add_index :notifications, [:account_id, :id], order: { id: :desc }, algorithm: :concurrently
+  end
+end
diff --git a/db/schema.rb b/db/schema.rb
index f7a21b6d3..620cb49fc 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -36,6 +36,13 @@ ActiveRecord::Schema.define(version: 2020_09_25_035221) do
     t.index ["conversation_id"], name: "index_account_conversations_on_conversation_id"
   end
 
+  create_table "account_deletion_requests", force: :cascade do |t|
+    t.bigint "account_id"
+    t.datetime "created_at", null: false
+    t.datetime "updated_at", null: false
+    t.index ["account_id"], name: "index_account_deletion_requests_on_account_id"
+  end
+
   create_table "account_domain_blocks", force: :cascade do |t|
     t.string "domain"
     t.datetime "created_at", null: false
@@ -454,6 +461,7 @@ ActiveRecord::Schema.define(version: 2020_09_25_035221) do
     t.bigint "target_account_id", null: false
     t.boolean "show_reblogs", default: true, null: false
     t.string "uri"
+    t.boolean "notify", default: false, null: false
     t.index ["account_id", "target_account_id"], name: "index_follow_requests_on_account_id_and_target_account_id", unique: true
   end
 
@@ -464,6 +472,7 @@ ActiveRecord::Schema.define(version: 2020_09_25_035221) do
     t.bigint "target_account_id", null: false
     t.boolean "show_reblogs", default: true, null: false
     t.string "uri"
+    t.boolean "notify", default: false, null: false
     t.index ["account_id", "target_account_id"], name: "index_follows_on_account_id_and_target_account_id", unique: true
     t.index ["target_account_id"], name: "index_follows_on_target_account_id"
   end
@@ -600,8 +609,8 @@ ActiveRecord::Schema.define(version: 2020_09_25_035221) do
     t.datetime "updated_at", null: false
     t.bigint "account_id", null: false
     t.bigint "from_account_id", null: false
-    t.index ["account_id", "activity_id", "activity_type"], name: "account_activity", unique: true
-    t.index ["account_id", "id"], name: "index_notifications_on_account_id_and_id", order: { id: :desc }
+    t.string "type"
+    t.index ["account_id", "id", "type"], name: "index_notifications_on_account_id_and_id_and_type", order: { id: :desc }
     t.index ["activity_id", "activity_type"], name: "index_notifications_on_activity_id_and_activity_type"
     t.index ["from_account_id"], name: "index_notifications_on_from_account_id"
   end
@@ -1037,6 +1046,7 @@ ActiveRecord::Schema.define(version: 2020_09_25_035221) do
   add_foreign_key "account_aliases", "accounts", on_delete: :cascade
   add_foreign_key "account_conversations", "accounts", on_delete: :cascade
   add_foreign_key "account_conversations", "conversations", on_delete: :cascade
+  add_foreign_key "account_deletion_requests", "accounts", on_delete: :cascade
   add_foreign_key "account_domain_blocks", "accounts", name: "fk_206c6029bd", on_delete: :cascade
   add_foreign_key "account_domain_permissions", "accounts", on_delete: :cascade
   add_foreign_key "account_identity_proofs", "accounts", on_delete: :cascade
diff --git a/lib/mastodon/accounts_cli.rb b/lib/mastodon/accounts_cli.rb
index 8c91c3013..8f9279a3c 100644
--- a/lib/mastodon/accounts_cli.rb
+++ b/lib/mastodon/accounts_cli.rb
@@ -87,7 +87,7 @@ module Mastodon
           say('Use --force to reattach it anyway and delete the other user')
           return
         elsif account.user.present?
-          account.user.destroy!
+          DeleteAccountService.new.call(account, reserve_email: false)
         end
       end
 
@@ -192,7 +192,7 @@ module Mastodon
       end
 
       say("Deleting user with #{account.statuses_count} statuses, this might take a while...")
-      SuspendAccountService.new.call(account, reserve_email: false)
+      DeleteAccountService.new.call(account, reserve_email: false)
       say('OK', :green)
     end
 
diff --git a/lib/mastodon/domains_cli.rb b/lib/mastodon/domains_cli.rb
index 1b2fa0dd8..8a507b44e 100644
--- a/lib/mastodon/domains_cli.rb
+++ b/lib/mastodon/domains_cli.rb
@@ -47,7 +47,7 @@ module Mastodon
 
       say('Deleting accounts from target domains...')
       processed, = parallelize_with_progress(scope) do |account|
-        SuspendAccountService.new.call(account, reserve_username: false, skip_side_effects: true) unless options[:dry_run]
+        DeleteAccountService.new.call(account, reserve_username: false, skip_side_effects: true) unless options[:dry_run]
       end
 
       DomainBlock.where(domain: domains).destroy_all unless options[:dry_run]
diff --git a/lib/paperclip/attachment_extensions.rb b/lib/paperclip/attachment_extensions.rb
index 93df0a326..752e79e65 100644
--- a/lib/paperclip/attachment_extensions.rb
+++ b/lib/paperclip/attachment_extensions.rb
@@ -35,6 +35,10 @@ module Paperclip
 
       formats.include?(other_extension.delete('.')) && File.basename(other_filename, other_extension) == File.basename(original_filename, File.extname(original_filename))
     end
+
+    def default_url(style_name = default_style)
+      @url_generator.for_as_default(style_name)
+    end
   end
 end
 
diff --git a/lib/paperclip/response_with_limit_adapter.rb b/lib/paperclip/response_with_limit_adapter.rb
index 8711b1349..17a2abd25 100644
--- a/lib/paperclip/response_with_limit_adapter.rb
+++ b/lib/paperclip/response_with_limit_adapter.rb
@@ -16,7 +16,7 @@ module Paperclip
     private
 
     def cache_current_values
-      @original_filename = filename_from_content_disposition || filename_from_path || 'data'
+      @original_filename = filename_from_content_disposition.presence || filename_from_path.presence || 'data'
       @size = @target.response.content_length
       @tempfile = copy_to_tempfile(@target)
       @content_type = ContentTypeDetector.new(@tempfile.path).detect
diff --git a/lib/paperclip/url_generator_extensions.rb b/lib/paperclip/url_generator_extensions.rb
index 1079efdbc..e1d6df2c2 100644
--- a/lib/paperclip/url_generator_extensions.rb
+++ b/lib/paperclip/url_generator_extensions.rb
@@ -11,6 +11,10 @@ module Paperclip
         Addressable::URI.parse(url).normalize.to_str.gsub(escape_regex) { |m| "%#{m.ord.to_s(16).upcase}" }
       end
     end
+
+    def for_as_default(style_name)
+      attachment_options[:interpolator].interpolate(default_url, @attachment, style_name)
+    end
   end
 end
 
diff --git a/lib/tasks/emojis.rake b/lib/tasks/emojis.rake
index 2ac8bc059..d0b8fa890 100644
--- a/lib/tasks/emojis.rake
+++ b/lib/tasks/emojis.rake
@@ -91,7 +91,7 @@ namespace :emojis do
   desc 'Generate emoji variants with white borders'
   task :generate_borders do
     src = Rails.root.join('app', 'javascript', 'mastodon', 'features', 'emoji', 'emoji_map.json')
-    emojis = '🎱🐜⚫🖤⬛◼️◾◼️✒️▪️💣🎳📷📸♣️🕶️✴️🔌💂‍♀️📽️🍳🦍💂🔪🕳️🕹️🕋🖊️🖋️💂‍♂️🎤🎓🎥🎼♠️🎩🦃📼📹🎮🐃🏴🐞👽⚾🐔☁️💨🕊️👀🍥👻🐐❕❔⛸️🌩️🔊🔇📃🌧️🐏🍚🍙🐓🐑💀☠️🌨️🔉🔈💬💭🏐🏳️⚪⬜◽◻️▫️'
+    emojis = '🎱🐜⚫🖤⬛◼️◾◼️✒️▪️💣🎳📷📸♣️🕶️✴️🔌💂‍♀️📽️🍳🦍💂🔪🕳️🕹️🕋🖊️🖋️💂‍♂️🎤🎓🎥🎼♠️🎩🦃📼📹🎮🐃🏴🐞🕺👽⚾🐔☁️💨🕊️👀🍥👻🐐❕❔⛸️🌩️🔊🔇📃🌧️🐏🍚🍙🐓🐑💀☠️🌨️🔉🔈💬💭🏐🏳️⚪⬜◽◻️▫️'
 
     map = Oj.load(File.read(src))
 
diff --git a/package.json b/package.json
index ab9e3e061..642edfafc 100644
--- a/package.json
+++ b/package.json
@@ -65,18 +65,18 @@
     "@babel/plugin-proposal-decorators": "^7.10.5",
     "@babel/plugin-transform-react-inline-elements": "^7.10.4",
     "@babel/plugin-transform-runtime": "^7.11.5",
-    "@babel/preset-env": "^7.11.0",
+    "@babel/preset-env": "^7.11.5",
     "@babel/preset-react": "^7.10.4",
     "@babel/runtime": "^7.11.2",
     "@clusterws/cws": "^3.0.0",
     "@gamestdio/websocket": "^0.3.2",
-    "@github/webauthn-json": "^0.4.2",
+    "@github/webauthn-json": "^0.5.4",
     "@rails/ujs": "^6.0.3",
     "array-includes": "^3.1.1",
     "arrow-key-navigation": "^1.2.0",
     "atrament": "0.2.4",
     "autoprefixer": "^9.8.6",
-    "axios": "^0.19.2",
+    "axios": "^0.20.0",
     "babel-loader": "^8.1.0",
     "babel-plugin-lodash": "^3.3.4",
     "babel-plugin-preval": "^5.0.0",
@@ -87,9 +87,9 @@
     "classnames": "^2.2.5",
     "compression-webpack-plugin": "^5.0.1",
     "cross-env": "^7.0.2",
-    "css-loader": "^4.2.2",
+    "css-loader": "^4.3.0",
     "cssnano": "^4.1.10",
-    "detect-passive-events": "^1.0.2",
+    "detect-passive-events": "^1.0.5",
     "dotenv": "^8.2.0",
     "emoji-mart": "Gargron/emoji-mart#build",
     "es6-symbol": "^3.1.3",
@@ -97,7 +97,7 @@
     "exif-js": "^2.3.0",
     "express": "^4.17.1",
     "favico.js": "^0.3.10",
-    "file-loader": "^6.0.0",
+    "file-loader": "^6.1.0",
     "font-awesome": "^4.7.0",
     "glob": "^7.1.6",
     "history": "^4.10.1",
@@ -161,12 +161,12 @@
     "stacktrace-js": "^2.0.2",
     "stringz": "^2.1.0",
     "substring-trie": "^1.0.2",
-    "terser-webpack-plugin": "^3.0.6",
+    "terser-webpack-plugin": "^4.2.2",
     "tesseract.js": "^2.1.1",
     "throng": "^4.0.0",
     "tiny-queue": "^0.2.1",
     "uuid": "^8.2.0",
-    "webpack": "^4.44.1",
+    "webpack": "^4.44.2",
     "webpack-assets-manifest": "^3.1.1",
     "webpack-bundle-analyzer": "^3.8.0",
     "webpack-cli": "^3.3.12",
@@ -189,7 +189,7 @@
     "react-test-renderer": "^16.13.1",
     "sass-lint": "^1.13.1",
     "webpack-dev-server": "^3.11.0",
-    "yargs": "^15.4.1"
+    "yargs": "^16.0.3"
   },
   "resolutions": {
     "kind-of": "^6.0.3"
diff --git a/public/emoji/1f57a_border.svg b/public/emoji/1f57a_border.svg
new file mode 100644
index 000000000..7d3729976
--- /dev/null
+++ b/public/emoji/1f57a_border.svg
@@ -0,0 +1,31 @@
+<?xml version="1.0"?>
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="-2 -2 40 40">
+  <g>
+    <path d="M26.157 35.26c-.005-.034-.055-.214-.055-.214l-.057.027.004-.134c-.021-.58-.608-.51-.678-.516-.07-.006-.305.006-.596-.031-.29-.037-.57-.108-.816-.257-.287-.174-.678-.467-1.014-.663s-.758-.437-.758-.437-.367.682-1.127.654c-.409-.015-1.05-.337-1.133-.36-.11-.031-.159.031-.205.116-.046.086-.211.684-.211 1.005 0 .204.025.333.044.405l-.055-.017c-.025-.007-.049.012-.049.039v.687c0 .045.037.147.155.171.118.024 1.759.165 1.821.11s.13-.232.183-.257c.053-.024.603.094.859.155s.698.165 1.218.165 1.191-.153 1.607-.257c.415-.104.741-.243.815-.289.074-.046.053-.067.048-.102z" stroke="white" stroke-linejoin="round" stroke-width="4px"/>
+    <path d="M21.19 35.405l-.41-.287c.008-.013.829-1.147 2.439-.666.092-.126.23-.324.363-.544l.428.26c-.24.396-.491.718-.501.731l-.111.142-.17-.061c-1.379-.491-2.031.415-2.038.425z" stroke="white" stroke-linejoin="round" stroke-width="4px"/>
+    <path d="M14.268 35.26c-.005-.034-.055-.214-.055-.214l-.057.027.004-.134c-.021-.58-.608-.51-.678-.516-.07-.006-.305.006-.596-.031s-.57-.108-.816-.257c-.287-.174-.678-.467-1.014-.663s-.758-.437-.758-.437-.367.682-1.127.654c-.409-.015-1.05-.337-1.133-.36-.11-.031-.159.031-.205.116-.046.086-.211.684-.211 1.005 0 .204.025.333.044.405l-.055-.017c-.025-.007-.049.012-.049.039v.687c0 .045.037.147.155.171.118.024 1.759.165 1.821.11s.13-.232.183-.257c.053-.024.603.094.859.155s.698.165 1.218.165 1.191-.153 1.607-.257c.415-.104.741-.243.815-.289.073-.046.053-.067.048-.102z" stroke="white" stroke-linejoin="round" stroke-width="4px"/>
+    <path d="M9.3 35.406l-.409-.288c.008-.012.823-1.149 2.439-.666.091-.126.23-.324.363-.544l.428.26c-.24.396-.491.718-.501.731l-.111.142-.17-.061c-1.366-.488-2.012.388-2.039.426z" stroke="white" stroke-linejoin="round" stroke-width="4px"/>
+    <path d="M28.658 1.816c-.19-.089-.279.102-.279.102l-.378.812c.007-.014-.083-.071-.077-.082l.56-1.201s.089-.19-.102-.279-.279.102-.279.102l-.054.115-.472 1.013c.001-.003-.093-.049-.092-.051l.622-1.336s.089-.19-.102-.279c-.19-.089-.279.102-.279.102l-.622 1.336c-.001.002-.097-.039-.099-.036l.515-1.106s.089-.19-.102-.279c-.19-.089-.279.102-.279.102l-.702 1.507c-.048.103-.139.105-.179.078-.106-.072-.05-.26-.079-.424-.045-.25-.196-.415-.355-.418-.176-.003-.206.138-.192.187.028.095.073.254.126.506.037.177-.017.324-.017.324-.023.072-.085.318-.038.604.028.169.092.352.205.52l-.332.714 1.507.659.366-.956c.267-.119.5-.319.633-.604l.044-.095.631-1.355c.003-.004.091-.194-.099-.282z" stroke="white" stroke-linejoin="round" stroke-width="4px"/>
+    <path d="M26.348 2.443c.04-.012.06-.036.075-.058.168.108.389.272.491.543.132.351.054.627-.028.798-.066.138-.172.168-.099-.047.092-.27.143-.866-.455-1.205l-.055-.03c.001 0 .037.009.071-.001z" stroke="white" stroke-linejoin="round" stroke-width="4px"/>
+    <path d="M11.397 20.373c-.076-.064-.202-.171-.385-.352-.129-.128-.165-.279-.165-.279-.021-.073-.105-.312-.303-.524-.117-.126-.271-.243-.459-.319l-.116-.779-1.62.284.224.999c-.156.247-.24.542-.193.854l.016.104.224 1.478s.031.207.239.176.176-.239.176-.239L8.9 20.89c.002.015.108.013.11.026l.198 1.31s.031.207.239.176c.207-.031.176-.239.176-.239l-.019-.126-.167-1.105c0 .003.104-.01.105-.009l.221 1.457s.031.207.239.176c.207-.031.176-.239.176-.239l-.22-1.457c0-.002.103-.021.102-.024l.182 1.206s.031.207.239.176c.207-.031.176-.239.176-.239l-.249-1.644c-.017-.112.057-.165.106-.164.128.001.185.189.3.309.176.183.393.237.527.152.148-.092.095-.227.056-.259z" stroke="white" stroke-linejoin="round" stroke-width="4px"/>
+    <path d="M25.814 3.729S23.755 8.05 23.512 8.19c-.219.126-1.719-.042-3.699.354-1.979.396-2.744-.155-4.769-.075-1.176.046-2.413.452-3.227.648-1.237.299-3.276 3.237-3.848 4.26-.443.791-.421 1.68-.327 2.372.078.578.058 1.486.82 3.41l1.719-.281s-.289-3.783-.043-4.192c.102-.169 1.323-2.119 2.303-2.473l8.188-.385s3.375-.485 4.302-1.167c.708-.521 2.497-6.251 2.497-6.251l-1.614-.681z" stroke="white" stroke-linejoin="round" stroke-width="4px"/>
+    <path d="M15.546 5.857c.175-.172.407-.289.671-.314.613-.064 1.16.383 1.222.997l.319 3.153c.061.611-.384 1.16-.997 1.221-.615.063-1.161-.384-1.222-.997l-.32-3.153c-.035-.35.095-.676.327-.907z" stroke="white" stroke-linejoin="round" stroke-width="4px"/>
+    <path d="M19.243 4.296c0 1.849-1.309 3.348-2.927 3.348s-2.929-1.499-2.929-3.348c0-1.848 1.311-3.347 2.929-3.347 1.618 0 2.927 1.499 2.927 3.347z" stroke="white" stroke-linejoin="round" stroke-width="4px"/>
+    <path d="M17.89 2.018s-1.14 1.07-2.349 1.255c0 0-.723 1.171-.768 1.847 0 0-.45-1.081-.81-.9-.36.18-.496.855-.18 1.305.315.451.705 1.226.363 1.003-.689-.45-.79-.768-.976-1.07-.127-.206-.425-.943-.465-2.162-.024-.721.232-2.116 1.707-2.857 1.278-.644 3.287-.315 4.231.359.945.676 1.18 2.38.594 3.056 0 0-.085-.63-.488-1.14-.226-.283-.859-.696-.859-.696z" stroke="white" stroke-linejoin="round" stroke-width="4px"/>
+    <path d="M12.407 9.474v9.253l7.083-.024s.625-2.703 1.034-4.549c.474-2.135-1.079-5.392-1.079-5.392l-1.082-.108-.212-1.011-1.056.4-.151 1.751-.456-1.666-1.548-.135-.184 1.067-2.349.414z" stroke="white" stroke-linejoin="round" stroke-width="4px"/>
+    <path d="M11.781 9.593s.688 2.443.385 7.5c2.228-.038 3.963-.732 3.963-.732s.146-1.919-.228-3.615c-.546-2.469-1.105-3.921-1.105-3.921l-3.015.768zm6.516-1.246s.623 3.271.484 5.224c-.14 1.953-.235 2.694-.235 2.694s.441.486 1.57.466c.467-1.222.752-3.679.695-5.183-.059-1.585-1.002-3.127-1.002-3.127l-1.512-.074zm-5.89 10.38s-.607 6.995-.982 8.307c-.334 1.168-3.799 6.742-3.799 6.742s1.646.406 3.229.094c0 0 3.163-3.836 3.99-5.688.24-.539 1.835-5.521 1.835-5.521s1.712 1.908 2.863 3.599c.728 2.003.09 7.442.09 7.442s1.483.312 2.899.042c0 0 .859-5.397.651-7.688-.033-.367-.103-1.05-.245-1.309-.619-1.123-1.963-3.231-2.822-4.172-.774-.847-.625-1.873-.625-1.873l-7.084.025z" stroke="white" stroke-linejoin="round" stroke-width="4px"/>
+  </g>
+  <path fill="#292F33" d="M26.157 35.26c-.005-.034-.055-.214-.055-.214l-.057.027.004-.134c-.021-.58-.608-.51-.678-.516-.07-.006-.305.006-.596-.031-.29-.037-.57-.108-.816-.257-.287-.174-.678-.467-1.014-.663s-.758-.437-.758-.437-.367.682-1.127.654c-.409-.015-1.05-.337-1.133-.36-.11-.031-.159.031-.205.116-.046.086-.211.684-.211 1.005 0 .204.025.333.044.405l-.055-.017c-.025-.007-.049.012-.049.039v.687c0 .045.037.147.155.171.118.024 1.759.165 1.821.11s.13-.232.183-.257c.053-.024.603.094.859.155s.698.165 1.218.165 1.191-.153 1.607-.257c.415-.104.741-.243.815-.289.074-.046.053-.067.048-.102z"/>
+  <path fill="#4B545D" d="M21.19 35.405l-.41-.287c.008-.013.829-1.147 2.439-.666.092-.126.23-.324.363-.544l.428.26c-.24.396-.491.718-.501.731l-.111.142-.17-.061c-1.379-.491-2.031.415-2.038.425z"/>
+  <path fill="#292F33" d="M14.268 35.26c-.005-.034-.055-.214-.055-.214l-.057.027.004-.134c-.021-.58-.608-.51-.678-.516-.07-.006-.305.006-.596-.031s-.57-.108-.816-.257c-.287-.174-.678-.467-1.014-.663s-.758-.437-.758-.437-.367.682-1.127.654c-.409-.015-1.05-.337-1.133-.36-.11-.031-.159.031-.205.116-.046.086-.211.684-.211 1.005 0 .204.025.333.044.405l-.055-.017c-.025-.007-.049.012-.049.039v.687c0 .045.037.147.155.171.118.024 1.759.165 1.821.11s.13-.232.183-.257c.053-.024.603.094.859.155s.698.165 1.218.165 1.191-.153 1.607-.257c.415-.104.741-.243.815-.289.073-.046.053-.067.048-.102z"/>
+  <path fill="#4B545D" d="M9.3 35.406l-.409-.288c.008-.012.823-1.149 2.439-.666.091-.126.23-.324.363-.544l.428.26c-.24.396-.491.718-.501.731l-.111.142-.17-.061c-1.366-.488-2.012.388-2.039.426z"/>
+  <path fill="#FFDC5D" d="M28.658 1.816c-.19-.089-.279.102-.279.102l-.378.812c.007-.014-.083-.071-.077-.082l.56-1.201s.089-.19-.102-.279-.279.102-.279.102l-.054.115-.472 1.013c.001-.003-.093-.049-.092-.051l.622-1.336s.089-.19-.102-.279c-.19-.089-.279.102-.279.102l-.622 1.336c-.001.002-.097-.039-.099-.036l.515-1.106s.089-.19-.102-.279c-.19-.089-.279.102-.279.102l-.702 1.507c-.048.103-.139.105-.179.078-.106-.072-.05-.26-.079-.424-.045-.25-.196-.415-.355-.418-.176-.003-.206.138-.192.187.028.095.073.254.126.506.037.177-.017.324-.017.324-.023.072-.085.318-.038.604.028.169.092.352.205.52l-.332.714 1.507.659.366-.956c.267-.119.5-.319.633-.604l.044-.095.631-1.355c.003-.004.091-.194-.099-.282z"/>
+  <path fill="#EF9645" d="M26.348 2.443c.04-.012.06-.036.075-.058.168.108.389.272.491.543.132.351.054.627-.028.798-.066.138-.172.168-.099-.047.092-.27.143-.866-.455-1.205l-.055-.03c.001 0 .037.009.071-.001z"/>
+  <path fill="#FFDC5D" d="M11.397 20.373c-.076-.064-.202-.171-.385-.352-.129-.128-.165-.279-.165-.279-.021-.073-.105-.312-.303-.524-.117-.126-.271-.243-.459-.319l-.116-.779-1.62.284.224.999c-.156.247-.24.542-.193.854l.016.104.224 1.478s.031.207.239.176.176-.239.176-.239L8.9 20.89c.002.015.108.013.11.026l.198 1.31s.031.207.239.176c.207-.031.176-.239.176-.239l-.019-.126-.167-1.105c0 .003.104-.01.105-.009l.221 1.457s.031.207.239.176c.207-.031.176-.239.176-.239l-.22-1.457c0-.002.103-.021.102-.024l.182 1.206s.031.207.239.176c.207-.031.176-.239.176-.239l-.249-1.644c-.017-.112.057-.165.106-.164.128.001.185.189.3.309.176.183.393.237.527.152.148-.092.095-.227.056-.259z"/>
+  <path fill="#292F33" d="M25.814 3.729S23.755 8.05 23.512 8.19c-.219.126-1.719-.042-3.699.354-1.979.396-2.744-.155-4.769-.075-1.176.046-2.413.452-3.227.648-1.237.299-3.276 3.237-3.848 4.26-.443.791-.421 1.68-.327 2.372.078.578.058 1.486.82 3.41l1.719-.281s-.289-3.783-.043-4.192c.102-.169 1.323-2.119 2.303-2.473l8.188-.385s3.375-.485 4.302-1.167c.708-.521 2.497-6.251 2.497-6.251l-1.614-.681z"/>
+  <path fill="#FFDC5D" d="M15.546 5.857c.175-.172.407-.289.671-.314.613-.064 1.16.383 1.222.997l.319 3.153c.061.611-.384 1.16-.997 1.221-.615.063-1.161-.384-1.222-.997l-.32-3.153c-.035-.35.095-.676.327-.907z"/>
+  <path fill="#FFDC5D" d="M19.243 4.296c0 1.849-1.309 3.348-2.927 3.348s-2.929-1.499-2.929-3.348c0-1.848 1.311-3.347 2.929-3.347 1.618 0 2.927 1.499 2.927 3.347z"/>
+  <path fill="#FFAC33" d="M17.89 2.018s-1.14 1.07-2.349 1.255c0 0-.723 1.171-.768 1.847 0 0-.45-1.081-.81-.9-.36.18-.496.855-.18 1.305.315.451.705 1.226.363 1.003-.689-.45-.79-.768-.976-1.07-.127-.206-.425-.943-.465-2.162-.024-.721.232-2.116 1.707-2.857 1.278-.644 3.287-.315 4.231.359.945.676 1.18 2.38.594 3.056 0 0-.085-.63-.488-1.14-.226-.283-.859-.696-.859-.696z"/>
+  <path fill="#DD2E44" d="M12.407 9.474v9.253l7.083-.024s.625-2.703 1.034-4.549c.474-2.135-1.079-5.392-1.079-5.392l-1.082-.108-.212-1.011-1.056.4-.151 1.751-.456-1.666-1.548-.135-.184 1.067-2.349.414z"/>
+  <path fill="#292F33" d="M11.781 9.593s.688 2.443.385 7.5c2.228-.038 3.963-.732 3.963-.732s.146-1.919-.228-3.615c-.546-2.469-1.105-3.921-1.105-3.921l-3.015.768zm6.516-1.246s.623 3.271.484 5.224c-.14 1.953-.235 2.694-.235 2.694s.441.486 1.57.466c.467-1.222.752-3.679.695-5.183-.059-1.585-1.002-3.127-1.002-3.127l-1.512-.074zm-5.89 10.38s-.607 6.995-.982 8.307c-.334 1.168-3.799 6.742-3.799 6.742s1.646.406 3.229.094c0 0 3.163-3.836 3.99-5.688.24-.539 1.835-5.521 1.835-5.521s1.712 1.908 2.863 3.599c.728 2.003.09 7.442.09 7.442s1.483.312 2.899.042c0 0 .859-5.397.651-7.688-.033-.367-.103-1.05-.245-1.309-.619-1.123-1.963-3.231-2.822-4.172-.774-.847-.625-1.873-.625-1.873l-7.084.025z"/>
+</svg>
diff --git a/spec/controllers/accounts_controller_spec.rb b/spec/controllers/accounts_controller_spec.rb
index 93bf2c83f..b04f4650b 100644
--- a/spec/controllers/accounts_controller_spec.rb
+++ b/spec/controllers/accounts_controller_spec.rb
@@ -348,24 +348,8 @@ RSpec.describe AccountsController, type: :controller do
         context 'in authorized fetch mode' do
           let(:authorized_fetch_mode) { true }
 
-          it 'returns http success' do
-            expect(response).to have_http_status(200)
-          end
-
-          it 'returns application/activity+json' do
-            expect(response.content_type).to eq 'application/activity+json'
-          end
-
-          it_behaves_like 'cachable response'
-
-          it 'returns Vary header with Signature' do
-            expect(response.headers['Vary']).to include 'Signature'
-          end
-
-          it 'renders bare minimum account' do
-            json = body_as_json
-            expect(json).to include(:id, :type, :preferredUsername, :inbox, :publicKey)
-            expect(json).to_not include(:name, :summary)
+          it 'returns http unauthorized' do
+            expect(response).to have_http_status(401)
           end
         end
       end
diff --git a/spec/controllers/api/v1/accounts_controller_spec.rb b/spec/controllers/api/v1/accounts_controller_spec.rb
index 024409dab..1e656503f 100644
--- a/spec/controllers/api/v1/accounts_controller_spec.rb
+++ b/spec/controllers/api/v1/accounts_controller_spec.rb
@@ -71,50 +71,80 @@ RSpec.describe Api::V1::AccountsController, type: :controller do
     let(:scopes) { 'write:follows' }
     let(:other_account) { Fabricate(:user, email: 'bob@example.com', account: Fabricate(:account, username: 'bob', locked: locked)).account }
 
-    before do
-      post :follow, params: { id: other_account.id }
-    end
+    context do
+      before do
+        post :follow, params: { id: other_account.id }
+      end
 
-    context 'with unlocked account' do
-      let(:locked) { false }
+      context 'with unlocked account' do
+        let(:locked) { false }
 
-      it 'returns http success' do
-        expect(response).to have_http_status(200)
-      end
+        it 'returns http success' do
+          expect(response).to have_http_status(200)
+        end
 
-      it 'returns JSON with following=true and requested=false' do
-        json = body_as_json
+        it 'returns JSON with following=true and requested=false' do
+          json = body_as_json
 
-        expect(json[:following]).to be true
-        expect(json[:requested]).to be false
-      end
+          expect(json[:following]).to be true
+          expect(json[:requested]).to be false
+        end
+
+        it 'creates a following relation between user and target user' do
+          expect(user.account.following?(other_account)).to be true
+        end
 
-      it 'creates a following relation between user and target user' do
-        expect(user.account.following?(other_account)).to be true
+        it_behaves_like 'forbidden for wrong scope', 'read:accounts'
       end
 
-      it_behaves_like 'forbidden for wrong scope', 'read:accounts'
+      context 'with locked account' do
+        let(:locked) { true }
+
+        it 'returns http success' do
+          expect(response).to have_http_status(200)
+        end
+
+        it 'returns JSON with following=false and requested=true' do
+          json = body_as_json
+
+          expect(json[:following]).to be false
+          expect(json[:requested]).to be true
+        end
+
+        it 'creates a follow request relation between user and target user' do
+          expect(user.account.requested?(other_account)).to be true
+        end
+
+        it_behaves_like 'forbidden for wrong scope', 'read:accounts'
+      end
     end
 
-    context 'with locked account' do
-      let(:locked) { true }
+    context 'modifying follow options' do
+      let(:locked) { false }
 
-      it 'returns http success' do
-        expect(response).to have_http_status(200)
+      before do
+        user.account.follow!(other_account, reblogs: false, notify: false)
       end
 
-      it 'returns JSON with following=false and requested=true' do
+      it 'changes reblogs option' do
+        post :follow, params: { id: other_account.id, reblogs: true }
+
         json = body_as_json
 
-        expect(json[:following]).to be false
-        expect(json[:requested]).to be true
+        expect(json[:following]).to be true
+        expect(json[:showing_reblogs]).to be true
+        expect(json[:notifying]).to be false
       end
 
-      it 'creates a follow request relation between user and target user' do
-        expect(user.account.requested?(other_account)).to be true
-      end
+      it 'changes notify option' do
+        post :follow, params: { id: other_account.id, notify: true }
+
+        json = body_as_json
 
-      it_behaves_like 'forbidden for wrong scope', 'read:accounts'
+        expect(json[:following]).to be true
+        expect(json[:showing_reblogs]).to be false
+        expect(json[:notifying]).to be true
+      end
     end
   end
 
diff --git a/spec/controllers/auth/registrations_controller_spec.rb b/spec/controllers/auth/registrations_controller_spec.rb
index c2e9f33a8..bef822763 100644
--- a/spec/controllers/auth/registrations_controller_spec.rb
+++ b/spec/controllers/auth/registrations_controller_spec.rb
@@ -199,9 +199,10 @@ RSpec.describe Auth::RegistrationsController, type: :controller do
       end
 
       subject do
+        inviter = Fabricate(:user, confirmed_at: 2.days.ago)
         Setting.registrations_mode = 'approved'
         request.headers["Accept-Language"] = accept_language
-        invite = Fabricate(:invite, max_uses: nil, expires_at: 1.hour.from_now)
+        invite = Fabricate(:invite, user: inviter, max_uses: nil, expires_at: 1.hour.from_now)
         post :create, params: { user: { account_attributes: { username: 'test' }, email: 'test@example.com', password: '12345678', password_confirmation: '12345678', 'invite_code': invite.code, agreement: 'true' } }
       end
 
diff --git a/spec/controllers/concerns/export_controller_concern_spec.rb b/spec/controllers/concerns/export_controller_concern_spec.rb
index e5861c801..fce129bee 100644
--- a/spec/controllers/concerns/export_controller_concern_spec.rb
+++ b/spec/controllers/concerns/export_controller_concern_spec.rb
@@ -5,6 +5,7 @@ require 'rails_helper'
 describe ApplicationController, type: :controller do
   controller do
     include ExportControllerConcern
+
     def index
       send_export_file
     end
diff --git a/spec/controllers/settings/deletes_controller_spec.rb b/spec/controllers/settings/deletes_controller_spec.rb
index 996872efd..8d5c4774f 100644
--- a/spec/controllers/settings/deletes_controller_spec.rb
+++ b/spec/controllers/settings/deletes_controller_spec.rb
@@ -77,26 +77,26 @@ describe Settings::DeletesController do
           expect(response).to redirect_to settings_delete_path
         end
       end
-    end
 
-    context 'when not signed in' do
-      it 'redirects' do
-        delete :destroy
-        expect(response).to redirect_to '/auth/sign_in'
-      end
-    end
+      context 'when account deletions are disabled' do
+        around do |example|
+          open_deletion = Setting.open_deletion
+          example.run
+          Setting.open_deletion = open_deletion
+        end
 
-    context do
-      around do |example|
-        open_deletion = Setting.open_deletion
-        example.run
-        Setting.open_deletion = open_deletion
+        it 'redirects' do
+          Setting.open_deletion = false
+          delete :destroy
+          expect(response).to redirect_to root_path
+        end
       end
+    end
 
+    context 'when not signed in' do
       it 'redirects' do
-        Setting.open_deletion = false
         delete :destroy
-        expect(response).to redirect_to root_path
+        expect(response).to redirect_to '/auth/sign_in'
       end
     end
   end
diff --git a/spec/fabricators/account_deletion_request_fabricator.rb b/spec/fabricators/account_deletion_request_fabricator.rb
new file mode 100644
index 000000000..08a82ba3c
--- /dev/null
+++ b/spec/fabricators/account_deletion_request_fabricator.rb
@@ -0,0 +1,3 @@
+Fabricator(:account_deletion_request) do
+  account
+end
diff --git a/spec/models/account_deletion_request_spec.rb b/spec/models/account_deletion_request_spec.rb
new file mode 100644
index 000000000..afaecbe22
--- /dev/null
+++ b/spec/models/account_deletion_request_spec.rb
@@ -0,0 +1,4 @@
+require 'rails_helper'
+
+RSpec.describe AccountDeletionRequest, type: :model do
+end
diff --git a/spec/models/concerns/account_interactions_spec.rb b/spec/models/concerns/account_interactions_spec.rb
index 36e346f14..48b44e58d 100644
--- a/spec/models/concerns/account_interactions_spec.rb
+++ b/spec/models/concerns/account_interactions_spec.rb
@@ -14,14 +14,14 @@ describe AccountInteractions do
     context 'account with Follow' do
       it 'returns { target_account_id => { reblogs: true } }' do
         Fabricate(:follow, account: account, target_account: target_account)
-        is_expected.to eq(target_account_id => { reblogs: true })
+        is_expected.to eq(target_account_id => { reblogs: true, notify: false })
       end
     end
 
     context 'account with Follow but with reblogs disabled' do
       it 'returns { target_account_id => { reblogs: false } }' do
         Fabricate(:follow, account: account, target_account: target_account, show_reblogs: false)
-        is_expected.to eq(target_account_id => { reblogs: false })
+        is_expected.to eq(target_account_id => { reblogs: false, notify: false })
       end
     end
 
diff --git a/spec/models/follow_request_spec.rb b/spec/models/follow_request_spec.rb
index 4b824c0db..7c8e121d9 100644
--- a/spec/models/follow_request_spec.rb
+++ b/spec/models/follow_request_spec.rb
@@ -7,7 +7,7 @@ RSpec.describe FollowRequest, type: :model do
     let(:target_account) { Fabricate(:account) }
 
     it 'calls Account#follow!, MergeWorker.perform_async, and #destroy!' do
-      expect(account).to        receive(:follow!).with(target_account, reblogs: true, uri: follow_request.uri)
+      expect(account).to        receive(:follow!).with(target_account, reblogs: true, notify: false, uri: follow_request.uri)
       expect(MergeWorker).to    receive(:perform_async).with(target_account.id, account.id)
       expect(follow_request).to receive(:destroy!)
       follow_request.authorize!
diff --git a/spec/models/invite_spec.rb b/spec/models/invite_spec.rb
index 30abfb86b..b0596c561 100644
--- a/spec/models/invite_spec.rb
+++ b/spec/models/invite_spec.rb
@@ -29,7 +29,7 @@ RSpec.describe Invite, type: :model do
 
     it 'returns false when invite creator has been disabled' do
       invite = Fabricate(:invite, max_uses: nil, expires_at: nil)
-      SuspendAccountService.new.call(invite.user.account)
+      invite.user.account.suspend!
       expect(invite.valid_for_use?).to be false
     end
   end
diff --git a/spec/models/webauthn_credentials_spec.rb b/spec/models/webauthn_credentials_spec.rb
index 9289c371e..a63ae6cd2 100644
--- a/spec/models/webauthn_credentials_spec.rb
+++ b/spec/models/webauthn_credentials_spec.rb
@@ -69,8 +69,8 @@ RSpec.describe WebauthnCredential, type: :model do
       expect(webauthn_credential).to model_have_error_on_field(:sign_count)
     end
 
-    it 'is invalid if sign_count is greater 2**32 - 1' do
-      webauthn_credential = Fabricate.build(:webauthn_credential, sign_count: 2**32)
+    it 'is invalid if sign_count is greater 2**63 - 1' do
+      webauthn_credential = Fabricate.build(:webauthn_credential, sign_count: 2**63)
 
       webauthn_credential.valid?
 
diff --git a/spec/services/suspend_account_service_spec.rb b/spec/services/delete_account_service_spec.rb
index 32726d763..d208b25b8 100644
--- a/spec/services/suspend_account_service_spec.rb
+++ b/spec/services/delete_account_service_spec.rb
@@ -1,6 +1,6 @@
 require 'rails_helper'
 
-RSpec.describe SuspendAccountService, type: :service do
+RSpec.describe DeleteAccountService, type: :service do
   describe '#call on local account' do
     before do
       stub_request(:post, "https://alice.com/inbox").to_return(status: 201)
diff --git a/spec/services/import_service_spec.rb b/spec/services/import_service_spec.rb
index 7618e9076..b1909d4fd 100644
--- a/spec/services/import_service_spec.rb
+++ b/spec/services/import_service_spec.rb
@@ -95,6 +95,7 @@ RSpec.describe ImportService, type: :service do
       let(:import) { Import.create(account: account, type: 'following', data: csv) }
       it 'follows the listed accounts, including boosts' do
         subject.call(import)
+
         expect(account.following.count).to eq 1
         expect(account.follow_requests.count).to eq 1
         expect(Follow.find_by(account: account, target_account: bob).show_reblogs).to be true
diff --git a/spec/services/notify_service_spec.rb b/spec/services/notify_service_spec.rb
index b09d335ba..118436f8b 100644
--- a/spec/services/notify_service_spec.rb
+++ b/spec/services/notify_service_spec.rb
@@ -2,13 +2,14 @@ require 'rails_helper'
 
 RSpec.describe NotifyService, type: :service do
   subject do
-    -> { described_class.new.call(recipient, activity) }
+    -> { described_class.new.call(recipient, type, activity) }
   end
 
   let(:user) { Fabricate(:user) }
   let(:recipient) { user.account }
   let(:sender) { Fabricate(:account, domain: 'example.com') }
   let(:activity) { Fabricate(:follow, account: sender, target_account: recipient) }
+  let(:type) { :follow }
 
   it { is_expected.to change(Notification, :count).by(1) }
 
@@ -50,6 +51,7 @@ RSpec.describe NotifyService, type: :service do
   
   context 'for direct messages' do
     let(:activity) { Fabricate(:mention, account: recipient, status: Fabricate(:status, account: sender, visibility: :direct)) }
+    let(:type)     { :mention }
 
     before do
       user.settings.interactions = user.settings.interactions.merge('must_be_following_dm' => enabled)
@@ -93,6 +95,7 @@ RSpec.describe NotifyService, type: :service do
   describe 'reblogs' do
     let(:status)   { Fabricate(:status, account: Fabricate(:account)) }
     let(:activity) { Fabricate(:status, account: sender, reblog: status) }
+    let(:type)     { :reblog }
 
     it 'shows reblogs by default' do
       recipient.follow!(sender)
@@ -114,6 +117,7 @@ RSpec.describe NotifyService, type: :service do
     let(:asshole)  { Fabricate(:account, username: 'asshole') }
     let(:reply_to) { Fabricate(:status, account: asshole) }
     let(:activity) { Fabricate(:mention, account: recipient, status: Fabricate(:status, account: sender, thread: reply_to)) }
+    let(:type)     { :mention }
 
     it 'does not notify when conversation is muted' do
       recipient.mute_conversation!(activity.status.conversation)
diff --git a/spec/workers/refollow_worker_spec.rb b/spec/workers/refollow_worker_spec.rb
index 29771aa59..6b4c04291 100644
--- a/spec/workers/refollow_worker_spec.rb
+++ b/spec/workers/refollow_worker_spec.rb
@@ -23,8 +23,8 @@ describe RefollowWorker do
       result = subject.perform(account.id)
 
       expect(result).to be_nil
-      expect(service).to have_received(:call).with(alice, account, reblogs: true)
-      expect(service).to have_received(:call).with(bob, account, reblogs: false)
+      expect(service).to have_received(:call).with(alice, account, reblogs: true, notify: false)
+      expect(service).to have_received(:call).with(bob, account, reblogs: false, notify: false)
     end
   end
 end
diff --git a/streaming/index.js b/streaming/index.js
index 62f6b5fe0..e4a5feb21 100644
--- a/streaming/index.js
+++ b/streaming/index.js
@@ -81,6 +81,19 @@ const redisUrlToClient = (defaultConfig, redisUrl) => {
 
 const numWorkers = +process.env.STREAMING_CLUSTER_NUM || (env === 'development' ? 1 : Math.max(os.cpus().length - 1, 1));
 
+/**
+ * @param {string} json
+ * @return {Object.<string, any>|null}
+ */
+const parseJSON = (json) => {
+  try {
+    return JSON.parse(json);
+  } catch (err) {
+    log.error(err);
+    return null;
+  }
+};
+
 const startMaster = () => {
   if (!process.env.SOCKET && process.env.PORT && isNaN(+process.env.PORT)) {
     log.warn('UNIX domain socket is now supported by using SOCKET. Please migrate from PORT hack.');
@@ -524,7 +537,9 @@ const startWorker = (workerId) => {
     log.verbose(req.requestId, `Starting stream from ${ids.join(', ')} for ${accountId}${streamType}`);
 
     const listener = message => {
-      const { event, payload, queued_at } = JSON.parse(message);
+      const json = parseJSON(message);
+      if (!json) return;
+      const { event, payload, queued_at } = json;
 
       const transmit = () => {
         const now            = new Date().getTime();
@@ -954,7 +969,9 @@ const startWorker = (workerId) => {
     ws.on('error', onEnd);
 
     ws.on('message', data => {
-      const { type, stream, ...params } = JSON.parse(data);
+      const json = parseJSON(data);
+      if (!json) return;
+      const { type, stream, ...params } = json;
 
       if (type === 'subscribe') {
         subscribeWebsocketToChannel(session, firstParam(stream), params);
diff --git a/yarn.lock b/yarn.lock
index 99df34583..5d6ff2fde 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -842,10 +842,10 @@
     "@babel/helper-create-regexp-features-plugin" "^7.10.4"
     "@babel/helper-plugin-utils" "^7.10.4"
 
-"@babel/preset-env@^7.11.0":
-  version "7.11.0"
-  resolved "https://registry.yarnpkg.com/@babel/preset-env/-/preset-env-7.11.0.tgz#860ee38f2ce17ad60480c2021ba9689393efb796"
-  integrity sha512-2u1/k7rG/gTh02dylX2kL3S0IJNF+J6bfDSp4DI2Ma8QN6Y9x9pmAax59fsCk6QUQG0yqH47yJWA+u1I1LccAg==
+"@babel/preset-env@^7.11.5":
+  version "7.11.5"
+  resolved "https://registry.yarnpkg.com/@babel/preset-env/-/preset-env-7.11.5.tgz#18cb4b9379e3e92ffea92c07471a99a2914e4272"
+  integrity sha512-kXqmW1jVcnB2cdueV+fyBM8estd5mlNfaQi6lwLgRwCby4edpavgbFhiBNjmWA3JpB/yZGSISa7Srf+TwxDQoA==
   dependencies:
     "@babel/compat-data" "^7.11.0"
     "@babel/helper-compilation-targets" "^7.10.4"
@@ -909,7 +909,7 @@
     "@babel/plugin-transform-unicode-escapes" "^7.10.4"
     "@babel/plugin-transform-unicode-regex" "^7.10.4"
     "@babel/preset-modules" "^0.1.3"
-    "@babel/types" "^7.11.0"
+    "@babel/types" "^7.11.5"
     browserslist "^4.12.0"
     core-js-compat "^3.6.2"
     invariant "^2.2.2"
@@ -1112,10 +1112,10 @@
   resolved "https://registry.yarnpkg.com/@gamestdio/websocket/-/websocket-0.3.2.tgz#321ba0976ee30fd14e51dbf8faa85ce7b325f76a"
   integrity sha512-J3n5SKim+ZoLbe44hRGI/VYAwSMCeIJuBy+FfP6EZaujEpNchPRFcIsVQLWAwpU1bP2Ji63rC+rEUOd1vjUB6Q==
 
-"@github/webauthn-json@^0.4.2":
-  version "0.4.2"
-  resolved "https://registry.yarnpkg.com/@github/webauthn-json/-/webauthn-json-0.4.2.tgz#573bba7f30f035d82a6b6040430eb4e9729db849"
-  integrity sha512-10RwfEzpg0y68Coj480tYE4KajRO39ii0652bWbKM0OfXlV9szw6N5vMwnNzelvAMigcEDiAtFcvFCvB8GnTtA==
+"@github/webauthn-json@^0.5.4":
+  version "0.5.4"
+  resolved "https://registry.yarnpkg.com/@github/webauthn-json/-/webauthn-json-0.5.4.tgz#fe4f47647c2b29e42e33f6f30484fec7e5adfeb1"
+  integrity sha512-lZi5cSZi2F08a2kmjxr/FU13ILenpxkZJUWo1p3hHAmMHLLr5GAVlkCJmvWeIsIiAp65AHh3dQIZSMPIylcClw==
 
 "@istanbuljs/load-nyc-config@^1.0.0":
   version "1.1.0"
@@ -1482,9 +1482,9 @@
   integrity sha512-tHq6qdbT9U1IRSGf14CL0pUlULksvY9OZ+5eEgl1N7t+OA3tGvNpxJCzuKQlsNgCVwbAs670L1vcVQi8j9HjnA==
 
 "@types/node@*":
-  version "14.6.0"
-  resolved "https://registry.yarnpkg.com/@types/node/-/node-14.6.0.tgz#7d4411bf5157339337d7cff864d9ff45f177b499"
-  integrity sha512-mikldZQitV94akrc4sCcSjtJfsTKt4p+e/s0AGscVA6XArQ9kFclP+ZiYUMnq987rc6QlYxXv/EivqlfSLxpKA==
+  version "14.11.1"
+  resolved "https://registry.yarnpkg.com/@types/node/-/node-14.11.1.tgz#56af902ad157e763f9ba63d671c39cda3193c835"
+  integrity sha512-oTQgnd0hblfLsJ6BvJzzSL+Inogp3lq9fGgqRkMB/ziKMgEUaFl801OncOzUmalfzt14N0oPHMK47ipl+wbTIw==
 
 "@types/normalize-package-data@^2.4.0":
   version "2.4.0"
@@ -1691,9 +1691,9 @@
   integrity sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==
 
 abab@^2.0.3:
-  version "2.0.4"
-  resolved "https://registry.yarnpkg.com/abab/-/abab-2.0.4.tgz#6dfa57b417ca06d21b2478f0e638302f99c2405c"
-  integrity sha512-Eu9ELJWCz/c1e9gTiCY+FceWxcqzjYEbqMgtndnuSqZSUCOL73TWNK2mHfIj4Cw2E/ongOp+JISVNCmovt2KYQ==
+  version "2.0.5"
+  resolved "https://registry.yarnpkg.com/abab/-/abab-2.0.5.tgz#c0b678fb32d60fc1219c784d6a826fe385aeb79a"
+  integrity sha512-9IK9EadsbHo6jLWIpxpR6pL0sazTXV6+SQv25ZB+F7Bj9mJNaOc4nCRabwd5M/JwmUa8idz6Eci6eKfJryPs6Q==
 
 accepts@~1.3.4, accepts@~1.3.5, accepts@~1.3.7:
   version "1.3.7"
@@ -1766,12 +1766,7 @@ ajv-keywords@^1.0.0:
   resolved "https://registry.yarnpkg.com/ajv-keywords/-/ajv-keywords-1.5.1.tgz#314dd0a4b3368fad3dfcdc54ede6171b886daf3c"
   integrity sha1-MU3QpLM2j609/NxU7eYXG4htrzw=
 
-ajv-keywords@^3.1.0, ajv-keywords@^3.4.1:
-  version "3.4.1"
-  resolved "https://registry.yarnpkg.com/ajv-keywords/-/ajv-keywords-3.4.1.tgz#ef916e271c64ac12171fd8384eaae6b2345854da"
-  integrity sha512-RO1ibKvd27e6FEShVFfPALuHI3WjSVNeK5FIsmme/LYRNxjKuNj+Dt7bucLa6NdSv3JcVTyMlm9kGR84z1XpaQ==
-
-ajv-keywords@^3.5.2:
+ajv-keywords@^3.1.0, ajv-keywords@^3.4.1, ajv-keywords@^3.5.2:
   version "3.5.2"
   resolved "https://registry.yarnpkg.com/ajv-keywords/-/ajv-keywords-3.5.2.tgz#31f29da5ab6e00d1c2d329acf7b5929614d5014d"
   integrity sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==
@@ -2088,12 +2083,12 @@ axe-core@^3.5.4:
   resolved "https://registry.yarnpkg.com/axe-core/-/axe-core-3.5.5.tgz#84315073b53fa3c0c51676c588d59da09a192227"
   integrity sha512-5P0QZ6J5xGikH780pghEdbEKijCTrruK9KxtPZCFWUpef0f6GipO+xEZ5GKCb020mmqgbiNO6TcA55CriL784Q==
 
-axios@^0.19.2:
-  version "0.19.2"
-  resolved "https://registry.yarnpkg.com/axios/-/axios-0.19.2.tgz#3ea36c5d8818d0d5f8a8a97a6d36b86cdc00cb27"
-  integrity sha512-fjgm5MvRHLhx+osE2xoekY70AhARk3a6hkN+3Io1jc00jtquGvxYlKlsFUhmUET0V5te6CcZI7lcv2Ym61mjHA==
+axios@^0.20.0:
+  version "0.20.0"
+  resolved "https://registry.yarnpkg.com/axios/-/axios-0.20.0.tgz#057ba30f04884694993a8cd07fa394cff11c50bd"
+  integrity sha512-ANA4rr2BDcmmAQLOKft2fufrtuvlqR+cXNNinUmvfeSNCOF98PZL+7M/v1zIdGo7OLjEA9J2gXJL+j4zGsl0bA==
   dependencies:
-    follow-redirects "1.5.10"
+    follow-redirects "^1.10.0"
 
 axobject-query@^2.1.2:
   version "2.2.0"
@@ -2506,14 +2501,14 @@ browserify-zlib@^0.2.0:
     pako "~1.0.5"
 
 browserslist@^4.0.0, browserslist@^4.12.0, browserslist@^4.8.5:
-  version "4.14.1"
-  resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.14.1.tgz#cb2b490ba881d45dc3039078c7ed04411eaf3fa3"
-  integrity sha512-zyBTIHydW37pnb63c7fHFXUG6EcqWOqoMdDx6cdyaDFriZ20EoVxcE95S54N+heRqY8m8IUgB5zYta/gCwSaaA==
+  version "4.14.3"
+  resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.14.3.tgz#381f9e7f13794b2eb17e1761b4f118e8ae665a53"
+  integrity sha512-GcZPC5+YqyPO4SFnz48/B0YaCwS47Q9iPChRGi6t7HhflKBcINzFrJvRfC+jp30sRMKxF+d4EHGs27Z0XP1NaQ==
   dependencies:
-    caniuse-lite "^1.0.30001124"
-    electron-to-chromium "^1.3.562"
-    escalade "^3.0.2"
-    node-releases "^1.1.60"
+    caniuse-lite "^1.0.30001131"
+    electron-to-chromium "^1.3.570"
+    escalade "^3.1.0"
+    node-releases "^1.1.61"
 
 bser@2.1.1:
   version "2.1.1"
@@ -2587,7 +2582,7 @@ cacache@^12.0.2:
     unique-filename "^1.1.1"
     y18n "^4.0.0"
 
-cacache@^15.0.4, cacache@^15.0.5:
+cacache@^15.0.5:
   version "15.0.5"
   resolved "https://registry.yarnpkg.com/cacache/-/cacache-15.0.5.tgz#69162833da29170d6732334643c60e005f5f17d0"
   integrity sha512-lloiL22n7sOjEEXdL8NAjTgv9a1u43xICE9/203qonkZUCj5X1UEWIdf2/Y0d6QcCtMzbKQyhrcDbdvlZTs/+A==
@@ -2681,10 +2676,10 @@ caniuse-api@^3.0.0:
     lodash.memoize "^4.1.2"
     lodash.uniq "^4.5.0"
 
-caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001109, caniuse-lite@^1.0.30001124:
-  version "1.0.30001124"
-  resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001124.tgz#5d9998190258e11630d674fc50ea8e579ae0ced2"
-  integrity sha512-zQW8V3CdND7GHRH6rxm6s59Ww4g/qGWTheoboW9nfeMg7sUoopIfKCcNZUjwYRCOrvereh3kwDpZj4VLQ7zGtA==
+caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001109, caniuse-lite@^1.0.30001131:
+  version "1.0.30001133"
+  resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001133.tgz#ec564c5495311299eb05245e252d589a84acd95e"
+  integrity sha512-s3XAUFaC/ntDb1O3lcw9K8MPeOW7KO3z9+GzAoBxfz1B0VdacXPMKgFUtG4KIsgmnbexmi013s9miVu4h+qMHw==
 
 capture-exit@^2.0.0:
   version "2.0.0"
@@ -2878,6 +2873,15 @@ cliui@^6.0.0:
     strip-ansi "^6.0.0"
     wrap-ansi "^6.2.0"
 
+cliui@^7.0.0:
+  version "7.0.1"
+  resolved "https://registry.yarnpkg.com/cliui/-/cliui-7.0.1.tgz#a4cb67aad45cd83d8d05128fc9f4d8fbb887e6b3"
+  integrity sha512-rcvHOWyGyid6I1WjT/3NatKj2kDt9OdSHSXpyLXaMWFbKpGACNW8pRhhdPUq9MWUOdwn8Rz9AVETjF4105rZZQ==
+  dependencies:
+    string-width "^4.2.0"
+    strip-ansi "^6.0.0"
+    wrap-ansi "^7.0.0"
+
 co@^4.6.0:
   version "4.6.0"
   resolved "https://registry.yarnpkg.com/co/-/co-4.6.0.tgz#6ea6bdf3d853ae54ccb8e47bfa0bf3f9031fb184"
@@ -3258,10 +3262,10 @@ css-list-helpers@^1.0.1:
   dependencies:
     tcomb "^2.5.0"
 
-css-loader@^4.2.2:
-  version "4.2.2"
-  resolved "https://registry.yarnpkg.com/css-loader/-/css-loader-4.2.2.tgz#b668b3488d566dc22ebcf9425c5f254a05808c89"
-  integrity sha512-omVGsTkZPVwVRpckeUnLshPp12KsmMSLqYxs12+RzM9jRR5Y+Idn/tBffjXRvOE+qW7if24cuceFJqYR5FmGBg==
+css-loader@^4.3.0:
+  version "4.3.0"
+  resolved "https://registry.yarnpkg.com/css-loader/-/css-loader-4.3.0.tgz#c888af64b2a5b2e85462c72c0f4a85c7e2e0821e"
+  integrity sha512-rdezjCjScIrsL8BSYszgT4s476IcNKt6yX69t0pHjJVnPUTDpn4WfIpDQTN3wCJvUvfsz/mFjuGOekf3PY3NUg==
   dependencies:
     camelcase "^6.0.0"
     cssesc "^3.0.0"
@@ -3273,7 +3277,7 @@ css-loader@^4.2.2:
     postcss-modules-scope "^2.2.0"
     postcss-modules-values "^3.0.0"
     postcss-value-parser "^4.1.0"
-    schema-utils "^2.7.0"
+    schema-utils "^2.7.1"
     semver "^7.3.2"
 
 css-select-base-adapter@^0.1.1:
@@ -3474,14 +3478,7 @@ debug@2.6.9, debug@^2.1.1, debug@^2.2.0, debug@^2.3.3, debug@^2.6.9:
   dependencies:
     ms "2.0.0"
 
-debug@=3.1.0:
-  version "3.1.0"
-  resolved "https://registry.yarnpkg.com/debug/-/debug-3.1.0.tgz#5bb5a0672628b64149566ba16819e61518c67261"
-  integrity sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==
-  dependencies:
-    ms "2.0.0"
-
-debug@^3.0.0, debug@^3.1.1, debug@^3.2.5:
+debug@^3.1.1, debug@^3.2.5:
   version "3.2.6"
   resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.6.tgz#e83d17de16d8a7efb7717edbe5fb10135eee629b"
   integrity sha512-mel+jf7nrtEl5Pn1Qx46zARXKDpBbvzezse7p7LqINmdoIk8PYP5SySaxEmYv6TZ0JyEKA1hsCId6DIhgITtWQ==
@@ -3545,7 +3542,7 @@ default-gateway@^4.2.0:
     execa "^1.0.0"
     ip-regex "^2.1.0"
 
-define-properties@^1.1.2, define-properties@^1.1.3:
+define-properties@^1.1.3:
   version "1.1.3"
   resolved "https://registry.yarnpkg.com/define-properties/-/define-properties-1.1.3.tgz#cf88da6cbee26fe6db7094f61d870cbd84cee9f1"
   integrity sha512-3MqfYKj2lLzdMSf8ZIZE/V+Zuy+BgD6f164e8K2w7dgnpKArBDerGYpM46IYYcjnkdPNMjPk9A6VFB8+3SKlXQ==
@@ -3635,10 +3632,10 @@ detect-node@^2.0.4:
   resolved "https://registry.yarnpkg.com/detect-node/-/detect-node-2.0.4.tgz#014ee8f8f669c5c58023da64b8179c083a28c46c"
   integrity sha512-ZIzRpLJrOj7jjP2miAtgqIfmzbxa4ZOr5jJc601zklsfEx9oTzmmj2nVpIPRpNlRTIh8lc1kyViIY7BWSGNmKw==
 
-detect-passive-events@^1.0.2:
-  version "1.0.4"
-  resolved "https://registry.yarnpkg.com/detect-passive-events/-/detect-passive-events-1.0.4.tgz#6ed477e6e5bceb79079735dcd357789d37f9a91a"
-  integrity sha1-btR35uW863kHlzXc01d4nTf5qRo=
+detect-passive-events@^1.0.5:
+  version "1.0.5"
+  resolved "https://registry.yarnpkg.com/detect-passive-events/-/detect-passive-events-1.0.5.tgz#ce324db665123bef9e368b8059ff95d95217cc05"
+  integrity sha512-foW7Q35wwOCxVzW0xLf5XeB5Fhe7oyRgvkBYdiP9IWgLMzjqUqTvsJv9ymuEWGjY6AoDXD3OC294+Z9iuOw0QA==
 
 diff-sequences@^25.2.6:
   version "25.2.6"
@@ -3760,9 +3757,9 @@ domutils@^1.7.0:
     domelementtype "1"
 
 dot-prop@^5.2.0:
-  version "5.2.0"
-  resolved "https://registry.yarnpkg.com/dot-prop/-/dot-prop-5.2.0.tgz#c34ecc29556dc45f1f4c22697b6f4904e0cc4fcb"
-  integrity sha512-uEUyaDKoSQ1M4Oq8l45hSE26SnTxL6snNnqvK/VWx5wJhmff5z0FUVJDKDanor/6w3kzE3i7XZOk+7wC0EXr1A==
+  version "5.3.0"
+  resolved "https://registry.yarnpkg.com/dot-prop/-/dot-prop-5.3.0.tgz#90ccce708cd9cd82cc4dc8c3ddd9abdd55b20e88"
+  integrity sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==
   dependencies:
     is-obj "^2.0.0"
 
@@ -3804,10 +3801,10 @@ ejs@^2.3.4, ejs@^2.6.1:
   resolved "https://registry.yarnpkg.com/ejs/-/ejs-2.7.4.tgz#48661287573dcc53e366c7a1ae52c3a120eec9ba"
   integrity sha512-7vmuyh5+kuUyJKePhQfRQBhXV5Ce+RnaeeQArKu1EAMpL3WbgMt5WG6uQZpEVvYSSsxMXRKOewtDk9RaTKXRlA==
 
-electron-to-chromium@^1.3.562:
-  version "1.3.562"
-  resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.562.tgz#79c20277ee1c8d0173a22af00e38433b752bc70f"
-  integrity sha512-WhRe6liQ2q/w1MZc8mD8INkenHivuHdrr4r5EQHNomy3NJux+incP6M6lDMd0paShP3MD0WGe5R1TWmEClf+Bg==
+electron-to-chromium@^1.3.570:
+  version "1.3.570"
+  resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.570.tgz#3f5141cc39b4e3892a276b4889980dabf1d29c7f"
+  integrity sha512-Y6OCoVQgFQBP5py6A/06+yWxUZHDlNr/gNDGatjH8AZqXl8X0tE4LfjLJsXGz/JmWJz8a6K7bR1k+QzZ+k//fg==
 
 elliptic@^6.5.3:
   version "6.5.3"
@@ -4021,10 +4018,10 @@ es6-weak-map@^2.0.1:
     es6-iterator "^2.0.3"
     es6-symbol "^3.1.1"
 
-escalade@^3.0.2:
-  version "3.0.2"
-  resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.0.2.tgz#6a580d70edb87880f22b4c91d0d56078df6962c4"
-  integrity sha512-gPYAU37hYCUhW5euPeR+Y74F7BL+IBsV93j5cvGriSaD1aG6MGsqsV1yamRdrWrb2j3aiZvb0X+UBOWpx3JWtQ==
+escalade@^3.0.2, escalade@^3.1.0:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.1.0.tgz#e8e2d7c7a8b76f6ee64c2181d6b8151441602d4e"
+  integrity sha512-mAk+hPSO8fLDkhV7V0dXazH5pDc6MrjBTPyD3VeKzxnVFjH1MIxbCdqGZB9O8+EwWakZs3ZCbDS4IpRt79V1ig==
 
 escape-html@^1.0.3, escape-html@~1.0.3:
   version "1.0.3"
@@ -4578,13 +4575,13 @@ file-entry-cache@^5.0.1:
   dependencies:
     flat-cache "^2.0.1"
 
-file-loader@^6.0.0:
-  version "6.0.0"
-  resolved "https://registry.yarnpkg.com/file-loader/-/file-loader-6.0.0.tgz#97bbfaab7a2460c07bcbd72d3a6922407f67649f"
-  integrity sha512-/aMOAYEFXDdjG0wytpTL5YQLfZnnTmLNjn+AIrJ/6HVnTfDqLsVKUUwkDf4I4kgex36BvjuXEn/TX9B/1ESyqQ==
+file-loader@^6.1.0:
+  version "6.1.0"
+  resolved "https://registry.yarnpkg.com/file-loader/-/file-loader-6.1.0.tgz#65b9fcfb0ea7f65a234a1f10cdd7f1ab9a33f253"
+  integrity sha512-26qPdHyTsArQ6gU4P1HJbAbnFTyT2r0pG7czh1GFAd9TZbj0n94wWbupgixZH/ET/meqi2/5+F7DhW4OAXD+Lg==
   dependencies:
     loader-utils "^2.0.0"
-    schema-utils "^2.6.5"
+    schema-utils "^2.7.1"
 
 file-type@^12.4.1:
   version "12.4.2"
@@ -4718,19 +4715,10 @@ flush-write-stream@^1.0.0:
     inherits "^2.0.3"
     readable-stream "^2.3.6"
 
-follow-redirects@1.5.10:
-  version "1.5.10"
-  resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.5.10.tgz#7b7a9f9aea2fdff36786a94ff643ed07f4ff5e2a"
-  integrity sha512-0V5l4Cizzvqt5D44aTXbFZz+FtyXV1vrDN6qrelxtfYQKW0KO0W2T/hkE8xvGa/540LkZlkaUjO4ailYTFtHVQ==
-  dependencies:
-    debug "=3.1.0"
-
-follow-redirects@^1.0.0:
-  version "1.11.0"
-  resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.11.0.tgz#afa14f08ba12a52963140fe43212658897bc0ecb"
-  integrity sha512-KZm0V+ll8PfBrKwMzdo5D13b1bur9Iq9Zd/RMmAoQQcl2PxxFml8cxXPaaPYVbV0RjNjq1CU7zIzAOqtUPudmA==
-  dependencies:
-    debug "^3.0.0"
+follow-redirects@^1.0.0, follow-redirects@^1.10.0:
+  version "1.13.0"
+  resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.13.0.tgz#b42e8d93a2a7eea5ed88633676d6597bc8e384db"
+  integrity sha512-aq6gF1BEKje4a9i9+5jimNFIpq4Q1WiwBToeRK5NvZBd/TRsmW8BsJfOEGkr76TbOyPVD3OVDN910EcUNtRYEA==
 
 font-awesome@^4.7.0:
   version "4.7.0"
@@ -4889,7 +4877,7 @@ gensync@^1.0.0-beta.1:
   resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.1.tgz#58f4361ff987e5ff6e1e7a210827aa371eaac269"
   integrity sha512-r8EC6NO1sngH/zdD9fiRDLdcgnbayXah+mLgManTaIZJqEC1MZstmnox8KpnI2/fxQwrp5OpCOYWLp4rBl4Jcg==
 
-get-caller-file@^2.0.1:
+get-caller-file@^2.0.1, get-caller-file@^2.0.5:
   version "2.0.5"
   resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-2.0.5.tgz#4f94412a82db32f36e3b0b9741f8a97feb031f7e"
   integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==
@@ -5090,7 +5078,7 @@ has-flag@^4.0.0:
   resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b"
   integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==
 
-has-symbols@^1.0.0, has-symbols@^1.0.1:
+has-symbols@^1.0.1:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.0.1.tgz#9f5214758a44196c406d9bd76cebf81ec2dd31e8"
   integrity sha512-PLcsoqu++dmEIZB+6totNFKq/7Do+Z0u4oT0zKOJNl3lYK6vGwwu2hjHs+68OEZbTjiUE9bgOABXbP/GvrS0Kg==
@@ -5659,9 +5647,9 @@ is-binary-path@~2.1.0:
     binary-extensions "^2.0.0"
 
 is-callable@^1.1.4, is-callable@^1.2.0:
-  version "1.2.0"
-  resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.2.0.tgz#83336560b54a38e35e3a2df7afd0454d691468bb"
-  integrity sha512-pyVD9AaGLxtg6srb2Ng6ynWJqkHU9bEM087AKck0w8QwDarTfNcpIYoU8x8Hv2Icm8u6kFJM18Dag8lyqGkviw==
+  version "1.2.1"
+  resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.2.1.tgz#4d1e21a4f437509d25ce55f8184350771421c96d"
+  integrity sha512-wliAfSzx6V+6WfMOmus1xy0XvSgf/dlStkvTfq7F0g4bOIW0PSUbnyse3NhDwdyYS1ozfUtAAySqTws3z9Eqgg==
 
 is-ci@^2.0.0:
   version "2.0.0"
@@ -6388,15 +6376,6 @@ jest-watcher@^26.3.0:
     jest-util "^26.3.0"
     string-length "^4.0.1"
 
-jest-worker@^26.0.0:
-  version "26.2.1"
-  resolved "https://registry.yarnpkg.com/jest-worker/-/jest-worker-26.2.1.tgz#5d630ab93f666b53f911615bc13e662b382bd513"
-  integrity sha512-+XcGMMJDTeEGncRb5M5Zq9P7K4sQ1sirhjdOxsN1462h6lFo9w59bl2LVQmdGEEeU3m+maZCkS2Tcc9SfCHO4A==
-  dependencies:
-    "@types/node" "*"
-    merge-stream "^2.0.0"
-    supports-color "^7.0.0"
-
 jest-worker@^26.3.0:
   version "26.3.0"
   resolved "https://registry.yarnpkg.com/jest-worker/-/jest-worker-26.3.0.tgz#7c8a97e4f4364b4f05ed8bca8ca0c24de091871f"
@@ -7206,14 +7185,14 @@ nice-try@^1.0.4:
   integrity sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==
 
 node-fetch@^2.6.0:
-  version "2.6.0"
-  resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.0.tgz#e633456386d4aa55863f676a7ab0daa8fdecb0fd"
-  integrity sha512-8dG4H5ujfvFiqDmVu9fQ5bOHUC15JMjMY/Zumv26oOvvVJjM67KF8koCWIabKQ1GJIa9r2mMZscBq/TbdOcmNA==
+  version "2.6.1"
+  resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.1.tgz#045bd323631f76ed2e2b55573394416b639a0052"
+  integrity sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw==
 
-node-forge@0.9.0:
-  version "0.9.0"
-  resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-0.9.0.tgz#d624050edbb44874adca12bb9a52ec63cb782579"
-  integrity sha512-7ASaDa3pD+lJ3WvXFsxekJQelBKRpne+GOVbLbtHYdd7pFspyeuJHnWfLplGf3SwKGbfs/aYl5V/JCIaHVUKKQ==
+node-forge@^0.10.0:
+  version "0.10.0"
+  resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-0.10.0.tgz#32dea2afb3e9926f02ee5ce8794902691a676bf3"
+  integrity sha512-PPmu8eEeG9saEUvI97fm4OYxXVB6bFvyNTyiUOBichBpFG8A1Ljw3bY62+5oOjDEMHRnd0Y7HQ+x7uzxOzC6JA==
 
 node-int64@^0.4.0:
   version "0.4.0"
@@ -7266,10 +7245,10 @@ node-notifier@^8.0.0:
     uuid "^8.3.0"
     which "^2.0.2"
 
-node-releases@^1.1.60:
-  version "1.1.60"
-  resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-1.1.60.tgz#6948bdfce8286f0b5d0e5a88e8384e954dfe7084"
-  integrity sha512-gsO4vjEdQaTusZAEebUWp2a5d7dF5DYoIpDG7WySnk7BuZDW+GPpHXoXXuYawRBr/9t5q54tirPz79kFIWg4dA==
+node-releases@^1.1.61:
+  version "1.1.61"
+  resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-1.1.61.tgz#707b0fca9ce4e11783612ba4a2fcba09047af16e"
+  integrity sha512-DD5vebQLg8jLCOzwupn954fbIiZht05DAZs0k2u8NStSe6h9XdsuIQL8hSRKYiU8WUQRznmSDrKGbv3ObOmC7g==
 
 normalize-package-data@^2.3.2, normalize-package-data@^2.5.0:
   version "2.5.0"
@@ -7401,7 +7380,7 @@ object-is@^1.0.1:
     define-properties "^1.1.3"
     es-abstract "^1.17.5"
 
-object-keys@^1.0.11, object-keys@^1.0.12, object-keys@^1.1.1:
+object-keys@^1.0.12, object-keys@^1.1.1:
   version "1.1.1"
   resolved "https://registry.yarnpkg.com/object-keys/-/object-keys-1.1.1.tgz#1c47f272df277f3b1daf061677d9c82e2322c60e"
   integrity sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==
@@ -7414,14 +7393,14 @@ object-visit@^1.0.0:
     isobject "^3.0.0"
 
 object.assign@^4.1.0:
-  version "4.1.0"
-  resolved "https://registry.yarnpkg.com/object.assign/-/object.assign-4.1.0.tgz#968bf1100d7956bb3ca086f006f846b3bc4008da"
-  integrity sha512-exHJeq6kBKj58mqGyTQ9DFvrZC/eR6OwxzoM9YRoGBqrXYonaFyGiFMuc9VZrXf7DarreEwMpurG3dd+CNyW5w==
+  version "4.1.1"
+  resolved "https://registry.yarnpkg.com/object.assign/-/object.assign-4.1.1.tgz#303867a666cdd41936ecdedfb1f8f3e32a478cdd"
+  integrity sha512-VT/cxmx5yaoHSOTSyrCygIDFco+RsibY2NM0a4RdEeY/4KgqezwFtK1yr3U67xYhqJSlASm2pKhLVzPj2lr4bA==
   dependencies:
-    define-properties "^1.1.2"
-    function-bind "^1.1.1"
-    has-symbols "^1.0.0"
-    object-keys "^1.0.11"
+    define-properties "^1.1.3"
+    es-abstract "^1.18.0-next.0"
+    has-symbols "^1.0.1"
+    object-keys "^1.1.1"
 
 object.entries@^1.1.2:
   version "1.1.2"
@@ -7596,10 +7575,10 @@ p-limit@^2.0.0, p-limit@^2.2.0:
   dependencies:
     p-try "^2.0.0"
 
-p-limit@^3.0.1:
-  version "3.0.1"
-  resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-3.0.1.tgz#584784ac0722d1aed09f19f90ed2999af6ce2839"
-  integrity sha512-mw/p92EyOzl2MhauKodw54Rx5ZK4624rNfgNaBguFZkHzyUG9WsDzFF5/yQVEJinbJDdP4jEfMN+uBquiGnaLg==
+p-limit@^3.0.2:
+  version "3.0.2"
+  resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-3.0.2.tgz#1664e010af3cadc681baafd3e2a437be7b0fb5fe"
+  integrity sha512-iwqZSOoWIW+Ew4kAGUlN16J4M7OB3ysMLSZtnhmqx7njIHFPlxWBX8xo3lVTyFVq6mI/lL9qt2IsN1sHwaxJkg==
   dependencies:
     p-try "^2.0.0"
 
@@ -9460,7 +9439,7 @@ schema-utils@^1.0.0:
     ajv-errors "^1.0.0"
     ajv-keywords "^3.1.0"
 
-schema-utils@^2.2.0, schema-utils@^2.6.5, schema-utils@^2.6.6, schema-utils@^2.7.0, schema-utils@^2.7.1:
+schema-utils@^2.2.0, schema-utils@^2.6.5, schema-utils@^2.7.0, schema-utils@^2.7.1:
   version "2.7.1"
   resolved "https://registry.yarnpkg.com/schema-utils/-/schema-utils-2.7.1.tgz#1ca4f32d1b24c590c203b8e7a50bf0ea4cd394d7"
   integrity sha512-SHiNtMOUGWBQJwzISiVYKu82GiV4QYGePp3odlY1tuKO7gPtphAT5R/py0fA6xtbgLL/RvtJZnU9b8s0F1q0Xg==
@@ -9483,11 +9462,11 @@ select-hose@^2.0.0:
   integrity sha1-Yl2GWPhlr0Psliv8N2o3NZpJlMo=
 
 selfsigned@^1.10.7:
-  version "1.10.7"
-  resolved "https://registry.yarnpkg.com/selfsigned/-/selfsigned-1.10.7.tgz#da5819fd049d5574f28e88a9bcc6dbc6e6f3906b"
-  integrity sha512-8M3wBCzeWIJnQfl43IKwOmC4H/RAp50S8DF60znzjW5GVqTcSe2vWclt7hmYVPkKPlHWOu5EaWOMZ2Y6W8ZXTA==
+  version "1.10.8"
+  resolved "https://registry.yarnpkg.com/selfsigned/-/selfsigned-1.10.8.tgz#0d17208b7d12c33f8eac85c41835f27fc3d81a30"
+  integrity sha512-2P4PtieJeEwVgTU9QEcwIRDQ/mXJLX8/+I3ur+Pg16nS8oNbrGxEso9NyYWy8NAmXiNl4dlAp5MwoNeCWzON4w==
   dependencies:
-    node-forge "0.9.0"
+    node-forge "^0.10.0"
 
 "semver@2 || 3 || 4 || 5", semver@^5.4.1, semver@^5.5.0, semver@^5.5.1, semver@^5.6.0:
   version "5.7.1"
@@ -9545,6 +9524,13 @@ serialize-javascript@^4.0.0:
   dependencies:
     randombytes "^2.1.0"
 
+serialize-javascript@^5.0.1:
+  version "5.0.1"
+  resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-5.0.1.tgz#7886ec848049a462467a97d3d918ebb2aaf934f4"
+  integrity sha512-SaaNal9imEO737H2c05Og0/8LUXG7EnsZyMa8MzkmuHoELfT6txuj0cMqRj6zfPKnmQ1yasR4PCJc8x+M4JSPA==
+  dependencies:
+    randombytes "^2.1.0"
+
 serve-index@^1.9.1:
   version "1.9.1"
   resolved "https://registry.yarnpkg.com/serve-index/-/serve-index-1.9.1.tgz#d3768d69b1e7d82e5ce050fff5b453bea12a9239"
@@ -9831,9 +9817,9 @@ spdx-expression-parse@^3.0.0:
     spdx-license-ids "^3.0.0"
 
 spdx-license-ids@^3.0.0:
-  version "3.0.5"
-  resolved "https://registry.yarnpkg.com/spdx-license-ids/-/spdx-license-ids-3.0.5.tgz#3694b5804567a458d3c8045842a6358632f62654"
-  integrity sha512-J+FWzZoynJEXGphVIS+XEh3kFSjZX/1i9gFBaWQcB+/tmpe2qUsSBABpcxqxnAxFdiUFEgAX1bjYGQvIZmoz9Q==
+  version "3.0.6"
+  resolved "https://registry.yarnpkg.com/spdx-license-ids/-/spdx-license-ids-3.0.6.tgz#c80757383c28abf7296744998cbc106ae8b854ce"
+  integrity sha512-+orQK83kyMva3WyPf59k1+Y525csj5JejicWut55zeTWANuN17qSiSLUXWtzHeNWORSvT7GLDJ/E/XiIWoXBTw==
 
 spdy-transport@^3.0.0:
   version "3.0.0"
@@ -10210,9 +10196,9 @@ supports-color@^6.1.0:
     has-flag "^3.0.0"
 
 supports-color@^7.0.0, supports-color@^7.1.0:
-  version "7.1.0"
-  resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-7.1.0.tgz#68e32591df73e25ad1c4b49108a2ec507962bfd1"
-  integrity sha512-oRSIpR8pxT1Wr2FquTNnGet79b3BWljqOuoW/h4oBhxJ/HUbX5nX6JSruTkvXDCFMwDPvsaTTbvMLKZWSy0R5g==
+  version "7.2.0"
+  resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-7.2.0.tgz#1b7dcdcb32b8138801b3e478ba6a51caa89648da"
+  integrity sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==
   dependencies:
     has-flag "^4.0.0"
 
@@ -10320,22 +10306,22 @@ terser-webpack-plugin@^1.4.3:
     webpack-sources "^1.4.0"
     worker-farm "^1.7.0"
 
-terser-webpack-plugin@^3.0.6:
-  version "3.0.6"
-  resolved "https://registry.yarnpkg.com/terser-webpack-plugin/-/terser-webpack-plugin-3.0.6.tgz#db0a108bbdd3680d72c9b491fbabad09ba207b99"
-  integrity sha512-z3HLOOPUHkCNGkeEHqqiMAIy1pjpHwS1o+i6Zn0Ws3EAvHJj46737efNNEvJ0Vx9BdDQM83d56qySDJOSORA0A==
+terser-webpack-plugin@^4.2.2:
+  version "4.2.2"
+  resolved "https://registry.yarnpkg.com/terser-webpack-plugin/-/terser-webpack-plugin-4.2.2.tgz#d86200c700053bba637913fe4310ba1bdeb5568e"
+  integrity sha512-3qAQpykRTD5DReLu5/cwpsg7EZFzP3Q0Hp2XUWJUw2mpq2jfgOKTZr8IZKKnNieRVVo1UauROTdhbQJZveGKtQ==
   dependencies:
-    cacache "^15.0.4"
+    cacache "^15.0.5"
     find-cache-dir "^3.3.1"
-    jest-worker "^26.0.0"
-    p-limit "^3.0.1"
-    schema-utils "^2.6.6"
-    serialize-javascript "^4.0.0"
+    jest-worker "^26.3.0"
+    p-limit "^3.0.2"
+    schema-utils "^2.7.1"
+    serialize-javascript "^5.0.1"
     source-map "^0.6.1"
-    terser "^4.8.0"
+    terser "^5.3.2"
     webpack-sources "^1.4.3"
 
-terser@^4.1.2, terser@^4.8.0:
+terser@^4.1.2:
   version "4.8.0"
   resolved "https://registry.yarnpkg.com/terser/-/terser-4.8.0.tgz#63056343d7c70bb29f3af665865a46fe03a0df17"
   integrity sha512-EAPipTNeWsb/3wLPeup1tVPaXfIaU68xMnVdPafIL1TV05OhASArYyIfFvnvJCNrR2NIOvDVNNTFRa+Re2MWyw==
@@ -10344,6 +10330,15 @@ terser@^4.1.2, terser@^4.8.0:
     source-map "~0.6.1"
     source-map-support "~0.5.12"
 
+terser@^5.3.2:
+  version "5.3.2"
+  resolved "https://registry.yarnpkg.com/terser/-/terser-5.3.2.tgz#f4bea90eb92945b2a028ceef79181b9bb586e7af"
+  integrity sha512-H67sydwBz5jCUA32ZRL319ULu+Su1cAoZnnc+lXnenGRYWyLE3Scgkt8mNoAsMx0h5kdo758zdoS0LG9rYZXDQ==
+  dependencies:
+    commander "^2.20.0"
+    source-map "~0.6.1"
+    source-map-support "~0.5.12"
+
 tesseract.js-core@^2.2.0:
   version "2.2.0"
   resolved "https://registry.yarnpkg.com/tesseract.js-core/-/tesseract.js-core-2.2.0.tgz#6ef78051272a381969fac3e45a226e85022cffef"
@@ -11062,10 +11057,10 @@ webpack-sources@^1.0.0, webpack-sources@^1.1.0, webpack-sources@^1.4.0, webpack-
     source-list-map "^2.0.0"
     source-map "~0.6.1"
 
-webpack@^4.44.1:
-  version "4.44.1"
-  resolved "https://registry.yarnpkg.com/webpack/-/webpack-4.44.1.tgz#17e69fff9f321b8f117d1fda714edfc0b939cc21"
-  integrity sha512-4UOGAohv/VGUNQJstzEywwNxqX417FnjZgZJpJQegddzPmTvph37eBIRbRTfdySXzVtJXLJfbMN3mMYhM6GdmQ==
+webpack@^4.44.2:
+  version "4.44.2"
+  resolved "https://registry.yarnpkg.com/webpack/-/webpack-4.44.2.tgz#6bfe2b0af055c8b2d1e90ed2cd9363f841266b72"
+  integrity sha512-6KJVGlCxYdISyurpQ0IPTklv+DULv05rs2hseIXer6D7KrUicRDLFb4IUM1S6LUAKypPM/nSiVSuv8jHu1m3/Q==
   dependencies:
     "@webassemblyjs/ast" "1.9.0"
     "@webassemblyjs/helper-module-context" "1.9.0"
@@ -11194,6 +11189,15 @@ wrap-ansi@^6.2.0:
     string-width "^4.1.0"
     strip-ansi "^6.0.0"
 
+wrap-ansi@^7.0.0:
+  version "7.0.0"
+  resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
+  integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
+  dependencies:
+    ansi-styles "^4.0.0"
+    string-width "^4.1.0"
+    strip-ansi "^6.0.0"
+
 wrappy@1:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f"
@@ -11255,6 +11259,11 @@ y18n@^4.0.0:
   resolved "https://registry.yarnpkg.com/y18n/-/y18n-4.0.0.tgz#95ef94f85ecc81d007c264e190a120f0a3c8566b"
   integrity sha512-r9S/ZyXu/Xu9q1tYlpsLIsa3EeLXXk0VwlxqTcFRfg9EhMW+17kbt9G0NrgCmhGb5vT2hyhJZLfDGx+7+5Uj/w==
 
+y18n@^5.0.1:
+  version "5.0.1"
+  resolved "https://registry.yarnpkg.com/y18n/-/y18n-5.0.1.tgz#1ad2a7eddfa8bce7caa2e1f6b5da96c39d99d571"
+  integrity sha512-/jJ831jEs4vGDbYPQp4yGKDYPSCCEQ45uZWJHE1AoYBzqdZi8+LDWas0z4HrmJXmKdpFsTiowSHXdxyFhpmdMg==
+
 yallist@^3.0.2:
   version "3.1.1"
   resolved "https://registry.yarnpkg.com/yallist/-/yallist-3.1.1.tgz#dbb7daf9bfd8bac9ab45ebf602b8cbad0d5d08fd"
@@ -11286,6 +11295,11 @@ yargs-parser@^18.1.2:
     camelcase "^5.0.0"
     decamelize "^1.2.0"
 
+yargs-parser@^20.0.0:
+  version "20.0.0"
+  resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-20.0.0.tgz#c65a1daaa977ad63cebdd52159147b789a4e19a9"
+  integrity sha512-8eblPHTL7ZWRkyjIZJjnGf+TijiKJSwA24svzLRVvtgoi/RZiKa9fFQTrlx0OKLnyHSdt/enrdadji6WFfESVA==
+
 yargs@^13.3.2:
   version "13.3.2"
   resolved "https://registry.yarnpkg.com/yargs/-/yargs-13.3.2.tgz#ad7ffefec1aa59565ac915f82dccb38a9c31a2dd"
@@ -11302,7 +11316,7 @@ yargs@^13.3.2:
     y18n "^4.0.0"
     yargs-parser "^13.1.2"
 
-yargs@^15.3.1, yargs@^15.4.1:
+yargs@^15.3.1:
   version "15.4.1"
   resolved "https://registry.yarnpkg.com/yargs/-/yargs-15.4.1.tgz#0d87a16de01aee9d8bec2bfbf74f67851730f4f8"
   integrity sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==
@@ -11319,6 +11333,19 @@ yargs@^15.3.1, yargs@^15.4.1:
     y18n "^4.0.0"
     yargs-parser "^18.1.2"
 
+yargs@^16.0.3:
+  version "16.0.3"
+  resolved "https://registry.yarnpkg.com/yargs/-/yargs-16.0.3.tgz#7a919b9e43c90f80d4a142a89795e85399a7e54c"
+  integrity sha512-6+nLw8xa9uK1BOEOykaiYAJVh6/CjxWXK/q9b5FpRgNslt8s22F2xMBqVIKgCRjNgGvGPBy8Vog7WN7yh4amtA==
+  dependencies:
+    cliui "^7.0.0"
+    escalade "^3.0.2"
+    get-caller-file "^2.0.5"
+    require-directory "^2.1.1"
+    string-width "^4.2.0"
+    y18n "^5.0.1"
+    yargs-parser "^20.0.0"
+
 zlibjs@^0.3.1:
   version "0.3.1"
   resolved "https://registry.yarnpkg.com/zlibjs/-/zlibjs-0.3.1.tgz#50197edb28a1c42ca659cc8b4e6a9ddd6d444554"