about summary refs log tree commit diff
diff options
context:
space:
mode:
authorThibG <thib@sitedethib.com>2020-09-29 10:29:42 +0200
committerGitHub <noreply@github.com>2020-09-29 10:29:42 +0200
commit62e3f588dee75fc6a37457f8218630daad056cba (patch)
tree69439eebc4da7137206ee82e6ec3fa21cf8c7ed0
parent787d5d728923393f12421a480b3c7aee789a11fe (diff)
parentccfc2b6dce4bfda283bb6bc968a23fe4a76951d7 (diff)
Merge pull request #1430 from ThibG/glitch-soc/merge-upstream
Merge upstream changes
-rw-r--r--Gemfile18
-rw-r--r--Gemfile.lock144
-rw-r--r--app/controllers/accounts_controller.rb11
-rw-r--r--app/controllers/activitypub/outboxes_controller.rb3
-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.js30
-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.rb10
-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.rb1
-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.rb16
-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, 2540 insertions, 1034 deletions
diff --git a/Gemfile b/Gemfile
index 4ca62d76b..c79db13be 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 581d494ab..4f432eed0 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,20 +45,20 @@ 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)
-    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)
@@ -79,23 +79,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)
@@ -160,13 +160,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)
@@ -210,7 +209,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)
@@ -233,7 +232,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)
@@ -363,7 +362,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)
@@ -387,7 +386,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)
@@ -398,7 +397,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)
@@ -406,11 +405,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)
@@ -427,7 +426,7 @@ GEM
     pry-rails (0.3.9)
       pry (>= 0.10.4)
     public_suffix (4.0.6)
-    puma (4.3.6)
+    puma (5.0.1)
       nio4r (~> 2.0)
     pundit (2.1.0)
       activesupport (>= 3.0.0)
@@ -441,18 +440,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)
@@ -468,9 +467,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)
@@ -482,7 +481,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)
@@ -501,7 +500,7 @@ GEM
       redis-store (>= 1.2, < 2)
     redis-store (1.9.0)
       redis (>= 4, < 5)
-    regexp_parser (1.7.1)
+    regexp_parser (1.8.0)
     request_store (1.5.0)
       rack (>= 1.4)
     responders (3.0.1)
@@ -536,18 +535,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)
@@ -556,7 +555,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)
@@ -565,7 +563,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)
@@ -594,7 +592,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)
@@ -613,7 +611,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
@@ -654,7 +652,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)
@@ -681,8 +679,8 @@ DEPENDENCIES
   active_record_query_trace (~> 1.7)
   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)
@@ -711,7 +709,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)
@@ -752,7 +750,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)
@@ -762,12 +760,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)
@@ -780,7 +778,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)
@@ -797,12 +795,12 @@ 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)
   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 54106933c..356542767 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
 
@@ -49,7 +50,7 @@ class AccountsController < ApplicationController
 
       format.json do
         expires_in 3.minutes, public: !(authorized_fetch_mode? && signed_request_account.present?)
-        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
@@ -154,12 +155,4 @@ class AccountsController < ApplicationController
   def params_slice(*keys)
     params.slice(*keys).permit(*keys)
   end
-
-  def restrict_fields_to
-    if signed_request_account.present? || public_fetch_mode?
-      # Return all fields
-    else
-      %i(id type preferred_username inbox public_key endpoints)
-    end
-  end
 end
diff --git a/app/controllers/activitypub/outboxes_controller.rb b/app/controllers/activitypub/outboxes_controller.rb
index e066860bf..5fd735ad6 100644
--- a/app/controllers/activitypub/outboxes_controller.rb
+++ b/app/controllers/activitypub/outboxes_controller.rb
@@ -57,9 +57,8 @@ class ActivityPub::OutboxesController < ActivityPub::BaseController
   def set_statuses
     return unless page_requested?
 
