about summary refs log tree commit diff
diff options
context:
space:
mode:
authorClaire <claire.github-309c@sitedethib.com>2021-04-20 12:17:14 +0200
committerClaire <claire.github-309c@sitedethib.com>2021-04-20 12:17:14 +0200
commite2a2bc90213a653b772b457499cedbfe2e830d74 (patch)
treec97643e3977ce9110fdf081ed3f3a70ae1a4457f
parentdf326b8b5c0659edb2aca77690a892f228b0e099 (diff)
parentb5ac17c4b6bfa85494fd768bbf1af87ca79b622b (diff)
Merge branch 'main' into glitch-soc/merge-upstream
Conflicts:
- `README.md`:
  Upstream updated copyright year, we don't mention it so kept our version.
- `app/controllers/admin/dashboard_controller.rb`:
  Not really a conflict, upstream change (removing the spam checker) too close
  to glitch-soc changes. Ported upstream changes.
- `app/models/form/admin_settings.rb`:
  Same.
- `app/services/remove_status_service.rb`:
  Same.
- `app/views/admin/settings/edit.html.haml`:
  Same.
- `config/settings.yml`:
  Same.
- `config/environments/production.rb`:
  Not a real conflict, upstream added a default HTTP header, but we have
  extra headers in glitch-soc.
  Added the header.
-rw-r--r--.ruby-version2
-rw-r--r--Dockerfile2
-rw-r--r--Gemfile12
-rw-r--r--Gemfile.lock100
-rw-r--r--app/controllers/admin/dashboard_controller.rb1
-rw-r--r--app/controllers/admin/follow_recommendations_controller.rb53
-rw-r--r--app/controllers/api/v1/push/subscriptions_controller.rb28
-rw-r--r--app/controllers/api/v1/suggestions_controller.rb2
-rw-r--r--app/controllers/api/v2/suggestions_controller.rb19
-rw-r--r--app/controllers/api/web/push_subscriptions_controller.rb25
-rw-r--r--app/helpers/application_helper.rb2
-rw-r--r--app/helpers/email_helper.rb18
-rw-r--r--app/javascript/mastodon/actions/importer/normalizer.js1
-rw-r--r--app/javascript/mastodon/actions/onboarding.js13
-rw-r--r--app/javascript/mastodon/actions/suggestions.js24
-rw-r--r--app/javascript/mastodon/components/account.js6
-rw-r--r--app/javascript/mastodon/components/logo.js9
-rw-r--r--app/javascript/mastodon/containers/mastodon.js47
-rw-r--r--app/javascript/mastodon/features/compose/components/search_results.js10
-rw-r--r--app/javascript/mastodon/features/emoji/emoji.js2
-rw-r--r--app/javascript/mastodon/features/follow_recommendations/components/account.js85
-rw-r--r--app/javascript/mastodon/features/follow_recommendations/index.js95
-rw-r--r--app/javascript/mastodon/features/ui/index.js11
-rw-r--r--app/javascript/mastodon/features/ui/util/async-components.js4
-rw-r--r--app/javascript/mastodon/reducers/suggestions.js8
-rw-r--r--app/javascript/styles/mastodon/components.scss66
-rw-r--r--app/lib/account_reach_finder.rb25
-rw-r--r--app/lib/activitypub/activity/create.rb5
-rw-r--r--app/lib/activitypub/activity/flag.rb2
-rw-r--r--app/lib/admin/system_check/sidekiq_process_check.rb1
-rw-r--r--app/lib/application_extension.rb4
-rw-r--r--app/lib/formatter.rb35
-rw-r--r--app/lib/potential_friendship_tracker.rb12
-rw-r--r--app/lib/spam_check.rb198
-rw-r--r--app/lib/status_reach_finder.rb25
-rw-r--r--app/lib/tag_manager.rb8
-rw-r--r--app/models/account.rb17
-rw-r--r--app/models/account_suggestions.rb17
-rw-r--r--app/models/account_summary.rb25
-rw-r--r--app/models/canonical_email_block.rb27
-rw-r--r--app/models/concerns/account_associations.rb3
-rw-r--r--app/models/follow_recommendation.rb39
-rw-r--r--app/models/follow_recommendation_filter.rb26
-rw-r--r--app/models/follow_recommendation_suppression.rb28
-rw-r--r--app/models/form/account_batch.rb18
-rw-r--r--app/models/form/admin_settings.rb2
-rw-r--r--app/models/web/push_subscription.rb112
-rw-r--r--app/policies/follow_recommendation_policy.rb15
-rw-r--r--app/serializers/rest/suggestion_serializer.rb7
-rw-r--r--app/services/process_mentions_service.rb5
-rw-r--r--app/services/remove_status_service.rb39
-rw-r--r--app/services/report_service.rb2
-rw-r--r--app/services/suspend_account_service.rb12
-rw-r--r--app/services/unsuspend_account_service.rb15
-rw-r--r--app/validators/blacklisted_email_validator.rb30
-rw-r--r--app/views/admin/dashboard/index.html.haml2
-rw-r--r--app/views/admin/follow_recommendations/_account.html.haml20
-rw-r--r--app/views/admin/follow_recommendations/show.html.haml41
-rw-r--r--app/views/admin/rules/index.html.haml5
-rw-r--r--app/views/admin/settings/edit.html.haml3
-rw-r--r--app/views/user_mailer/webauthn_enabled.text.erb4
-rw-r--r--app/workers/scheduler/follow_recommendations_scheduler.rb61
-rw-r--r--app/workers/web/push_notification_worker.rb65
-rw-r--r--config/application.rb1
-rw-r--r--config/environments/production.rb9
-rw-r--r--config/initializers/content_security_policy.rb14
-rw-r--r--config/initializers/doorkeeper.rb5
-rw-r--r--config/initializers/paperclip.rb4
-rw-r--r--config/initializers/suppress_csrf_warnings.rb4
-rw-r--r--config/locales/en.yml21
-rw-r--r--config/locales/simple_form.en.yml14
-rw-r--r--config/navigation.rb1
-rw-r--r--config/routes.rb4
-rw-r--r--config/settings.yml1
-rw-r--r--config/sidekiq.yml4
-rw-r--r--db/migrate/20210306164523_account_ids_to_timestamp_ids.rb17
-rw-r--r--db/migrate/20210322164601_create_account_summaries.rb9
-rw-r--r--db/migrate/20210323114347_create_follow_recommendations.rb5
-rw-r--r--db/migrate/20210324171613_create_follow_recommendation_suppressions.rb9
-rw-r--r--db/migrate/20210416200740_create_canonical_email_blocks.rb10
-rw-r--r--db/schema.rb75
-rw-r--r--db/views/account_summaries_v01.sql22
-rw-r--r--db/views/follow_recommendations_v01.sql38
-rw-r--r--lib/active_record/batches.rb44
-rw-r--r--lib/tasks/emojis.rake2
-rw-r--r--package.json34
-rw-r--r--public/emoji/1f6b2_border.svg19
-rw-r--r--spec/controllers/api/v1/apps_controller_spec.rb78
-rw-r--r--spec/controllers/api/v1/push/subscriptions_controller_spec.rb28
-rw-r--r--spec/controllers/api/web/push_subscriptions_controller_spec.rb23
-rw-r--r--spec/fabricators/canonical_email_block_fabricator.rb4
-rw-r--r--spec/fabricators/follow_recommendation_suppression_fabricator.rb3
-rw-r--r--spec/lib/spam_check_spec.rb192
-rw-r--r--spec/lib/tag_manager_spec.rb36
-rw-r--r--spec/models/canonical_email_block_spec.rb47
-rw-r--r--spec/models/follow_recommendation_suppression_spec.rb4
-rw-r--r--spec/models/web/push_subscription_spec.rb94
-rw-r--r--spec/validators/blacklisted_email_validator_spec.rb29
-rw-r--r--spec/workers/web/push_notification_worker_spec.rb48
-rw-r--r--yarn.lock427
100 files changed, 1903 insertions, 1076 deletions
diff --git a/.ruby-version b/.ruby-version
index 37c2961c2..2c9b4ef42 100644
--- a/.ruby-version
+++ b/.ruby-version
@@ -1 +1 @@
-2.7.2
+2.7.3
diff --git a/Dockerfile b/Dockerfile
index 962e5a8c9..ee0fc6e69 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -26,7 +26,7 @@ RUN ARCH= && \
 	mv node-v$NODE_VER-linux-$ARCH /opt/node
 
 # Install Ruby
-ENV RUBY_VER="2.7.2"
+ENV RUBY_VER="2.7.3"
 RUN apt-get update && \
   apt-get install -y --no-install-recommends build-essential \
     bison libyaml-dev libgdbm-dev libreadline-dev libjemalloc-dev \
diff --git a/Gemfile b/Gemfile
index c866625c0..059ec195b 100644
--- a/Gemfile
+++ b/Gemfile
@@ -32,9 +32,9 @@ gem 'browser'
 gem 'charlock_holmes', '~> 0.7.7'
 gem 'iso-639'
 gem 'chewy', '~> 5.2'
-gem 'cld3', '~> 3.4.1'
+gem 'cld3', '~> 3.4.2'
 gem 'devise', '~> 4.7'
-gem 'devise-two-factor', git: 'https://github.com/ClearlyClaire/devise-two-factor', ref: '594bb8a32e6f94df7e5ba7c9399eaf9ff25bac0d'
+gem 'devise-two-factor', '~> 4.0'
 
 group :pam_authentication, optional: true do
   gem 'devise_pam_authenticatable2', '~> 9.2'
@@ -62,9 +62,8 @@ gem 'idn-ruby', require: 'idn'
 gem 'kaminari', '~> 1.2'
 gem 'link_header', '~> 0.0'
 gem 'mime-types', '~> 3.3.1', require: 'mime/types/columnar'
-gem 'nilsimsa', git: 'https://github.com/witgo/nilsimsa', ref: 'fd184883048b922b176939f851338d0a4971a532'
 gem 'nokogiri', '~> 1.11'
-gem 'nsa', git: 'https://github.com/Gargron/nsa', ref: 'd1079e0cdafdfed7f9f35478d13b9bdaa65965c0'
+gem 'nsa', '~> 0.2'
 gem 'oj', '~> 3.11'
 gem 'ox', '~> 2.14'
 gem 'parslet'
@@ -95,7 +94,7 @@ gem 'tty-prompt', '~> 0.23', require: false
 gem 'twitter-text', '~> 3.1.0'
 gem 'tzinfo-data', '~> 1.2021'
 gem 'webpacker', '~> 5.2'
-gem 'webpush'
+gem 'webpush', '~> 0.3'
 gem 'webauthn', '~> 3.0.0.alpha1'
 
 gem 'json-ld'
@@ -126,7 +125,7 @@ group :test do
   gem 'rspec-sidekiq', '~> 3.1'
   gem 'simplecov', '~> 0.21', require: false
   gem 'webmock', '~> 3.12'
-  gem 'parallel_tests', '~> 3.6'
+  gem 'parallel_tests', '~> 3.7'
   gem 'rspec_junit_formatter', '~> 0.4'
 end
 
@@ -160,4 +159,3 @@ gem 'concurrent-ruby', require: false
 gem 'connection_pool', require: false
 
 gem 'xorcist', '~> 1.1'
-gem 'pluck_each', git: 'https://github.com/nsommer/pluck_each', ref: '73be0947c52fc54bf6d7085378db008358aac5eb'
diff --git a/Gemfile.lock b/Gemfile.lock
index 1d6480a7b..a43a2c1fe 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -1,42 +1,3 @@
-GIT
-  remote: https://github.com/ClearlyClaire/devise-two-factor
-  revision: 594bb8a32e6f94df7e5ba7c9399eaf9ff25bac0d
-  ref: 594bb8a32e6f94df7e5ba7c9399eaf9ff25bac0d
-  specs:
-    devise-two-factor (3.1.0)
-      activesupport (< 7.0)
-      attr_encrypted (>= 1.3, < 4, != 2)
-      devise
-      railties (< 7.0)
-      rotp (~> 6)
-
-GIT
-  remote: https://github.com/Gargron/nsa
-  revision: d1079e0cdafdfed7f9f35478d13b9bdaa65965c0
-  ref: d1079e0cdafdfed7f9f35478d13b9bdaa65965c0
-  specs:
-    nsa (0.2.8)
-      activesupport (>= 4.2, < 7)
-      concurrent-ruby (~> 1.0, >= 1.0.2)
-      sidekiq (>= 3.5)
-      statsd-ruby (~> 1.4, >= 1.4.0)
-
-GIT
-  remote: https://github.com/nsommer/pluck_each
-  revision: 73be0947c52fc54bf6d7085378db008358aac5eb
-  ref: 73be0947c52fc54bf6d7085378db008358aac5eb
-  specs:
-    pluck_each (0.1.3)
-      activerecord (>= 6.1.0)
-      activesupport (>= 6.1.0)
-
-GIT
-  remote: https://github.com/witgo/nilsimsa
-  revision: fd184883048b922b176939f851338d0a4971a532
-  ref: fd184883048b922b176939f851338d0a4971a532
-  specs:
-    nilsimsa (1.1.2)
-
 GEM
   remote: https://rubygems.org/
   specs:
@@ -120,8 +81,8 @@ GEM
       cocaine (~> 0.5.3)
     awrence (1.1.1)
     aws-eventstream (1.1.1)
-    aws-partitions (1.436.0)
-    aws-sdk-core (3.113.0)
+    aws-partitions (1.445.0)
+    aws-sdk-core (3.114.0)
       aws-eventstream (~> 1, >= 1.0.2)
       aws-partitions (~> 1, >= 1.239.0)
       aws-sigv4 (~> 1.1)
@@ -129,7 +90,7 @@ GEM
     aws-sdk-kms (1.43.0)
       aws-sdk-core (~> 3, >= 3.112.0)
       aws-sigv4 (~> 1.1)
-    aws-sdk-s3 (1.93.0)
+    aws-sdk-s3 (1.93.1)
       aws-sdk-core (~> 3, >= 3.112.0)
       aws-sdk-kms (~> 1)
       aws-sigv4 (~> 1.1)
@@ -192,15 +153,15 @@ GEM
       elasticsearch (>= 2.0.0)
       elasticsearch-dsl
     chunky_png (1.3.15)
-    cld3 (3.4.1)
-      ffi (>= 1.1.0, < 1.15.0)
+    cld3 (3.4.2)
+      ffi (>= 1.1.0, < 1.16.0)
     climate_control (0.2.0)
     cocaine (0.5.8)
       climate_control (>= 0.0.3, < 1.0)
     coderay (1.1.3)
     color_diff (0.1)
     concurrent-ruby (1.1.8)
-    connection_pool (2.2.3)
+    connection_pool (2.2.5)
     cose (1.0.0)
       cbor (~> 0.5.9)
       openssl-signature_algorithm (~> 0.4.0)
@@ -216,6 +177,12 @@ GEM
       railties (>= 4.1.0)
       responders
       warden (~> 1.2.3)
+    devise-two-factor (4.0.0)
+      activesupport (< 6.2)
+      attr_encrypted (>= 1.3, < 4, != 2)
+      devise (~> 4.0)
+      railties (< 6.2)
+      rotp (~> 6.0)
     devise_pam_authenticatable2 (9.2.0)
       devise (>= 4.0.0)
       rpam2 (~> 4.0)
@@ -225,7 +192,7 @@ GEM
     docile (1.3.4)
     domain_name (0.5.20190701)
       unf (>= 0.0.5, < 1.0.0)
-    doorkeeper (5.5.0)
+    doorkeeper (5.5.1)
       railties (>= 5)
     dotenv (2.7.6)
     dotenv-rails (2.7.6)
@@ -257,7 +224,7 @@ GEM
     faraday-net_http (1.0.1)
     fast_blank (1.0.0)
     fastimage (2.2.3)
-    ffi (1.14.2)
+    ffi (1.15.0)
     ffi-compiler (1.0.1)
       ffi (>= 1.0.0)
       rake
@@ -313,7 +280,7 @@ GEM
     httplog (1.4.3)
       rack (>= 1.0)
       rainbow (>= 2.0.0)
-    i18n (1.8.9)
+    i18n (1.8.10)
       concurrent-ruby (~> 1.0)
     i18n-tasks (0.9.34)
       activesupport (>= 4.0.2)
@@ -369,7 +336,7 @@ GEM
       activesupport (>= 4)
       railties (>= 4)
       request_store (~> 1.0)
-    loofah (2.9.0)
+    loofah (2.9.1)
       crass (~> 1.0.2)
       nokogiri (>= 1.5.9)
     mail (2.7.1)
@@ -401,12 +368,17 @@ GEM
       net-ssh (>= 2.6.5, < 7.0.0)
     net-ssh (6.1.0)
     nio4r (2.5.7)
-    nokogiri (1.11.2)
+    nokogiri (1.11.3)
       mini_portile2 (~> 2.5.0)
       racc (~> 1.4)
     nokogumbo (2.0.4)
       nokogiri (~> 1.8, >= 1.8.4)
-    oj (3.11.3)
+    nsa (0.2.8)
+      activesupport (>= 4.2, < 7)
+      concurrent-ruby (~> 1.0, >= 1.0.2)
+      sidekiq (>= 3.5)
+      statsd-ruby (~> 1.4, >= 1.4.0)
+    oj (3.11.5)
     omniauth (1.9.1)
       hashie (>= 3.4.6)
       rack (>= 1.6.2, < 3)
@@ -434,9 +406,9 @@ GEM
       av (~> 0.9.0)
       paperclip (>= 2.5.2)
     parallel (1.20.1)
-    parallel_tests (3.6.0)
+    parallel_tests (3.7.0)
       parallel
-    parser (3.0.0.0)
+    parser (3.0.1.0)
       ast (~> 2.4.1)
     parslet (2.0.0)
     pastel (0.8.0)
@@ -444,7 +416,7 @@ GEM
     pg (1.2.3)
     pghero (2.8.1)
       activerecord (>= 5)
-    pkg-config (1.4.5)
+    pkg-config (1.4.6)
     posix-spawn (0.3.15)
     premailer (1.14.2)
       addressable
@@ -530,7 +502,7 @@ GEM
     responders (3.0.1)
       actionpack (>= 5.0)
       railties (>= 5.0)
-    rexml (3.2.4)
+    rexml (3.2.5)
     rotp (6.2.0)
     rpam2 (4.0.2)
     rqrcode (1.2.0)
@@ -591,7 +563,7 @@ GEM
       railties (>= 4.0.0)
     securecompare (1.0.0)
     semantic_range (2.3.0)
-    sidekiq (6.2.0)
+    sidekiq (6.2.1)
       connection_pool (>= 2.2.2)
       rack (~> 2.0)
       redis (>= 4.2.0)
@@ -604,7 +576,7 @@ GEM
       sidekiq (>= 3)
       thwait
       tilt (>= 1.4.0)
-    sidekiq-unique-jobs (7.0.7)
+    sidekiq-unique-jobs (7.0.8)
       brpoplpush-redis_script (> 0.1.1, <= 2.0.0)
       concurrent-ruby (~> 1.0, >= 1.0.5)
       sidekiq (>= 5.0, < 7.0)
@@ -651,7 +623,7 @@ GEM
       openssl-signature_algorithm (~> 0.4.0)
     tty-color (0.6.0)
     tty-cursor (0.7.1)
-    tty-prompt (0.23.0)
+    tty-prompt (0.23.1)
       pastel (~> 0.8)
       tty-reader (~> 0.8)
     tty-reader (0.9.0)
@@ -728,13 +700,13 @@ DEPENDENCIES
   capybara (~> 3.35)
   charlock_holmes (~> 0.7.7)
   chewy (~> 5.2)
-  cld3 (~> 3.4.1)
+  cld3 (~> 3.4.2)
   climate_control (~> 0.2)
   color_diff (~> 0.1)
   concurrent-ruby
   connection_pool
   devise (~> 4.7)
-  devise-two-factor!
+  devise-two-factor (~> 4.0)
   devise_pam_authenticatable2 (~> 9.2)
   discard (~> 1.2)
   doorkeeper (~> 5.5)
@@ -769,9 +741,8 @@ DEPENDENCIES
   microformats (~> 4.2)
   mime-types (~> 3.3.1)
   net-ldap (~> 0.17)
-  nilsimsa!
   nokogiri (~> 1.11)
-  nsa!
+  nsa (~> 0.2)
   oj (~> 3.11)
   omniauth (~> 1.9)
   omniauth-cas (~> 2.0)
@@ -781,12 +752,11 @@ DEPENDENCIES
   paperclip (~> 6.0)
   paperclip-av-transcoder (~> 0.6)
   parallel (~> 1.20)
-  parallel_tests (~> 3.6)
+  parallel_tests (~> 3.7)
   parslet
   pg (~> 1.2)
   pghero (~> 2.8)
   pkg-config (~> 1.4)
-  pluck_each!
   posix-spawn
   premailer-rails
   private_address_check (~> 0.5)
@@ -834,5 +804,5 @@ DEPENDENCIES
   webauthn (~> 3.0.0.alpha1)
   webmock (~> 3.12)
   webpacker (~> 5.2)
-  webpush
+  webpush (~> 0.3)
   xorcist (~> 1.1)
diff --git a/app/controllers/admin/dashboard_controller.rb b/app/controllers/admin/dashboard_controller.rb
index 9e921fb95..a00d7ed96 100644
--- a/app/controllers/admin/dashboard_controller.rb
+++ b/app/controllers/admin/dashboard_controller.rb
@@ -36,7 +36,6 @@ module Admin
       @profile_directory     = Setting.profile_directory
       @timeline_preview      = Setting.timeline_preview
       @keybase_integration   = Setting.enable_keybase
-      @spam_check_enabled    = Setting.spam_check_enabled
       @trends_enabled        = Setting.trends
     end
 
diff --git a/app/controllers/admin/follow_recommendations_controller.rb b/app/controllers/admin/follow_recommendations_controller.rb
new file mode 100644
index 000000000..e3eac62b3
--- /dev/null
+++ b/app/controllers/admin/follow_recommendations_controller.rb
@@ -0,0 +1,53 @@
+# frozen_string_literal: true
+
+module Admin
+  class FollowRecommendationsController < BaseController
+    before_action :set_language
+
+    def show
+      authorize :follow_recommendation, :show?
+
+      @form     = Form::AccountBatch.new
+      @accounts = filtered_follow_recommendations
+    end
+
+    def update
+      @form = Form::AccountBatch.new(form_account_batch_params.merge(current_account: current_account, action: action_from_button))
+      @form.save
+    rescue ActionController::ParameterMissing
+      # Do nothing
+    ensure
+      redirect_to admin_follow_recommendations_path(filter_params)
+    end
+
+    private
+
+    def set_language
+      @language = follow_recommendation_filter.language
+    end
+
+    def filtered_follow_recommendations
+      follow_recommendation_filter.results
+    end
+
+    def follow_recommendation_filter
+      @follow_recommendation_filter ||= FollowRecommendationFilter.new(filter_params)
+    end
+
+    def form_account_batch_params
+      params.require(:form_account_batch).permit(:action, account_ids: [])
+    end
+
+    def filter_params
+      params.slice(*FollowRecommendationFilter::KEYS).permit(*FollowRecommendationFilter::KEYS)
+    end
+
+    def action_from_button
+      if params[:suppress]
+        'suppress_follow_recommendation'
+      elsif params[:unsuppress]
+        'unsuppress_follow_recommendation'
+      end
+    end
+  end
+end
diff --git a/app/controllers/api/v1/push/subscriptions_controller.rb b/app/controllers/api/v1/push/subscriptions_controller.rb
index 0918c61e9..47f2e6440 100644
--- a/app/controllers/api/v1/push/subscriptions_controller.rb
+++ b/app/controllers/api/v1/push/subscriptions_controller.rb
@@ -3,13 +3,13 @@
 class Api::V1::Push::SubscriptionsController < Api::BaseController
   before_action -> { doorkeeper_authorize! :push }
   before_action :require_user!
-  before_action :set_web_push_subscription
-  before_action :check_web_push_subscription, only: [:show, :update]
+  before_action :set_push_subscription
+  before_action :check_push_subscription, only: [:show, :update]
 
   def create
-    @web_subscription&.destroy!
+    @push_subscription&.destroy!
 
-    @web_subscription = ::Web::PushSubscription.create!(
+    @push_subscription = Web::PushSubscription.create!(
       endpoint: subscription_params[:endpoint],
       key_p256dh: subscription_params[:keys][:p256dh],
       key_auth: subscription_params[:keys][:auth],
@@ -18,31 +18,31 @@ class Api::V1::Push::SubscriptionsController < Api::BaseController
       access_token_id: doorkeeper_token.id
     )
 
-    render json: @web_subscription, serializer: REST::WebPushSubscriptionSerializer
+    render json: @push_subscription, serializer: REST::WebPushSubscriptionSerializer
   end
 
   def show
-    render json: @web_subscription, serializer: REST::WebPushSubscriptionSerializer
+    render json: @push_subscription, serializer: REST::WebPushSubscriptionSerializer
   end
 
   def update
-    @web_subscription.update!(data: data_params)
-    render json: @web_subscription, serializer: REST::WebPushSubscriptionSerializer
+    @push_subscription.update!(data: data_params)
+    render json: @push_subscription, serializer: REST::WebPushSubscriptionSerializer
   end
 
   def destroy
-    @web_subscription&.destroy!
+    @push_subscription&.destroy!
     render_empty
   end
 
   private
 
-  def set_web_push_subscription
-    @web_subscription = ::Web::PushSubscription.find_by(access_token_id: doorkeeper_token.id)
+  def set_push_subscription
+    @push_subscription = Web::PushSubscription.find_by(access_token_id: doorkeeper_token.id)
   end
 
-  def check_web_push_subscription
-    not_found if @web_subscription.nil?
+  def check_push_subscription
+    not_found if @push_subscription.nil?
   end
 
   def subscription_params
@@ -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, :status])
+    params.require(:data).permit(:policy, alerts: [:follow, :follow_request, :favourite, :reblog, :mention, :poll, :status])
   end
 end
diff --git a/app/controllers/api/v1/suggestions_controller.rb b/app/controllers/api/v1/suggestions_controller.rb
index 52054160d..b2788cc76 100644
--- a/app/controllers/api/v1/suggestions_controller.rb
+++ b/app/controllers/api/v1/suggestions_controller.rb
@@ -19,6 +19,6 @@ class Api::V1::SuggestionsController < Api::BaseController
   private
 
   def set_accounts
-    @accounts = PotentialFriendshipTracker.get(current_account.id, limit: limit_param(DEFAULT_ACCOUNTS_LIMIT))
+    @accounts = PotentialFriendshipTracker.get(current_account, limit_param(DEFAULT_ACCOUNTS_LIMIT))
   end
 end
diff --git a/app/controllers/api/v2/suggestions_controller.rb b/app/controllers/api/v2/suggestions_controller.rb
new file mode 100644
index 000000000..35eb276c0
--- /dev/null
+++ b/app/controllers/api/v2/suggestions_controller.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+class Api::V2::SuggestionsController < Api::BaseController
+  include Authorization
+
+  before_action -> { doorkeeper_authorize! :read }
+  before_action :require_user!
+  before_action :set_suggestions
+
+  def index
+    render json: @suggestions, each_serializer: REST::SuggestionSerializer
+  end
+
+  private
+
+  def set_suggestions
+    @suggestions = AccountSuggestions.get(current_account, limit_param(DEFAULT_ACCOUNTS_LIMIT))
+  end
+end
diff --git a/app/controllers/api/web/push_subscriptions_controller.rb b/app/controllers/api/web/push_subscriptions_controller.rb
index 1dce3e70f..bed57fc54 100644
--- a/app/controllers/api/web/push_subscriptions_controller.rb
+++ b/app/controllers/api/web/push_subscriptions_controller.rb
@@ -2,6 +2,7 @@
 
 class Api::Web::PushSubscriptionsController < Api::Web::BaseController
   before_action :require_user!
+  before_action :set_push_subscription, only: :update
 
   def create
     active_session = current_session