-    @statuses = @account.statuses.permitted_for(@account, signed_request_account)
     @statuses = cache_collection_paginated_by_id(
-      @statuses,
+      @account.statuses.permitted_for(@account, signed_request_account),
       Status,
       LIMIT,
       params_slice(:max_id, :min_id, :since_id)
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 467225547..e962c4e97 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 85a9133e3..92ccb8061 100644
--- a/app/controllers/api/v1/accounts/statuses_controller.rb
+++ b/app/controllers/api/v1/accounts/statuses_controller.rb
@@ -18,7 +18,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 0080faf33..aef51a647 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 75c3e2495..87431f8cf 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 19a7ce157..0c15447a6 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 e1012a80b..428b62f68 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 8da5db961..fc7940e5a 100644
--- a/app/javascript/flavours/glitch/components/status.js
+++ b/app/javascript/flavours/glitch/components/status.js
@@ -680,6 +680,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..0ba55d5d8 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,32 @@ 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 '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 6576bff8e..2d4cc7f49 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 8195735a1..a6b57d331 100644
--- a/app/javascript/flavours/glitch/features/account_timeline/components/header.js
+++ b/app/javascript/flavours/glitch/features/account_timeline/components/header.js
@@ -56,6 +56,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);
   }
@@ -107,6 +111,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 5558ba2a3..c56cc9b8e 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 }, withReplies = false }) => {
   const path = withReplies ? `${accountId}:with_replies` : accountId;
 
@@ -28,6 +30,7 @@ const mapStateToProps = (state, { params: { accountId }, withReplies = false })
     featuredStatusIds: withReplies ? ImmutableList() : state.getIn(['timelines', `account:${accountId}:pinned`, 'items'], 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,
     withReplies: PropTypes.bool,
     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 a8fc1ad84..a399fc2b3 100644
--- a/app/javascript/flavours/glitch/features/ui/index.js
+++ b/app/javascript/flavours/glitch/features/ui/index.js
@@ -364,21 +364,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);
@@ -402,6 +387,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 b7babd4ad..66b5a17ac 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'])]),
 });
@@ -329,9 +319,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 94aee7939..224451f41 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 dc9ff580c..09b9e5e0e 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 0b3c48543..3a6b38181 100644
--- a/app/models/account.rb
+++ b/app/models/account.rb
@@ -226,23 +226,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 cca3a17fa..98849f8fc 100644
--- a/app/models/concerns/account_associations.rb
+++ b/app/models/concerns/account_associations.rb
@@ -60,5 +60,8 @@ module AccountAssociations
     # Hashtags
     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
   end
 end
diff --git a/app/models/concerns/account_interactions.rb b/app/models/concerns/account_interactions.rb
index be7211f2c..427ebdae2 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
@@ -95,25 +97,29 @@ module AccountInteractions
     has_many :announcement_mutes, 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 3325e264c..c1f19149b 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)
     MergeWorker.perform_async(target_account.id, account.id) if account.local?
     destroy!
   end
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 29d25eae8..7ea4e2f98 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 8495927af..d1ac2e4f2 100644
--- a/app/models/status.rb
+++ b/app/models/status.rb
@@ -93,19 +93,19 @@ class Status < ApplicationRecord
   scope :without_replies, -> { where('statuses.reply = FALSE OR statuses.in_reply_to_account_id = statuses.account_id') }
   scope :without_reblogs, -> { where('statuses.reblog_of_id IS NULL') }
   scope :with_public_visibility, -> { where(visibility: :public) }
-  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
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 4467362e1..6cd2ca6bd 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -169,7 +169,7 @@ class User < ApplicationRecord
   end
 
   def active_for_authentication?
-    true
+    !account.memorial?
   end
 
   def suspicious_sign_in?(ip)
@@ -177,7 +177,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 fa5c0dd9c..d0359580d 100644
--- a/app/policies/status_policy.rb
+++ b/app/policies/status_policy.rb
@@ -12,6 +12,7 @@ class StatusPolicy < ApplicationPolicy
   end
 
   def show?
+    return false if author.suspended?
     return false if local_only? && (current_account.nil? || !current_account.local?)
 
     if requires_mention?
diff --git a/app/serializers/rest/account_serializer.rb b/app/serializers/rest/account_serializer.rb
index 4e497cdbd..5cc42c7cf 100644
--- a/app/serializers/rest/account_serializer.rb
+++ b/app/serializers/rest/account_serializer.rb
@@ -8,8 +8,11 @@ class REST::AccountSerializer < ActiveModel::Serializer
              :followers_count, :following_count, :statuses_count, :last_status_at
 
   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
 
@@ -29,7 +32,7 @@ class REST::AccountSerializer < ActiveModel::Serializer
   end
 
   def note
-    Formatter.instance.simplified_format(object)
+    object.suspended? ? '' : Formatter.instance.simplified_format(object)
   end
 
   def url