@@ -15,9 +16,11 @@ class Api::Web::PushSubscriptionsController < Api::Web::BaseController
     alerts_enabled = active_session.detection.device.mobile? || active_session.detection.device.tablet?
 
     data = {
+      policy: 'all',
+
       alerts: {
         follow: alerts_enabled,
-        follow_request: false,
+        follow_request: alerts_enabled,
         favourite: alerts_enabled,
         reblog: alerts_enabled,
         mention: alerts_enabled,
@@ -28,7 +31,7 @@ class Api::Web::PushSubscriptionsController < Api::Web::BaseController
 
     data.deep_merge!(data_params) if params[:data]
 
-    web_subscription = ::Web::PushSubscription.create!(
+    push_subscription = ::Web::PushSubscription.create!(
       endpoint: subscription_params[:endpoint],
       key_p256dh: subscription_params[:keys][:p256dh],
       key_auth: subscription_params[:keys][:auth],
@@ -37,27 +40,27 @@ class Api::Web::PushSubscriptionsController < Api::Web::BaseController
       access_token_id: active_session.access_token_id
     )
 
-    active_session.update!(web_push_subscription: web_subscription)
+    active_session.update!(web_push_subscription: push_subscription)
 
-    render json: web_subscription, serializer: REST::WebPushSubscriptionSerializer
+    render json: push_subscription, serializer: REST::WebPushSubscriptionSerializer
   end
 
   def update
-    params.require([:id])
-
-    web_subscription = ::Web::PushSubscription.find(params[:id])
-    web_subscription.update!(data: data_params)
-
-    render json: web_subscription, serializer: REST::WebPushSubscriptionSerializer
+    @push_subscription.update!(data: data_params)
+    render json: @push_subscription, serializer: REST::WebPushSubscriptionSerializer
   end
 
   private
 
+  def set_push_subscription
+    @push_subscription = ::Web::PushSubscription.find(params[:id])
+  end
+
   def subscription_params
     @subscription_params ||= params.require(:subscription).permit(:endpoint, keys: [:auth, :p256dh])
   end
 
   def data_params
-    @data_params ||= params.require(:data).permit(alerts: [:follow, :follow_request, :favourite, :reblog, :mention, :poll, :status])
+    @data_params ||= params.require(:data).permit(:policy, alerts: [:follow, :follow_request, :favourite, :reblog, :mention, :poll, :status])
   end
 end
diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb
index 5a9496bd4..9be3419b0 100644
--- a/app/helpers/application_helper.rb
+++ b/app/helpers/application_helper.rb
@@ -91,8 +91,6 @@ module ApplicationHelper
       fa_icon('unlock', title: I18n.t('statuses.visibilities.unlisted'))
     elsif status.private_visibility? || status.limited_visibility?
       fa_icon('lock', title: I18n.t('statuses.visibilities.private'))
-    elsif status.direct_visibility?
-      fa_icon('envelope', title: I18n.t('statuses.visibilities.direct'))
     end
   end
 
diff --git a/app/helpers/email_helper.rb b/app/helpers/email_helper.rb
new file mode 100644
index 000000000..360783c62
--- /dev/null
+++ b/app/helpers/email_helper.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+module EmailHelper
+  def self.included(base)
+    base.extend(self)
+  end
+
+  def email_to_canonical_email(str)
+    username, domain = str.downcase.split('@', 2)
+    username, = username.gsub('.', '').split('+', 2)
+
+    "#{username}@#{domain}"
+  end
+
+  def email_to_canonical_email_hash(str)
+    Digest::SHA2.new(256).hexdigest(email_to_canonical_email(str))
+  end
+end
diff --git a/app/javascript/mastodon/actions/importer/normalizer.js b/app/javascript/mastodon/actions/importer/normalizer.js
index dca44917a..087f26491 100644
--- a/app/javascript/mastodon/actions/importer/normalizer.js
+++ b/app/javascript/mastodon/actions/importer/normalizer.js
@@ -24,6 +24,7 @@ export function normalizeAccount(account) {
 
   account.display_name_html = emojify(escapeTextContentForBrowser(displayName), emojiMap);
   account.note_emojified = emojify(account.note, emojiMap);
+  account.note_plain = unescapeHTML(account.note);
 
   if (account.fields) {
     account.fields = account.fields.map(pair => ({
diff --git a/app/javascript/mastodon/actions/onboarding.js b/app/javascript/mastodon/actions/onboarding.js
index 42d8ea33f..a1dd3a731 100644
--- a/app/javascript/mastodon/actions/onboarding.js
+++ b/app/javascript/mastodon/actions/onboarding.js
@@ -1,21 +1,8 @@
 import { changeSetting, saveSettings } from './settings';
-import { requestBrowserPermission } from './notifications';
 
 export const INTRODUCTION_VERSION = 20181216044202;
 
 export const closeOnboarding = () => dispatch => {
   dispatch(changeSetting(['introductionVersion'], INTRODUCTION_VERSION));
   dispatch(saveSettings());
-
-  dispatch(requestBrowserPermission((permission) => {
-    if (permission === 'granted') {
-      dispatch(changeSetting(['notifications', 'alerts', 'follow'], true));
-      dispatch(changeSetting(['notifications', 'alerts', 'favourite'], true));
-      dispatch(changeSetting(['notifications', 'alerts', 'reblog'], true));
-      dispatch(changeSetting(['notifications', 'alerts', 'mention'], true));
-      dispatch(changeSetting(['notifications', 'alerts', 'poll'], true));
-      dispatch(changeSetting(['notifications', 'alerts', 'status'], true));
-      dispatch(saveSettings());
-    }
-  }));
 };
diff --git a/app/javascript/mastodon/actions/suggestions.js b/app/javascript/mastodon/actions/suggestions.js
index b15bd916b..e3a549759 100644
--- a/app/javascript/mastodon/actions/suggestions.js
+++ b/app/javascript/mastodon/actions/suggestions.js
@@ -1,5 +1,6 @@
 import api from '../api';
 import { importFetchedAccounts } from './importer';
+import { fetchRelationships } from './accounts';
 
 export const SUGGESTIONS_FETCH_REQUEST = 'SUGGESTIONS_FETCH_REQUEST';
 export const SUGGESTIONS_FETCH_SUCCESS = 'SUGGESTIONS_FETCH_SUCCESS';
@@ -7,13 +8,17 @@ export const SUGGESTIONS_FETCH_FAIL    = 'SUGGESTIONS_FETCH_FAIL';
 
 export const SUGGESTIONS_DISMISS = 'SUGGESTIONS_DISMISS';
 
-export function fetchSuggestions() {
+export function fetchSuggestions(withRelationships = false) {
   return (dispatch, getState) => {
     dispatch(fetchSuggestionsRequest());
 
-    api(getState).get('/api/v1/suggestions').then(response => {
-      dispatch(importFetchedAccounts(response.data));
+    api(getState).get('/api/v2/suggestions').then(response => {
+      dispatch(importFetchedAccounts(response.data.map(x => x.account)));
       dispatch(fetchSuggestionsSuccess(response.data));
+
+      if (withRelationships) {
+        dispatch(fetchRelationships(response.data.map(item => item.account.id)));
+      }
     }).catch(error => dispatch(fetchSuggestionsFail(error)));
   };
 };
@@ -25,10 +30,10 @@ export function fetchSuggestionsRequest() {
   };
 };
 
-export function fetchSuggestionsSuccess(accounts) {
+export function fetchSuggestionsSuccess(suggestions) {
   return {
     type: SUGGESTIONS_FETCH_SUCCESS,
-    accounts,
+    suggestions,
     skipLoading: true,
   };
 };
@@ -48,5 +53,12 @@ export const dismissSuggestion = accountId => (dispatch, getState) => {
     id: accountId,
   });
 
-  api(getState).delete(`/api/v1/suggestions/${accountId}`);
+  api(getState).delete(`/api/v1/suggestions/${accountId}`).then(() => {
+    dispatch(fetchSuggestionsRequest());
+
+    api(getState).get('/api/v2/suggestions').then(response => {
+      dispatch(importFetchedAccounts(response.data.map(x => x.account)));
+      dispatch(fetchSuggestionsSuccess(response.data));
+    }).catch(error => dispatch(fetchSuggestionsFail(error)));
+  }).catch(() => {});
 };
diff --git a/app/javascript/mastodon/components/account.js b/app/javascript/mastodon/components/account.js
index 0e40ee1d6..a85d683a7 100644
--- a/app/javascript/mastodon/components/account.js
+++ b/app/javascript/mastodon/components/account.js
@@ -78,8 +78,10 @@ class Account extends ImmutablePureComponent {
 
     let buttons;
 
-    if (onActionClick && actionIcon) {
-      buttons = <IconButton icon={actionIcon} title={actionTitle} onClick={this.handleAction} />;
+    if (actionIcon) {
+      if (onActionClick) {
+        buttons = <IconButton icon={actionIcon} title={actionTitle} onClick={this.handleAction} />;
+      }
     } else if (account.get('id') !== me && account.get('relationship', null) !== null) {
       const following = account.getIn(['relationship', 'following']);
       const requested = account.getIn(['relationship', 'requested']);
diff --git a/app/javascript/mastodon/components/logo.js b/app/javascript/mastodon/components/logo.js
new file mode 100644
index 000000000..d1c7f08a9
--- /dev/null
+++ b/app/javascript/mastodon/components/logo.js
@@ -0,0 +1,9 @@
+import React from 'react';
+
+const Logo = () => (
+  <svg viewBox='0 0 216.4144 232.00976' className='logo'>
+    <use xlinkHref='#mastodon-svg-logo' />
+  </svg>
+);
+
+export default Logo;
diff --git a/app/javascript/mastodon/containers/mastodon.js b/app/javascript/mastodon/containers/mastodon.js
index 3ac58cf7c..513b59908 100644
--- a/app/javascript/mastodon/containers/mastodon.js
+++ b/app/javascript/mastodon/containers/mastodon.js
@@ -1,12 +1,10 @@
 import React from 'react';
-import { Provider, connect } from 'react-redux';
+import { Provider } from 'react-redux';
 import PropTypes from 'prop-types';
 import configureStore from '../store/configureStore';
-import { INTRODUCTION_VERSION } from '../actions/onboarding';
 import { BrowserRouter, Route } from 'react-router-dom';
 import { ScrollContext } from 'react-router-scroll-4';
 import UI from '../features/ui';
-import Introduction from '../features/introduction';
 import { fetchCustomEmojis } from '../actions/custom_emojis';
 import { hydrateStore } from '../actions/store';
 import { connectUserStream } from '../actions/streaming';
@@ -26,39 +24,6 @@ const hydrateAction = hydrateStore(initialState);
 store.dispatch(hydrateAction);
 store.dispatch(fetchCustomEmojis());
 
-const mapStateToProps = state => ({
-  showIntroduction: state.getIn(['settings', 'introductionVersion'], 0) < INTRODUCTION_VERSION,
-});
-
-@connect(mapStateToProps)
-class MastodonMount extends React.PureComponent {
-
-  static propTypes = {
-    showIntroduction: PropTypes.bool,
-  };
-
-  shouldUpdateScroll (_, { location }) {
-    return location.state !== previewMediaState && location.state !== previewVideoState;
-  }
-
-  render () {
-    const { showIntroduction } = this.props;
-
-    if (showIntroduction) {
-      return <Introduction />;
-    }
-
-    return (
-      <BrowserRouter basename='/web'>
-        <ScrollContext shouldUpdateScroll={this.shouldUpdateScroll}>
-          <Route path='/' component={UI} />
-        </ScrollContext>
-      </BrowserRouter>
-    );
-  }
-
-}
-
 export default class Mastodon extends React.PureComponent {
 
   static propTypes = {
@@ -76,6 +41,10 @@ export default class Mastodon extends React.PureComponent {
     }
   }
 
+  shouldUpdateScroll (_, { location }) {
+    return location.state !== previewMediaState && location.state !== previewVideoState;
+  }
+
   render () {
     const { locale } = this.props;
 
@@ -83,7 +52,11 @@ export default class Mastodon extends React.PureComponent {
       <IntlProvider locale={locale} messages={messages}>
         <Provider store={store}>
           <ErrorBoundary>
-            <MastodonMount />
+            <BrowserRouter basename='/web'>
+              <ScrollContext shouldUpdateScroll={this.shouldUpdateScroll}>
+                <Route path='/' component={UI} />
+              </ScrollContext>
+            </BrowserRouter>
           </ErrorBoundary>
         </Provider>
       </IntlProvider>
diff --git a/app/javascript/mastodon/features/compose/components/search_results.js b/app/javascript/mastodon/features/compose/components/search_results.js
index 4b4cdff74..a8b31b677 100644
--- a/app/javascript/mastodon/features/compose/components/search_results.js
+++ b/app/javascript/mastodon/features/compose/components/search_results.js
@@ -51,12 +51,12 @@ class SearchResults extends ImmutablePureComponent {
               <FormattedMessage id='suggestions.header' defaultMessage='You might be interested in…' />
             </div>
 
-            {suggestions && suggestions.map(accountId => (
+            {suggestions && suggestions.map(suggestion => (
               <AccountContainer
-                key={accountId}
-                id={accountId}
-                actionIcon='times'
-                actionTitle={intl.formatMessage(messages.dismissSuggestion)}
+                key={suggestion.get('account')}
+                id={suggestion.get('account')}
+                actionIcon={suggestion.get('source') === 'past_interaction' ? 'times' : null}
+                actionTitle={suggestion.get('source') === 'past_interaction' ? intl.formatMessage(messages.dismissSuggestion) : null}
                 onActionClick={dismissSuggestion}
               />
             ))}
diff --git a/app/javascript/mastodon/features/emoji/emoji.js b/app/javascript/mastodon/features/emoji/emoji.js
index 3de79ac9b..fb1a3804c 100644
--- a/app/javascript/mastodon/features/emoji/emoji.js
+++ b/app/javascript/mastodon/features/emoji/emoji.js
@@ -11,7 +11,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/follow_recommendations/components/account.js b/app/javascript/mastodon/features/follow_recommendations/components/account.js
new file mode 100644
index 000000000..bd855aab0
--- /dev/null
+++ b/app/javascript/mastodon/features/follow_recommendations/components/account.js
@@ -0,0 +1,85 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import { connect } from 'react-redux';
+import { makeGetAccount } from 'mastodon/selectors';
+import Avatar from 'mastodon/components/avatar';
+import DisplayName from 'mastodon/components/display_name';
+import Permalink from 'mastodon/components/permalink';
+import IconButton from 'mastodon/components/icon_button';
+import { injectIntl, defineMessages } from 'react-intl';
+import { followAccount, unfollowAccount } from 'mastodon/actions/accounts';
+
+const messages = defineMessages({
+  follow: { id: 'account.follow', defaultMessage: 'Follow' },
+  unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
+});
+
+const makeMapStateToProps = () => {
+  const getAccount = makeGetAccount();
+
+  const mapStateToProps = (state, props) => ({
+    account: getAccount(state, props.id),
+  });
+
+  return mapStateToProps;
+};
+
+const getFirstSentence = str => {
+  const arr = str.split(/(([\.\?!]+\s)|[.。?!\n•])/);
+
+  return arr[0];
+};
+
+export default @connect(makeMapStateToProps)
+@injectIntl
+class Account extends ImmutablePureComponent {
+
+  static propTypes = {
+    account: ImmutablePropTypes.map.isRequired,
+    intl: PropTypes.object.isRequired,
+    dispatch: PropTypes.func.isRequired,
+  };
+
+  handleFollow = () => {
+    const { account, dispatch } = this.props;
+
+    if (account.getIn(['relationship', 'following']) || account.getIn(['relationship', 'requested'])) {
+      dispatch(unfollowAccount(account.get('id')));
+    } else {
+      dispatch(followAccount(account.get('id')));
+    }
+  }
+
+  render () {
+    const { account, intl } = this.props;
+
+    let button;
+
+    if (account.getIn(['relationship', 'following'])) {
+      button = <IconButton icon='check' title={intl.formatMessage(messages.unfollow)} active onClick={this.handleFollow} />;
+    } else {
+      button = <IconButton icon='plus' title={intl.formatMessage(messages.follow)} onClick={this.handleFollow} />;
+    }
+
+    return (
+      <div className='account follow-recommendations-account'>
+        <div className='account__wrapper'>
+          <Permalink className='account__display-name account__display-name--with-note' title={account.get('acct')} href={account.get('url')} to={`/accounts/${account.get('id')}`}>
+            <div className='account__avatar-wrapper'><Avatar account={account} size={36} /></div>
+
+            <DisplayName account={account} />
+
+            <div className='account__note'>{getFirstSentence(account.get('note_plain'))}</div>
+          </Permalink>
+
+          <div className='account__relationship'>
+            {button}
+          </div>
+        </div>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/mastodon/features/follow_recommendations/index.js b/app/javascript/mastodon/features/follow_recommendations/index.js
new file mode 100644
index 000000000..1231a27ea
--- /dev/null
+++ b/app/javascript/mastodon/features/follow_recommendations/index.js
@@ -0,0 +1,95 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { connect } from 'react-redux';
+import { FormattedMessage } from 'react-intl';
+import { fetchSuggestions } from 'mastodon/actions/suggestions';
+import { changeSetting, saveSettings } from 'mastodon/actions/settings';
+import { requestBrowserPermission } from 'mastodon/actions/notifications';
+import Column from 'mastodon/features/ui/components/column';
+import Account from './components/account';
+import Logo from 'mastodon/components/logo';
+import imageGreeting from 'mastodon/../images/elephant_ui_greeting.svg';
+import Button from 'mastodon/components/button';
+
+const mapStateToProps = state => ({
+  suggestions: state.getIn(['suggestions', 'items']),
+  isLoading: state.getIn(['suggestions', 'isLoading']),
+});
+
+export default @connect(mapStateToProps)
+class FollowRecommendations extends ImmutablePureComponent {
+
+  static contextTypes = {
+    router: PropTypes.object.isRequired,
+  };
+
+  static propTypes = {
+    dispatch: PropTypes.func.isRequired,
+    suggestions: ImmutablePropTypes.list,
+    isLoading: PropTypes.bool,
+  };
+
+  componentDidMount () {
+    const { dispatch, suggestions } = this.props;
+
+    // Don't re-fetch if we're e.g. navigating backwards to this page,
+    // since we don't want followed accounts to disappear from the list
+
+    if (suggestions.size === 0) {
+      dispatch(fetchSuggestions(true));
+    }
+  }
+
+  handleDone = () => {
+    const { dispatch } = this.props;
+    const { router } = this.context;
+
+    dispatch(requestBrowserPermission((permission) => {
+      if (permission === 'granted') {
+        dispatch(changeSetting(['notifications', 'alerts', 'follow'], true));
+        dispatch(changeSetting(['notifications', 'alerts', 'favourite'], true));
+        dispatch(changeSetting(['notifications', 'alerts', 'reblog'], true));
+        dispatch(changeSetting(['notifications', 'alerts', 'mention'], true));
+        dispatch(changeSetting(['notifications', 'alerts', 'poll'], true));
+        dispatch(changeSetting(['notifications', 'alerts', 'status'], true));
+        dispatch(saveSettings());
+      }
+    }));
+
+    router.history.push('/timelines/home');
+  }
+
+  render () {
+    const { suggestions, isLoading } = this.props;
+
+    return (
+      <Column>
+        <div className='scrollable'>
+          <div className='column-title'>
+            <Logo />
+            <h3><FormattedMessage id='follow_recommendations.heading' defaultMessage="Follow people you'd like to see posts from! Here are some suggestions." /></h3>
+            <p><FormattedMessage id='follow_recommendations.lead' defaultMessage="Posts from people you follow will show up in chronological order on your home feed. Don't be afraid to make mistakes, you can unfollow people just as easily any time!" /></p>
+          </div>
+
+          {!isLoading && (
+            <React.Fragment>
+              <div>
+                {suggestions.map(suggestion => (
+                  <Account key={suggestion.get('account')} id={suggestion.get('account')} />
+                ))}
+              </div>
+
+              <div className='column-actions'>
+                <img src={imageGreeting} alt='' className='column-actions__background' />
+                <Button onClick={this.handleDone}><FormattedMessage id='follow_recommendations.done' defaultMessage='Done' /></Button>
+              </div>
+            </React.Fragment>
+          )}
+        </div>
+      </Column>
+    );
+  }
+
+}
diff --git a/app/javascript/mastodon/features/ui/index.js b/app/javascript/mastodon/features/ui/index.js
index 507ac1df1..078a69f0f 100644
--- a/app/javascript/mastodon/features/ui/index.js
+++ b/app/javascript/mastodon/features/ui/index.js
@@ -51,10 +51,12 @@ import {
   Lists,
   Search,
   Directory,
+  FollowRecommendations,
 } from './util/async-components';
 import { me } from '../../initial_state';
 import { previewState as previewMediaState } from './components/media_modal';
 import { previewState as previewVideoState } from './components/video_modal';
+import { closeOnboarding, INTRODUCTION_VERSION } from 'mastodon/actions/onboarding';
 
 // Dummy import, to make sure that <Status /> ends up in the application bundle.
 // Without this it ends up in ~8 very commonly used bundles.
@@ -71,6 +73,7 @@ const mapStateToProps = state => ({
   hasMediaAttachments: state.getIn(['compose', 'media_attachments']).size > 0,
   canUploadMore: !state.getIn(['compose', 'media_attachments']).some(x => ['audio', 'video'].includes(x.get('type'))) && state.getIn(['compose', 'media_attachments']).size < 4,
   dropdownMenuIsOpen: state.getIn(['dropdown_menu', 'openId']) !== null,
+  firstLaunch: state.getIn(['settings', 'introductionVersion'], 0) < INTRODUCTION_VERSION,
 });
 
 const keyMap = {
@@ -167,6 +170,7 @@ class SwitchingColumnsArea extends React.PureComponent {
           <WrappedRoute path='/bookmarks' component={BookmarkedStatuses} content={children} />
           <WrappedRoute path='/pinned' component={PinnedStatuses} content={children} componentParams={{ shouldUpdateScroll: this.shouldUpdateScroll }} />
 
+          <WrappedRoute path='/start' component={FollowRecommendations} content={children} />
           <WrappedRoute path='/search' component={Search} content={children} />
           <WrappedRoute path='/directory' component={Directory} content={children} componentParams={{ shouldUpdateScroll: this.shouldUpdateScroll }} />
 
@@ -215,6 +219,7 @@ class UI extends React.PureComponent {
     intl: PropTypes.object.isRequired,
     dropdownMenuIsOpen: PropTypes.bool,
     layout: PropTypes.string.isRequired,
+    firstLaunch: PropTypes.bool,
   };
 
   state = {
@@ -350,6 +355,12 @@ class UI extends React.PureComponent {
       navigator.serviceWorker.addEventListener('message', this.handleServiceWorkerPostMessage);
     }
 
+    // On first launch, redirect to the follow recommendations page
+    if (this.props.firstLaunch) {
+      this.context.router.history.replace('/start');
+      this.props.dispatch(closeOnboarding());
+    }
+
     this.props.dispatch(fetchMarkers());
     this.props.dispatch(expandHomeTimeline());
     this.props.dispatch(expandNotifications());
diff --git a/app/javascript/mastodon/features/ui/util/async-components.js b/app/javascript/mastodon/features/ui/util/async-components.js
index 986efda1e..aa90b226a 100644
--- a/app/javascript/mastodon/features/ui/util/async-components.js
+++ b/app/javascript/mastodon/features/ui/util/async-components.js
@@ -153,3 +153,7 @@ export function Audio () {
 export function Directory () {
   return import(/* webpackChunkName: "features/directory" */'../../directory');
 }
+
+export function FollowRecommendations () {
+  return import(/* webpackChunkName: "features/follow_recommendations" */'../../follow_recommendations');
+}
diff --git a/app/javascript/mastodon/reducers/suggestions.js b/app/javascript/mastodon/reducers/suggestions.js
index 834be728f..1a6e66ee7 100644
--- a/app/javascript/mastodon/reducers/suggestions.js
+++ b/app/javascript/mastodon/reducers/suggestions.js
@@ -19,18 +19,18 @@ export default function suggestionsReducer(state = initialState, action) {
     return state.set('isLoading', true);
   case SUGGESTIONS_FETCH_SUCCESS:
     return state.withMutations(map => {
-      map.set('items', fromJS(action.accounts.map(x => x.id)));
+      map.set('items', fromJS(action.suggestions.map(x => ({ ...x, account: x.account.id }))));
       map.set('isLoading', false);
     });
   case SUGGESTIONS_FETCH_FAIL:
     return state.set('isLoading', false);
   case SUGGESTIONS_DISMISS:
-    return state.update('items', list => list.filterNot(id => id === action.id));
+    return state.update('items', list => list.filterNot(x => x.account === action.id));
   case ACCOUNT_BLOCK_SUCCESS:
   case ACCOUNT_MUTE_SUCCESS:
-    return state.update('items', list => list.filterNot(id => id === action.relationship.id));
+    return state.update('items', list => list.filterNot(x => x.account === action.relationship.id));
   case DOMAIN_BLOCK_SUCCESS:
-    return state.update('items', list => list.filterNot(id => action.accounts.includes(id)));
+    return state.update('items', list => list.filterNot(x => action.accounts.includes(x.account)));
   default:
     return state;
   }
diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss
index 2059aa8f3..a359af2ca 100644
--- a/app/javascript/styles/mastodon/components.scss
+++ b/app/javascript/styles/mastodon/components.scss
@@ -1307,6 +1307,29 @@
     overflow: hidden;
     text-decoration: none;
     font-size: 14px;
+
+    &--with-note {
+      strong {
+        display: inline;
+      }
+    }
+  }
+
+  &__note {
+    white-space: nowrap;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    color: $ui-secondary-color;
+  }
+}
+
+.follow-recommendations-account {
+  .icon-button {
+    color: $ui-primary-color;
+
+    &.active {
+      color: $valid-value-color;
+    }
   }
 }
 
@@ -2459,6 +2482,49 @@ a.account__display-name {
   border-color: darken($ui-base-color, 8%);
 }
 
+.column-title {
+  text-align: center;
+  padding: 40px;
+
+  .logo {
+    fill: $primary-text-color;
+    width: 50px;
+    margin: 0 auto;
+    margin-bottom: 40px;
+  }
+
+  h3 {
+    font-size: 24px;
+    line-height: 1.5;
+    font-weight: 700;
+    margin-bottom: 10px;
+  }
+
+  p {
+    font-size: 16px;
+    line-height: 24px;
+    font-weight: 400;
+    color: $darker-text-color;
+  }
+}
+
+.column-actions {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  padding: 40px;
+  padding-top: 40px;
+  padding-bottom: 200px;
+
+  &__background {
+    position: absolute;
+    left: 0;
+    bottom: 0;
+    height: 220px;
+    width: auto;
+  }
+}
+
 .compose-panel {
   width: 285px;
   margin-top: 10px;
diff --git a/app/lib/account_reach_finder.rb b/app/lib/account_reach_finder.rb
new file mode 100644
index 000000000..706ce8c1f
--- /dev/null
+++ b/app/lib/account_reach_finder.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+class AccountReachFinder
+  def initialize(account)
+    @account = account
+  end
+
+  def inboxes
+    (followers_inboxes + reporters_inboxes + relay_inboxes).uniq
+  end
+
+  private
+
+  def followers_inboxes
+    @account.followers.inboxes
+  end
+
+  def reporters_inboxes
+    Account.where(id: @account.targeted_reports.select(:account_id)).inboxes
+  end
+
+  def relay_inboxes
+    Relay.enabled.pluck(:inbox_url)
+  end
+end
diff --git a/app/lib/activitypub/activity/create.rb b/app/lib/activitypub/activity/create.rb
index f10fc5f43..3a73f29ae 100644
--- a/app/lib/activitypub/activity/create.rb
+++ b/app/lib/activitypub/activity/create.rb
@@ -88,7 +88,6 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
 
     resolve_thread(@status)
     fetch_replies(@status)
-    check_for_spam
     distribute(@status)
     forward_for_reply
   end
@@ -498,10 +497,6 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
     Tombstone.exists?(uri: object_uri)
   end
 
-  def check_for_spam
-    SpamCheck.perform(@status)
-  end
-
   def forward_for_reply
     return unless @status.distributable? && @json['signature'].present? && reply_to_local?
 
diff --git a/app/lib/activitypub/activity/flag.rb b/app/lib/activitypub/activity/flag.rb
index 8dfc76f0a..b0443849a 100644
--- a/app/lib/activitypub/activity/flag.rb
+++ b/app/lib/activitypub/activity/flag.rb
@@ -10,6 +10,8 @@ class ActivityPub::Activity::Flag < ActivityPub::Activity
     target_accounts.each do |target_account|
       target_statuses = target_statuses_by_account[target_account.id]
 
+      next if target_account.suspended?
+
       ReportService.new.call(
         @account,
         target_account,
diff --git a/app/lib/admin/system_check/sidekiq_process_check.rb b/app/lib/admin/system_check/sidekiq_process_check.rb
index c44d86c44..22446edaf 100644
--- a/app/lib/admin/system_check/sidekiq_process_check.rb
+++ b/app/lib/admin/system_check/sidekiq_process_check.rb
@@ -7,7 +7,6 @@ class Admin::SystemCheck::SidekiqProcessCheck < Admin::SystemCheck::BaseCheck
     mailers
     pull
     scheduler
-    ingress
   ).freeze
 
   def pass?
diff --git a/app/lib/application_extension.rb b/app/lib/application_extension.rb
index 1d80b8c6d..e61cd0721 100644
--- a/app/lib/application_extension.rb
+++ b/app/lib/application_extension.rb
@@ -4,6 +4,8 @@ module ApplicationExtension
   extend ActiveSupport::Concern
 
   included do
-    validates :website, url: true, if: :website?
+    validates :name, length: { maximum: 60 }
+    validates :website, url: true, length: { maximum: 2_000 }, if: :website?
+    validates :redirect_uri, length: { maximum: 2_000 }
   end
 end
diff --git a/app/lib/formatter.rb b/app/lib/formatter.rb
index 02ebe6f89..b26138642 100644
--- a/app/lib/formatter.rb
+++ b/app/lib/formatter.rb
@@ -118,7 +118,7 @@ class Formatter
   end
 
   def format_field(account, str, **options)
-    html = account.local? ? encode_and_link_urls(str, me: true) : reformat(str)
+    html = account.local? ? encode_and_link_urls(str, me: true, with_domain: true) : reformat(str)
     html = encode_custom_emojis(html, account.emojis, options[:autoplay]) if options[:custom_emojify]
     html.html_safe # rubocop:disable Rails/OutputSafety
   end
@@ -187,7 +187,7 @@ class Formatter
       elsif entity[:hashtag]
         link_to_hashtag(entity)
       elsif entity[:screen_name]
-        link_to_mention(entity, accounts)
+        link_to_mention(entity, accounts, options)
       end
     end
   end
@@ -352,22 +352,37 @@ class Formatter
     encode(entity[:url])
   end
 
-  def link_to_mention(entity, linkable_accounts)
+  def link_to_mention(entity, linkable_accounts, options = {})
     acct = entity[:screen_name]
 
-    return link_to_account(acct) unless linkable_accounts
+    return link_to_account(acct, options) unless linkable_accounts
 
-    account = linkable_accounts.find { |item| TagManager.instance.same_acct?(item.acct, acct) }
-    account ? mention_html(account) : "@#{encode(acct)}"
+    same_username_hits = 0
+    account = nil
+    username, domain = acct.split('@')
+    domain = nil if TagManager.instance.local_domain?(domain)
+
+    linkable_accounts.each do |item|
+      same_username = item.username.casecmp(username).zero?
+      same_domain   = item.domain.nil? ? domain.nil? : item.domain.casecmp(domain)&.zero?
+
+      if same_username && !same_domain
+        same_username_hits += 1
+      elsif same_username && same_domain
+        account = item
+      end
+    end
+
+    account ? mention_html(account, with_domain: same_username_hits.positive? || options[:with_domain]) : "@#{encode(acct)}"
   end
 
-  def link_to_account(acct)
+  def link_to_account(acct, options = {})
     username, domain = acct.split('@')
 
     domain  = nil if TagManager.instance.local_domain?(domain)
     account = EntityCache.instance.mention(username, domain)
 
-    account ? mention_html(account) : "@#{encode(acct)}"
+    account ? mention_html(account, with_domain: options[:with_domain]) : "@#{encode(acct)}"
   end
 
   def link_to_hashtag(entity)
@@ -388,7 +403,7 @@ class Formatter
     "<a href=\"#{encode(tag_url(tag))}\" class=\"mention hashtag\" rel=\"tag\">#<span>#{encode(tag)}</span></a>"
   end
 
-  def mention_html(account)
-    "<span class=\"h-card\"><a href=\"#{encode(ActivityPub::TagManager.instance.url_for(account))}\" class=\"u-url mention\">@<span>#{encode(account.username)}</span></a></span>"
+  def mention_html(account, with_domain: false)
+    "<span class=\"h-card\"><a href=\"#{encode(ActivityPub::TagManager.instance.url_for(account))}\" class=\"u-url mention\">@<span>#{encode(with_domain ? account.pretty_acct : account.username)}</span></a></span>"
   end
 end
diff --git a/app/lib/potential_friendship_tracker.rb b/app/lib/potential_friendship_tracker.rb
index 188aa4a27..e72d454b6 100644
--- a/app/lib/potential_friendship_tracker.rb
+++ b/app/lib/potential_friendship_tracker.rb
@@ -28,10 +28,14 @@ class PotentialFriendshipTracker
       redis.zrem("interactions:#{account_id}", target_account_id)
     end
 
-    def get(account_id, limit: 20, offset: 0)
-      account_ids = redis.zrevrange("interactions:#{account_id}", offset, limit)
-      return [] if account_ids.empty?
-      Account.searchable.where(id: account_ids)
+    def get(account, limit)
+      account_ids = redis.zrevrange("interactions:#{account.id}", 0, limit)
+
+      return [] if account_ids.empty? || limit < 1
+
+      accounts = Account.searchable.where(id: account_ids).index_by(&:id)
+
+      account_ids.map { |id| accounts[id.to_i] }.compact
     end
   end
 end
diff --git a/app/lib/spam_check.rb b/app/lib/spam_check.rb
deleted file mode 100644
index dcb2db9ca..000000000
--- a/app/lib/spam_check.rb
+++ /dev/null
@@ -1,198 +0,0 @@
-# frozen_string_literal: true
-
-class SpamCheck
-  include Redisable
-  include ActionView::Helpers::TextHelper
-
-  # Threshold over which two Nilsimsa values are considered
-  # to refer to the same text
-  NILSIMSA_COMPARE_THRESHOLD = 95
-
-  # Nilsimsa doesn't work well on small inputs, so below
-  # this size, we check only for exact matches with MD5
-  NILSIMSA_MIN_SIZE = 10
-
-  # How long to keep the trail of digests between updates,
-  # there is no reason to store it forever
-  EXPIRE_SET_AFTER = 1.week.seconds
-
-  # How many digests to keep in an account's trail. If it's
-  # too small, spam could rotate around different message templates
-  MAX_TRAIL_SIZE = 10
-
-  # How many detected duplicates to allow through before
-  # considering the message as spam
-  THRESHOLD = 5
-
-  def initialize(status)
-    @account = status.account
-    @status  = status
-  end
-
-  def skip?
-    disabled? || already_flagged? || trusted? || no_unsolicited_mentions? || solicited_reply?
-  end
-
-  def spam?
-    if insufficient_data?
-      false
-    elsif nilsimsa?
-      digests_over_threshold?('nilsimsa') { |_, other_digest| nilsimsa_compare_value(digest, other_digest) >= NILSIMSA_COMPARE_THRESHOLD }
-    else
-      digests_over_threshold?('md5') { |_, other_digest| other_digest == digest }
-    end
-  end
-
-  def flag!
-    auto_report_status!
-  end
-
-  def remember!
-    # The scores in sorted sets don't actually have enough bits to hold an exact
-    # value of our snowflake IDs, so we use it only for its ordering property. To
-    # get the correct status ID back, we have to save it in the string value
-
-    redis.zadd(redis_key, @status.id, digest_with_algorithm)
-    redis.zremrangebyrank(redis_key, 0, -(MAX_TRAIL_SIZE + 1))
-    redis.expire(redis_key, EXPIRE_SET_AFTER)
-  end
-
-  def reset!
-    redis.del(redis_key)
-  end
-
-  def hashable_text
-    return @hashable_text if defined?(@hashable_text)
-
-    @hashable_text = @status.text
-    @hashable_text = remove_mentions(@hashable_text)
-    @hashable_text = strip_tags(@hashable_text) unless @status.local?
-    @hashable_text = normalize_unicode(@status.spoiler_text + ' ' + @hashable_text)
-    @hashable_text = remove_whitespace(@hashable_text)
-  end
-
-  def insufficient_data?
-    hashable_text.blank?
-  end
-
-  def digest
-    @digest ||= begin
-      if nilsimsa?
-        Nilsimsa.new(hashable_text).hexdigest
-      else
-        Digest::MD5.hexdigest(hashable_text)
-      end
-    end
-  end
-
-  def digest_with_algorithm
-    if nilsimsa?
-      ['nilsimsa', digest, @status.id].join(':')
-    else
-      ['md5', digest, @status.id].join(':')
-    end
-  end
-
-  class << self
-    def perform(status)
-      spam_check = new(status)
-
-      return if spam_check.skip?
-
-      if spam_check.spam?
-        spam_check.flag!
-      else
-        spam_check.remember!
-      end
-    end
-  end
-
-  private
-
-  def disabled?
-    !Setting.spam_check_enabled
-  end
-
-  def remove_mentions(text)
-    return text.gsub(Account::MENTION_RE, '') if @status.local?
-
-    Nokogiri::HTML.fragment(text).tap do |html|
-      mentions = @status.mentions.map { |mention| ActivityPub::TagManager.instance.url_for(mention.account) }
-
-      html.traverse do |element|
-        element.unlink if element.name == 'a' && mentions.include?(element['href'])
-      end
-    end.to_s
-  end
-
-  def normalize_unicode(text)
-    text.unicode_normalize(:nfkc).downcase
-  end
-
-  def remove_whitespace(text)
-    text.gsub(/\s+/, ' ').strip
-  end
-
-  def auto_report_status!
-    status_ids = Status.where(visibility: %i(public unlisted)).where(id: matching_status_ids).pluck(:id) + [@status.id] if @status.distributable?
-    ReportService.new.call(Account.representative, @account, status_ids: status_ids, comment: I18n.t('spam_check.spam_detected'))
-  end
-
-  def already_flagged?
-    @account.silenced? || @account.targeted_reports.unresolved.where(account_id: -99).exists?
-  end
-
-  def trusted?
-    @account.trust_level > Account::TRUST_LEVELS[:untrusted] || (@account.local? && @account.user_staff?)
-  end
-
-  def no_unsolicited_mentions?
-    @status.mentions.all? { |mention| mention.silent? || (!@account.local? && !mention.account.local?) || mention.account.following?(@account) }
-  end
-
-  def solicited_reply?
-    !@status.thread.nil? && @status.thread.mentions.where(account: @account).exists?
-  end
-
-  def nilsimsa_compare_value(first, second)
-    first  = [first].pack('H*')
-    second = [second].pack('H*')
-    bits   = 0
-
-    0.upto(31) do |i|
-      bits += Nilsimsa::POPC[255 & (first[i].ord ^ second[i].ord)].ord
-    end
-
-    128 - bits # -128 <= Nilsimsa Compare Value <= 128
-  end
-
-  def nilsimsa?
-    hashable_text.size > NILSIMSA_MIN_SIZE
-  end
-
-  def other_digests
-    redis.zrange(redis_key, 0, -1)
-  end
-
-  def digests_over_threshold?(filter_algorithm)
-    other_digests.select do |record|
-      algorithm, other_digest, status_id = record.split(':')
-
-      next unless algorithm == filter_algorithm
-
-      yield algorithm, other_digest, status_id
-    end.size >= THRESHOLD
-  end
-
-  def matching_status_ids
-    if nilsimsa?
-      other_digests.filter_map { |record| record.split(':')[2] if record.start_with?('nilsimsa') && nilsimsa_compare_value(digest, record.split(':')[1]) >= NILSIMSA_COMPARE_THRESHOLD }
-    else
-      other_digests.filter_map { |record| record.split(':')[2] if record.start_with?('md5') && record.split(':')[1] == digest }
-    end
-  end
-
-  def redis_key
-    @redis_key ||= "spam_check:#{@account.id}"
-  end
-end
diff --git a/app/lib/status_reach_finder.rb b/app/lib/status_reach_finder.rb
index 35b191dad..3aab3bde0 100644
--- a/app/lib/status_reach_finder.rb
+++ b/app/lib/status_reach_finder.rb
@@ -6,11 +6,22 @@ class StatusReachFinder
   end
 
   def inboxes
-    Account.where(id: reached_account_ids).inboxes
+    (reached_account_inboxes + followers_inboxes + relay_inboxes).uniq
   end
 
   private
 
+  def reached_account_inboxes
+    # When the status is a reblog, there are no interactions with it
+    # directly, we assume all interactions are with the original one
+
+    if @status.reblog?
+      []
+    else
+      Account.where(id: reached_account_ids).inboxes
+    end
+  end
+
   def reached_account_ids
     [
       replied_to_account_id,
@@ -49,4 +60,16 @@ class StatusReachFinder
   def replies_account_ids
     @status.replies.pluck(:account_id)
   end
+
+  def followers_inboxes
+    @status.account.followers.inboxes
+  end
+
+  def relay_inboxes
+    if @status.public_visibility?
+      Relay.enabled.pluck(:inbox_url)
+    else
+      []
+    end
+  end
 end
diff --git a/app/lib/tag_manager.rb b/app/lib/tag_manager.rb
index 29dde128c..a1d12a654 100644
--- a/app/lib/tag_manager.rb
+++ b/app/lib/tag_manager.rb
@@ -22,14 +22,6 @@ class TagManager
     uri.normalized_host
   end
 
-  def same_acct?(canonical, needle)
-    return true if canonical.casecmp(needle).zero?
-
-    username, domain = needle.split('@')
-
-    local_domain?(domain) && canonical.casecmp(username).zero?
-  end
-
   def local_url?(url)
     uri    = Addressable::URI.parse(url).normalize
     return false unless uri.host
diff --git a/app/models/account.rb b/app/models/account.rb
index 2e7d9f543..8f042c931 100644
--- a/app/models/account.rb
+++ b/app/models/account.rb
@@ -114,6 +114,7 @@ class Account < ApplicationRecord
   scope :matches_domain, ->(value) { where(arel_table[:domain].matches("%#{value}%")) }
   scope :searchable, -> { without_suspended.where(moved_to_account_id: nil) }
   scope :discoverable, -> { searchable.without_silenced.where(discoverable: true).left_outer_joins(:account_stat) }
+  scope :followable_by, ->(account) { joins(arel_table.join(Follow.arel_table, Arel::Nodes::OuterJoin).on(arel_table[:id].eq(Follow.arel_table[:target_account_id]).and(Follow.arel_table[:account_id].eq(account.id))).join_sources).where(Follow.arel_table[:id].eq(nil)).joins(arel_table.join(FollowRequest.arel_table, Arel::Nodes::OuterJoin).on(arel_table[:id].eq(FollowRequest.arel_table[:target_account_id]).and(FollowRequest.arel_table[:account_id].eq(account.id))).join_sources).where(FollowRequest.arel_table[:id].eq(nil)) }
   scope :tagged_with, ->(tag) { joins(:accounts_tags).where(accounts_tags: { tag_id: tag }) }
   scope :by_recent_status, -> { order(Arel.sql('(case when account_stats.last_status_at is null then 1 else 0 end) asc, account_stats.last_status_at desc, accounts.id desc')) }
   scope :by_recent_sign_in, -> { order(Arel.sql('(case when users.current_sign_in_at is null then 1 else 0 end) asc, users.current_sign_in_at desc, accounts.id desc')) }
@@ -238,6 +239,7 @@ class Account < ApplicationRecord
     transaction do
       create_deletion_request!
       update!(suspended_at: date, suspension_origin: origin)
+      create_canonical_email_block!
     end
   end
 
@@ -245,6 +247,7 @@ class Account < ApplicationRecord
     transaction do
       deletion_request&.destroy!
       update!(suspended_at: nil, suspension_origin: nil)
+      destroy_canonical_email_block!
     end
   end
 
@@ -365,7 +368,7 @@ class Account < ApplicationRecord
   end
 
   def excluded_from_timeline_account_ids
-    Rails.cache.fetch("exclude_account_ids_for:#{id}") { blocking.pluck(:target_account_id) + blocked_by.pluck(:account_id) + muting.pluck(:target_account_id) }
+    Rails.cache.fetch("exclude_account_ids_for:#{id}") { block_relationships.pluck(:target_account_id) + blocked_by_relationships.pluck(:account_id) + mute_relationships.pluck(:target_account_id) }
   end
 
   def excluded_from_timeline_domains
@@ -570,4 +573,16 @@ class Account < ApplicationRecord
   def clean_feed_manager
     FeedManager.instance.clean_feeds!(:home, [id])
   end
+
+  def create_canonical_email_block!
+    return unless local? && user_email.present?
+
+    CanonicalEmailBlock.create(reference_account: self, email: user_email)
+  end
+
+  def destroy_canonical_email_block!
+    return unless local?
+
+    CanonicalEmailBlock.where(reference_account: self).delete_all
+  end
 end
diff --git a/app/models/account_suggestions.rb b/app/models/account_suggestions.rb
new file mode 100644
index 000000000..7fe9d618e
--- /dev/null
+++ b/app/models/account_suggestions.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+class AccountSuggestions
+  class Suggestion < ActiveModelSerializers::Model
+    attributes :account, :source
+  end
+
+  def self.get(account, limit)
+    suggestions = PotentialFriendshipTracker.get(account, limit).map { |target_account| Suggestion.new(account: target_account, source: :past_interaction) }
+    suggestions.concat(FollowRecommendation.get(account, limit - suggestions.size, suggestions.map { |suggestion| suggestion.account.id }).map { |target_account| Suggestion.new(account: target_account, source: :global) }) if suggestions.size < limit
+    suggestions
+  end
+
+  def self.remove(account, target_account_id)
+    PotentialFriendshipTracker.remove(account.id, target_account_id)
+  end
+end
diff --git a/app/models/account_summary.rb b/app/models/account_summary.rb
new file mode 100644
index 000000000..6a7e17c6c
--- /dev/null
+++ b/app/models/account_summary.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+# == Schema Information
+#
+# Table name: account_summaries
+#
+#  account_id :bigint(8)        primary key
+#  language   :string
+#  sensitive  :boolean
+#
+
+class AccountSummary < ApplicationRecord
+  self.primary_key = :account_id
+
+  scope :safe, -> { where(sensitive: false) }
+  scope :localized, ->(locale) { where(language: locale) }
+  scope :filtered, -> { joins(arel_table.join(FollowRecommendationSuppression.arel_table, Arel::Nodes::OuterJoin).on(arel_table[:account_id].eq(FollowRecommendationSuppression.arel_table[:account_id])).join_sources).where(FollowRecommendationSuppression.arel_table[:id].eq(nil)) }
+
+  def self.refresh
+    Scenic.database.refresh_materialized_view(table_name, concurrently: true, cascade: false)
+  end
+
+  def readonly?
+    true
+  end
+end
diff --git a/app/models/canonical_email_block.rb b/app/models/canonical_email_block.rb
new file mode 100644
index 000000000..a8546d65a
--- /dev/null
+++ b/app/models/canonical_email_block.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+# == Schema Information
+#
+# Table name: canonical_email_blocks
+#
+#  id                   :bigint(8)        not null, primary key
+#  canonical_email_hash :string           default(""), not null
+#  reference_account_id :bigint(8)        not null
+#  created_at           :datetime         not null
+#  updated_at           :datetime         not null
+#
+
+class CanonicalEmailBlock < ApplicationRecord
+  include EmailHelper
+
+  belongs_to :reference_account, class_name: 'Account'
+
+  validates :canonical_email_hash, presence: true
+
+  def email=(email)
+    self.canonical_email_hash = email_to_canonical_email_hash(email)
+  end
+
+  def self.block?(email)
+    where(canonical_email_hash: email_to_canonical_email_hash(email)).exists?
+  end
+end
diff --git a/app/models/concerns/account_associations.rb b/app/models/concerns/account_associations.rb
index 98849f8fc..aaf371ebd 100644
--- a/app/models/concerns/account_associations.rb
+++ b/app/models/concerns/account_associations.rb
@@ -63,5 +63,8 @@ module AccountAssociations
 
     # Account deletion requests
     has_one :deletion_request, class_name: 'AccountDeletionRequest', inverse_of: :account, dependent: :destroy
+
+    # Follow recommendations
+    has_one :follow_recommendation_suppression, inverse_of: :account, dependent: :destroy
   end
 end
diff --git a/app/models/follow_recommendation.rb b/app/models/follow_recommendation.rb
new file mode 100644
index 000000000..c4355224d
--- /dev/null
+++ b/app/models/follow_recommendation.rb
@@ -0,0 +1,39 @@
+# frozen_string_literal: true
+# == Schema Information
+#
+# Table name: follow_recommendations
+#
+#  account_id :bigint(8)        primary key
+#  rank       :decimal(, )
+#  reason     :text             is an Array
+#
+
+class FollowRecommendation < ApplicationRecord
+  self.primary_key = :account_id
+
+  belongs_to :account_summary, foreign_key: :account_id
+  belongs_to :account, foreign_key: :account_id
+
+  scope :safe, -> { joins(:account_summary).merge(AccountSummary.safe) }
+  scope :localized, ->(locale) { joins(:account_summary).merge(AccountSummary.localized(locale)) }
+  scope :filtered, -> { joins(:account_summary).merge(AccountSummary.filtered) }
+
+  def readonly?
+    true
+  end
+
+  def self.get(account, limit, exclude_account_ids = [])
+    account_ids = Redis.current.zrevrange("follow_recommendations:#{account.user_locale}", 0, -1).map(&:to_i) - exclude_account_ids - [account.id]
+
+    return [] if account_ids.empty? || limit < 1
+
+    accounts = Account.followable_by(account)
+                      .not_excluded_by_account(account)
+                      .not_domain_blocked_by_account(account)
+                      .where(id: account_ids)
+                      .limit(limit)
+                      .index_by(&:id)
+
+    account_ids.map { |id| accounts[id] }.compact
+  end
+end
diff --git a/app/models/follow_recommendation_filter.rb b/app/models/follow_recommendation_filter.rb
new file mode 100644
index 000000000..acf03cd84
--- /dev/null
+++ b/app/models/follow_recommendation_filter.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+class FollowRecommendationFilter
+  KEYS = %i(
+    language
+    status
+  ).freeze
+
+  attr_reader :params, :language
+
+  def initialize(params)
+    @language = params.delete('language') || I18n.locale
+    @params   = params
+  end
+
+  def results
+    if params['status'] == 'suppressed'
+      Account.joins(:follow_recommendation_suppression).order(FollowRecommendationSuppression.arel_table[:id].desc).to_a
+    else
+      account_ids = Redis.current.zrevrange("follow_recommendations:#{@language}", 0, -1).map(&:to_i)
+      accounts    = Account.where(id: account_ids).index_by(&:id)
+
+      account_ids.map { |id| accounts[id] }.compact
+    end
+  end
+end
diff --git a/app/models/follow_recommendation_suppression.rb b/app/models/follow_recommendation_suppression.rb
new file mode 100644
index 000000000..170506b85
--- /dev/null
+++ b/app/models/follow_recommendation_suppression.rb
@@ -0,0 +1,28 @@
+# frozen_string_literal: true
+# == Schema Information
+#
+# Table name: follow_recommendation_suppressions
+#
+#  id         :bigint(8)        not null, primary key
+#  account_id :bigint(8)        not null
+#  created_at :datetime         not null
+#  updated_at :datetime         not null
+#
+
+class FollowRecommendationSuppression < ApplicationRecord
+  include Redisable
+
+  belongs_to :account
+
+  after_commit :remove_follow_recommendations, on: :create
+
+  private
+
+  def remove_follow_recommendations
+    redis.pipelined do
+      I18n.available_locales.each do |locale|
+        redis.zrem("follow_recommendations:#{locale}", account_id)
+      end
+    end
+  end
+end
diff --git a/app/models/form/account_batch.rb b/app/models/form/account_batch.rb
index 26d6d3abf..698933c9f 100644
--- a/app/models/form/account_batch.rb
+++ b/app/models/form/account_batch.rb
@@ -21,6 +21,10 @@ class Form::AccountBatch
       approve!
     when 'reject'
       reject!
+    when 'suppress_follow_recommendation'
+      suppress_follow_recommendation!
+    when 'unsuppress_follow_recommendation'
+      unsuppress_follow_recommendation!
     end
   end
 
@@ -79,4 +83,18 @@ class Form::AccountBatch
     records.each { |account| authorize(account.user, :reject?) }
            .each { |account| DeleteAccountService.new.call(account, reserve_email: false, reserve_username: false) }
   end
+
+  def suppress_follow_recommendation!
+    authorize(:follow_recommendation, :suppress?)
+
+    accounts.each do |account|
+      FollowRecommendationSuppression.create(account: account)
+    end
+  end
+
+  def unsuppress_follow_recommendation!
+    authorize(:follow_recommendation, :unsuppress?)
+
+    FollowRecommendationSuppression.where(account_id: account_ids).destroy_all
+  end
 end
diff --git a/app/models/form/admin_settings.rb b/app/models/form/admin_settings.rb
index 999d835e6..558a906d2 100644
--- a/app/models/form/admin_settings.rb
+++ b/app/models/form/admin_settings.rb
@@ -35,7 +35,6 @@ class Form::AdminSettings
     mascot
     show_reblogs_in_public_timelines
     show_replies_in_public_timelines
-    spam_check_enabled
     trends
     trendable_by_default
     show_domain_blocks
@@ -59,7 +58,6 @@ class Form::AdminSettings
     enable_keybase
     show_reblogs_in_public_timelines
     show_replies_in_public_timelines
-    spam_check_enabled
     trends
     trendable_by_default
     noindex
diff --git a/app/models/web/push_subscription.rb b/app/models/web/push_subscription.rb
index c407a7789..6e46573ae 100644
--- a/app/models/web/push_subscription.rb
+++ b/app/models/web/push_subscription.rb
@@ -24,81 +24,101 @@ class Web::PushSubscription < ApplicationRecord
   validates :key_p256dh, presence: true
   validates :key_auth, presence: true
 
-  def push(notification)
-    I18n.with_locale(associated_user&.locale || I18n.default_locale) do
-      push_payload(payload_for_notification(notification), 48.hours.seconds)
-    end
+  delegate :locale, to: :associated_user
+
+  def encrypt(payload)
+    Webpush::Encryption.encrypt(payload, key_p256dh, key_auth)
+  end
+
+  def audience
+    @audience ||= Addressable::URI.parse(endpoint).normalized_site
+  end
+
+  def crypto_key_header
+    p256ecdsa = vapid_key.public_key_for_push_header
+
+    "p256ecdsa=#{p256ecdsa}"
+  end
+
+  def authorization_header
+    jwt = JWT.encode({ aud: audience, exp: 24.hours.from_now.to_i, sub: "mailto:#{contact_email}" }, vapid_key.curve, 'ES256', typ: 'JWT')
+
+    "WebPush #{jwt}"
   end
 
   def pushable?(notification)
-    data&.key?('alerts') && ActiveModel::Type::Boolean.new.cast(data['alerts'][notification.type.to_s])
+    policy_allows_notification?(notification) && alert_enabled_for_notification_type?(notification)
   end
 
   def associated_user
     return @associated_user if defined?(@associated_user)
 
-    @associated_user = if user_id.nil?
-                         session_activation.user
-                       else
-                         user
-                       end
+    @associated_user = begin
+      if user_id.nil?
+        session_activation.user
+      else
+        user
+      end
+    end
   end
 
   def associated_access_token
     return @associated_access_token if defined?(@associated_access_token)
 
-    @associated_access_token = if access_token_id.nil?
-                                 find_or_create_access_token.token
-                               else
-                                 access_token.token
-                               end
+    @associated_access_token = begin
+      if access_token_id.nil?
+        find_or_create_access_token.token
+      else
+        access_token.token
+      end
+    end
   end
 
   class << self
     def unsubscribe_for(application_id, resource_owner)
-      access_token_ids = Doorkeeper::AccessToken.where(application_id: application_id, resource_owner_id: resource_owner.id, revoked_at: nil)
-                                                .pluck(:id)
-
+      access_token_ids = Doorkeeper::AccessToken.where(application_id: application_id, resource_owner_id: resource_owner.id, revoked_at: nil).pluck(:id)
       where(access_token_id: access_token_ids).delete_all
     end
   end
 
   private
 
-  def push_payload(message, ttl = 5.minutes.seconds)
-    Webpush.payload_send(
-      message: Oj.dump(message),
-      endpoint: endpoint,
-      p256dh: key_p256dh,
-      auth: key_auth,
-      ttl: ttl,
-      ssl_timeout: 10,
-      open_timeout: 10,
-      read_timeout: 10,
-      vapid: {
-        subject: "mailto:#{::Setting.site_contact_email}",
-        private_key: Rails.configuration.x.vapid_private_key,
-        public_key: Rails.configuration.x.vapid_public_key,
-      }
-    )
-  end
-
-  def payload_for_notification(notification)
-    ActiveModelSerializers::SerializableResource.new(
-      notification,
-      serializer: Web::NotificationSerializer,
-      scope: self,
-      scope_name: :current_push_subscription
-    ).as_json
-  end
-
   def find_or_create_access_token
     Doorkeeper::AccessToken.find_or_create_for(
       application: Doorkeeper::Application.find_by(superapp: true),
-      resource_owner: session_activation.user_id,
+      resource_owner: user_id || session_activation.user_id,
       scopes: Doorkeeper::OAuth::Scopes.from_string('read write follow push'),
       expires_in: Doorkeeper.configuration.access_token_expires_in,
       use_refresh_token: Doorkeeper.configuration.refresh_token_enabled?
     )
   end
+
+  def vapid_key
+    @vapid_key ||= Webpush::VapidKey.from_keys(Rails.configuration.x.vapid_public_key, Rails.configuration.x.vapid_private_key)
+  end
+
+  def contact_email
+    @contact_email ||= ::Setting.site_contact_email
+  end
+
+  def alert_enabled_for_notification_type?(notification)
+    truthy?(data&.dig('alerts', notification.type.to_s))
+  end
+
+  def policy_allows_notification?(notification)
+    case data&.dig('policy')
+    when nil, 'all'
+      true
+    when 'none'
+      false
+    when 'followed'
+      notification.account.following?(notification.from_account)
+    when 'follower'
+      notification.from_account.following?(notification.account)
+    end
+  end
+
+  def truthy?(val)
+    ActiveModel::Type::Boolean.new.cast(val)
+  end
 end
diff --git a/app/policies/follow_recommendation_policy.rb b/app/policies/follow_recommendation_policy.rb
new file mode 100644
index 000000000..68cd0e547
--- /dev/null
+++ b/app/policies/follow_recommendation_policy.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+class FollowRecommendationPolicy < ApplicationPolicy
+  def show?
+    staff?
+  end
+
+  def suppress?
+    staff?
+  end
+
+  def unsuppress?
+    staff?
+  end
+end
diff --git a/app/serializers/rest/suggestion_serializer.rb b/app/serializers/rest/suggestion_serializer.rb
new file mode 100644
index 000000000..3d697fd9f
--- /dev/null
+++ b/app/serializers/rest/suggestion_serializer.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+class REST::SuggestionSerializer < ActiveModel::Serializer
+  attributes :source
+
+  has_one :account, serializer: REST::AccountSerializer
+end
diff --git a/app/services/process_mentions_service.rb b/app/services/process_mentions_service.rb
index 570cd8272..ec4cb11f9 100644
--- a/app/services/process_mentions_service.rb
+++ b/app/services/process_mentions_service.rb
@@ -43,7 +43,6 @@ class ProcessMentionsService < BaseService
     end
 
     status.save!
-    check_for_spam(status)
 
     mentions.each { |mention| create_notification(mention) }
   end
@@ -72,8 +71,4 @@ class ProcessMentionsService < BaseService
   def resolve_account_service
     ResolveAccountService.new
   end
-
-  def check_for_spam(status)
-    SpamCheck.perform(status)
-  end
 end
diff --git a/app/services/remove_status_service.rb b/app/services/remove_status_service.rb
index 764ed288d..17868d4fd 100644
--- a/app/services/remove_status_service.rb
+++ b/app/services/remove_status_service.rb
@@ -27,10 +27,7 @@ class RemoveStatusService < BaseService
         # original object being removed implicitly removes reblogs
         # of it. The Delete activity of the original is forwarded
         # separately.
-        if @account.local? && !@options[:original_removed]
-          remove_from_remote_followers
-          remove_from_remote_reach
-        end
+        remove_from_remote_reach if @account.local? && !@options[:original_removed]
 
         # Since reblogs don't mention anyone, don't get reblogged,
         # favourited and don't contain their own media attachments
@@ -42,7 +39,6 @@ class RemoveStatusService < BaseService
           remove_from_public
           remove_from_media if @status.media_attachments.any?
           remove_from_direct if status.direct_visibility?
-          remove_from_spam_check
           remove_media
         end
 
@@ -85,13 +81,10 @@ class RemoveStatusService < BaseService
   end
 
   def remove_from_remote_reach
-    return if @status.reblog?
-
-    # People who got mentioned in the status, or who
-    # reblogged it from someone else might not follow
-    # the author and wouldn't normally receive the
-    # delete notification - so here, we explicitly
-    # send it to them
+    # Followers, relays, people who got mentioned in the status,
+    # or who reblogged it from someone else might not follow
+    # the author and wouldn't normally receive the delete
+    # notification - so here, we explicitly send it to them
 
     status_reach_finder = StatusReachFinder.new(@status)
 
@@ -100,24 +93,6 @@ class RemoveStatusService < BaseService
     end
   end
 
-  def remove_from_remote_followers
-    ActivityPub::DeliveryWorker.push_bulk(@account.followers.inboxes) do |inbox_url|
-      [signed_activity_json, @account.id, inbox_url]
-    end
-
-    relay! if relayable?
-  end
-
-  def relayable?
-    @status.public_visibility?
-  end
-
-  def relay!
-    ActivityPub::DeliveryWorker.push_bulk(Relay.enabled.pluck(:inbox_url)) do |inbox_url|
-      [signed_activity_json, @account.id, inbox_url]
-    end
-  end
-
   def signed_activity_json
     @signed_activity_json ||= Oj.dump(serialize_payload(@status, @status.reblog? ? ActivityPub::UndoAnnounceSerializer : ActivityPub::DeleteSerializer, signer: @account))
   end
@@ -171,10 +146,6 @@ class RemoveStatusService < BaseService
     @status.media_attachments.destroy_all
   end
 
-  def remove_from_spam_check
-    redis.zremrangebyscore("spam_check:#{@status.account_id}", @status.id, @status.id)
-  end
-
   def lock_options
     { redis: Redis.current, key: "distribute:#{@status.id}" }
   end
diff --git a/app/services/report_service.rb b/app/services/report_service.rb
index 9d9c7d6c9..bc0a8b464 100644
--- a/app/services/report_service.rb
+++ b/app/services/report_service.rb
@@ -10,6 +10,8 @@ class ReportService < BaseService
     @comment        = options.delete(:comment) || ''
     @options        = options
 
+    raise ActiveRecord::RecordNotFound if @target_account.suspended?
+
     create_report!
     notify_staff!
     forward_to_origin! if !@target_account.local? && ActiveModel::Type::Boolean.new.cast(@options[:forward])
diff --git a/app/services/suspend_account_service.rb b/app/services/suspend_account_service.rb
index 9f4da91d4..b8dc8d5e0 100644
--- a/app/services/suspend_account_service.rb
+++ b/app/services/suspend_account_service.rb
@@ -42,7 +42,13 @@ class SuspendAccountService < BaseService
   end
 
   def distribute_update_actor!
-    ActivityPub::UpdateDistributionWorker.perform_async(@account.id) if @account.local?
+    return unless @account.local?
+
+    account_reach_finder = AccountReachFinder.new(@account)
+
+    ActivityPub::DeliveryWorker.push_bulk(account_reach_finder.inboxes) do |inbox_url|
+      [signed_activity_json, @account.id, inbox_url]
+    end
   end
 
   def unmerge_from_home_timelines!
@@ -90,4 +96,8 @@ class SuspendAccountService < BaseService
       end
     end
   end
+
+  def signed_activity_json
+    @signed_activity_json ||= Oj.dump(serialize_payload(@account, ActivityPub::UpdateSerializer, signer: @account))
+  end
 end
diff --git a/app/services/unsuspend_account_service.rb b/app/services/unsuspend_account_service.rb
index ce9ee48ed..949c670aa 100644
--- a/app/services/unsuspend_account_service.rb
+++ b/app/services/unsuspend_account_service.rb
@@ -12,6 +12,7 @@ class UnsuspendAccountService < BaseService
     merge_into_home_timelines!
     merge_into_list_timelines!
     publish_media_attachments!
+    distribute_update_actor!
   end
 
   private
@@ -36,6 +37,16 @@ class UnsuspendAccountService < BaseService
     # @account would now be nil.
   end
 
+  def distribute_update_actor!
+    return unless @account.local?
+
+    account_reach_finder = AccountReachFinder.new(@account)
+
+    ActivityPub::DeliveryWorker.push_bulk(account_reach_finder.inboxes) do |inbox_url|
+      [signed_activity_json, @account.id, inbox_url]
+    end
+  end
+
   def merge_into_home_timelines!
     @account.followers_for_local_distribution.find_each do |follower|
       FeedManager.instance.merge_into_home(@account, follower)
@@ -81,4 +92,8 @@ class UnsuspendAccountService < BaseService
       end
     end
   end
+
+  def signed_activity_json
+    @signed_activity_json ||= Oj.dump(serialize_payload(@account, ActivityPub::UpdateSerializer, signer: @account))
+  end
 end
diff --git a/app/validators/blacklisted_email_validator.rb b/app/validators/blacklisted_email_validator.rb
index 1ca73fdcc..eb66ad93d 100644
--- a/app/validators/blacklisted_email_validator.rb
+++ b/app/validators/blacklisted_email_validator.rb
@@ -6,26 +6,25 @@ class BlacklistedEmailValidator < ActiveModel::Validator
 
     @email = user.email
 
-    user.errors.add(:email, :blocked) if blocked_email?
+    user.errors.add(:email, :blocked) if blocked_email_provider?
+    user.errors.add(:email, :taken) if blocked_canonical_email?
   end
 
   private
 
-  def blocked_email?
-    on_blacklist? || not_on_whitelist?
+  def blocked_email_provider?
+    disallowed_through_email_domain_block? || disallowed_through_configuration? || not_allowed_through_configuration?
   end
 
-  def on_blacklist?
-    return true  if EmailDomainBlock.block?(@email)
-    return false if Rails.configuration.x.email_domains_blacklist.blank?
-
-    domains = Rails.configuration.x.email_domains_blacklist.gsub('.', '\.')
-    regexp  = Regexp.new("@(.+\\.)?(#{domains})", true)
+  def blocked_canonical_email?
+    CanonicalEmailBlock.block?(@email)
+  end
 
-    regexp.match?(@email)
+  def disallowed_through_email_domain_block?
+    EmailDomainBlock.block?(@email)
   end
 
-  def not_on_whitelist?
+  def not_allowed_through_configuration?
     return false if Rails.configuration.x.email_domains_whitelist.blank?
 
     domains = Rails.configuration.x.email_domains_whitelist.gsub('.', '\.')
@@ -33,4 +32,13 @@ class BlacklistedEmailValidator < ActiveModel::Validator
 
     @email !~ regexp
   end
+
+  def disallowed_through_configuration?
+    return false if Rails.configuration.x.email_domains_blacklist.blank?
+
+    domains = Rails.configuration.x.email_domains_blacklist.gsub('.', '\.')
+    regexp  = Regexp.new("@(.+\\.)?(#{domains})", true)
+
+    regexp.match?(@email)
+  end
 end
diff --git a/app/views/admin/dashboard/index.html.haml b/app/views/admin/dashboard/index.html.haml
index f2f0c813d..ae5ee270e 100644
--- a/app/views/admin/dashboard/index.html.haml
+++ b/app/views/admin/dashboard/index.html.haml
@@ -79,8 +79,6 @@
           = feature_hint(link_to(t('admin.dashboard.trends'), edit_admin_settings_path), @trends_enabled)
         %li
           = feature_hint(link_to(t('admin.dashboard.feature_relay'), admin_relays_path), @relay_enabled)
-        %li
-          = feature_hint(link_to(t('admin.dashboard.feature_spam_check'), edit_admin_settings_path), @spam_check_enabled)
 
   .dashboard__widgets__versions
     %div
diff --git a/app/views/admin/follow_recommendations/_account.html.haml b/app/views/admin/follow_recommendations/_account.html.haml
new file mode 100644
index 000000000..af5a4aaf7
--- /dev/null
+++ b/app/views/admin/follow_recommendations/_account.html.haml
@@ -0,0 +1,20 @@
+.batch-table__row
+  %label.batch-table__row__select.batch-table__row__select--aligned.batch-checkbox
+    = f.check_box :account_ids, { multiple: true, include_hidden: false }, account.id
+  .batch-table__row__content.batch-table__row__content--unpadded
+    %table.accounts-table
+      %tbody
+        %tr
+          %td= account_link_to account
+          %td.accounts-table__count.optional
+            = number_to_human account.statuses_count, strip_insignificant_zeros: true
+            %small= t('accounts.posts', count: account.statuses_count).downcase
+          %td.accounts-table__count.optional
+            = number_to_human account.followers_count, strip_insignificant_zeros: true
+            %small= t('accounts.followers', count: account.followers_count).downcase
+          %td.accounts-table__count
+            - if account.last_status_at.present?
+              %time.time-ago{ datetime: account.last_status_at.to_date.iso8601, title: l(account.last_status_at.to_date) }= l account.last_status_at
+            - else
+              \-
+            %small= t('accounts.last_active')
diff --git a/app/views/admin/follow_recommendations/show.html.haml b/app/views/admin/follow_recommendations/show.html.haml
new file mode 100644
index 000000000..5b949a165
--- /dev/null
+++ b/app/views/admin/follow_recommendations/show.html.haml
@@ -0,0 +1,41 @@
+- content_for :page_title do
+  = t('admin.follow_recommendations.title')
+
+- content_for :header_tags do
+  = javascript_pack_tag 'admin', async: true, crossorigin: 'anonymous'
+
+%p= t('admin.follow_recommendations.description_html')
+
+%hr.spacer/
+
+= form_tag admin_follow_recommendations_path, method: 'GET', class: 'simple_form' do
+  .filters
+    .filter-subset.filter-subset--with-select
+      %strong= t('admin.follow_recommendations.language')
+      .input.select.optional
+        = select_tag :language, options_for_select(I18n.available_locales.map { |key| [human_locale(key), key]}, @language)
+
+    .filter-subset
+      %strong= t('admin.follow_recommendations.status')
+      %ul
+        %li= filter_link_to t('admin.accounts.moderation.active'), status: nil
+        %li= filter_link_to t('admin.follow_recommendations.suppressed'), status: 'suppressed'
+
+= form_for(@form, url: admin_follow_recommendations_path, method: :patch) do |f|
+  - RelationshipFilter::KEYS.each do |key|
+    = hidden_field_tag key, params[key] if params[key].present?
+
+  .batch-table
+    .batch-table__toolbar
+      %label.batch-table__toolbar__select.batch-checkbox-all
+        = check_box_tag :batch_checkbox_all, nil, false
+      .batch-table__toolbar__actions
+        - if params[:status].blank? && can?(:suppress, :follow_recommendation)
+          = f.button safe_join([fa_icon('times'), t('admin.follow_recommendations.suppress')]), name: :suppress, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') }
+        - if params[:status] == 'suppressed' && can?(:unsuppress, :follow_recommendation)
+          = f.button safe_join([fa_icon('plus'), t('admin.follow_recommendations.unsuppress')]), name: :unsuppress, class: 'table-action-link', type: :submit
+    .batch-table__body
+      - if @accounts.empty?
+        = nothing_here 'nothing-here--under-tabs'
+      - else
+        = render partial: 'account', collection: @accounts, locals: { f: f }
diff --git a/app/views/admin/rules/index.html.haml b/app/views/admin/rules/index.html.haml
index 3b069d083..4fb993ad0 100644
--- a/app/views/admin/rules/index.html.haml
+++ b/app/views/admin/rules/index.html.haml
@@ -1,8 +1,9 @@
 - content_for :page_title do
   = t('admin.rules.title')
 
-.simple_form
-  %p.hint= t('admin.rules.description')
+%p= t('admin.rules.description_html')
+
+%hr.spacer/
 
 - if can? :create, :rule
   = simple_form_for @rule, url: admin_rules_path do |f|
diff --git a/app/views/admin/settings/edit.html.haml b/app/views/admin/settings/edit.html.haml
index fa8d8441e..1fab9dd06 100644
--- a/app/views/admin/settings/edit.html.haml
+++ b/app/views/admin/settings/edit.html.haml
@@ -101,9 +101,6 @@
   .fields-group
     = f.input :show_replies_in_public_timelines, as: :boolean, wrapper: :with_label, label: t('admin.settings.show_replies_in_public_timelines.title'), hint: t('admin.settings.show_replies_in_public_timelines.desc_html')
 
-  .fields-group
-    = f.input :spam_check_enabled, as: :boolean, wrapper: :with_label, label: t('admin.settings.spam_check_enabled.title'), hint: t('admin.settings.spam_check_enabled.desc_html')
-
   %hr.spacer/
 
   .fields-group
diff --git a/app/views/user_mailer/webauthn_enabled.text.erb b/app/views/user_mailer/webauthn_enabled.text.erb
index 4c233fefb..d4482a69b 100644
--- a/app/views/user_mailer/webauthn_enabled.text.erb
+++ b/app/views/user_mailer/webauthn_enabled.text.erb
@@ -1,7 +1,7 @@
-<%= t 'devise.mailer.webauthn_credentia.added.title' %>
+<%= t 'devise.mailer.webauthn_credential.added.title' %>
 
 ===
 
-<%= t 'devise.mailer.webauthn_credentia.added.explanation' %>
+<%= t 'devise.mailer.webauthn_credential.added.explanation' %>
 
 => <%= edit_user_registration_url %>
diff --git a/app/workers/scheduler/follow_recommendations_scheduler.rb b/app/workers/scheduler/follow_recommendations_scheduler.rb
new file mode 100644
index 000000000..0a0286496
--- /dev/null
+++ b/app/workers/scheduler/follow_recommendations_scheduler.rb
@@ -0,0 +1,61 @@
+# frozen_string_literal: true
+
+class Scheduler::FollowRecommendationsScheduler
+  include Sidekiq::Worker
+  include Redisable
+
+  sidekiq_options retry: 0
+
+  # The maximum number of accounts that can be requested in one page from the
+  # API is 80, and the suggestions API does not allow pagination. This number
+  # leaves some room for accounts being filtered during live access
+  SET_SIZE = 100
+
+  def perform
+    # Maintaining a materialized view speeds-up subsequent queries significantly
+    AccountSummary.refresh
+
+    fallback_recommendations = FollowRecommendation.safe.filtered.limit(SET_SIZE).index_by(&:account_id)
+
+    I18n.available_locales.each do |locale|
+      recommendations = begin
+        if AccountSummary.safe.filtered.localized(locale).exists? # We can skip the work if no accounts with that language exist
+          FollowRecommendation.safe.filtered.localized(locale).limit(SET_SIZE).index_by(&:account_id)
+        else
+          {}
+        end
+      end
+
+      # Use language-agnostic results if there are not enough language-specific ones
+      missing = SET_SIZE - recommendations.keys.size
+
+      if missing.positive?
+        added = 0
+
+        # Avoid duplicate results
+        fallback_recommendations.each_value do |recommendation|
+          next if recommendations.key?(recommendation.account_id)
+
+          recommendations[recommendation.account_id] = recommendation
+          added += 1
+
+          break if added >= missing
+        end
+      end
+
+      redis.pipelined do
+        redis.del(key(locale))
+
+        recommendations.each_value do |recommendation|
+          redis.zadd(key(locale), recommendation.rank, recommendation.account_id)
+        end
+      end
+    end
+  end
+
+  private
+
+  def key(locale)
+    "follow_recommendations:#{locale}"
+  end
+end
diff --git a/app/workers/web/push_notification_worker.rb b/app/workers/web/push_notification_worker.rb
index 46aeaa30b..57f5b5c22 100644
--- a/app/workers/web/push_notification_worker.rb
+++ b/app/workers/web/push_notification_worker.rb
@@ -3,22 +3,67 @@
 class Web::PushNotificationWorker
   include Sidekiq::Worker
 
-  sidekiq_options backtrace: true, retry: 5
+  sidekiq_options queue: 'push', retry: 5
+
+  TTL     = 48.hours.to_s
+  URGENCY = 'normal'
 
   def perform(subscription_id, notification_id)
-    subscription = ::Web::PushSubscription.find(subscription_id)
-    notification = Notification.find(notification_id)
+    @subscription = Web::PushSubscription.find(subscription_id)
+    @notification = Notification.find(notification_id)
+
+    # Polymorphically associated activity could have been deleted
+    # in the meantime, so we have to double-check before proceeding
+    return unless @notification.activity.present? && @subscription.pushable?(@notification)
+
+    payload = @subscription.encrypt(push_notification_json)
 
-    subscription.push(notification) unless notification.activity.nil?
-  rescue Webpush::ResponseError => e
-    code = e.response.code.to_i
+    request_pool.with(@subscription.audience) do |http_client|
+      request = Request.new(:post, @subscription.endpoint, body: payload.fetch(:ciphertext), http_client: http_client)
 
-    if (400..499).cover?(code) && ![408, 429].include?(code)
-      subscription.destroy!
-    else
-      raise e
+      request.add_headers(
+        'Content-Type'     => 'application/octet-stream',
+        'Ttl'              => TTL,
+        'Urgency'          => URGENCY,
+        'Content-Encoding' => 'aesgcm',
+        'Encryption'       => "salt=#{Webpush.encode64(payload.fetch(:salt)).delete('=')}",
+        'Crypto-Key'       => "dh=#{Webpush.encode64(payload.fetch(:server_public_key)).delete('=')};#{@subscription.crypto_key_header}",
+        'Authorization'    => @subscription.authorization_header
+      )
+
+      request.perform do |response|
+        # If the server responds with an error in the 4xx range
+        # that isn't about rate-limiting or timeouts, we can
+        # assume that the subscription is invalid or expired
+        # and must be removed
+
+        if (400..499).cover?(response.code) && ![408, 429].include?(response.code)
+          @subscription.destroy!
+        elsif !(200...300).cover?(response.code)
+          raise Mastodon::UnexpectedResponseError, response
+        end
+      end
     end
   rescue ActiveRecord::RecordNotFound
     true
   end
+
+  private
+
+  def push_notification_json
+    json = I18n.with_locale(@subscription.locale || I18n.default_locale) do
+      ActiveModelSerializers::SerializableResource.new(
+        @notification,
+        serializer: Web::NotificationSerializer,
+        scope: @subscription,
+        scope_name: :current_push_subscription
+      ).as_json
+    end
+
+    Oj.dump(json)
+  end
+
+  def request_pool
+    RequestPool.current
+  end
 end
diff --git a/config/application.rb b/config/application.rb
index c911e76dc..eb2c91677 100644
--- a/config/application.rb
+++ b/config/application.rb
@@ -29,6 +29,7 @@ require_relative '../lib/webpacker/helper_extensions'
 require_relative '../lib/action_dispatch/cookie_jar_extensions'
 require_relative '../lib/rails/engine_extensions'
 require_relative '../lib/active_record/database_tasks_extensions'
+require_relative '../lib/active_record/batches'
 
 Dotenv::Railtie.load
 
diff --git a/config/environments/production.rb b/config/environments/production.rb
index 8d811451c..bf6b5d88e 100644
--- a/config/environments/production.rb
+++ b/config/environments/production.rb
@@ -90,9 +90,12 @@ Rails.application.configure do
   config.action_mailer.perform_caching = false
 
   # E-mails
+  outgoing_email_address = ENV.fetch('SMTP_FROM_ADDRESS', 'notifications@localhost')
+  outgoing_mail_domain   = Mail::Address.new(outgoing_email_address).domain
   config.action_mailer.default_options = {
-    from: ENV.fetch('SMTP_FROM_ADDRESS', 'notifications@localhost'),
-    reply_to: ENV['SMTP_REPLY_TO']
+    from: outgoing_email_address,
+    reply_to: ENV['SMTP_REPLY_TO'],
+    'Message-ID': -> { "<#{Mail.random_tag}@#{outgoing_mail_domain}>" },
   }
 
   config.action_mailer.smtp_settings = {
@@ -116,10 +119,10 @@ Rails.application.configure do
     'X-Frame-Options'         => 'DENY',
     'X-Content-Type-Options'  => 'nosniff',
     'X-XSS-Protection'        => '1; mode=block',
+    'Permissions-Policy'      => 'interest-cohort=()',
     'Referrer-Policy'         => 'same-origin',
     'Strict-Transport-Security' => 'max-age=63072000; includeSubDomains; preload',
     'X-Clacks-Overhead' => 'GNU Natalie Nguyen'
-
   }
 
   config.x.otp_secret = ENV.fetch('OTP_SECRET')
diff --git a/config/initializers/content_security_policy.rb b/config/initializers/content_security_policy.rb
index 3a1438201..549ac3568 100644
--- a/config/initializers/content_security_policy.rb
+++ b/config/initializers/content_security_policy.rb
@@ -53,11 +53,13 @@ Rails.application.config.content_security_policy_nonce_generator = -> request {
 
 Rails.application.config.content_security_policy_nonce_directives = %w(style-src)
 
-PgHero::HomeController.content_security_policy do |p|
-  p.script_src :self, :unsafe_inline, assets_host
-  p.style_src  :self, :unsafe_inline, assets_host
-end
+Rails.application.reloader.to_prepare do
+  PgHero::HomeController.content_security_policy do |p|
+    p.script_src :self, :unsafe_inline, assets_host
+    p.style_src  :self, :unsafe_inline, assets_host
+  end
 
-PgHero::HomeController.after_action do
-  request.content_security_policy_nonce_generator = nil
+  PgHero::HomeController.after_action do
+    request.content_security_policy_nonce_generator = nil
+  end
 end
diff --git a/config/initializers/doorkeeper.rb b/config/initializers/doorkeeper.rb
index 63cff7c59..f78db8653 100644
--- a/config/initializers/doorkeeper.rb
+++ b/config/initializers/doorkeeper.rb
@@ -52,6 +52,11 @@ Doorkeeper.configure do
   # Issue access tokens with refresh token (disabled by default)
   # use_refresh_token
 
+  # Forbids creating/updating applications with arbitrary scopes that are
+  # not in configuration, i.e. `default_scopes` or `optional_scopes`.
+  # (Disabled by default)
+  enforce_configured_scopes
+
   # Provide support for an owner to be assigned to each registered application (disabled by default)
   # Optional parameter :confirmation => true (default false) if you want to enforce ownership of
   # a registered application
diff --git a/config/initializers/paperclip.rb b/config/initializers/paperclip.rb
index 9ad7fd814..e2a045647 100644
--- a/config/initializers/paperclip.rb
+++ b/config/initializers/paperclip.rb
@@ -112,7 +112,9 @@ else
   )
 end
 
-Paperclip.options[:content_type_mappings] = { csv: Import::FILE_TYPES }
+Rails.application.reloader.to_prepare do
+  Paperclip.options[:content_type_mappings] = { csv: Import::FILE_TYPES }
+end
 
 # In some places in the code, we rescue this exception, but we don't always
 # load the S3 library, so it may be an undefined constant:
diff --git a/config/initializers/suppress_csrf_warnings.rb b/config/initializers/suppress_csrf_warnings.rb
index 410ab585b..b86adc6f1 100644
--- a/config/initializers/suppress_csrf_warnings.rb
+++ b/config/initializers/suppress_csrf_warnings.rb
@@ -1,3 +1,5 @@
 # frozen_string_literal: true
 
-ActionController::Base.log_warning_on_csrf_failure = false
+Rails.application.reloader.to_prepare do
+  ActionController::Base.log_warning_on_csrf_failure = false
+end
diff --git a/config/locales/en.yml b/config/locales/en.yml
index 182a8e985..88acf3164 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -315,10 +315,12 @@ en:
       new:
         create: Create announcement
         title: New announcement
+      publish: Publish
       published_msg: Announcement successfully published!
       scheduled_for: Scheduled for %{time}
       scheduled_msg: Announcement scheduled for publication!
       title: Announcements
+      unpublish: Unpublish
       unpublished_msg: Announcement successfully unpublished!
       updated_msg: Announcement successfully updated!
     custom_emojis:
@@ -363,7 +365,6 @@ en:
       feature_profile_directory: Profile directory
       feature_registrations: Registrations
       feature_relay: Federation relay
-      feature_spam_check: Anti-spam
       feature_timeline_preview: Timeline preview
       features: Features
       hidden_service: Federation with hidden services
@@ -441,6 +442,14 @@ en:
         create: Add domain
         title: Block new e-mail domain
       title: Blocked e-mail domains
+    follow_recommendations:
+      description_html: "<strong>Follow recommendations help new users quickly find interesting content</strong>. When a user has not interacted with others enough to form personalized follow recommendations, these accounts are recommended instead. They are re-calculated on a daily basis from a mix of accounts with the highest recent engagements and highest local follower counts for a given language."
+      language: For language
+      status: Status
+      suppress: Suppress follow recommendation
+      suppressed: Suppressed
+      title: Follow recommendations
+      unsuppress: Restore follow recommendation
     instances:
       by_domain: Domain
       delivery_available: Delivery is available
@@ -545,8 +554,10 @@ en:
       updated_at: Updated
     rules:
       add_new: Add rule
-      description: While most claim to have read and agree to the terms of service, usually people do not read through until after a problem arises. Make it easier to see your server's rules at a glance by providing them in a flat bullet point list. Try to keep individual rules short and simple, but try not to split them up into many separate items either.
+      delete: Delete
+      description_html: While most claim to have read and agree to the terms of service, usually people do not read through until after a problem arises. <strong>Make it easier to see your server's rules at a glance by providing them in a flat bullet point list.</strong> Try to keep individual rules short and simple, but try not to split them up into many separate items either.
       edit: Edit rule
+      empty: No server rules have been defined yet.
       title: Server rules
     settings:
       activity_api_enabled:
@@ -627,9 +638,6 @@ en:
         desc_html: You can write your own privacy policy, terms of service or other legalese. You can use HTML tags
         title: Custom terms of service
       site_title: Server name
-      spam_check_enabled:
-        desc_html: Mastodon can auto-report accounts that send repeated unsolicited messages. There may be false positives.
-        title: Anti-spam automation
       thumbnail:
         desc_html: Used for previews via OpenGraph and API. 1200x630px recommended
         title: Server thumbnail
@@ -691,6 +699,7 @@ en:
       add_new: Add new
       delete: Delete
       edit_preset: Edit warning preset
+      empty: You haven't defined any warning presets yet.
       title: Manage warning presets
   admin_mailer:
     new_pending_account:
@@ -1209,8 +1218,6 @@ en:
     relationships: Follows and followers
     two_factor_authentication: Two-factor Auth
     webauthn_authentication: Security keys
-  spam_check:
-    spam_detected: This is an automated report. Spam has been detected.
   statuses:
     attached:
       audio:
diff --git a/config/locales/simple_form.en.yml b/config/locales/simple_form.en.yml
index 97d5b3122..7146adced 100644
--- a/config/locales/simple_form.en.yml
+++ b/config/locales/simple_form.en.yml
@@ -30,19 +30,19 @@ en:
       defaults:
         autofollow: People who sign up through the invite will automatically follow you
         avatar: PNG, GIF or JPG. At most %{size}. Will be downscaled to %{dimensions}px
-        bot: This account mainly performs automated actions and might not be monitored
+        bot: Signal to others that the account mainly performs automated actions and might not be monitored
         context: One or multiple contexts where the filter should apply
         current_password: For security purposes please enter the password of the current account
         current_username: To confirm, please enter the username of the current account
         digest: Only sent after a long period of inactivity and only if you have received any personal messages in your absence
-        discoverable: The profile directory is another way by which your account can reach a wider audience
+        discoverable: Allow your account to be discovered by strangers through recommendations and other features
         email: You will be sent a confirmation e-mail
         fields: You can have up to 4 items displayed as a table on your profile
         header: PNG, GIF or JPG. At most %{size}. Will be downscaled to %{dimensions}px
         inbox_url: Copy the URL from the frontpage of the relay you want to use
         irreversible: Filtered toots will disappear irreversibly, even if filter is later removed
         locale: The language of the user interface, e-mails and push notifications
-        locked: Requires you to manually approve followers
+        locked: Manually control who can follow you by approving follow requests
         password: Use at least 8 characters
         phrase: Will be matched regardless of casing in text or content warning of a toot
         scopes: Which APIs the application will be allowed to access. If you select a top-level scope, you don't need to select individual ones.
@@ -51,7 +51,7 @@ en:
         setting_display_media_default: Hide media marked as sensitive
         setting_display_media_hide_all: Always hide media
         setting_display_media_show_all: Always show media
-        setting_hide_network: Who you follow and who follows you will not be shown on your profile
+        setting_hide_network: Who you follow and who follows you will be hidden on your profile
         setting_noindex: Affects your public profile and status pages
         setting_show_application: The application you use to toot will be displayed in the detailed view of your toots
         setting_use_blurhash: Gradients are based on the colors of the hidden visuals but obfuscate any details
@@ -128,7 +128,7 @@ en:
         context: Filter contexts
         current_password: Current password
         data: Data
-        discoverable: List this account on the directory
+        discoverable: Suggest account to others
         display_name: Display name
         email: E-mail address
         expires_in: Expire after
@@ -138,7 +138,7 @@ en:
         inbox_url: URL of the relay inbox
         irreversible: Drop instead of hide
         locale: Interface language
-        locked: Lock account
+        locked: Require follow requests
         max_uses: Max number of uses
         new_password: New password
         note: Bio
@@ -160,7 +160,7 @@ en:
         setting_display_media_hide_all: Hide all
         setting_display_media_show_all: Show all
         setting_expand_spoilers: Always expand toots marked with content warnings
-        setting_hide_network: Hide your network
+        setting_hide_network: Hide your social graph
         setting_noindex: Opt-out of search engine indexing
         setting_reduce_motion: Reduce motion in animations
         setting_show_application: Disclose application used to send toots
diff --git a/config/navigation.rb b/config/navigation.rb
index 0bb3189c1..c626b09ee 100644
--- a/config/navigation.rb
+++ b/config/navigation.rb
@@ -45,6 +45,7 @@ SimpleNavigation::Configuration.run do |navigation|
       s.item :accounts, safe_join([fa_icon('users fw'), t('admin.accounts.title')]), admin_accounts_url, highlights_on: %r{/admin/accounts|/admin/pending_accounts}
       s.item :invites, safe_join([fa_icon('user-plus fw'), t('admin.invites.title')]), admin_invites_path
       s.item :tags, safe_join([fa_icon('hashtag fw'), t('admin.tags.title')]), admin_tags_path, highlights_on: %r{/admin/tags}
+      s.item :follow_recommendations, safe_join([fa_icon('user-plus fw'), t('admin.follow_recommendations.title')]), admin_follow_recommendations_path, highlights_on: %r{/admin/follow_recommendations}
       s.item :instances, safe_join([fa_icon('cloud fw'), t('admin.instances.title')]), admin_instances_url(limited: whitelist_mode? ? nil : '1'), highlights_on: %r{/admin/instances|/admin/domain_blocks|/admin/domain_allows}, if: -> { current_user.admin? }
       s.item :email_domain_blocks, safe_join([fa_icon('envelope fw'), t('admin.email_domain_blocks.title')]), admin_email_domain_blocks_url, highlights_on: %r{/admin/email_domain_blocks}, if: -> { current_user.admin? }
       s.item :ip_blocks, safe_join([fa_icon('ban fw'), t('admin.ip_blocks.title')]), admin_ip_blocks_url, highlights_on: %r{/admin/ip_blocks}, if: -> { current_user.admin? }
diff --git a/config/routes.rb b/config/routes.rb
index 8ec67113b..73d6c6618 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -3,8 +3,6 @@
 require 'sidekiq_unique_jobs/web'
 require 'sidekiq-scheduler/web'
 
-Sidekiq::Web.set :session_secret, Rails.application.secrets[:secret_key_base]
-
 Rails.application.routes.draw do
   root 'home#index'
 
@@ -296,6 +294,7 @@ Rails.application.routes.draw do
     end
 
     resources :account_moderation_notes, only: [:create, :destroy]
+    resource :follow_recommendations, only: [:show, :update]
 
     resources :tags, only: [:index, :show, :update] do
       collection do
@@ -513,6 +512,7 @@ Rails.application.routes.draw do
     namespace :v2 do
       resources :media, only: [:create]
       get '/search', to: 'search#index', as: :search
+      resources :suggestions, only: [:index]
     end
 
     namespace :web do
diff --git a/config/settings.yml b/config/settings.yml
index 1d9488052..0af1a61a2 100644
--- a/config/settings.yml
+++ b/config/settings.yml
@@ -75,7 +75,6 @@ defaults: &defaults
   show_reblogs_in_public_timelines: false
   show_replies_in_public_timelines: false
   default_content_type: 'text/plain'
-  spam_check_enabled: true
   show_domain_blocks: 'disabled'
   show_domain_blocks_rationale: 'disabled'
   outgoing_spoilers: ''
diff --git a/config/sidekiq.yml b/config/sidekiq.yml
index 010923717..a8e4c7feb 100644
--- a/config/sidekiq.yml
+++ b/config/sidekiq.yml
@@ -25,6 +25,10 @@
     cron: '<%= Random.rand(0..59) %> <%= Random.rand(0..2) %> * * *'
     class: Scheduler::FeedCleanupScheduler
     queue: scheduler
+  follow_recommendations_scheduler:
+    cron: '<%= Random.rand(0..59) %> <%= Random.rand(6..9) %> * * *'
+    class: Scheduler::FollowRecommendationsScheduler
+    queue: scheduler
   doorkeeper_cleanup_scheduler:
     cron: '<%= Random.rand(0..59) %> <%= Random.rand(0..2) %> * * 0'
     class: Scheduler::DoorkeeperCleanupScheduler
diff --git a/db/migrate/20210306164523_account_ids_to_timestamp_ids.rb b/db/migrate/20210306164523_account_ids_to_timestamp_ids.rb
new file mode 100644
index 000000000..39cd4cdea
--- /dev/null
+++ b/db/migrate/20210306164523_account_ids_to_timestamp_ids.rb
@@ -0,0 +1,17 @@
+class AccountIdsToTimestampIds < ActiveRecord::Migration[5.1]
+  def up
+    # Set up the accounts.id column to use our timestamp-based IDs.
+    safety_assured do
+      execute("ALTER TABLE accounts ALTER COLUMN id SET DEFAULT timestamp_id('accounts')")
+    end
+
+    # Make sure we have a sequence to use.
+    Mastodon::Snowflake.ensure_id_sequences_exist
+  end
+
+  def down
+    execute("LOCK accounts")
+    execute("SELECT setval('accounts_id_seq', (SELECT MAX(id) FROM accounts))")
+    execute("ALTER TABLE accounts ALTER COLUMN id SET DEFAULT nextval('accounts_id_seq')")
+  end
+end
diff --git a/db/migrate/20210322164601_create_account_summaries.rb b/db/migrate/20210322164601_create_account_summaries.rb
new file mode 100644
index 000000000..b9faf180d
--- /dev/null
+++ b/db/migrate/20210322164601_create_account_summaries.rb
@@ -0,0 +1,9 @@
+class CreateAccountSummaries < ActiveRecord::Migration[5.2]
+  def change
+    create_view :account_summaries, materialized: true
+
+    # To be able to refresh the view concurrently,
+    # at least one unique index is required
+    safety_assured { add_index :account_summaries, :account_id, unique: true }
+  end
+end
diff --git a/db/migrate/20210323114347_create_follow_recommendations.rb b/db/migrate/20210323114347_create_follow_recommendations.rb
new file mode 100644
index 000000000..77e729032
--- /dev/null
+++ b/db/migrate/20210323114347_create_follow_recommendations.rb
@@ -0,0 +1,5 @@
+class CreateFollowRecommendations < ActiveRecord::Migration[5.2]
+  def change
+    create_view :follow_recommendations
+  end
+end
diff --git a/db/migrate/20210324171613_create_follow_recommendation_suppressions.rb b/db/migrate/20210324171613_create_follow_recommendation_suppressions.rb
new file mode 100644
index 000000000..c17a0be63
--- /dev/null
+++ b/db/migrate/20210324171613_create_follow_recommendation_suppressions.rb
@@ -0,0 +1,9 @@
+class CreateFollowRecommendationSuppressions < ActiveRecord::Migration[6.1]
+  def change
+    create_table :follow_recommendation_suppressions do |t|
+      t.references :account, null: false, foreign_key: { on_delete: :cascade }, index: { unique: true }
+
+      t.timestamps
+    end
+  end
+end
diff --git a/db/migrate/20210416200740_create_canonical_email_blocks.rb b/db/migrate/20210416200740_create_canonical_email_blocks.rb
new file mode 100644
index 000000000..a1f1660bf
--- /dev/null
+++ b/db/migrate/20210416200740_create_canonical_email_blocks.rb
@@ -0,0 +1,10 @@
+class CreateCanonicalEmailBlocks < ActiveRecord::Migration[6.1]
+  def change
+    create_table :canonical_email_blocks do |t|
+      t.string :canonical_email_hash, null: false, default: '', index: { unique: true }
+      t.belongs_to :reference_account, null: false, foreign_key: { on_cascade: :delete, to_table: 'accounts' }
+
+      t.timestamps
+    end
+  end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 4c67353fb..fba4a5758 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -2,15 +2,15 @@
 # of editing this file, please use the migrations feature of Active Record to
 # incrementally modify your database, and then regenerate this schema definition.
 #
-# Note that this schema.rb definition is the authoritative source for your
-# database schema. If you need to create the application database on another
-# system, you should be using db:schema:load, not running all the migrations
-# from scratch. The latter is a flawed and unsustainable approach (the more migrations
-# you'll amass, the slower it'll run and the greater likelihood for issues).
+# This file is the source Rails uses to define your schema when running `bin/rails
+# db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to
+# be faster and is potentially less error prone than running all of your
+# migrations from scratch. Old migrations may fail to apply correctly if those
+# migrations use external dependencies or application code.
 #
 # It's strongly recommended that you check this file into your version control system.
 
-ActiveRecord::Schema.define(version: 2021_03_08_133107) do
+ActiveRecord::Schema.define(version: 2021_04_16_200740) do
 
   # These are extensions that must be enabled in order to support this database
   enable_extension "plpgsql"
@@ -142,7 +142,7 @@ ActiveRecord::Schema.define(version: 2021_03_08_133107) do
     t.index ["target_account_id"], name: "index_account_warnings_on_target_account_id"
   end
 
-  create_table "accounts", force: :cascade do |t|
+  create_table "accounts", id: :bigint, default: -> { "timestamp_id('accounts'::text)" }, force: :cascade do |t|
     t.string "username", default: "", null: false
     t.string "domain"
     t.string "secret", default: "", null: false
@@ -280,6 +280,15 @@ ActiveRecord::Schema.define(version: 2021_03_08_133107) do
     t.index ["status_id"], name: "index_bookmarks_on_status_id"
   end
 
+  create_table "canonical_email_blocks", force: :cascade do |t|
+    t.string "canonical_email_hash", default: "", null: false
+    t.bigint "reference_account_id", null: false
+    t.datetime "created_at", precision: 6, null: false
+    t.datetime "updated_at", precision: 6, null: false
+    t.index ["canonical_email_hash"], name: "index_canonical_email_blocks_on_canonical_email_hash", unique: true
+    t.index ["reference_account_id"], name: "index_canonical_email_blocks_on_reference_account_id"
+  end
+
   create_table "conversation_mutes", force: :cascade do |t|
     t.bigint "conversation_id", null: false
     t.bigint "account_id", null: false
@@ -406,6 +415,13 @@ ActiveRecord::Schema.define(version: 2021_03_08_133107) do
     t.index ["tag_id"], name: "index_featured_tags_on_tag_id"
   end
 
+  create_table "follow_recommendation_suppressions", force: :cascade do |t|
+    t.bigint "account_id", null: false
+    t.datetime "created_at", precision: 6, null: false
+    t.datetime "updated_at", precision: 6, null: false
+    t.index ["account_id"], name: "index_follow_recommendation_suppressions_on_account_id", unique: true
+  end
+
   create_table "follow_requests", force: :cascade do |t|
     t.datetime "created_at", null: false
     t.datetime "updated_at", null: false
@@ -986,6 +1002,7 @@ ActiveRecord::Schema.define(version: 2021_03_08_133107) do
   add_foreign_key "blocks", "accounts", name: "fk_4269e03e65", on_delete: :cascade
   add_foreign_key "bookmarks", "accounts", on_delete: :cascade
   add_foreign_key "bookmarks", "statuses", on_delete: :cascade
+  add_foreign_key "canonical_email_blocks", "accounts", column: "reference_account_id"
   add_foreign_key "conversation_mutes", "accounts", name: "fk_225b4212bb", on_delete: :cascade
   add_foreign_key "conversation_mutes", "conversations", on_delete: :cascade
   add_foreign_key "custom_filters", "accounts", on_delete: :cascade
@@ -998,6 +1015,7 @@ ActiveRecord::Schema.define(version: 2021_03_08_133107) do
   add_foreign_key "favourites", "statuses", name: "fk_b0e856845e", on_delete: :cascade
   add_foreign_key "featured_tags", "accounts", on_delete: :cascade
   add_foreign_key "featured_tags", "tags", on_delete: :cascade
+  add_foreign_key "follow_recommendation_suppressions", "accounts", on_delete: :cascade
   add_foreign_key "follow_requests", "accounts", column: "target_account_id", name: "fk_9291ec025d", on_delete: :cascade
   add_foreign_key "follow_requests", "accounts", name: "fk_76d644b0e7", on_delete: :cascade
   add_foreign_key "follows", "accounts", column: "target_account_id", name: "fk_745ca29eac", on_delete: :cascade
@@ -1081,4 +1099,47 @@ ActiveRecord::Schema.define(version: 2021_03_08_133107) do
   SQL
   add_index "instances", ["domain"], name: "index_instances_on_domain", unique: true
 
+  create_view "account_summaries", materialized: true, sql_definition: <<-SQL
+      SELECT accounts.id AS account_id,
+      mode() WITHIN GROUP (ORDER BY t0.language) AS language,
+      mode() WITHIN GROUP (ORDER BY t0.sensitive) AS sensitive
+     FROM (accounts
+       CROSS JOIN LATERAL ( SELECT statuses.account_id,
+              statuses.language,
+              statuses.sensitive
+             FROM statuses
+            WHERE ((statuses.account_id = accounts.id) AND (statuses.deleted_at IS NULL))
+            ORDER BY statuses.id DESC
+           LIMIT 20) t0)
+    WHERE ((accounts.suspended_at IS NULL) AND (accounts.silenced_at IS NULL) AND (accounts.moved_to_account_id IS NULL) AND (accounts.discoverable = true) AND (accounts.locked = false))
+    GROUP BY accounts.id;
+  SQL
+  add_index "account_summaries", ["account_id"], name: "index_account_summaries_on_account_id", unique: true
+
+  create_view "follow_recommendations", sql_definition: <<-SQL
+      SELECT t0.account_id,
+      sum(t0.rank) AS rank,
+      array_agg(t0.reason) AS reason
+     FROM ( SELECT accounts.id AS account_id,
+              ((count(follows.id))::numeric / (1.0 + (count(follows.id))::numeric)) AS rank,
+              'most_followed'::text AS reason
+             FROM ((follows
+               JOIN accounts ON ((accounts.id = follows.target_account_id)))
+               JOIN users ON ((users.account_id = follows.account_id)))
+            WHERE ((users.current_sign_in_at >= (now() - 'P30D'::interval)) AND (accounts.suspended_at IS NULL) AND (accounts.moved_to_account_id IS NULL) AND (accounts.silenced_at IS NULL) AND (accounts.locked = false) AND (accounts.discoverable = true))
+            GROUP BY accounts.id
+           HAVING (count(follows.id) >= 5)
+          UNION ALL
+           SELECT accounts.id AS account_id,
+              (sum((status_stats.reblogs_count + status_stats.favourites_count)) / (1.0 + sum((status_stats.reblogs_count + status_stats.favourites_count)))) AS rank,
+              'most_interactions'::text AS reason
+             FROM ((status_stats
+               JOIN statuses ON ((statuses.id = status_stats.status_id)))
+               JOIN accounts ON ((accounts.id = statuses.account_id)))
+            WHERE ((statuses.id >= (((date_part('epoch'::text, (now() - 'P30D'::interval)) * (1000)::double precision))::bigint << 16)) AND (accounts.suspended_at IS NULL) AND (accounts.moved_to_account_id IS NULL) AND (accounts.silenced_at IS NULL) AND (accounts.locked = false) AND (accounts.discoverable = true))
+            GROUP BY accounts.id
+           HAVING (sum((status_stats.reblogs_count + status_stats.favourites_count)) >= (5)::numeric)) t0
+    GROUP BY t0.account_id
+    ORDER BY (sum(t0.rank)) DESC;
+  SQL
 end
diff --git a/db/views/account_summaries_v01.sql b/db/views/account_summaries_v01.sql
new file mode 100644
index 000000000..5a632b622
--- /dev/null
+++ b/db/views/account_summaries_v01.sql
@@ -0,0 +1,22 @@
+SELECT
+  accounts.id AS account_id,
+  mode() WITHIN GROUP (ORDER BY language ASC) AS language,
+  mode() WITHIN GROUP (ORDER BY sensitive ASC) AS sensitive
+FROM accounts
+CROSS JOIN LATERAL (
+  SELECT
+    statuses.account_id,
+    statuses.language,
+    statuses.sensitive
+  FROM statuses
+  WHERE statuses.account_id = accounts.id
+    AND statuses.deleted_at IS NULL
+  ORDER BY statuses.id DESC
+  LIMIT 20
+) t0
+WHERE accounts.suspended_at IS NULL
+  AND accounts.silenced_at IS NULL
+  AND accounts.moved_to_account_id IS NULL
+  AND accounts.discoverable = 't'
+  AND accounts.locked = 'f'
+GROUP BY accounts.id
diff --git a/db/views/follow_recommendations_v01.sql b/db/views/follow_recommendations_v01.sql
new file mode 100644
index 000000000..799abeaee
--- /dev/null
+++ b/db/views/follow_recommendations_v01.sql
@@ -0,0 +1,38 @@
+SELECT
+  account_id,
+  sum(rank) AS rank,
+  array_agg(reason) AS reason
+FROM (
+  SELECT
+    accounts.id AS account_id,
+    count(follows.id) / (1.0 + count(follows.id)) AS rank,
+    'most_followed' AS reason
+  FROM follows
+  INNER JOIN accounts ON accounts.id = follows.target_account_id
+  INNER JOIN users ON users.account_id = follows.account_id
+  WHERE users.current_sign_in_at >= (now() - interval '30 days')
+    AND accounts.suspended_at IS NULL
+    AND accounts.moved_to_account_id IS NULL
+    AND accounts.silenced_at IS NULL
+    AND accounts.locked = 'f'
+    AND accounts.discoverable = 't'
+  GROUP BY accounts.id
+  HAVING count(follows.id) >= 5
+  UNION ALL
+  SELECT accounts.id AS account_id,
+         sum(reblogs_count + favourites_count) / (1.0 + sum(reblogs_count + favourites_count)) AS rank,
+         'most_interactions' AS reason
+  FROM status_stats
+  INNER JOIN statuses ON statuses.id = status_stats.status_id
+  INNER JOIN accounts ON accounts.id = statuses.account_id
+  WHERE statuses.id >= ((date_part('epoch', now() - interval '30 days') * 1000)::bigint << 16)
+    AND accounts.suspended_at IS NULL
+    AND accounts.moved_to_account_id IS NULL
+    AND accounts.silenced_at IS NULL
+    AND accounts.locked = 'f'
+    AND accounts.discoverable = 't'
+  GROUP BY accounts.id
+  HAVING sum(reblogs_count + favourites_count) >= 5
+) t0
+GROUP BY account_id
+ORDER BY rank DESC
diff --git a/lib/active_record/batches.rb b/lib/active_record/batches.rb
new file mode 100644
index 000000000..55d29e52e
--- /dev/null
+++ b/lib/active_record/batches.rb
@@ -0,0 +1,44 @@
+# frozen_string_literal: true
+
+module ActiveRecord
+  module Batches
+    def pluck_each(*column_names)
+      relation = self
+
+      options = column_names.extract_options!
+
+      flatten     = column_names.size == 1
+      batch_limit = options[:batch_limit] || 1_000
+      order       = options[:order] || :asc
+
+      column_names.unshift(primary_key)
+
+      relation = relation.reorder(batch_order(order)).limit(batch_limit)
+      relation.skip_query_cache!
+
+      batch_relation = relation
+
+      loop do
+        batch = batch_relation.pluck(*column_names)
+
+        break if batch.empty?
+
+        primary_key_offset = batch.last[0]
+
+        batch.each do |record|
+          if flatten
+            yield record[1]
+          else
+            yield record[1..-1]
+          end
+        end
+
+        break if batch.size < batch_limit
+
+        batch_relation = relation.where(
+          predicate_builder[primary_key, primary_key_offset, order == :desc ? :lt : :gt]
+        )
+      end
+    end
+  end
+end
diff --git a/lib/tasks/emojis.rake b/lib/tasks/emojis.rake
index c8655cc47..a373e7652 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 c5855f6af..426adbc1d 100644
--- a/package.json
+++ b/package.json
@@ -60,12 +60,12 @@
   },
   "private": true,
   "dependencies": {
-    "@babel/core": "^7.13.14",
+    "@babel/core": "^7.13.15",
     "@babel/plugin-proposal-class-properties": "^7.8.3",
-    "@babel/plugin-proposal-decorators": "^7.13.5",
+    "@babel/plugin-proposal-decorators": "^7.13.15",
     "@babel/plugin-transform-react-inline-elements": "^7.12.13",
-    "@babel/plugin-transform-runtime": "^7.13.10",
-    "@babel/preset-env": "^7.13.12",
+    "@babel/plugin-transform-runtime": "^7.13.15",
+    "@babel/preset-env": "^7.13.15",
     "@babel/preset-react": "^7.13.13",
     "@babel/runtime": "^7.13.10",
     "@gamestdio/websocket": "^0.3.2",
@@ -83,12 +83,12 @@
     "babel-plugin-transform-react-remove-prop-types": "^0.4.24",
     "babel-runtime": "^6.26.0",
     "blurhash": "^1.1.3",
-    "classnames": "^2.2.5",
+    "classnames": "^2.3.1",
     "color-blend": "^3.0.1",
     "compression-webpack-plugin": "^6.1.1",
     "cross-env": "^7.0.3",
-    "css-loader": "^5.2.0",
-    "cssnano": "^4.1.10",
+    "css-loader": "^5.2.2",
+    "cssnano": "^4.1.11",
     "detect-passive-events": "^2.0.3",
     "dotenv": "^8.2.0",
     "emoji-mart": "Gargron/emoji-mart#build",
@@ -109,11 +109,11 @@
     "intl-messageformat": "^2.2.0",
     "intl-relativeformat": "^6.4.3",
     "is-nan": "^1.3.2",
-    "js-yaml": "^4.0.0",
+    "js-yaml": "^4.1.0",
     "lodash": "^4.17.21",
     "mark-loader": "^0.1.6",
     "marky": "^1.2.1",
-    "mini-css-extract-plugin": "^1.4.0",
+    "mini-css-extract-plugin": "^1.5.0",
     "mkdirp": "^1.0.4",
     "npmlog": "^4.1.2",
     "object-assign": "^4.1.1",
@@ -146,7 +146,7 @@
     "react-swipeable-views": "^0.13.9",
     "react-textarea-autosize": "^8.3.2",
     "react-toggle": "^4.1.2",
-    "redis": "^3.0.2",
+    "redis": "^3.1.1",
     "redux": "^4.0.5",
     "redux-immutable": "^4.0.0",
     "redux-thunk": "^2.2.0",
@@ -155,7 +155,7 @@
     "requestidlecallback": "^0.3.0",
     "reselect": "^4.0.0",
     "rimraf": "^3.0.2",
-    "sass": "^1.32.8",
+    "sass": "^1.32.10",
     "sass-loader": "^10.1.1",
     "stacktrace-js": "^2.0.2",
     "stringz": "^2.1.0",
@@ -167,23 +167,23 @@
     "twitter-text": "3.1.0",
     "uuid": "^8.3.1",
     "webpack": "^4.46.0",
-    "webpack-assets-manifest": "^4.0.2",
-    "webpack-bundle-analyzer": "^4.4.0",
+    "webpack-assets-manifest": "^4.0.5",
+    "webpack-bundle-analyzer": "^4.4.1",
     "webpack-cli": "^3.3.12",
     "webpack-merge": "^5.7.3",
     "wicg-inert": "^3.1.1",
-    "ws": "^7.4.4"
+    "ws": "^7.4.5"
   },
   "devDependencies": {
     "@testing-library/jest-dom": "^5.11.10",
     "@testing-library/react": "^11.2.6",
     "babel-eslint": "^10.1.0",
     "babel-jest": "^26.6.3",
-    "eslint": "^7.23.0",
+    "eslint": "^7.24.0",
     "eslint-plugin-import": "~2.22.1",
     "eslint-plugin-jsx-a11y": "~6.4.1",
-    "eslint-plugin-promise": "~4.3.1",
-    "eslint-plugin-react": "~7.23.1",
+    "eslint-plugin-promise": "~5.1.0",
+    "eslint-plugin-react": "~7.23.2",
     "jest": "^26.6.3",
     "raf": "^3.4.1",
     "react-intl-translations-manager": "^5.0.3",
diff --git a/public/emoji/1f6b2_border.svg b/public/emoji/1f6b2_border.svg
new file mode 100644
index 000000000..0219841a1
--- /dev/null
+++ b/public/emoji/1f6b2_border.svg
@@ -0,0 +1,19 @@
+<?xml version="1.0"?>
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="-2 -2 40 40">
+  <g>
+    <path d="M7 24c1.957 0 3.633 1.135 4.455 2.772l3.477-1.739C13.488 22.058 10.446 20 6.916 20c-1.301 0-2.534.285-3.649.787l1.668 3.67C5.566 24.17 6.262 24 7 24zm22 0c1.467 0 2.772.643 3.688 1.648l2.897-2.635C33.952 21.169 31.573 20 28.916 20c-3.576 0-6.652 2.111-8.073 5.15l3.648 1.722C25.293 25.18 27.003 24 29 24z" stroke="white" stroke-linejoin="round" stroke-width="4px"/>
+    <path d="M7 22c-3.866 0-7 3.134-7 7s3.134 7 7 7 7-3.134 7-7-3.133-7-7-7zm0 12c-2.761 0-5-2.238-5-5s2.239-5 5-5 5 2.238 5 5-2.238 5-5 5zm22-12c-3.865 0-7 3.134-7 7s3.135 7 7 7c3.867 0 7-3.134 7-7s-3.133-7-7-7zm0 12c-2.761 0-5-2.238-5-5s2.239-5 5-5c2.762 0 5 2.238 5 5s-2.238 5-5 5z" stroke="white" stroke-linejoin="round" stroke-width="4px"/>
+    <path d="M29.984 28.922c-.005-.067-.021-.132-.04-.198-.019-.065-.04-.126-.071-.186-.013-.024-.015-.052-.029-.075l-7-11c-.297-.466-.914-.604-1.381-.307-.299.19-.444.513-.445.843H12c-.552 0-1 .447-1 1 0 .553.448 1 1 1h10c.027 0 .05-.014.077-.016L27.178 28H18c-.552 0-1 .447-1 1s.448 1 1 1h11.001c.116 0 .23-.028.343-.069.034-.013.064-.027.097-.043.031-.017.066-.024.097-.044.03-.02.048-.051.075-.072.055-.044.103-.089.147-.143.041-.049.074-.099.104-.154.03-.056.055-.11.075-.172.021-.066.033-.132.04-.201.004-.036.021-.066.021-.102 0-.027-.014-.051-.016-.078z" stroke="white" stroke-linejoin="round" stroke-width="4px"/>
+    <path d="M21.581 16l-2.899 8.117-5.929-6.775c-.364-.415-.996-.459-1.411-.094-.415.364-.457.995-.094 1.411l6.664 7.615-.854 2.39c-.185.519.086 1.092.606 1.277.111.04.224.059.336.059.411 0 .796-.255.942-.664L23.705 16h-2.124z" stroke="white" stroke-linejoin="round" stroke-width="4px"/>
+    <path d="M7 30c-.15 0-.303-.034-.446-.105-.494-.247-.694-.848-.447-1.342l3.062-6.106C9.186 22.419 11 19.651 11 17c0-3.242-2.293-4.043-2.316-4.051-.524-.175-.807-.741-.632-1.265.174-.524.739-.81 1.265-.632C9.467 11.102 13 12.333 13 17c0 3.068-1.836 6.042-2.131 6.497l-2.974 5.949C7.72 29.798 7.367 30 7 30z" stroke="white" stroke-linejoin="round" stroke-width="4px"/>
+    <path d="M14.612 13.663c-.054 0-.11-.004-.165-.014l-6-1c-.544-.091-.913-.606-.822-1.151.091-.544.601-.913 1.151-.822l6 1c.544.091.913.606.822 1.151-.082.489-.506.836-.986.836zM26.383 17c-.03 0-.059-.002-.089-.006l-5.672-.708c-.372-.046-.644-.374-.62-.748.023-.374.333-.665.707-.665.041 0 4.067-.018 5.989-1.299.25-.167.582-.157.824.026.239.185.337.501.241.788l-.709 2.127c-.096.293-.369.485-.671.485z" stroke="white" stroke-linejoin="round" stroke-width="4px"/>
+    <path d="M20 29c0 1.104-.895 2-2 2-1.104 0-2-.896-2-2s.896-2 2-2c1.105 0 2 .896 2 2z" stroke="white" stroke-linejoin="round" stroke-width="4px"/>
+  </g>
+  <path fill="#EA596E" d="M7 24c1.957 0 3.633 1.135 4.455 2.772l3.477-1.739C13.488 22.058 10.446 20 6.916 20c-1.301 0-2.534.285-3.649.787l1.668 3.67C5.566 24.17 6.262 24 7 24zm22 0c1.467 0 2.772.643 3.688 1.648l2.897-2.635C33.952 21.169 31.573 20 28.916 20c-3.576 0-6.652 2.111-8.073 5.15l3.648 1.722C25.293 25.18 27.003 24 29 24z"/>
+  <path fill="#292F33" d="M7 22c-3.866 0-7 3.134-7 7s3.134 7 7 7 7-3.134 7-7-3.133-7-7-7zm0 12c-2.761 0-5-2.238-5-5s2.239-5 5-5 5 2.238 5 5-2.238 5-5 5zm22-12c-3.865 0-7 3.134-7 7s3.135 7 7 7c3.867 0 7-3.134 7-7s-3.133-7-7-7zm0 12c-2.761 0-5-2.238-5-5s2.239-5 5-5c2.762 0 5 2.238 5 5s-2.238 5-5 5z"/>
+  <path fill="#DD2E44" d="M29.984 28.922c-.005-.067-.021-.132-.04-.198-.019-.065-.04-.126-.071-.186-.013-.024-.015-.052-.029-.075l-7-11c-.297-.466-.914-.604-1.381-.307-.299.19-.444.513-.445.843H12c-.552 0-1 .447-1 1 0 .553.448 1 1 1h10c.027 0 .05-.014.077-.016L27.178 28H18c-.552 0-1 .447-1 1s.448 1 1 1h11.001c.116 0 .23-.028.343-.069.034-.013.064-.027.097-.043.031-.017.066-.024.097-.044.03-.02.048-.051.075-.072.055-.044.103-.089.147-.143.041-.049.074-.099.104-.154.03-.056.055-.11.075-.172.021-.066.033-.132.04-.201.004-.036.021-.066.021-.102 0-.027-.014-.051-.016-.078z"/>
+  <path fill="#DD2E44" d="M21.581 16l-2.899 8.117-5.929-6.775c-.364-.415-.996-.459-1.411-.094-.415.364-.457.995-.094 1.411l6.664 7.615-.854 2.39c-.185.519.086 1.092.606 1.277.111.04.224.059.336.059.411 0 .796-.255.942-.664L23.705 16h-2.124z"/>
+  <path fill="#DD2E44" d="M7 30c-.15 0-.303-.034-.446-.105-.494-.247-.694-.848-.447-1.342l3.062-6.106C9.186 22.419 11 19.651 11 17c0-3.242-2.293-4.043-2.316-4.051-.524-.175-.807-.741-.632-1.265.174-.524.739-.81 1.265-.632C9.467 11.102 13 12.333 13 17c0 3.068-1.836 6.042-2.131 6.497l-2.974 5.949C7.72 29.798 7.367 30 7 30z"/>
+  <path fill="#292F33" d="M14.612 13.663c-.054 0-.11-.004-.165-.014l-6-1c-.544-.091-.913-.606-.822-1.151.091-.544.601-.913 1.151-.822l6 1c.544.091.913.606.822 1.151-.082.489-.506.836-.986.836zM26.383 17c-.03 0-.059-.002-.089-.006l-5.672-.708c-.372-.046-.644-.374-.62-.748.023-.374.333-.665.707-.665.041 0 4.067-.018 5.989-1.299.25-.167.582-.157.824.026.239.185.337.501.241.788l-.709 2.127c-.096.293-.369.485-.671.485z"/>
+  <path fill="#66757F" d="M20 29c0 1.104-.895 2-2 2-1.104 0-2-.896-2-2s.896-2 2-2c1.105 0 2 .896 2 2z"/>
+</svg>
diff --git a/spec/controllers/api/v1/apps_controller_spec.rb b/spec/controllers/api/v1/apps_controller_spec.rb
index 60a4c3b41..70cd62d48 100644
--- a/spec/controllers/api/v1/apps_controller_spec.rb
+++ b/spec/controllers/api/v1/apps_controller_spec.rb
@@ -4,23 +4,83 @@ RSpec.describe Api::V1::AppsController, type: :controller do
   render_views
 
   describe 'POST #create' do
+    let(:client_name) { 'Test app' }
+    let(:scopes) { nil }
+    let(:redirect_uris) { 'urn:ietf:wg:oauth:2.0:oob' }
+    let(:website) { nil }
+
+    let(:app_params) do
+      {
+        client_name: client_name,
+        redirect_uris: redirect_uris,
+        scopes: scopes,
+        website: website,
+      }
+    end
+
     before do
-      post :create, params: { client_name: 'Test app', redirect_uris: 'urn:ietf:wg:oauth:2.0:oob' }
+      post :create, params: app_params
     end
 
-    it 'returns http success' do
-      expect(response).to have_http_status(200)
+    context 'with valid params' do
+      it 'returns http success' do
+        expect(response).to have_http_status(200)
+      end
+
+      it 'creates an OAuth app' do
+        expect(Doorkeeper::Application.find_by(name: client_name)).to_not be nil
+      end
+
+      it 'returns client ID and client secret' do
+        json = body_as_json
+
+        expect(json[:client_id]).to_not be_blank
+        expect(json[:client_secret]).to_not be_blank
+      end
+    end
+
+    context 'with an unsupported scope' do
+      let(:scopes) { 'hoge' }
+
+      it 'returns http unprocessable entity' do
+        expect(response).to have_http_status(422)
+      end
     end
 
-    it 'creates an OAuth app' do
-      expect(Doorkeeper::Application.find_by(name: 'Test app')).to_not be nil
+    context 'with many duplicate scopes' do
+      let(:scopes) { (%w(read) * 40).join(' ') }
+
+      it 'returns http success' do
+        expect(response).to have_http_status(200)
+      end
+
+      it 'only saves the scope once' do
+        expect(Doorkeeper::Application.find_by(name: client_name).scopes.to_s).to eq 'read'
+      end
+    end
+
+    context 'with a too-long name' do
+      let(:client_name) { 'hoge' * 20 }
+
+      it 'returns http unprocessable entity' do
+        expect(response).to have_http_status(422)
+      end
+    end
+
+    context 'with a too-long website' do
+      let(:website) { 'https://foo.bar/' + ('hoge' * 2_000) }
+
+      it 'returns http unprocessable entity' do
+        expect(response).to have_http_status(422)
+      end
     end
 
-    it 'returns client ID and client secret' do
-      json = body_as_json
+    context 'with a too-long redirect_uris' do
+      let(:redirect_uris) { 'https://foo.bar/' + ('hoge' * 2_000) }
 
-      expect(json[:client_id]).to_not be_blank
-      expect(json[:client_secret]).to_not be_blank
+      it 'returns http unprocessable entity' do
+        expect(response).to have_http_status(422)
+      end
     end
   end
 end
diff --git a/spec/controllers/api/v1/push/subscriptions_controller_spec.rb b/spec/controllers/api/v1/push/subscriptions_controller_spec.rb
index 01146294f..534d02879 100644
--- a/spec/controllers/api/v1/push/subscriptions_controller_spec.rb
+++ b/spec/controllers/api/v1/push/subscriptions_controller_spec.rb
@@ -27,20 +27,27 @@ describe Api::V1::Push::SubscriptionsController do
   let(:alerts_payload) do
     {
       data: {
+        policy: 'all',
+
         alerts: {
           follow: true,
+          follow_request: true,
           favourite: false,
           reblog: true,
           mention: false,
+          poll: true,
+          status: false,
         }
       }
     }.with_indifferent_access
   end
 
   describe 'POST #create' do
-    it 'saves push subscriptions' do
+    before do
       post :create, params: create_payload
+    end
 
+    it 'saves push subscriptions' do
       push_subscription = Web::PushSubscription.find_by(endpoint: create_payload[:subscription][:endpoint])
 
       expect(push_subscription.endpoint).to eq(create_payload[:subscription][:endpoint])
@@ -52,31 +59,34 @@ describe Api::V1::Push::SubscriptionsController do
 
     it 'replaces old subscription on repeat calls' do
       post :create, params: create_payload
-      post :create, params: create_payload
-
       expect(Web::PushSubscription.where(endpoint: create_payload[:subscription][:endpoint]).count).to eq 1
     end
   end
 
   describe 'PUT #update' do
-    it 'changes alert settings' do
+    before do
       post :create, params: create_payload
       put :update, params: alerts_payload
+    end
 
+    it 'changes alert settings' do
       push_subscription = Web::PushSubscription.find_by(endpoint: create_payload[:subscription][:endpoint])
 
-      expect(push_subscription.data.dig('alerts', 'follow')).to eq(alerts_payload[:data][:alerts][:follow].to_s)
-      expect(push_subscription.data.dig('alerts', 'favourite')).to eq(alerts_payload[:data][:alerts][:favourite].to_s)
-      expect(push_subscription.data.dig('alerts', 'reblog')).to eq(alerts_payload[:data][:alerts][:reblog].to_s)
-      expect(push_subscription.data.dig('alerts', 'mention')).to eq(alerts_payload[:data][:alerts][:mention].to_s)
+      expect(push_subscription.data['policy']).to eq(alerts_payload[:data][:policy])
+
+      %w(follow follow_request favourite reblog mention poll status).each do |type|
+        expect(push_subscription.data['alerts'][type]).to eq(alerts_payload[:data][:alerts][type.to_sym].to_s)
+      end
     end
   end
 
   describe 'DELETE #destroy' do
-    it 'removes the subscription' do
+    before do
       post :create, params: create_payload
       delete :destroy
+    end
 
+    it 'removes the subscription' do
       expect(Web::PushSubscription.find_by(endpoint: create_payload[:subscription][:endpoint])).to be_nil
     end
   end
diff --git a/spec/controllers/api/web/push_subscriptions_controller_spec.rb b/spec/controllers/api/web/push_subscriptions_controller_spec.rb
index 381cdeab9..bda4a7661 100644
--- a/spec/controllers/api/web/push_subscriptions_controller_spec.rb
+++ b/spec/controllers/api/web/push_subscriptions_controller_spec.rb
@@ -22,11 +22,16 @@ describe Api::Web::PushSubscriptionsController do
   let(:alerts_payload) do
     {
       data: {
+        policy: 'all',
+
         alerts: {
           follow: true,
+          follow_request: false,
           favourite: false,
           reblog: true,
           mention: false,
+          poll: true,
+          status: false,
         }
       }
     }
@@ -59,10 +64,11 @@ describe Api::Web::PushSubscriptionsController do
 
         push_subscription = Web::PushSubscription.find_by(endpoint: create_payload[:subscription][:endpoint])
 
-        expect(push_subscription.data['alerts']['follow']).to eq(alerts_payload[:data][:alerts][:follow].to_s)
-        expect(push_subscription.data['alerts']['favourite']).to eq(alerts_payload[:data][:alerts][:favourite].to_s)
-        expect(push_subscription.data['alerts']['reblog']).to eq(alerts_payload[:data][:alerts][:reblog].to_s)
-        expect(push_subscription.data['alerts']['mention']).to eq(alerts_payload[:data][:alerts][:mention].to_s)
+        expect(push_subscription.data['policy']).to eq 'all'
+
+        %w(follow follow_request favourite reblog mention poll status).each do |type|
+          expect(push_subscription.data['alerts'][type]).to eq(alerts_payload[:data][:alerts][type.to_sym].to_s)
+        end
       end
     end
   end
@@ -81,10 +87,11 @@ describe Api::Web::PushSubscriptionsController do
 
       push_subscription = Web::PushSubscription.find_by(endpoint: create_payload[:subscription][:endpoint])
 
-      expect(push_subscription.data['alerts']['follow']).to eq(alerts_payload[:data][:alerts][:follow].to_s)
-      expect(push_subscription.data['alerts']['favourite']).to eq(alerts_payload[:data][:alerts][:favourite].to_s)
-      expect(push_subscription.data['alerts']['reblog']).to eq(alerts_payload[:data][:alerts][:reblog].to_s)
-      expect(push_subscription.data['alerts']['mention']).to eq(alerts_payload[:data][:alerts][:mention].to_s)
+      expect(push_subscription.data['policy']).to eq 'all'
+
+      %w(follow follow_request favourite reblog mention poll status).each do |type|
+        expect(push_subscription.data['alerts'][type]).to eq(alerts_payload[:data][:alerts][type.to_sym].to_s)
+      end
     end
   end
 end
diff --git a/spec/fabricators/canonical_email_block_fabricator.rb b/spec/fabricators/canonical_email_block_fabricator.rb
new file mode 100644
index 000000000..a0b6e0d22
--- /dev/null
+++ b/spec/fabricators/canonical_email_block_fabricator.rb
@@ -0,0 +1,4 @@
+Fabricator(:canonical_email_block) do
+  email "test@example.com"
+  reference_account { Fabricate(:account) }
+end
diff --git a/spec/fabricators/follow_recommendation_suppression_fabricator.rb b/spec/fabricators/follow_recommendation_suppression_fabricator.rb
new file mode 100644
index 000000000..4a6a07a66
--- /dev/null
+++ b/spec/fabricators/follow_recommendation_suppression_fabricator.rb
@@ -0,0 +1,3 @@
+Fabricator(:follow_recommendation_suppression) do
+  account
+end
diff --git a/spec/lib/spam_check_spec.rb b/spec/lib/spam_check_spec.rb
deleted file mode 100644
index 159d83257..000000000
--- a/spec/lib/spam_check_spec.rb
+++ /dev/null
@@ -1,192 +0,0 @@
-# frozen_string_literal: true
-
-require 'rails_helper'
-
-RSpec.describe SpamCheck do
-  let!(:sender) { Fabricate(:account) }
-  let!(:alice) { Fabricate(:account, username: 'alice') }
-  let!(:bob) { Fabricate(:account, username: 'bob') }
-
-  def status_with_html(text, options = {})
-    status = PostStatusService.new.call(sender, { text: text }.merge(options))
-    status.update_columns(text: Formatter.instance.format(status), local: false)
-    status
-  end
-
-  describe '#hashable_text' do
-    it 'removes mentions from HTML for remote statuses' do
-      status = status_with_html('@alice Hello')
-      expect(described_class.new(status).hashable_text).to eq 'hello'
-    end
-
-    it 'removes mentions from text for local statuses' do
-      status = PostStatusService.new.call(alice, text: "Hey @#{sender.username}, how are you?")
-      expect(described_class.new(status).hashable_text).to eq 'hey , how are you?'
-    end
-  end
-
-  describe '#insufficient_data?' do
-    it 'returns true when there is no text' do
-      status = status_with_html('@alice')
-      expect(described_class.new(status).insufficient_data?).to be true
-    end
-
-    it 'returns false when there is text' do
-      status = status_with_html('@alice h')
-      expect(described_class.new(status).insufficient_data?).to be false
-    end
-  end
-
-  describe '#digest' do
-    it 'returns a string' do
-      status = status_with_html('@alice Hello world')
-      expect(described_class.new(status).digest).to be_a String
-    end
-  end
-
-  describe '#spam?' do
-    it 'returns false for a unique status' do
-      status = status_with_html('@alice Hello')
-      expect(described_class.new(status).spam?).to be false
-    end
-
-    it 'returns false for different statuses to the same recipient' do
-      status1 = status_with_html('@alice Hello')
-      described_class.new(status1).remember!
-      status2 = status_with_html('@alice Are you available to talk?')
-      expect(described_class.new(status2).spam?).to be false
-    end
-
-    it 'returns false for statuses with different content warnings' do
-      status1 = status_with_html('@alice Are you available to talk?')
-      described_class.new(status1).remember!
-      status2 = status_with_html('@alice Are you available to talk?', spoiler_text: 'This is a completely different matter than what I was talking about previously, I swear!')
-      expect(described_class.new(status2).spam?).to be false
-    end
-
-    it 'returns false for different statuses to different recipients' do
-      status1 = status_with_html('@alice How is it going?')
-      described_class.new(status1).remember!
-      status2 = status_with_html('@bob Are you okay?')
-      expect(described_class.new(status2).spam?).to be false
-    end
-
-    it 'returns false for very short different statuses to different recipients' do
-      status1 = status_with_html('@alice 🙄')
-      described_class.new(status1).remember!
-      status2 = status_with_html('@bob Huh?')
-      expect(described_class.new(status2).spam?).to be false
-    end
-
-    it 'returns false for statuses with no text' do
-      status1 = status_with_html('@alice')
-      described_class.new(status1).remember!
-      status2 = status_with_html('@bob')
-      expect(described_class.new(status2).spam?).to be false
-    end
-
-    it 'returns true for duplicate statuses to the same recipient' do
-      described_class::THRESHOLD.times do
-        status1 = status_with_html('@alice Hello')
-        described_class.new(status1).remember!
-      end
-
-      status2 = status_with_html('@alice Hello')
-      expect(described_class.new(status2).spam?).to be true
-    end
-
-    it 'returns true for duplicate statuses to different recipients' do
-      described_class::THRESHOLD.times do
-        status1 = status_with_html('@alice Hello')
-        described_class.new(status1).remember!
-      end
-
-      status2 = status_with_html('@bob Hello')
-      expect(described_class.new(status2).spam?).to be true
-    end
-
-    it 'returns true for nearly identical statuses with random numbers' do
-      source_text = 'Sodium, atomic number 11, was first isolated by Humphry Davy in 1807. A chemical component of salt, he named it Na in honor of the saltiest region on earth, North America.'
-
-      described_class::THRESHOLD.times do
-        status1 = status_with_html('@alice ' + source_text + ' 1234')
-        described_class.new(status1).remember!
-      end
-
-      status2 = status_with_html('@bob ' + source_text + ' 9568')
-      expect(described_class.new(status2).spam?).to be true
-    end
-  end
-
-  describe '#skip?' do
-    it 'returns true when the sender is already silenced' do
-      status = status_with_html('@alice Hello')
-      sender.silence!
-      expect(described_class.new(status).skip?).to be true
-    end
-
-    it 'returns true when the mentioned person follows the sender' do
-      status = status_with_html('@alice Hello')
-      alice.follow!(sender)
-      expect(described_class.new(status).skip?).to be true
-    end
-
-    it 'returns false when even one mentioned person doesn\'t follow the sender' do
-      status = status_with_html('@alice @bob Hello')
-      alice.follow!(sender)
-      expect(described_class.new(status).skip?).to be false
-    end
-
-    it 'returns true when the sender is replying to a status that mentions the sender' do
-      parent = PostStatusService.new.call(alice, text: "Hey @#{sender.username}, how are you?")
-      status = status_with_html('@alice @bob Hello', thread: parent)
-      expect(described_class.new(status).skip?).to be true
-    end
-  end
-
-  describe '#remember!' do
-    let(:status) { status_with_html('@alice') }
-    let(:spam_check) { described_class.new(status) }
-    let(:redis_key) { spam_check.send(:redis_key) }
-
-    it 'remembers' do
-      expect(Redis.current.exists?(redis_key)).to be true
-      spam_check.remember!
-      expect(Redis.current.exists?(redis_key)).to be true
-    end
-  end
-
-  describe '#reset!' do
-    let(:status) { status_with_html('@alice') }
-    let(:spam_check) { described_class.new(status) }
-    let(:redis_key) { spam_check.send(:redis_key) }
-
-    before do
-      spam_check.remember!
-    end
-
-    it 'resets' do
-      expect(Redis.current.exists?(redis_key)).to be true
-      spam_check.reset!
-      expect(Redis.current.exists?(redis_key)).to be false
-    end
-  end
-
-  describe '#flag!' do
-    let!(:status1) { status_with_html('@alice General Kenobi you are a bold one') }
-    let!(:status2) { status_with_html('@alice @bob General Kenobi, you are a bold one') }
-
-    before do
-      described_class.new(status1).remember!
-      described_class.new(status2).flag!
-    end
-
-    it 'creates a report about the account' do
-      expect(sender.targeted_reports.unresolved.count).to eq 1
-    end
-
-    it 'attaches both matching statuses to the report' do
-      expect(sender.targeted_reports.first.status_ids).to include(status1.id, status2.id)
-    end
-  end
-end
diff --git a/spec/lib/tag_manager_spec.rb b/spec/lib/tag_manager_spec.rb
index e9a7aa934..2230f9710 100644
--- a/spec/lib/tag_manager_spec.rb
+++ b/spec/lib/tag_manager_spec.rb
@@ -83,40 +83,4 @@ RSpec.describe TagManager do
       expect(TagManager.instance.local_url?('https://domainn.test/')).to eq false
     end
   end
-
-  describe '#same_acct?' do
-    # The following comparisons MUST be case-insensitive.
-
-    it 'returns true if the needle has a correct username and domain for remote user' do
-      expect(TagManager.instance.same_acct?('username@domain.test', 'UsErNaMe@DoMaIn.Test')).to eq true
-    end
-
-    it 'returns false if the needle is missing a domain for remote user' do
-      expect(TagManager.instance.same_acct?('username@domain.test', 'UsErNaMe')).to eq false
-    end
-
-    it 'returns false if the needle has an incorrect domain for remote user' do
-      expect(TagManager.instance.same_acct?('username@domain.test', 'UsErNaMe@incorrect.test')).to eq false
-    end
-
-    it 'returns false if the needle has an incorrect username for remote user' do
-      expect(TagManager.instance.same_acct?('username@domain.test', 'incorrect@DoMaIn.test')).to eq false
-    end
-
-    it 'returns true if the needle has a correct username and domain for local user' do
-      expect(TagManager.instance.same_acct?('username', 'UsErNaMe@Cb6E6126.nGrOk.Io')).to eq true
-    end
-
-    it 'returns true if the needle is missing a domain for local user' do
-      expect(TagManager.instance.same_acct?('username', 'UsErNaMe')).to eq true
-    end
-
-    it 'returns false if the needle has an incorrect username for local user' do
-      expect(TagManager.instance.same_acct?('username', 'UsErNaM@Cb6E6126.nGrOk.Io')).to eq false
-    end
-
-    it 'returns false if the needle has an incorrect domain for local user' do
-      expect(TagManager.instance.same_acct?('username', 'incorrect@Cb6E6126.nGrOk.Io')).to eq false
-    end
-  end
 end
diff --git a/spec/models/canonical_email_block_spec.rb b/spec/models/canonical_email_block_spec.rb
new file mode 100644
index 000000000..8e0050d65
--- /dev/null
+++ b/spec/models/canonical_email_block_spec.rb
@@ -0,0 +1,47 @@
+require 'rails_helper'
+
+RSpec.describe CanonicalEmailBlock, type: :model do
+  describe '#email=' do
+    let(:target_hash) { '973dfe463ec85785f5f95af5ba3906eedb2d931c24e69824a89ea65dba4e813b' }
+
+    it 'sets canonical_email_hash' do
+      subject.email = 'test@example.com'
+      expect(subject.canonical_email_hash).to eq target_hash
+    end
+
+    it 'sets the same hash even with dot permutations' do
+      subject.email = 't.e.s.t@example.com'
+      expect(subject.canonical_email_hash).to eq target_hash
+    end
+
+    it 'sets the same hash even with extensions' do
+      subject.email = 'test+mastodon1@example.com'
+      expect(subject.canonical_email_hash).to eq target_hash
+    end
+
+    it 'sets the same hash with different casing' do
+      subject.email = 'Test@EXAMPLE.com'
+      expect(subject.canonical_email_hash).to eq target_hash
+    end
+  end
+
+  describe '.block?' do
+    let!(:canonical_email_block) { Fabricate(:canonical_email_block, email: 'foo@bar.com') }
+
+    it 'returns true for the same email' do
+      expect(described_class.block?('foo@bar.com')).to be true
+    end
+
+    it 'returns true for the same email with dots' do
+      expect(described_class.block?('f.oo@bar.com')).to be true
+    end
+
+    it 'returns true for the same email with extensions' do
+      expect(described_class.block?('foo+spam@bar.com')).to be true
+    end
+
+    it 'returns false for different email' do
+      expect(described_class.block?('hoge@bar.com')).to be false
+    end
+  end
+end
diff --git a/spec/models/follow_recommendation_suppression_spec.rb b/spec/models/follow_recommendation_suppression_spec.rb
new file mode 100644
index 000000000..39107a2b0
--- /dev/null
+++ b/spec/models/follow_recommendation_suppression_spec.rb
@@ -0,0 +1,4 @@
+require 'rails_helper'
+
+RSpec.describe FollowRecommendationSuppression, type: :model do
+end
diff --git a/spec/models/web/push_subscription_spec.rb b/spec/models/web/push_subscription_spec.rb
index c6665611c..b44904369 100644
--- a/spec/models/web/push_subscription_spec.rb
+++ b/spec/models/web/push_subscription_spec.rb
@@ -1,16 +1,94 @@
 require 'rails_helper'
 
 RSpec.describe Web::PushSubscription, type: :model do
-  let(:alerts) { { mention: true, reblog: false, follow: true, follow_request: false, favourite: true } }
-  let(:push_subscription) { Web::PushSubscription.new(data: { alerts: alerts }) }
+  let(:account) { Fabricate(:account) }
+
+  let(:policy) { 'all' }
+
+  let(:data) do
+    {
+      policy: policy,
+
+      alerts: {
+        mention: true,
+        reblog: false,
+        follow: true,
+        follow_request: false,
+        favourite: true,
+      },
+    }
+  end
+
+  subject { described_class.new(data: data) }
 
   describe '#pushable?' do
-    it 'obeys alert settings' do
-      expect(push_subscription.send(:pushable?, Notification.new(activity_type: 'Mention'))).to eq true
-      expect(push_subscription.send(:pushable?, Notification.new(activity_type: 'Status'))).to eq false
-      expect(push_subscription.send(:pushable?, Notification.new(activity_type: 'Follow'))).to eq true
-      expect(push_subscription.send(:pushable?, Notification.new(activity_type: 'FollowRequest'))).to eq false
-      expect(push_subscription.send(:pushable?, Notification.new(activity_type: 'Favourite'))).to eq true
+    let(:notification_type) { :mention }
+    let(:notification) { Fabricate(:notification, account: account, type: notification_type) }
+
+    %i(mention reblog follow follow_request favourite).each do |type|
+      context "when notification is a #{type}" do
+        let(:notification_type) { type }
+
+        it "returns boolean corresonding to alert setting" do
+          expect(subject.pushable?(notification)).to eq data[:alerts][type]
+        end
+      end
+    end
+
+    context 'when policy is all' do
+      let(:policy) { 'all' }
+
+      it 'returns true' do
+        expect(subject.pushable?(notification)).to eq true
+      end
+    end
+
+    context 'when policy is none' do
+      let(:policy) { 'none' }
+
+      it 'returns false' do
+        expect(subject.pushable?(notification)).to eq false
+      end
+    end
+
+    context 'when policy is followed' do
+      let(:policy) { 'followed' }
+
+      context 'and notification is from someone you follow' do
+        before do
+          account.follow!(notification.from_account)
+        end
+
+        it 'returns true' do
+          expect(subject.pushable?(notification)).to eq true
+        end
+      end
+
+      context 'and notification is not from someone you follow' do
+        it 'returns false' do
+          expect(subject.pushable?(notification)).to eq false
+        end
+      end
+    end
+
+    context 'when policy is follower' do
+      let(:policy) { 'follower' }
+
+      context 'and notification is from someone who follows you' do
+        before do
+          notification.from_account.follow!(account)
+        end
+
+        it 'returns true' do
+          expect(subject.pushable?(notification)).to eq true
+        end
+      end
+
+      context 'and notification is not from someone who follows you' do
+        it 'returns false' do
+          expect(subject.pushable?(notification)).to eq false
+        end
+      end
     end
   end
 end
diff --git a/spec/validators/blacklisted_email_validator_spec.rb b/spec/validators/blacklisted_email_validator_spec.rb
index 53b355a57..f7d5e01bc 100644
--- a/spec/validators/blacklisted_email_validator_spec.rb
+++ b/spec/validators/blacklisted_email_validator_spec.rb
@@ -9,23 +9,36 @@ RSpec.describe BlacklistedEmailValidator, type: :validator do
 
     before do
       allow(user).to receive(:valid_invitation?) { false }
-      allow_any_instance_of(described_class).to receive(:blocked_email?) { blocked_email }
-      described_class.new.validate(user)
+      allow_any_instance_of(described_class).to receive(:blocked_email_provider?) { blocked_email }
     end
 
-    context 'blocked_email?' do
+    subject { described_class.new.validate(user); errors }
+
+    context 'when e-mail provider is blocked' do
       let(:blocked_email) { true }
 
-      it 'calls errors.add' do
-        expect(errors).to have_received(:add).with(:email, :blocked)
+      it 'adds error' do
+        expect(subject).to have_received(:add).with(:email, :blocked)
       end
     end
 
-    context '!blocked_email?' do
+    context 'when e-mail provider is not blocked' do
       let(:blocked_email) { false }
 
-      it 'not calls errors.add' do
-        expect(errors).not_to have_received(:add).with(:email, :blocked)
+      it 'does not add errors' do
+        expect(subject).not_to have_received(:add).with(:email, :blocked)
+      end
+
+      context 'when canonical e-mail is blocked' do
+        let(:other_user) { Fabricate(:user, email: 'i.n.f.o@mail.com') }
+
+        before do
+          other_user.account.suspend!
+        end
+
+        it 'adds error' do
+          expect(subject).to have_received(:add).with(:email, :taken)
+        end
       end
     end
   end
diff --git a/spec/workers/web/push_notification_worker_spec.rb b/spec/workers/web/push_notification_worker_spec.rb
new file mode 100644
index 000000000..5bc24f888
--- /dev/null
+++ b/spec/workers/web/push_notification_worker_spec.rb
@@ -0,0 +1,48 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+describe Web::PushNotificationWorker do
+  subject { described_class.new }
+
+  let(:p256dh) { 'BN4GvZtEZiZuqFxSKVZfSfluwKBD7UxHNBmWkfiZfCtgDE8Bwh-_MtLXbBxTBAWH9r7IPKL0lhdcaqtL1dfxU5E=' }
+  let(:auth) { 'Q2BoAjC09xH3ywDLNJr-dA==' }
+  let(:endpoint) { 'https://updates.push.services.mozilla.com/push/v1/subscription-id' }
+  let(:user) { Fabricate(:user) }
+  let(:notification) { Fabricate(:notification) }
+  let(:subscription) { Fabricate(:web_push_subscription, user_id: user.id, key_p256dh: p256dh, key_auth: auth, endpoint: endpoint, data: { alerts: { notification.type => true } }) }
+  let(:vapid_public_key) { 'BB37UCyc8LLX4PNQSe-04vSFvpUWGrENubUaslVFM_l5TxcGVMY0C3RXPeUJAQHKYlcOM2P4vTYmkoo0VZGZTM4=' }
+  let(:vapid_private_key) { 'OPrw1Sum3gRoL4-DXfSCC266r-qfFSRZrnj8MgIhRHg=' }
+  let(:vapid_key) { Webpush::VapidKey.from_keys(vapid_public_key, vapid_private_key) }
+  let(:contact_email) { 'sender@example.com' }
+  let(:ciphertext) { "+\xB8\xDBT}\x13\xB6\xDD.\xF9\xB0\xA7\xC8\xD2\x80\xFD\x99#\xF7\xAC\x83\xA4\xDB,\x1F\xB5\xB9w\x85>\xF7\xADr" }
+  let(:salt) { "X\x97\x953\xE4X\xF8_w\xE7T\x95\xC51q\xFE" }
+  let(:server_public_key) { "\x04\b-RK9w\xDD$\x16lFz\xF9=\xB4~\xC6\x12k\xF3\xF40t\xA9\xC1\fR\xC3\x81\x80\xAC\f\x7F\xE4\xCC\x8E\xC2\x88 n\x8BB\xF1\x9C\x14\a\xFA\x8D\xC9\x80\xA1\xDDyU\\&c\x01\x88#\x118Ua" }
+  let(:shared_secret) { "\t\xA7&\x85\t\xC5m\b\xA8\xA7\xF8B{1\xADk\xE1y'm\xEDE\xEC\xDD\xEDj\xB3$s\xA9\xDA\xF0" }
+  let(:payload) { { ciphertext: ciphertext, salt: salt, server_public_key: server_public_key, shared_secret: shared_secret } }
+
+  describe 'perform' do
+    before do
+      allow_any_instance_of(subscription.class).to receive(:contact_email).and_return(contact_email)
+      allow_any_instance_of(subscription.class).to receive(:vapid_key).and_return(vapid_key)
+      allow(Webpush::Encryption).to receive(:encrypt).and_return(payload)
+      allow(JWT).to receive(:encode).and_return('jwt.encoded.payload')
+
+      stub_request(:post, endpoint).to_return(status: 201, body: '')
+
+      subject.perform(subscription.id, notification.id)
+    end
+
+    it 'calls the relevant service with the correct headers' do
+      expect(a_request(:post, endpoint).with(headers: {
+        'Content-Encoding' => 'aesgcm',
+        'Content-Type' => 'application/octet-stream',
+        'Crypto-Key' => 'dh=BAgtUks5d90kFmxGevk9tH7GEmvz9DB0qcEMUsOBgKwMf-TMjsKIIG6LQvGcFAf6jcmAod15VVwmYwGIIxE4VWE;p256ecdsa=' + vapid_public_key.delete('='),
+        'Encryption' => 'salt=WJeVM-RY-F9351SVxTFx_g',
+        'Ttl' => '172800',
+        'Urgency' => 'normal',
+        'Authorization' => 'WebPush jwt.encoded.payload',
+      }, body: "+\xB8\xDBT}\u0013\xB6\xDD.\xF9\xB0\xA7\xC8Ҁ\xFD\x99#\xF7\xAC\x83\xA4\xDB,\u001F\xB5\xB9w\x85>\xF7\xADr")).to have_been_made
+    end
+  end
+end
diff --git a/yarn.lock b/yarn.lock
index d0aa62307..41251fc6f 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -16,24 +16,24 @@
   dependencies:
     "@babel/highlight" "^7.12.13"
 
-"@babel/compat-data@^7.13.0", "@babel/compat-data@^7.13.12", "@babel/compat-data@^7.13.8":
-  version "7.13.12"
-  resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.13.12.tgz#a8a5ccac19c200f9dd49624cac6e19d7be1236a1"
-  integrity sha512-3eJJ841uKxeV8dcN/2yGEUy+RfgQspPEgQat85umsE1rotuquQ2AbIub4S6j7c50a2d+4myc+zSlnXeIHrOnhQ==
+"@babel/compat-data@^7.13.11", "@babel/compat-data@^7.13.12", "@babel/compat-data@^7.13.15", "@babel/compat-data@^7.13.8":
+  version "7.13.15"
+  resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.13.15.tgz#7e8eea42d0b64fda2b375b22d06c605222e848f4"
+  integrity sha512-ltnibHKR1VnrU4ymHyQ/CXtNXI6yZC0oJThyW78Hft8XndANwi+9H+UIklBDraIjFEJzw8wmcM427oDd9KS5wA==
 
-"@babel/core@^7.1.0", "@babel/core@^7.13.14", "@babel/core@^7.7.2", "@babel/core@^7.7.5":
-  version "7.13.14"
-  resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.13.14.tgz#8e46ebbaca460a63497c797e574038ab04ae6d06"
-  integrity sha512-wZso/vyF4ki0l0znlgM4inxbdrUvCb+cVz8grxDq+6C9k6qbqoIJteQOKicaKjCipU3ISV+XedCqpL2RJJVehA==
+"@babel/core@^7.1.0", "@babel/core@^7.13.15", "@babel/core@^7.7.2", "@babel/core@^7.7.5":
+  version "7.13.15"
+  resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.13.15.tgz#a6d40917df027487b54312202a06812c4f7792d0"
+  integrity sha512-6GXmNYeNjS2Uz+uls5jalOemgIhnTMeaXo+yBUA72kC2uX/8VW6XyhVIo2L8/q0goKQA3EVKx0KOQpVKSeWadQ==
   dependencies:
     "@babel/code-frame" "^7.12.13"
     "@babel/generator" "^7.13.9"
     "@babel/helper-compilation-targets" "^7.13.13"
     "@babel/helper-module-transforms" "^7.13.14"
     "@babel/helpers" "^7.13.10"
-    "@babel/parser" "^7.13.13"
+    "@babel/parser" "^7.13.15"
     "@babel/template" "^7.12.13"
-    "@babel/traverse" "^7.13.13"
+    "@babel/traverse" "^7.13.15"
     "@babel/types" "^7.13.14"
     convert-source-map "^1.7.0"
     debug "^4.1.0"
@@ -81,7 +81,7 @@
     "@babel/helper-annotate-as-pure" "^7.12.13"
     "@babel/types" "^7.12.13"
 
-"@babel/helper-compilation-targets@^7.13.0", "@babel/helper-compilation-targets@^7.13.10", "@babel/helper-compilation-targets@^7.13.13", "@babel/helper-compilation-targets@^7.13.8":
+"@babel/helper-compilation-targets@^7.13.0", "@babel/helper-compilation-targets@^7.13.13", "@babel/helper-compilation-targets@^7.13.8":
   version "7.13.13"
   resolved "https://registry.yarnpkg.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.13.13.tgz#2b2972a0926474853f41e4adbc69338f520600e5"
   integrity sha512-q1kcdHNZehBwD9jYPh3WyXcsFERi39X4I59I3NadciWtNDyZ6x+GboOxncFK0kXlKIv6BJm5acncehXWUjWQMQ==
@@ -91,10 +91,10 @@
     browserslist "^4.14.5"
     semver "^6.3.0"
 
-"@babel/helper-create-class-features-plugin@^7.13.0":
-  version "7.13.0"
-  resolved "https://registry.yarnpkg.com/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.13.0.tgz#28d04ad9cfbd1ed1d8b988c9ea7b945263365846"
-  integrity sha512-twwzhthM4/+6o9766AW2ZBHpIHPSGrPGk1+WfHiu13u/lBnggXGNYCpeAyVfNwGDKfkhEDp+WOD/xafoJ2iLjA==
+"@babel/helper-create-class-features-plugin@^7.13.0", "@babel/helper-create-class-features-plugin@^7.13.11":
+  version "7.13.11"
+  resolved "https://registry.yarnpkg.com/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.13.11.tgz#30d30a005bca2c953f5653fc25091a492177f4f6"
+  integrity sha512-ays0I7XYq9xbjCSvT+EvysLgfc3tOkwCULHjrnscGT3A9qD4sk3wXnJ3of0MAWsWGjdinFvajHU2smYuqXKMrw==
   dependencies:
     "@babel/helper-function-name" "^7.12.13"
     "@babel/helper-member-expression-to-functions" "^7.13.0"
@@ -110,10 +110,10 @@
     "@babel/helper-annotate-as-pure" "^7.12.13"
     regexpu-core "^4.7.1"
 
-"@babel/helper-define-polyfill-provider@^0.1.2":
-  version "0.1.2"
-  resolved "https://registry.yarnpkg.com/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.1.2.tgz#619f01afe1deda460676c25c463b42eaefdb71a2"
-  integrity sha512-hWeolZJivTNGHXHzJjQz/NwDaG4mGXf22ZroOP8bQYgvHNzaQ5tylsVbAcAS2oDjXBwpu8qH2I/654QFS2rDpw==
+"@babel/helper-define-polyfill-provider@^0.2.0":
+  version "0.2.0"
+  resolved "https://registry.yarnpkg.com/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.2.0.tgz#a640051772045fedaaecc6f0c6c69f02bdd34bf1"
+  integrity sha512-JT8tHuFjKBo8NnaUbblz7mIu1nnvUDiHVjXXkulZULyidvo/7P6TY7+YqpV37IfF+KUFxmlK04elKtGKXaiVgw==
   dependencies:
     "@babel/helper-compilation-targets" "^7.13.0"
     "@babel/helper-module-imports" "^7.12.13"
@@ -176,21 +176,7 @@
   dependencies:
     "@babel/types" "^7.13.12"
 
-"@babel/helper-module-imports@^7.0.0-beta.49":
-  version "7.12.5"
-  resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.12.5.tgz#1bfc0229f794988f76ed0a4d4e90860850b54dfb"
-  integrity sha512-SR713Ogqg6++uexFRORf/+nPXMmWIn80TALu0uaFb+iQIUoR7bOC7zBWyzBs5b3tBBJXuyD0cRu1F15GyzjOWA==
-  dependencies:
-    "@babel/types" "^7.12.5"
-
-"@babel/helper-module-imports@^7.12.13":
-  version "7.12.13"
-  resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.12.13.tgz#ec67e4404f41750463e455cc3203f6a32e93fcb0"
-  integrity sha512-NGmfvRp9Rqxy0uHSSVP+SRIW1q31a7Ji10cLBcqSDUngGentY4FRiHOFZFE1CLU5eiL0oE8reH7Tg1y99TDM/g==
-  dependencies:
-    "@babel/types" "^7.12.13"
-
-"@babel/helper-module-imports@^7.13.12":
+"@babel/helper-module-imports@^7.0.0-beta.49", "@babel/helper-module-imports@^7.12.13", "@babel/helper-module-imports@^7.13.12":
   version "7.13.12"
   resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.13.12.tgz#c6a369a6f3621cb25da014078684da9196b61977"
   integrity sha512-4cVvR2/1B693IuOvSI20xqqa/+bl7lqAMR59R4iu39R9aOX8/JoYY1sFaNvUMyMBGnHdwvJgUrzNLoUZxXypxA==
@@ -328,10 +314,10 @@
     chalk "^2.0.0"
     js-tokens "^4.0.0"
 
-"@babel/parser@^7.1.0", "@babel/parser@^7.12.13", "@babel/parser@^7.13.13", "@babel/parser@^7.7.0":
-  version "7.13.13"
-  resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.13.13.tgz#42f03862f4aed50461e543270916b47dd501f0df"
-  integrity sha512-OhsyMrqygfk5v8HmWwOzlYjJrtLaFhF34MrfG/Z73DgYCI6ojNUTUp2TYbtnjo8PegeJp12eamsNettCQjKjVw==
+"@babel/parser@^7.1.0", "@babel/parser@^7.12.13", "@babel/parser@^7.13.15", "@babel/parser@^7.7.0":
+  version "7.13.15"
+  resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.13.15.tgz#8e66775fb523599acb6a289e12929fa5ab0954d8"
+  integrity sha512-b9COtcAlVEQljy/9fbcMHpG+UIW9ReF+gpaxDHTlZd0c6/UU9ng8zdySAW9sRTzpvcdCHn6bUcbuYUgGzLAWVQ==
 
 "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining@^7.13.12":
   version "7.13.12"
@@ -342,10 +328,10 @@
     "@babel/helper-skip-transparent-expression-wrappers" "^7.12.1"
     "@babel/plugin-proposal-optional-chaining" "^7.13.12"
 
-"@babel/plugin-proposal-async-generator-functions@^7.13.8":
-  version "7.13.8"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-async-generator-functions/-/plugin-proposal-async-generator-functions-7.13.8.tgz#87aacb574b3bc4b5603f6fe41458d72a5a2ec4b1"
-  integrity sha512-rPBnhj+WgoSmgq+4gQUtXx/vOcU+UYtjy1AA/aeD61Hwj410fwYyqfUcRP3lR8ucgliVJL/G7sXcNUecC75IXA==
+"@babel/plugin-proposal-async-generator-functions@^7.13.15":
+  version "7.13.15"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-async-generator-functions/-/plugin-proposal-async-generator-functions-7.13.15.tgz#80e549df273a3b3050431b148c892491df1bcc5b"
+  integrity sha512-VapibkWzFeoa6ubXy/NgV5U2U4MVnUlvnx6wo1XhlsaTrLYWE0UFpDQsVrmn22q5CzeloqJ8gEMHSKxuee6ZdA==
   dependencies:
     "@babel/helper-plugin-utils" "^7.13.0"
     "@babel/helper-remap-async-to-generator" "^7.13.0"
@@ -359,12 +345,12 @@
     "@babel/helper-create-class-features-plugin" "^7.13.0"
     "@babel/helper-plugin-utils" "^7.13.0"
 
-"@babel/plugin-proposal-decorators@^7.13.5":
-  version "7.13.5"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-decorators/-/plugin-proposal-decorators-7.13.5.tgz#d28071457a5ba8ee1394b23e38d5dcf32ea20ef7"
-  integrity sha512-i0GDfVNuoapwiheevUOuSW67mInqJ8qw7uWfpjNVeHMn143kXblEy/bmL9AdZ/0yf/4BMQeWXezK0tQIvNPqag==
+"@babel/plugin-proposal-decorators@^7.13.15":
+  version "7.13.15"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-decorators/-/plugin-proposal-decorators-7.13.15.tgz#e91ccfef2dc24dd5bd5dcc9fc9e2557c684ecfb8"
+  integrity sha512-ibAMAqUm97yzi+LPgdr5Nqb9CMkeieGHvwPg1ywSGjZrZHQEGqE01HmOio8kxRpA/+VtOHouIVy2FMpBbtltjA==
   dependencies:
-    "@babel/helper-create-class-features-plugin" "^7.13.0"
+    "@babel/helper-create-class-features-plugin" "^7.13.11"
     "@babel/helper-plugin-utils" "^7.13.0"
     "@babel/plugin-syntax-decorators" "^7.12.13"
 
@@ -796,10 +782,10 @@
     "@babel/helper-annotate-as-pure" "^7.10.4"
     "@babel/helper-plugin-utils" "^7.10.4"
 
-"@babel/plugin-transform-regenerator@^7.12.13":
-  version "7.12.13"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.12.13.tgz#b628bcc9c85260ac1aeb05b45bde25210194a2f5"
-  integrity sha512-lxb2ZAvSLyJ2PEe47hoGWPmW22v7CtSl9jW8mingV4H2sEX/JOcrAj2nPuGWi56ERUm2bUpjKzONAuT6HCn2EA==
+"@babel/plugin-transform-regenerator@^7.13.15":
+  version "7.13.15"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.13.15.tgz#e5eb28945bf8b6563e7f818945f966a8d2997f39"
+  integrity sha512-Bk9cOLSz8DiurcMETZ8E2YtIVJbFCPGW28DJWUakmyVWtQSm6Wsf0p4B4BfEr/eL2Nkhe/CICiUiMOCi1TPhuQ==
   dependencies:
     regenerator-transform "^0.14.2"
 
@@ -810,16 +796,16 @@
   dependencies:
     "@babel/helper-plugin-utils" "^7.12.13"
 
-"@babel/plugin-transform-runtime@^7.13.10":
-  version "7.13.10"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.13.10.tgz#a1e40d22e2bf570c591c9c7e5ab42d6bf1e419e1"
-  integrity sha512-Y5k8ipgfvz5d/76tx7JYbKQTcgFSU6VgJ3kKQv4zGTKr+a9T/KBvfRvGtSFgKDQGt/DBykQixV0vNWKIdzWErA==
+"@babel/plugin-transform-runtime@^7.13.15":
+  version "7.13.15"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.13.15.tgz#2eddf585dd066b84102517e10a577f24f76a9cd7"
+  integrity sha512-d+ezl76gx6Jal08XngJUkXM4lFXK/5Ikl9Mh4HKDxSfGJXmZ9xG64XT2oivBzfxb/eQ62VfvoMkaCZUKJMVrBA==
   dependencies:
-    "@babel/helper-module-imports" "^7.12.13"
+    "@babel/helper-module-imports" "^7.13.12"
     "@babel/helper-plugin-utils" "^7.13.0"
-    babel-plugin-polyfill-corejs2 "^0.1.4"
-    babel-plugin-polyfill-corejs3 "^0.1.3"
-    babel-plugin-polyfill-regenerator "^0.1.2"
+    babel-plugin-polyfill-corejs2 "^0.2.0"
+    babel-plugin-polyfill-corejs3 "^0.2.0"
+    babel-plugin-polyfill-regenerator "^0.2.0"
     semver "^6.3.0"
 
 "@babel/plugin-transform-shorthand-properties@^7.12.13":
@@ -873,17 +859,17 @@
     "@babel/helper-create-regexp-features-plugin" "^7.12.13"
     "@babel/helper-plugin-utils" "^7.12.13"
 
-"@babel/preset-env@^7.13.12":
-  version "7.13.12"
-  resolved "https://registry.yarnpkg.com/@babel/preset-env/-/preset-env-7.13.12.tgz#6dff470478290582ac282fb77780eadf32480237"
-  integrity sha512-JzElc6jk3Ko6zuZgBtjOd01pf9yYDEIH8BcqVuYIuOkzOwDesoa/Nz4gIo4lBG6K861KTV9TvIgmFuT6ytOaAA==
+"@babel/preset-env@^7.13.15":
+  version "7.13.15"
+  resolved "https://registry.yarnpkg.com/@babel/preset-env/-/preset-env-7.13.15.tgz#c8a6eb584f96ecba183d3d414a83553a599f478f"
+  integrity sha512-D4JAPMXcxk69PKe81jRJ21/fP/uYdcTZ3hJDF5QX2HSI9bBxxYw/dumdR6dGumhjxlprHPE4XWoPaqzZUVy2MA==
   dependencies:
-    "@babel/compat-data" "^7.13.12"
-    "@babel/helper-compilation-targets" "^7.13.10"
+    "@babel/compat-data" "^7.13.15"
+    "@babel/helper-compilation-targets" "^7.13.13"
     "@babel/helper-plugin-utils" "^7.13.0"
     "@babel/helper-validator-option" "^7.12.17"
     "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining" "^7.13.12"
-    "@babel/plugin-proposal-async-generator-functions" "^7.13.8"
+    "@babel/plugin-proposal-async-generator-functions" "^7.13.15"
     "@babel/plugin-proposal-class-properties" "^7.13.0"
     "@babel/plugin-proposal-dynamic-import" "^7.13.8"
     "@babel/plugin-proposal-export-namespace-from" "^7.12.13"
@@ -931,7 +917,7 @@
     "@babel/plugin-transform-object-super" "^7.12.13"
     "@babel/plugin-transform-parameters" "^7.13.0"
     "@babel/plugin-transform-property-literals" "^7.12.13"
-    "@babel/plugin-transform-regenerator" "^7.12.13"
+    "@babel/plugin-transform-regenerator" "^7.13.15"
     "@babel/plugin-transform-reserved-words" "^7.12.13"
     "@babel/plugin-transform-shorthand-properties" "^7.12.13"
     "@babel/plugin-transform-spread" "^7.13.0"
@@ -941,10 +927,10 @@
     "@babel/plugin-transform-unicode-escapes" "^7.12.13"
     "@babel/plugin-transform-unicode-regex" "^7.12.13"
     "@babel/preset-modules" "^0.1.4"
-    "@babel/types" "^7.13.12"
-    babel-plugin-polyfill-corejs2 "^0.1.4"
-    babel-plugin-polyfill-corejs3 "^0.1.3"
-    babel-plugin-polyfill-regenerator "^0.1.2"
+    "@babel/types" "^7.13.14"
+    babel-plugin-polyfill-corejs2 "^0.2.0"
+    babel-plugin-polyfill-corejs3 "^0.2.0"
+    babel-plugin-polyfill-regenerator "^0.2.0"
     core-js-compat "^3.9.0"
     semver "^6.3.0"
 
@@ -1002,21 +988,21 @@
     "@babel/parser" "^7.12.13"
     "@babel/types" "^7.12.13"
 
-"@babel/traverse@^7.1.0", "@babel/traverse@^7.12.13", "@babel/traverse@^7.13.0", "@babel/traverse@^7.13.13", "@babel/traverse@^7.7.0":
-  version "7.13.13"
-  resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.13.13.tgz#39aa9c21aab69f74d948a486dd28a2dbdbf5114d"
-  integrity sha512-CblEcwmXKR6eP43oQGG++0QMTtCjAsa3frUuzHoiIJWpaIIi8dwMyEFUJoXRLxagGqCK+jALRwIO+o3R9p/uUg==
+"@babel/traverse@^7.1.0", "@babel/traverse@^7.12.13", "@babel/traverse@^7.13.0", "@babel/traverse@^7.13.13", "@babel/traverse@^7.13.15", "@babel/traverse@^7.7.0":
+  version "7.13.15"
+  resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.13.15.tgz#c38bf7679334ddd4028e8e1f7b3aa5019f0dada7"
+  integrity sha512-/mpZMNvj6bce59Qzl09fHEs8Bt8NnpEDQYleHUPZQ3wXUMvXi+HJPLars68oAbmp839fGoOkv2pSL2z9ajCIaQ==
   dependencies:
     "@babel/code-frame" "^7.12.13"
     "@babel/generator" "^7.13.9"
     "@babel/helper-function-name" "^7.12.13"
     "@babel/helper-split-export-declaration" "^7.12.13"
-    "@babel/parser" "^7.13.13"
-    "@babel/types" "^7.13.13"
+    "@babel/parser" "^7.13.15"
+    "@babel/types" "^7.13.14"
     debug "^4.1.0"
     globals "^11.1.0"
 
-"@babel/types@^7.0.0", "@babel/types@^7.0.0-beta.49", "@babel/types@^7.10.4", "@babel/types@^7.12.1", "@babel/types@^7.12.13", "@babel/types@^7.13.0", "@babel/types@^7.13.12", "@babel/types@^7.13.13", "@babel/types@^7.13.14", "@babel/types@^7.3.0", "@babel/types@^7.3.3", "@babel/types@^7.4.4", "@babel/types@^7.7.0":
+"@babel/types@^7.0.0", "@babel/types@^7.0.0-beta.49", "@babel/types@^7.10.4", "@babel/types@^7.12.1", "@babel/types@^7.12.13", "@babel/types@^7.13.0", "@babel/types@^7.13.12", "@babel/types@^7.13.14", "@babel/types@^7.3.0", "@babel/types@^7.3.3", "@babel/types@^7.4.4", "@babel/types@^7.7.0":
   version "7.13.14"
   resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.13.14.tgz#c35a4abb15c7cd45a2746d78ab328e362cbace0d"
   integrity sha512-A2aa3QTkWoyqsZZFl56MLUsfmh7O0gN41IPvXAE/++8ojpbz12SszD7JEGYVdn4f9Kt4amIei07swF1h4AqmmQ==
@@ -1025,15 +1011,6 @@
     lodash "^4.17.19"
     to-fast-properties "^2.0.0"
 
-"@babel/types@^7.12.5":
-  version "7.13.13"
-  resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.13.13.tgz#dcd8b815b38f537a3697ce84c8e3cc62197df96f"
-  integrity sha512-kt+EpC6qDfIaqlP+DIbIJOclYy/A1YXs9dAf/ljbi+39Bcbc073H6jKVpXEr/EoIh5anGn5xq/yRVzKl+uIc9w==
-  dependencies:
-    "@babel/helper-validator-identifier" "^7.12.11"
-    lodash "^4.17.19"
-    to-fast-properties "^2.0.0"
-
 "@bcoe/v8-coverage@^0.2.3":
   version "0.2.3"
   resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39"
@@ -2287,29 +2264,29 @@ babel-plugin-macros@^2.8.0:
     cosmiconfig "^6.0.0"
     resolve "^1.12.0"
 
-babel-plugin-polyfill-corejs2@^0.1.4:
-  version "0.1.5"
-  resolved "https://registry.yarnpkg.com/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.1.5.tgz#8fc4779965311393594a1b9ad3adefab3860c8fe"
-  integrity sha512-5IzdFIjYWqlOFVr/hMYUpc+5fbfuvJTAISwIY58jhH++ZtawtNlcJnxAixlk8ahVwHCz1ipW/kpXYliEBp66wg==
+babel-plugin-polyfill-corejs2@^0.2.0:
+  version "0.2.0"
+  resolved "https://registry.yarnpkg.com/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.2.0.tgz#686775bf9a5aa757e10520903675e3889caeedc4"
+  integrity sha512-9bNwiR0dS881c5SHnzCmmGlMkJLl0OUZvxrxHo9w/iNoRuqaPjqlvBf4HrovXtQs/au5yKkpcdgfT1cC5PAZwg==
   dependencies:
-    "@babel/compat-data" "^7.13.0"
-    "@babel/helper-define-polyfill-provider" "^0.1.2"
+    "@babel/compat-data" "^7.13.11"
+    "@babel/helper-define-polyfill-provider" "^0.2.0"
     semver "^6.1.1"
 
-babel-plugin-polyfill-corejs3@^0.1.3:
-  version "0.1.4"
-  resolved "https://registry.yarnpkg.com/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.1.4.tgz#2ae290200e953bade30907b7a3bebcb696e6c59d"
-  integrity sha512-ysSzFn/qM8bvcDAn4mC7pKk85Y5dVaoa9h4u0mHxOEpDzabsseONhUpR7kHxpUinfj1bjU7mUZqD23rMZBoeSg==
+babel-plugin-polyfill-corejs3@^0.2.0:
+  version "0.2.0"
+  resolved "https://registry.yarnpkg.com/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.2.0.tgz#f4b4bb7b19329827df36ff56f6e6d367026cb7a2"
+  integrity sha512-zZyi7p3BCUyzNxLx8KV61zTINkkV65zVkDAFNZmrTCRVhjo1jAS+YLvDJ9Jgd/w2tsAviCwFHReYfxO3Iql8Yg==
   dependencies:
-    "@babel/helper-define-polyfill-provider" "^0.1.2"
-    core-js-compat "^3.8.1"
+    "@babel/helper-define-polyfill-provider" "^0.2.0"
+    core-js-compat "^3.9.1"
 
-babel-plugin-polyfill-regenerator@^0.1.2:
-  version "0.1.3"
-  resolved "https://registry.yarnpkg.com/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.1.3.tgz#350f857225fc640ae1ec78d1536afcbb457db841"
-  integrity sha512-hRjTJQiOYt/wBKEc+8V8p9OJ9799blAJcuKzn1JXh3pApHoWl1Emxh2BHc6MC7Qt6bbr3uDpNxaYQnATLIudEg==
+babel-plugin-polyfill-regenerator@^0.2.0:
+  version "0.2.0"
+  resolved "https://registry.yarnpkg.com/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.2.0.tgz#853f5f5716f4691d98c84f8069c7636ea8da7ab8"
+  integrity sha512-J7vKbCuD2Xi/eEHxquHN14bXAW9CXtecwuLrOIDJtcZzTaPzV1VdEfoUf9AzcRBMolKUQKM9/GVojeh0hFiqMg==
   dependencies:
-    "@babel/helper-define-polyfill-provider" "^0.1.2"
+    "@babel/helper-define-polyfill-provider" "^0.2.0"
 
 babel-plugin-preval@^5.0.0:
   version "5.0.0"
@@ -2882,10 +2859,10 @@ char-regex@^1.0.2:
   resolved "https://registry.yarnpkg.com/char-regex/-/char-regex-1.0.2.tgz#d744358226217f981ed58f479b1d6bcc29545dcf"
   integrity sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==
 
-"chokidar@>=2.0.0 <4.0.0", chokidar@^3.4.1:
-  version "3.4.1"
-  resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.4.1.tgz#e905bdecf10eaa0a0b1db0c664481cc4cbc22ba1"
-  integrity sha512-TQTJyr2stihpC4Sya9hs2Xh+O2wf+igjL36Y75xx2WdHuiICcn/XJza46Jwt0eT5hVpQOzo3FpY3cj3RVYLX0g==
+"chokidar@>=3.0.0 <4.0.0", chokidar@^3.4.1:
+  version "3.5.1"
+  resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.5.1.tgz#ee9ce7bbebd2b79f49f304799d5468e31e14e68a"
+  integrity sha512-9+s+Od+W0VJJzawDma/gvBNQqkTiqYTWLuZoyAsivsI4AaWTCzHG06/TMjsf1cYe9Cb97UCEhjz7HvnPk2p/tw==
   dependencies:
     anymatch "~3.1.1"
     braces "~3.0.2"
@@ -2893,9 +2870,9 @@ char-regex@^1.0.2:
     is-binary-path "~2.1.0"
     is-glob "~4.0.1"
     normalize-path "~3.0.0"
-    readdirp "~3.4.0"
+    readdirp "~3.5.0"
   optionalDependencies:
-    fsevents "~2.1.2"
+    fsevents "~2.3.1"
 
 chokidar@^2.1.8:
   version "2.1.8"
@@ -2966,10 +2943,10 @@ class-utils@^0.3.5:
     isobject "^3.0.0"
     static-extend "^0.1.1"
 
-classnames@^2.2.5:
-  version "2.2.6"
-  resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.2.6.tgz#43935bffdd291f326dad0a205309b38d00f650ce"
-  integrity sha512-JR/iSQOSt+LQIWwrwEzJ9uk0xfN3mTVYMwt1Ir5mUcSN6pU+V4zQFFaJsclJbPuAUQH+yfWef6tm7l1quW3C8Q==
+classnames@^2.2.5, classnames@^2.3.1:
+  version "2.3.1"
+  resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.3.1.tgz#dfcfa3891e306ec1dad105d0e88f4417b8535e8e"
+  integrity sha512-OlQdbZ7gLfGarSqxesMesDa5uz7KFbID8Kpq/SxIoNGDqY8lSYs0D+hhtBXhcdB3rcbXArFr7vlHheLk1voeNA==
 
 clean-stack@^2.0.0:
   version "2.2.0"
@@ -3255,10 +3232,10 @@ copy-descriptor@^0.1.0:
   resolved "https://registry.yarnpkg.com/copy-descriptor/-/copy-descriptor-0.1.1.tgz#676f6eb3c39997c2ee1ac3a924fd6124748f578d"
   integrity sha1-Z29us8OZl8LuGsOpJP1hJHSPV40=
 
-core-js-compat@^3.8.1, core-js-compat@^3.9.0:
-  version "3.9.0"
-  resolved "https://registry.yarnpkg.com/core-js-compat/-/core-js-compat-3.9.0.tgz#29da39385f16b71e1915565aa0385c4e0963ad56"
-  integrity sha512-YK6fwFjCOKWwGnjFUR3c544YsnA/7DoLL0ysncuOJ4pwbriAtOpvM2bygdlcXbvQCQZ7bBU9CL4t7tGl7ETRpQ==
+core-js-compat@^3.9.0, core-js-compat@^3.9.1:
+  version "3.10.1"
+  resolved "https://registry.yarnpkg.com/core-js-compat/-/core-js-compat-3.10.1.tgz#62183a3a77ceeffcc420d907a3e6fc67d9b27f1c"
+  integrity sha512-ZHQTdTPkqvw2CeHiZC970NNJcnwzT6YIueDMASKt+p3WbZsLXOcoD392SkcWhkC0wBBHhlfhqGKKsNCQUozYtg==
   dependencies:
     browserslist "^4.16.3"
     semver "7.0.0"
@@ -3424,23 +3401,22 @@ css-list-helpers@^1.0.1:
   dependencies:
     tcomb "^2.5.0"
 
-css-loader@^5.2.0:
-  version "5.2.0"
-  resolved "https://registry.yarnpkg.com/css-loader/-/css-loader-5.2.0.tgz#a9ecda190500863673ce4434033710404efbff00"
-  integrity sha512-MfRo2MjEeLXMlUkeUwN71Vx5oc6EJnx5UQ4Yi9iUtYQvrPtwLUucYptz0hc6n++kdNcyF5olYBS4vPjJDAcLkw==
+css-loader@^5.2.2:
+  version "5.2.2"
+  resolved "https://registry.yarnpkg.com/css-loader/-/css-loader-5.2.2.tgz#65f2c1482255f15847ecad6cbc515cae8a5b234e"
+  integrity sha512-IS722y7Lh2Yq+acMR74tdf3faMOLRP2RfLwS0VzSS7T98IHtacMWJLku3A0OBTFHB07zAa4nWBhA8gfxwQVWGQ==
   dependencies:
     camelcase "^6.2.0"
-    cssesc "^3.0.0"
     icss-utils "^5.1.0"
     loader-utils "^2.0.0"
-    postcss "^8.2.8"
+    postcss "^8.2.10"
     postcss-modules-extract-imports "^3.0.0"
     postcss-modules-local-by-default "^4.0.0"
     postcss-modules-scope "^3.0.0"
     postcss-modules-values "^4.0.0"
     postcss-value-parser "^4.1.0"
     schema-utils "^3.0.0"
-    semver "^7.3.4"
+    semver "^7.3.5"
 
 css-select-base-adapter@^0.1.1:
   version "0.1.1"
@@ -3502,10 +3478,10 @@ cssesc@^3.0.0:
   resolved "https://registry.yarnpkg.com/cssesc/-/cssesc-3.0.0.tgz#37741919903b868565e1c09ea747445cd18983ee"
   integrity sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==
 
-cssnano-preset-default@^4.0.7:
-  version "4.0.7"
-  resolved "https://registry.yarnpkg.com/cssnano-preset-default/-/cssnano-preset-default-4.0.7.tgz#51ec662ccfca0f88b396dcd9679cdb931be17f76"
-  integrity sha512-x0YHHx2h6p0fCl1zY9L9roD7rnlltugGu7zXSKQx6k2rYw0Hi3IqxcoAGF7u9Q5w1nt7vK0ulxV8Lo+EvllGsA==
+cssnano-preset-default@^4.0.8:
+  version "4.0.8"
+  resolved "https://registry.yarnpkg.com/cssnano-preset-default/-/cssnano-preset-default-4.0.8.tgz#920622b1fc1e95a34e8838203f1397a504f2d3ff"
+  integrity sha512-LdAyHuq+VRyeVREFmuxUZR1TXjQm8QQU/ktoo/x7bz+SdOge1YKc5eMN6pRW7YWBmyq59CqYba1dJ5cUukEjLQ==
   dependencies:
     css-declaration-sorter "^4.0.1"
     cssnano-util-raw-cache "^4.0.1"
@@ -3535,7 +3511,7 @@ cssnano-preset-default@^4.0.7:
     postcss-ordered-values "^4.1.2"
     postcss-reduce-initial "^4.0.3"
     postcss-reduce-transforms "^4.0.2"
-    postcss-svgo "^4.0.2"
+    postcss-svgo "^4.0.3"
     postcss-unique-selectors "^4.0.1"
 
 cssnano-util-get-arguments@^4.0.0:
@@ -3560,13 +3536,13 @@ cssnano-util-same-parent@^4.0.0:
   resolved "https://registry.yarnpkg.com/cssnano-util-same-parent/-/cssnano-util-same-parent-4.0.1.tgz#574082fb2859d2db433855835d9a8456ea18bbf3"
   integrity sha512-WcKx5OY+KoSIAxBW6UBBRay1U6vkYheCdjyVNDm85zt5K9mHoGOfsOsqIszfAqrQQFIIKgjh2+FDgIj/zsl21Q==
 
-cssnano@^4.1.10:
-  version "4.1.10"
-  resolved "https://registry.yarnpkg.com/cssnano/-/cssnano-4.1.10.tgz#0ac41f0b13d13d465487e111b778d42da631b8b2"
-  integrity sha512-5wny+F6H4/8RgNlaqab4ktc3e0/blKutmq8yNlBFXA//nSFFAqAngjNVRzUvCgYROULmZZUoosL/KSoZo5aUaQ==
+cssnano@^4.1.11:
+  version "4.1.11"
+  resolved "https://registry.yarnpkg.com/cssnano/-/cssnano-4.1.11.tgz#c7b5f5b81da269cb1fd982cb960c1200910c9a99"
+  integrity sha512-6gZm2htn7xIPJOHY824ERgj8cNPgPxyCSnkXc4v7YvNW+TdVfzgngHcEhy/8D11kUWRUMbke+tC+AUcUsnMz2g==
   dependencies:
     cosmiconfig "^5.0.0"
-    cssnano-preset-default "^4.0.7"
+    cssnano-preset-default "^4.0.8"
     is-resolvable "^1.0.0"
     postcss "^7.0.0"
 
@@ -3761,10 +3737,10 @@ delegates@^1.0.0:
   resolved "https://registry.yarnpkg.com/delegates/-/delegates-1.0.0.tgz#84c6e159b81904fdca59a0ef44cd870d31250f9a"
   integrity sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o=
 
-denque@^1.4.1:
-  version "1.4.1"
-  resolved "https://registry.yarnpkg.com/denque/-/denque-1.4.1.tgz#6744ff7641c148c3f8a69c307e51235c1f4a37cf"
-  integrity sha512-OfzPuSZKGcgr96rf1oODnfjqBFmr1DVoc/TrItj3Ohe0Ah1C5WX5Baquw/9U9KovnQ88EqmJbD66rKYUQYN1tQ==
+denque@^1.5.0:
+  version "1.5.0"
+  resolved "https://registry.yarnpkg.com/denque/-/denque-1.5.0.tgz#773de0686ff2d8ec2ff92914316a47b73b1c73de"
+  integrity sha512-CYiCSgIF1p6EUByQPlGkKnP1M9g0ZV3qMIrqMqZqdwazygIA/YP2vrbcyl1h/WppKJTdl1F85cXIle+394iDAQ==
 
 depd@~1.1.2:
   version "1.1.2"
@@ -4298,15 +4274,15 @@ eslint-plugin-jsx-a11y@~6.4.1:
     jsx-ast-utils "^3.1.0"
     language-tags "^1.0.5"
 
-eslint-plugin-promise@~4.3.1:
-  version "4.3.1"
-  resolved "https://registry.yarnpkg.com/eslint-plugin-promise/-/eslint-plugin-promise-4.3.1.tgz#61485df2a359e03149fdafc0a68b0e030ad2ac45"
-  integrity sha512-bY2sGqyptzFBDLh/GMbAxfdJC+b0f23ME63FOE4+Jao0oZ3E1LEwFtWJX/1pGMJLiTtrSSern2CRM/g+dfc0eQ==
+eslint-plugin-promise@~5.1.0:
+  version "5.1.0"
+  resolved "https://registry.yarnpkg.com/eslint-plugin-promise/-/eslint-plugin-promise-5.1.0.tgz#fb2188fb734e4557993733b41aa1a688f46c6f24"
+  integrity sha512-NGmI6BH5L12pl7ScQHbg7tvtk4wPxxj8yPHH47NvSmMtFneC077PSeY3huFj06ZWZvtbfxSPt3RuOQD5XcR4ng==
 
-eslint-plugin-react@~7.23.1:
-  version "7.23.1"
-  resolved "https://registry.yarnpkg.com/eslint-plugin-react/-/eslint-plugin-react-7.23.1.tgz#f1a2e844c0d1967c822388204a8bc4dee8415b11"
-  integrity sha512-MvFGhZjI8Z4HusajmSw0ougGrq3Gs4vT/0WgwksZgf5RrLrRa2oYAw56okU4tZJl8+j7IYNuTM+2RnFEuTSdRQ==
+eslint-plugin-react@~7.23.2:
+  version "7.23.2"
+  resolved "https://registry.yarnpkg.com/eslint-plugin-react/-/eslint-plugin-react-7.23.2.tgz#2d2291b0f95c03728b55869f01102290e792d494"
+  integrity sha512-AfjgFQB+nYszudkxRkTFu0UR1zEQig0ArVMPloKhxwlwkzaw/fBiH0QWcBBhZONlXqQC51+nfqFrkn4EzHcGBw==
   dependencies:
     array-includes "^3.1.3"
     array.prototype.flatmap "^1.2.4"
@@ -4393,10 +4369,10 @@ eslint@^2.7.0:
     text-table "~0.2.0"
     user-home "^2.0.0"
 
-eslint@^7.23.0:
-  version "7.23.0"
-  resolved "https://registry.yarnpkg.com/eslint/-/eslint-7.23.0.tgz#8d029d252f6e8cf45894b4bee08f5493f8e94325"
-  integrity sha512-kqvNVbdkjzpFy0XOszNwjkKzZ+6TcwCQ/h+ozlcIWwaimBBuhlQ4nN6kbiM2L+OjDcznkTJxzYfRFH92sx4a0Q==
+eslint@^7.24.0:
+  version "7.24.0"
+  resolved "https://registry.yarnpkg.com/eslint/-/eslint-7.24.0.tgz#2e44fa62d93892bfdb100521f17345ba54b8513a"
+  integrity sha512-k9gaHeHiFmGCDQ2rEfvULlSLruz6tgfA8DEn+rY9/oYPFFTlz55mM/Q/Rij1b2Y42jwZiK3lXvNTw6w6TXzcKQ==
   dependencies:
     "@babel/code-frame" "7.12.11"
     "@eslint/eslintrc" "^0.4.0"
@@ -4992,11 +4968,16 @@ fsevents@^1.2.7:
     bindings "^1.5.0"
     nan "^2.12.1"
 
-fsevents@^2.1.2, fsevents@~2.1.2:
+fsevents@^2.1.2:
   version "2.1.3"
   resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.1.3.tgz#fb738703ae8d2f9fe900c33836ddebee8b97f23e"
   integrity sha512-Auw9a4AxqWpa9GUfj370BMPzzyncfBABW8Mab7BGWBYDj4Isgq+cDKtx0i6u9jcX9pQDnswsaaOTgTmA5pEjuQ==
 
+fsevents@~2.3.1:
+  version "2.3.2"
+  resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.2.tgz#8a526f78b8fdf4623b709e0b975c52c24c02fd1a"
+  integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==
+
 function-bind@^1.1.1:
   version "1.1.1"
   resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d"
@@ -5402,11 +5383,6 @@ hsla-regex@^1.0.0:
   resolved "https://registry.yarnpkg.com/hsla-regex/-/hsla-regex-1.0.0.tgz#c1ce7a3168c8c6614033a4b5f7877f3b225f9c38"
   integrity sha1-wc56MWjIxmFAM6S194d/OyJfnDg=
 
-html-comment-regex@^1.1.0:
-  version "1.1.2"
-  resolved "https://registry.yarnpkg.com/html-comment-regex/-/html-comment-regex-1.1.2.tgz#97d4688aeb5c81886a364faa0cad1dda14d433a7"
-  integrity sha512-P+M65QY2JQ5Y0G9KKdlDpo0zK+/OHptU5AaBwUfAIDJZk1MYf32Frm84EcOytfJE0t5JvkAnKlmjsXDnWzCJmQ==
-
 html-encoding-sniffer@^2.0.1:
   version "2.0.1"
   resolved "https://registry.yarnpkg.com/html-encoding-sniffer/-/html-encoding-sniffer-2.0.1.tgz#42a6dc4fd33f00281176e8b23759ca4e4fa185f3"
@@ -6074,13 +6050,6 @@ is-string@^1.0.5:
   resolved "https://registry.yarnpkg.com/is-string/-/is-string-1.0.5.tgz#40493ed198ef3ff477b8c7f92f644ec82a5cd3a6"
   integrity sha512-buY6VNRjhQMiF1qWDouloZlQbRhDPCebwxSjxMjxgemYT46YMd2NR0/H+fBhEfWX4A/w9TBJ+ol+okqJKFE6vQ==
 
-is-svg@^3.0.0:
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/is-svg/-/is-svg-3.0.0.tgz#9321dbd29c212e5ca99c4fa9794c714bcafa2f75"
-  integrity sha512-gi4iHK53LR2ujhLVVj+37Ykh9GLqYHX6JOVXbLAucaG/Cqw9xwdFOjDM2qeifLs1sF1npXXFvDu0r5HNgCMrzQ==
-  dependencies:
-    html-comment-regex "^1.1.0"
-
 is-symbol@^1.0.2:
   version "1.0.3"
   resolved "https://registry.yarnpkg.com/is-symbol/-/is-symbol-1.0.3.tgz#38e1014b9e6329be0de9d24a414fd7441ec61937"
@@ -6603,10 +6572,10 @@ js-yaml@^3.13.1, js-yaml@^3.4.6, js-yaml@^3.5.1, js-yaml@^3.5.4:
     argparse "^1.0.7"
     esprima "^4.0.0"
 
-js-yaml@^4.0.0:
-  version "4.0.0"
-  resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.0.0.tgz#f426bc0ff4b4051926cd588c71113183409a121f"
-  integrity sha512-pqon0s+4ScYUvX30wxQi3PogGFAlUyH0awepWvwkj4jD4v+ova3RiYw8bmA6x2rDrEaj8i/oWKoRxpVNW+Re8Q==
+js-yaml@^4.1.0:
+  version "4.1.0"
+  resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.1.0.tgz#c1fb65f8f5017901cdd2c951864ba18458a10602"
+  integrity sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==
   dependencies:
     argparse "^2.0.1"
 
@@ -6916,11 +6885,6 @@ lodash.defaults@^4.0.1:
   resolved "https://registry.yarnpkg.com/lodash.defaults/-/lodash.defaults-4.2.0.tgz#d09178716ffea4dde9e5fb7b37f6f0802274580c"
   integrity sha1-0JF4cW/+pN3p5ft7N/bwgCJ0WAw=
 
-lodash.escaperegexp@^4.0:
-  version "4.1.2"
-  resolved "https://registry.yarnpkg.com/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz#64762c48618082518ac3df4ccf5d5886dae20347"
-  integrity sha1-ZHYsSGGAglGKw99Mz11YhtriA0c=
-
 lodash.get@^4.0:
   version "4.4.2"
   resolved "https://registry.yarnpkg.com/lodash.get/-/lodash.get-4.4.2.tgz#2d177f652fa31e939b4438d5341499dfa3825e99"
@@ -7183,10 +7147,10 @@ min-indent@^1.0.0:
   resolved "https://registry.yarnpkg.com/min-indent/-/min-indent-1.0.1.tgz#a63f681673b30571fbe8bc25686ae746eefa9869"
   integrity sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==
 
-mini-css-extract-plugin@^1.4.0:
-  version "1.4.0"
-  resolved "https://registry.yarnpkg.com/mini-css-extract-plugin/-/mini-css-extract-plugin-1.4.0.tgz#c8e571c4b6d63afa56c47260343adf623349c473"
-  integrity sha512-DyQr5DhXXARKZoc4kwvCvD95kh69dUupfuKOmBUqZ4kBTmRaRZcU32lYu3cLd6nEGXhQ1l7LzZ3F/CjItaY6VQ==
+mini-css-extract-plugin@^1.5.0:
+  version "1.5.0"
+  resolved "https://registry.yarnpkg.com/mini-css-extract-plugin/-/mini-css-extract-plugin-1.5.0.tgz#69bee3b273d2d4ee8649a2eb409514b7df744a27"
+  integrity sha512-SIbuLMv6jsk1FnLIU5OUG/+VMGUprEjM1+o2trOAx8i5KOKMrhyezb1dJ4Ugsykb8Jgq8/w5NEopy6escV9G7g==
   dependencies:
     loader-utils "^2.0.0"
     schema-utils "^3.0.0"
@@ -7346,10 +7310,10 @@ nan@^2.12.1:
   resolved "https://registry.yarnpkg.com/nan/-/nan-2.14.1.tgz#d7be34dfa3105b91494c3147089315eff8874b01"
   integrity sha512-isWHgVjnFjh2x2yuJ/tj3JbwoHu3UC2dX5G/88Cm24yB6YopVgxvBObDY7n5xW6ExmFhJpSEQqFPvq9zaXc8Jw==
 
-nanoid@^3.1.20:
-  version "3.1.20"
-  resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.1.20.tgz#badc263c6b1dcf14b71efaa85f6ab4c1d6cfc788"
-  integrity sha512-a1cQNyczgKbLX9jwbS/+d7W8fX/RfgYR7lVWwWOGIPNgK2m0MWvrGF6/m4kk6U3QcFMnZf3RIhL0v2Jgh/0Uxw==
+nanoid@^3.1.22:
+  version "3.1.22"
+  resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.1.22.tgz#b35f8fb7d151990a8aebd5aa5015c03cf726f844"
+  integrity sha512-/2ZUaJX2ANuLtTvqTlgqBQNJoQO398KyJgZloL0PZkC0dpysjncRUPsFe3DUPzz/y3h+u7C46np8RMuvF3jsSQ==
 
 nanomatch@^1.2.9:
   version "1.2.13"
@@ -8485,12 +8449,11 @@ postcss-selector-parser@^6.0.4:
     uniq "^1.0.1"
     util-deprecate "^1.0.2"
 
-postcss-svgo@^4.0.2:
-  version "4.0.2"
-  resolved "https://registry.yarnpkg.com/postcss-svgo/-/postcss-svgo-4.0.2.tgz#17b997bc711b333bab143aaed3b8d3d6e3d38258"
-  integrity sha512-C6wyjo3VwFm0QgBy+Fu7gCYOkCmgmClghO+pjcxvrcBKtiKt0uCF+hvbMO1fyv5BMImRK90SMb+dwUnfbGd+jw==
+postcss-svgo@^4.0.3:
+  version "4.0.3"
+  resolved "https://registry.yarnpkg.com/postcss-svgo/-/postcss-svgo-4.0.3.tgz#343a2cdbac9505d416243d496f724f38894c941e"
+  integrity sha512-NoRbrcMWTtUghzuKSoIm6XV+sJdvZ7GZSc3wdBN0W19FTtp2ko8NqLsgoh/m9CzNhU3KLPvQmjIwtaNFkaFTvw==
   dependencies:
-    is-svg "^3.0.0"
     postcss "^7.0.0"
     postcss-value-parser "^3.0.0"
     svgo "^1.0.0"
@@ -8533,13 +8496,13 @@ postcss@^7.0.0, postcss@^7.0.1, postcss@^7.0.27, postcss@^7.0.32:
     source-map "^0.6.1"
     supports-color "^6.1.0"
 
-postcss@^8.2.8:
-  version "8.2.8"
-  resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.2.8.tgz#0b90f9382efda424c4f0f69a2ead6f6830d08ece"
-  integrity sha512-1F0Xb2T21xET7oQV9eKuctbM9S7BC0fetoHCc4H13z0PT6haiRLP4T0ZY4XWh7iLP0usgqykT6p9B2RtOf4FPw==
+postcss@^8.2.10:
+  version "8.2.10"
+  resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.2.10.tgz#ca7a042aa8aff494b334d0ff3e9e77079f6f702b"
+  integrity sha512-b/h7CPV7QEdrqIxtAf2j31U5ef05uBDuvoXv6L51Q4rcS1jdlXAVKJv+atCFdUXYl9dyTHGyoMzIepwowRJjFw==
   dependencies:
     colorette "^1.2.2"
-    nanoid "^3.1.20"
+    nanoid "^3.1.22"
     source-map "^0.6.1"
 
 postgres-array@~2.0.0:
@@ -9150,10 +9113,10 @@ readdirp@^2.2.1:
     micromatch "^3.1.10"
     readable-stream "^2.0.2"
 
-readdirp@~3.4.0:
-  version "3.4.0"
-  resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.4.0.tgz#9fdccdf9e9155805449221ac645e8303ab5b9ada"
-  integrity sha512-0xe001vZBnJEK+uKcj8qOhyAKPzIT+gStxWr3LCB0DwcXR5NZJ3IaC+yGnHCYzB/S7ov3m3EEbZI2zeNvX+hGQ==
+readdirp@~3.5.0:
+  version "3.5.0"
+  resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.5.0.tgz#9ba74c019b15d365278d2e91bb8c48d7b4d42c9e"
+  integrity sha512-cMhu7c/8rdhkHXWsY+osBhfSy0JikwpHK/5+imo+LpeasTF8ouErHrlYkwT0++njiyuDvc7OFY5T3ukvZ8qmFQ==
   dependencies:
     picomatch "^2.2.1"
 
@@ -9174,10 +9137,10 @@ redent@^3.0.0:
     indent-string "^4.0.0"
     strip-indent "^3.0.0"
 
-redis-commands@^1.5.0:
-  version "1.6.0"
-  resolved "https://registry.yarnpkg.com/redis-commands/-/redis-commands-1.6.0.tgz#36d4ca42ae9ed29815cdb30ad9f97982eba1ce23"
-  integrity sha512-2jnZ0IkjZxvguITjFTrGiLyzQZcTvaw8DAaCXxZq/dsHXz7KfMQ3OUJy7Tz9vnRtZRVz6VRCPDvruvU8Ts44wQ==
+redis-commands@^1.7.0:
+  version "1.7.0"
+  resolved "https://registry.yarnpkg.com/redis-commands/-/redis-commands-1.7.0.tgz#15a6fea2d58281e27b1cd1acfb4b293e278c3a89"
+  integrity sha512-nJWqw3bTFy21hX/CPKHth6sfhZbdiHP6bTawSgQBlKOVRG7EZkfHbbHwQJnrE4vsQf0CMNE+3gJ4Fmm16vdVlQ==
 
 redis-errors@^1.0.0, redis-errors@^1.2.0:
   version "1.2.0"
@@ -9191,13 +9154,13 @@ redis-parser@^3.0.0:
   dependencies:
     redis-errors "^1.0.0"
 
-redis@^3.0.2:
-  version "3.0.2"
-  resolved "https://registry.yarnpkg.com/redis/-/redis-3.0.2.tgz#bd47067b8a4a3e6a2e556e57f71cc82c7360150a"
-  integrity sha512-PNhLCrjU6vKVuMOyFu7oSP296mwBkcE6lrAjruBYG5LgdSqtRBoVQIylrMyVZD/lkF24RSNNatzvYag6HRBHjQ==
+redis@^3.1.1:
+  version "3.1.1"
+  resolved "https://registry.yarnpkg.com/redis/-/redis-3.1.1.tgz#a44bee7c072dcf685e139048d6a1a4d3b00f5d01"
+  integrity sha512-QhkKhOuzhogR1NDJfBD34TQJz2ZJwDhhIC6ZmvpftlmfYShHHQXjjNspAJ+Z2HH5NwSBVYBVganbiZ8bgFMHjg==
   dependencies:
-    denque "^1.4.1"
-    redis-commands "^1.5.0"
+    denque "^1.5.0"
+    redis-commands "^1.7.0"
     redis-errors "^1.2.0"
     redis-parser "^3.0.0"
 
@@ -9633,12 +9596,12 @@ sass-loader@^10.1.1:
     schema-utils "^3.0.0"
     semver "^7.3.2"
 
-sass@^1.32.8:
-  version "1.32.8"
-  resolved "https://registry.yarnpkg.com/sass/-/sass-1.32.8.tgz#f16a9abd8dc530add8834e506878a2808c037bdc"
-  integrity sha512-Sl6mIeGpzjIUZqvKnKETfMf0iDAswD9TNlv13A7aAF3XZlRPMq4VvJWBC2N2DXbp94MQVdNSFG6LfF/iOXrPHQ==
+sass@^1.32.10:
+  version "1.32.10"
+  resolved "https://registry.yarnpkg.com/sass/-/sass-1.32.10.tgz#d40da4e20031b450359ee1c7e69bc8cc89569241"
+  integrity sha512-Nx0pcWoonAkn7CRp0aE/hket1UP97GiR1IFw3kcjV3pnenhWgZEWUf0ZcfPOV2fK52fnOcK3JdC/YYZ9E47DTQ==
   dependencies:
-    chokidar ">=2.0.0 <4.0.0"
+    chokidar ">=3.0.0 <4.0.0"
 
 sax@~1.2.4:
   version "1.2.4"
@@ -9722,10 +9685,10 @@ semver@^6.0.0, semver@^6.1.1, semver@^6.1.2, semver@^6.3.0:
   resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.0.tgz#ee0a64c8af5e8ceea67687b133761e1becbd1d3d"
   integrity sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==
 
-semver@^7.2.1, semver@^7.3.2, semver@^7.3.4:
-  version "7.3.4"
-  resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.4.tgz#27aaa7d2e4ca76452f98d3add093a72c943edc97"
-  integrity sha512-tCfb2WLjqFAtXn4KEdxIhalnRtoKFN7nAwj0B3ZXCbQloV2tq5eDbcTmT68JJD3nRJq24/XgxtQKFIpQdtvmVw==
+semver@^7.2.1, semver@^7.3.2, semver@^7.3.5:
+  version "7.3.5"
+  resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.5.tgz#0b621c879348d8998e4b0e4be94b3f12e6018ef7"
+  integrity sha512-PoeGJYh8HK4BTO/a9Tf6ZG3veo/A7ZVsYrSA6J8ny9nb3B1VrpkuN+z9OE5wfE5p6H4LchYZsegiQgbJD94ZFQ==
   dependencies:
     lru-cache "^6.0.0"
 
@@ -10118,9 +10081,9 @@ sshpk@^1.7.0:
     tweetnacl "~0.14.0"
 
 ssri@^6.0.1:
-  version "6.0.1"
-  resolved "https://registry.yarnpkg.com/ssri/-/ssri-6.0.1.tgz#2a3c41b28dd45b62b63676ecb74001265ae9edd8"
-  integrity sha512-3Wge10hNcT1Kur4PDFwEieXSCMCJs/7WvSACcrMYrNp+b8kDL1/0wJch5Ni2WrtwEa2IO8OsVfeKIciKCDx/QA==
+  version "6.0.2"
+  resolved "https://registry.yarnpkg.com/ssri/-/ssri-6.0.2.tgz#157939134f20464e7301ddba3e90ffa8f7728ac5"
+  integrity sha512-cepbSq/neFK7xB6A50KHN0xHDotYzq58wWCa5LeWqnPrHG8GzfEjO/4O8kpmcGW+oaxkvhEJCWgbgNk4/ZV93Q==
   dependencies:
     figgy-pudding "^3.5.1"
 
@@ -11213,15 +11176,14 @@ webidl-conversions@^6.1.0:
   resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-6.1.0.tgz#9111b4d7ea80acd40f5270d666621afa78b69514"
   integrity sha512-qBIvFLGiBpLjfwmYAaHPXsn+ho5xZnGvyGvsarywGNc8VyQJUMHJ8OBKGGrPER0okBeMDaan4mNBlgBROxuI8w==
 
-webpack-assets-manifest@^4.0.2:
-  version "4.0.2"
-  resolved "https://registry.yarnpkg.com/webpack-assets-manifest/-/webpack-assets-manifest-4.0.2.tgz#ead6e6dbdcd1c2af45d11a382246fcc79a286372"
-  integrity sha512-bBb9PvEGDOCFvW5/t6Yp9MEE0fymNJ0OvEud9nPvQegDbQEUZ/2WTeHnNoALwWMu1x3JHPyqHVYh8SwtYZ/dww==
+webpack-assets-manifest@^4.0.5:
+  version "4.0.5"
+  resolved "https://registry.yarnpkg.com/webpack-assets-manifest/-/webpack-assets-manifest-4.0.5.tgz#802d45fd58203fc7a70ac557636a93605a218d3f"
+  integrity sha512-cvvr0AtTHyi7D9otmLkv0Bv8j5KKwwD5Wwt6MNxLxgc3U3XmIZnNykw2PMChzUvPr9Ibiv9ceROIc0mS1C7MeA==
   dependencies:
     chalk "^4.0"
     deepmerge "^4.0"
     lockfile "^1.0"
-    lodash.escaperegexp "^4.0"
     lodash.get "^4.0"
     lodash.has "^4.0"
     mkdirp "^1.0"
@@ -11229,10 +11191,10 @@ webpack-assets-manifest@^4.0.2:
     tapable "^1.0"
     webpack-sources "^1.0"
 
-webpack-bundle-analyzer@^4.4.0:
-  version "4.4.0"
-  resolved "https://registry.yarnpkg.com/webpack-bundle-analyzer/-/webpack-bundle-analyzer-4.4.0.tgz#74013106e7e2b07cbd64f3a5ae847f7e814802c7"
-  integrity sha512-9DhNa+aXpqdHk8LkLPTBU/dMfl84Y+WE2+KnfI6rSpNRNVKa0VGLjPd2pjFubDeqnWmulFggxmWBxhfJXZnR0g==
+webpack-bundle-analyzer@^4.4.1:
+  version "4.4.1"
+  resolved "https://registry.yarnpkg.com/webpack-bundle-analyzer/-/webpack-bundle-analyzer-4.4.1.tgz#c71fb2eaffc10a4754d7303b224adb2342069da1"
+  integrity sha512-j5m7WgytCkiVBoOGavzNokBOqxe6Mma13X1asfVYtKWM3wxBiRRu1u1iG0Iol5+qp9WgyhkMmBAcvjEfJ2bdDw==
   dependencies:
     acorn "^8.0.4"
     acorn-walk "^8.0.0"
@@ -11512,15 +11474,10 @@ ws@^6.2.1:
   dependencies:
     async-limiter "~1.0.0"
 
-ws@^7.2.3, ws@^7.3.1:
-  version "7.4.0"
-  resolved "https://registry.yarnpkg.com/ws/-/ws-7.4.0.tgz#a5dd76a24197940d4a8bb9e0e152bb4503764da7"
-  integrity sha512-kyFwXuV/5ymf+IXhS6f0+eAFvydbaBW3zjpT6hUdAh/hbVjTIB5EHBGi0bPoCLSK2wcuz3BrEkB9LrYv1Nm4NQ==
-
-ws@^7.4.4:
-  version "7.4.4"
-  resolved "https://registry.yarnpkg.com/ws/-/ws-7.4.4.tgz#383bc9742cb202292c9077ceab6f6047b17f2d59"
-  integrity sha512-Qm8k8ojNQIMx7S+Zp8u/uHOx7Qazv3Yv4q68MiWWWOJhiwG5W3x7iqmRtJo8xxrciZUY4vRxUTJCKuRnF28ZZw==
+ws@^7.2.3, ws@^7.3.1, ws@^7.4.5:
+  version "7.4.5"
+  resolved "https://registry.yarnpkg.com/ws/-/ws-7.4.5.tgz#a484dd851e9beb6fdb420027e3885e8ce48986c1"
+  integrity sha512-xzyu3hFvomRfXKH8vOFMU3OguG6oOvhXMo3xsGy3xWExqaM2dxBbVxuD99O7m3ZUFMvvscsZDqxfgMaRr/Nr1g==
 
 xml-name-validator@^3.0.0:
   version "3.0.0"