@@ -37,23 +40,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
@@ -63,4 +62,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 dc23ef8d8..1cf3382b3 100644
--- a/app/services/block_domain_service.rb
+++ b/app/services/block_domain_service.rb
@@ -36,7 +36,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 02b26458a..a0ab3b4b7 100644
--- a/app/services/favourite_service.rb
+++ b/app/services/favourite_service.rb
@@ -29,7 +29,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 e4ca10eb1..fc187db40 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 f45422970..feffb872b 100644
--- a/app/services/process_mentions_service.rb
+++ b/app/services/process_mentions_service.rb
@@ -58,7 +58,7 @@ class ProcessMentionsService < BaseService
     mentioned_account = mention.account
 
     if mentioned_account.local?
-      LocalNotificationWorker.perform_async(mentioned_account.id, mention.id, mention.class.name)
+      LocalNotificationWorker.perform_async(mentioned_account.id, mention.id, mention.class.name, :mention)
     elsif mentioned_account.activitypub? && !@status.local_only?
       ActivityPub::DeliveryWorker.perform_async(activitypub_json, mention.status.account_id, mentioned_account.inbox_url)
     end
diff --git a/app/services/reblog_service.rb b/app/services/reblog_service.rb
index 6cecb5ac4..6f018e24b 100644
--- a/app/services/reblog_service.rb
+++ b/app/services/reblog_service.rb
@@ -45,7 +45,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 873df7fbd..7ef7b09a2 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 6113edde1..8571b59e1 100644
--- a/app/workers/scheduler/user_cleanup_scheduler.rb
+++ b/app/workers/scheduler/user_cleanup_scheduler.rb
@@ -6,9 +6,22 @@ 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
     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 c82dfbb6d..6ea267ab0 100644
--- a/config/navigation.rb
+++ b/config/navigation.rb
@@ -27,7 +27,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 48d2718c8..25c9b93ec 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -234,7 +234,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
@@ -474,7 +474,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 b56aa109a..9678b9d79 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
 #
 # It's strongly recommended that you check this file into your version control system.
 
-ActiveRecord::Schema.define(version: 2020_06_30_190544) do
+ActiveRecord::Schema.define(version: 2020_09_17_222734) do
 
   # These are extensions that must be enabled in order to support this database
   enable_extension "plpgsql"
@@ -36,6 +36,13 @@ ActiveRecord::Schema.define(version: 2020_06_30_190544) 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
@@ -404,6 +411,7 @@ ActiveRecord::Schema.define(version: 2020_06_30_190544) 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
 
@@ -414,6 +422,7 @@ ActiveRecord::Schema.define(version: 2020_06_30_190544) 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
@@ -538,8 +547,8 @@ ActiveRecord::Schema.define(version: 2020_06_30_190544) 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
@@ -928,6 +937,7 @@ ActiveRecord::Schema.define(version: 2020_06_30_190544) 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_identity_proofs", "accounts", on_delete: :cascade
   add_foreign_key "account_migrations", "accounts", column: "target_account_id", on_delete: :nullify
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 558737c27..5433ddd9d 100644
--- a/lib/mastodon/domains_cli.rb
+++ b/lib/mastodon/domains_cli.rb
@@ -42,7 +42,7 @@ module Mastodon
       end
 
       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 11e2b5c84..30dfbc471 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",
     "atrament": "0.2.4",
     "arrow-key-navigation": "^1.2.0",
     "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",
@@ -160,12 +160,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",
@@ -188,7 +188,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 3a68e6de5..de2175144 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 6978601ad..04a3020ed 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"
@@ -4571,13 +4568,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"
@@ -4711,19 +4708,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"
@@ -4882,7 +4870,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==
@@ -5083,7 +5071,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==
@@ -5652,9 +5640,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"
@@ -6381,15 +6369,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"
@@ -7191,14 +7170,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"
@@ -7251,10 +7230,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"
@@ -7386,7 +7365,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==
@@ -7399,14 +7378,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"
@@ -7581,10 +7560,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"
 
@@ -9426,7 +9405,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==
@@ -9449,11 +9428,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"
@@ -9511,6 +9490,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"
@@ -9797,9 +9783,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"
@@ -10171,9 +10157,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"
 
@@ -10281,22 +10267,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==
@@ -10305,6 +10291,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"
@@ -11023,10 +11018,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"
@@ -11155,6 +11150,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"
@@ -11216,6 +11220,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"
@@ -11247,6 +11256,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"
@@ -11263,7 +11277,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==
@@ -11280,6 +11294,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"