about summary refs log tree commit diff
diff options
context:
space:
mode:
authorFire Demon <firedemon@creature.cafe>2020-10-22 17:34:29 -0500
committerFire Demon <firedemon@creature.cafe>2020-10-22 17:34:29 -0500
commitadf60e63806a4b8f843ce6722a9086044f0bb5cd (patch)
tree722969bdb867727a65aeb78bfdab4fa5f60248a5
parentc36dd229f9dcb1d77c46d8db23297fc5781b4a97 (diff)
parent36e5c9d45be0e94216b5b92ea8749a00bb68e0e3 (diff)
Merge remote-tracking branch 'upstream/master' into merge-glitch
-rw-r--r--.codeclimate.yml2
-rw-r--r--Gemfile10
-rw-r--r--Gemfile.lock83
-rw-r--r--app/controllers/activitypub/followers_synchronizations_controller.rb36
-rw-r--r--app/controllers/activitypub/inboxes_controller.rb14
-rw-r--r--app/controllers/admin/ip_blocks_controller.rb56
-rw-r--r--app/controllers/api/v1/accounts_controller.rb4
-rw-r--r--app/controllers/api/v1/mutes_controller.rb33
-rw-r--r--app/controllers/auth/registrations_controller.rb6
-rw-r--r--app/controllers/settings/preferences_controller.rb1
-rw-r--r--app/helpers/admin/action_logs_helper.rb4
-rw-r--r--app/helpers/webfinger_helper.rb33
-rw-r--r--app/javascript/core/admin.js1
-rw-r--r--app/javascript/core/auth.js1
-rw-r--r--app/javascript/core/common.js1
-rw-r--r--app/javascript/core/embed.js2
-rw-r--r--app/javascript/core/public.js1
-rw-r--r--app/javascript/core/settings.js1
-rw-r--r--app/javascript/core/two_factor_authentication.js1
-rw-r--r--app/javascript/flavours/glitch/actions/accounts.js4
-rw-r--r--app/javascript/flavours/glitch/actions/markers.js8
-rw-r--r--app/javascript/flavours/glitch/actions/mutes.js10
-rw-r--r--app/javascript/flavours/glitch/actions/notifications.js44
-rw-r--r--app/javascript/flavours/glitch/components/account.js7
-rw-r--r--app/javascript/flavours/glitch/components/autosuggest_emoji.js2
-rw-r--r--app/javascript/flavours/glitch/components/column_header.js18
-rw-r--r--app/javascript/flavours/glitch/components/icon_with_badge.js4
-rw-r--r--app/javascript/flavours/glitch/containers/mastodon.js7
-rw-r--r--app/javascript/flavours/glitch/features/emoji_picker/index.js2
-rw-r--r--app/javascript/flavours/glitch/features/getting_started/components/announcements.js3
-rw-r--r--app/javascript/flavours/glitch/features/notifications/components/column_settings.js36
-rw-r--r--app/javascript/flavours/glitch/features/notifications/components/notifications_permission_banner.js30
-rw-r--r--app/javascript/flavours/glitch/features/notifications/components/setting_toggle.js5
-rw-r--r--app/javascript/flavours/glitch/features/notifications/containers/column_settings_container.js35
-rw-r--r--app/javascript/flavours/glitch/features/notifications/index.js9
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/focal_point_modal.js3
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/mute_modal.js44
-rw-r--r--app/javascript/flavours/glitch/features/ui/index.js4
-rw-r--r--app/javascript/flavours/glitch/packs/about.js1
-rw-r--r--app/javascript/flavours/glitch/packs/common.js1
-rw-r--r--app/javascript/flavours/glitch/packs/error.js1
-rw-r--r--app/javascript/flavours/glitch/packs/home.js1
-rw-r--r--app/javascript/flavours/glitch/packs/public.js1
-rw-r--r--app/javascript/flavours/glitch/packs/settings.js1
-rw-r--r--app/javascript/flavours/glitch/packs/share.js1
-rw-r--r--app/javascript/flavours/glitch/reducers/mutes.js4
-rw-r--r--app/javascript/flavours/glitch/reducers/notifications.js14
-rw-r--r--app/javascript/flavours/glitch/reducers/relationships.js2
-rw-r--r--app/javascript/flavours/glitch/reducers/settings.js13
-rw-r--r--app/javascript/flavours/glitch/styles/components/accounts.scss121
-rw-r--r--app/javascript/flavours/glitch/styles/components/columns.scss156
-rw-r--r--app/javascript/flavours/glitch/styles/components/index.scss11
-rw-r--r--app/javascript/flavours/glitch/styles/components/modal.scss16
-rw-r--r--app/javascript/flavours/glitch/styles/mastodon-light/diff.scss5
-rw-r--r--app/javascript/flavours/glitch/util/config.js10
-rw-r--r--app/javascript/flavours/glitch/util/emoji/index.js3
-rw-r--r--app/javascript/flavours/glitch/util/main.js2
-rw-r--r--app/javascript/flavours/glitch/util/notifications.js29
-rw-r--r--app/javascript/mastodon/actions/accounts.js4
-rw-r--r--app/javascript/mastodon/actions/markers.js8
-rw-r--r--app/javascript/mastodon/actions/mutes.js10
-rw-r--r--app/javascript/mastodon/actions/notifications.js49
-rw-r--r--app/javascript/mastodon/actions/onboarding.js13
-rw-r--r--app/javascript/mastodon/components/account.js7
-rw-r--r--app/javascript/mastodon/components/autosuggest_emoji.js3
-rw-r--r--app/javascript/mastodon/components/column_header.js18
-rw-r--r--app/javascript/mastodon/components/icon_button.js1
-rw-r--r--app/javascript/mastodon/components/icon_with_badge.js4
-rw-r--r--app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js2
-rw-r--r--app/javascript/mastodon/features/emoji/emoji.js3
-rw-r--r--app/javascript/mastodon/features/getting_started/components/announcements.js6
-rw-r--r--app/javascript/mastodon/features/getting_started/index.js4
-rw-r--r--app/javascript/mastodon/features/introduction/index.js3
-rw-r--r--app/javascript/mastodon/features/notifications/components/column_settings.js36
-rw-r--r--app/javascript/mastodon/features/notifications/components/notifications_permission_banner.js30
-rw-r--r--app/javascript/mastodon/features/notifications/components/setting_toggle.js5
-rw-r--r--app/javascript/mastodon/features/notifications/containers/column_settings_container.js35
-rw-r--r--app/javascript/mastodon/features/notifications/index.js9
-rw-r--r--app/javascript/mastodon/features/ui/components/columns_area.js4
-rw-r--r--app/javascript/mastodon/features/ui/components/focal_point_modal.js3
-rw-r--r--app/javascript/mastodon/features/ui/components/media_modal.js2
-rw-r--r--app/javascript/mastodon/features/ui/components/mute_modal.js44
-rw-r--r--app/javascript/mastodon/features/ui/index.js8
-rw-r--r--app/javascript/mastodon/initial_state.js1
-rw-r--r--app/javascript/mastodon/locales/defaultMessages.json164
-rw-r--r--app/javascript/mastodon/locales/en.json21
-rw-r--r--app/javascript/mastodon/locales/ja.json2
-rw-r--r--app/javascript/mastodon/main.js2
-rw-r--r--app/javascript/mastodon/reducers/mutes.js4
-rw-r--r--app/javascript/mastodon/reducers/notifications.js17
-rw-r--r--app/javascript/mastodon/reducers/relationships.js2
-rw-r--r--app/javascript/mastodon/reducers/settings.js13
-rw-r--r--app/javascript/mastodon/utils/config.js10
-rw-r--r--app/javascript/mastodon/utils/notifications.js29
-rw-r--r--app/javascript/packs/about.js1
-rw-r--r--app/javascript/packs/application.js1
-rw-r--r--app/javascript/packs/common.js1
-rw-r--r--app/javascript/packs/error.js1
-rw-r--r--app/javascript/packs/public-path.js21
-rw-r--r--app/javascript/packs/public.js1
-rw-r--r--app/javascript/packs/share.js1
-rw-r--r--app/javascript/styles/mastodon-light/diff.scss5
-rw-r--r--app/javascript/styles/mastodon/components.scss76
-rw-r--r--app/lib/activitypub/activity.rb2
-rw-r--r--app/lib/activitypub/tag_manager.rb4
-rw-r--r--app/lib/fast_ip_map.rb32
-rw-r--r--app/lib/sanitize_config.rb1
-rw-r--r--app/lib/settings/scoped_settings.rb2
-rw-r--r--app/lib/user_settings_decorator.rb5
-rw-r--r--app/lib/webfinger.rb93
-rw-r--r--app/models/account.rb6
-rw-r--r--app/models/account_alias.rb2
-rw-r--r--app/models/account_migration.rb2
-rw-r--r--app/models/concerns/account_interactions.rb31
-rw-r--r--app/models/concerns/expireable.rb10
-rw-r--r--app/models/follow.rb8
-rw-r--r--app/models/form/ip_block_batch.rb31
-rw-r--r--app/models/form/redirect.rb2
-rw-r--r--app/models/ip_block.rb41
-rw-r--r--app/models/mute.rb3
-rw-r--r--app/models/remote_follow.rb10
-rw-r--r--app/models/user.rb18
-rw-r--r--app/policies/ip_block_policy.rb15
-rw-r--r--app/serializers/initial_state_serializer.rb1
-rw-r--r--app/serializers/rest/muted_account_serializer.rb10
-rw-r--r--app/services/activitypub/fetch_remote_account_service.rb7
-rw-r--r--app/services/activitypub/prepare_followers_synchronization_service.rb13
-rw-r--r--app/services/activitypub/synchronize_followers_service.rb74
-rw-r--r--app/services/app_sign_up_service.rb4
-rw-r--r--app/services/mute_service.rb6
-rw-r--r--app/services/resolve_account_service.rb9
-rw-r--r--app/services/resolve_mentions_service.rb2
-rw-r--r--app/views/admin/ip_blocks/_ip_block.html.haml11
-rw-r--r--app/views/admin/ip_blocks/index.html.haml28
-rw-r--r--app/views/admin/ip_blocks/new.html.haml20
-rw-r--r--app/views/admin/pending_accounts/_account.html.haml2
-rwxr-xr-xapp/views/layouts/application.html.haml1
-rw-r--r--app/views/layouts/embedded.html.haml1
-rw-r--r--app/views/settings/preferences/appearance/show.html.haml1
-rw-r--r--app/views/statuses/_simple_status.html.haml13
-rw-r--r--app/views/well_known/host_meta/show.xml.ruby1
-rw-r--r--app/workers/activitypub/delivery_worker.rb10
-rw-r--r--app/workers/activitypub/distribution_worker.rb2
-rw-r--r--app/workers/activitypub/followers_synchronization_worker.rb14
-rw-r--r--app/workers/delete_mute_worker.rb10
-rw-r--r--app/workers/scheduler/ip_cleanup_scheduler.rb18
-rw-r--r--chart/templates/cronjob-media-remove.yaml73
-rw-r--r--chart/values.yaml.template6
-rw-r--r--config/initializers/paperclip.rb2
-rw-r--r--config/initializers/rack_attack.rb4
-rw-r--r--config/initializers/twitter_regex.rb2
-rw-r--r--config/locales/en.yml19
-rw-r--r--config/locales/simple_form.en.yml16
-rw-r--r--config/navigation.rb1
-rw-r--r--config/routes.rb21
-rw-r--r--config/settings.yml1
-rw-r--r--config/webpack/configuration.js17
-rw-r--r--db/migrate/20200317021758_add_expires_at_to_mutes.rb5
-rw-r--r--db/migrate/20201008202037_create_ip_blocks.rb12
-rw-r--r--db/migrate/20201008220312_add_sign_up_ip_to_users.rb5
-rw-r--r--db/schema.rb13
-rw-r--r--lib/cli.rb4
-rw-r--r--lib/mastodon/ip_blocks_cli.rb132
-rw-r--r--package.json36
-rw-r--r--spec/controllers/activitypub/followers_synchronizations_controller_spec.rb58
-rw-r--r--spec/controllers/activitypub/inboxes_controller_spec.rb50
-rw-r--r--spec/controllers/api/v1/mutes_controller_spec.rb21
-rw-r--r--spec/controllers/remote_follow_controller_spec.rb10
-rw-r--r--spec/controllers/well_known/host_meta_controller_spec.rb2
-rw-r--r--spec/fabricators/ip_block_fabricator.rb6
-rw-r--r--spec/lib/fast_ip_map_spec.rb21
-rw-r--r--spec/lib/feed_manager_spec.rb1
-rw-r--r--spec/models/concerns/account_interactions_spec.rb43
-rw-r--r--spec/models/ip_block_spec.rb5
-rw-r--r--spec/services/activitypub/synchronize_followers_service_spec.rb105
-rw-r--r--spec/services/app_sign_up_service_spec.rb13
-rw-r--r--spec/workers/activitypub/delivery_worker_spec.rb10
-rw-r--r--yarn.lock1116
178 files changed, 3043 insertions, 1005 deletions
diff --git a/.codeclimate.yml b/.codeclimate.yml
index 9c9b4517a..dc8ca9a6f 100644
--- a/.codeclimate.yml
+++ b/.codeclimate.yml
@@ -30,7 +30,7 @@ plugins:
     channel: eslint-7
   rubocop:
     enabled: true
-    channel: rubocop-0-88
+    channel: rubocop-0-92
   sass-lint:
     enabled: true
 exclude_patterns:
diff --git a/Gemfile b/Gemfile
index 70c54d359..ff35a1109 100644
--- a/Gemfile
+++ b/Gemfile
@@ -20,7 +20,7 @@ gem 'makara', '~> 0.4'
 gem 'pghero', '~> 2.7'
 gem 'dotenv-rails', '~> 2.7'
 
-gem 'aws-sdk-s3', '~> 1.81', require: false
+gem 'aws-sdk-s3', '~> 1.83', require: false
 gem 'fog-core', '<= 2.1.0'
 gem 'fog-openstack', '~> 0.3', require: false
 gem 'paperclip', '~> 6.0'
@@ -54,7 +54,6 @@ gem 'doorkeeper', '~> 5.4'
 gem 'ed25519', '~> 1.2'
 gem 'fast_blank', '~> 1.0'
 gem 'fastimage'
-gem 'goldfinger', '~> 2.1'
 gem 'hiredis', '~> 0.6'
 gem 'redis-namespace', '~> 1.8'
 gem 'health_check', git: 'https://github.com/ianheggie/health_check', ref: '0b799ead604f900ed50685e9b2d469cd2befba5b'
@@ -142,9 +141,9 @@ group :development do
   gem 'letter_opener', '~> 1.7'
   gem 'letter_opener_web', '~> 1.4'
   gem 'memory_profiler'
-  gem 'rubocop', '~> 0.91', require: false
+  gem 'rubocop', '~> 0.93', require: false
   gem 'rubocop-rails', '~> 2.8', require: false
-  gem 'brakeman', '~> 4.9', require: false
+  gem 'brakeman', '~> 4.10', require: false
   gem 'bundler-audit', '~> 0.7', require: false
 
   gem 'capistrano', '~> 3.14'
@@ -163,7 +162,8 @@ end
 gem 'concurrent-ruby', require: false
 gem 'connection_pool', require: false
 
-gem "reek", "~> 6.0", :group => :development
+gem 'xorcist', '~> 1.1'
+gem 'pluck_each', '~> 0.1.3'
 
 gem "w3c_validators", "~> 1.3"
 
diff --git a/Gemfile.lock b/Gemfile.lock
index 66d907880..681c16497 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -54,7 +54,7 @@ GEM
       activemodel (= 5.2.4.4)
       activesupport (= 5.2.4.4)
       arel (>= 9.0)
-    activerecord-import (1.0.6)
+    activerecord-import (1.0.7)
       activerecord (>= 3.2)
     activestorage (5.2.4.4)
       actionpack (= 5.2.4.4)
@@ -81,23 +81,23 @@ GEM
       cocaine (~> 0.5.3)
     awrence (1.1.1)
     aws-eventstream (1.1.0)
-    aws-partitions (1.373.0)
-    aws-sdk-core (3.107.0)
+    aws-partitions (1.380.0)
+    aws-sdk-core (3.109.1)
       aws-eventstream (~> 1, >= 1.0.2)
       aws-partitions (~> 1, >= 1.239.0)
       aws-sigv4 (~> 1.1)
       jmespath (~> 1.0)
-    aws-sdk-kms (1.38.0)
-      aws-sdk-core (~> 3, >= 3.99.0)
+    aws-sdk-kms (1.39.0)
+      aws-sdk-core (~> 3, >= 3.109.0)
       aws-sigv4 (~> 1.1)
-    aws-sdk-s3 (1.81.0)
-      aws-sdk-core (~> 3, >= 3.104.3)
+    aws-sdk-s3 (1.83.0)
+      aws-sdk-core (~> 3, >= 3.109.0)
       aws-sdk-kms (~> 1)
       aws-sigv4 (~> 1.1)
     aws-sigv4 (1.2.2)
       aws-eventstream (~> 1, >= 1.0.2)
     bcrypt (3.1.16)
-    better_errors (2.8.1)
+    better_errors (2.8.3)
       coderay (>= 1.0.0)
       erubi (>= 1.0.0)
       rack (>= 0.9.0)
@@ -108,7 +108,7 @@ GEM
       ffi (~> 1.10.0)
     bootsnap (1.4.8)
       msgpack (~> 1.0)
-    brakeman (4.9.1)
+    brakeman (4.10.0)
     browser (4.2.0)
     builder (3.2.4)
     bullet (6.1.0)
@@ -242,12 +242,7 @@ GEM
       ruby-progressbar (~> 1.4)
     globalid (0.4.2)
       activesupport (>= 4.2.0)
-    goldfinger (2.1.1)
-      addressable (~> 2.5)
-      http (~> 4.0)
-      nokogiri (~> 1.8)
-      oj (~> 3.0)
-    hamlit (2.11.1)
+    hamlit (2.13.0)
       temple (>= 0.8.2)
       thor
       tilt
@@ -320,7 +315,6 @@ GEM
       activerecord
       kaminari-core (= 1.2.1)
     kaminari-core (1.2.1)
-    kwalify (0.7.2)
     launchy (2.5.0)
       addressable (~> 2.7)
     letter_opener (1.7.0)
@@ -402,7 +396,7 @@ GEM
     parallel (1.19.2)
     parallel_tests (3.3.0)
       parallel
-    parser (2.7.1.4)
+    parser (2.7.2.0)
       ast (~> 2.4.1)
     parslet (2.0.0)
     pastel (0.8.0)
@@ -410,7 +404,10 @@ GEM
     pg (1.2.3)
     pghero (2.7.2)
       activerecord (>= 5)
-    pkg-config (1.4.3)
+    pkg-config (1.4.4)
+    pluck_each (0.1.3)
+      activerecord (> 3.2.0)
+      activesupport (> 3.0.0)
     posix-spawn (0.3.15)
     premailer (1.14.2)
       addressable
@@ -428,13 +425,12 @@ GEM
       pry (~> 0.13.0)
     pry-rails (0.3.9)
       pry (>= 0.10.4)
-    psych (3.1.0)
     public_suffix (4.0.6)
-    puma (5.0.1)
+    puma (5.0.2)
       nio4r (~> 2.0)
     pundit (2.1.0)
       activesupport (>= 3.0.0)
-    raabro (1.3.1)
+    raabro (1.3.3)
     rack (2.2.3)
     rack-attack (6.3.1)
       rack (>= 1.0, < 3)
@@ -504,12 +500,7 @@ GEM
       redis-store (>= 1.2, < 2)
     redis-store (1.9.0)
       redis (>= 4, < 5)
-    reek (6.0.1)
-      kwalify (~> 0.7.0)
-      parser (>= 2.5.0.0, < 2.8, != 2.5.1.1)
-      psych (~> 3.1.0)
-      rainbow (>= 2.0, < 4.0)
-    regexp_parser (1.8.0)
+    regexp_parser (1.8.2)
     request_store (1.5.0)
       rack (>= 1.4)
     responders (3.0.1)
@@ -522,7 +513,7 @@ GEM
       chunky_png (~> 1.0)
       rqrcode_core (~> 0.1)
     rqrcode_core (0.1.2)
-    rspec-core (3.9.2)
+    rspec-core (3.9.3)
       rspec-support (~> 3.9.3)
     rspec-expectations (3.9.2)
       diff-lcs (>= 1.2.0, < 2.0)
@@ -544,17 +535,17 @@ GEM
     rspec-support (3.9.3)
     rspec_junit_formatter (0.4.1)
       rspec-core (>= 2, < 4, != 2.12.0)
-    rubocop (0.91.0)
+    rubocop (0.93.0)
       parallel (~> 1.10)
-      parser (>= 2.7.1.1)
+      parser (>= 2.7.1.5)
       rainbow (>= 2.2.2, < 4.0)
-      regexp_parser (>= 1.7)
+      regexp_parser (>= 1.8)
       rexml
-      rubocop-ast (>= 0.4.0, < 1.0)
+      rubocop-ast (>= 0.6.0)
       ruby-progressbar (~> 1.7)
       unicode-display_width (>= 1.4.0, < 2.0)
-    rubocop-ast (0.4.2)
-      parser (>= 2.7.1.4)
+    rubocop-ast (0.7.1)
+      parser (>= 2.7.1.5)
     rubocop-rails (2.8.1)
       activesupport (>= 4.2.0)
       rack (>= 1.1)
@@ -585,19 +576,19 @@ GEM
       sidekiq (>= 3)
       thwait
       tilt (>= 1.4.0)
-    sidekiq-unique-jobs (6.0.23)
+    sidekiq-unique-jobs (6.0.24)
       concurrent-ruby (~> 1.0, >= 1.0.5)
       sidekiq (>= 4.0, < 7.0)
       thor (>= 0.20, < 2.0)
     simple-navigation (4.1.0)
       activesupport (>= 2.3.2)
-    simple_form (5.0.2)
+    simple_form (5.0.3)
       actionpack (>= 5.0)
       activemodel (>= 5.0)
     simplecov (0.19.0)
       docile (~> 1.1)
       simplecov-html (~> 0.11)
-    simplecov-html (0.12.2)
+    simplecov-html (0.12.3)
     sprockets (3.7.2)
       concurrent-ruby (~> 1.0)
       rack (> 1, < 3)
@@ -642,16 +633,17 @@ GEM
       unf (~> 0.1.0)
     tzinfo (1.2.7)
       thread_safe (~> 0.1)
-    tzinfo-data (1.2020.1)
+    tzinfo-data (1.2020.2)
       tzinfo (>= 1.0.0)
     unf (0.1.4)
       unf_ext
     unf_ext (0.0.7.7)
     unicode-display_width (1.7.0)
     uniform_notifier (1.13.0)
-    w3c_validators (1.3.5)
+    w3c_validators (1.3.6)
       json (>= 1.8)
       nokogiri (~> 1.6)
+      rexml (~> 3.2)
     warden (1.2.9)
       rack (>= 2.0.9)
     webauthn (3.0.0.alpha1)
@@ -680,6 +672,7 @@ GEM
       websocket-extensions (>= 0.1.0)
     websocket-extensions (0.1.5)
     wisper (2.0.1)
+    xorcist (1.1.2)
     xpath (3.2.0)
       nokogiri (~> 1.8)
 
@@ -692,12 +685,12 @@ DEPENDENCIES
   activerecord-import (~> 1.0)
   addressable (~> 2.7)
   annotate (~> 3.1)
-  aws-sdk-s3 (~> 1.81)
+  aws-sdk-s3 (~> 1.83)
   better_errors (~> 2.8)
   binding_of_caller (~> 0.7)
   blurhash (~> 0.1)
   bootsnap (~> 1.4)
-  brakeman (~> 4.9)
+  brakeman (~> 4.10)
   browser
   bullet (~> 6.1)
   bundler-audit (~> 0.7)
@@ -728,7 +721,6 @@ DEPENDENCIES
   fog-core (<= 2.1.0)
   fog-openstack (~> 0.3)
   fuubar (~> 2.5)
-  goldfinger (~> 2.1)
   hamlit-rails (~> 0.2)
   health_check!
   hiredis (~> 0.6)
@@ -768,6 +760,7 @@ DEPENDENCIES
   pg (~> 1.2)
   pghero (~> 2.7)
   pkg-config (~> 1.4)
+  pluck_each (~> 0.1.3)
   posix-spawn
   premailer-rails
   private_address_check (~> 0.5)
@@ -787,12 +780,11 @@ DEPENDENCIES
   redis (~> 4.2)
   redis-namespace (~> 1.8)
   redis-rails (~> 5.0)
-  reek (~> 6.0)
   rqrcode (~> 1.1)
   rspec-rails (~> 4.0)
   rspec-sidekiq (~> 3.1)
   rspec_junit_formatter (~> 0.4)
-  rubocop (~> 0.91)
+  rubocop (~> 0.93)
   rubocop-rails (~> 2.8)
   ruby-progressbar (~> 1.10)
   sanitize (~> 5.2)
@@ -819,9 +811,10 @@ DEPENDENCIES
   webmock (~> 3.9)
   webpacker (~> 5.2)
   webpush
+  xorcist (~> 1.1)
 
 RUBY VERSION
-   ruby 2.6.6p146
+   ruby 2.7.1p83
 
 BUNDLED WITH
    2.1.4
diff --git a/app/controllers/activitypub/followers_synchronizations_controller.rb b/app/controllers/activitypub/followers_synchronizations_controller.rb
new file mode 100644
index 000000000..525031105
--- /dev/null
+++ b/app/controllers/activitypub/followers_synchronizations_controller.rb
@@ -0,0 +1,36 @@
+# frozen_string_literal: true
+
+class ActivityPub::FollowersSynchronizationsController < ActivityPub::BaseController
+  include SignatureVerification
+  include AccountOwnedConcern
+
+  before_action :require_signature!
+  before_action :set_items
+  before_action :set_cache_headers
+
+  def show
+    expires_in 0, public: false
+    render json: collection_presenter,
+           serializer: ActivityPub::CollectionSerializer,
+           adapter: ActivityPub::Adapter,
+           content_type: 'application/activity+json'
+  end
+
+  private
+
+  def uri_prefix
+    signed_request_account.uri[/http(s?):\/\/[^\/]+\//]
+  end
+
+  def set_items
+    @items = @account.followers.where(Account.arel_table[:uri].matches(uri_prefix + '%', false, true)).pluck(:uri)
+  end
+
+  def collection_presenter
+    ActivityPub::CollectionPresenter.new(
+      id: account_followers_synchronization_url(@account),
+      type: :ordered,
+      items: @items
+    )
+  end
+end
diff --git a/app/controllers/activitypub/inboxes_controller.rb b/app/controllers/activitypub/inboxes_controller.rb
index 3e67f3909..4c0e83122 100644
--- a/app/controllers/activitypub/inboxes_controller.rb
+++ b/app/controllers/activitypub/inboxes_controller.rb
@@ -11,6 +11,7 @@ class ActivityPub::InboxesController < ActivityPub::BaseController
 
   def create
     upgrade_account
+    process_collection_synchronization
     process_payload
     head 202
   end
@@ -52,6 +53,19 @@ class ActivityPub::InboxesController < ActivityPub::BaseController
     DeliveryFailureTracker.reset!(signed_request_account.inbox_url)
   end
 
+  def process_collection_synchronization
+    raw_params = request.headers['Collection-Synchronization']
+    return if raw_params.blank? || ENV['DISABLE_FOLLOWERS_SYNCHRONIZATION'] == 'true'
+
+    # Re-using the syntax for signature parameters
+    tree   = SignatureParamsParser.new.parse(raw_params)
+    params = SignatureParamsTransformer.new.apply(tree)
+
+    ActivityPub::PrepareFollowersSynchronizationService.new.call(signed_request_account, params)
+  rescue Parslet::ParseFailed
+    Rails.logger.warn 'Error parsing Collection-Synchronization header'
+  end
+
   def process_payload
     ActivityPub::ProcessingWorker.perform_async(signed_request_account.id, body, @account&.id)
   end
diff --git a/app/controllers/admin/ip_blocks_controller.rb b/app/controllers/admin/ip_blocks_controller.rb
new file mode 100644
index 000000000..92b8b0d2b
--- /dev/null
+++ b/app/controllers/admin/ip_blocks_controller.rb
@@ -0,0 +1,56 @@
+# frozen_string_literal: true
+
+module Admin
+  class IpBlocksController < BaseController
+    def index
+      authorize :ip_block, :index?
+
+      @ip_blocks = IpBlock.page(params[:page])
+      @form      = Form::IpBlockBatch.new
+    end
+
+    def new
+      authorize :ip_block, :create?
+
+      @ip_block = IpBlock.new(ip: '', severity: :no_access, expires_in: 1.year)
+    end
+
+    def create
+      authorize :ip_block, :create?
+
+      @ip_block = IpBlock.new(resource_params)
+
+      if @ip_block.save
+        log_action :create, @ip_block
+        redirect_to admin_ip_blocks_path, notice: I18n.t('admin.ip_blocks.created_msg')
+      else
+        render :new
+      end
+    end
+
+    def batch
+      @form = Form::IpBlockBatch.new(form_ip_block_batch_params.merge(current_account: current_account, action: action_from_button))
+      @form.save
+    rescue ActionController::ParameterMissing
+      flash[:alert] = I18n.t('admin.ip_blocks.no_ip_block_selected')
+    rescue Mastodon::NotPermittedError
+      flash[:alert] = I18n.t('admin.custom_emojis.not_permitted')
+    ensure
+      redirect_to admin_ip_blocks_path
+    end
+
+    private
+
+    def resource_params
+      params.require(:ip_block).permit(:ip, :severity, :comment, :expires_in)
+    end
+
+    def action_from_button
+      'delete' if params[:delete]
+    end
+
+    def form_ip_block_batch_params
+      params.require(:form_ip_block_batch).permit(ip_block_ids: [])
+    end
+  end
+end
diff --git a/app/controllers/api/v1/accounts_controller.rb b/app/controllers/api/v1/accounts_controller.rb
index 453929afe..6e909bbf2 100644
--- a/app/controllers/api/v1/accounts_controller.rb
+++ b/app/controllers/api/v1/accounts_controller.rb
@@ -20,7 +20,7 @@ class Api::V1::AccountsController < Api::BaseController
   end
 
   def create
-    token    = AppSignUpService.new.call(doorkeeper_token.application, account_params)
+    token    = AppSignUpService.new.call(doorkeeper_token.application, request.remote_ip, account_params)
     response = Doorkeeper::OAuth::TokenResponse.new(token)
 
     headers.merge!(response.headers)
@@ -42,7 +42,7 @@ class Api::V1::AccountsController < Api::BaseController
   end
 
   def mute
-    MuteService.new.call(current_user.account, @account, notifications: truthy_param?(:notifications), timelines_only: truthy_param?(:timelines_only))
+    MuteService.new.call(current_user.account, @account, notifications: truthy_param?(:notifications), timelines_only: truthy_param?(:timelines_only), duration: (params[:duration] || 0))
     render json: @account, serializer: REST::RelationshipSerializer, relationships: relationships
   end
 
diff --git a/app/controllers/api/v1/mutes_controller.rb b/app/controllers/api/v1/mutes_controller.rb
index a89f3d700..fd52511d7 100644
--- a/app/controllers/api/v1/mutes_controller.rb
+++ b/app/controllers/api/v1/mutes_controller.rb
@@ -6,25 +6,16 @@ class Api::V1::MutesController < Api::BaseController
   after_action :insert_pagination_headers
 
   def index
-    @data = @accounts = load_accounts
-    render json: @accounts, each_serializer: REST::AccountSerializer
+    @accounts = load_accounts
+    render json: @accounts, each_serializer: REST::MutedAccountSerializer
   end
 
-  def details
-    @data = @mutes = load_mutes
-    render json: @mutes, each_serializer: REST::MuteSerializer
-  end 
-
   private
 
   def load_accounts
     paginated_mutes.map(&:target_account)
   end
 
-  def load_mutes
-    paginated_mutes.includes(:account, :target_account).to_a
-  end
-
   def paginated_mutes
     @paginated_mutes ||= Mute.eager_load(:target_account)
                              .joins(:target_account)
@@ -43,34 +34,26 @@ class Api::V1::MutesController < Api::BaseController
 
   def next_path
     if records_continue?
-      url_for pagination_params(max_id: pagination_max_id)
+      api_v1_mutes_url pagination_params(max_id: pagination_max_id)
     end
   end
 
   def prev_path
-    unless @data.empty?
-      url_for pagination_params(since_id: pagination_since_id)
+    unless paginated_mutes.empty?
+      api_v1_mutes_url pagination_params(since_id: pagination_since_id)
     end
   end
 
   def pagination_max_id
-    if params[:action] == "details"
-      @mutes.last.id
-    else
-      paginated_mutes.last.id
-    end
+    paginated_mutes.last.id
   end
 
   def pagination_since_id
-    if params[:action] == "details"
-      @mutes.first.id
-    else
-      paginated_mutes.first.id
-    end
+    paginated_mutes.first.id
   end
 
   def records_continue?
-    @data.size == limit_param(DEFAULT_ACCOUNTS_LIMIT)
+    paginated_mutes.size == limit_param(DEFAULT_ACCOUNTS_LIMIT)
   end
 
   def pagination_params(core_params)
diff --git a/app/controllers/auth/registrations_controller.rb b/app/controllers/auth/registrations_controller.rb
index 55975b274..c67757bbc 100644
--- a/app/controllers/auth/registrations_controller.rb
+++ b/app/controllers/auth/registrations_controller.rb
@@ -52,9 +52,9 @@ class Auth::RegistrationsController < Devise::RegistrationsController
   def build_resource(hash = nil)
     super(hash)
 
-    resource.locale             = I18n.locale
-    resource.invite_code        = params[:invite_code] if resource.invite_code.blank?
-    resource.current_sign_in_ip = request.remote_ip
+    resource.locale      = I18n.locale
+    resource.invite_code = params[:invite_code] if resource.invite_code.blank?
+    resource.sign_up_ip  = request.remote_ip
 
     resource.build_account if resource.account.nil?
   end
diff --git a/app/controllers/settings/preferences_controller.rb b/app/controllers/settings/preferences_controller.rb
index 0a7c53f83..3d6696fc4 100644
--- a/app/controllers/settings/preferences_controller.rb
+++ b/app/controllers/settings/preferences_controller.rb
@@ -48,6 +48,7 @@ class Settings::PreferencesController < Settings::BaseController
       :setting_display_media,
       :setting_expand_spoilers,
       :setting_reduce_motion,
+      :setting_disable_swiping,
       :setting_system_font_ui,
       :setting_system_emoji_font,
       :setting_noindex,
diff --git a/app/helpers/admin/action_logs_helper.rb b/app/helpers/admin/action_logs_helper.rb
index 8e398c3b2..0f3ca36e2 100644
--- a/app/helpers/admin/action_logs_helper.rb
+++ b/app/helpers/admin/action_logs_helper.rb
@@ -29,6 +29,8 @@ module Admin::ActionLogsHelper
       link_to record.target_account.acct, admin_account_path(record.target_account_id)
     when 'Announcement'
       link_to truncate(record.text), edit_admin_announcement_path(record.id)
+    when 'IpBlock'
+      "#{record.ip}/#{record.ip.prefix} (#{I18n.t("simple_form.labels.ip_block.severities.#{record.severity}")})"
     end
   end
 
@@ -48,6 +50,8 @@ module Admin::ActionLogsHelper
       end
     when 'Announcement'
       truncate(attributes['text'].is_a?(Array) ? attributes['text'].last : attributes['text'])
+    when 'IpBlock'
+      "#{attributes['ip']}/#{attributes['ip'].prefix} (#{I18n.t("simple_form.labels.ip_block.severities.#{attributes['severity']}")})"
     end
   end
 end
diff --git a/app/helpers/webfinger_helper.rb b/app/helpers/webfinger_helper.rb
index ab7ca4698..482f4e19e 100644
--- a/app/helpers/webfinger_helper.rb
+++ b/app/helpers/webfinger_helper.rb
@@ -1,38 +1,7 @@
 # frozen_string_literal: true
 
-# Monkey-patch on monkey-patch.
-# Because it conflicts with the request.rb patch.
-class HTTP::Timeout::PerOperationOriginal < HTTP::Timeout::PerOperation
-  def connect(socket_class, host, port, nodelay = false)
-    ::Timeout.timeout(@connect_timeout, HTTP::TimeoutError) do
-      @socket = socket_class.open(host, port)
-      @socket.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_NODELAY, 1) if nodelay
-    end
-  end
-end
-
 module WebfingerHelper
   def webfinger!(uri)
-    hidden_service_uri = /\.(onion|i2p)(:\d+)?$/.match(uri)
-
-    raise Mastodon::HostValidationError, 'Instance does not support hidden service connections' if !Rails.configuration.x.access_to_hidden_service && hidden_service_uri
-
-    opts = {
-      ssl: !hidden_service_uri,
-
-      headers: {
-        'User-Agent': Mastodon::Version.user_agent,
-      },
-
-      timeout_class: HTTP::Timeout::PerOperationOriginal,
-
-      timeout_options: {
-        write_timeout: 10,
-        connect_timeout: 5,
-        read_timeout: 10,
-      },
-    }
-
-    Goldfinger::Client.new(uri, opts.merge(Rails.configuration.x.http_client_proxy)).finger
+    Webfinger.new(uri).perform
   end
 end
diff --git a/app/javascript/core/admin.js b/app/javascript/core/admin.js
index f2334c254..bbc7cfac7 100644
--- a/app/javascript/core/admin.js
+++ b/app/javascript/core/admin.js
@@ -1,5 +1,6 @@
 //  This file will be loaded on admin pages, regardless of theme.
 
+import 'packs/public-path';
 import { delegate } from '@rails/ujs';
 import ready from '../mastodon/ready';
 
diff --git a/app/javascript/core/auth.js b/app/javascript/core/auth.js
index ca04730a3..d1d14d99e 100644
--- a/app/javascript/core/auth.js
+++ b/app/javascript/core/auth.js
@@ -1,2 +1,3 @@
+import 'packs/public-path';
 import './settings';
 import './two_factor_authentication';
diff --git a/app/javascript/core/common.js b/app/javascript/core/common.js
index 3b038ca40..1cee2f603 100644
--- a/app/javascript/core/common.js
+++ b/app/javascript/core/common.js
@@ -1,5 +1,6 @@
 //  This file will be loaded on all pages, regardless of theme.
 
+import 'packs/public-path';
 import 'font-awesome/css/font-awesome.css';
 
 require.context('../images/', true);
diff --git a/app/javascript/core/embed.js b/app/javascript/core/embed.js
index 6146e6592..9083eb7a3 100644
--- a/app/javascript/core/embed.js
+++ b/app/javascript/core/embed.js
@@ -1,5 +1,7 @@
 //  This file will be loaded on embed pages, regardless of theme.
 
+import 'packs/public-path';
+
 window.addEventListener('message', e => {
   const data = e.data || {};
 
diff --git a/app/javascript/core/public.js b/app/javascript/core/public.js
index 8c26f1404..b67fb13e5 100644
--- a/app/javascript/core/public.js
+++ b/app/javascript/core/public.js
@@ -1,5 +1,6 @@
 //  This file will be loaded on public pages, regardless of theme.
 
+import 'packs/public-path';
 import ready from '../mastodon/ready';
 
 const { delegate } = require('@rails/ujs');
diff --git a/app/javascript/core/settings.js b/app/javascript/core/settings.js
index 9403e339b..d5bb9532c 100644
--- a/app/javascript/core/settings.js
+++ b/app/javascript/core/settings.js
@@ -1,5 +1,6 @@
 //  This file will be loaded on settings pages, regardless of theme.
 
+import 'packs/public-path';
 import escapeTextContentForBrowser from 'escape-html';
 const { delegate } = require('@rails/ujs');
 import emojify from '../mastodon/features/emoji/emoji';
diff --git a/app/javascript/core/two_factor_authentication.js b/app/javascript/core/two_factor_authentication.js
index dde06be8c..f076cdf30 100644
--- a/app/javascript/core/two_factor_authentication.js
+++ b/app/javascript/core/two_factor_authentication.js
@@ -1,3 +1,4 @@
+import 'packs/public-path';
 import axios from 'axios';
 import * as WebAuthnJSON from '@github/webauthn-json';
 import ready from '../mastodon/ready';
diff --git a/app/javascript/flavours/glitch/actions/accounts.js b/app/javascript/flavours/glitch/actions/accounts.js
index 24606231c..a4eef2fd2 100644
--- a/app/javascript/flavours/glitch/actions/accounts.js
+++ b/app/javascript/flavours/glitch/actions/accounts.js
@@ -274,11 +274,11 @@ export function unblockAccountFail(error) {
 };
 
 
-export function muteAccount(id, notifications, timelinesOnly) {
+export function muteAccount(id, notifications, timelinesOnly, duration=0) {
   return (dispatch, getState) => {
     dispatch(muteAccountRequest(id));
 
-    api(getState).post(`/api/v1/accounts/${id}/mute`, { notifications, timelinesOnly }).then(response => {
+    api(getState).post(`/api/v1/accounts/${id}/mute`, { notifications, timelinesOnly, duration }).then(response => {
       // Pass in entire statuses map so we can use it to filter stuff in different parts of the reducers
       dispatch(muteAccountSuccess(response.data, getState().get('statuses')));
     }).catch(error => {
diff --git a/app/javascript/flavours/glitch/actions/markers.js b/app/javascript/flavours/glitch/actions/markers.js
index 80bcada6e..c0e7a93af 100644
--- a/app/javascript/flavours/glitch/actions/markers.js
+++ b/app/javascript/flavours/glitch/actions/markers.js
@@ -100,8 +100,12 @@ export function submitMarkersSuccess({ home, notifications }) {
   };
 };
 
-export function submitMarkers() {
-  return (dispatch, getState) => debouncedSubmitMarkers(dispatch, getState);
+export function submitMarkers(params = {}) {
+  const result = (dispatch, getState) => debouncedSubmitMarkers(dispatch, getState);
+  if (params.immediate === true) {
+    debouncedSubmitMarkers.flush();
+  }
+  return result;
 };
 
 export const fetchMarkers = () => (dispatch, getState) => {
diff --git a/app/javascript/flavours/glitch/actions/mutes.js b/app/javascript/flavours/glitch/actions/mutes.js
index 645261627..b85cc7863 100644
--- a/app/javascript/flavours/glitch/actions/mutes.js
+++ b/app/javascript/flavours/glitch/actions/mutes.js
@@ -13,6 +13,7 @@ export const MUTES_EXPAND_FAIL    = 'MUTES_EXPAND_FAIL';
 
 export const MUTES_INIT_MODAL = 'MUTES_INIT_MODAL';
 export const MUTES_TOGGLE_HIDE_NOTIFICATIONS = 'MUTES_TOGGLE_HIDE_NOTIFICATIONS';
+export const MUTES_CHANGE_DURATION = 'MUTES_CHANGE_DURATION';
 export const MUTES_TOGGLE_TIMELINES_ONLY = 'MUTES_TOGGLE_TIMELINES_ONLY';
 
 export function fetchMutes() {
@@ -106,6 +107,15 @@ export function toggleHideNotifications() {
   };
 }
 
+export function changeMuteDuration(duration) {
+  return dispatch => {
+    dispatch({
+      type: MUTES_CHANGE_DURATION,
+      duration,
+    });
+  };
+}
+
 export function toggleTimelinesOnly() {
   return dispatch => {
     dispatch({ type: MUTES_TOGGLE_TIMELINES_ONLY });
diff --git a/app/javascript/flavours/glitch/actions/notifications.js b/app/javascript/flavours/glitch/actions/notifications.js
index 7f311153b..eb7087027 100644
--- a/app/javascript/flavours/glitch/actions/notifications.js
+++ b/app/javascript/flavours/glitch/actions/notifications.js
@@ -16,6 +16,7 @@ import { getFiltersRegex } from 'flavours/glitch/selectors';
 import { usePendingItems as preferPendingItems } from 'flavours/glitch/util/initial_state';
 import compareId from 'flavours/glitch/util/compare_id';
 import { searchTextFromRawStatus } from 'flavours/glitch/actions/importer/normalizer';
+import { requestNotificationPermission } from 'flavours/glitch/util/notifications';
 
 export const NOTIFICATIONS_UPDATE = 'NOTIFICATIONS_UPDATE';
 export const NOTIFICATIONS_UPDATE_NOOP = 'NOTIFICATIONS_UPDATE_NOOP';
@@ -46,8 +47,12 @@ export const NOTIFICATIONS_UNMOUNT = 'NOTIFICATIONS_UNMOUNT';
 
 export const NOTIFICATIONS_SET_VISIBILITY = 'NOTIFICATIONS_SET_VISIBILITY';
 
+
 export const NOTIFICATIONS_MARK_AS_READ = 'NOTIFICATIONS_MARK_AS_READ';
 
+export const NOTIFICATIONS_SET_BROWSER_SUPPORT    = 'NOTIFICATIONS_SET_BROWSER_SUPPORT';
+export const NOTIFICATIONS_SET_BROWSER_PERMISSION = 'NOTIFICATIONS_SET_BROWSER_PERMISSION';
+
 defineMessages({
   mention: { id: 'notification.mention', defaultMessage: '{name} mentioned you' },
 });
@@ -327,3 +332,42 @@ export function markNotificationsAsRead() {
     type: NOTIFICATIONS_MARK_AS_READ,
   };
 };
+
+// Browser support
+export function setupBrowserNotifications() {
+  return dispatch => {
+    dispatch(setBrowserSupport('Notification' in window));
+    if ('Notification' in window) {
+      dispatch(setBrowserPermission(Notification.permission));
+    }
+
+    if ('Notification' in window && 'permissions' in navigator) {
+      navigator.permissions.query({ name: 'notifications' }).then((status) => {
+        status.onchange = () => dispatch(setBrowserPermission(Notification.permission));
+      });
+    }
+  };
+}
+
+export function requestBrowserPermission(callback = noOp) {
+  return dispatch => {
+    requestNotificationPermission((permission) => {
+      dispatch(setBrowserPermission(permission));
+      callback(permission);
+    });
+  };
+};
+
+export function setBrowserSupport (value) {
+  return {
+    type: NOTIFICATIONS_SET_BROWSER_SUPPORT,
+    value,
+  };
+}
+
+export function setBrowserPermission (value) {
+  return {
+    type: NOTIFICATIONS_SET_BROWSER_PERMISSION,
+    value,
+  };
+}
diff --git a/app/javascript/flavours/glitch/components/account.js b/app/javascript/flavours/glitch/components/account.js
index f3e58dfe3..23399c630 100644
--- a/app/javascript/flavours/glitch/components/account.js
+++ b/app/javascript/flavours/glitch/components/account.js
@@ -8,6 +8,7 @@ import IconButton from './icon_button';
 import { defineMessages, injectIntl } from 'react-intl';
 import ImmutablePureComponent from 'react-immutable-pure-component';
 import { me } from 'flavours/glitch/util/initial_state';
+import RelativeTimestamp from './relative_timestamp';
 
 const messages = defineMessages({
   follow: { id: 'account.follow', defaultMessage: 'Follow' },
@@ -116,6 +117,11 @@ class Account extends ImmutablePureComponent {
       }
     }
 
+    let mute_expires_at;
+    if (account.get('mute_expires_at')) {
+      mute_expires_at =  <div><RelativeTimestamp timestamp={account.get('mute_expires_at')} futureDate /></div>;
+    }
+
     return small ? (
       <Permalink
         className='account small'
@@ -138,6 +144,7 @@ class Account extends ImmutablePureComponent {
         <div className='account__wrapper'>
           <Permalink key={account.get('id')} className='account__display-name' href={account.get('url')} to={`/accounts/${account.get('id')}`}>
             <div className='account__avatar-wrapper'><Avatar account={account} size={36} /></div>
+            {mute_expires_at}
             <DisplayName account={account} />
           </Permalink>
           {buttons ?
diff --git a/app/javascript/flavours/glitch/components/autosuggest_emoji.js b/app/javascript/flavours/glitch/components/autosuggest_emoji.js
index c8609e48f..d04c1eb68 100644
--- a/app/javascript/flavours/glitch/components/autosuggest_emoji.js
+++ b/app/javascript/flavours/glitch/components/autosuggest_emoji.js
@@ -2,7 +2,7 @@ import React from 'react';
 import PropTypes from 'prop-types';
 import unicodeMapping from 'flavours/glitch/util/emoji/emoji_unicode_mapping_light';
 
-const assetHost = process.env.CDN_HOST || '';
+import { assetHost } from 'flavours/glitch/util/config';
 
 export default class AutosuggestEmoji extends React.PureComponent {
 
diff --git a/app/javascript/flavours/glitch/components/column_header.js b/app/javascript/flavours/glitch/components/column_header.js
index 01bd4a246..ccd0714f1 100644
--- a/app/javascript/flavours/glitch/components/column_header.js
+++ b/app/javascript/flavours/glitch/components/column_header.js
@@ -34,6 +34,7 @@ class ColumnHeader extends React.PureComponent {
     onMove: PropTypes.func,
     onClick: PropTypes.func,
     appendContent: PropTypes.node,
+    collapseIssues: PropTypes.bool,
   };
 
   state = {
@@ -88,7 +89,7 @@ class ColumnHeader extends React.PureComponent {
   }
 
   render () {
-    const { title, icon, active, children, pinned, multiColumn, extraButton, showBackButton, intl: { formatMessage }, placeholder, appendContent } = this.props;
+    const { title, icon, active, children, pinned, multiColumn, extraButton, showBackButton, intl: { formatMessage }, placeholder, appendContent, collapseIssues } = this.props;
     const { collapsed, animating } = this.state;
 
     const wrapperClassName = classNames('column-header__wrapper', {
@@ -150,7 +151,20 @@ class ColumnHeader extends React.PureComponent {
     }
 
     if (children || (multiColumn && this.props.onPin)) {
-      collapseButton = <button className={collapsibleButtonClassName} title={formatMessage(collapsed ? messages.show : messages.hide)} aria-label={formatMessage(collapsed ? messages.show : messages.hide)} aria-pressed={collapsed ? 'false' : 'true'} onClick={this.handleToggleClick}><Icon id='sliders' /></button>;
+      collapseButton = (
+        <button
+          className={collapsibleButtonClassName}
+          title={formatMessage(collapsed ? messages.show : messages.hide)}
+          aria-label={formatMessage(collapsed ? messages.show : messages.hide)}
+          aria-pressed={collapsed ? 'false' : 'true'}
+          onClick={this.handleToggleClick}
+        >
+          <i className='icon-with-badge'>
+            <Icon id='sliders' />
+            {collapseIssues && <i className='icon-with-badge__issue-badge' />}
+          </i>
+        </button>
+      );
     }
 
     const hasTitle = icon && title;
diff --git a/app/javascript/flavours/glitch/components/icon_with_badge.js b/app/javascript/flavours/glitch/components/icon_with_badge.js
index 219efc28c..a42ba4589 100644
--- a/app/javascript/flavours/glitch/components/icon_with_badge.js
+++ b/app/javascript/flavours/glitch/components/icon_with_badge.js
@@ -4,16 +4,18 @@ import Icon from 'flavours/glitch/components/icon';
 
 const formatNumber = num => num > 40 ? '40+' : num;
 
-const IconWithBadge = ({ id, count, className }) => (
+const IconWithBadge = ({ id, count, issueBadge, className }) => (
   <i className='icon-with-badge'>
     <Icon id={id} fixedWidth className={className} />
     {count > 0 && <i className='icon-with-badge__badge'>{formatNumber(count)}</i>}
+    {issueBadge && <i className='icon-with-badge__issue-badge' />}
   </i>
 );
 
 IconWithBadge.propTypes = {
   id: PropTypes.string.isRequired,
   count: PropTypes.number.isRequired,
+  issueBadge: PropTypes.bool,
   className: PropTypes.string,
 };
 
diff --git a/app/javascript/flavours/glitch/containers/mastodon.js b/app/javascript/flavours/glitch/containers/mastodon.js
index 8101be87e..762280bec 100644
--- a/app/javascript/flavours/glitch/containers/mastodon.js
+++ b/app/javascript/flavours/glitch/containers/mastodon.js
@@ -32,13 +32,6 @@ export default class Mastodon extends React.PureComponent {
 
   componentDidMount() {
     this.disconnect = store.dispatch(connectUserStream());
-
-    // Desktop notifications
-    // Ask after 1 minute
-    if (typeof window.Notification !== 'undefined' && Notification.permission === 'default') {
-      window.setTimeout(() => Notification.requestPermission(), 60 * 1000);
-    }
-
     store.dispatch(showOnboardingOnce());
   }
 
diff --git a/app/javascript/flavours/glitch/features/emoji_picker/index.js b/app/javascript/flavours/glitch/features/emoji_picker/index.js
index d0d9714a8..89219d739 100644
--- a/app/javascript/flavours/glitch/features/emoji_picker/index.js
+++ b/app/javascript/flavours/glitch/features/emoji_picker/index.js
@@ -13,6 +13,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
 import detectPassiveEvents from 'detect-passive-events';
 import { buildCustomEmojis, categoriesFromEmojis } from 'flavours/glitch/util/emoji';
 import { useSystemEmojiFont } from 'flavours/glitch/util/initial_state';
+import { assetHost } from 'flavours/glitch/util/config';
 
 const messages = defineMessages({
   emoji: { id: 'emoji_button.label', defaultMessage: 'Insert emoji' },
@@ -105,7 +106,6 @@ const mapDispatchToProps = (dispatch, { onPickEmoji }) => ({
   },
 });
 
-const assetHost = process.env.CDN_HOST || '';
 let EmojiPicker, Emoji; // load asynchronously
 
 const backgroundImageFn = () => `${assetHost}/emoji/sheet_10.png`;
diff --git a/app/javascript/flavours/glitch/features/getting_started/components/announcements.js b/app/javascript/flavours/glitch/features/getting_started/components/announcements.js
index acaa78fe3..cd81d07de 100644
--- a/app/javascript/flavours/glitch/features/getting_started/components/announcements.js
+++ b/app/javascript/flavours/glitch/features/getting_started/components/announcements.js
@@ -15,6 +15,7 @@ import EmojiPickerDropdown from 'flavours/glitch/features/emoji_picker';
 import AnimatedNumber from 'flavours/glitch/components/animated_number';
 import TransitionMotion from 'react-motion/lib/TransitionMotion';
 import spring from 'react-motion/lib/spring';
+import { assetHost } from 'flavours/glitch/util/config';
 
 const messages = defineMessages({
   close: { id: 'lightbox.close', defaultMessage: 'Close' },
@@ -153,8 +154,6 @@ class Content extends ImmutablePureComponent {
 
 }
 
-const assetHost = process.env.CDN_HOST || '';
-
 class Emoji extends React.PureComponent {
 
   static propTypes = {
diff --git a/app/javascript/flavours/glitch/features/notifications/components/column_settings.js b/app/javascript/flavours/glitch/features/notifications/components/column_settings.js
index e4d5d0eda..9748219dd 100644
--- a/app/javascript/flavours/glitch/features/notifications/components/column_settings.js
+++ b/app/javascript/flavours/glitch/features/notifications/components/column_settings.js
@@ -12,6 +12,10 @@ export default class ColumnSettings extends React.PureComponent {
     pushSettings: ImmutablePropTypes.map.isRequired,
     onChange: PropTypes.func.isRequired,
     onClear: PropTypes.func.isRequired,
+    onRequestNotificationPermission: PropTypes.func,
+    alertsEnabled: PropTypes.bool,
+    browserSupport: PropTypes.bool,
+    browserPermission: PropTypes.bool,
   };
 
   onPushChange = (path, checked) => {
@@ -19,7 +23,7 @@ export default class ColumnSettings extends React.PureComponent {
   }
 
   render () {
-    const { settings, pushSettings, onChange, onClear } = this.props;
+    const { settings, pushSettings, onChange, onClear, alertsEnabled, browserSupport, browserPermission } = this.props;
 
     const filterShowStr = <FormattedMessage id='notifications.column_settings.filter_bar.show' defaultMessage='Show' />;
     const filterAdvancedStr = <FormattedMessage id='notifications.column_settings.filter_bar.advanced' defaultMessage='Display all categories' />;
@@ -33,6 +37,12 @@ export default class ColumnSettings extends React.PureComponent {
 
     return (
       <div>
+        {alertsEnabled && browserSupport && browserPermission === 'denied' && (
+          <div className='column-settings__row column-settings__row--with-margin'>
+            <span className='warning-hint'><FormattedMessage id='notifications.permission_denied' defaultMessage='Desktop notifications are unavailable due to previously denied browser permissions request' /></span>
+          </div>
+        )}
+
         <div className='column-settings__row'>
           <ClearColumnButton onClick={onClear} />
         </div>
@@ -41,6 +51,7 @@ export default class ColumnSettings extends React.PureComponent {
           <span id='notifications-filter-bar' className='column-settings__section'>
             <FormattedMessage id='notifications.column_settings.filter_bar.category' defaultMessage='Quick filter bar' />
           </span>
+
           <div className='column-settings__row'>
             <SettingToggle id='show-filter-bar' prefix='notifications' settings={settings} settingPath={['quickFilter', 'show']} onChange={onChange} label={filterShowStr} />
             <SettingToggle id='show-filter-bar' prefix='notifications' settings={settings} settingPath={['quickFilter', 'advanced']} onChange={onChange} label={filterAdvancedStr} />
@@ -51,7 +62,7 @@ export default class ColumnSettings extends React.PureComponent {
           <span id='notifications-follow' className='column-settings__section'><FormattedMessage id='notifications.column_settings.follow' defaultMessage='New followers:' /></span>
 
           <div className='column-settings__row'>
-            <SettingToggle prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'follow']} onChange={onChange} label={alertStr} />
+            <SettingToggle disabled={browserPermission !== 'granted'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'follow']} onChange={onChange} label={alertStr} />
             {showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'follow']} meta={pushMeta} onChange={this.onPushChange} label={pushStr} />}
             <SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'follow']} onChange={onChange} label={showStr} />
             <SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'follow']} onChange={onChange} label={soundStr} />
@@ -62,7 +73,7 @@ export default class ColumnSettings extends React.PureComponent {
           <span id='notifications-follow-request' className='column-settings__section'><FormattedMessage id='notifications.column_settings.follow_request' defaultMessage='New follow requests:' /></span>
 
           <div className='column-settings__row'>
-            <SettingToggle prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'follow_request']} onChange={onChange} label={alertStr} />
+            <SettingToggle disabled={browserPermission !== 'granted'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'follow_request']} onChange={onChange} label={alertStr} />
             {showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'follow_request']} onChange={this.onPushChange} label={pushStr} />}
             <SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'follow_request']} onChange={onChange} label={showStr} />
             <SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'follow_request']} onChange={onChange} label={soundStr} />
@@ -73,7 +84,7 @@ export default class ColumnSettings extends React.PureComponent {
           <span id='notifications-favourite' className='column-settings__section'><FormattedMessage id='notifications.column_settings.favourite' defaultMessage='Favourites:' /></span>
 
           <div className='column-settings__row'>
-            <SettingToggle prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'favourite']} onChange={onChange} label={alertStr} />
+            <SettingToggle disabled={browserPermission !== 'granted'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'favourite']} onChange={onChange} label={alertStr} />
             {showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'favourite']} meta={pushMeta} onChange={this.onPushChange} label={pushStr} />}
             <SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'favourite']} onChange={onChange} label={showStr} />
             <SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'favourite']} onChange={onChange} label={soundStr} />
@@ -84,7 +95,7 @@ export default class ColumnSettings extends React.PureComponent {
           <span id='notifications-mention' className='column-settings__section'><FormattedMessage id='notifications.column_settings.mention' defaultMessage='Mentions:' /></span>
 
           <div className='column-settings__row'>
-            <SettingToggle prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'mention']} onChange={onChange} label={alertStr} />
+            <SettingToggle disabled={browserPermission !== 'granted'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'mention']} onChange={onChange} label={alertStr} />
             {showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'mention']} meta={pushMeta} onChange={this.onPushChange} label={pushStr} />}
             <SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'mention']} onChange={onChange} label={showStr} />
             <SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'mention']} onChange={onChange} label={soundStr} />
@@ -95,7 +106,7 @@ export default class ColumnSettings extends React.PureComponent {
           <span id='notifications-reblog' className='column-settings__section'><FormattedMessage id='notifications.column_settings.reblog' defaultMessage='Boosts:' /></span>
 
           <div className='column-settings__row'>
-            <SettingToggle prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'reblog']} onChange={onChange} label={alertStr} />
+            <SettingToggle disabled={browserPermission !== 'granted'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'reblog']} onChange={onChange} label={alertStr} />
             {showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'reblog']} meta={pushMeta} onChange={this.onPushChange} label={pushStr} />}
             <SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'reblog']} onChange={onChange} label={showStr} />
             <SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'reblog']} onChange={onChange} label={soundStr} />
@@ -106,12 +117,23 @@ export default class ColumnSettings extends React.PureComponent {
           <span id='notifications-poll' className='column-settings__section'><FormattedMessage id='notifications.column_settings.poll' defaultMessage='Poll results:' /></span>
 
           <div className='column-settings__row'>
-            <SettingToggle prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'poll']} onChange={onChange} label={alertStr} />
+            <SettingToggle disabled={browserPermission !== 'granted'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'poll']} onChange={onChange} label={alertStr} />
             {showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'poll']} meta={pushMeta} onChange={this.onPushChange} label={pushStr} />}
             <SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'poll']} onChange={onChange} label={showStr} />
             <SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'poll']} onChange={onChange} label={soundStr} />
           </div>
         </div>
+
+        <div role='group' aria-labelledby='notifications-status'>
+          <span id='notifications-status' className='column-settings__section'><FormattedMessage id='notifications.column_settings.status' defaultMessage='New toots:' /></span>
+
+          <div className='column-settings__row'>
+            <SettingToggle disabled={browserPermission !== 'granted'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'status']} onChange={onChange} label={alertStr} />
+            {showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'status']} meta={pushMeta} onChange={this.onPushChange} label={pushStr} />}
+            <SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'status']} onChange={onChange} label={showStr} />
+            <SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'status']} onChange={onChange} label={soundStr} />
+          </div>
+        </div>
       </div>
     );
   }
diff --git a/app/javascript/flavours/glitch/features/notifications/components/notifications_permission_banner.js b/app/javascript/flavours/glitch/features/notifications/components/notifications_permission_banner.js
new file mode 100644
index 000000000..8e77f5a03
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/notifications/components/notifications_permission_banner.js
@@ -0,0 +1,30 @@
+import React from 'react';
+import Icon from 'flavours/glitch/components/icon';
+import Button from 'flavours/glitch/components/button';
+import { requestBrowserPermission } from 'flavours/glitch/actions/notifications';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import { FormattedMessage } from 'react-intl';
+
+export default @connect(() => {})
+class NotificationsPermissionBanner extends React.PureComponent {
+
+  static propTypes = {
+    dispatch: PropTypes.func.isRequired,
+  };
+
+  handleClick = () => {
+    this.props.dispatch(requestBrowserPermission());
+  }
+
+  render () {
+    return (
+      <div className='notifications-permission-banner'>
+        <h2><FormattedMessage id='notifications_permission_banner.title' defaultMessage='Never miss a thing' /></h2>
+        <p><FormattedMessage id='notifications_permission_banner.how_to_control' defaultMessage="To receive notifications when Mastodon isn't open, enable desktop notifications. You can control precisely which types of interactions generate desktop notifications through the {icon} button above once they're enabled." values={{ icon: <Icon id='sliders' /> }} /></p>
+        <Button onClick={this.handleClick}><FormattedMessage id='notifications_permission_banner.enable' defaultMessage='Enable desktop notifications' /></Button>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/notifications/components/setting_toggle.js b/app/javascript/flavours/glitch/features/notifications/components/setting_toggle.js
index 0264b6815..e472f7c4f 100644
--- a/app/javascript/flavours/glitch/features/notifications/components/setting_toggle.js
+++ b/app/javascript/flavours/glitch/features/notifications/components/setting_toggle.js
@@ -13,6 +13,7 @@ export default class SettingToggle extends React.PureComponent {
     meta: PropTypes.node,
     onChange: PropTypes.func.isRequired,
     defaultValue: PropTypes.bool,
+    disabled: PropTypes.bool,
   }
 
   onChange = ({ target }) => {
@@ -20,12 +21,12 @@ export default class SettingToggle extends React.PureComponent {
   }
 
   render () {
-    const { prefix, settings, settingPath, label, meta, defaultValue } = this.props;
+    const { prefix, settings, settingPath, label, meta, defaultValue, disabled } = this.props;
     const id = ['setting-toggle', prefix, ...settingPath].filter(Boolean).join('-');
 
     return (
       <div className='setting-toggle'>
-        <Toggle id={id} checked={settings.getIn(settingPath, defaultValue)} onChange={this.onChange} onKeyDown={this.onKeyDown} />
+        <Toggle disabled={disabled} id={id} checked={settings.getIn(settingPath, defaultValue)} onChange={this.onChange} onKeyDown={this.onKeyDown} />
         <label htmlFor={id} className='setting-toggle__label'>{label}</label>
         {meta && <span className='setting-meta__label'>{meta}</span>}
       </div>
diff --git a/app/javascript/flavours/glitch/features/notifications/containers/column_settings_container.js b/app/javascript/flavours/glitch/features/notifications/containers/column_settings_container.js
index 4b863712a..c2564f44e 100644
--- a/app/javascript/flavours/glitch/features/notifications/containers/column_settings_container.js
+++ b/app/javascript/flavours/glitch/features/notifications/containers/column_settings_container.js
@@ -3,28 +3,55 @@ import { defineMessages, injectIntl } from 'react-intl';
 import ColumnSettings from '../components/column_settings';
 import { changeSetting } from 'flavours/glitch/actions/settings';
 import { setFilter } from 'flavours/glitch/actions/notifications';
-import { clearNotifications } from 'flavours/glitch/actions/notifications';
+import { clearNotifications, requestBrowserPermission } from 'flavours/glitch/actions/notifications';
 import { changeAlerts as changePushNotifications } from 'flavours/glitch/actions/push_notifications';
 import { openModal } from 'flavours/glitch/actions/modal';
+import { showAlert } from 'flavours/glitch/actions/alerts';
 
 const messages = defineMessages({
   clearMessage: { id: 'notifications.clear_confirmation', defaultMessage: 'Are you sure you want to permanently clear all your notifications?' },
   clearConfirm: { id: 'notifications.clear', defaultMessage: 'Clear notifications' },
+  permissionDenied: { id: 'notifications.permission_denied_alert', defaultMessage: 'Desktop notifications can\'t be enabled, as browser permission has been denied before' },
 });
 
 const mapStateToProps = state => ({
   settings: state.getIn(['settings', 'notifications']),
   pushSettings: state.get('push_notifications'),
+  alertsEnabled: state.getIn(['settings', 'notifications', 'alerts']).includes(true),
+  browserSupport: state.getIn(['notifications', 'browserSupport']),
+  browserPermission: state.getIn(['notifications', 'browserPermission']),
 });
 
 const mapDispatchToProps = (dispatch, { intl }) => ({
 
   onChange (path, checked) {
     if (path[0] === 'push') {
-      dispatch(changePushNotifications(path.slice(1), checked));
+      if (checked && typeof window.Notification !== 'undefined' && Notification.permission !== 'granted') {
+        dispatch(requestBrowserPermission((permission) => {
+          if (permission === 'granted') {
+            dispatch(changePushNotifications(path.slice(1), checked));
+          } else {
+            dispatch(showAlert(undefined, messages.permissionDenied));
+          }
+        }));
+      } else {
+        dispatch(changePushNotifications(path.slice(1), checked));
+      }
     } else if (path[0] === 'quickFilter') {
       dispatch(changeSetting(['notifications', ...path], checked));
       dispatch(setFilter('all'));
+    } else if (path[0] === 'alerts' && checked && typeof window.Notification !== 'undefined' && Notification.permission !== 'granted') {
+      if (checked && typeof window.Notification !== 'undefined' && Notification.permission !== 'granted') {
+        dispatch(requestBrowserPermission((permission) => {
+          if (permission === 'granted') {
+            dispatch(changeSetting(['notifications', ...path], checked));
+          } else {
+            dispatch(showAlert(undefined, messages.permissionDenied));
+          }
+        }));
+      } else {
+        dispatch(changeSetting(['notifications', ...path], checked));
+      }
     } else {
       dispatch(changeSetting(['notifications', ...path], checked));
     }
@@ -38,6 +65,10 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
     }));
   },
 
+  onRequestNotificationPermission () {
+    dispatch(requestBrowserPermission());
+  },
+
 });
 
 export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(ColumnSettings));
diff --git a/app/javascript/flavours/glitch/features/notifications/index.js b/app/javascript/flavours/glitch/features/notifications/index.js
index 475968caa..af928594d 100644
--- a/app/javascript/flavours/glitch/features/notifications/index.js
+++ b/app/javascript/flavours/glitch/features/notifications/index.js
@@ -27,6 +27,7 @@ import ScrollableList from 'flavours/glitch/components/scrollable_list';
 import LoadGap from 'flavours/glitch/components/load_gap';
 import Icon from 'flavours/glitch/components/icon';
 import compareId from 'flavours/glitch/util/compare_id';
+import NotificationsPermissionBanner from './components/notifications_permission_banner';
 
 import NotificationPurgeButtonsContainer from 'flavours/glitch/containers/notification_purge_buttons_container';
 
@@ -62,6 +63,7 @@ const mapStateToProps = state => ({
   notifCleaningActive: state.getIn(['notifications', 'cleaningMode']),
   lastReadId: state.getIn(['notifications', 'readMarkerId']),
   canMarkAsRead: state.getIn(['notifications', 'readMarkerId']) !== '0' && getNotifications(state).some(item => item !== null && compareId(item.get('id'), state.getIn(['notifications', 'readMarkerId'])) > 0),
+  needsNotificationPermission: state.getIn(['settings', 'notifications', 'alerts']).includes(true) && state.getIn(['notifications', 'browserSupport']) && state.getIn(['notifications', 'browserPermission']) === 'default',
 });
 
 /* glitch */
@@ -71,7 +73,7 @@ const mapDispatchToProps = dispatch => ({
   },
   onMarkAsRead() {
     dispatch(markNotificationsAsRead());
-    dispatch(submitMarkers());
+    dispatch(submitMarkers({ immediate: true }));
   },
   onMount() {
     dispatch(mountNotifications());
@@ -105,6 +107,7 @@ class Notifications extends React.PureComponent {
     onUnmount: PropTypes.func,
     lastReadId: PropTypes.string,
     canMarkAsRead: PropTypes.bool,
+    needsNotificationPermission: PropTypes.bool,
   };
 
   static defaultProps = {
@@ -211,7 +214,7 @@ class Notifications extends React.PureComponent {
   }
 
   render () {
-    const { intl, notifications, shouldUpdateScroll, isLoading, isUnread, columnId, multiColumn, hasMore, numPending, showFilterBar, lastReadId, canMarkAsRead } = this.props;
+    const { intl, notifications, shouldUpdateScroll, isLoading, isUnread, columnId, multiColumn, hasMore, numPending, showFilterBar, lastReadId, canMarkAsRead, needsNotificationPermission } = this.props;
     const { notifCleaning, notifCleaningActive } = this.props;
     const { animatingNCD } = this.state;
     const pinned = !!columnId;
@@ -257,6 +260,8 @@ class Notifications extends React.PureComponent {
         showLoading={isLoading && notifications.size === 0}
         hasMore={hasMore}
         numPending={numPending}
+        prepend={needsNotificationPermission && <NotificationsPermissionBanner />}
+        alwaysPrepend
         emptyMessage={emptyMessage}
         onLoadMore={this.handleLoadOlder}
         onLoadPending={this.handleLoadPending}
diff --git a/app/javascript/flavours/glitch/features/ui/components/focal_point_modal.js b/app/javascript/flavours/glitch/features/ui/components/focal_point_modal.js
index b790b29a0..5de3e26d5 100644
--- a/app/javascript/flavours/glitch/features/ui/components/focal_point_modal.js
+++ b/app/javascript/flavours/glitch/features/ui/components/focal_point_modal.js
@@ -20,6 +20,7 @@ import GIFV from 'flavours/glitch/components/gifv';
 import { me } from 'flavours/glitch/util/initial_state';
 import tesseractCorePath from 'tesseract.js-core/tesseract-core.wasm.js';
 import tesseractWorkerPath from 'tesseract.js/dist/worker.min.js';
+import { assetHost } from 'flavours/glitch/util/config';
 
 const messages = defineMessages({
   close: { id: 'lightbox.close', defaultMessage: 'Close' },
@@ -50,8 +51,6 @@ const removeExtraLineBreaks = str => str.replace(/\n\n/g, '******')
   .replace(/\n/g, ' ')
   .replace(/\*\*\*\*\*\*/g, '\n\n');
 
-const assetHost = process.env.CDN_HOST || '';
-
 class ImageLoader extends React.PureComponent {
 
   static propTypes = {
diff --git a/app/javascript/flavours/glitch/features/ui/components/mute_modal.js b/app/javascript/flavours/glitch/features/ui/components/mute_modal.js
index eb4bc02d2..5970ceddb 100644
--- a/app/javascript/flavours/glitch/features/ui/components/mute_modal.js
+++ b/app/javascript/flavours/glitch/features/ui/components/mute_modal.js
@@ -1,27 +1,34 @@
 import React from 'react';
 import { connect } from 'react-redux';
 import PropTypes from 'prop-types';
-import { injectIntl, FormattedMessage } from 'react-intl';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
 import Toggle from 'react-toggle';
 import Button from 'flavours/glitch/components/button';
 import { closeModal } from 'flavours/glitch/actions/modal';
 import { muteAccount } from 'flavours/glitch/actions/accounts';
-import { toggleHideNotifications } from 'flavours/glitch/actions/mutes';
+import { toggleHideNotifications, changeMuteDuration } from 'flavours/glitch/actions/mutes';
 import { toggleTimelinesOnly } from 'flavours/glitch/actions/mutes';
 
+const messages = defineMessages({
+  minutes: { id: 'intervals.full.minutes', defaultMessage: '{number, plural, one {# minute} other {# minutes}}' },
+  hours: { id: 'intervals.full.hours', defaultMessage: '{number, plural, one {# hour} other {# hours}}' },
+  days: { id: 'intervals.full.days', defaultMessage: '{number, plural, one {# day} other {# days}}' },
+  indefinite: { id: 'mute_modal.indefinite', defaultMessage: 'Indefinite' },
+});
 
 const mapStateToProps = state => {
   return {
     account: state.getIn(['mutes', 'new', 'account']),
     notifications: state.getIn(['mutes', 'new', 'notifications']),
+    muteDuration: state.getIn(['mutes', 'new', 'duration']),
     timelinesOnly: state.getIn(['mutes', 'new', 'timelines_only']),
   };
 };
 
 const mapDispatchToProps = dispatch => {
   return {
-    onConfirm(account, notifications, timelinesOnly) {
-      dispatch(muteAccount(account.get('id'), notifications, timelinesOnly));
+    onConfirm(account, notifications, timelinesOnly, muteDuration) {
+      dispatch(muteAccount(account.get('id'), notifications, timelinesOnly, muteDuration));
     },
 
     onClose() {
@@ -32,6 +39,10 @@ const mapDispatchToProps = dispatch => {
       dispatch(toggleHideNotifications());
     },
 
+    onChangeMuteDuration(e) {
+      dispatch(changeMuteDuration(e.target.value));
+    },
+
     onToggleTimelinesOnly() {
       dispatch(toggleTimelinesOnly());
     },
@@ -51,6 +62,8 @@ class MuteModal extends React.PureComponent {
     onToggleNotifications: PropTypes.func.isRequired,
     onTimelinesOnly: PropTypes.func.isRequired,
     intl: PropTypes.object.isRequired,
+    muteDuration: PropTypes.number.isRequired,
+    onChangeMuteDuration: PropTypes.func.isRequired,
   };
 
   componentDidMount() {
@@ -59,7 +72,7 @@ class MuteModal extends React.PureComponent {
 
   handleClick = () => {
     this.props.onClose();
-    this.props.onConfirm(this.props.account, this.props.notifications, this.props.timelinesOnly);
+    this.props.onConfirm(this.props.account, this.props.notifications, this.props.timelinesOnly, this.props.muteDuration);
   }
 
   handleCancel = () => {
@@ -74,12 +87,16 @@ class MuteModal extends React.PureComponent {
     this.props.onToggleNotifications();
   }
 
+  changeMuteDuration = (e) => {
+    this.props.onChangeMuteDuration(e);
+  }
+
   toggleTimelinesOnly = () => {
     this.props.onToggleTimelinesOnly();
   }
 
   render () {
-    const { account, notifications, timelinesOnly } = this.props;
+    const { account, notifications, timelinesOnly, muteDuration, intl } = this.props;
 
     return (
       <div className='modal-root__modal mute-modal'>
@@ -110,6 +127,21 @@ class MuteModal extends React.PureComponent {
               <Toggle id='mute-modal__timelines-only-checkbox' checked={timelinesOnly} onChange={this.toggleTimelinesOnly} />
             </label>
           </div>
+          <div>
+            <span><FormattedMessage id='mute_modal.duration' defaultMessage='Duration' />: </span>
+
+            {/* eslint-disable-next-line jsx-a11y/no-onchange */}
+            <select value={muteDuration} onChange={this.changeMuteDuration}>
+              <option value={0}>{intl.formatMessage(messages.indefinite)}</option>
+              <option value={300}>{intl.formatMessage(messages.minutes, { number: 5 })}</option>
+              <option value={1800}>{intl.formatMessage(messages.minutes, { number: 30 })}</option>
+              <option value={3600}>{intl.formatMessage(messages.hours, { number: 1 })}</option>
+              <option value={21600}>{intl.formatMessage(messages.hours, { number: 6 })}</option>
+              <option value={86400}>{intl.formatMessage(messages.days, { number: 1 })}</option>
+              <option value={259200}>{intl.formatMessage(messages.days, { number: 3 })}</option>
+              <option value={604800}>{intl.formatMessage(messages.days, { number: 7 })}</option>
+            </select>
+          </div>
         </div>
 
         <div className='mute-modal__action-bar'>
diff --git a/app/javascript/flavours/glitch/features/ui/index.js b/app/javascript/flavours/glitch/features/ui/index.js
index 9b0ff2426..9b4e99905 100644
--- a/app/javascript/flavours/glitch/features/ui/index.js
+++ b/app/javascript/flavours/glitch/features/ui/index.js
@@ -361,7 +361,7 @@ class UI extends React.Component {
     const visibility = !document[this.visibilityHiddenProp];
     this.props.dispatch(notificationsSetVisibility(visibility));
     if (visibility) {
-      this.props.dispatch(submitMarkers());
+      this.props.dispatch(submitMarkers({ immediate: true }));
     }
   }
 
@@ -387,7 +387,7 @@ class UI extends React.Component {
 
   componentDidMount () {
     this.hotkeys.__mousetrap__.stopCallback = (e, element) => {
-      return ['TEXTAREA', 'SELECT', 'INPUT'].includes(element.tagName) && !e.altKey;
+      return ['TEXTAREA', 'SELECT', 'INPUT'].includes(element.tagName);
     };
 
     if (typeof document.hidden !== 'undefined') { // Opera 12.10 and Firefox 18 and later support
diff --git a/app/javascript/flavours/glitch/packs/about.js b/app/javascript/flavours/glitch/packs/about.js
index bc0a4887b..2e2cce501 100644
--- a/app/javascript/flavours/glitch/packs/about.js
+++ b/app/javascript/flavours/glitch/packs/about.js
@@ -1,3 +1,4 @@
+import 'packs/public-path';
 import loadPolyfills from 'flavours/glitch/util/load_polyfills';
 
 function loaded() {
diff --git a/app/javascript/flavours/glitch/packs/common.js b/app/javascript/flavours/glitch/packs/common.js
index 1fedc890a..7dc34eba9 100644
--- a/app/javascript/flavours/glitch/packs/common.js
+++ b/app/javascript/flavours/glitch/packs/common.js
@@ -1,3 +1,4 @@
+import 'packs/public-path';
 import { start } from '@rails/ujs';
 
 start();
diff --git a/app/javascript/flavours/glitch/packs/error.js b/app/javascript/flavours/glitch/packs/error.js
index 81c86c3ab..9f692ad37 100644
--- a/app/javascript/flavours/glitch/packs/error.js
+++ b/app/javascript/flavours/glitch/packs/error.js
@@ -1,3 +1,4 @@
+import 'packs/public-path';
 import ready from 'flavours/glitch/util/ready';
 
 ready(() => {
diff --git a/app/javascript/flavours/glitch/packs/home.js b/app/javascript/flavours/glitch/packs/home.js
index b8f7b7d8e..d06688985 100644
--- a/app/javascript/flavours/glitch/packs/home.js
+++ b/app/javascript/flavours/glitch/packs/home.js
@@ -1,3 +1,4 @@
+import 'packs/public-path';
 import loadPolyfills from 'flavours/glitch/util/load_polyfills';
 
 loadPolyfills().then(() => {
diff --git a/app/javascript/flavours/glitch/packs/public.js b/app/javascript/flavours/glitch/packs/public.js
index b9b588dc6..c74e5c9af 100644
--- a/app/javascript/flavours/glitch/packs/public.js
+++ b/app/javascript/flavours/glitch/packs/public.js
@@ -1,3 +1,4 @@
+import 'packs/public-path';
 import loadPolyfills from 'flavours/glitch/util/load_polyfills';
 import ready from 'flavours/glitch/util/ready';
 import loadKeyboardExtensions from 'flavours/glitch/util/load_keyboard_extensions';
diff --git a/app/javascript/flavours/glitch/packs/settings.js b/app/javascript/flavours/glitch/packs/settings.js
index 8a9f23505..9c4d119c1 100644
--- a/app/javascript/flavours/glitch/packs/settings.js
+++ b/app/javascript/flavours/glitch/packs/settings.js
@@ -1,3 +1,4 @@
+import 'packs/public-path';
 import loadPolyfills from 'flavours/glitch/util/load_polyfills';
 import ready from 'flavours/glitch/util/ready';
 import loadKeyboardExtensions from 'flavours/glitch/util/load_keyboard_extensions';
diff --git a/app/javascript/flavours/glitch/packs/share.js b/app/javascript/flavours/glitch/packs/share.js
index 9f2aa2553..f4a97e201 100644
--- a/app/javascript/flavours/glitch/packs/share.js
+++ b/app/javascript/flavours/glitch/packs/share.js
@@ -1,3 +1,4 @@
+import 'packs/public-path';
 import loadPolyfills from 'flavours/glitch/util/load_polyfills';
 
 function loaded() {
diff --git a/app/javascript/flavours/glitch/reducers/mutes.js b/app/javascript/flavours/glitch/reducers/mutes.js
index d170c2594..f116e106a 100644
--- a/app/javascript/flavours/glitch/reducers/mutes.js
+++ b/app/javascript/flavours/glitch/reducers/mutes.js
@@ -3,6 +3,7 @@ import Immutable from 'immutable';
 import {
   MUTES_INIT_MODAL,
   MUTES_TOGGLE_HIDE_NOTIFICATIONS,
+  MUTES_CHANGE_DURATION,
   MUTES_TOGGLE_TIMELINES_ONLY,
 } from 'flavours/glitch/actions/mutes';
 
@@ -10,6 +11,7 @@ const initialState = Immutable.Map({
   new: Immutable.Map({
     account: null,
     notifications: true,
+    duration: 0,
     timelinesOnly: false,
   }),
 });
@@ -24,6 +26,8 @@ export default function mutes(state = initialState, action) {
     });
   case MUTES_TOGGLE_HIDE_NOTIFICATIONS:
     return state.updateIn(['new', 'notifications'], (old) => !old);
+  case MUTES_CHANGE_DURATION:
+    return state.setIn(['new', 'duration'], Number(action.duration));
   case MUTES_TOGGLE_TIMELINES_ONLY:
     return state.updateIn(['new', 'timelines_only'], (old) => !old);
   default:
diff --git a/app/javascript/flavours/glitch/reducers/notifications.js b/app/javascript/flavours/glitch/reducers/notifications.js
index e136369ae..b4c5ef71a 100644
--- a/app/javascript/flavours/glitch/reducers/notifications.js
+++ b/app/javascript/flavours/glitch/reducers/notifications.js
@@ -17,6 +17,8 @@ import {
   NOTIFICATIONS_ENTER_CLEARING_MODE,
   NOTIFICATIONS_MARK_ALL_FOR_DELETE,
   NOTIFICATIONS_MARK_AS_READ,
+  NOTIFICATIONS_SET_BROWSER_SUPPORT,
+  NOTIFICATIONS_SET_BROWSER_PERMISSION,
 } from 'flavours/glitch/actions/notifications';
 import {
   ACCOUNT_BLOCK_SUCCESS,
@@ -44,6 +46,8 @@ const initialState = ImmutableMap({
   isLoading: false,
   cleaningMode: false,
   isTabVisible: true,
+  browserSupport: false,
+  browserPermission: 'default',
   // notification removal mark of new notifs loaded whilst cleaningMode is true.
   markNewForDelete: false,
 });
@@ -185,7 +189,7 @@ const deleteMarkedNotifs = (state) => {
 
 const updateMounted = (state) => {
   state = state.update('mounted', count => count + 1);
-  if (!shouldCountUnreadNotifications(state)) {
+  if (!shouldCountUnreadNotifications(state, state.get('mounted') === 1)) {
     state = state.set('readMarkerId', state.get('lastReadId'));
     state = clearUnread(state);
   }
@@ -201,7 +205,7 @@ const updateVisibility = (state, visibility) => {
   return state;
 };
 
-const shouldCountUnreadNotifications = (state) => {
+const shouldCountUnreadNotifications = (state, ignoreScroll = false) => {
   const isTabVisible   = state.get('isTabVisible');
   const isOnTop        = state.get('top');
   const isMounted      = state.get('mounted') > 0;
@@ -209,7 +213,7 @@ const shouldCountUnreadNotifications = (state) => {
   const lastItem       = state.get('items').findLast(item => item !== null);
   const lastItemReached = !state.get('hasMore') || lastReadId === '0' || (lastItem && compareId(lastItem.get('id'), lastReadId) <= 0);
 
-  return !(isTabVisible && isOnTop && isMounted && lastItemReached);
+  return !(isTabVisible && (ignoreScroll || isOnTop) && isMounted && lastItemReached);
 };
 
 const recountUnread = (state, last_read_id) => {
@@ -275,6 +279,10 @@ export default function notifications(state = initialState, action) {
     return action.timeline === 'home' ?
       state.update(action.usePendingItems ? 'pendingItems' : 'items', items => items.first() ? items.unshift(null) : items) :
       state;
+  case NOTIFICATIONS_SET_BROWSER_SUPPORT:
+    return state.set('browserSupport', action.value);
+  case NOTIFICATIONS_SET_BROWSER_PERMISSION:
+    return state.set('browserPermission', action.value);
 
   case NOTIFICATION_MARK_FOR_DELETE:
     return markForDelete(state, action.id, action.yes);
diff --git a/app/javascript/flavours/glitch/reducers/relationships.js b/app/javascript/flavours/glitch/reducers/relationships.js
index 33eb5b425..49dd77ef5 100644
--- a/app/javascript/flavours/glitch/reducers/relationships.js
+++ b/app/javascript/flavours/glitch/reducers/relationships.js
@@ -45,7 +45,7 @@ const initialState = ImmutableMap();
 export default function relationships(state = initialState, action) {
   switch(action.type) {
   case ACCOUNT_FOLLOW_REQUEST:
-    return state.setIn([action.id, action.locked ? 'requested' : 'following'], true);
+    return state.getIn([action.id, 'following']) ? state : state.setIn([action.id, action.locked ? 'requested' : 'following'], true);
   case ACCOUNT_FOLLOW_FAIL:
     return state.setIn([action.id, action.locked ? 'requested' : 'following'], false);
   case ACCOUNT_UNFOLLOW_REQUEST:
diff --git a/app/javascript/flavours/glitch/reducers/settings.js b/app/javascript/flavours/glitch/reducers/settings.js
index ac7d59908..64d13e18c 100644
--- a/app/javascript/flavours/glitch/reducers/settings.js
+++ b/app/javascript/flavours/glitch/reducers/settings.js
@@ -33,12 +33,13 @@ const initialState = ImmutableMap({
 
   notifications: ImmutableMap({
     alerts: ImmutableMap({
-      follow: true,
+      follow: false,
       follow_request: false,
-      favourite: true,
-      reblog: true,
-      mention: true,
-      poll: true,
+      favourite: false,
+      reblog: false,
+      mention: false,
+      poll: false,
+      status: false,
     }),
 
     quickFilter: ImmutableMap({
@@ -54,6 +55,7 @@ const initialState = ImmutableMap({
       reblog: true,
       mention: true,
       poll: true,
+      status: true,
     }),
 
     sounds: ImmutableMap({
@@ -63,6 +65,7 @@ const initialState = ImmutableMap({
       reblog: true,
       mention: true,
       poll: true,
+      status: true,
     }),
   }),
 
diff --git a/app/javascript/flavours/glitch/styles/components/accounts.scss b/app/javascript/flavours/glitch/styles/components/accounts.scss
index 145219fa7..c0bbe5633 100644
--- a/app/javascript/flavours/glitch/styles/components/accounts.scss
+++ b/app/javascript/flavours/glitch/styles/components/accounts.scss
@@ -254,127 +254,6 @@
   text-align: center;
 }
 
-.column-settings__outer {
-  background: lighten($ui-base-color, 8%);
-  padding: 15px;
-}
-
-.column-settings__section {
-  color: $darker-text-color;
-  cursor: default;
-  display: block;
-  font-weight: 500;
-  margin-bottom: 10px;
-}
-
-.column-settings__hashtags {
-  .column-settings__row {
-    margin-bottom: 15px;
-  }
-
-  .column-select {
-    &__control {
-      @include search-input();
-
-      &::placeholder {
-        color: lighten($darker-text-color, 4%);
-      }
-
-      &::-moz-focus-inner {
-        border: 0;
-      }
-
-      &::-moz-focus-inner,
-      &:focus,
-      &:active {
-        outline: 0 !important;
-      }
-
-      &:focus {
-        background: lighten($ui-base-color, 4%);
-      }
-
-      @media screen and (max-width: 600px) {
-        font-size: 16px;
-      }
-    }
-
-    &__placeholder {
-      color: $dark-text-color;
-      padding-left: 2px;
-      font-size: 12px;
-    }
-
-    &__value-container {
-      padding-left: 6px;
-    }
-
-    &__multi-value {
-      background: lighten($ui-base-color, 8%);
-
-      &__remove {
-        cursor: pointer;
-
-        &:hover,
-        &:active,
-        &:focus {
-          background: lighten($ui-base-color, 12%);
-          color: lighten($darker-text-color, 4%);
-        }
-      }
-    }
-
-    &__multi-value__label,
-    &__input {
-      color: $darker-text-color;
-    }
-
-    &__clear-indicator,
-    &__dropdown-indicator {
-      cursor: pointer;
-      transition: none;
-      color: $dark-text-color;
-
-      &:hover,
-      &:active,
-      &:focus {
-        color: lighten($dark-text-color, 4%);
-      }
-    }
-
-    &__indicator-separator {
-      background-color: lighten($ui-base-color, 8%);
-    }
-
-    &__menu {
-      @include search-popout();
-      padding: 0;
-      background: $ui-secondary-color;
-    }
-
-    &__menu-list {
-      padding: 6px;
-    }
-
-    &__option {
-      color: $inverted-text-color;
-      border-radius: 4px;
-      font-size: 14px;
-
-      &--is-focused,
-      &--is-selected {
-        background: darken($ui-secondary-color, 10%);
-      }
-    }
-  }
-}
-
-.column-settings__row {
-  .text-btn {
-    margin-bottom: 15px;
-  }
-}
-
 .relationship-tag {
   color: $primary-text-color;
   margin-bottom: 4px;
diff --git a/app/javascript/flavours/glitch/styles/components/columns.scss b/app/javascript/flavours/glitch/styles/components/columns.scss
index 6b657660a..be32ae52e 100644
--- a/app/javascript/flavours/glitch/styles/components/columns.scss
+++ b/app/javascript/flavours/glitch/styles/components/columns.scss
@@ -463,6 +463,15 @@
   flex: 1;
 }
 
+.column-header__issue-btn {
+  color: $warning-red;
+
+  &:hover {
+    color: $error-red;
+    text-decoration: underline;
+  }
+}
+
 .column-header__icon {
   display: inline-block;
   margin-right: 5px;
@@ -560,3 +569,150 @@
     margin: 0 5px;
   }
 }
+
+.column-settings__outer {
+  background: lighten($ui-base-color, 8%);
+  padding: 15px;
+}
+
+.column-settings__section {
+  color: $darker-text-color;
+  cursor: default;
+  display: block;
+  font-weight: 500;
+  margin-bottom: 10px;
+}
+
+.column-settings__row--with-margin {
+  margin-bottom: 15px;
+}
+
+.column-settings__hashtags {
+  .column-settings__row {
+    margin-bottom: 15px;
+  }
+
+  .column-select {
+    &__control {
+      @include search-input();
+
+      &::placeholder {
+        color: lighten($darker-text-color, 4%);
+      }
+
+      &::-moz-focus-inner {
+        border: 0;
+      }
+
+      &::-moz-focus-inner,
+      &:focus,
+      &:active {
+        outline: 0 !important;
+      }
+
+      &:focus {
+        background: lighten($ui-base-color, 4%);
+      }
+
+      @media screen and (max-width: 600px) {
+        font-size: 16px;
+      }
+    }
+
+    &__placeholder {
+      color: $dark-text-color;
+      padding-left: 2px;
+      font-size: 12px;
+    }
+
+    &__value-container {
+      padding-left: 6px;
+    }
+
+    &__multi-value {
+      background: lighten($ui-base-color, 8%);
+
+      &__remove {
+        cursor: pointer;
+
+        &:hover,
+        &:active,
+        &:focus {
+          background: lighten($ui-base-color, 12%);
+          color: lighten($darker-text-color, 4%);
+        }
+      }
+    }
+
+    &__multi-value__label,
+    &__input {
+      color: $darker-text-color;
+    }
+
+    &__clear-indicator,
+    &__dropdown-indicator {
+      cursor: pointer;
+      transition: none;
+      color: $dark-text-color;
+
+      &:hover,
+      &:active,
+      &:focus {
+        color: lighten($dark-text-color, 4%);
+      }
+    }
+
+    &__indicator-separator {
+      background-color: lighten($ui-base-color, 8%);
+    }
+
+    &__menu {
+      @include search-popout();
+      padding: 0;
+      background: $ui-secondary-color;
+    }
+
+    &__menu-list {
+      padding: 6px;
+    }
+
+    &__option {
+      color: $inverted-text-color;
+      border-radius: 4px;
+      font-size: 14px;
+
+      &--is-focused,
+      &--is-selected {
+        background: darken($ui-secondary-color, 10%);
+      }
+    }
+  }
+}
+
+.column-settings__row {
+  .text-btn {
+    margin-bottom: 15px;
+  }
+}
+
+.notifications-permission-banner {
+  padding: 30px;
+  border-bottom: 1px solid lighten($ui-base-color, 8%);
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+
+  h2 {
+    font-size: 16px;
+    font-weight: 500;
+    margin-bottom: 15px;
+    text-align: center;
+  }
+
+  p {
+    color: $darker-text-color;
+    margin-bottom: 15px;
+    text-align: center;
+  }
+}
diff --git a/app/javascript/flavours/glitch/styles/components/index.scss b/app/javascript/flavours/glitch/styles/components/index.scss
index 04266c497..56d658d97 100644
--- a/app/javascript/flavours/glitch/styles/components/index.scss
+++ b/app/javascript/flavours/glitch/styles/components/index.scss
@@ -708,6 +708,17 @@
     line-height: 14px;
     color: $primary-text-color;
   }
+
+  &__issue-badge {
+    position: absolute;
+    left: 11px;
+    bottom: 1px;
+    display: block;
+    background: $error-red;
+    border-radius: 50%;
+    width: 0.625rem;
+    height: 0.625rem;
+  }
 }
 
 .column-link--transparent .icon-with-badge__badge {
diff --git a/app/javascript/flavours/glitch/styles/components/modal.scss b/app/javascript/flavours/glitch/styles/components/modal.scss
index f80045505..b9335b5b4 100644
--- a/app/javascript/flavours/glitch/styles/components/modal.scss
+++ b/app/javascript/flavours/glitch/styles/components/modal.scss
@@ -785,6 +785,22 @@
       }
     }
   }
+
+  select {
+    appearance: none;
+    box-sizing: border-box;
+    font-size: 14px;
+    color: $inverted-text-color;
+    display: inline-block;
+    width: auto;
+    outline: 0;
+    font-family: inherit;
+    background: $simple-background-color url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 14.933 18.467' height='19.698' width='15.929'><path d='M3.467 14.967l-3.393-3.5H14.86l-3.392 3.5c-1.866 1.925-3.666 3.5-4 3.5-.335 0-2.135-1.575-4-3.5zm.266-11.234L7.467 0 11.2 3.733l3.733 3.734H0l3.733-3.734z' fill='#{hex-color(darken($simple-background-color, 14%))}'/></svg>") no-repeat right 8px center / auto 16px;
+    border: 1px solid darken($simple-background-color, 14%);
+    border-radius: 4px;
+    padding: 6px 10px;
+    padding-right: 30px;
+  }
 }
 
 .confirmation-modal__container,
diff --git a/app/javascript/flavours/glitch/styles/mastodon-light/diff.scss b/app/javascript/flavours/glitch/styles/mastodon-light/diff.scss
index e5a5cc246..163b5220c 100644
--- a/app/javascript/flavours/glitch/styles/mastodon-light/diff.scss
+++ b/app/javascript/flavours/glitch/styles/mastodon-light/diff.scss
@@ -385,3 +385,8 @@
 .directory__tag > div {
   box-shadow: none;
 }
+
+.mute-modal select {
+  border: 1px solid lighten($ui-base-color, 8%);
+  background: $simple-background-color url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 14.933 18.467' height='19.698' width='15.929'><path d='M3.467 14.967l-3.393-3.5H14.86l-3.392 3.5c-1.866 1.925-3.666 3.5-4 3.5-.335 0-2.135-1.575-4-3.5zm.266-11.234L7.467 0 11.2 3.733l3.733 3.734H0l3.733-3.734z' fill='#{hex-color(lighten($ui-base-color, 8%))}'/></svg>") no-repeat right 8px center / auto 16px;
+}
diff --git a/app/javascript/flavours/glitch/util/config.js b/app/javascript/flavours/glitch/util/config.js
new file mode 100644
index 000000000..c3e2b73ae
--- /dev/null
+++ b/app/javascript/flavours/glitch/util/config.js
@@ -0,0 +1,10 @@
+import ready from './ready';
+
+export let assetHost = '';
+
+ready(() => {
+  const cdnHost = document.querySelector('meta[name=cdn-host]');
+  if (cdnHost) {
+    assetHost = cdnHost.content || '';
+  }
+});
diff --git a/app/javascript/flavours/glitch/util/emoji/index.js b/app/javascript/flavours/glitch/util/emoji/index.js
index 233ec25e3..43ff4661c 100644
--- a/app/javascript/flavours/glitch/util/emoji/index.js
+++ b/app/javascript/flavours/glitch/util/emoji/index.js
@@ -1,11 +1,10 @@
 import { autoPlayGif, useSystemEmojiFont } from 'flavours/glitch/util/initial_state';
 import unicodeMapping from './emoji_unicode_mapping_light';
+import { assetHost } from 'flavours/glitch/util/config';
 import Trie from 'substring-trie';
 
 const trie = new Trie(Object.keys(unicodeMapping));
 
-const assetHost = process.env.CDN_HOST || '';
-
 // Convert to file names from emojis. (For different variation selector emojis)
 const emojiFilenames = (emojis) => {
   return emojis.map(v => unicodeMapping[v].filename);
diff --git a/app/javascript/flavours/glitch/util/main.js b/app/javascript/flavours/glitch/util/main.js
index 1fdb9ff2b..6577b70c2 100644
--- a/app/javascript/flavours/glitch/util/main.js
+++ b/app/javascript/flavours/glitch/util/main.js
@@ -1,4 +1,5 @@
 import * as registerPushNotifications from 'flavours/glitch/actions/push_notifications';
+import { setupBrowserNotifications } from 'flavours/glitch/actions/notifications';
 import { default as Mastodon, store } from 'flavours/glitch/containers/mastodon';
 import React from 'react';
 import ReactDOM from 'react-dom';
@@ -22,6 +23,7 @@ function main() {
     const props = JSON.parse(mountNode.getAttribute('data-props'));
 
     ReactDOM.render(<Mastodon {...props} />, mountNode);
+    store.dispatch(setupBrowserNotifications());
     if (process.env.NODE_ENV === 'production') {
       // avoid offline in dev mode because it's harder to debug
       require('offline-plugin/runtime').install();
diff --git a/app/javascript/flavours/glitch/util/notifications.js b/app/javascript/flavours/glitch/util/notifications.js
new file mode 100644
index 000000000..ab119c2e3
--- /dev/null
+++ b/app/javascript/flavours/glitch/util/notifications.js
@@ -0,0 +1,29 @@
+// Handles browser quirks, based on
+// https://developer.mozilla.org/en-US/docs/Web/API/Notifications_API/Using_the_Notifications_API
+
+const checkNotificationPromise = () => {
+  try {
+    Notification.requestPermission().then();
+  } catch(e) {
+    return false;
+  }
+
+  return true;
+};
+
+const handlePermission = (permission, callback) => {
+  // Whatever the user answers, we make sure Chrome stores the information
+  if(!('permission' in Notification)) {
+    Notification.permission = permission;
+  }
+
+  callback(Notification.permission);
+};
+
+export const requestNotificationPermission = (callback) => {
+  if (checkNotificationPromise()) {
+    Notification.requestPermission().then((permission) => handlePermission(permission, callback));
+  } else {
+    Notification.requestPermission((permission) => handlePermission(permission, callback));
+  }
+};
diff --git a/app/javascript/mastodon/actions/accounts.js b/app/javascript/mastodon/actions/accounts.js
index 723c04e55..58b636602 100644
--- a/app/javascript/mastodon/actions/accounts.js
+++ b/app/javascript/mastodon/actions/accounts.js
@@ -257,11 +257,11 @@ export function unblockAccountFail(error) {
 };
 
 
-export function muteAccount(id, notifications) {
+export function muteAccount(id, notifications, duration=0) {
   return (dispatch, getState) => {
     dispatch(muteAccountRequest(id));
 
-    api(getState).post(`/api/v1/accounts/${id}/mute`, { notifications }).then(response => {
+    api(getState).post(`/api/v1/accounts/${id}/mute`, { notifications, duration }).then(response => {
       // Pass in entire statuses map so we can use it to filter stuff in different parts of the reducers
       dispatch(muteAccountSuccess(response.data, getState().get('statuses')));
     }).catch(error => {
diff --git a/app/javascript/mastodon/actions/markers.js b/app/javascript/mastodon/actions/markers.js
index 41a95503e..c4b61effd 100644
--- a/app/javascript/mastodon/actions/markers.js
+++ b/app/javascript/mastodon/actions/markers.js
@@ -100,8 +100,12 @@ export function submitMarkersSuccess({ home, notifications }) {
   };
 };
 
-export function submitMarkers() {
-  return (dispatch, getState) => debouncedSubmitMarkers(dispatch, getState);
+export function submitMarkers(params = {}) {
+  const result = (dispatch, getState) => debouncedSubmitMarkers(dispatch, getState);
+  if (params.immediate === true) {
+    debouncedSubmitMarkers.flush();
+  }
+  return result;
 };
 
 export const fetchMarkers = () => (dispatch, getState) => {
diff --git a/app/javascript/mastodon/actions/mutes.js b/app/javascript/mastodon/actions/mutes.js
index 9f645faee..d8874f353 100644
--- a/app/javascript/mastodon/actions/mutes.js
+++ b/app/javascript/mastodon/actions/mutes.js
@@ -13,6 +13,7 @@ export const MUTES_EXPAND_FAIL    = 'MUTES_EXPAND_FAIL';
 
 export const MUTES_INIT_MODAL = 'MUTES_INIT_MODAL';
 export const MUTES_TOGGLE_HIDE_NOTIFICATIONS = 'MUTES_TOGGLE_HIDE_NOTIFICATIONS';
+export const MUTES_CHANGE_DURATION = 'MUTES_CHANGE_DURATION';
 
 export function fetchMutes() {
   return (dispatch, getState) => {
@@ -104,3 +105,12 @@ export function toggleHideNotifications() {
     dispatch({ type: MUTES_TOGGLE_HIDE_NOTIFICATIONS });
   };
 }
+
+export function changeMuteDuration(duration) {
+  return dispatch => {
+    dispatch({
+      type: MUTES_CHANGE_DURATION,
+      duration,
+    });
+  };
+}
diff --git a/app/javascript/mastodon/actions/notifications.js b/app/javascript/mastodon/actions/notifications.js
index 552def63b..c4fa66428 100644
--- a/app/javascript/mastodon/actions/notifications.js
+++ b/app/javascript/mastodon/actions/notifications.js
@@ -16,6 +16,7 @@ import { getFiltersRegex } from '../selectors';
 import { usePendingItems as preferPendingItems } from 'mastodon/initial_state';
 import compareId from 'mastodon/compare_id';
 import { searchTextFromRawStatus } from 'mastodon/actions/importer/normalizer';
+import { requestNotificationPermission } from '../utils/notifications';
 
 export const NOTIFICATIONS_UPDATE      = 'NOTIFICATIONS_UPDATE';
 export const NOTIFICATIONS_UPDATE_NOOP = 'NOTIFICATIONS_UPDATE_NOOP';
@@ -33,8 +34,12 @@ export const NOTIFICATIONS_LOAD_PENDING = 'NOTIFICATIONS_LOAD_PENDING';
 export const NOTIFICATIONS_MOUNT   = 'NOTIFICATIONS_MOUNT';
 export const NOTIFICATIONS_UNMOUNT = 'NOTIFICATIONS_UNMOUNT';
 
+
 export const NOTIFICATIONS_MARK_AS_READ = 'NOTIFICATIONS_MARK_AS_READ';
 
+export const NOTIFICATIONS_SET_BROWSER_SUPPORT    = 'NOTIFICATIONS_SET_BROWSER_SUPPORT';
+export const NOTIFICATIONS_SET_BROWSER_PERMISSION = 'NOTIFICATIONS_SET_BROWSER_PERMISSION';
+
 defineMessages({
   mention: { id: 'notification.mention', defaultMessage: '{name} mentioned you' },
   group: { id: 'notifications.group', defaultMessage: '{count} notifications' },
@@ -234,3 +239,47 @@ export const mountNotifications = () => ({
 export const unmountNotifications = () => ({
   type: NOTIFICATIONS_UNMOUNT,
 });
+
+
+export const markNotificationsAsRead = () => ({
+  type: NOTIFICATIONS_MARK_AS_READ,
+});
+
+// Browser support
+export function setupBrowserNotifications() {
+  return dispatch => {
+    dispatch(setBrowserSupport('Notification' in window));
+    if ('Notification' in window) {
+      dispatch(setBrowserPermission(Notification.permission));
+    }
+
+    if ('Notification' in window && 'permissions' in navigator) {
+      navigator.permissions.query({ name: 'notifications' }).then((status) => {
+        status.onchange = () => dispatch(setBrowserPermission(Notification.permission));
+      });
+    }
+  };
+}
+
+export function requestBrowserPermission(callback = noOp) {
+  return dispatch => {
+    requestNotificationPermission((permission) => {
+      dispatch(setBrowserPermission(permission));
+      callback(permission);
+    });
+  };
+};
+
+export function setBrowserSupport (value) {
+  return {
+    type: NOTIFICATIONS_SET_BROWSER_SUPPORT,
+    value,
+  };
+}
+
+export function setBrowserPermission (value) {
+  return {
+    type: NOTIFICATIONS_SET_BROWSER_PERMISSION,
+    value,
+  };
+}
diff --git a/app/javascript/mastodon/actions/onboarding.js b/app/javascript/mastodon/actions/onboarding.js
index a1dd3a731..42d8ea33f 100644
--- a/app/javascript/mastodon/actions/onboarding.js
+++ b/app/javascript/mastodon/actions/onboarding.js
@@ -1,8 +1,21 @@
 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/components/account.js b/app/javascript/mastodon/components/account.js
index 2705a6001..0e40ee1d6 100644
--- a/app/javascript/mastodon/components/account.js
+++ b/app/javascript/mastodon/components/account.js
@@ -8,6 +8,7 @@ import IconButton from './icon_button';
 import { defineMessages, injectIntl } from 'react-intl';
 import ImmutablePureComponent from 'react-immutable-pure-component';
 import { me } from '../initial_state';
+import RelativeTimestamp from './relative_timestamp';
 
 const messages = defineMessages({
   follow: { id: 'account.follow', defaultMessage: 'Follow' },
@@ -107,11 +108,17 @@ class Account extends ImmutablePureComponent {
       }
     }
 
+    let mute_expires_at;
+    if (account.get('mute_expires_at')) {
+      mute_expires_at =  <div><RelativeTimestamp timestamp={account.get('mute_expires_at')} futureDate /></div>;
+    }
+
     return (
       <div className='account'>
         <div className='account__wrapper'>
           <Permalink key={account.get('id')} className='account__display-name' title={account.get('acct')} href={account.get('url')} to={`/accounts/${account.get('id')}`}>
             <div className='account__avatar-wrapper'><Avatar account={account} size={36} /></div>
+            {mute_expires_at}
             <DisplayName account={account} />
           </Permalink>
 
diff --git a/app/javascript/mastodon/components/autosuggest_emoji.js b/app/javascript/mastodon/components/autosuggest_emoji.js
index ce4383a60..4937e4d98 100644
--- a/app/javascript/mastodon/components/autosuggest_emoji.js
+++ b/app/javascript/mastodon/components/autosuggest_emoji.js
@@ -1,8 +1,7 @@
 import React from 'react';
 import PropTypes from 'prop-types';
 import unicodeMapping from '../features/emoji/emoji_unicode_mapping_light';
-
-const assetHost = process.env.CDN_HOST || '';
+import { assetHost } from 'mastodon/utils/config';
 
 export default class AutosuggestEmoji extends React.PureComponent {
 
diff --git a/app/javascript/mastodon/components/column_header.js b/app/javascript/mastodon/components/column_header.js
index 1bb583583..236e92296 100644
--- a/app/javascript/mastodon/components/column_header.js
+++ b/app/javascript/mastodon/components/column_header.js
@@ -34,6 +34,7 @@ class ColumnHeader extends React.PureComponent {
     onMove: PropTypes.func,
     onClick: PropTypes.func,
     appendContent: PropTypes.node,
+    collapseIssues: PropTypes.bool,
   };
 
   state = {
@@ -83,7 +84,7 @@ class ColumnHeader extends React.PureComponent {
   }
 
   render () {
-    const { title, icon, active, children, pinned, multiColumn, extraButton, showBackButton, intl: { formatMessage }, placeholder, appendContent } = this.props;
+    const { title, icon, active, children, pinned, multiColumn, extraButton, showBackButton, intl: { formatMessage }, placeholder, appendContent, collapseIssues } = this.props;
     const { collapsed, animating } = this.state;
 
     const wrapperClassName = classNames('column-header__wrapper', {
@@ -145,7 +146,20 @@ class ColumnHeader extends React.PureComponent {
     }
 
     if (children || (multiColumn && this.props.onPin)) {
-      collapseButton = <button className={collapsibleButtonClassName} title={formatMessage(collapsed ? messages.show : messages.hide)} aria-label={formatMessage(collapsed ? messages.show : messages.hide)} aria-pressed={collapsed ? 'false' : 'true'} onClick={this.handleToggleClick}><Icon id='sliders' /></button>;
+      collapseButton = (
+        <button
+          className={collapsibleButtonClassName}
+          title={formatMessage(collapsed ? messages.show : messages.hide)}
+          aria-label={formatMessage(collapsed ? messages.show : messages.hide)}
+          aria-pressed={collapsed ? 'false' : 'true'}
+          onClick={this.handleToggleClick}
+        >
+          <i className='icon-with-badge'>
+            <Icon id='sliders' />
+            {collapseIssues && <i className='icon-with-badge__issue-badge' />}
+          </i>
+        </button>
+      );
     }
 
     const hasTitle = icon && title;
diff --git a/app/javascript/mastodon/components/icon_button.js b/app/javascript/mastodon/components/icon_button.js
index 7f83dc1b9..7ec39198a 100644
--- a/app/javascript/mastodon/components/icon_button.js
+++ b/app/javascript/mastodon/components/icon_button.js
@@ -116,6 +116,7 @@ export default class IconButton extends React.PureComponent {
       activate,
       deactivate,
       overlayed: overlay,
+      'icon-button--with-counter': typeof counter !== 'undefined',
     });
 
     if (typeof counter !== 'undefined') {
diff --git a/app/javascript/mastodon/components/icon_with_badge.js b/app/javascript/mastodon/components/icon_with_badge.js
index 7851eb4be..4214eccfd 100644
--- a/app/javascript/mastodon/components/icon_with_badge.js
+++ b/app/javascript/mastodon/components/icon_with_badge.js
@@ -4,16 +4,18 @@ import Icon from 'mastodon/components/icon';
 
 const formatNumber = num => num > 40 ? '40+' : num;
 
-const IconWithBadge = ({ id, count, className }) => (
+const IconWithBadge = ({ id, count, issueBadge, className }) => (
   <i className='icon-with-badge'>
     <Icon id={id} fixedWidth className={className} />
     {count > 0 && <i className='icon-with-badge__badge'>{formatNumber(count)}</i>}
+    {issueBadge && <i className='icon-with-badge__issue-badge' />}
   </i>
 );
 
 IconWithBadge.propTypes = {
   id: PropTypes.string.isRequired,
   count: PropTypes.number.isRequired,
+  issueBadge: PropTypes.bool,
   className: PropTypes.string,
 };
 
diff --git a/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js b/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js
index e8a36a923..bac136e5e 100644
--- a/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js
+++ b/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js
@@ -7,6 +7,7 @@ import classNames from 'classnames';
 import ImmutablePropTypes from 'react-immutable-proptypes';
 import detectPassiveEvents from 'detect-passive-events';
 import { buildCustomEmojis, categoriesFromEmojis } from '../../emoji/emoji';
+import { assetHost } from 'mastodon/utils/config';
 
 const messages = defineMessages({
   emoji: { id: 'emoji_button.label', defaultMessage: 'Insert emoji' },
@@ -25,7 +26,6 @@ const messages = defineMessages({
   flags: { id: 'emoji_button.flags', defaultMessage: 'Flags' },
 });
 
-const assetHost = process.env.CDN_HOST || '';
 let EmojiPicker, Emoji; // load asynchronously
 
 const backgroundImageFn = () => `${assetHost}/emoji/sheet_10.png`;
diff --git a/app/javascript/mastodon/features/emoji/emoji.js b/app/javascript/mastodon/features/emoji/emoji.js
index 5d9dad097..4e37f3a80 100644
--- a/app/javascript/mastodon/features/emoji/emoji.js
+++ b/app/javascript/mastodon/features/emoji/emoji.js
@@ -1,11 +1,10 @@
 import { autoPlayGif } from '../../initial_state';
 import unicodeMapping from './emoji_unicode_mapping_light';
+import { assetHost } from 'mastodon/utils/config';
 import Trie from 'substring-trie';
 
 const trie = new Trie(Object.keys(unicodeMapping));
 
-const assetHost = process.env.CDN_HOST || '';
-
 // Convert to file names from emojis. (For different variation selector emojis)
 const emojiFilenames = (emojis) => {
   return emojis.map(v => unicodeMapping[v].filename);
diff --git a/app/javascript/mastodon/features/getting_started/components/announcements.js b/app/javascript/mastodon/features/getting_started/components/announcements.js
index 1896994da..4853c3935 100644
--- a/app/javascript/mastodon/features/getting_started/components/announcements.js
+++ b/app/javascript/mastodon/features/getting_started/components/announcements.js
@@ -6,7 +6,7 @@ import PropTypes from 'prop-types';
 import IconButton from 'mastodon/components/icon_button';
 import Icon from 'mastodon/components/icon';
 import { defineMessages, injectIntl, FormattedMessage, FormattedDate } from 'react-intl';
-import { autoPlayGif, reduceMotion } from 'mastodon/initial_state';
+import { autoPlayGif, reduceMotion, disableSwiping } from 'mastodon/initial_state';
 import elephantUIPlane from 'mastodon/../images/elephant_ui_plane.svg';
 import { mascot } from 'mastodon/initial_state';
 import unicodeMapping from 'mastodon/features/emoji/emoji_unicode_mapping_light';
@@ -15,6 +15,7 @@ import EmojiPickerDropdown from 'mastodon/features/compose/containers/emoji_pick
 import AnimatedNumber from 'mastodon/components/animated_number';
 import TransitionMotion from 'react-motion/lib/TransitionMotion';
 import spring from 'react-motion/lib/spring';
+import { assetHost } from 'mastodon/utils/config';
 
 const messages = defineMessages({
   close: { id: 'lightbox.close', defaultMessage: 'Close' },
@@ -153,8 +154,6 @@ class Content extends ImmutablePureComponent {
 
 }
 
-const assetHost = process.env.CDN_HOST || '';
-
 class Emoji extends React.PureComponent {
 
   static propTypes = {
@@ -436,6 +435,7 @@ class Announcements extends ImmutablePureComponent {
                 removeReaction={this.props.removeReaction}
                 intl={intl}
                 selected={index === idx}
+                disabled={disableSwiping}
               />
             ))}
           </ReactSwipeableViews>
diff --git a/app/javascript/mastodon/features/getting_started/index.js b/app/javascript/mastodon/features/getting_started/index.js
index 02e1bbba5..1b9994612 100644
--- a/app/javascript/mastodon/features/getting_started/index.js
+++ b/app/javascript/mastodon/features/getting_started/index.js
@@ -10,7 +10,7 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
 import { me, profile_directory, showTrends } from '../../initial_state';
 import { fetchFollowRequests } from 'mastodon/actions/accounts';
 import { List as ImmutableList } from 'immutable';
-import NavigationBar from '../compose/components/navigation_bar';
+import NavigationContainer from '../compose/containers/navigation_container';
 import Icon from 'mastodon/components/icon';
 import LinkFooter from 'mastodon/features/ui/components/link_footer';
 import TrendsContainer from './containers/trends_container';
@@ -168,7 +168,7 @@ class GettingStarted extends ImmutablePureComponent {
 
         <div className='getting-started'>
           <div className='getting-started__wrapper' style={{ height }}>
-            {!multiColumn && <NavigationBar account={myAccount} />}
+            {!multiColumn && <NavigationContainer />}
             {navItems}
           </div>
 
diff --git a/app/javascript/mastodon/features/introduction/index.js b/app/javascript/mastodon/features/introduction/index.js
index 754477bb9..5820750a4 100644
--- a/app/javascript/mastodon/features/introduction/index.js
+++ b/app/javascript/mastodon/features/introduction/index.js
@@ -9,6 +9,7 @@ import screenHello from '../../../images/screen_hello.svg';
 import screenFederation from '../../../images/screen_federation.svg';
 import screenInteractions from '../../../images/screen_interactions.svg';
 import logoTransparent from '../../../images/logo_transparent.svg';
+import { disableSwiping } from 'mastodon/initial_state';
 
 const FrameWelcome = ({ domain, onNext }) => (
   <div className='introduction__frame'>
@@ -171,7 +172,7 @@ class Introduction extends React.PureComponent {
 
     return (
       <div className='introduction'>
-        <ReactSwipeableViews index={currentIndex} onChangeIndex={this.handleSwipe} className='introduction__pager'>
+        <ReactSwipeableViews index={currentIndex} onChangeIndex={this.handleSwipe} disabled={disableSwiping} className='introduction__pager'>
           {pages.map((page, i) => (
             <div key={i} className={classNames('introduction__frame-wrapper', { 'active': i === currentIndex })}>{page}</div>
           ))}
diff --git a/app/javascript/mastodon/features/notifications/components/column_settings.js b/app/javascript/mastodon/features/notifications/components/column_settings.js
index 8bd03fbda..169e4b44d 100644
--- a/app/javascript/mastodon/features/notifications/components/column_settings.js
+++ b/app/javascript/mastodon/features/notifications/components/column_settings.js
@@ -12,6 +12,10 @@ export default class ColumnSettings extends React.PureComponent {
     pushSettings: ImmutablePropTypes.map.isRequired,
     onChange: PropTypes.func.isRequired,
     onClear: PropTypes.func.isRequired,
+    onRequestNotificationPermission: PropTypes.func,
+    alertsEnabled: PropTypes.bool,
+    browserSupport: PropTypes.bool,
+    browserPermission: PropTypes.bool,
   };
 
   onPushChange = (path, checked) => {
@@ -19,7 +23,7 @@ export default class ColumnSettings extends React.PureComponent {
   }
 
   render () {
-    const { settings, pushSettings, onChange, onClear } = this.props;
+    const { settings, pushSettings, onChange, onClear, alertsEnabled, browserSupport, browserPermission } = this.props;
 
     const filterShowStr = <FormattedMessage id='notifications.column_settings.filter_bar.show' defaultMessage='Show' />;
     const filterAdvancedStr = <FormattedMessage id='notifications.column_settings.filter_bar.advanced' defaultMessage='Display all categories' />;
@@ -32,6 +36,12 @@ export default class ColumnSettings extends React.PureComponent {
 
     return (
       <div>
+        {alertsEnabled && browserSupport && browserPermission === 'denied' && (
+          <div className='column-settings__row column-settings__row--with-margin'>
+            <span className='warning-hint'><FormattedMessage id='notifications.permission_denied' defaultMessage='Desktop notifications are unavailable due to previously denied browser permissions request' /></span>
+          </div>
+        )}
+
         <div className='column-settings__row'>
           <ClearColumnButton onClick={onClear} />
         </div>
@@ -40,6 +50,7 @@ export default class ColumnSettings extends React.PureComponent {
           <span id='notifications-filter-bar' className='column-settings__section'>
             <FormattedMessage id='notifications.column_settings.filter_bar.category' defaultMessage='Quick filter bar' />
           </span>
+
           <div className='column-settings__row'>
             <SettingToggle id='show-filter-bar' prefix='notifications' settings={settings} settingPath={['quickFilter', 'show']} onChange={onChange} label={filterShowStr} />
             <SettingToggle id='show-filter-bar' prefix='notifications' settings={settings} settingPath={['quickFilter', 'advanced']} onChange={onChange} label={filterAdvancedStr} />
@@ -50,7 +61,7 @@ export default class ColumnSettings extends React.PureComponent {
           <span id='notifications-follow' className='column-settings__section'><FormattedMessage id='notifications.column_settings.follow' defaultMessage='New followers:' /></span>
 
           <div className='column-settings__row'>
-            <SettingToggle prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'follow']} onChange={onChange} label={alertStr} />
+            <SettingToggle disabled={browserPermission !== 'granted'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'follow']} onChange={onChange} label={alertStr} />
             {showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'follow']} onChange={this.onPushChange} label={pushStr} />}
             <SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'follow']} onChange={onChange} label={showStr} />
             <SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'follow']} onChange={onChange} label={soundStr} />
@@ -61,7 +72,7 @@ export default class ColumnSettings extends React.PureComponent {
           <span id='notifications-follow-request' className='column-settings__section'><FormattedMessage id='notifications.column_settings.follow_request' defaultMessage='New follow requests:' /></span>
 
           <div className='column-settings__row'>
-            <SettingToggle prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'follow_request']} onChange={onChange} label={alertStr} />
+            <SettingToggle disabled={browserPermission !== 'granted'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'follow_request']} onChange={onChange} label={alertStr} />
             {showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'follow_request']} onChange={this.onPushChange} label={pushStr} />}
             <SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'follow_request']} onChange={onChange} label={showStr} />
             <SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'follow_request']} onChange={onChange} label={soundStr} />
@@ -72,7 +83,7 @@ export default class ColumnSettings extends React.PureComponent {
           <span id='notifications-favourite' className='column-settings__section'><FormattedMessage id='notifications.column_settings.favourite' defaultMessage='Favourites:' /></span>
 
           <div className='column-settings__row'>
-            <SettingToggle prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'favourite']} onChange={onChange} label={alertStr} />
+            <SettingToggle disabled={browserPermission !== 'granted'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'favourite']} onChange={onChange} label={alertStr} />
             {showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'favourite']} onChange={this.onPushChange} label={pushStr} />}
             <SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'favourite']} onChange={onChange} label={showStr} />
             <SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'favourite']} onChange={onChange} label={soundStr} />
@@ -83,7 +94,7 @@ export default class ColumnSettings extends React.PureComponent {
           <span id='notifications-mention' className='column-settings__section'><FormattedMessage id='notifications.column_settings.mention' defaultMessage='Mentions:' /></span>
 
           <div className='column-settings__row'>
-            <SettingToggle prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'mention']} onChange={onChange} label={alertStr} />
+            <SettingToggle disabled={browserPermission !== 'granted'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'mention']} onChange={onChange} label={alertStr} />
             {showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'mention']} onChange={this.onPushChange} label={pushStr} />}
             <SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'mention']} onChange={onChange} label={showStr} />
             <SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'mention']} onChange={onChange} label={soundStr} />
@@ -94,7 +105,7 @@ export default class ColumnSettings extends React.PureComponent {
           <span id='notifications-reblog' className='column-settings__section'><FormattedMessage id='notifications.column_settings.reblog' defaultMessage='Boosts:' /></span>
 
           <div className='column-settings__row'>
-            <SettingToggle prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'reblog']} onChange={onChange} label={alertStr} />
+            <SettingToggle disabled={browserPermission !== 'granted'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'reblog']} onChange={onChange} label={alertStr} />
             {showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'reblog']} onChange={this.onPushChange} label={pushStr} />}
             <SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'reblog']} onChange={onChange} label={showStr} />
             <SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'reblog']} onChange={onChange} label={soundStr} />
@@ -105,12 +116,23 @@ export default class ColumnSettings extends React.PureComponent {
           <span id='notifications-poll' className='column-settings__section'><FormattedMessage id='notifications.column_settings.poll' defaultMessage='Poll results:' /></span>
 
           <div className='column-settings__row'>
-            <SettingToggle prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'poll']} onChange={onChange} label={alertStr} />
+            <SettingToggle disabled={browserPermission !== 'granted'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'poll']} onChange={onChange} label={alertStr} />
             {showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'poll']} onChange={this.onPushChange} label={pushStr} />}
             <SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'poll']} onChange={onChange} label={showStr} />
             <SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'poll']} onChange={onChange} label={soundStr} />
           </div>
         </div>
+
+        <div role='group' aria-labelledby='notifications-status'>
+          <span id='notifications-status' className='column-settings__section'><FormattedMessage id='notifications.column_settings.status' defaultMessage='New toots:' /></span>
+
+          <div className='column-settings__row'>
+            <SettingToggle disabled={browserPermission !== 'granted'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'status']} onChange={onChange} label={alertStr} />
+            {showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'status']} onChange={this.onPushChange} label={pushStr} />}
+            <SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'status']} onChange={onChange} label={showStr} />
+            <SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'status']} onChange={onChange} label={soundStr} />
+          </div>
+        </div>
       </div>
     );
   }
diff --git a/app/javascript/mastodon/features/notifications/components/notifications_permission_banner.js b/app/javascript/mastodon/features/notifications/components/notifications_permission_banner.js
new file mode 100644
index 000000000..766c9bb5b
--- /dev/null
+++ b/app/javascript/mastodon/features/notifications/components/notifications_permission_banner.js
@@ -0,0 +1,30 @@
+import React from 'react';
+import Icon from 'mastodon/components/icon';
+import Button from 'mastodon/components/button';
+import { requestBrowserPermission } from 'mastodon/actions/notifications';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import { FormattedMessage } from 'react-intl';
+
+export default @connect(() => {})
+class NotificationsPermissionBanner extends React.PureComponent {
+
+  static propTypes = {
+    dispatch: PropTypes.func.isRequired,
+  };
+
+  handleClick = () => {
+    this.props.dispatch(requestBrowserPermission());
+  }
+
+  render () {
+    return (
+      <div className='notifications-permission-banner'>
+        <h2><FormattedMessage id='notifications_permission_banner.title' defaultMessage='Never miss a thing' /></h2>
+        <p><FormattedMessage id='notifications_permission_banner.how_to_control' defaultMessage="To receive notifications when Mastodon isn't open, enable desktop notifications. You can control precisely which types of interactions generate desktop notifications through the {icon} button above once they're enabled." values={{ icon: <Icon id='sliders' /> }} /></p>
+        <Button onClick={this.handleClick}><FormattedMessage id='notifications_permission_banner.enable' defaultMessage='Enable desktop notifications' /></Button>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/mastodon/features/notifications/components/setting_toggle.js b/app/javascript/mastodon/features/notifications/components/setting_toggle.js
index e6f593ef8..c4c8bffbe 100644
--- a/app/javascript/mastodon/features/notifications/components/setting_toggle.js
+++ b/app/javascript/mastodon/features/notifications/components/setting_toggle.js
@@ -12,6 +12,7 @@ export default class SettingToggle extends React.PureComponent {
     label: PropTypes.node.isRequired,
     onChange: PropTypes.func.isRequired,
     defaultValue: PropTypes.bool,
+    disabled: PropTypes.bool,
   }
 
   onChange = ({ target }) => {
@@ -19,12 +20,12 @@ export default class SettingToggle extends React.PureComponent {
   }
 
   render () {
-    const { prefix, settings, settingPath, label, defaultValue } = this.props;
+    const { prefix, settings, settingPath, label, defaultValue, disabled } = this.props;
     const id = ['setting-toggle', prefix, ...settingPath].filter(Boolean).join('-');
 
     return (
       <div className='setting-toggle'>
-        <Toggle id={id} checked={settings.getIn(settingPath, defaultValue)} onChange={this.onChange} onKeyDown={this.onKeyDown} />
+        <Toggle disabled={disabled} id={id} checked={settings.getIn(settingPath, defaultValue)} onChange={this.onChange} onKeyDown={this.onKeyDown} />
         <label htmlFor={id} className='setting-toggle__label'>{label}</label>
       </div>
     );
diff --git a/app/javascript/mastodon/features/notifications/containers/column_settings_container.js b/app/javascript/mastodon/features/notifications/containers/column_settings_container.js
index a67f26295..9a70bd4f3 100644
--- a/app/javascript/mastodon/features/notifications/containers/column_settings_container.js
+++ b/app/javascript/mastodon/features/notifications/containers/column_settings_container.js
@@ -3,28 +3,55 @@ import { defineMessages, injectIntl } from 'react-intl';
 import ColumnSettings from '../components/column_settings';
 import { changeSetting } from '../../../actions/settings';
 import { setFilter } from '../../../actions/notifications';
-import { clearNotifications } from '../../../actions/notifications';
+import { clearNotifications, requestBrowserPermission } from '../../../actions/notifications';
 import { changeAlerts as changePushNotifications } from '../../../actions/push_notifications';
 import { openModal } from '../../../actions/modal';
+import { showAlert } from '../../../actions/alerts';
 
 const messages = defineMessages({
   clearMessage: { id: 'notifications.clear_confirmation', defaultMessage: 'Are you sure you want to permanently clear all your notifications?' },
   clearConfirm: { id: 'notifications.clear', defaultMessage: 'Clear notifications' },
+  permissionDenied: { id: 'notifications.permission_denied_alert', defaultMessage: 'Desktop notifications can\'t be enabled, as browser permission has been denied before' },
 });
 
 const mapStateToProps = state => ({
   settings: state.getIn(['settings', 'notifications']),
   pushSettings: state.get('push_notifications'),
+  alertsEnabled: state.getIn(['settings', 'notifications', 'alerts']).includes(true),
+  browserSupport: state.getIn(['notifications', 'browserSupport']),
+  browserPermission: state.getIn(['notifications', 'browserPermission']),
 });
 
 const mapDispatchToProps = (dispatch, { intl }) => ({
 
   onChange (path, checked) {
     if (path[0] === 'push') {
-      dispatch(changePushNotifications(path.slice(1), checked));
+      if (checked && typeof window.Notification !== 'undefined' && Notification.permission !== 'granted') {
+        dispatch(requestBrowserPermission((permission) => {
+          if (permission === 'granted') {
+            dispatch(changePushNotifications(path.slice(1), checked));
+          } else {
+            dispatch(showAlert(undefined, messages.permissionDenied));
+          }
+        }));
+      } else {
+        dispatch(changePushNotifications(path.slice(1), checked));
+      }
     } else if (path[0] === 'quickFilter') {
       dispatch(changeSetting(['notifications', ...path], checked));
       dispatch(setFilter('all'));
+    } else if (path[0] === 'alerts' && checked && typeof window.Notification !== 'undefined' && Notification.permission !== 'granted') {
+      if (checked && typeof window.Notification !== 'undefined' && Notification.permission !== 'granted') {
+        dispatch(requestBrowserPermission((permission) => {
+          if (permission === 'granted') {
+            dispatch(changeSetting(['notifications', ...path], checked));
+          } else {
+            dispatch(showAlert(undefined, messages.permissionDenied));
+          }
+        }));
+      } else {
+        dispatch(changeSetting(['notifications', ...path], checked));
+      }
     } else {
       dispatch(changeSetting(['notifications', ...path], checked));
     }
@@ -38,6 +65,10 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
     }));
   },
 
+  onRequestNotificationPermission () {
+    dispatch(requestBrowserPermission());
+  },
+
 });
 
 export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(ColumnSettings));
diff --git a/app/javascript/mastodon/features/notifications/index.js b/app/javascript/mastodon/features/notifications/index.js
index 41a45b2b6..73df7f49d 100644
--- a/app/javascript/mastodon/features/notifications/index.js
+++ b/app/javascript/mastodon/features/notifications/index.js
@@ -12,6 +12,7 @@ import {
   unmountNotifications,
   markNotificationsAsRead,
 } from '../../actions/notifications';
+import { submitMarkers } from '../../actions/markers';
 import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
 import NotificationContainer from './containers/notification_container';
 import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
@@ -24,6 +25,7 @@ import ScrollableList from '../../components/scrollable_list';
 import LoadGap from '../../components/load_gap';
 import Icon from 'mastodon/components/icon';
 import compareId from 'mastodon/compare_id';
+import NotificationsPermissionBanner from './components/notifications_permission_banner';
 
 const messages = defineMessages({
   title: { id: 'column.notifications', defaultMessage: 'Notifications' },
@@ -54,6 +56,7 @@ const mapStateToProps = state => ({
   numPending: state.getIn(['notifications', 'pendingItems'], ImmutableList()).size,
   lastReadId: state.getIn(['notifications', 'readMarkerId']),
   canMarkAsRead: state.getIn(['notifications', 'readMarkerId']) !== '0' && getNotifications(state).some(item => item !== null && compareId(item.get('id'), state.getIn(['notifications', 'readMarkerId'])) > 0),
+  needsNotificationPermission: state.getIn(['settings', 'notifications', 'alerts']).includes(true) && state.getIn(['notifications', 'browserSupport']) && state.getIn(['notifications', 'browserPermission']) === 'default',
 });
 
 export default @connect(mapStateToProps)
@@ -74,6 +77,7 @@ class Notifications extends React.PureComponent {
     numPending: PropTypes.number,
     lastReadId: PropTypes.string,
     canMarkAsRead: PropTypes.bool,
+    needsNotificationPermission: PropTypes.bool,
   };
 
   static defaultProps = {
@@ -162,10 +166,11 @@ class Notifications extends React.PureComponent {
 
   handleMarkAsRead = () => {
     this.props.dispatch(markNotificationsAsRead());
+    this.props.dispatch(submitMarkers({ immediate: true }));
   };
 
   render () {
-    const { intl, notifications, shouldUpdateScroll, isLoading, isUnread, columnId, multiColumn, hasMore, numPending, showFilterBar, lastReadId, canMarkAsRead } = this.props;
+    const { intl, notifications, shouldUpdateScroll, isLoading, isUnread, columnId, multiColumn, hasMore, numPending, showFilterBar, lastReadId, canMarkAsRead, needsNotificationPermission } = this.props;
     const pinned = !!columnId;
     const emptyMessage = <FormattedMessage id='empty_column.notifications' defaultMessage="You don't have any notifications yet. Interact with others to start the conversation." />;
 
@@ -209,6 +214,8 @@ class Notifications extends React.PureComponent {
         showLoading={isLoading && notifications.size === 0}
         hasMore={hasMore}
         numPending={numPending}
+        prepend={needsNotificationPermission && <NotificationsPermissionBanner />}
+        alwaysPrepend
         emptyMessage={emptyMessage}
         onLoadMore={this.handleLoadOlder}
         onLoadPending={this.handleLoadPending}
diff --git a/app/javascript/mastodon/features/ui/components/columns_area.js b/app/javascript/mastodon/features/ui/components/columns_area.js
index 9b03cf26d..ecc0b8f0b 100644
--- a/app/javascript/mastodon/features/ui/components/columns_area.js
+++ b/app/javascript/mastodon/features/ui/components/columns_area.js
@@ -8,6 +8,8 @@ import ReactSwipeableViews from 'react-swipeable-views';
 import TabsBar, { links, getIndex, getLink } from './tabs_bar';
 import { Link } from 'react-router-dom';
 
+import { disableSwiping } from 'mastodon/initial_state';
+
 import BundleContainer from '../containers/bundle_container';
 import ColumnLoading from './column_loading';
 import DrawerLoading from './drawer_loading';
@@ -185,7 +187,7 @@ class ColumnsArea extends ImmutablePureComponent {
       const floatingActionButton = shouldHideFAB(this.context.router.history.location.pathname) ? null : <Link key='floating-action-button' to='/statuses/new' className='floating-action-button' aria-label={intl.formatMessage(messages.publish)}><Icon id='pencil' /></Link>;
 
       const content = columnIndex !== -1 ? (
-        <ReactSwipeableViews key='content' hysteresis={0.2} threshold={15} index={columnIndex} onChangeIndex={this.handleSwipe} onTransitionEnd={this.handleAnimationEnd} animateTransitions={shouldAnimate} springConfig={{ duration: '400ms', delay: '0s', easeFunction: 'ease' }} style={{ height: '100%' }}>
+        <ReactSwipeableViews key='content' hysteresis={0.2} threshold={15} index={columnIndex} onChangeIndex={this.handleSwipe} onTransitionEnd={this.handleAnimationEnd} animateTransitions={shouldAnimate} springConfig={{ duration: '400ms', delay: '0s', easeFunction: 'ease' }} style={{ height: '100%' }} disabled={disableSwiping}>
           {links.map(this.renderView)}
         </ReactSwipeableViews>
       ) : (
diff --git a/app/javascript/mastodon/features/ui/components/focal_point_modal.js b/app/javascript/mastodon/features/ui/components/focal_point_modal.js
index 926a495d1..e19f277d8 100644
--- a/app/javascript/mastodon/features/ui/components/focal_point_modal.js
+++ b/app/javascript/mastodon/features/ui/components/focal_point_modal.js
@@ -20,6 +20,7 @@ import GIFV from 'mastodon/components/gifv';
 import { me } from 'mastodon/initial_state';
 import tesseractCorePath from 'tesseract.js-core/tesseract-core.wasm.js';
 import tesseractWorkerPath from 'tesseract.js/dist/worker.min.js';
+import { assetHost } from 'mastodon/utils/config';
 
 const messages = defineMessages({
   close: { id: 'lightbox.close', defaultMessage: 'Close' },
@@ -50,8 +51,6 @@ const removeExtraLineBreaks = str => str.replace(/\n\n/g, '******')
   .replace(/\n/g, ' ')
   .replace(/\*\*\*\*\*\*/g, '\n\n');
 
-const assetHost = process.env.CDN_HOST || '';
-
 class ImageLoader extends React.PureComponent {
 
   static propTypes = {
diff --git a/app/javascript/mastodon/features/ui/components/media_modal.js b/app/javascript/mastodon/features/ui/components/media_modal.js
index a02e3bbd7..54ec51fcf 100644
--- a/app/javascript/mastodon/features/ui/components/media_modal.js
+++ b/app/javascript/mastodon/features/ui/components/media_modal.js
@@ -10,6 +10,7 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
 import ImageLoader from './image_loader';
 import Icon from 'mastodon/components/icon';
 import GIFV from 'mastodon/components/gifv';
+import { disableSwiping } from 'mastodon/initial_state';
 
 const messages = defineMessages({
   close: { id: 'lightbox.close', defaultMessage: 'Close' },
@@ -212,6 +213,7 @@ class MediaModal extends ImmutablePureComponent {
             containerStyle={containerStyle}
             onChangeIndex={this.handleSwipe}
             index={index}
+            disabled={disableSwiping}
           >
             {content}
           </ReactSwipeableViews>
diff --git a/app/javascript/mastodon/features/ui/components/mute_modal.js b/app/javascript/mastodon/features/ui/components/mute_modal.js
index 852830c3c..d8d8e68c3 100644
--- a/app/javascript/mastodon/features/ui/components/mute_modal.js
+++ b/app/javascript/mastodon/features/ui/components/mute_modal.js
@@ -1,25 +1,32 @@
 import React from 'react';
 import { connect } from 'react-redux';
 import PropTypes from 'prop-types';
-import { injectIntl, FormattedMessage } from 'react-intl';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
 import Toggle from 'react-toggle';
 import Button from '../../../components/button';
 import { closeModal } from '../../../actions/modal';
 import { muteAccount } from '../../../actions/accounts';
-import { toggleHideNotifications } from '../../../actions/mutes';
+import { toggleHideNotifications, changeMuteDuration } from '../../../actions/mutes';
 
+const messages = defineMessages({
+  minutes: { id: 'intervals.full.minutes', defaultMessage: '{number, plural, one {# minute} other {# minutes}}' },
+  hours: { id: 'intervals.full.hours', defaultMessage: '{number, plural, one {# hour} other {# hours}}' },
+  days: { id: 'intervals.full.days', defaultMessage: '{number, plural, one {# day} other {# days}}' },
+  indefinite: { id: 'mute_modal.indefinite', defaultMessage: 'Indefinite' },
+});
 
 const mapStateToProps = state => {
   return {
     account: state.getIn(['mutes', 'new', 'account']),
     notifications: state.getIn(['mutes', 'new', 'notifications']),
+    muteDuration: state.getIn(['mutes', 'new', 'duration']),
   };
 };
 
 const mapDispatchToProps = dispatch => {
   return {
-    onConfirm(account, notifications) {
-      dispatch(muteAccount(account.get('id'), notifications));
+    onConfirm(account, notifications, muteDuration) {
+      dispatch(muteAccount(account.get('id'), notifications, muteDuration));
     },
 
     onClose() {
@@ -29,6 +36,10 @@ const mapDispatchToProps = dispatch => {
     onToggleNotifications() {
       dispatch(toggleHideNotifications());
     },
+
+    onChangeMuteDuration(e) {
+      dispatch(changeMuteDuration(e.target.value));
+    },
   };
 };
 
@@ -43,6 +54,8 @@ class MuteModal extends React.PureComponent {
     onConfirm: PropTypes.func.isRequired,
     onToggleNotifications: PropTypes.func.isRequired,
     intl: PropTypes.object.isRequired,
+    muteDuration: PropTypes.number.isRequired,
+    onChangeMuteDuration: PropTypes.func.isRequired,
   };
 
   componentDidMount() {
@@ -51,7 +64,7 @@ class MuteModal extends React.PureComponent {
 
   handleClick = () => {
     this.props.onClose();
-    this.props.onConfirm(this.props.account, this.props.notifications);
+    this.props.onConfirm(this.props.account, this.props.notifications, this.props.muteDuration);
   }
 
   handleCancel = () => {
@@ -66,8 +79,12 @@ class MuteModal extends React.PureComponent {
     this.props.onToggleNotifications();
   }
 
+  changeMuteDuration = (e) => {
+    this.props.onChangeMuteDuration(e);
+  }
+
   render () {
-    const { account, notifications } = this.props;
+    const { account, notifications, muteDuration, intl } = this.props;
 
     return (
       <div className='modal-root__modal mute-modal'>
@@ -91,6 +108,21 @@ class MuteModal extends React.PureComponent {
               <FormattedMessage id='mute_modal.hide_notifications' defaultMessage='Hide notifications from this user?' />
             </label>
           </div>
+          <div>
+            <span><FormattedMessage id='mute_modal.duration' defaultMessage='Duration' />: </span>
+
+            {/* eslint-disable-next-line jsx-a11y/no-onchange */}
+            <select value={muteDuration} onChange={this.changeMuteDuration}>
+              <option value={0}>{intl.formatMessage(messages.indefinite)}</option>
+              <option value={300}>{intl.formatMessage(messages.minutes, { number: 5 })}</option>
+              <option value={1800}>{intl.formatMessage(messages.minutes, { number: 30 })}</option>
+              <option value={3600}>{intl.formatMessage(messages.hours, { number: 1 })}</option>
+              <option value={21600}>{intl.formatMessage(messages.hours, { number: 6 })}</option>
+              <option value={86400}>{intl.formatMessage(messages.days, { number: 1 })}</option>
+              <option value={259200}>{intl.formatMessage(messages.days, { number: 3 })}</option>
+              <option value={604800}>{intl.formatMessage(messages.days, { number: 7 })}</option>
+            </select>
+          </div>
         </div>
 
         <div className='mute-modal__action-bar'>
diff --git a/app/javascript/mastodon/features/ui/index.js b/app/javascript/mastodon/features/ui/index.js
index d05133507..c6df49a5f 100644
--- a/app/javascript/mastodon/features/ui/index.js
+++ b/app/javascript/mastodon/features/ui/index.js
@@ -266,7 +266,7 @@ class UI extends React.PureComponent {
 
   handleWindowFocus = () => {
     this.props.dispatch(focusApp());
-    this.props.dispatch(submitMarkers());
+    this.props.dispatch(submitMarkers({ immediate: true }));
   }
 
   handleWindowBlur = () => {
@@ -366,10 +366,6 @@ class UI extends React.PureComponent {
       navigator.serviceWorker.addEventListener('message', this.handleServiceWorkerPostMessage);
     }
 
-    if (typeof window.Notification !== 'undefined' && Notification.permission === 'default') {
-      window.setTimeout(() => Notification.requestPermission(), 120 * 1000);
-    }
-
     this.props.dispatch(fetchMarkers());
     this.props.dispatch(expandHomeTimeline());
     this.props.dispatch(expandNotifications());
@@ -379,7 +375,7 @@ class UI extends React.PureComponent {
 
   componentDidMount () {
     this.hotkeys.__mousetrap__.stopCallback = (e, element) => {
-      return ['TEXTAREA', 'SELECT', 'INPUT'].includes(element.tagName) && !e.altKey;
+      return ['TEXTAREA', 'SELECT', 'INPUT'].includes(element.tagName);
     };
   }
 
diff --git a/app/javascript/mastodon/initial_state.js b/app/javascript/mastodon/initial_state.js
index 847d29dea..89b59051c 100644
--- a/app/javascript/mastodon/initial_state.js
+++ b/app/javascript/mastodon/initial_state.js
@@ -26,5 +26,6 @@ export const usePendingItems = getMeta('use_pending_items');
 export const showTrends = getMeta('trends');
 export const title = getMeta('title');
 export const cropImages = getMeta('crop_images');
+export const disableSwiping = getMeta('disable_swiping');
 
 export default initialState;
diff --git a/app/javascript/mastodon/locales/defaultMessages.json b/app/javascript/mastodon/locales/defaultMessages.json
index fcec001e9..db1158e50 100644
--- a/app/javascript/mastodon/locales/defaultMessages.json
+++ b/app/javascript/mastodon/locales/defaultMessages.json
@@ -168,10 +168,18 @@
   {
     "descriptors": [
       {
+        "defaultMessage": "This page could not be displayed correctly. This error is likely caused by a browser add-on or automatic translation tools.",
+        "id": "error.unexpected_crash.explanation_addons"
+      },
+      {
         "defaultMessage": "Due to a bug in our code or a browser compatibility issue, this page could not be displayed correctly.",
         "id": "error.unexpected_crash.explanation"
       },
       {
+        "defaultMessage": "Try disabling them and refreshing the page. If that does not help, you may still be able to use Mastodon through a different browser or native app.",
+        "id": "error.unexpected_crash.next_steps_addons"
+      },
+      {
         "defaultMessage": "Try refreshing the page. If that does not help, you may still be able to use Mastodon through a different browser or native app.",
         "id": "error.unexpected_crash.next_steps"
       },
@@ -268,6 +276,15 @@
   {
     "descriptors": [
       {
+        "defaultMessage": "Put it back",
+        "id": "picture_in_picture.restore"
+      }
+    ],
+    "path": "app/javascript/mastodon/components/picture_in_picture_placeholder.json"
+  },
+  {
+    "descriptors": [
+      {
         "defaultMessage": "Closed",
         "id": "poll.closed"
       },
@@ -636,6 +653,15 @@
   {
     "descriptors": [
       {
+        "defaultMessage": "Profile unavailable",
+        "id": "empty_column.account_unavailable"
+      }
+    ],
+    "path": "app/javascript/mastodon/features/account_gallery/index.json"
+  },
+  {
+    "descriptors": [
+      {
         "defaultMessage": "Toots",
         "id": "account.posts"
       },
@@ -797,6 +823,14 @@
         "id": "account.show_reblogs"
       },
       {
+        "defaultMessage": "Notify me when @{name} posts",
+        "id": "account.enable_notifications"
+      },
+      {
+        "defaultMessage": "Stop notifying me when @{name} posts",
+        "id": "account.disable_notifications"
+      },
+      {
         "defaultMessage": "Pinned toots",
         "id": "navigation_bar.pins"
       },
@@ -2126,6 +2160,18 @@
         "id": "confirmations.delete_list.confirm"
       },
       {
+        "defaultMessage": "Any followed user",
+        "id": "lists.replies_policy.all_replies"
+      },
+      {
+        "defaultMessage": "No one",
+        "id": "lists.replies_policy.no_replies"
+      },
+      {
+        "defaultMessage": "Members of the list",
+        "id": "lists.replies_policy.list_replies"
+      },
+      {
         "defaultMessage": "Edit list",
         "id": "lists.edit"
       },
@@ -2134,6 +2180,10 @@
         "id": "lists.delete"
       },
       {
+        "defaultMessage": "Show replies to:",
+        "id": "lists.replies_policy.title"
+      },
+      {
         "defaultMessage": "There is nothing in this list yet. When members of this list post new statuses, they will appear here.",
         "id": "empty_column.list"
       }
@@ -2219,6 +2269,10 @@
         "id": "notifications.column_settings.push"
       },
       {
+        "defaultMessage": "Desktop notifications are unavailable due to previously denied browser permissions request",
+        "id": "notifications.permission_denied"
+      },
+      {
         "defaultMessage": "Quick filter bar",
         "id": "notifications.column_settings.filter_bar.category"
       },
@@ -2245,6 +2299,10 @@
       {
         "defaultMessage": "Poll results:",
         "id": "notifications.column_settings.poll"
+      },
+      {
+        "defaultMessage": "New toots:",
+        "id": "notifications.column_settings.status"
       }
     ],
     "path": "app/javascript/mastodon/features/notifications/components/column_settings.json"
@@ -2272,6 +2330,10 @@
         "id": "notifications.filter.follows"
       },
       {
+        "defaultMessage": "Updates from people you follow",
+        "id": "notifications.filter.statuses"
+      },
+      {
         "defaultMessage": "All",
         "id": "notifications.filter.all"
       }
@@ -2314,6 +2376,10 @@
         "id": "notification.reblog"
       },
       {
+        "defaultMessage": "{name} just posted",
+        "id": "notification.status"
+      },
+      {
         "defaultMessage": "{name} has requested to follow you",
         "id": "notification.follow_request"
       }
@@ -2323,12 +2389,33 @@
   {
     "descriptors": [
       {
+        "defaultMessage": "Never miss a thing",
+        "id": "notifications_permission_banner.title"
+      },
+      {
+        "defaultMessage": "To receive notifications when Mastodon isn't open, enable desktop notifications. You can control precisely which types of interactions generate desktop notifications through the {icon} button above once they're enabled.",
+        "id": "notifications_permission_banner.how_to_control"
+      },
+      {
+        "defaultMessage": "Enable desktop notifications",
+        "id": "notifications_permission_banner.enable"
+      }
+    ],
+    "path": "app/javascript/mastodon/features/notifications/components/notifications_permission_banner.json"
+  },
+  {
+    "descriptors": [
+      {
         "defaultMessage": "Are you sure you want to permanently clear all your notifications?",
         "id": "notifications.clear_confirmation"
       },
       {
         "defaultMessage": "Clear notifications",
         "id": "notifications.clear"
+      },
+      {
+        "defaultMessage": "Desktop notifications can't be enabled, as browser permission has been denied before",
+        "id": "notifications.permission_denied_alert"
       }
     ],
     "path": "app/javascript/mastodon/features/notifications/containers/column_settings_container.json"
@@ -2340,6 +2427,10 @@
         "id": "column.notifications"
       },
       {
+        "defaultMessage": "Mark every notification as read",
+        "id": "notifications.mark_as_read"
+      },
+      {
         "defaultMessage": "You don't have any notifications yet. Interact with others to start the conversation.",
         "id": "empty_column.notifications"
       }
@@ -2349,6 +2440,47 @@
   {
     "descriptors": [
       {
+        "defaultMessage": "Reply",
+        "id": "status.reply"
+      },
+      {
+        "defaultMessage": "Reply to thread",
+        "id": "status.replyAll"
+      },
+      {
+        "defaultMessage": "Boost",
+        "id": "status.reblog"
+      },
+      {
+        "defaultMessage": "Boost with original visibility",
+        "id": "status.reblog_private"
+      },
+      {
+        "defaultMessage": "Unboost",
+        "id": "status.cancel_reblog_private"
+      },
+      {
+        "defaultMessage": "This post cannot be boosted",
+        "id": "status.cannot_reblog"
+      },
+      {
+        "defaultMessage": "Favourite",
+        "id": "status.favourite"
+      },
+      {
+        "defaultMessage": "Reply",
+        "id": "confirmations.reply.confirm"
+      },
+      {
+        "defaultMessage": "Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?",
+        "id": "confirmations.reply.message"
+      }
+    ],
+    "path": "app/javascript/mastodon/features/picture_in_picture/components/footer.json"
+  },
+  {
+    "descriptors": [
+      {
         "defaultMessage": "Pinned toot",
         "id": "column.pins"
       }
@@ -2799,6 +2931,14 @@
         "id": "upload_form.description"
       },
       {
+        "defaultMessage": "Analyzing picture…",
+        "id": "upload_modal.analyzing_picture"
+      },
+      {
+        "defaultMessage": "Preparing OCR…",
+        "id": "upload_modal.preparing_ocr"
+      },
+      {
         "defaultMessage": "Edit media",
         "id": "upload_modal.edit_media"
       },
@@ -2811,10 +2951,6 @@
         "id": "upload_form.thumbnail"
       },
       {
-        "defaultMessage": "Analyzing picture…",
-        "id": "upload_modal.analyzing_picture"
-      },
-      {
         "defaultMessage": "Detect text from picture",
         "id": "upload_modal.detect_text"
       },
@@ -2911,6 +3047,22 @@
   {
     "descriptors": [
       {
+        "defaultMessage": "{number, plural, one {# minute} other {# minutes}}",
+        "id": "intervals.full.minutes"
+      },
+      {
+        "defaultMessage": "{number, plural, one {# hour} other {# hours}}",
+        "id": "intervals.full.hours"
+      },
+      {
+        "defaultMessage": "{number, plural, one {# day} other {# days}}",
+        "id": "intervals.full.days"
+      },
+      {
+        "defaultMessage": "Indefinite",
+        "id": "mute_modal.indefinite"
+      },
+      {
         "defaultMessage": "Are you sure you want to mute {name}?",
         "id": "confirmations.mute.message"
       },
@@ -2923,6 +3075,10 @@
         "id": "mute_modal.hide_notifications"
       },
       {
+        "defaultMessage": "Duration",
+        "id": "mute_modal.duration"
+      },
+      {
         "defaultMessage": "Cancel",
         "id": "confirmation_modal.cancel"
       },
diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json
index bb07bbc15..e90be0a62 100644
--- a/app/javascript/mastodon/locales/en.json
+++ b/app/javascript/mastodon/locales/en.json
@@ -9,8 +9,10 @@
   "account.browse_more_on_origin_server": "Browse more on the original profile",
   "account.cancel_follow_request": "Cancel follow request",
   "account.direct": "Direct message @{name}",
+  "account.disable_notifications": "Stop notifying me when @{name} posts",
   "account.domain_blocked": "Domain blocked",
   "account.edit_profile": "Edit profile",
+  "account.enable_notifications": "Notify me when @{name} posts",
   "account.endorse": "Feature on profile",
   "account.follow": "Follow",
   "account.followers": "Followers",
@@ -170,7 +172,9 @@
   "empty_column.notifications": "You don't have any notifications yet. Interact with others to start the conversation.",
   "empty_column.public": "There is nothing here! Write something publicly, or manually follow users from other servers to fill it up",
   "error.unexpected_crash.explanation": "Due to a bug in our code or a browser compatibility issue, this page could not be displayed correctly.",
+  "error.unexpected_crash.explanation_addons": "This page could not be displayed correctly. This error is likely caused by a browser add-on or automatic translation tools.",
   "error.unexpected_crash.next_steps": "Try refreshing the page. If that does not help, you may still be able to use Mastodon through a different browser or native app.",
+  "error.unexpected_crash.next_steps_addons": "Try disabling them and refreshing the page. If that does not help, you may still be able to use Mastodon through a different browser or native app.",
   "errors.unexpected_crash.copy_stacktrace": "Copy stacktrace to clipboard",
   "errors.unexpected_crash.report_issue": "Report issue",
   "follow_request.authorize": "Authorize",
@@ -264,6 +268,10 @@
   "lists.edit.submit": "Change title",
   "lists.new.create": "Add list",
   "lists.new.title_placeholder": "New list title",
+  "lists.replies_policy.all_replies": "Any followed user",
+  "lists.replies_policy.list_replies": "Members of the list",
+  "lists.replies_policy.no_replies": "No one",
+  "lists.replies_policy.title": "Show replies to:",
   "lists.search": "Search among people you follow",
   "lists.subheading": "Your lists",
   "load_pending": "{count, plural, one {# new item} other {# new items}}",
@@ -271,7 +279,9 @@
   "media_gallery.toggle_visible": "Hide {number, plural, one {image} other {images}}",
   "missing_indicator.label": "Not found",
   "missing_indicator.sublabel": "This resource could not be found",
+  "mute_modal.duration": "Duration",
   "mute_modal.hide_notifications": "Hide notifications from this user?",
+  "mute_modal.indefinite": "Indefinite",
   "navigation_bar.apps": "Mobile apps",
   "navigation_bar.blocks": "Blocked users",
   "navigation_bar.bookmarks": "Bookmarks",
@@ -303,6 +313,7 @@
   "notification.own_poll": "Your poll has ended",
   "notification.poll": "A poll you have voted in has ended",
   "notification.reblog": "{name} boosted your toot",
+  "notification.status": "{name} just posted",
   "notifications.clear": "Clear notifications",
   "notifications.clear_confirmation": "Are you sure you want to permanently clear all your notifications?",
   "notifications.column_settings.alert": "Desktop notifications",
@@ -318,13 +329,22 @@
   "notifications.column_settings.reblog": "Boosts:",
   "notifications.column_settings.show": "Show in column",
   "notifications.column_settings.sound": "Play sound",
+  "notifications.column_settings.status": "New toots:",
   "notifications.filter.all": "All",
   "notifications.filter.boosts": "Boosts",
   "notifications.filter.favourites": "Favourites",
   "notifications.filter.follows": "Follows",
   "notifications.filter.mentions": "Mentions",
   "notifications.filter.polls": "Poll results",
+  "notifications.filter.statuses": "Updates from people you follow",
   "notifications.group": "{count} notifications",
+  "notifications.mark_as_read": "Mark every notification as read",
+  "notifications.permission_denied": "Desktop notifications are unavailable due to previously denied browser permissions request",
+  "notifications.permission_denied_alert": "Desktop notifications can't be enabled, as browser permission has been denied before",
+  "notifications_permission_banner.enable": "Enable desktop notifications",
+  "notifications_permission_banner.how_to_control": "To receive notifications when Mastodon isn't open, enable desktop notifications. You can control precisely which types of interactions generate desktop notifications through the {icon} button above once they're enabled.",
+  "notifications_permission_banner.title": "Never miss a thing",
+  "picture_in_picture.restore": "Put it back",
   "poll.closed": "Closed",
   "poll.refresh": "Refresh",
   "poll.total_people": "{count, plural, one {# person} other {# people}}",
@@ -451,6 +471,7 @@
   "upload_modal.detect_text": "Detect text from picture",
   "upload_modal.edit_media": "Edit media",
   "upload_modal.hint": "Click or drag the circle on the preview to choose the focal point which will always be in view on all thumbnails.",
+  "upload_modal.preparing_ocr": "Preparing OCR…",
   "upload_modal.preview_label": "Preview ({ratio})",
   "upload_progress.label": "Uploading...",
   "video.close": "Close video",
diff --git a/app/javascript/mastodon/locales/ja.json b/app/javascript/mastodon/locales/ja.json
index ef8d8d1e6..d26871c98 100644
--- a/app/javascript/mastodon/locales/ja.json
+++ b/app/javascript/mastodon/locales/ja.json
@@ -272,6 +272,8 @@
   "missing_indicator.label": "見つかりません",
   "missing_indicator.sublabel": "見つかりませんでした",
   "mute_modal.hide_notifications": "このユーザーからの通知を隠しますか?",
+  "mute_modal.duration": "ミュートする期間",
+  "mute_modal.indefinite": "無期限",
   "navigation_bar.apps": "アプリ",
   "navigation_bar.blocks": "ブロックしたユーザー",
   "navigation_bar.bookmarks": "ブックマーク",
diff --git a/app/javascript/mastodon/main.js b/app/javascript/mastodon/main.js
index da4884fd3..bda51f692 100644
--- a/app/javascript/mastodon/main.js
+++ b/app/javascript/mastodon/main.js
@@ -1,4 +1,5 @@
 import * as registerPushNotifications from './actions/push_notifications';
+import { setupBrowserNotifications } from './actions/notifications';
 import { default as Mastodon, store } from './containers/mastodon';
 import React from 'react';
 import ReactDOM from 'react-dom';
@@ -22,6 +23,7 @@ function main() {
     const props = JSON.parse(mountNode.getAttribute('data-props'));
 
     ReactDOM.render(<Mastodon {...props} />, mountNode);
+    store.dispatch(setupBrowserNotifications());
     if (process.env.NODE_ENV === 'production') {
       // avoid offline in dev mode because it's harder to debug
       require('offline-plugin/runtime').install();
diff --git a/app/javascript/mastodon/reducers/mutes.js b/app/javascript/mastodon/reducers/mutes.js
index 4672e5097..a9eb61ff8 100644
--- a/app/javascript/mastodon/reducers/mutes.js
+++ b/app/javascript/mastodon/reducers/mutes.js
@@ -3,12 +3,14 @@ import Immutable from 'immutable';
 import {
   MUTES_INIT_MODAL,
   MUTES_TOGGLE_HIDE_NOTIFICATIONS,
+  MUTES_CHANGE_DURATION,
 } from '../actions/mutes';
 
 const initialState = Immutable.Map({
   new: Immutable.Map({
     account: null,
     notifications: true,
+    duration: 0,
   }),
 });
 
@@ -21,6 +23,8 @@ export default function mutes(state = initialState, action) {
     });
   case MUTES_TOGGLE_HIDE_NOTIFICATIONS:
     return state.updateIn(['new', 'notifications'], (old) => !old);
+  case MUTES_CHANGE_DURATION:
+    return state.setIn(['new', 'duration'], Number(action.duration));
   default:
     return state;
   }
diff --git a/app/javascript/mastodon/reducers/notifications.js b/app/javascript/mastodon/reducers/notifications.js
index b01db806f..1d4874717 100644
--- a/app/javascript/mastodon/reducers/notifications.js
+++ b/app/javascript/mastodon/reducers/notifications.js
@@ -10,6 +10,8 @@ import {
   NOTIFICATIONS_MOUNT,
   NOTIFICATIONS_UNMOUNT,
   NOTIFICATIONS_MARK_AS_READ,
+  NOTIFICATIONS_SET_BROWSER_SUPPORT,
+  NOTIFICATIONS_SET_BROWSER_PERMISSION,
 } from '../actions/notifications';
 import {
   ACCOUNT_BLOCK_SUCCESS,
@@ -40,6 +42,8 @@ const initialState = ImmutableMap({
   readMarkerId: '0',
   isTabVisible: true,
   isLoading: false,
+  browserSupport: false,
+  browserPermission: 'default',
 });
 
 const notificationToMap = notification => ImmutableMap({
@@ -151,7 +155,7 @@ const deleteByStatus = (state, statusId) => {
 
 const updateMounted = (state) => {
   state = state.update('mounted', count => count + 1);
-  if (!shouldCountUnreadNotifications(state)) {
+  if (!shouldCountUnreadNotifications(state, state.get('mounted') === 1)) {
     state = state.set('readMarkerId', state.get('lastReadId'));
     state = clearUnread(state);
   }
@@ -167,14 +171,15 @@ const updateVisibility = (state, visibility) => {
   return state;
 };
 
-const shouldCountUnreadNotifications = (state) => {
+const shouldCountUnreadNotifications = (state, ignoreScroll = false) => {
   const isTabVisible   = state.get('isTabVisible');
   const isOnTop        = state.get('top');
   const isMounted      = state.get('mounted') > 0;
   const lastReadId     = state.get('lastReadId');
-  const lastItemReached = !state.get('hasMore') || lastReadId === '0' || (!state.get('items').isEmpty() && compareId(state.get('items').last().get('id'), lastReadId) <= 0);
+  const lastItem       = state.get('items').findLast(item => item !== null);
+  const lastItemReached = !state.get('hasMore') || lastReadId === '0' || (lastItem && compareId(lastItem.get('id'), lastReadId) <= 0);
 
-  return !(isTabVisible && isOnTop && isMounted && lastItemReached);
+  return !(isTabVisible && (ignoreScroll || isOnTop) && isMounted && lastItemReached);
 };
 
 const recountUnread = (state, last_read_id) => {
@@ -241,6 +246,10 @@ export default function notifications(state = initialState, action) {
   case NOTIFICATIONS_MARK_AS_READ:
     const lastNotification = state.get('items').find(item => item !== null);
     return lastNotification ? recountUnread(state, lastNotification.get('id')) : state;
+  case NOTIFICATIONS_SET_BROWSER_SUPPORT:
+    return state.set('browserSupport', action.value);
+  case NOTIFICATIONS_SET_BROWSER_PERMISSION:
+    return state.set('browserPermission', action.value);
   default:
     return state;
   }
diff --git a/app/javascript/mastodon/reducers/relationships.js b/app/javascript/mastodon/reducers/relationships.js
index 1d050cc63..53949258a 100644
--- a/app/javascript/mastodon/reducers/relationships.js
+++ b/app/javascript/mastodon/reducers/relationships.js
@@ -45,7 +45,7 @@ const initialState = ImmutableMap();
 export default function relationships(state = initialState, action) {
   switch(action.type) {
   case ACCOUNT_FOLLOW_REQUEST:
-    return state.setIn([action.id, action.locked ? 'requested' : 'following'], true);
+    return state.getIn([action.id, 'following']) ? state : state.setIn([action.id, action.locked ? 'requested' : 'following'], true);
   case ACCOUNT_FOLLOW_FAIL:
     return state.setIn([action.id, action.locked ? 'requested' : 'following'], false);
   case ACCOUNT_UNFOLLOW_REQUEST:
diff --git a/app/javascript/mastodon/reducers/settings.js b/app/javascript/mastodon/reducers/settings.js
index efef2ad9a..057fa353a 100644
--- a/app/javascript/mastodon/reducers/settings.js
+++ b/app/javascript/mastodon/reducers/settings.js
@@ -29,12 +29,13 @@ const initialState = ImmutableMap({
 
   notifications: ImmutableMap({
     alerts: ImmutableMap({
-      follow: true,
+      follow: false,
       follow_request: false,
-      favourite: true,
-      reblog: true,
-      mention: true,
-      poll: true,
+      favourite: false,
+      reblog: false,
+      mention: false,
+      poll: false,
+      status: false,
     }),
 
     quickFilter: ImmutableMap({
@@ -50,6 +51,7 @@ const initialState = ImmutableMap({
       reblog: true,
       mention: true,
       poll: true,
+      status: true,
     }),
 
     sounds: ImmutableMap({
@@ -59,6 +61,7 @@ const initialState = ImmutableMap({
       reblog: true,
       mention: true,
       poll: true,
+      status: true,
     }),
   }),
 
diff --git a/app/javascript/mastodon/utils/config.js b/app/javascript/mastodon/utils/config.js
new file mode 100644
index 000000000..932cd0cbf
--- /dev/null
+++ b/app/javascript/mastodon/utils/config.js
@@ -0,0 +1,10 @@
+import ready from '../ready';
+
+export let assetHost = '';
+
+ready(() => {
+  const cdnHost = document.querySelector('meta[name=cdn-host]');
+  if (cdnHost) {
+    assetHost = cdnHost.content || '';
+  }
+});
diff --git a/app/javascript/mastodon/utils/notifications.js b/app/javascript/mastodon/utils/notifications.js
new file mode 100644
index 000000000..ab119c2e3
--- /dev/null
+++ b/app/javascript/mastodon/utils/notifications.js
@@ -0,0 +1,29 @@
+// Handles browser quirks, based on
+// https://developer.mozilla.org/en-US/docs/Web/API/Notifications_API/Using_the_Notifications_API
+
+const checkNotificationPromise = () => {
+  try {
+    Notification.requestPermission().then();
+  } catch(e) {
+    return false;
+  }
+
+  return true;
+};
+
+const handlePermission = (permission, callback) => {
+  // Whatever the user answers, we make sure Chrome stores the information
+  if(!('permission' in Notification)) {
+    Notification.permission = permission;
+  }
+
+  callback(Notification.permission);
+};
+
+export const requestNotificationPermission = (callback) => {
+  if (checkNotificationPromise()) {
+    Notification.requestPermission().then((permission) => handlePermission(permission, callback));
+  } else {
+    Notification.requestPermission((permission) => handlePermission(permission, callback));
+  }
+};
diff --git a/app/javascript/packs/about.js b/app/javascript/packs/about.js
index 843cb2c87..892d825ec 100644
--- a/app/javascript/packs/about.js
+++ b/app/javascript/packs/about.js
@@ -1,3 +1,4 @@
+import './public-path';
 import loadPolyfills from '../mastodon/load_polyfills';
 import { start } from '../mastodon/common';
 
diff --git a/app/javascript/packs/application.js b/app/javascript/packs/application.js
index c65ebed74..91240aecf 100644
--- a/app/javascript/packs/application.js
+++ b/app/javascript/packs/application.js
@@ -1,3 +1,4 @@
+import './public-path';
 import loadPolyfills from '../mastodon/load_polyfills';
 import { start } from '../mastodon/common';
 
diff --git a/app/javascript/packs/common.js b/app/javascript/packs/common.js
index 5d42509c5..05dff8e49 100644
--- a/app/javascript/packs/common.js
+++ b/app/javascript/packs/common.js
@@ -1 +1,2 @@
+import './public-path';
 import 'styles/application.scss';
diff --git a/app/javascript/packs/error.js b/app/javascript/packs/error.js
index 685c89065..6376dc2f5 100644
--- a/app/javascript/packs/error.js
+++ b/app/javascript/packs/error.js
@@ -1,3 +1,4 @@
+import './public-path';
 import ready from '../mastodon/ready';
 
 ready(() => {
diff --git a/app/javascript/packs/public-path.js b/app/javascript/packs/public-path.js
new file mode 100644
index 000000000..f96109f4f
--- /dev/null
+++ b/app/javascript/packs/public-path.js
@@ -0,0 +1,21 @@
+// Dynamically set webpack's loading path depending on a meta header, in order
+// to share the same assets regardless of instance configuration.
+// See https://webpack.js.org/guides/public-path/#on-the-fly
+
+function removeOuterSlashes(string) {
+  return string.replace(/^\/*/, '').replace(/\/*$/, '');
+}
+
+function formatPublicPath(host = '', path = '') {
+  let formattedHost = removeOuterSlashes(host);
+  if (formattedHost && !/^http/i.test(formattedHost)) {
+    formattedHost = `//${formattedHost}`;
+  }
+  const formattedPath = removeOuterSlashes(path);
+  return `${formattedHost}/${formattedPath}/`;
+}
+
+const cdnHost = document.querySelector('meta[name=cdn-host]');
+
+// eslint-disable-next-line camelcase, no-undef, no-unused-vars
+__webpack_public_path__ = formatPublicPath(cdnHost ? cdnHost.content : '', process.env.PUBLIC_OUTPUT_PATH);
diff --git a/app/javascript/packs/public.js b/app/javascript/packs/public.js
index 5ad25a9b0..3f6700195 100644
--- a/app/javascript/packs/public.js
+++ b/app/javascript/packs/public.js
@@ -1,3 +1,4 @@
+import './public-path';
 import loadPolyfills from '../mastodon/load_polyfills';
 import ready from '../mastodon/ready';
 import { start } from '../mastodon/common';
diff --git a/app/javascript/packs/share.js b/app/javascript/packs/share.js
index 4ef23e1b2..1225d7b52 100644
--- a/app/javascript/packs/share.js
+++ b/app/javascript/packs/share.js
@@ -1,3 +1,4 @@
+import './public-path';
 import loadPolyfills from '../mastodon/load_polyfills';
 import { start } from '../mastodon/common';
 
diff --git a/app/javascript/styles/mastodon-light/diff.scss b/app/javascript/styles/mastodon-light/diff.scss
index 6b81e7623..64f4adb84 100644
--- a/app/javascript/styles/mastodon-light/diff.scss
+++ b/app/javascript/styles/mastodon-light/diff.scss
@@ -759,3 +759,8 @@ html {
 .compose-form .compose-form__warning {
   box-shadow: none;
 }
+
+.mute-modal select {
+  border: 1px solid lighten($ui-base-color, 8%);
+  background: $simple-background-color url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 14.933 18.467' height='19.698' width='15.929'><path d='M3.467 14.967l-3.393-3.5H14.86l-3.392 3.5c-1.866 1.925-3.666 3.5-4 3.5-.335 0-2.135-1.575-4-3.5zm.266-11.234L7.467 0 11.2 3.733l3.733 3.734H0l3.733-3.734z' fill='#{hex-color(lighten($ui-base-color, 8%))}'/></svg>") no-repeat right 8px center / auto 16px;
+}
diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss
index ff4bb3428..be554a502 100644
--- a/app/javascript/styles/mastodon/components.scss
+++ b/app/javascript/styles/mastodon/components.scss
@@ -163,8 +163,7 @@
 }
 
 .icon-button {
-  display: inline-flex;
-  align-items: center;
+  display: inline-block;
   padding: 0;
   color: $action-button-color;
   border: 0;
@@ -173,6 +172,7 @@
   cursor: pointer;
   transition: all 100ms ease-in;
   transition-property: background-color, color;
+  text-decoration: none;
 
   &:hover,
   &:active,
@@ -247,6 +247,12 @@
     }
   }
 
+  &--with-counter {
+    display: inline-flex;
+    align-items: center;
+    width: auto !important;
+  }
+
   &__counter {
     display: inline-block;
     width: 14px;
@@ -1152,6 +1158,10 @@
 
 .status__action-bar-button {
   margin-right: 18px;
+
+  &.icon-button--with-counter {
+    margin-right: 14px;
+  }
 }
 
 .status__action-bar-dropdown {
@@ -2408,6 +2418,17 @@ a.account__display-name {
     line-height: 14px;
     color: $primary-text-color;
   }
+
+  &__issue-badge {
+    position: absolute;
+    left: 11px;
+    bottom: 1px;
+    display: block;
+    background: $error-red;
+    border-radius: 50%;
+    width: 0.625rem;
+    height: 0.625rem;
+  }
 }
 
 .column-link--transparent .icon-with-badge__badge {
@@ -3443,6 +3464,15 @@ a.status-card.compact:hover {
   cursor: pointer;
 }
 
+.column-header__issue-btn {
+  color: $warning-red;
+
+  &:hover {
+    color: $error-red;
+    text-decoration: underline;
+  }
+}
+
 .column-header__icon {
   display: inline-block;
   margin-right: 5px;
@@ -3702,6 +3732,10 @@ a.status-card.compact:hover {
   margin-bottom: 10px;
 }
 
+.column-settings__row--with-margin {
+  margin-bottom: 15px;
+}
+
 .column-settings__hashtags {
   .column-settings__row {
     margin-bottom: 15px;
@@ -5044,6 +5078,22 @@ a.status-card.compact:hover {
       }
     }
   }
+
+  select {
+    appearance: none;
+    box-sizing: border-box;
+    font-size: 14px;
+    color: $inverted-text-color;
+    display: inline-block;
+    width: auto;
+    outline: 0;
+    font-family: inherit;
+    background: $simple-background-color url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 14.933 18.467' height='19.698' width='15.929'><path d='M3.467 14.967l-3.393-3.5H14.86l-3.392 3.5c-1.866 1.925-3.666 3.5-4 3.5-.335 0-2.135-1.575-4-3.5zm.266-11.234L7.467 0 11.2 3.733l3.733 3.734H0l3.733-3.734z' fill='#{hex-color(darken($simple-background-color, 14%))}'/></svg>") no-repeat right 8px center / auto 16px;
+    border: 1px solid darken($simple-background-color, 14%);
+    border-radius: 4px;
+    padding: 6px 10px;
+    padding-right: 30px;
+  }
 }
 
 .confirmation-modal__container,
@@ -7122,3 +7172,25 @@ noscript {
     border-color: lighten($ui-base-color, 12%);
   }
 }
+
+.notifications-permission-banner {
+  padding: 30px;
+  border-bottom: 1px solid lighten($ui-base-color, 8%);
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+
+  h2 {
+    font-size: 16px;
+    font-weight: 500;
+    margin-bottom: 15px;
+    text-align: center;
+  }
+
+  p {
+    color: $darker-text-color;
+    margin-bottom: 15px;
+    text-align: center;
+  }
+}
diff --git a/app/lib/activitypub/activity.rb b/app/lib/activitypub/activity.rb
index 7567b66d4..968dd3f67 100644
--- a/app/lib/activitypub/activity.rb
+++ b/app/lib/activitypub/activity.rb
@@ -74,7 +74,7 @@ class ActivityPub::Activity
     @object_uri ||= begin
       str = value_or_id(@object)
 
-      if str.start_with?('bear:')
+      if str&.start_with?('bear:')
         Addressable::URI.parse(str).query_values['u']
       else
         str
diff --git a/app/lib/activitypub/tag_manager.rb b/app/lib/activitypub/tag_manager.rb
index c26301f7e..c89c1ebb7 100644
--- a/app/lib/activitypub/tag_manager.rb
+++ b/app/lib/activitypub/tag_manager.rb
@@ -40,6 +40,10 @@ class ActivityPub::TagManager
     end
   end
 
+  def uri_for_username(username)
+    account_url(username: username)
+  end
+
   def generate_uri_for(_target)
     URI.join(root_url, 'payloads', SecureRandom.uuid)
   end
diff --git a/app/lib/fast_ip_map.rb b/app/lib/fast_ip_map.rb
new file mode 100644
index 000000000..ba30b45f3
--- /dev/null
+++ b/app/lib/fast_ip_map.rb
@@ -0,0 +1,32 @@
+# frozen_string_literal: true
+
+class FastIpMap
+  MAX_IPV4_PREFIX = 32
+  MAX_IPV6_PREFIX = 128
+
+  # @param [Enumerable<IPAddr>] addresses
+  def initialize(addresses)
+    @fast_lookup = {}
+    @ranges      = []
+
+    # Hash look-up is faster but only works for exact matches, so we split
+    # exact addresses from non-exact ones
+    addresses.each do |address|
+      if (address.ipv4? && address.prefix == MAX_IPV4_PREFIX) || (address.ipv6? && address.prefix == MAX_IPV6_PREFIX)
+        @fast_lookup[address.to_s] = true
+      else
+        @ranges << address
+      end
+    end
+
+    # We're more likely to hit wider-reaching ranges when checking for
+    # inclusion, so make sure they're sorted first
+    @ranges.sort_by!(&:prefix)
+  end
+
+  # @param [IPAddr] address
+  # @return [Boolean]
+  def include?(address)
+    @fast_lookup[address.to_s] || @ranges.any? { |cidr| cidr.include?(address) }
+  end
+end
diff --git a/app/lib/sanitize_config.rb b/app/lib/sanitize_config.rb
index adbbd2168..3bc25fe9f 100644
--- a/app/lib/sanitize_config.rb
+++ b/app/lib/sanitize_config.rb
@@ -18,6 +18,7 @@ class Sanitize
       gopher
       xmpp
       magnet
+      gemini
     ).freeze
 
     CLASS_WHITELIST_TRANSFORMER = lambda do |env|
diff --git a/app/lib/settings/scoped_settings.rb b/app/lib/settings/scoped_settings.rb
index 8aa826561..9889940f3 100644
--- a/app/lib/settings/scoped_settings.rb
+++ b/app/lib/settings/scoped_settings.rb
@@ -12,7 +12,6 @@ module Settings
       @object = object
     end
 
-    # rubocop:disable Style/MethodMissingSuper
     def method_missing(method, *args)
       method_name = method.to_s
       # set a value for a variable
@@ -25,7 +24,6 @@ module Settings
         self[method_name]
       end
     end
-    # rubocop:enable Style/MethodMissingSuper
 
     def respond_to_missing?(*)
       true
diff --git a/app/lib/user_settings_decorator.rb b/app/lib/user_settings_decorator.rb
index 8d1af705a..b87635dbc 100644
--- a/app/lib/user_settings_decorator.rb
+++ b/app/lib/user_settings_decorator.rb
@@ -32,6 +32,7 @@ class UserSettingsDecorator
     user.settings['display_media']       = display_media_preference if change?('setting_display_media')
     user.settings['expand_spoilers']     = expand_spoilers_preference if change?('setting_expand_spoilers')
     user.settings['reduce_motion']       = reduce_motion_preference if change?('setting_reduce_motion')
+    user.settings['disable_swiping']     = disable_swiping_preference if change?('setting_disable_swiping')
     user.settings['system_font_ui']      = system_font_ui_preference if change?('setting_system_font_ui')
     user.settings['system_emoji_font']   = system_emoji_font_preference if change?('setting_system_emoji_font')
     user.settings['noindex']             = noindex_preference if change?('setting_noindex')
@@ -123,6 +124,10 @@ class UserSettingsDecorator
     boolean_cast_setting 'setting_reduce_motion'
   end
 
+  def disable_swiping_preference
+    boolean_cast_setting 'setting_disable_swiping'
+  end
+
   def noindex_preference
     boolean_cast_setting 'setting_noindex'
   end
diff --git a/app/lib/webfinger.rb b/app/lib/webfinger.rb
new file mode 100644
index 000000000..b2374c494
--- /dev/null
+++ b/app/lib/webfinger.rb
@@ -0,0 +1,93 @@
+# frozen_string_literal: true
+
+class Webfinger
+  class Error < StandardError; end
+
+  class Response
+    def initialize(body)
+      @json = Oj.load(body, mode: :strict)
+    end
+
+    def subject
+      @json['subject']
+    end
+
+    def link(rel, attribute)
+      links.dig(rel, attribute)
+    end
+
+    private
+
+    def links
+      @links ||= @json['links'].map { |link| [link['rel'], link] }.to_h
+    end
+  end
+
+  def initialize(uri)
+    _, @domain = uri.split('@')
+
+    raise ArgumentError, 'Webfinger requested for local account' if @domain.nil?
+
+    @uri = uri
+  end
+
+  def perform
+    Response.new(body_from_webfinger)
+  rescue Oj::ParseError
+    raise Webfinger::Error, "Invalid JSON in response for #{@uri}"
+  rescue Addressable::URI::InvalidURIError
+    raise Webfinger::Error, "Invalid URI for #{@uri}"
+  end
+
+  private
+
+  def body_from_webfinger(url = standard_url, use_fallback = true)
+    webfinger_request(url).perform do |res|
+      if res.code == 200
+        res.body_with_limit
+      elsif res.code == 404 && use_fallback
+        body_from_host_meta
+      else
+        raise Webfinger::Error, "Request for #{@uri} returned HTTP #{res.code}"
+      end
+    end
+  end
+
+  def body_from_host_meta
+    host_meta_request.perform do |res|
+      if res.code == 200
+        body_from_webfinger(url_from_template(res.body_with_limit), false)
+      else
+        raise Webfinger::Error, "Request for #{@uri} returned HTTP #{res.code}"
+      end
+    end
+  end
+
+  def url_from_template(str)
+    link = Nokogiri::XML(str).at_xpath('//xmlns:Link[@rel="lrdd"]')
+
+    if link.present?
+      link['template'].gsub('{uri}', @uri)
+    else
+      raise Webfinger::Error, "Request for #{@uri} returned host-meta without link to Webfinger"
+    end
+  rescue Nokogiri::XML::XPath::SyntaxError
+    raise Webfinger::Error, "Invalid XML encountered in host-meta for #{@uri}"
+  end
+
+  def host_meta_request
+    Request.new(:get, host_meta_url).add_headers('Accept' => 'application/xrd+xml, application/xml, text/xml')
+  end
+
+  def webfinger_request(url)
+    Request.new(:get, url).add_headers('Accept' => 'application/jrd+json, application/json')
+  end
+
+  def standard_url
+    "https://#{@domain}/.well-known/webfinger?resource=#{@uri}"
+  end
+
+  def host_meta_url
+    "https://#{@domain}/.well-known/host-meta"
+  end
+end
diff --git a/app/models/account.rb b/app/models/account.rb
index 228e36755..1b8afe594 100644
--- a/app/models/account.rb
+++ b/app/models/account.rb
@@ -361,6 +361,12 @@ class Account < ApplicationRecord
     shared_inbox_url.presence || inbox_url
   end
 
+  def synchronization_uri_prefix
+    return 'local' if local?
+
+    @synchronization_uri_prefix ||= uri[/http(s?):\/\/[^\/]+\//]
+  end
+
   def max_visibility_for_domain(domain)
     return 'public' if domain.blank?
 
diff --git a/app/models/account_alias.rb b/app/models/account_alias.rb
index 792e9e8d4..3d659142a 100644
--- a/app/models/account_alias.rb
+++ b/app/models/account_alias.rb
@@ -33,7 +33,7 @@ class AccountAlias < ApplicationRecord
   def set_uri
     target_account = ResolveAccountService.new.call(acct)
     self.uri       = ActivityPub::TagManager.instance.uri_for(target_account) unless target_account.nil?
-  rescue Goldfinger::Error, HTTP::Error, OpenSSL::SSL::SSLError, Mastodon::Error
+  rescue Webfinger::Error, HTTP::Error, OpenSSL::SSL::SSLError, Mastodon::Error
     # Validation will take care of it
   end
 
diff --git a/app/models/account_migration.rb b/app/models/account_migration.rb
index 681b5b2cd..4fae98ed7 100644
--- a/app/models/account_migration.rb
+++ b/app/models/account_migration.rb
@@ -54,7 +54,7 @@ class AccountMigration < ApplicationRecord
 
   def set_target_account
     self.target_account = ResolveAccountService.new.call(acct)
-  rescue Goldfinger::Error, HTTP::Error, OpenSSL::SSL::SSLError, Mastodon::Error
+  rescue Webfinger::Error, HTTP::Error, OpenSSL::SSL::SSLError, Mastodon::Error
     # Validation will take care of it
   end
 
diff --git a/app/models/concerns/account_interactions.rb b/app/models/concerns/account_interactions.rb
index 184064e94..98d06d586 100644
--- a/app/models/concerns/account_interactions.rb
+++ b/app/models/concerns/account_interactions.rb
@@ -132,14 +132,19 @@ module AccountInteractions
                        .find_or_create_by!(target_account: other_account)
   end
 
-  def mute!(other_account, notifications: nil, timelines_only: nil)
+  def mute!(other_account, notifications: nil, timelines_only: nil, duration: 0)
     notifications = true if notifications.nil?
     timelines_only = false if timelines_only.nil?
-    mute = mute_relationships.create_with(hide_notifications: notifications, timelines_only: timelines_only).find_or_create_by!(target_account: other_account)
+    mute = mute_relationships.create_with(hide_notifications: notifications, timelines_only: timelines_only).find_or_initialize_by(target_account: other_account)
+    mute.expires_in = duration.zero? ? nil : duration
+    mute.save!
+
     remove_potential_friendship(other_account)
 
     # When toggling a mute between hiding and allowing notifications, the mute will already exist, so the find_or_create_by! call will return the existing Mute without updating the hide_notifications attribute. Therefore, we check that hide_notifications? is what we want and set it if it isn't.
-    mute.update!(hide_notifications: notifications, timelines_only: timelines_only) if mute.hide_notifications? != notifications
+    if mute.hide_notifications? != notifications || mute.timelines_only? != timelines_only
+      mute.update!(hide_notifications: notifications, timelines_only: timelines_only)
+    end
 
     mute
   end
@@ -253,6 +258,26 @@ module AccountInteractions
          .where('users.current_sign_in_at > ?', User::ACTIVE_DURATION.ago)
   end
 
+  def remote_followers_hash(url_prefix)
+    Rails.cache.fetch("followers_hash:#{id}:#{url_prefix}") do
+      digest = "\x00" * 32
+      followers.where(Account.arel_table[:uri].matches(url_prefix + '%', false, true)).pluck_each(:uri) do |uri|
+        Xorcist.xor!(digest, Digest::SHA256.digest(uri))
+      end
+      digest.unpack('H*')[0]
+    end
+  end
+
+  def local_followers_hash
+    Rails.cache.fetch("followers_hash:#{id}:local") do
+      digest = "\x00" * 32
+      followers.where(domain: nil).pluck_each(:username) do |username|
+        Xorcist.xor!(digest, Digest::SHA256.digest(ActivityPub::TagManager.instance.uri_for_username(username)))
+      end
+      digest.unpack('H*')[0]
+    end
+  end
+
   private
 
   def remove_potential_friendship(other_account, mutual = false)
diff --git a/app/models/concerns/expireable.rb b/app/models/concerns/expireable.rb
index f7d2bab49..a66a4661b 100644
--- a/app/models/concerns/expireable.rb
+++ b/app/models/concerns/expireable.rb
@@ -6,7 +6,15 @@ module Expireable
   included do
     scope :expired, -> { where.not(expires_at: nil).where('expires_at < ?', Time.now.utc) }
 
-    attr_reader :expires_in
+    def expires_in
+      return @expires_in if defined?(@expires_in)
+
+      if expires_at.nil?
+        nil
+      else
+        (expires_at - created_at).to_i
+      end
+    end
 
     def expires_in=(interval)
       self.expires_at = interval.to_i.seconds.from_now if interval.present?
diff --git a/app/models/follow.rb b/app/models/follow.rb
index 0b4ddbf3f..55a9da792 100644
--- a/app/models/follow.rb
+++ b/app/models/follow.rb
@@ -41,8 +41,10 @@ class Follow < ApplicationRecord
 
   before_validation :set_uri, only: :create
   after_create :increment_cache_counters
+  after_create :invalidate_hash_cache
   after_destroy :remove_endorsements
   after_destroy :decrement_cache_counters
+  after_destroy :invalidate_hash_cache
 
   private
 
@@ -63,4 +65,10 @@ class Follow < ApplicationRecord
     account&.decrement_count!(:following_count)
     target_account&.decrement_count!(:followers_count)
   end
+
+  def invalidate_hash_cache
+    return if account.local? && target_account.local?
+
+    Rails.cache.delete("followers_hash:#{target_account_id}:#{account.synchronization_uri_prefix}")
+  end
 end
diff --git a/app/models/form/ip_block_batch.rb b/app/models/form/ip_block_batch.rb
new file mode 100644
index 000000000..f6fe9b593
--- /dev/null
+++ b/app/models/form/ip_block_batch.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+class Form::IpBlockBatch
+  include ActiveModel::Model
+  include Authorization
+  include AccountableConcern
+
+  attr_accessor :ip_block_ids, :action, :current_account
+
+  def save
+    case action
+    when 'delete'
+      delete!
+    end
+  end
+
+  private
+
+  def ip_blocks
+    @ip_blocks ||= IpBlock.where(id: ip_block_ids)
+  end
+
+  def delete!
+    ip_blocks.each { |ip_block| authorize(ip_block, :destroy?) }
+
+    ip_blocks.each do |ip_block|
+      ip_block.destroy
+      log_action :destroy, ip_block
+    end
+  end
+end
diff --git a/app/models/form/redirect.rb b/app/models/form/redirect.rb
index a7961f8e8..19ee9faed 100644
--- a/app/models/form/redirect.rb
+++ b/app/models/form/redirect.rb
@@ -32,7 +32,7 @@ class Form::Redirect
 
   def set_target_account
     @target_account = ResolveAccountService.new.call(acct)
-  rescue Goldfinger::Error, HTTP::Error, OpenSSL::SSL::SSLError, Mastodon::Error
+  rescue Webfinger::Error, HTTP::Error, OpenSSL::SSL::SSLError, Mastodon::Error
     # Validation will take care of it
   end
 
diff --git a/app/models/ip_block.rb b/app/models/ip_block.rb
new file mode 100644
index 000000000..aedd3ca0d
--- /dev/null
+++ b/app/models/ip_block.rb
@@ -0,0 +1,41 @@
+# frozen_string_literal: true
+# == Schema Information
+#
+# Table name: ip_blocks
+#
+#  id         :bigint(8)        not null, primary key
+#  created_at :datetime         not null
+#  updated_at :datetime         not null
+#  expires_at :datetime
+#  ip         :inet             default(#<IPAddr: IPv4:0.0.0.0/255.255.255.255>), not null
+#  severity   :integer          default(NULL), not null
+#  comment    :text             default(""), not null
+#
+
+class IpBlock < ApplicationRecord
+  CACHE_KEY = 'blocked_ips'
+
+  include Expireable
+
+  enum severity: {
+    sign_up_requires_approval: 5000,
+    no_access: 9999,
+  }
+
+  validates :ip, :severity, presence: true
+
+  after_commit :reset_cache
+
+  class << self
+    def blocked?(remote_ip)
+      blocked_ips_map = Rails.cache.fetch(CACHE_KEY) { FastIpMap.new(IpBlock.where(severity: :no_access).pluck(:ip)) }
+      blocked_ips_map.include?(remote_ip)
+    end
+  end
+
+  private
+
+  def reset_cache
+    Rails.cache.delete(CACHE_KEY)
+  end
+end
diff --git a/app/models/mute.rb b/app/models/mute.rb
index 11f833d8e..bdd1d27d6 100644
--- a/app/models/mute.rb
+++ b/app/models/mute.rb
@@ -9,12 +9,15 @@
 #  hide_notifications :boolean          default(TRUE), not null
 #  account_id         :bigint(8)        not null
 #  target_account_id  :bigint(8)        not null
+#  hide_notifications :boolean          default(TRUE), not null
+#  expires_at         :datetime
 #  timelines_only     :boolean          default(FALSE), not null
 #
 
 class Mute < ApplicationRecord
   include Paginable
   include RelationshipCacheable
+  include Expireable
 
   belongs_to :account
   belongs_to :target_account, class_name: 'Account'
diff --git a/app/models/remote_follow.rb b/app/models/remote_follow.rb
index 30b84f7d5..911c06713 100644
--- a/app/models/remote_follow.rb
+++ b/app/models/remote_follow.rb
@@ -56,7 +56,7 @@ class RemoteFollow
 
     if domain.nil?
       @addressable_template = Addressable::Template.new("#{authorize_interaction_url}?uri={uri}")
-    elsif redirect_url_link.nil? || redirect_url_link.template.nil?
+    elsif redirect_uri_template.nil?
       missing_resource_error
     else
       @addressable_template = Addressable::Template.new(redirect_uri_template)
@@ -64,16 +64,12 @@ class RemoteFollow
   end
 
   def redirect_uri_template
-    redirect_url_link.template
-  end
-
-  def redirect_url_link
-    acct_resource&.link('http://ostatus.org/schema/1.0/subscribe')
+    acct_resource&.link('http://ostatus.org/schema/1.0/subscribe', 'template')
   end
 
   def acct_resource
     @acct_resource ||= webfinger!("acct:#{acct}")
-  rescue Goldfinger::Error, HTTP::ConnectionError
+  rescue Webfinger::Error, HTTP::ConnectionError
     nil
   end
 
diff --git a/app/models/user.rb b/app/models/user.rb
index afe248f57..4ec5a5d8e 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -41,6 +41,7 @@
 #  sign_in_token             :string
 #  sign_in_token_sent_at     :datetime
 #  webauthn_id               :string
+#  sign_up_ip                :inet
 #  username                  :string
 #  kobold                    :string
 #
@@ -99,7 +100,7 @@ class User < ApplicationRecord
   scope :inactive, -> { where(arel_table[:current_sign_in_at].lt(ACTIVE_DURATION.ago)) }
   scope :active, -> { confirmed.where(arel_table[:current_sign_in_at].gteq(ACTIVE_DURATION.ago)).joins(:account).where(accounts: { suspended_at: nil }) }
   scope :matches_email, ->(value) { where(arel_table[:email].matches("#{value}%")) }
-  scope :matches_ip, ->(value) { left_joins(:session_activations).where('users.current_sign_in_ip <<= ?', value).or(left_joins(:session_activations).where('users.last_sign_in_ip <<= ?', value)).or(left_joins(:session_activations).where('session_activations.ip <<= ?', value)) }
+  scope :matches_ip, ->(value) { left_joins(:session_activations).where('users.current_sign_in_ip <<= ?', value).or(left_joins(:session_activations).where('users.sign_up_ip <<= ?', value)).or(left_joins(:session_activations).where('users.last_sign_in_ip <<= ?', value)).or(left_joins(:session_activations).where('session_activations.ip <<= ?', value)) }
   scope :emailable, -> { confirmed.enabled.joins(:account).merge(Account.searchable) }
   scope :lower_username, ->(username) { where('lower(users.username) = lower(?)', username) }
 
@@ -118,7 +119,7 @@ class User < ApplicationRecord
            :reduce_motion, :system_font_ui, :noindex, :flavour, :skin, :display_media, :hide_network, :hide_followers_count,
            :expand_spoilers, :default_language, :show_application,
            :advanced_layout, :use_blurhash, :use_pending_items, :trends, :crop_images,
-           :default_content_type, :system_emoji_font,
+           :disable_swiping, :default_content_type, :system_emoji_font,
            :manual_publish, :style_dashed_nest, :style_underline_a, :style_css_profile,
            :style_css_profile_errors, :style_css_webapp, :style_css_webapp_errors,
            :style_wide_media,
@@ -363,6 +364,7 @@ class User < ApplicationRecord
 
       arr << [current_sign_in_at, current_sign_in_ip] if current_sign_in_ip.present?
       arr << [last_sign_in_at, last_sign_in_ip] if last_sign_in_ip.present?
+      arr << [created_at, sign_up_ip] if sign_up_ip.present?
 
       arr.sort_by { |pair| pair.first || Time.now.utc }.uniq(&:last).reverse!
     end
@@ -417,7 +419,17 @@ class User < ApplicationRecord
   end
 
   def set_approved
-    self.approved = open_registrations? || valid_invitation? || external?
+    self.approved = begin
+      if sign_up_from_ip_requires_approval?
+        false
+      else
+        open_registrations? || valid_invitation? || external?
+      end
+    end
+  end
+
+  def sign_up_from_ip_requires_approval?
+    !sign_up_ip.nil? && IpBlock.where(severity: :sign_up_requires_approval).where('ip >>= ?', sign_up_ip.to_s).exists?
   end
 
   def open_registrations?
diff --git a/app/policies/ip_block_policy.rb b/app/policies/ip_block_policy.rb
new file mode 100644
index 000000000..34dbd746a
--- /dev/null
+++ b/app/policies/ip_block_policy.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+class IpBlockPolicy < ApplicationPolicy
+  def index?
+    admin?
+  end
+
+  def create?
+    admin?
+  end
+
+  def destroy?
+    admin?
+  end
+end
diff --git a/app/serializers/initial_state_serializer.rb b/app/serializers/initial_state_serializer.rb
index c520c9bcb..470cec8a1 100644
--- a/app/serializers/initial_state_serializer.rb
+++ b/app/serializers/initial_state_serializer.rb
@@ -48,6 +48,7 @@ class InitialStateSerializer < ActiveModel::Serializer
       store[:display_media]     = object.current_account.user.setting_display_media
       store[:expand_spoilers]   = object.current_account.user.setting_expand_spoilers
       store[:reduce_motion]     = object.current_account.user.setting_reduce_motion
+      store[:disable_swiping]   = object.current_account.user.setting_disable_swiping
       store[:advanced_layout]   = object.current_account.user.setting_advanced_layout
       store[:use_blurhash]      = object.current_account.user.setting_use_blurhash
       store[:use_pending_items] = object.current_account.user.setting_use_pending_items
diff --git a/app/serializers/rest/muted_account_serializer.rb b/app/serializers/rest/muted_account_serializer.rb
new file mode 100644
index 000000000..3ddd706dc
--- /dev/null
+++ b/app/serializers/rest/muted_account_serializer.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+class REST::MutedAccountSerializer < REST::AccountSerializer
+  attribute :mute_expires_at
+
+  def mute_expires_at
+    mute = current_user.account.mute_relationships.find_by(target_account_id: object.id)
+    mute && !mute.expired? ? mute.expires_at : nil
+  end
+end
diff --git a/app/services/activitypub/fetch_remote_account_service.rb b/app/services/activitypub/fetch_remote_account_service.rb
index 83fbf6d07..e5bd0c47c 100644
--- a/app/services/activitypub/fetch_remote_account_service.rb
+++ b/app/services/activitypub/fetch_remote_account_service.rb
@@ -39,17 +39,16 @@ class ActivityPub::FetchRemoteAccountService < BaseService
     webfinger                            = webfinger!("acct:#{@username}@#{@domain}")
     confirmed_username, confirmed_domain = split_acct(webfinger.subject)
 
-    return webfinger.link('self')&.href == @uri if @username.casecmp(confirmed_username).zero? && @domain.casecmp(confirmed_domain).zero?
+    return webfinger.link('self', 'href') == @uri if @username.casecmp(confirmed_username).zero? && @domain.casecmp(confirmed_domain).zero?
 
     webfinger                            = webfinger!("acct:#{confirmed_username}@#{confirmed_domain}")
     @username, @domain                   = split_acct(webfinger.subject)
-    self_reference                       = webfinger.link('self')
 
     return false unless @username.casecmp(confirmed_username).zero? && @domain.casecmp(confirmed_domain).zero?
-    return false if self_reference&.href != @uri
+    return false if webfinger.link('self', 'href') != @uri
 
     true
-  rescue Goldfinger::Error
+  rescue Webfinger::Error
     false
   end
 
diff --git a/app/services/activitypub/prepare_followers_synchronization_service.rb b/app/services/activitypub/prepare_followers_synchronization_service.rb
new file mode 100644
index 000000000..2d22ed701
--- /dev/null
+++ b/app/services/activitypub/prepare_followers_synchronization_service.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+class ActivityPub::PrepareFollowersSynchronizationService < BaseService
+  include JsonLdHelper
+
+  def call(account, params)
+    @account = account
+
+    return if params['collectionId'] != @account.followers_url || invalid_origin?(params['url']) || @account.local_followers_hash == params['digest']
+
+    ActivityPub::FollowersSynchronizationWorker.perform_async(@account.id, params['url'])
+  end
+end
diff --git a/app/services/activitypub/synchronize_followers_service.rb b/app/services/activitypub/synchronize_followers_service.rb
new file mode 100644
index 000000000..d83fcf55e
--- /dev/null
+++ b/app/services/activitypub/synchronize_followers_service.rb
@@ -0,0 +1,74 @@
+# frozen_string_literal: true
+
+class ActivityPub::SynchronizeFollowersService < BaseService
+  include JsonLdHelper
+  include Payloadable
+
+  def call(account, partial_collection_url)
+    @account = account
+
+    items = collection_items(partial_collection_url)
+    return if items.nil?
+
+    # There could be unresolved accounts (hence the call to .compact) but this
+    # should never happen in practice, since in almost all cases we keep an
+    # Account record, and should we not do that, we should have sent a Delete.
+    # In any case there is not much we can do if that occurs.
+    @expected_followers = items.map { |uri| ActivityPub::TagManager.instance.uri_to_resource(uri, Account) }.compact
+
+    remove_unexpected_local_followers!
+    handle_unexpected_outgoing_follows!
+  end
+
+  private
+
+  def remove_unexpected_local_followers!
+    @account.followers.local.where.not(id: @expected_followers.map(&:id)).each do |unexpected_follower|
+      UnfollowService.new.call(unexpected_follower, @account)
+    end
+  end
+
+  def handle_unexpected_outgoing_follows!
+    @expected_followers.each do |expected_follower|
+      next if expected_follower.following?(@account)
+
+      if expected_follower.requested?(@account)
+        # For some reason the follow request went through but we missed it
+        expected_follower.follow_requests.find_by(target_account: @account)&.authorize!
+      else
+        # Since we were not aware of the follow from our side, we do not have an
+        # ID for it that we can include in the Undo activity. For this reason,
+        # the Undo may not work with software that relies exclusively on
+        # matching activity IDs and not the actor and target
+        follow = Follow.new(account: expected_follower, target_account: @account)
+        ActivityPub::DeliveryWorker.perform_async(build_undo_follow_json(follow), follow.account_id, follow.target_account.inbox_url)
+      end
+    end
+  end
+
+  def build_undo_follow_json(follow)
+    Oj.dump(serialize_payload(follow, ActivityPub::UndoFollowSerializer))
+  end
+
+  def collection_items(collection_or_uri)
+    collection = fetch_collection(collection_or_uri)
+    return unless collection.is_a?(Hash)
+
+    collection = fetch_collection(collection['first']) if collection['first'].present?
+    return unless collection.is_a?(Hash)
+
+    case collection['type']
+    when 'Collection', 'CollectionPage'
+      collection['items']
+    when 'OrderedCollection', 'OrderedCollectionPage'
+      collection['orderedItems']
+    end
+  end
+
+  def fetch_collection(collection_or_uri)
+    return collection_or_uri if collection_or_uri.is_a?(Hash)
+    return if invalid_origin?(collection_or_uri)
+
+    fetch_resource_without_id_validation(collection_or_uri, nil, true)
+  end
+end
diff --git a/app/services/app_sign_up_service.rb b/app/services/app_sign_up_service.rb
index c9739c77d..e00694157 100644
--- a/app/services/app_sign_up_service.rb
+++ b/app/services/app_sign_up_service.rb
@@ -1,13 +1,13 @@
 # frozen_string_literal: true
 
 class AppSignUpService < BaseService
-  def call(app, params)
+  def call(app, remote_ip, params)
     return unless allowed_registrations?
 
     user_params           = params.slice(:email, :password, :agreement, :locale)
     account_params        = params.slice(:username)
     invite_request_params = { text: params[:reason] }
-    user                  = User.create!(user_params.merge(created_by_application: app, password_confirmation: user_params[:password], account_attributes: account_params, invite_request_attributes: invite_request_params))
+    user                  = User.create!(user_params.merge(created_by_application: app, sign_up_ip: remote_ip, password_confirmation: user_params[:password], account_attributes: account_params, invite_request_attributes: invite_request_params))
 
     Doorkeeper::AccessToken.create!(application: app,
                                     resource_owner_id: user.id,
diff --git a/app/services/mute_service.rb b/app/services/mute_service.rb
index 1a3f4981f..67df92f5c 100644
--- a/app/services/mute_service.rb
+++ b/app/services/mute_service.rb
@@ -1,10 +1,10 @@
 # frozen_string_literal: true
 
 class MuteService < BaseService
-  def call(account, target_account, notifications: nil, timelines_only: nil)
+  def call(account, target_account, notifications: nil, timelines_only: nil, duration: 0)
     return if account.id == target_account.id
 
-    mute = account.mute!(target_account, notifications: notifications, timelines_only: timelines_only)
+    mute = account.mute!(target_account, notifications: notifications, timelines_only: timelines_only, duration: duration)
 
     if mute.hide_notifications?
       BlockWorker.perform_async(account.id, target_account.id)
@@ -12,6 +12,8 @@ class MuteService < BaseService
       MuteWorker.perform_async(account.id, target_account.id)
     end
 
+    DeleteMuteWorker.perform_at(duration.seconds, mute.id) if duration != 0
+
     mute
   end
 end
diff --git a/app/services/resolve_account_service.rb b/app/services/resolve_account_service.rb
index ba77552c6..3f7bb7cc5 100644
--- a/app/services/resolve_account_service.rb
+++ b/app/services/resolve_account_service.rb
@@ -26,11 +26,10 @@ class ResolveAccountService < BaseService
 
     @account ||= Account.find_remote(@username, @domain)
 
-    return @account if @account&.local? || !webfinger_update_due?
+    return @account if @account&.local? || @domain.nil? || !webfinger_update_due?
 
     # At this point we are in need of a Webfinger query, which may
     # yield us a different username/domain through a redirect
-
     process_webfinger!(@uri)
 
     # Because the username/domain pair may be different than what
@@ -47,7 +46,7 @@ class ResolveAccountService < BaseService
     # either needs to be created, or updated from fresh data
 
     process_account!
-  rescue Goldfinger::Error, WebfingerRedirectError, Oj::ParseError => e
+  rescue Webfinger::Error, WebfingerRedirectError, Oj::ParseError => e
     Rails.logger.debug "Webfinger query for #{@uri} failed: #{e}"
     nil
   end
@@ -118,11 +117,11 @@ class ResolveAccountService < BaseService
   end
 
   def activitypub_ready?
-    !@webfinger.link('self').nil? && ['application/activity+json', 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"'].include?(@webfinger.link('self').type)
+    ['application/activity+json', 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"'].include?(@webfinger.link('self', 'type'))
   end
 
   def actor_url
-    @actor_url ||= @webfinger.link('self').href
+    @actor_url ||= @webfinger.link('self', 'href')
   end
 
   def actor_json
diff --git a/app/services/resolve_mentions_service.rb b/app/services/resolve_mentions_service.rb
index e51e9f1ef..6478dc902 100644
--- a/app/services/resolve_mentions_service.rb
+++ b/app/services/resolve_mentions_service.rb
@@ -28,7 +28,7 @@ class ResolveMentionsService < BaseService
       if mention_undeliverable?(mentioned_account)
         begin
           mentioned_account = resolve_account_service.call(Regexp.last_match(1))
-        rescue Goldfinger::Error, HTTP::Error, OpenSSL::SSL::SSLError, Mastodon::UnexpectedResponseError
+        rescue Webfinger::Error, HTTP::Error, OpenSSL::SSL::SSLError, Mastodon::UnexpectedResponseError
           mentioned_account = nil
         end
       end
diff --git a/app/views/admin/ip_blocks/_ip_block.html.haml b/app/views/admin/ip_blocks/_ip_block.html.haml
new file mode 100644
index 000000000..e07e2b444
--- /dev/null
+++ b/app/views/admin/ip_blocks/_ip_block.html.haml
@@ -0,0 +1,11 @@
+.batch-table__row
+  %label.batch-table__row__select.batch-table__row__select--aligned.batch-checkbox
+    = f.check_box :ip_block_ids, { multiple: true, include_hidden: false }, ip_block.id
+  .batch-table__row__content
+    .batch-table__row__content__text
+      %samp= "#{ip_block.ip}/#{ip_block.ip.prefix}"
+      - if ip_block.comment.present?
+        •
+        = ip_block.comment
+      %br/
+      = t("simple_form.labels.ip_block.severities.#{ip_block.severity}")
diff --git a/app/views/admin/ip_blocks/index.html.haml b/app/views/admin/ip_blocks/index.html.haml
new file mode 100644
index 000000000..a282a4cfe
--- /dev/null
+++ b/app/views/admin/ip_blocks/index.html.haml
@@ -0,0 +1,28 @@
+- content_for :page_title do
+  = t('admin.ip_blocks.title')
+
+- content_for :header_tags do
+  = javascript_pack_tag 'admin', integrity: true, async: true, crossorigin: 'anonymous'
+
+- if can?(:create, :ip_block)
+  - content_for :heading_actions do
+    = link_to t('admin.ip_blocks.add_new'), new_admin_ip_block_path, class: 'button'
+
+= form_for(@form, url: batch_admin_ip_blocks_path) do |f|
+  = hidden_field_tag :page, params[:page] || 1
+
+  .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 can?(:destroy, :ip_block)
+          = f.button safe_join([fa_icon('times'), t('admin.ip_blocks.delete')]), name: :delete, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') }
+    .batch-table__body
+      - if @ip_blocks.empty?
+        = nothing_here 'nothing-here--under-tabs'
+      - else
+        = render partial: 'ip_block', collection: @ip_blocks, locals: { f: f }
+
+= paginate @ip_blocks
+
diff --git a/app/views/admin/ip_blocks/new.html.haml b/app/views/admin/ip_blocks/new.html.haml
new file mode 100644
index 000000000..69f6b98b9
--- /dev/null
+++ b/app/views/admin/ip_blocks/new.html.haml
@@ -0,0 +1,20 @@
+- content_for :page_title do
+  = t('.title')
+
+= simple_form_for @ip_block, url: admin_ip_blocks_path do |f|
+  = render 'shared/error_messages', object: @ip_block
+
+  .fields-group
+    = f.input :ip, as: :string, wrapper: :with_block_label, input_html: { placeholder: '192.0.2.0/24' }
+
+  .fields-group
+    = f.input :expires_in, wrapper: :with_block_label, collection: [1.day, 2.weeks, 1.month, 6.months, 1.year, 3.years].map(&:to_i), label_method: lambda { |i| I18n.t("admin.ip_blocks.expires_in.#{i}") }, prompt: I18n.t('invites.expires_in_prompt')
+
+  .fields-group
+    = f.input :severity, as: :radio_buttons, collection: IpBlock.severities.keys, include_blank: false, wrapper: :with_block_label, label_method: lambda { |severity| safe_join([I18n.t("simple_form.labels.ip_block.severities.#{severity}"), content_tag(:span, I18n.t("simple_form.hints.ip_block.severities.#{severity}"), class: 'hint')]) }
+
+  .fields-group
+    = f.input :comment, as: :string, wrapper: :with_block_label
+
+  .actions
+    = f.button :button, t('admin.ip_blocks.add_new'), type: :submit
diff --git a/app/views/admin/pending_accounts/_account.html.haml b/app/views/admin/pending_accounts/_account.html.haml
index 7a9796a67..5b475b59a 100644
--- a/app/views/admin/pending_accounts/_account.html.haml
+++ b/app/views/admin/pending_accounts/_account.html.haml
@@ -7,7 +7,7 @@
         %strong= account.user_email
         = "(@#{account.username})"
       %br/
-      = account.user_current_sign_in_ip
+      %samp= account.user_current_sign_in_ip

       = t 'admin.accounts.time_in_queue', time: time_ago_in_words(account.user&.created_at)
 
diff --git a/app/views/layouts/application.html.haml b/app/views/layouts/application.html.haml
index 34f742c16..e00fc60c6 100755
--- a/app/views/layouts/application.html.haml
+++ b/app/views/layouts/application.html.haml
@@ -6,6 +6,7 @@
 
     - if cdn_host?
       %link{ rel: 'dns-prefetch', href: cdn_host }/
+      %meta{ name: 'cdn-host', content: cdn_host }/
 
     - if storage_host?
       %link{ rel: 'dns-prefetch', href: storage_host }/
diff --git a/app/views/layouts/embedded.html.haml b/app/views/layouts/embedded.html.haml
index 75441b452..69b206f69 100644
--- a/app/views/layouts/embedded.html.haml
+++ b/app/views/layouts/embedded.html.haml
@@ -6,6 +6,7 @@
 
     - if cdn_host?
       %link{ rel: 'dns-prefetch', href: cdn_host }/
+      %meta{ name: 'cdn-host', content: cdn_host }/
 
     - if storage_host?
       %link{ rel: 'dns-prefetch', href: storage_host }/
diff --git a/app/views/settings/preferences/appearance/show.html.haml b/app/views/settings/preferences/appearance/show.html.haml
index 48031a973..13f9aa668 100644
--- a/app/views/settings/preferences/appearance/show.html.haml
+++ b/app/views/settings/preferences/appearance/show.html.haml
@@ -27,6 +27,7 @@
   .fields-group
     = f.input :setting_auto_play_gif, as: :boolean, wrapper: :with_label, recommended: true
     = f.input :setting_reduce_motion, as: :boolean, wrapper: :with_label
+    = f.input :setting_disable_swiping, as: :boolean, wrapper: :with_label
     = f.input :setting_system_font_ui, as: :boolean, wrapper: :with_label
     = f.input :setting_system_emoji_font, as: :boolean, wrapper: :with_label
 
diff --git a/app/views/statuses/_simple_status.html.haml b/app/views/statuses/_simple_status.html.haml
index 0f2763d27..445cf09ea 100644
--- a/app/views/statuses/_simple_status.html.haml
+++ b/app/views/statuses/_simple_status.html.haml
@@ -59,13 +59,12 @@
       = t 'statuses.show_thread'
 
   .status__action-bar
-    .status__action-bar__counter
-      = link_to remote_interaction_path(status, type: :reply), class: 'status__action-bar-button icon-button modal-button' do
-        - if status.in_reply_to_id.nil?
-          = fa_icon 'reply fw'
-        - else
-          = fa_icon 'reply-all fw'
-      .status__action-bar__counter__label= obscured_counter status.replies_count
+    = link_to remote_interaction_path(status, type: :reply), class: 'status__action-bar-button icon-button icon-button--with-counter modal-button' do
+      - if status.in_reply_to_id.nil?
+        = fa_icon 'reply fw'
+      - else
+        = fa_icon 'reply-all fw'
+      %span.icon-button__counter= obscured_counter status.replies_count
     = link_to remote_interaction_path(status, type: :reblog), class: 'status__action-bar-button icon-button modal-button' do
       - if status.distributable?
         = fa_icon 'retweet fw'
diff --git a/app/views/well_known/host_meta/show.xml.ruby b/app/views/well_known/host_meta/show.xml.ruby
index 0a6bdc322..b4e867c5f 100644
--- a/app/views/well_known/host_meta/show.xml.ruby
+++ b/app/views/well_known/host_meta/show.xml.ruby
@@ -5,7 +5,6 @@ doc << Ox::Element.new('XRD').tap do |xrd|
 
   xrd << Ox::Element.new('Link').tap do |link|
     link['rel']      = 'lrdd'
-    link['type']     = 'application/xrd+xml'
     link['template'] = @webfinger_template
   end
 end
diff --git a/app/workers/activitypub/delivery_worker.rb b/app/workers/activitypub/delivery_worker.rb
index 60775787a..6c5a576a7 100644
--- a/app/workers/activitypub/delivery_worker.rb
+++ b/app/workers/activitypub/delivery_worker.rb
@@ -2,6 +2,7 @@
 
 class ActivityPub::DeliveryWorker
   include Sidekiq::Worker
+  include RoutingHelper
   include JsonLdHelper
 
   STOPLIGHT_FAILURE_THRESHOLD = 10
@@ -38,9 +39,18 @@ class ActivityPub::DeliveryWorker
     Request.new(:post, @inbox_url, body: @json, http_client: http_client).tap do |request|
       request.on_behalf_of(@source_account, :uri, sign_with: @options[:sign_with])
       request.add_headers(HEADERS)
+      request.add_headers({ 'Collection-Synchronization' => synchronization_header }) if ENV['DISABLE_FOLLOWERS_SYNCHRONIZATION'] != 'true' && @options[:synchronize_followers]
     end
   end
 
+  def synchronization_header
+    "collectionId=\"#{account_followers_url(@source_account)}\", digest=\"#{@source_account.remote_followers_hash(inbox_url_prefix)}\", url=\"#{account_followers_synchronization_url(@source_account)}\""
+  end
+
+  def inbox_url_prefix
+    @inbox_url[/http(s?):\/\/[^\/]+\//]
+  end
+
   def perform_request
     light = Stoplight(@inbox_url) do
       request_pool.with(@host) do |http_client|
diff --git a/app/workers/activitypub/distribution_worker.rb b/app/workers/activitypub/distribution_worker.rb
index ff4bac018..1602c3e24 100644
--- a/app/workers/activitypub/distribution_worker.rb
+++ b/app/workers/activitypub/distribution_worker.rb
@@ -15,7 +15,7 @@ class ActivityPub::DistributionWorker
     return if skip_distribution?
 
     ActivityPub::DeliveryWorker.push_bulk(inboxes) do |inbox_url|
-      [payload(Addressable::URI.parse(inbox_url).host), @account.id, inbox_url]
+      [payload(Addressable::URI.parse(inbox_url).host), @account.id, inbox_url, { synchronize_followers: !@status.distributable? }]
     end
 
     relay! if relayable?
diff --git a/app/workers/activitypub/followers_synchronization_worker.rb b/app/workers/activitypub/followers_synchronization_worker.rb
new file mode 100644
index 000000000..35a3ef0b9
--- /dev/null
+++ b/app/workers/activitypub/followers_synchronization_worker.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+class ActivityPub::FollowersSynchronizationWorker
+  include Sidekiq::Worker
+
+  sidekiq_options queue: 'push', lock: :until_executed
+
+  def perform(account_id, url)
+    @account = Account.find_by(id: account_id)
+    return true if @account.nil?
+
+    ActivityPub::SynchronizeFollowersService.new.call(@account, url)
+  end
+end
diff --git a/app/workers/delete_mute_worker.rb b/app/workers/delete_mute_worker.rb
new file mode 100644
index 000000000..eb031020e
--- /dev/null
+++ b/app/workers/delete_mute_worker.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+class DeleteMuteWorker
+  include Sidekiq::Worker
+
+  def perform(mute_id)
+    mute = Mute.find_by(id: mute_id)
+    UnmuteService.new.call(mute.account, mute.target_account) if mute&.expired?
+  end
+end
diff --git a/app/workers/scheduler/ip_cleanup_scheduler.rb b/app/workers/scheduler/ip_cleanup_scheduler.rb
index 6d38b52a2..853f20e25 100644
--- a/app/workers/scheduler/ip_cleanup_scheduler.rb
+++ b/app/workers/scheduler/ip_cleanup_scheduler.rb
@@ -3,13 +3,23 @@
 class Scheduler::IpCleanupScheduler
   include Sidekiq::Worker
 
-  RETENTION_PERIOD = 1.year
+  IP_RETENTION_PERIOD = 1.year.freeze
 
   sidekiq_options lock: :until_executed, retry: 0
 
   def perform
-    time_ago = RETENTION_PERIOD.ago
-    SessionActivation.where('updated_at < ?', time_ago).in_batches.destroy_all
-    User.where('last_sign_in_at < ?', time_ago).where.not(last_sign_in_ip: nil).in_batches.update_all(last_sign_in_ip: nil)
+    clean_ip_columns!
+    clean_expired_ip_blocks!
+  end
+
+  private
+
+  def clean_ip_columns!
+    SessionActivation.where('updated_at < ?', IP_RETENTION_PERIOD.ago).in_batches.destroy_all
+    User.where('current_sign_in_at < ?', IP_RETENTION_PERIOD.ago).in_batches.update_all(last_sign_in_ip: nil, current_sign_in_ip: nil, sign_up_ip: nil)
+  end
+
+  def clean_expired_ip_blocks!
+    IpBlock.expired.in_batches.destroy_all
   end
 end
diff --git a/chart/templates/cronjob-media-remove.yaml b/chart/templates/cronjob-media-remove.yaml
new file mode 100644
index 000000000..5d78f3395
--- /dev/null
+++ b/chart/templates/cronjob-media-remove.yaml
@@ -0,0 +1,73 @@
+{{ if .Values.cron.removeMedia.enabled }}
+apiVersion: batch/v1beta1
+kind: CronJob
+metadata:
+  name: {{ include "mastodon.fullname" . }}-media-remove
+  labels:
+    {{- include "mastodon.labels" . | nindent 4 }}
+spec:
+  schedule: {{ .Values.cron.removeMedia.schedule }}
+  jobTemplate:
+    spec:
+      template:
+        metadata:
+          name: {{ include "mastodon.fullname" . }}-media-remove
+        spec:
+          restartPolicy: OnFailure
+          # ensure we run on the same node as the other rails components; only
+          # required when using PVCs that are ReadWriteOnce
+          {{- if or (eq "ReadWriteOnce" .Values.persistence.assets.accessMode) (eq "ReadWriteOnce" .Values.persistence.system.accessMode) }}
+          affinity:
+            podAffinity:
+              requiredDuringSchedulingIgnoredDuringExecution:
+              - labelSelector:
+                  matchExpressions:
+                    - key: component
+                      operator: In
+                      values:
+                        - rails
+                topologyKey: kubernetes.io/hostname
+          {{- end }}
+          volumes:
+            - name: assets
+              persistentVolumeClaim:
+                claimName: {{ template "mastodon.fullname" . }}-assets
+            - name: system
+              persistentVolumeClaim:
+                claimName: {{ template "mastodon.fullname" . }}-system
+          containers:
+            - name: {{ include "mastodon.fullname" . }}-media-remove
+              image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
+              imagePullPolicy: {{ .Values.image.pullPolicy }}
+              command:
+                - bin/tootctl
+                - media
+                - remove
+              envFrom:
+                - configMapRef:
+                    name: {{ include "mastodon.fullname" . }}-env
+                - secretRef:
+                    name: {{ template "mastodon.fullname" . }}
+              env:
+                - name: "DB_PASS"
+                  valueFrom:
+                    secretKeyRef:
+                      {{- if .Values.postgresql.enabled }}
+                      name: {{ .Release.Name }}-postgresql
+                      {{- else }}
+                      name: {{ template "mastodon.fullname" . }}
+                      {{- end }}
+                      key: postgresql-password
+                - name: "REDIS_PASSWORD"
+                  valueFrom:
+                    secretKeyRef:
+                      name: {{ .Release.Name }}-redis
+                      key: redis-password
+                - name: "PORT"
+                  value: {{ .Values.application.web.port | quote }}
+              volumeMounts:
+                - name: assets
+                  mountPath: /opt/mastodon/public/assets
+                - name: system
+                  mountPath: /opt/mastodon/public/system
+{{- end }}
diff --git a/chart/values.yaml.template b/chart/values.yaml.template
index ff680b81f..c18d2f0b5 100644
--- a/chart/values.yaml.template
+++ b/chart/values.yaml.template
@@ -39,6 +39,12 @@ createAdmin:
 # available locales: https://github.com/tootsuite/mastodon/blob/master/config/application.rb#L43
 locale: en
 
+cron:
+  # run `tootctl media remove` every week
+  removeMedia:
+    enabled: true
+    schedule: "0 0 * * 0"
+
 application:
   web:
     port: 3000
diff --git a/config/initializers/paperclip.rb b/config/initializers/paperclip.rb
index b4849370d..b841d5220 100644
--- a/config/initializers/paperclip.rb
+++ b/config/initializers/paperclip.rb
@@ -62,7 +62,7 @@ if ENV['S3_ENABLED'] == 'true'
     s3_options: {
       signature_version: ENV.fetch('S3_SIGNATURE_VERSION') { 'v4' },
       http_open_timeout: ENV.fetch('S3_OPEN_TIMEOUT'){ '5' }.to_i,
-      http_read_timeout: 5,
+      http_read_timeout: ENV.fetch('S3_READ_TIMEOUT'){ '5' }.to_i,
       http_idle_timeout: 5,
       retry_limit: 0,
     }
diff --git a/config/initializers/rack_attack.rb b/config/initializers/rack_attack.rb
index f11e87b11..4904b8d57 100644
--- a/config/initializers/rack_attack.rb
+++ b/config/initializers/rack_attack.rb
@@ -42,6 +42,10 @@ class Rack::Attack
     req.remote_ip == '127.0.0.1' || req.remote_ip == '::1'
   end
 
+  Rack::Attack.blocklist('deny from blocklist') do |req|
+    IpBlock.blocked?(req.remote_ip)
+  end
+
   throttle('throttle_authenticated_api', limit: 300, period: 5.minutes) do |req|
     req.authenticated_user_id if req.api_request?
   end
diff --git a/config/initializers/twitter_regex.rb b/config/initializers/twitter_regex.rb
index f84f7c0cb..7f99a0005 100644
--- a/config/initializers/twitter_regex.rb
+++ b/config/initializers/twitter_regex.rb
@@ -29,7 +29,7 @@ module Twitter
       (                                                                                     #   $1 total match
         (#{REGEXEN[:valid_url_preceding_chars]})                                            #   $2 Preceding character
         (                                                                                   #   $3 URL
-          ((?:https?|dat|dweb|ipfs|ipns|ssb|gopher):\/\/)?                                  #   $4 Protocol (optional)
+          ((?:https?|dat|dweb|ipfs|ipns|ssb|gopher|gemini):\/\/)?                           #   $4 Protocol (optional)
           (#{REGEXEN[:valid_domain]})                                                       #   $5 Domain(s)
           (?::(#{REGEXEN[:valid_port_number]}))?                                            #   $6 Port number (optional)
           (/#{REGEXEN[:valid_url_path]}*)?                                                  #   $7 URL Path and anchor
diff --git a/config/locales/en.yml b/config/locales/en.yml
index 427b2c3fc..084006a2a 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -223,12 +223,14 @@ en:
         create_domain_allow: Create Domain Allow
         create_domain_block: Create Domain Block
         create_email_domain_block: Create E-mail Domain Block
+        create_ip_block: Create IP rule
         demote_user: Demote User
         destroy_announcement: Delete Announcement
         destroy_custom_emoji: Delete Custom Emoji
         destroy_domain_allow: Delete Domain Allow
         destroy_domain_block: Delete Domain Block
         destroy_email_domain_block: Delete e-mail domain block
+        destroy_ip_block: Delete IP rule
         destroy_status: Delete Status
         disable_2fa_user: Disable 2FA
         disable_custom_emoji: Disable Custom Emoji
@@ -259,12 +261,14 @@ en:
         create_domain_allow: "%{name} allowed federation with domain %{target}"
         create_domain_block: "%{name} blocked domain %{target}"
         create_email_domain_block: "%{name} blocked e-mail domain %{target}"
+        create_ip_block: "%{name} created rule for IP %{target}"
         demote_user: "%{name} demoted user %{target}"
         destroy_announcement: "%{name} deleted announcement %{target}"
         destroy_custom_emoji: "%{name} destroyed emoji %{target}"
         destroy_domain_allow: "%{name} disallowed federation with domain %{target}"
         destroy_domain_block: "%{name} unblocked domain %{target}"
         destroy_email_domain_block: "%{name} unblocked e-mail domain %{target}"
+        destroy_ip_block: "%{name} deleted rule for IP %{target}"
         destroy_status: "%{name} removed status by %{target}"
         disable_2fa_user: "%{name} disabled two factor requirement for user %{target}"
         disable_custom_emoji: "%{name} disabled emoji %{target}"
@@ -449,6 +453,21 @@ en:
         expired: Expired
         title: Filter
       title: Invites
+    ip_blocks:
+      add_new: Create rule
+      created_msg: Successfully added new IP rule
+      delete: Delete
+      expires_in:
+        '1209600': 2 weeks
+        '15778476': 6 months
+        '2629746': 1 month
+        '31556952': 1 year
+        '86400': 1 day
+        '94670856': 3 years
+      new:
+        title: Create new IP rule
+      no_ip_block_selected: No IP rules were changed as none were selected
+      title: IP rules
     pending_accounts:
       title: Pending accounts (%{count})
     relationships:
diff --git a/config/locales/simple_form.en.yml b/config/locales/simple_form.en.yml
index 910e77ec2..b69487953 100644
--- a/config/locales/simple_form.en.yml
+++ b/config/locales/simple_form.en.yml
@@ -65,6 +65,14 @@ en:
         data: CSV file exported from another Mastodon server
       invite_request:
         text: This will help us review your application
+      ip_block:
+        comment: Optional. Remember why you added this rule.
+        expires_in: IP addresses are a finite resource, they are sometimes shared and often change hands. For this reason, indefinite IP blocks are not recommended.
+        ip: Enter an IPv4 or IPv6 address. You can block entire ranges using the CIDR syntax. Be careful not to lock yourself out!
+        severities:
+          no_access: Block access to all resources
+          sign_up_requires_approval: New sign-ups will require your approval
+        severity: Choose what will happen with requests from this IP
       sessions:
         otp: 'Enter the two-factor code generated by your phone app or use one of your recovery codes:'
         webauthn: If it's an USB key be sure to insert it and, if necessary, tap it.
@@ -136,6 +144,7 @@ en:
         setting_default_privacy: Posting privacy
         setting_default_sensitive: Always mark media as sensitive
         setting_delete_modal: Show confirmation dialog before deleting a toot
+        setting_disable_swiping: Disable swiping motions
         setting_display_media: Media display
         setting_display_media_default: Default
         setting_display_media_hide_all: Hide all
@@ -169,6 +178,13 @@ en:
         comment: Comment
       invite_request:
         text: Why do you want to join?
+      ip_block:
+        comment: Comment
+        ip: IP
+        severities:
+          no_access: Block access
+          sign_up_requires_approval: Limit sign-ups
+        severity: Rule
       notification_emails:
         digest: Send digest e-mails
         favourite: Someone favourited your status
diff --git a/config/navigation.rb b/config/navigation.rb
index 5fcbd2fe4..cb97709d6 100644
--- a/config/navigation.rb
+++ b/config/navigation.rb
@@ -51,6 +51,7 @@ SimpleNavigation::Configuration.run do |navigation|
       s.item :tags, safe_join([fa_icon('hashtag fw'), t('admin.tags.title')]), admin_tags_path, highlights_on: %r{/admin/tags}
       s.item :instances, safe_join([fa_icon('cloud fw'), t('admin.instances.title')]), admin_instances_url, 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? }
     end
 
     n.item :admin, safe_join([fa_icon('cogs fw'), t('admin.title')]), admin_dashboard_url, if: proc { current_user.staff? } do |s|
diff --git a/config/routes.rb b/config/routes.rb
index 13b744bf8..377ae3c46 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -55,10 +55,10 @@ Rails.application.routes.draw do
 
   devise_for :users, path: 'auth', controllers: {
     omniauth_callbacks: 'auth/omniauth_callbacks',
-    sessions: 'auth/sessions',
-    registrations: 'auth/registrations',
-    passwords: 'auth/passwords',
-    confirmations: 'auth/confirmations',
+    sessions:           'auth/sessions',
+    registrations:      'auth/registrations',
+    passwords:          'auth/passwords',
+    confirmations:      'auth/confirmations',
   }
 
   get '/users/:username', to: redirect('/@%{username}'), constraints: lambda { |req| req.format.nil? || req.format.html? }
@@ -86,6 +86,7 @@ Rails.application.routes.draw do
     resource :inbox, only: [:create], module: :activitypub
     resource :claim, only: [:create], module: :activitypub
     resources :collections, only: [:show], module: :activitypub
+    resource :followers_synchronization, only: [:show], module: :activitypub
   end
 
   resource :inbox, only: [:create], module: :activitypub
@@ -294,6 +295,12 @@ Rails.application.routes.draw do
       resource :two_factor_authentication, only: [:destroy]
     end
 
+    resources :ip_blocks, only: [:index, :new, :create] do
+      collection do
+        post :batch
+      end
+    end
+
     resources :account_moderation_notes, only: [:create, :destroy]
 
     resources :tags, only: [:index, :show, :update] do
@@ -395,11 +402,7 @@ Rails.application.routes.draw do
 
       resources :media,        only: [:create, :update, :show]
       resources :blocks,       only: [:index]
-      resources :mutes,        only: [:index] do
-        collection do
-          get 'details'
-        end
-      end
+      resources :mutes,        only: [:index]
       resources :favourites,   only: [:index]
       resources :bookmarks,    only: [:index]
       resources :reports,      only: [:create]
diff --git a/config/settings.yml b/config/settings.yml
index be9fe093a..0877fe111 100644
--- a/config/settings.yml
+++ b/config/settings.yml
@@ -27,6 +27,7 @@ defaults: &defaults
   expand_spoilers: false
   preview_sensitive_media: false
   reduce_motion: false
+  disable_swiping: false
   show_application: false
   system_font_ui: false
   system_emoji_font: false
diff --git a/config/webpack/configuration.js b/config/webpack/configuration.js
index 926af9b39..b34ba0e0a 100644
--- a/config/webpack/configuration.js
+++ b/config/webpack/configuration.js
@@ -57,22 +57,9 @@ for (let i = 0; i < skinFiles.length; i++) {
   }
 }
 
-function removeOuterSlashes(string) {
-  return string.replace(/^\/*/, '').replace(/\/*$/, '');
-}
-
-function formatPublicPath(host = '', path = '') {
-  let formattedHost = removeOuterSlashes(host);
-  if (formattedHost && !/^http/i.test(formattedHost)) {
-    formattedHost = `//${formattedHost}`;
-  }
-  const formattedPath = removeOuterSlashes(path);
-  return `${formattedHost}/${formattedPath}/`;
-}
-
 const output = {
   path: resolve('public', settings.public_output_path),
-  publicPath: formatPublicPath(env.CDN_HOST, settings.public_output_path),
+  publicPath: `/${settings.public_output_path}/`,
 };
 
 module.exports = {
@@ -80,8 +67,8 @@ module.exports = {
   core,
   flavours,
   env: {
-    CDN_HOST: env.CDN_HOST,
     NODE_ENV: env.NODE_ENV,
+    PUBLIC_OUTPUT_PATH: settings.public_output_path,
   },
   output,
 };
diff --git a/db/migrate/20200317021758_add_expires_at_to_mutes.rb b/db/migrate/20200317021758_add_expires_at_to_mutes.rb
new file mode 100644
index 000000000..eaae8319d
--- /dev/null
+++ b/db/migrate/20200317021758_add_expires_at_to_mutes.rb
@@ -0,0 +1,5 @@
+class AddExpiresAtToMutes < ActiveRecord::Migration[5.2]
+  def change
+    add_column :mutes, :expires_at, :datetime
+  end
+end
diff --git a/db/migrate/20201008202037_create_ip_blocks.rb b/db/migrate/20201008202037_create_ip_blocks.rb
new file mode 100644
index 000000000..32acd6ede
--- /dev/null
+++ b/db/migrate/20201008202037_create_ip_blocks.rb
@@ -0,0 +1,12 @@
+class CreateIpBlocks < ActiveRecord::Migration[5.2]
+  def change
+    create_table :ip_blocks do |t|
+      t.inet :ip, null: false, default: '0.0.0.0'
+      t.integer :severity, null: false, default: 0
+      t.datetime :expires_at
+      t.text :comment, null: false, default: ''
+
+      t.timestamps
+    end
+  end
+end
diff --git a/db/migrate/20201008220312_add_sign_up_ip_to_users.rb b/db/migrate/20201008220312_add_sign_up_ip_to_users.rb
new file mode 100644
index 000000000..66cd624bb
--- /dev/null
+++ b/db/migrate/20201008220312_add_sign_up_ip_to_users.rb
@@ -0,0 +1,5 @@
+class AddSignUpIpToUsers < ActiveRecord::Migration[5.2]
+  def change
+    add_column :users, :sign_up_ip, :inet
+  end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 620cb49fc..323851b10 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
 #
 # It's strongly recommended that you check this file into your version control system.
 
-ActiveRecord::Schema.define(version: 2020_09_25_035221) do
+ActiveRecord::Schema.define(version: 2020_10_08_220312) do
 
   # These are extensions that must be enabled in order to support this database
   enable_extension "plpgsql"
@@ -521,6 +521,15 @@ ActiveRecord::Schema.define(version: 2020_09_25_035221) do
     t.index ["user_id"], name: "index_invites_on_user_id"
   end
 
+  create_table "ip_blocks", force: :cascade do |t|
+    t.datetime "created_at", null: false
+    t.datetime "updated_at", null: false
+    t.datetime "expires_at"
+    t.inet "ip", default: "0.0.0.0", null: false
+    t.integer "severity", default: 0, null: false
+    t.text "comment", default: "", null: false
+  end
+
   create_table "list_accounts", force: :cascade do |t|
     t.bigint "list_id", null: false
     t.bigint "account_id", null: false
@@ -597,6 +606,7 @@ ActiveRecord::Schema.define(version: 2020_09_25_035221) do
     t.boolean "hide_notifications", default: true, null: false
     t.bigint "account_id", null: false
     t.bigint "target_account_id", null: false
+    t.datetime "expires_at"
     t.boolean "timelines_only", default: false, null: false
     t.index ["account_id", "target_account_id"], name: "index_mutes_on_account_id_and_target_account_id", unique: true
     t.index ["target_account_id"], name: "index_mutes_on_target_account_id"
@@ -1000,6 +1010,7 @@ ActiveRecord::Schema.define(version: 2020_09_25_035221) do
     t.string "username"
     t.string "kobold"
     t.string "webauthn_id"
+    t.inet "sign_up_ip"
     t.index "lower((username)::text)", name: "index_on_users_username_lowercase", unique: true
     t.index ["account_id"], name: "index_users_on_account_id"
     t.index ["confirmation_token"], name: "index_users_on_confirmation_token", unique: true
diff --git a/lib/cli.rb b/lib/cli.rb
index 9162144cc..2a4dd11b2 100644
--- a/lib/cli.rb
+++ b/lib/cli.rb
@@ -13,6 +13,7 @@ require_relative 'mastodon/preview_cards_cli'
 require_relative 'mastodon/cache_cli'
 require_relative 'mastodon/upgrade_cli'
 require_relative 'mastodon/email_domain_blocks_cli'
+require_relative 'mastodon/ip_blocks_cli'
 require_relative 'mastodon/version'
 
 module Mastodon
@@ -57,6 +58,9 @@ module Mastodon
     desc 'email_domain_blocks SUBCOMMAND ...ARGS', 'Manage e-mail domain blocks'
     subcommand 'email_domain_blocks', Mastodon::EmailDomainBlocksCLI
 
+    desc 'ip_blocks SUBCOMMAND ...ARGS', 'Manage IP blocks'
+    subcommand 'ip_blocks', Mastodon::IpBlocksCLI
+
     option :dry_run, type: :boolean
     desc 'self-destruct', 'Erase the server from the federation'
     long_desc <<~LONG_DESC
diff --git a/lib/mastodon/ip_blocks_cli.rb b/lib/mastodon/ip_blocks_cli.rb
new file mode 100644
index 000000000..6aff36d90
--- /dev/null
+++ b/lib/mastodon/ip_blocks_cli.rb
@@ -0,0 +1,132 @@
+# frozen_string_literal: true
+
+require 'rubygems/package'
+require_relative '../../config/boot'
+require_relative '../../config/environment'
+require_relative 'cli_helper'
+
+module Mastodon
+  class IpBlocksCLI < Thor
+    def self.exit_on_failure?
+      true
+    end
+
+    option :severity, required: true, enum: %w(no_access sign_up_requires_approval), desc: 'Severity of the block'
+    option :comment, aliases: [:c], desc: 'Optional comment'
+    option :duration, aliases: [:d], type: :numeric, desc: 'Duration of the block in seconds'
+    option :force, type: :boolean, aliases: [:f], desc: 'Overwrite existing blocks'
+    desc 'add IP...', 'Add one or more IP blocks'
+    long_desc <<-LONG_DESC
+      Add one or more IP blocks. You can use CIDR syntax to
+      block IP ranges. You must specify --severity of the block. All
+      options will be copied for each IP block you create in one command.
+
+      You can add a --comment. If an IP block already exists for one of
+      the provided IPs, it will be skipped unless you use the --force
+      option to overwrite it.
+    LONG_DESC
+    def add(*addresses)
+      if addresses.empty?
+        say('No IP(s) given', :red)
+        exit(1)
+      end
+
+      skipped   = 0
+      processed = 0
+      failed    = 0
+
+      addresses.each do |address|
+        ip_block = IpBlock.find_by(ip: address)
+
+        if ip_block.present? && !options[:force]
+          say("#{address} is already blocked", :yellow)
+          skipped += 1
+          next
+        end
+
+        ip_block ||= IpBlock.new(ip: address)
+
+        ip_block.severity   = options[:severity]
+        ip_block.comment    = options[:comment]
+        ip_block.expires_in = options[:duration]
+
+        if ip_block.save
+          processed += 1
+        else
+          say("#{address} could not be saved", :red)
+          failed += 1
+        end
+      end
+
+      say("Added #{processed}, skipped #{skipped}, failed #{failed}", color(processed, failed))
+    end
+
+    option :force, type: :boolean, aliases: [:f], desc: 'Remove blocks for ranges that cover given IP(s)'
+    desc 'remove IP...', 'Remove one or more IP blocks'
+    long_desc <<-LONG_DESC
+      Remove one or more IP blocks. Normally, only exact matches are removed. If
+      you want to ensure that all of the given IP addresses are unblocked, you
+      can use --force which will also remove any blocks for IP ranges that would
+      cover the given IP(s).
+    LONG_DESC
+    def remove(*addresses)
+      if addresses.empty?
+        say('No IP(s) given', :red)
+        exit(1)
+      end
+
+      processed = 0
+      skipped   = 0
+
+      addresses.each do |address|
+        ip_blocks = begin
+          if options[:force]
+            IpBlock.where('ip >>= ?', address)
+          else
+            IpBlock.where('ip <<= ?', address)
+          end
+        end
+
+        if ip_blocks.empty?
+          say("#{address} is not yet blocked", :yellow)
+          skipped += 1
+          next
+        end
+
+        ip_blocks.in_batches.destroy_all
+        processed += 1
+      end
+
+      say("Removed #{processed}, skipped #{skipped}", color(processed, 0))
+    end
+
+    option :format, aliases: [:f], enum: %w(plain nginx), desc: 'Format of the output'
+    desc 'export', 'Export blocked IPs'
+    long_desc <<-LONG_DESC
+      Export blocked IPs. Different formats are supported for usage with other
+      tools. Only blocks with no_access severity are returned.
+    LONG_DESC
+    def export
+      IpBlock.where(severity: :no_access).find_each do |ip_block|
+        case options[:format]
+        when 'nginx'
+          puts "deny #{ip_block.ip}/#{ip_block.ip.prefix};"
+        else
+          puts "#{ip_block.ip}/#{ip_block.ip.prefix}"
+        end
+      end
+    end
+
+    private
+
+    def color(processed, failed)
+      if !processed.zero? && failed.zero?
+        :green
+      elsif failed.zero?
+        :yellow
+      else
+        :red
+      end
+    end
+  end
+end
diff --git a/package.json b/package.json
index 642edfafc..9045dcb2b 100644
--- a/package.json
+++ b/package.json
@@ -70,7 +70,7 @@
     "@babel/runtime": "^7.11.2",
     "@clusterws/cws": "^3.0.0",
     "@gamestdio/websocket": "^0.3.2",
-    "@github/webauthn-json": "^0.5.4",
+    "@github/webauthn-json": "^0.5.6",
     "@rails/ujs": "^6.0.3",
     "array-includes": "^3.1.1",
     "arrow-key-navigation": "^1.2.0",
@@ -85,7 +85,7 @@
     "babel-runtime": "^6.26.0",
     "blurhash": "^1.1.3",
     "classnames": "^2.2.5",
-    "compression-webpack-plugin": "^5.0.1",
+    "compression-webpack-plugin": "^6.0.3",
     "cross-env": "^7.0.2",
     "css-loader": "^4.3.0",
     "cssnano": "^4.1.10",
@@ -97,13 +97,13 @@
     "exif-js": "^2.3.0",
     "express": "^4.17.1",
     "favico.js": "^0.3.10",
-    "file-loader": "^6.1.0",
+    "file-loader": "^6.1.1",
     "font-awesome": "^4.7.0",
     "glob": "^7.1.6",
     "history": "^4.10.1",
-    "http-link-header": "^1.0.2",
+    "http-link-header": "^1.0.3",
     "immutable": "^3.8.2",
-    "imports-loader": "^1.1.0",
+    "imports-loader": "^1.2.0",
     "intersection-observer": "^0.11.0",
     "intl": "^1.2.5",
     "intl-messageformat": "^2.2.0",
@@ -113,7 +113,7 @@
     "lodash": "^4.17.19",
     "mark-loader": "^0.1.6",
     "marky": "^1.2.1",
-    "mini-css-extract-plugin": "^0.11.0",
+    "mini-css-extract-plugin": "^0.11.3",
     "mkdirp": "^1.0.4",
     "npmlog": "^4.1.2",
     "object-assign": "^4.1.1",
@@ -137,7 +137,7 @@
     "react-masonry-infinite": "^1.2.2",
     "react-motion": "^0.5.2",
     "react-notification": "^6.8.5",
-    "react-overlays": "^0.9.1",
+    "react-overlays": "^0.9.2",
     "react-redux": "^7.2.1",
     "react-redux-loading-bar": "^4.0.8",
     "react-router-dom": "^4.1.1",
@@ -156,34 +156,34 @@
     "requestidlecallback": "^0.3.0",
     "reselect": "^4.0.0",
     "rimraf": "^3.0.2",
-    "sass": "^1.26.10",
-    "sass-loader": "^10.0.2",
+    "sass": "^1.27.0",
+    "sass-loader": "^10.0.3",
     "stacktrace-js": "^2.0.2",
     "stringz": "^2.1.0",
     "substring-trie": "^1.0.2",
-    "terser-webpack-plugin": "^4.2.2",
+    "terser-webpack-plugin": "^4.2.3",
     "tesseract.js": "^2.1.1",
     "throng": "^4.0.0",
     "tiny-queue": "^0.2.1",
-    "uuid": "^8.2.0",
+    "uuid": "^8.3.1",
     "webpack": "^4.44.2",
     "webpack-assets-manifest": "^3.1.1",
-    "webpack-bundle-analyzer": "^3.8.0",
+    "webpack-bundle-analyzer": "^3.9.0",
     "webpack-cli": "^3.3.12",
     "webpack-merge": "^4.2.1",
     "wicg-inert": "^3.0.3"
   },
   "devDependencies": {
     "@testing-library/jest-dom": "^5.11.4",
-    "@testing-library/react": "^10.4.7",
+    "@testing-library/react": "^11.0.4",
     "babel-eslint": "^10.1.0",
-    "babel-jest": "^26.3.0",
-    "eslint": "^7.6.0",
-    "eslint-plugin-import": "~2.22.0",
+    "babel-jest": "^26.5.2",
+    "eslint": "^7.11.0",
+    "eslint-plugin-import": "~2.22.1",
     "eslint-plugin-jsx-a11y": "~6.3.1",
     "eslint-plugin-promise": "~4.2.1",
-    "eslint-plugin-react": "~7.20.4",
-    "jest": "^26.4.2",
+    "eslint-plugin-react": "~7.21.4",
+    "jest": "^26.5.3",
     "raf": "^3.4.1",
     "react-intl-translations-manager": "^5.0.3",
     "react-test-renderer": "^16.13.1",
diff --git a/spec/controllers/activitypub/followers_synchronizations_controller_spec.rb b/spec/controllers/activitypub/followers_synchronizations_controller_spec.rb
new file mode 100644
index 000000000..a24d3f8e0
--- /dev/null
+++ b/spec/controllers/activitypub/followers_synchronizations_controller_spec.rb
@@ -0,0 +1,58 @@
+require 'rails_helper'
+
+RSpec.describe ActivityPub::FollowersSynchronizationsController, type: :controller do
+  let!(:account)    { Fabricate(:account) }
+  let!(:follower_1) { Fabricate(:account, domain: 'example.com', uri: 'https://example.com/users/a') }
+  let!(:follower_2) { Fabricate(:account, domain: 'example.com', uri: 'https://example.com/users/b') }
+  let!(:follower_3) { Fabricate(:account, domain: 'foo.com', uri: 'https://foo.com/users/a') }
+
+  before do
+    follower_1.follow!(account)
+    follower_2.follow!(account)
+    follower_3.follow!(account)
+  end
+
+  before do
+    allow(controller).to receive(:signed_request_account).and_return(remote_account)
+  end
+
+  describe 'GET #show' do
+    context 'without signature' do
+      let(:remote_account) { nil }
+
+      before do
+        get :show, params: { account_username: account.username }
+      end
+
+      it 'returns http not authorized' do
+        expect(response).to have_http_status(401)
+      end
+    end
+
+    context 'with signature from example.com' do
+      let(:remote_account) { Fabricate(:account, domain: 'example.com', uri: 'https://example.com/instance') }
+
+      before do
+        get :show, params: { account_username: account.username }
+      end
+
+      it 'returns http success' do
+        expect(response).to have_http_status(200)
+      end
+
+      it 'returns application/activity+json' do
+        expect(response.content_type).to eq 'application/activity+json'
+      end
+
+      it 'returns orderedItems with followers from example.com' do
+        json = body_as_json
+        expect(json[:orderedItems]).to be_an Array
+        expect(json[:orderedItems].sort).to eq [follower_1.uri, follower_2.uri]
+      end
+
+      it 'returns private Cache-Control header' do
+        expect(response.headers['Cache-Control']).to eq 'max-age=0, private'
+      end
+    end
+  end
+end
diff --git a/spec/controllers/activitypub/inboxes_controller_spec.rb b/spec/controllers/activitypub/inboxes_controller_spec.rb
index f3bc23953..e5c004611 100644
--- a/spec/controllers/activitypub/inboxes_controller_spec.rb
+++ b/spec/controllers/activitypub/inboxes_controller_spec.rb
@@ -22,6 +22,56 @@ RSpec.describe ActivityPub::InboxesController, type: :controller do
       end
     end
 
+    context 'with Collection-Synchronization header' do
+      let(:remote_account)             { Fabricate(:account, followers_url: 'https://example.com/followers', domain: 'example.com', uri: 'https://example.com/actor', protocol: :activitypub) }
+      let(:synchronization_collection) { remote_account.followers_url }
+      let(:synchronization_url)        { 'https://example.com/followers-for-domain' }
+      let(:synchronization_hash)       { 'somehash' }
+      let(:synchronization_header)     { "collectionId=\"#{synchronization_collection}\", digest=\"#{synchronization_hash}\", url=\"#{synchronization_url}\"" }
+
+      before do
+        allow(ActivityPub::FollowersSynchronizationWorker).to receive(:perform_async).and_return(nil)
+        allow_any_instance_of(Account).to receive(:local_followers_hash).and_return('somehash')
+
+        request.headers['Collection-Synchronization'] = synchronization_header
+        post :create, body: '{}'
+      end
+
+      context 'with mismatching target collection' do
+        let(:synchronization_collection) { 'https://example.com/followers2' }
+
+        it 'does not start a synchronization job' do
+          expect(ActivityPub::FollowersSynchronizationWorker).not_to have_received(:perform_async)
+        end
+      end
+
+      context 'with mismatching domain in partial collection attribute' do
+        let(:synchronization_url) { 'https://example.org/followers' }
+
+        it 'does not start a synchronization job' do
+          expect(ActivityPub::FollowersSynchronizationWorker).not_to have_received(:perform_async)
+        end
+      end
+
+      context 'with matching digest' do
+        it 'does not start a synchronization job' do
+          expect(ActivityPub::FollowersSynchronizationWorker).not_to have_received(:perform_async)
+        end
+      end
+
+      context 'with mismatching digest' do
+        let(:synchronization_hash) { 'wronghash' }
+
+        it 'starts a synchronization job' do
+          expect(ActivityPub::FollowersSynchronizationWorker).to have_received(:perform_async)
+        end
+      end
+
+      it 'returns http accepted' do
+        expect(response).to have_http_status(202)
+      end
+    end
+
     context 'without signature' do
       before do
         post :create, body: '{}'
diff --git a/spec/controllers/api/v1/mutes_controller_spec.rb b/spec/controllers/api/v1/mutes_controller_spec.rb
index 95ec17d6f..a2b814a69 100644
--- a/spec/controllers/api/v1/mutes_controller_spec.rb
+++ b/spec/controllers/api/v1/mutes_controller_spec.rb
@@ -60,25 +60,4 @@ RSpec.describe Api::V1::MutesController, type: :controller do
       end
     end
   end
-
-  describe 'GET #details' do
-    before do
-      Fabricate(:mute, account: user.account, hide_notifications: false)
-      get :details, params: { limit: 1 }
-    end
-
-    let(:mutes) { JSON.parse(response.body) }
-
-    it 'returns http success' do
-      expect(response).to have_http_status(:success)
-    end
-
-    it 'returns one mute' do
-      expect(mutes.size).to be(1)
-    end
-
-    it 'returns whether the mute hides notifications' do
-      expect(mutes.first["hide_notifications"]).to be(false)
-    end 
-  end
 end
diff --git a/spec/controllers/remote_follow_controller_spec.rb b/spec/controllers/remote_follow_controller_spec.rb
index 3ef8f14d9..7312dde58 100644
--- a/spec/controllers/remote_follow_controller_spec.rb
+++ b/spec/controllers/remote_follow_controller_spec.rb
@@ -43,8 +43,7 @@ describe RemoteFollowController do
         end
 
         it 'renders new when template is nil' do
-          link_with_nil_template = double(template: nil)
-          resource_with_link = double(link: link_with_nil_template)
+          resource_with_link = double(link: nil)
           allow_any_instance_of(WebfingerHelper).to receive(:webfinger!).with('acct:user@example.com').and_return(resource_with_link)
           post :create, params: { account_username: @account.to_param, remote_follow: { acct: 'user@example.com' } }
 
@@ -55,8 +54,7 @@ describe RemoteFollowController do
 
       context 'when webfinger values are good' do
         before do
-          link_with_template = double(template: 'http://example.com/follow_me?acct={uri}')
-          resource_with_link = double(link: link_with_template)
+          resource_with_link = double(link: 'http://example.com/follow_me?acct={uri}')
           allow_any_instance_of(WebfingerHelper).to receive(:webfinger!).with('acct:user@example.com').and_return(resource_with_link)
           post :create, params: { account_username: @account.to_param, remote_follow: { acct: 'user@example.com' } }
         end
@@ -78,8 +76,8 @@ describe RemoteFollowController do
         expect(response).to render_template(:new)
       end
 
-      it 'renders new with error when goldfinger fails' do
-        allow_any_instance_of(WebfingerHelper).to receive(:webfinger!).with('acct:user@example.com').and_raise(Goldfinger::Error)
+      it 'renders new with error when webfinger fails' do
+        allow_any_instance_of(WebfingerHelper).to receive(:webfinger!).with('acct:user@example.com').and_raise(Webfinger::Error)
         post :create, params: { account_username: @account.to_param, remote_follow: { acct: 'user@example.com' } }
 
         expect(response).to render_template(:new)
diff --git a/spec/controllers/well_known/host_meta_controller_spec.rb b/spec/controllers/well_known/host_meta_controller_spec.rb
index b43ae19d8..643ba9cd3 100644
--- a/spec/controllers/well_known/host_meta_controller_spec.rb
+++ b/spec/controllers/well_known/host_meta_controller_spec.rb
@@ -12,7 +12,7 @@ describe WellKnown::HostMetaController, type: :controller do
       expect(response.body).to eq <<XML
 <?xml version="1.0" encoding="UTF-8"?>
 <XRD xmlns="http://docs.oasis-open.org/ns/xri/xrd-1.0">
-  <Link rel="lrdd" type="application/xrd+xml" template="https://cb6e6126.ngrok.io/.well-known/webfinger?resource={uri}"/>
+  <Link rel="lrdd" template="https://cb6e6126.ngrok.io/.well-known/webfinger?resource={uri}"/>
 </XRD>
 XML
     end
diff --git a/spec/fabricators/ip_block_fabricator.rb b/spec/fabricators/ip_block_fabricator.rb
new file mode 100644
index 000000000..31dc336e6
--- /dev/null
+++ b/spec/fabricators/ip_block_fabricator.rb
@@ -0,0 +1,6 @@
+Fabricator(:ip_block) do
+  ip         ""
+  severity   ""
+  expires_at "2020-10-08 22:20:37"
+  comment    "MyText"
+end
\ No newline at end of file
diff --git a/spec/lib/fast_ip_map_spec.rb b/spec/lib/fast_ip_map_spec.rb
new file mode 100644
index 000000000..c66f64828
--- /dev/null
+++ b/spec/lib/fast_ip_map_spec.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+describe FastIpMap do
+  describe '#include?' do
+    subject { described_class.new([IPAddr.new('20.4.0.0/16'), IPAddr.new('145.22.30.0/24'), IPAddr.new('189.45.86.3')])}
+
+    it 'returns true for an exact match' do
+      expect(subject.include?(IPAddr.new('189.45.86.3'))).to be true
+    end
+
+    it 'returns true for a range match' do
+      expect(subject.include?(IPAddr.new('20.4.45.7'))).to be true
+    end
+
+    it 'returns false for no match' do
+      expect(subject.include?(IPAddr.new('145.22.40.64'))).to be false
+    end
+  end
+end
diff --git a/spec/lib/feed_manager_spec.rb b/spec/lib/feed_manager_spec.rb
index 22c9ff31b..78563ee94 100644
--- a/spec/lib/feed_manager_spec.rb
+++ b/spec/lib/feed_manager_spec.rb
@@ -108,6 +108,7 @@ RSpec.describe FeedManager do
 
       it 'returns false for status by followee mentioning another account' do
         bob.follow!(alice)
+        jeff.follow!(alice)
         status = PostStatusService.new.call(alice, text: 'Hey @jeff')
         expect(FeedManager.instance.filter?(:home, status, bob)).to be false
       end
diff --git a/spec/models/concerns/account_interactions_spec.rb b/spec/models/concerns/account_interactions_spec.rb
index 48b44e58d..db959280c 100644
--- a/spec/models/concerns/account_interactions_spec.rb
+++ b/spec/models/concerns/account_interactions_spec.rb
@@ -546,6 +546,49 @@ describe AccountInteractions do
     end
   end
 
+  describe '#followers_hash' do
+    let(:me) { Fabricate(:account, username: 'Me') }
+    let(:remote_1) { Fabricate(:account, username: 'alice', domain: 'example.org', uri: 'https://example.org/users/alice') }
+    let(:remote_2) { Fabricate(:account, username: 'bob', domain: 'example.org', uri: 'https://example.org/users/bob') }
+    let(:remote_3) { Fabricate(:account, username: 'eve', domain: 'foo.org', uri: 'https://foo.org/users/eve') }
+
+    before do
+      remote_1.follow!(me)
+      remote_2.follow!(me)
+      remote_3.follow!(me)
+      me.follow!(remote_1)
+    end
+
+    context 'on a local user' do
+      it 'returns correct hash for remote domains' do
+        expect(me.remote_followers_hash('https://example.org/')).to eq '707962e297b7bd94468a21bc8e506a1bcea607a9142cd64e27c9b106b2a5f6ec'
+        expect(me.remote_followers_hash('https://foo.org/')).to eq 'ccb9c18a67134cfff9d62c7f7e7eb88e6b803446c244b84265565f4eba29df0e'
+      end
+
+      it 'invalidates cache as needed when removing or adding followers' do
+        expect(me.remote_followers_hash('https://example.org/')).to eq '707962e297b7bd94468a21bc8e506a1bcea607a9142cd64e27c9b106b2a5f6ec'
+        remote_1.unfollow!(me)
+        expect(me.remote_followers_hash('https://example.org/')).to eq '241b00794ce9b46aa864f3220afadef128318da2659782985bac5ed5bd436bff'
+        remote_1.follow!(me)
+        expect(me.remote_followers_hash('https://example.org/')).to eq '707962e297b7bd94468a21bc8e506a1bcea607a9142cd64e27c9b106b2a5f6ec'
+      end
+    end
+
+    context 'on a remote user' do
+      it 'returns correct hash for remote domains' do
+        expect(remote_1.local_followers_hash).to eq Digest::SHA256.hexdigest(ActivityPub::TagManager.instance.uri_for(me))
+      end
+
+      it 'invalidates cache as needed when removing or adding followers' do
+        expect(remote_1.local_followers_hash).to eq Digest::SHA256.hexdigest(ActivityPub::TagManager.instance.uri_for(me))
+        me.unfollow!(remote_1)
+        expect(remote_1.local_followers_hash).to eq '0000000000000000000000000000000000000000000000000000000000000000'
+        me.follow!(remote_1)
+        expect(remote_1.local_followers_hash).to eq Digest::SHA256.hexdigest(ActivityPub::TagManager.instance.uri_for(me))
+      end
+    end
+  end
+
   describe 'muting an account' do
     let(:me) { Fabricate(:account, username: 'Me') }
     let(:you) { Fabricate(:account, username: 'You') }
diff --git a/spec/models/ip_block_spec.rb b/spec/models/ip_block_spec.rb
new file mode 100644
index 000000000..6603c6417
--- /dev/null
+++ b/spec/models/ip_block_spec.rb
@@ -0,0 +1,5 @@
+require 'rails_helper'
+
+RSpec.describe IpBlock, type: :model do
+  pending "add some examples to (or delete) #{__FILE__}"
+end
diff --git a/spec/services/activitypub/synchronize_followers_service_spec.rb b/spec/services/activitypub/synchronize_followers_service_spec.rb
new file mode 100644
index 000000000..75dcf204b
--- /dev/null
+++ b/spec/services/activitypub/synchronize_followers_service_spec.rb
@@ -0,0 +1,105 @@
+require 'rails_helper'
+
+RSpec.describe ActivityPub::SynchronizeFollowersService, type: :service do
+  let(:actor)          { Fabricate(:account, domain: 'example.com', uri: 'http://example.com/account', inbox_url: 'http://example.com/inbox') }
+  let(:alice)          { Fabricate(:account, username: 'alice') }
+  let(:bob)            { Fabricate(:account, username: 'bob') }
+  let(:eve)            { Fabricate(:account, username: 'eve') }
+  let(:mallory)        { Fabricate(:account, username: 'mallory') }
+  let(:collection_uri) { 'http://example.com/partial-followers' }
+
+  let(:items) do
+    [
+      ActivityPub::TagManager.instance.uri_for(alice),
+      ActivityPub::TagManager.instance.uri_for(eve),
+      ActivityPub::TagManager.instance.uri_for(mallory),
+    ]
+  end
+
+  let(:payload) do
+    {
+      '@context': 'https://www.w3.org/ns/activitystreams',
+      type: 'Collection',
+      id: collection_uri,
+      items: items,
+    }.with_indifferent_access
+  end
+
+  subject { described_class.new }
+
+  shared_examples 'synchronizes followers' do
+    before do
+      alice.follow!(actor)
+      bob.follow!(actor)
+      mallory.request_follow!(actor)
+
+      allow(ActivityPub::DeliveryWorker).to receive(:perform_async)
+
+      subject.call(actor, collection_uri)
+    end
+
+    it 'keeps expected followers' do
+      expect(alice.following?(actor)).to be true
+    end
+
+    it 'removes local followers not in the remote list' do
+      expect(bob.following?(actor)).to be false
+    end
+
+    it 'converts follow requests to follow relationships when they have been accepted' do
+      expect(mallory.following?(actor)).to be true
+    end
+
+    it 'sends an Undo Follow to the actor' do
+      expect(ActivityPub::DeliveryWorker).to have_received(:perform_async).with(anything, eve.id, actor.inbox_url)
+    end
+  end
+
+  describe '#call' do
+    context 'when the endpoint is a Collection of actor URIs' do
+      before do
+        stub_request(:get, collection_uri).to_return(status: 200, body: Oj.dump(payload))
+      end
+
+      it_behaves_like 'synchronizes followers'
+    end
+
+    context 'when the endpoint is an OrderedCollection of actor URIs' do
+      let(:payload) do
+        {
+          '@context': 'https://www.w3.org/ns/activitystreams',
+          type: 'OrderedCollection',
+          id: collection_uri,
+          orderedItems: items,
+        }.with_indifferent_access
+      end
+
+      before do
+        stub_request(:get, collection_uri).to_return(status: 200, body: Oj.dump(payload))
+      end
+
+      it_behaves_like 'synchronizes followers'
+    end
+
+    context 'when the endpoint is a paginated Collection of actor URIs' do
+      let(:payload) do
+        {
+          '@context': 'https://www.w3.org/ns/activitystreams',
+          type: 'Collection',
+          id: collection_uri,
+          first: {
+            type: 'CollectionPage',
+            partOf: collection_uri,
+            items: items,
+          }
+        }.with_indifferent_access
+      end
+
+      before do
+        stub_request(:get, collection_uri).to_return(status: 200, body: Oj.dump(payload))
+      end
+
+      it_behaves_like 'synchronizes followers'
+    end
+  end
+end
diff --git a/spec/services/app_sign_up_service_spec.rb b/spec/services/app_sign_up_service_spec.rb
index e7c7f3ba1..e0c83b704 100644
--- a/spec/services/app_sign_up_service_spec.rb
+++ b/spec/services/app_sign_up_service_spec.rb
@@ -3,6 +3,7 @@ require 'rails_helper'
 RSpec.describe AppSignUpService, type: :service do
   let(:app) { Fabricate(:application, scopes: 'read write') }
   let(:good_params) { { username: 'alice', password: '12345678', email: 'good@email.com', agreement: true } }
+  let(:remote_ip) { IPAddr.new('198.0.2.1') }
 
   subject { described_class.new }
 
@@ -10,16 +11,16 @@ RSpec.describe AppSignUpService, type: :service do
     it 'returns nil when registrations are closed' do
       tmp = Setting.registrations_mode
       Setting.registrations_mode = 'none'
-      expect(subject.call(app, good_params)).to be_nil
+      expect(subject.call(app, remote_ip, good_params)).to be_nil
       Setting.registrations_mode = tmp
     end
 
     it 'raises an error when params are missing' do
-      expect { subject.call(app, {}) }.to raise_error ActiveRecord::RecordInvalid
+      expect { subject.call(app, remote_ip, {}) }.to raise_error ActiveRecord::RecordInvalid
     end
 
     it 'creates an unconfirmed user with access token' do
-      access_token = subject.call(app, good_params)
+      access_token = subject.call(app, remote_ip, good_params)
       expect(access_token).to_not be_nil
       user = User.find_by(id: access_token.resource_owner_id)
       expect(user).to_not be_nil
@@ -27,13 +28,13 @@ RSpec.describe AppSignUpService, type: :service do
     end
 
     it 'creates access token with the app\'s scopes' do
-      access_token = subject.call(app, good_params)
+      access_token = subject.call(app, remote_ip, good_params)
       expect(access_token).to_not be_nil
       expect(access_token.scopes.to_s).to eq 'read write'
     end
 
     it 'creates an account' do
-      access_token = subject.call(app, good_params)
+      access_token = subject.call(app, remote_ip, good_params)
       expect(access_token).to_not be_nil
       user = User.find_by(id: access_token.resource_owner_id)
       expect(user).to_not be_nil
@@ -42,7 +43,7 @@ RSpec.describe AppSignUpService, type: :service do
     end
 
     it 'creates an account with invite request text' do
-      access_token = subject.call(app, good_params.merge(reason: 'Foo bar'))
+      access_token = subject.call(app, remote_ip, good_params.merge(reason: 'Foo bar'))
       expect(access_token).to_not be_nil
       user = User.find_by(id: access_token.resource_owner_id)
       expect(user).to_not be_nil
diff --git a/spec/workers/activitypub/delivery_worker_spec.rb b/spec/workers/activitypub/delivery_worker_spec.rb
index 351be185c..f4633731e 100644
--- a/spec/workers/activitypub/delivery_worker_spec.rb
+++ b/spec/workers/activitypub/delivery_worker_spec.rb
@@ -3,16 +3,22 @@
 require 'rails_helper'
 
 describe ActivityPub::DeliveryWorker do
+  include RoutingHelper
+
   subject { described_class.new }
 
   let(:sender)  { Fabricate(:account) }
   let(:payload) { 'test' }
 
+  before do
+    allow_any_instance_of(Account).to receive(:remote_followers_hash).with('https://example.com/').and_return('somehash')
+  end
+
   describe 'perform' do
     it 'performs a request' do
       stub_request(:post, 'https://example.com/api').to_return(status: 200)
-      subject.perform(payload, sender.id, 'https://example.com/api')
-      expect(a_request(:post, 'https://example.com/api')).to have_been_made.once
+      subject.perform(payload, sender.id, 'https://example.com/api', { synchronize_followers: true })
+      expect(a_request(:post, 'https://example.com/api').with(headers: { 'Collection-Synchronization' => "collectionId=\"#{account_followers_url(sender)}\", digest=\"somehash\", url=\"#{account_followers_synchronization_url(sender)}\"" })).to have_been_made.once
     end
 
     it 'raises when request fails' do
diff --git a/yarn.lock b/yarn.lock
index 5d6ff2fde..ff3afd379 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1095,6 +1095,22 @@
   resolved "https://registry.yarnpkg.com/@emotion/weak-memoize/-/weak-memoize-0.2.4.tgz#622a72bebd1e3f48d921563b4b60a762295a81fc"
   integrity sha512-6PYY5DVdAY1ifaQW6XYTnOMihmBVT27elqSjEoodchsGjzYlEsTQMcEhSud99kVawatyTZRTiVkJ/c6lwbQ7nA==
 
+"@eslint/eslintrc@^0.1.3":
+  version "0.1.3"
+  resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-0.1.3.tgz#7d1a2b2358552cc04834c0979bd4275362e37085"
+  integrity sha512-4YVwPkANLeNtRjMekzux1ci8hIaH5eGKktGqR0d3LWsKNn5B2X/1Z6Trxy7jQXl9EBGE6Yj02O+t09FMeRllaA==
+  dependencies:
+    ajv "^6.12.4"
+    debug "^4.1.1"
+    espree "^7.3.0"
+    globals "^12.1.0"
+    ignore "^4.0.6"
+    import-fresh "^3.2.1"
+    js-yaml "^3.13.1"
+    lodash "^4.17.19"
+    minimatch "^3.0.4"
+    strip-json-comments "^3.1.1"
+
 "@formatjs/intl-unified-numberformat@^3.3.3":
   version "3.3.6"
   resolved "https://registry.yarnpkg.com/@formatjs/intl-unified-numberformat/-/intl-unified-numberformat-3.3.6.tgz#ab69818f7568894023cb31fdb5b5c7eed62c6537"
@@ -1112,10 +1128,10 @@
   resolved "https://registry.yarnpkg.com/@gamestdio/websocket/-/websocket-0.3.2.tgz#321ba0976ee30fd14e51dbf8faa85ce7b325f76a"
   integrity sha512-J3n5SKim+ZoLbe44hRGI/VYAwSMCeIJuBy+FfP6EZaujEpNchPRFcIsVQLWAwpU1bP2Ji63rC+rEUOd1vjUB6Q==
 
-"@github/webauthn-json@^0.5.4":
-  version "0.5.4"
-  resolved "https://registry.yarnpkg.com/@github/webauthn-json/-/webauthn-json-0.5.4.tgz#fe4f47647c2b29e42e33f6f30484fec7e5adfeb1"
-  integrity sha512-lZi5cSZi2F08a2kmjxr/FU13ILenpxkZJUWo1p3hHAmMHLLr5GAVlkCJmvWeIsIiAp65AHh3dQIZSMPIylcClw==
+"@github/webauthn-json@^0.5.6":
+  version "0.5.6"
+  resolved "https://registry.yarnpkg.com/@github/webauthn-json/-/webauthn-json-0.5.6.tgz#dd0c2c284d6b1b806fea3c534da72d65cbdafed2"
+  integrity sha512-38PqUby4jxRHFKryRFsqmlznXrl/Tray0lmHOaLq12uTdLIjO4i++FUrThf/IOcNmee+qYhEZS1kVSOuSPbY2Q==
 
 "@istanbuljs/load-nyc-config@^1.0.0":
   version "1.1.0"
@@ -1133,93 +1149,93 @@
   resolved "https://registry.yarnpkg.com/@istanbuljs/schema/-/schema-0.1.2.tgz#26520bf09abe4a5644cd5414e37125a8954241dd"
   integrity sha512-tsAQNx32a8CoFhjhijUIhI4kccIAgmGhy8LZMZgGfmXcpMbPRUqn5LWmgRttILi6yeGmBJd2xsPkFMs0PzgPCw==
 
-"@jest/console@^26.3.0":
-  version "26.3.0"
-  resolved "https://registry.yarnpkg.com/@jest/console/-/console-26.3.0.tgz#ed04063efb280c88ba87388b6f16427c0a85c856"
-  integrity sha512-/5Pn6sJev0nPUcAdpJHMVIsA8sKizL2ZkcKPE5+dJrCccks7tcM7c9wbgHudBJbxXLoTbqsHkG1Dofoem4F09w==
+"@jest/console@^26.5.2":
+  version "26.5.2"
+  resolved "https://registry.yarnpkg.com/@jest/console/-/console-26.5.2.tgz#94fc4865b1abed7c352b5e21e6c57be4b95604a6"
+  integrity sha512-lJELzKINpF1v74DXHbCRIkQ/+nUV1M+ntj+X1J8LxCgpmJZjfLmhFejiMSbjjD66fayxl5Z06tbs3HMyuik6rw==
   dependencies:
-    "@jest/types" "^26.3.0"
+    "@jest/types" "^26.5.2"
     "@types/node" "*"
     chalk "^4.0.0"
-    jest-message-util "^26.3.0"
-    jest-util "^26.3.0"
+    jest-message-util "^26.5.2"
+    jest-util "^26.5.2"
     slash "^3.0.0"
 
-"@jest/core@^26.4.2":
-  version "26.4.2"
-  resolved "https://registry.yarnpkg.com/@jest/core/-/core-26.4.2.tgz#85d0894f31ac29b5bab07aa86806d03dd3d33edc"
-  integrity sha512-sDva7YkeNprxJfepOctzS8cAk9TOekldh+5FhVuXS40+94SHbiicRO1VV2tSoRtgIo+POs/Cdyf8p76vPTd6dg==
+"@jest/core@^26.5.3":
+  version "26.5.3"
+  resolved "https://registry.yarnpkg.com/@jest/core/-/core-26.5.3.tgz#712ed4adb64c3bda256a3f400ff1d3eb2a031f13"
+  integrity sha512-CiU0UKFF1V7KzYTVEtFbFmGLdb2g4aTtY0WlyUfLgj/RtoTnJFhh50xKKr7OYkdmBUlGFSa2mD1TU3UZ6OLd4g==
   dependencies:
-    "@jest/console" "^26.3.0"
-    "@jest/reporters" "^26.4.1"
-    "@jest/test-result" "^26.3.0"
-    "@jest/transform" "^26.3.0"
-    "@jest/types" "^26.3.0"
+    "@jest/console" "^26.5.2"
+    "@jest/reporters" "^26.5.3"
+    "@jest/test-result" "^26.5.2"
+    "@jest/transform" "^26.5.2"
+    "@jest/types" "^26.5.2"
     "@types/node" "*"
     ansi-escapes "^4.2.1"
     chalk "^4.0.0"
     exit "^0.1.2"
     graceful-fs "^4.2.4"
-    jest-changed-files "^26.3.0"
-    jest-config "^26.4.2"
-    jest-haste-map "^26.3.0"
-    jest-message-util "^26.3.0"
+    jest-changed-files "^26.5.2"
+    jest-config "^26.5.3"
+    jest-haste-map "^26.5.2"
+    jest-message-util "^26.5.2"
     jest-regex-util "^26.0.0"
-    jest-resolve "^26.4.0"
-    jest-resolve-dependencies "^26.4.2"
-    jest-runner "^26.4.2"
-    jest-runtime "^26.4.2"
-    jest-snapshot "^26.4.2"
-    jest-util "^26.3.0"
-    jest-validate "^26.4.2"
-    jest-watcher "^26.3.0"
+    jest-resolve "^26.5.2"
+    jest-resolve-dependencies "^26.5.3"
+    jest-runner "^26.5.3"
+    jest-runtime "^26.5.3"
+    jest-snapshot "^26.5.3"
+    jest-util "^26.5.2"
+    jest-validate "^26.5.3"
+    jest-watcher "^26.5.2"
     micromatch "^4.0.2"
     p-each-series "^2.1.0"
     rimraf "^3.0.0"
     slash "^3.0.0"
     strip-ansi "^6.0.0"
 
-"@jest/environment@^26.3.0":
-  version "26.3.0"
-  resolved "https://registry.yarnpkg.com/@jest/environment/-/environment-26.3.0.tgz#e6953ab711ae3e44754a025f838bde1a7fd236a0"
-  integrity sha512-EW+MFEo0DGHahf83RAaiqQx688qpXgl99wdb8Fy67ybyzHwR1a58LHcO376xQJHfmoXTu89M09dH3J509cx2AA==
+"@jest/environment@^26.5.2":
+  version "26.5.2"
+  resolved "https://registry.yarnpkg.com/@jest/environment/-/environment-26.5.2.tgz#eba3cfc698f6e03739628f699c28e8a07f5e65fe"
+  integrity sha512-YjhCD/Zhkz0/1vdlS/QN6QmuUdDkpgBdK4SdiVg4Y19e29g4VQYN5Xg8+YuHjdoWGY7wJHMxc79uDTeTOy9Ngw==
   dependencies:
-    "@jest/fake-timers" "^26.3.0"
-    "@jest/types" "^26.3.0"
+    "@jest/fake-timers" "^26.5.2"
+    "@jest/types" "^26.5.2"
     "@types/node" "*"
-    jest-mock "^26.3.0"
+    jest-mock "^26.5.2"
 
-"@jest/fake-timers@^26.3.0":
-  version "26.3.0"
-  resolved "https://registry.yarnpkg.com/@jest/fake-timers/-/fake-timers-26.3.0.tgz#f515d4667a6770f60ae06ae050f4e001126c666a"
-  integrity sha512-ZL9ytUiRwVP8ujfRepffokBvD2KbxbqMhrXSBhSdAhISCw3gOkuntisiSFv+A6HN0n0fF4cxzICEKZENLmW+1A==
+"@jest/fake-timers@^26.5.2":
+  version "26.5.2"
+  resolved "https://registry.yarnpkg.com/@jest/fake-timers/-/fake-timers-26.5.2.tgz#1291ac81680ceb0dc7daa1f92c059307eea6400a"
+  integrity sha512-09Hn5Oraqt36V1akxQeWMVL0fR9c6PnEhpgLaYvREXZJAh2H2Y+QLCsl0g7uMoJeoWJAuz4tozk1prbR1Fc1sw==
   dependencies:
-    "@jest/types" "^26.3.0"
+    "@jest/types" "^26.5.2"
     "@sinonjs/fake-timers" "^6.0.1"
     "@types/node" "*"
-    jest-message-util "^26.3.0"
-    jest-mock "^26.3.0"
-    jest-util "^26.3.0"
+    jest-message-util "^26.5.2"
+    jest-mock "^26.5.2"
+    jest-util "^26.5.2"
 
-"@jest/globals@^26.4.2":
-  version "26.4.2"
-  resolved "https://registry.yarnpkg.com/@jest/globals/-/globals-26.4.2.tgz#73c2a862ac691d998889a241beb3dc9cada40d4a"
-  integrity sha512-Ot5ouAlehhHLRhc+sDz2/9bmNv9p5ZWZ9LE1pXGGTCXBasmi5jnYjlgYcYt03FBwLmZXCZ7GrL29c33/XRQiow==
+"@jest/globals@^26.5.3":
+  version "26.5.3"
+  resolved "https://registry.yarnpkg.com/@jest/globals/-/globals-26.5.3.tgz#90769b40e0af3fa0b28f6d8c5bbe3712467243fd"
+  integrity sha512-7QztI0JC2CuB+Wx1VdnOUNeIGm8+PIaqngYsZXQCkH2QV0GFqzAYc9BZfU0nuqA6cbYrWh5wkuMzyii3P7deug==
   dependencies:
-    "@jest/environment" "^26.3.0"
-    "@jest/types" "^26.3.0"
-    expect "^26.4.2"
+    "@jest/environment" "^26.5.2"
+    "@jest/types" "^26.5.2"
+    expect "^26.5.3"
 
-"@jest/reporters@^26.4.1":
-  version "26.4.1"
-  resolved "https://registry.yarnpkg.com/@jest/reporters/-/reporters-26.4.1.tgz#3b4d6faf28650f3965f8b97bc3d114077fb71795"
-  integrity sha512-aROTkCLU8++yiRGVxLsuDmZsQEKO6LprlrxtAuzvtpbIFl3eIjgIf3EUxDKgomkS25R9ZzwGEdB5weCcBZlrpQ==
+"@jest/reporters@^26.5.3":
+  version "26.5.3"
+  resolved "https://registry.yarnpkg.com/@jest/reporters/-/reporters-26.5.3.tgz#e810e9c2b670f33f1c09e9975749260ca12f1c17"
+  integrity sha512-X+vR0CpfMQzYcYmMFKNY9n4jklcb14Kffffp7+H/MqitWnb0440bW2L76NGWKAa+bnXhNoZr+lCVtdtPmfJVOQ==
   dependencies:
     "@bcoe/v8-coverage" "^0.2.3"
-    "@jest/console" "^26.3.0"
-    "@jest/test-result" "^26.3.0"
-    "@jest/transform" "^26.3.0"
-    "@jest/types" "^26.3.0"
+    "@jest/console" "^26.5.2"
+    "@jest/test-result" "^26.5.2"
+    "@jest/transform" "^26.5.2"
+    "@jest/types" "^26.5.2"
     chalk "^4.0.0"
     collect-v8-coverage "^1.0.0"
     exit "^0.1.2"
@@ -1230,63 +1246,63 @@
     istanbul-lib-report "^3.0.0"
     istanbul-lib-source-maps "^4.0.0"
     istanbul-reports "^3.0.2"
-    jest-haste-map "^26.3.0"
-    jest-resolve "^26.4.0"
-    jest-util "^26.3.0"
-    jest-worker "^26.3.0"
+    jest-haste-map "^26.5.2"
+    jest-resolve "^26.5.2"
+    jest-util "^26.5.2"
+    jest-worker "^26.5.0"
     slash "^3.0.0"
     source-map "^0.6.0"
     string-length "^4.0.1"
     terminal-link "^2.0.0"
-    v8-to-istanbul "^5.0.1"
+    v8-to-istanbul "^6.0.1"
   optionalDependencies:
     node-notifier "^8.0.0"
 
-"@jest/source-map@^26.3.0":
-  version "26.3.0"
-  resolved "https://registry.yarnpkg.com/@jest/source-map/-/source-map-26.3.0.tgz#0e646e519883c14c551f7b5ae4ff5f1bfe4fc3d9"
-  integrity sha512-hWX5IHmMDWe1kyrKl7IhFwqOuAreIwHhbe44+XH2ZRHjrKIh0LO5eLQ/vxHFeAfRwJapmxuqlGAEYLadDq6ZGQ==
+"@jest/source-map@^26.5.0":
+  version "26.5.0"
+  resolved "https://registry.yarnpkg.com/@jest/source-map/-/source-map-26.5.0.tgz#98792457c85bdd902365cd2847b58fff05d96367"
+  integrity sha512-jWAw9ZwYHJMe9eZq/WrsHlwF8E3hM9gynlcDpOyCb9bR8wEd9ZNBZCi7/jZyzHxC7t3thZ10gO2IDhu0bPKS5g==
   dependencies:
     callsites "^3.0.0"
     graceful-fs "^4.2.4"
     source-map "^0.6.0"
 
-"@jest/test-result@^26.3.0":
-  version "26.3.0"
-  resolved "https://registry.yarnpkg.com/@jest/test-result/-/test-result-26.3.0.tgz#46cde01fa10c0aaeb7431bf71e4a20d885bc7fdb"
-  integrity sha512-a8rbLqzW/q7HWheFVMtghXV79Xk+GWwOK1FrtimpI5n1la2SY0qHri3/b0/1F0Ve0/yJmV8pEhxDfVwiUBGtgg==
+"@jest/test-result@^26.5.2":
+  version "26.5.2"
+  resolved "https://registry.yarnpkg.com/@jest/test-result/-/test-result-26.5.2.tgz#cc1a44cfd4db2ecee3fb0bc4e9fe087aa54b5230"
+  integrity sha512-E/Zp6LURJEGSCWpoMGmCFuuEI1OWuI3hmZwmULV0GsgJBh7u0rwqioxhRU95euUuviqBDN8ruX/vP/4bwYolXw==
   dependencies:
-    "@jest/console" "^26.3.0"
-    "@jest/types" "^26.3.0"
+    "@jest/console" "^26.5.2"
+    "@jest/types" "^26.5.2"
     "@types/istanbul-lib-coverage" "^2.0.0"
     collect-v8-coverage "^1.0.0"
 
-"@jest/test-sequencer@^26.4.2":
-  version "26.4.2"
-  resolved "https://registry.yarnpkg.com/@jest/test-sequencer/-/test-sequencer-26.4.2.tgz#58a3760a61eec758a2ce6080201424580d97cbba"
-  integrity sha512-83DRD8N3M0tOhz9h0bn6Kl6dSp+US6DazuVF8J9m21WAp5x7CqSMaNycMP0aemC/SH/pDQQddbsfHRTBXVUgog==
+"@jest/test-sequencer@^26.5.3":
+  version "26.5.3"
+  resolved "https://registry.yarnpkg.com/@jest/test-sequencer/-/test-sequencer-26.5.3.tgz#9ae0ab9bc37d5171b28424029192e50229814f8d"
+  integrity sha512-Wqzb7aQ13L3T47xHdpUqYMOpiqz6Dx2QDDghp5AV/eUDXR7JieY+E1s233TQlNyl+PqtqgjVokmyjzX/HA51BA==
   dependencies:
-    "@jest/test-result" "^26.3.0"
+    "@jest/test-result" "^26.5.2"
     graceful-fs "^4.2.4"
-    jest-haste-map "^26.3.0"
-    jest-runner "^26.4.2"
-    jest-runtime "^26.4.2"
+    jest-haste-map "^26.5.2"
+    jest-runner "^26.5.3"
+    jest-runtime "^26.5.3"
 
-"@jest/transform@^26.3.0":
-  version "26.3.0"
-  resolved "https://registry.yarnpkg.com/@jest/transform/-/transform-26.3.0.tgz#c393e0e01459da8a8bfc6d2a7c2ece1a13e8ba55"
-  integrity sha512-Isj6NB68QorGoFWvcOjlUhpkT56PqNIsXKR7XfvoDlCANn/IANlh8DrKAA2l2JKC3yWSMH5wS0GwuQM20w3b2A==
+"@jest/transform@^26.5.2":
+  version "26.5.2"
+  resolved "https://registry.yarnpkg.com/@jest/transform/-/transform-26.5.2.tgz#6a0033a1d24316a1c75184d010d864f2c681bef5"
+  integrity sha512-AUNjvexh+APhhmS8S+KboPz+D3pCxPvEAGduffaAJYxIFxGi/ytZQkrqcKDUU0ERBAo5R7087fyOYr2oms1seg==
   dependencies:
     "@babel/core" "^7.1.0"
-    "@jest/types" "^26.3.0"
+    "@jest/types" "^26.5.2"
     babel-plugin-istanbul "^6.0.0"
     chalk "^4.0.0"
     convert-source-map "^1.4.0"
     fast-json-stable-stringify "^2.0.0"
     graceful-fs "^4.2.4"
-    jest-haste-map "^26.3.0"
+    jest-haste-map "^26.5.2"
     jest-regex-util "^26.0.0"
-    jest-util "^26.3.0"
+    jest-util "^26.5.2"
     micromatch "^4.0.2"
     pirates "^4.0.1"
     slash "^3.0.0"
@@ -1303,10 +1319,10 @@
     "@types/yargs" "^15.0.0"
     chalk "^3.0.0"
 
-"@jest/types@^26.3.0":
-  version "26.3.0"
-  resolved "https://registry.yarnpkg.com/@jest/types/-/types-26.3.0.tgz#97627bf4bdb72c55346eef98e3b3f7ddc4941f71"
-  integrity sha512-BDPG23U0qDeAvU4f99haztXwdAg3hz4El95LkAM+tHAqqhiVzRpEGHHU8EDxT/AnxOrA65YjLBwDahdJ9pTLJQ==
+"@jest/types@^26.3.0", "@jest/types@^26.5.2":
+  version "26.5.2"
+  resolved "https://registry.yarnpkg.com/@jest/types/-/types-26.5.2.tgz#44c24f30c8ee6c7f492ead9ec3f3c62a5289756d"
+  integrity sha512-QDs5d0gYiyetI8q+2xWdkixVQMklReZr4ltw7GFDtb4fuJIBCE6mzj2LnitGqCuAlLap6wPyb8fpoHgwZz5fdg==
   dependencies:
     "@types/istanbul-lib-coverage" "^2.0.0"
     "@types/istanbul-reports" "^3.0.0"
@@ -1340,15 +1356,18 @@
   dependencies:
     "@sinonjs/commons" "^1.7.0"
 
-"@testing-library/dom@^7.17.1":
-  version "7.18.1"
-  resolved "https://registry.yarnpkg.com/@testing-library/dom/-/dom-7.18.1.tgz#c49530410fb184522b3b59c4f9cd6397dc5b462d"
-  integrity sha512-tGq4KAFjaI7j375sMM1RRVleWA0viJWs/w69B+nyDkqYLNkhdTHdV6mGkspJlkn3PUfyBDi3rERDv4PA/LrpVA==
+"@testing-library/dom@^7.24.2":
+  version "7.24.3"
+  resolved "https://registry.yarnpkg.com/@testing-library/dom/-/dom-7.24.3.tgz#dae3071463cf28dc7755b43d9cf2202e34cbb85d"
+  integrity sha512-6eW9fUhEbR423FZvoHRwbWm9RUUByLWGayYFNVvqTnQLYvsNpBS4uEuKH9aqr3trhxFwGVneJUonehL3B1sHJw==
   dependencies:
+    "@babel/code-frame" "^7.10.4"
     "@babel/runtime" "^7.10.3"
+    "@types/aria-query" "^4.2.0"
     aria-query "^4.2.2"
-    dom-accessibility-api "^0.4.5"
-    pretty-format "^25.5.0"
+    chalk "^4.1.0"
+    dom-accessibility-api "^0.5.1"
+    pretty-format "^26.4.2"
 
 "@testing-library/jest-dom@^5.11.4":
   version "5.11.4"
@@ -1364,13 +1383,18 @@
     lodash "^4.17.15"
     redent "^3.0.0"
 
-"@testing-library/react@^10.4.7":
-  version "10.4.7"
-  resolved "https://registry.yarnpkg.com/@testing-library/react/-/react-10.4.7.tgz#fc14847fb70a5e93576b8f7f0d1490ead02a9061"
-  integrity sha512-hUYbum3X2f1ZKusKfPaooKNYqE/GtPiQ+D2HJaJ4pkxeNJQFVUEvAvEh9+3QuLdBeTWkDMNY5NSijc5+pGdM4Q==
+"@testing-library/react@^11.0.4":
+  version "11.0.4"
+  resolved "https://registry.yarnpkg.com/@testing-library/react/-/react-11.0.4.tgz#c84082bfe1593d8fcd475d46baee024452f31dee"
+  integrity sha512-U0fZO2zxm7M0CB5h1+lh31lbAwMSmDMEMGpMT3BUPJwIjDEKYWOV4dx7lb3x2Ue0Pyt77gmz/VropuJnSz/Iew==
   dependencies:
-    "@babel/runtime" "^7.10.3"
-    "@testing-library/dom" "^7.17.1"
+    "@babel/runtime" "^7.11.2"
+    "@testing-library/dom" "^7.24.2"
+
+"@types/aria-query@^4.2.0":
+  version "4.2.0"
+  resolved "https://registry.yarnpkg.com/@types/aria-query/-/aria-query-4.2.0.tgz#14264692a9d6e2fa4db3df5e56e94b5e25647ac0"
+  integrity sha512-iIgQNzCm0v7QMhhe4Jjn9uRh+I6GoPmt03CbEtwx3ao8/EfoQcmgtqH4vQ5Db/lxiIGaWDv6nwvunuh0RyX0+A==
 
 "@types/babel__core@^7.0.0", "@types/babel__core@^7.1.3", "@types/babel__core@^7.1.7":
   version "7.1.9"
@@ -1405,6 +1429,13 @@
   dependencies:
     "@babel/types" "^7.3.0"
 
+"@types/babel__traverse@^7.0.4":
+  version "7.0.15"
+  resolved "https://registry.yarnpkg.com/@types/babel__traverse/-/babel__traverse-7.0.15.tgz#db9e4238931eb69ef8aab0ad6523d4d4caa39d03"
+  integrity sha512-Pzh9O3sTK8V6I1olsXpCfj2k/ygO2q1X0vhhnDrEQyYLHZesWz+zMZMVcwXLCYf0U36EtmyYaFGPfXlTtDHe3A==
+  dependencies:
+    "@babel/types" "^7.3.0"
+
 "@types/color-name@^1.1.1":
   version "1.1.1"
   resolved "https://registry.yarnpkg.com/@types/color-name/-/color-name-1.1.1.tgz#1c1261bbeaa10a8055bbc5d8ab84b7b2afc846a0"
@@ -1466,7 +1497,7 @@
     jest-diff "^25.2.1"
     pretty-format "^25.2.1"
 
-"@types/json-schema@^7.0.5":
+"@types/json-schema@^7.0.5", "@types/json-schema@^7.0.6":
   version "7.0.6"
   resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.6.tgz#f4c7ec43e81b319a9815115031709f26987891f0"
   integrity sha512-3c+yGKvVP5Y9TYBEibGNR+kLtijnj7mYrXRg+WpFb2X9xm04g/DXYkfg4hmzJQosc9snFNUPkbYIhu+KAm6jJw==
@@ -1511,10 +1542,10 @@
   resolved "https://registry.yarnpkg.com/@types/schema-utils/-/schema-utils-1.0.0.tgz#295d36f01e2cb8bc3207ca1d9a68e210db6b40cb"
   integrity sha512-YesPanU1+WCigC/Aj1Mga8UCOjHIfMNHZ3zzDsUY7lI8GlKnh/Kv2QwJOQ+jNQ36Ru7IfzSedlG14hppYaN13A==
 
-"@types/stack-utils@^1.0.1":
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-1.0.1.tgz#0a851d3bd96498fa25c33ab7278ed3bd65f06c3e"
-  integrity sha512-l42BggppR6zLmpfU6fq9HEa2oGPEI8yrSPL3GITjfRInppYFahObbIQOQK3UGxEnyQpltZLaPe75046NOZQikw==
+"@types/stack-utils@^2.0.0":
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-2.0.0.tgz#7036640b4e21cc2f259ae826ce843d277dad8cff"
+  integrity sha512-RJJrrySY7A8havqpGObOB4W92QXKJo63/jFLLgpvOtsGUqbQZ9Sbgl35KMm1DjC6j7AvmmU2bIno+3IyEaemaw==
 
 "@types/testing-library__jest-dom@^5.9.1":
   version "5.9.1"
@@ -1743,11 +1774,16 @@ acorn@^6.4.1:
   resolved "https://registry.yarnpkg.com/acorn/-/acorn-6.4.1.tgz#531e58ba3f51b9dacb9a6646ca4debf5b14ca474"
   integrity sha512-ZVA9k326Nwrj3Cj9jlh3wGFutC2ZornPNARZwsNYqQYgN0EsV2d53w5RN/co65Ohn4sUAUtb1rSUAOD6XN9idA==
 
-acorn@^7.1.1, acorn@^7.3.1:
+acorn@^7.1.1:
   version "7.4.0"
   resolved "https://registry.yarnpkg.com/acorn/-/acorn-7.4.0.tgz#e1ad486e6c54501634c6c397c5c121daa383607c"
   integrity sha512-+G7P8jJmCHr+S+cLfQxygbWhXy+8YTVGzAkpEbcLo2mLoL7tij/VG41QSHACSf5QgYRhMZYHuNc6drJaO0Da+w==
 
+acorn@^7.4.0:
+  version "7.4.1"
+  resolved "https://registry.yarnpkg.com/acorn/-/acorn-7.4.1.tgz#feaed255973d2e77555b83dbc08851a6c63520fa"
+  integrity sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==
+
 aggregate-error@^3.0.0:
   version "3.1.0"
   resolved "https://registry.yarnpkg.com/aggregate-error/-/aggregate-error-3.1.0.tgz#92670ff50f5359bdb7a3e0d40d0ec30c5737687a"
@@ -1789,6 +1825,16 @@ ajv@^6.1.0, ajv@^6.10.0, ajv@^6.10.2, ajv@^6.12.3, ajv@^6.12.4, ajv@^6.9.1:
     json-schema-traverse "^0.4.1"
     uri-js "^4.2.2"
 
+ajv@^6.12.5:
+  version "6.12.6"
+  resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.12.6.tgz#baf5a62e802b07d977034586f8c3baf5adf26df4"
+  integrity sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==
+  dependencies:
+    fast-deep-equal "^3.1.1"
+    fast-json-stable-stringify "^2.0.0"
+    json-schema-traverse "^0.4.1"
+    uri-js "^4.2.2"
+
 alphanum-sort@^1.0.0:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/alphanum-sort/-/alphanum-sort-1.0.2.tgz#97a1119649b211ad33691d9f9f486a8ec9fbe0a3"
@@ -2107,16 +2153,16 @@ babel-eslint@^10.1.0:
     eslint-visitor-keys "^1.0.0"
     resolve "^1.12.0"
 
-babel-jest@^26.3.0:
-  version "26.3.0"
-  resolved "https://registry.yarnpkg.com/babel-jest/-/babel-jest-26.3.0.tgz#10d0ca4b529ca3e7d1417855ef7d7bd6fc0c3463"
-  integrity sha512-sxPnQGEyHAOPF8NcUsD0g7hDCnvLL2XyblRBcgrzTWBB/mAIpWow3n1bEL+VghnnZfreLhFSBsFluRoK2tRK4g==
+babel-jest@^26.5.2:
+  version "26.5.2"
+  resolved "https://registry.yarnpkg.com/babel-jest/-/babel-jest-26.5.2.tgz#164f367a35946c6cf54eaccde8762dec50422250"
+  integrity sha512-U3KvymF3SczA3vOL/cgiUFOznfMET+XDIXiWnoJV45siAp2pLMG8i2+/MGZlAC3f/F6Q40LR4M4qDrWZ9wkK8A==
   dependencies:
-    "@jest/transform" "^26.3.0"
-    "@jest/types" "^26.3.0"
+    "@jest/transform" "^26.5.2"
+    "@jest/types" "^26.5.2"
     "@types/babel__core" "^7.1.7"
     babel-plugin-istanbul "^6.0.0"
-    babel-preset-jest "^26.3.0"
+    babel-preset-jest "^26.5.0"
     chalk "^4.0.0"
     graceful-fs "^4.2.4"
     slash "^3.0.0"
@@ -2166,10 +2212,10 @@ babel-plugin-istanbul@^6.0.0:
     istanbul-lib-instrument "^4.0.0"
     test-exclude "^6.0.0"
 
-babel-plugin-jest-hoist@^26.2.0:
-  version "26.2.0"
-  resolved "https://registry.yarnpkg.com/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-26.2.0.tgz#bdd0011df0d3d513e5e95f76bd53b51147aca2dd"
-  integrity sha512-B/hVMRv8Nh1sQ1a3EY8I0n4Y1Wty3NrR5ebOyVT302op+DOAau+xNEImGMsUWOC3++ZlMooCytKz+NgN8aKGbA==
+babel-plugin-jest-hoist@^26.5.0:
+  version "26.5.0"
+  resolved "https://registry.yarnpkg.com/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-26.5.0.tgz#3916b3a28129c29528de91e5784a44680db46385"
+  integrity sha512-ck17uZFD3CDfuwCLATWZxkkuGGFhMij8quP8CNhwj8ek1mqFgbFzRJ30xwC04LLscj/aKsVFfRST+b5PT7rSuw==
   dependencies:
     "@babel/template" "^7.3.3"
     "@babel/types" "^7.3.3"
@@ -2245,12 +2291,12 @@ babel-preset-current-node-syntax@^0.1.3:
     "@babel/plugin-syntax-optional-catch-binding" "^7.8.3"
     "@babel/plugin-syntax-optional-chaining" "^7.8.3"
 
-babel-preset-jest@^26.3.0:
-  version "26.3.0"
-  resolved "https://registry.yarnpkg.com/babel-preset-jest/-/babel-preset-jest-26.3.0.tgz#ed6344506225c065fd8a0b53e191986f74890776"
-  integrity sha512-5WPdf7nyYi2/eRxCbVrE1kKCWxgWY4RsPEbdJWFm7QsesFGqjdkyLeu1zRkwM1cxK6EPIlNd6d2AxLk7J+t4pw==
+babel-preset-jest@^26.5.0:
+  version "26.5.0"
+  resolved "https://registry.yarnpkg.com/babel-preset-jest/-/babel-preset-jest-26.5.0.tgz#f1b166045cd21437d1188d29f7fba470d5bdb0e7"
+  integrity sha512-F2vTluljhqkiGSJGBg/jOruA8vIIIL11YrxRcO7nviNTMbbofPSHwnm8mgP7d/wS7wRSexRoI6X1A6T74d4LQA==
   dependencies:
-    babel-plugin-jest-hoist "^26.2.0"
+    babel-plugin-jest-hoist "^26.5.0"
     babel-preset-current-node-syntax "^0.1.3"
 
 babel-runtime@^6.26.0:
@@ -2501,12 +2547,12 @@ browserify-zlib@^0.2.0:
     pako "~1.0.5"
 
 browserslist@^4.0.0, browserslist@^4.12.0, browserslist@^4.8.5:
-  version "4.14.3"
-  resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.14.3.tgz#381f9e7f13794b2eb17e1761b4f118e8ae665a53"
-  integrity sha512-GcZPC5+YqyPO4SFnz48/B0YaCwS47Q9iPChRGi6t7HhflKBcINzFrJvRfC+jp30sRMKxF+d4EHGs27Z0XP1NaQ==
+  version "4.14.5"
+  resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.14.5.tgz#1c751461a102ddc60e40993639b709be7f2c4015"
+  integrity sha512-Z+vsCZIvCBvqLoYkBFTwEYH3v5MCQbsAjp50ERycpOjnPmolg1Gjy4+KaWWpm8QOJt9GHkhdqAl14NpCX73CWA==
   dependencies:
-    caniuse-lite "^1.0.30001131"
-    electron-to-chromium "^1.3.570"
+    caniuse-lite "^1.0.30001135"
+    electron-to-chromium "^1.3.571"
     escalade "^3.1.0"
     node-releases "^1.1.61"
 
@@ -2676,10 +2722,10 @@ caniuse-api@^3.0.0:
     lodash.memoize "^4.1.2"
     lodash.uniq "^4.5.0"
 
-caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001109, caniuse-lite@^1.0.30001131:
-  version "1.0.30001133"
-  resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001133.tgz#ec564c5495311299eb05245e252d589a84acd95e"
-  integrity sha512-s3XAUFaC/ntDb1O3lcw9K8MPeOW7KO3z9+GzAoBxfz1B0VdacXPMKgFUtG4KIsgmnbexmi013s9miVu4h+qMHw==
+caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001109, caniuse-lite@^1.0.30001135:
+  version "1.0.30001143"
+  resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001143.tgz#560f2cfb9f313d1d7e52eb8dac0e4e36c8821c0d"
+  integrity sha512-p/PO5YbwmCpBJPxjOiKBvAlUPgF8dExhfEpnsH+ys4N/791WHrYrGg0cyHiAURl5hSbx5vIcjKmQAP6sHDYH3w==
 
 capture-exit@^2.0.0:
   version "2.0.0"
@@ -2721,7 +2767,7 @@ chalk@^3.0.0:
     ansi-styles "^4.1.0"
     supports-color "^7.1.0"
 
-chalk@^4.0.0:
+chalk@^4.0.0, chalk@^4.1.0:
   version "4.1.0"
   resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.0.tgz#4e14870a618d9e2edd97dd8345fd9d9dc315646a"
   integrity sha512-qwx12AxXe2Q5xQ43Ac//I6v5aXTipYrSESdOgzrN+9XjgEpyjpKuvSGaN4qE93f7TQTlerQQ8S+EQ0EyDoVL1A==
@@ -2739,10 +2785,10 @@ check-types@^8.0.3:
   resolved "https://registry.yarnpkg.com/check-types/-/check-types-8.0.3.tgz#3356cca19c889544f2d7a95ed49ce508a0ecf552"
   integrity sha512-YpeKZngUmG65rLudJ4taU7VLkOCTMhNl/u4ctNC56LQS/zJTyNH0Lrtwm1tfTsbLlwvlfsA2d1c8vCf/Kh2KwQ==
 
-"chokidar@>=2.0.0 <4.0.0":
-  version "3.4.0"
-  resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.4.0.tgz#b30611423ce376357c765b9b8f904b9fba3c0be8"
-  integrity sha512-aXAaho2VJtisB/1fg1+3nlLJqGOuewTzQpd/Tz0yTg2R0e4IGtshYvtjowyEumcBv2z+y4+kc75Mz7j5xJskcQ==
+"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==
   dependencies:
     anymatch "~3.1.1"
     braces "~3.0.2"
@@ -2773,21 +2819,6 @@ chokidar@^2.1.8:
   optionalDependencies:
     fsevents "^1.2.7"
 
-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==
-  dependencies:
-    anymatch "~3.1.1"
-    braces "~3.0.2"
-    glob-parent "~5.1.0"
-    is-binary-path "~2.1.0"
-    is-glob "~4.0.1"
-    normalize-path "~3.0.0"
-    readdirp "~3.4.0"
-  optionalDependencies:
-    fsevents "~2.1.2"
-
 chownr@^1.1.1:
   version "1.1.4"
   resolved "https://registry.yarnpkg.com/chownr/-/chownr-1.1.4.tgz#6fc9d7b42d32a583596337666e7d08084da2cc6b"
@@ -2988,15 +3019,15 @@ compressible@~2.0.16:
   dependencies:
     mime-db ">= 1.43.0 < 2"
 
-compression-webpack-plugin@^5.0.1:
-  version "5.0.1"
-  resolved "https://registry.yarnpkg.com/compression-webpack-plugin/-/compression-webpack-plugin-5.0.1.tgz#316c1a4e4ccc94510a978c967fc143581d1e3643"
-  integrity sha512-Wcb99O4UkdDZiM+blEw6h+cUfJYCn2kgK0l3fjLOm72Stso9DVMieQpBD4PVpyI7DLL6+zNh0iJV3p4HDwTinQ==
+compression-webpack-plugin@^6.0.3:
+  version "6.0.3"
+  resolved "https://registry.yarnpkg.com/compression-webpack-plugin/-/compression-webpack-plugin-6.0.3.tgz#d0d3e913810e3bf67462e1cecd794b3109af89de"
+  integrity sha512-xzSWiZWwBs+HHGhlYxw0oFaYL/0VYErEqDHCAJhJ3Mza5fmF5JJ4iaB6Ap2JT68C0UhhmoI4Mh37LVz/THv2Fw==
   dependencies:
     cacache "^15.0.5"
     find-cache-dir "^3.3.1"
-    schema-utils "^2.7.0"
-    serialize-javascript "^4.0.0"
+    schema-utils "^3.0.0"
+    serialize-javascript "^5.0.1"
     webpack-sources "^1.4.3"
 
 compression@^1.7.4:
@@ -3498,9 +3529,9 @@ decamelize@^1.2.0:
   integrity sha1-9lNNFRSCabIDUue+4m9QH5oZEpA=
 
 decimal.js@^10.2.0:
-  version "10.2.0"
-  resolved "https://registry.yarnpkg.com/decimal.js/-/decimal.js-10.2.0.tgz#39466113a9e036111d02f82489b5fd6b0b5ed231"
-  integrity sha512-vDPw+rDgn3bZe1+F/pyEwb1oMG2XTlRVgAa6B4KccTEpYgF8w6eQllVbQcfIJnZyvzFtFpxnpGtx8dd7DJp/Rw==
+  version "10.2.1"
+  resolved "https://registry.yarnpkg.com/decimal.js/-/decimal.js-10.2.1.tgz#238ae7b0f0c793d3e3cea410108b35a2c01426a3"
+  integrity sha512-KaL7+6Fw6i5A2XSnsbhm/6B+NuEA7TZ4vqxnd5tXz9sbKtrN9Srj8ab4vKVdK8YAqZO9P1kg45Y6YLoduPf+kw==
 
 decode-uri-component@^0.2.0:
   version "0.2.0"
@@ -3642,10 +3673,10 @@ diff-sequences@^25.2.6:
   resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-25.2.6.tgz#5f467c00edd35352b7bca46d7927d60e687a76dd"
   integrity sha512-Hq8o7+6GaZeoFjtpgvRBUknSXNeJiCx7V9Fr94ZMljNiCr9n9L8H8aJqgWOQiDDGdyn29fRNcDdRVJ5fdyihfg==
 
-diff-sequences@^26.3.0:
-  version "26.3.0"
-  resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-26.3.0.tgz#62a59b1b29ab7fd27cef2a33ae52abe73042d0a2"
-  integrity sha512-5j5vdRcw3CNctePNYN0Wy2e/JbWT6cAYnXv5OuqPhDpyCGc0uLu2TK0zOCJWNB9kOIfYMSpIulRaDgIi4HJ6Ig==
+diff-sequences@^26.5.0:
+  version "26.5.0"
+  resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-26.5.0.tgz#ef766cf09d43ed40406611f11c6d8d9dd8b2fefd"
+  integrity sha512-ZXx86srb/iYy6jG71k++wBN9P9J05UNQ5hQHQd9MtMPvcqXPx/vKU69jfHV637D00Q2gSgPk2D+jSx3l1lDW/Q==
 
 diffie-hellman@^5.0.0:
   version "5.0.3"
@@ -3698,10 +3729,10 @@ doctrine@^3.0.0:
   dependencies:
     esutils "^2.0.2"
 
-dom-accessibility-api@^0.4.5:
-  version "0.4.7"
-  resolved "https://registry.yarnpkg.com/dom-accessibility-api/-/dom-accessibility-api-0.4.7.tgz#31d01c113af49f323409b3ed09e56967aba485a8"
-  integrity sha512-5+GzhTpCQYHz4NjL8loYTDVBnXIjNLBadWQBKxXk+osFEplLt3EsSYBu2YZcdZ8QqrvCHgW6TSMGMbmgfhrn2g==
+dom-accessibility-api@^0.5.1:
+  version "0.5.2"
+  resolved "https://registry.yarnpkg.com/dom-accessibility-api/-/dom-accessibility-api-0.5.2.tgz#ef3cdb5d3f0d599d8f9c8b18df2fb63c9793739d"
+  integrity sha512-k7hRNKAiPJXD2aBqfahSo4/01cTsKWXf+LqJgglnkN2Nz8TsxXKQBXHhKe0Ye9fEfHEZY49uSA5Sr3AqP/sWKA==
 
 dom-helpers@^3.2.1, dom-helpers@^3.4.0:
   version "3.4.0"
@@ -3801,10 +3832,10 @@ ejs@^2.3.4, ejs@^2.6.1:
   resolved "https://registry.yarnpkg.com/ejs/-/ejs-2.7.4.tgz#48661287573dcc53e366c7a1ae52c3a120eec9ba"
   integrity sha512-7vmuyh5+kuUyJKePhQfRQBhXV5Ce+RnaeeQArKu1EAMpL3WbgMt5WG6uQZpEVvYSSsxMXRKOewtDk9RaTKXRlA==
 
-electron-to-chromium@^1.3.570:
-  version "1.3.570"
-  resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.570.tgz#3f5141cc39b4e3892a276b4889980dabf1d29c7f"
-  integrity sha512-Y6OCoVQgFQBP5py6A/06+yWxUZHDlNr/gNDGatjH8AZqXl8X0tE4LfjLJsXGz/JmWJz8a6K7bR1k+QzZ+k//fg==
+electron-to-chromium@^1.3.571:
+  version "1.3.574"
+  resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.574.tgz#bdd87f62fe70165e5c862a0acf0cee9889e23aa3"
+  integrity sha512-kF8Bfe1h8X1pPwlw6oRoIXj0DevowviP6fl0wcljm+nZjy/7+Fos4THo1N/7dVGEJlyEqK9C8qNnbheH+Eazfw==
 
 elliptic@^6.5.3:
   version "6.5.3"
@@ -3908,37 +3939,37 @@ error-stack-parser@^2.0.6:
     stackframe "^1.1.1"
 
 es-abstract@^1.17.0, es-abstract@^1.17.0-next.0, es-abstract@^1.17.0-next.1, es-abstract@^1.17.2, es-abstract@^1.17.5:
-  version "1.17.6"
-  resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.17.6.tgz#9142071707857b2cacc7b89ecb670316c3e2d52a"
-  integrity sha512-Fr89bON3WFyUi5EvAeI48QTWX0AyekGgLA8H+c+7fbfCkJwRWRMLd8CQedNEyJuoYYhmtEqY92pgte1FAhBlhw==
+  version "1.17.7"
+  resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.17.7.tgz#a4de61b2f66989fc7421676c1cb9787573ace54c"
+  integrity sha512-VBl/gnfcJ7OercKA9MVaegWsBHFjV492syMudcnQZvt/Dw8ezpcOHYZXa/J96O8vx+g4x65YKhxOwDUh63aS5g==
   dependencies:
     es-to-primitive "^1.2.1"
     function-bind "^1.1.1"
     has "^1.0.3"
     has-symbols "^1.0.1"
-    is-callable "^1.2.0"
-    is-regex "^1.1.0"
-    object-inspect "^1.7.0"
+    is-callable "^1.2.2"
+    is-regex "^1.1.1"
+    object-inspect "^1.8.0"
     object-keys "^1.1.1"
-    object.assign "^4.1.0"
+    object.assign "^4.1.1"
     string.prototype.trimend "^1.0.1"
     string.prototype.trimstart "^1.0.1"
 
-es-abstract@^1.18.0-next.0:
-  version "1.18.0-next.0"
-  resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.18.0-next.0.tgz#b302834927e624d8e5837ed48224291f2c66e6fc"
-  integrity sha512-elZXTZXKn51hUBdJjSZGYRujuzilgXo8vSPQzjGYXLvSlGiCo8VO8ZGV3kjo9a0WNJJ57hENagwbtlRuHuzkcQ==
+es-abstract@^1.18.0-next.0, es-abstract@^1.18.0-next.1:
+  version "1.18.0-next.1"
+  resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.18.0-next.1.tgz#6e3a0a4bda717e5023ab3b8e90bec36108d22c68"
+  integrity sha512-I4UGspA0wpZXWENrdA0uHbnhte683t3qT/1VFH9aX2dA5PPSf6QW5HHXf5HImaqPmjXaVeVk4RGWnaylmV7uAA==
   dependencies:
     es-to-primitive "^1.2.1"
     function-bind "^1.1.1"
     has "^1.0.3"
     has-symbols "^1.0.1"
-    is-callable "^1.2.0"
+    is-callable "^1.2.2"
     is-negative-zero "^2.0.0"
     is-regex "^1.1.1"
     object-inspect "^1.8.0"
     object-keys "^1.1.1"
-    object.assign "^4.1.0"
+    object.assign "^4.1.1"
     string.prototype.trimend "^1.0.1"
     string.prototype.trimstart "^1.0.1"
 
@@ -4060,7 +4091,7 @@ escope@^3.6.0:
     esrecurse "^4.1.0"
     estraverse "^4.1.1"
 
-eslint-import-resolver-node@^0.3.3:
+eslint-import-resolver-node@^0.3.4:
   version "0.3.4"
   resolved "https://registry.yarnpkg.com/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.4.tgz#85ffa81942c25012d8231096ddf679c03042c717"
   integrity sha512-ogtf+5AB/O+nM6DIeBUNr2fuT7ot9Qg/1harBfBtaP13ekEWFQEEMP94BCB7zaNW3gyY+8SHYF00rnqYwXKWOA==
@@ -4076,17 +4107,17 @@ eslint-module-utils@^2.6.0:
     debug "^2.6.9"
     pkg-dir "^2.0.0"
 
-eslint-plugin-import@~2.22.0:
-  version "2.22.0"
-  resolved "https://registry.yarnpkg.com/eslint-plugin-import/-/eslint-plugin-import-2.22.0.tgz#92f7736fe1fde3e2de77623c838dd992ff5ffb7e"
-  integrity sha512-66Fpf1Ln6aIS5Gr/55ts19eUuoDhAbZgnr6UxK5hbDx6l/QgQgx61AePq+BV4PP2uXQFClgMVzep5zZ94qqsxg==
+eslint-plugin-import@~2.22.1:
+  version "2.22.1"
+  resolved "https://registry.yarnpkg.com/eslint-plugin-import/-/eslint-plugin-import-2.22.1.tgz#0896c7e6a0cf44109a2d97b95903c2bb689d7702"
+  integrity sha512-8K7JjINHOpH64ozkAhpT3sd+FswIZTfMZTjdx052pnWrgRCVfp8op9tbjpAk3DdUeI/Ba4C8OjdC0r90erHEOw==
   dependencies:
     array-includes "^3.1.1"
     array.prototype.flat "^1.2.3"
     contains-path "^0.1.0"
     debug "^2.6.9"
     doctrine "1.5.0"
-    eslint-import-resolver-node "^0.3.3"
+    eslint-import-resolver-node "^0.3.4"
     eslint-module-utils "^2.6.0"
     has "^1.0.3"
     minimatch "^3.0.4"
@@ -4117,16 +4148,16 @@ eslint-plugin-promise@~4.2.1:
   resolved "https://registry.yarnpkg.com/eslint-plugin-promise/-/eslint-plugin-promise-4.2.1.tgz#845fd8b2260ad8f82564c1222fce44ad71d9418a"
   integrity sha512-VoM09vT7bfA7D+upt+FjeBO5eHIJQBUWki1aPvB+vbNiHS3+oGIJGIeyBtKQTME6UPXXy3vV07OL1tHd3ANuDw==
 
-eslint-plugin-react@~7.20.4:
-  version "7.20.4"
-  resolved "https://registry.yarnpkg.com/eslint-plugin-react/-/eslint-plugin-react-7.20.4.tgz#c14d2631221ec694ddd84557d7152f44b66e4aa0"
-  integrity sha512-y4DOQ0LrzuDQFEAnYFGjJMRHQQqfTco02qiWI00eGQYikHTzC15S5aRHGWSffnThv8sBpsmFBLky3K5keniAJg==
+eslint-plugin-react@~7.21.4:
+  version "7.21.4"
+  resolved "https://registry.yarnpkg.com/eslint-plugin-react/-/eslint-plugin-react-7.21.4.tgz#31060b2e5ff82b12e24a3cc33edb7d12f904775c"
+  integrity sha512-uHeQ8A0hg0ltNDXFu3qSfFqTNPXm1XithH6/SY318UX76CMj7Q599qWpgmMhVQyvhq36pm7qvoN3pb6/3jsTFg==
   dependencies:
     array-includes "^3.1.1"
     array.prototype.flatmap "^1.2.3"
     doctrine "^2.1.0"
     has "^1.0.3"
-    jsx-ast-utils "^2.4.1"
+    jsx-ast-utils "^2.4.1 || ^3.0.0"
     object.entries "^1.1.2"
     object.fromentries "^2.0.2"
     object.values "^1.1.1"
@@ -4142,12 +4173,12 @@ eslint-scope@^4.0.3:
     esrecurse "^4.1.0"
     estraverse "^4.1.1"
 
-eslint-scope@^5.1.0:
-  version "5.1.0"
-  resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-5.1.0.tgz#d0f971dfe59c69e0cada684b23d49dbf82600ce5"
-  integrity sha512-iiGRvtxWqgtx5m8EyQUJihBloE4EnYeGE/bz1wSPwJE6tZuJUtHlhqDM4Xj2ukE8Dyy1+HCZ4hE0fzIVMzb58w==
+eslint-scope@^5.1.1:
+  version "5.1.1"
+  resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-5.1.1.tgz#e786e59a66cb92b3f6c1fb0d508aab174848f48c"
+  integrity sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==
   dependencies:
-    esrecurse "^4.1.0"
+    esrecurse "^4.3.0"
     estraverse "^4.1.1"
 
 eslint-utils@^2.1.0:
@@ -4162,6 +4193,11 @@ eslint-visitor-keys@^1.0.0, eslint-visitor-keys@^1.1.0, eslint-visitor-keys@^1.3
   resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-1.3.0.tgz#30ebd1ef7c2fdff01c3a4f151044af25fab0523e"
   integrity sha512-6J72N8UNa462wa/KFODt/PJ3IU60SDpC3QXC1Hjc1BXXpfL2C9R5+AU7jhe0F6GREqVMh4Juu+NY7xn+6dipUQ==
 
+eslint-visitor-keys@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-2.0.0.tgz#21fdc8fbcd9c795cc0321f0563702095751511a8"
+  integrity sha512-QudtT6av5WXels9WjIM7qz1XD1cWGvX4gGXvp/zBn9nXG02D0utdU3Em2m/QjTnrsk6bBjmCygl3rmj118msQQ==
+
 eslint@^2.7.0:
   version "2.13.1"
   resolved "https://registry.yarnpkg.com/eslint/-/eslint-2.13.1.tgz#e4cc8fa0f009fb829aaae23855a29360be1f6c11"
@@ -4201,22 +4237,23 @@ eslint@^2.7.0:
     text-table "~0.2.0"
     user-home "^2.0.0"
 
-eslint@^7.6.0:
-  version "7.6.0"
-  resolved "https://registry.yarnpkg.com/eslint/-/eslint-7.6.0.tgz#522d67cfaea09724d96949c70e7a0550614d64d6"
-  integrity sha512-QlAManNtqr7sozWm5TF4wIH9gmUm2hE3vNRUvyoYAa4y1l5/jxD/PQStEjBMQtCqZmSep8UxrcecI60hOpe61w==
+eslint@^7.11.0:
+  version "7.11.0"
+  resolved "https://registry.yarnpkg.com/eslint/-/eslint-7.11.0.tgz#aaf2d23a0b5f1d652a08edacea0c19f7fadc0b3b"
+  integrity sha512-G9+qtYVCHaDi1ZuWzBsOWo2wSwd70TXnU6UHA3cTYHp7gCTXZcpggWFoUVAMRarg68qtPoNfFbzPh+VdOgmwmw==
   dependencies:
     "@babel/code-frame" "^7.0.0"
+    "@eslint/eslintrc" "^0.1.3"
     ajv "^6.10.0"
     chalk "^4.0.0"
     cross-spawn "^7.0.2"
     debug "^4.0.1"
     doctrine "^3.0.0"
     enquirer "^2.3.5"
-    eslint-scope "^5.1.0"
+    eslint-scope "^5.1.1"
     eslint-utils "^2.1.0"
-    eslint-visitor-keys "^1.3.0"
-    espree "^7.2.0"
+    eslint-visitor-keys "^2.0.0"
+    espree "^7.3.0"
     esquery "^1.2.0"
     esutils "^2.0.2"
     file-entry-cache "^5.0.1"
@@ -4251,12 +4288,12 @@ espree@^3.1.6:
     acorn "^5.5.0"
     acorn-jsx "^3.0.0"
 
-espree@^7.2.0:
-  version "7.2.0"
-  resolved "https://registry.yarnpkg.com/espree/-/espree-7.2.0.tgz#1c263d5b513dbad0ac30c4991b93ac354e948d69"
-  integrity sha512-H+cQ3+3JYRMEIOl87e7QdHX70ocly5iW4+dttuR8iYSPr/hXKFb+7dBsZ7+u1adC4VrnPlTkv0+OwuPnDop19g==
+espree@^7.3.0:
+  version "7.3.0"
+  resolved "https://registry.yarnpkg.com/espree/-/espree-7.3.0.tgz#dc30437cf67947cf576121ebd780f15eeac72348"
+  integrity sha512-dksIWsvKCixn1yrEXO8UosNSxaDoSYpq9reEjZSbHLpT5hpaCAKTLBwq0RHtLrIr+c0ByiYzWT8KTMRzoRCNlw==
   dependencies:
-    acorn "^7.3.1"
+    acorn "^7.4.0"
     acorn-jsx "^5.2.0"
     eslint-visitor-keys "^1.3.0"
 
@@ -4272,7 +4309,7 @@ esquery@^1.2.0:
   dependencies:
     estraverse "^5.1.0"
 
-esrecurse@^4.1.0:
+esrecurse@^4.1.0, esrecurse@^4.3.0:
   version "4.3.0"
   resolved "https://registry.yarnpkg.com/esrecurse/-/esrecurse-4.3.0.tgz#7ad7964d679abb28bee72cec63758b1c5d2c9921"
   integrity sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==
@@ -4400,16 +4437,16 @@ expand-tilde@^2.0.0, expand-tilde@^2.0.2:
   dependencies:
     homedir-polyfill "^1.0.1"
 
-expect@^26.4.2:
-  version "26.4.2"
-  resolved "https://registry.yarnpkg.com/expect/-/expect-26.4.2.tgz#36db120928a5a2d7d9736643032de32f24e1b2a1"
-  integrity sha512-IlJ3X52Z0lDHm7gjEp+m76uX46ldH5VpqmU0006vqDju/285twh7zaWMRhs67VpQhBwjjMchk+p5aA0VkERCAA==
+expect@^26.5.3:
+  version "26.5.3"
+  resolved "https://registry.yarnpkg.com/expect/-/expect-26.5.3.tgz#89d9795036f7358b0a9a5243238eb8086482d741"
+  integrity sha512-kkpOhGRWGOr+TEFUnYAjfGvv35bfP+OlPtqPIJpOCR9DVtv8QV+p8zG0Edqafh80fsjeE+7RBcVUq1xApnYglw==
   dependencies:
-    "@jest/types" "^26.3.0"
+    "@jest/types" "^26.5.2"
     ansi-styles "^4.0.0"
     jest-get-type "^26.3.0"
-    jest-matcher-utils "^26.4.2"
-    jest-message-util "^26.3.0"
+    jest-matcher-utils "^26.5.2"
+    jest-message-util "^26.5.2"
     jest-regex-util "^26.0.0"
 
 express@^4.16.3, express@^4.17.1:
@@ -4575,13 +4612,13 @@ file-entry-cache@^5.0.1:
   dependencies:
     flat-cache "^2.0.1"
 
-file-loader@^6.1.0:
-  version "6.1.0"
-  resolved "https://registry.yarnpkg.com/file-loader/-/file-loader-6.1.0.tgz#65b9fcfb0ea7f65a234a1f10cdd7f1ab9a33f253"
-  integrity sha512-26qPdHyTsArQ6gU4P1HJbAbnFTyT2r0pG7czh1GFAd9TZbj0n94wWbupgixZH/ET/meqi2/5+F7DhW4OAXD+Lg==
+file-loader@^6.1.1:
+  version "6.1.1"
+  resolved "https://registry.yarnpkg.com/file-loader/-/file-loader-6.1.1.tgz#a6f29dfb3f5933a1c350b2dbaa20ac5be0539baa"
+  integrity sha512-Klt8C4BjWSXYQAfhpYYkG4qHNTna4toMHEbWrI5IuVoxbU6uiDKeKAP99R8mmbJi3lvewn/jQBOgU4+NS3tDQw==
   dependencies:
     loader-utils "^2.0.0"
-    schema-utils "^2.7.1"
+    schema-utils "^3.0.0"
 
 file-type@^12.4.1:
   version "12.4.2"
@@ -5288,10 +5325,10 @@ http-errors@~1.7.2:
     statuses ">= 1.5.0 < 2"
     toidentifier "1.0.0"
 
-http-link-header@^1.0.2:
-  version "1.0.2"
-  resolved "https://registry.yarnpkg.com/http-link-header/-/http-link-header-1.0.2.tgz#bea50f02e1c7996021f1013b428c63f77e0f4e11"
-  integrity sha512-z6YOZ8ZEnejkcCWlGZzYXNa6i+ZaTfiTg3WhlV/YvnNya3W/RbX1bMVUMTuCrg/DrtTCQxaFCkXCz4FtLpcebg==
+http-link-header@^1.0.3:
+  version "1.0.3"
+  resolved "https://registry.yarnpkg.com/http-link-header/-/http-link-header-1.0.3.tgz#abbc2cdc5e06dd7e196a4983adac08a2d085ec90"
+  integrity sha512-nARK1wSKoBBrtcoESlHBx36c1Ln/gnbNQi1eB6MeTUefJIT3NvUOsV15bClga0k38f0q/kN5xxrGSDS3EFnm9w==
 
 "http-parser-js@>=0.4.0 <0.4.11":
   version "0.4.10"
@@ -5395,7 +5432,7 @@ import-fresh@^2.0.0:
     caller-path "^2.0.0"
     resolve-from "^3.0.0"
 
-import-fresh@^3.0.0, import-fresh@^3.1.0:
+import-fresh@^3.0.0, import-fresh@^3.1.0, import-fresh@^3.2.1:
   version "3.2.1"
   resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-3.2.1.tgz#633ff618506e793af5ac91bf48b72677e15cbe66"
   integrity sha512-6e1q1cnWP2RXD9/keSkxHScg508CdXqXWgWBaETNhyuBFz+kUZlKboh+ISK+bU++DmbHimVBrOz/zzPe0sZ3sQ==
@@ -5426,13 +5463,13 @@ import-local@^3.0.2:
     pkg-dir "^4.2.0"
     resolve-cwd "^3.0.0"
 
-imports-loader@^1.1.0:
-  version "1.1.0"
-  resolved "https://registry.yarnpkg.com/imports-loader/-/imports-loader-1.1.0.tgz#1c3a388d0c5cd7f9eb08f3646d4aae3b70e57933"
-  integrity sha512-HcPM6rULdQ6EBLVq+5O+CF9xb7qiUjsRm6V28bTG/c3IU5sQkVZzUDwYY0r4jHvSAmVFdO9WA/vLAURR5WQSeQ==
+imports-loader@^1.2.0:
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/imports-loader/-/imports-loader-1.2.0.tgz#b06823d0bb42e6f5ff89bc893829000eda46693f"
+  integrity sha512-zPvangKEgrrPeqeUqH0Uhc59YqK07JqZBi9a9cQ3v/EKUIqrbJHY4CvUrDus2lgQa5AmPyXuGrWP8JJTqzE5RQ==
   dependencies:
     loader-utils "^2.0.0"
-    schema-utils "^2.7.0"
+    schema-utils "^3.0.0"
     source-map "^0.6.1"
     strip-comments "^2.0.1"
 
@@ -5646,10 +5683,10 @@ is-binary-path@~2.1.0:
   dependencies:
     binary-extensions "^2.0.0"
 
-is-callable@^1.1.4, is-callable@^1.2.0:
-  version "1.2.1"
-  resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.2.1.tgz#4d1e21a4f437509d25ce55f8184350771421c96d"
-  integrity sha512-wliAfSzx6V+6WfMOmus1xy0XvSgf/dlStkvTfq7F0g4bOIW0PSUbnyse3NhDwdyYS1ozfUtAAySqTws3z9Eqgg==
+is-callable@^1.1.4, is-callable@^1.2.2:
+  version "1.2.2"
+  resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.2.2.tgz#c7c6715cd22d4ddb48d3e19970223aceabb080d9"
+  integrity sha512-dnMqspv5nU3LoewK2N/y7KLtxtakvTuaCsU9FU50/QDmdbHNy/4/JuRtMHqRU22o3q+W89YQndQEeCVwK+3qrA==
 
 is-ci@^2.0.0:
   version "2.0.0"
@@ -5868,7 +5905,7 @@ is-regex@^1.0.4:
   dependencies:
     has-symbols "^1.0.1"
 
-is-regex@^1.1.0, is-regex@^1.1.1:
+is-regex@^1.1.1:
   version "1.1.1"
   resolved "https://registry.yarnpkg.com/is-regex/-/is-regex-1.1.1.tgz#c6f98aacc546f6cec5468a07b7b153ab564a57b9"
   integrity sha512-1+QkEcxiLlB7VEyFtyBg94e08OAsvq7FUBgApTq/w2ymCLyKJgDPsybBENVtA7XCQEgEXxKPonG+mvYRxh/LIg==
@@ -6009,57 +6046,57 @@ istanbul-reports@^3.0.2:
     html-escaper "^2.0.0"
     istanbul-lib-report "^3.0.0"
 
-jest-changed-files@^26.3.0:
-  version "26.3.0"
-  resolved "https://registry.yarnpkg.com/jest-changed-files/-/jest-changed-files-26.3.0.tgz#68fb2a7eb125f50839dab1f5a17db3607fe195b1"
-  integrity sha512-1C4R4nijgPltX6fugKxM4oQ18zimS7LqQ+zTTY8lMCMFPrxqBFb7KJH0Z2fRQJvw2Slbaipsqq7s1mgX5Iot+g==
+jest-changed-files@^26.5.2:
+  version "26.5.2"
+  resolved "https://registry.yarnpkg.com/jest-changed-files/-/jest-changed-files-26.5.2.tgz#330232c6a5c09a7f040a5870e8f0a9c6abcdbed5"
+  integrity sha512-qSmssmiIdvM5BWVtyK/nqVpN3spR5YyvkvPqz1x3BR1bwIxsWmU/MGwLoCrPNLbkG2ASAKfvmJpOduEApBPh2w==
   dependencies:
-    "@jest/types" "^26.3.0"
+    "@jest/types" "^26.5.2"
     execa "^4.0.0"
     throat "^5.0.0"
 
-jest-cli@^26.4.2:
-  version "26.4.2"
-  resolved "https://registry.yarnpkg.com/jest-cli/-/jest-cli-26.4.2.tgz#24afc6e4dfc25cde4c7ec4226fb7db5f157c21da"
-  integrity sha512-zb+lGd/SfrPvoRSC/0LWdaWCnscXc1mGYW//NP4/tmBvRPT3VntZ2jtKUONsRi59zc5JqmsSajA9ewJKFYp8Cw==
+jest-cli@^26.5.3:
+  version "26.5.3"
+  resolved "https://registry.yarnpkg.com/jest-cli/-/jest-cli-26.5.3.tgz#f936b98f247b76b7bc89c7af50af82c88e356a80"
+  integrity sha512-HkbSvtugpSXBf2660v9FrNVUgxvPkssN8CRGj9gPM8PLhnaa6zziFiCEKQAkQS4uRzseww45o0TR+l6KeRYV9A==
   dependencies:
-    "@jest/core" "^26.4.2"
-    "@jest/test-result" "^26.3.0"
-    "@jest/types" "^26.3.0"
+    "@jest/core" "^26.5.3"
+    "@jest/test-result" "^26.5.2"
+    "@jest/types" "^26.5.2"
     chalk "^4.0.0"
     exit "^0.1.2"
     graceful-fs "^4.2.4"
     import-local "^3.0.2"
     is-ci "^2.0.0"
-    jest-config "^26.4.2"
-    jest-util "^26.3.0"
-    jest-validate "^26.4.2"
+    jest-config "^26.5.3"
+    jest-util "^26.5.2"
+    jest-validate "^26.5.3"
     prompts "^2.0.1"
-    yargs "^15.3.1"
+    yargs "^15.4.1"
 
-jest-config@^26.4.2:
-  version "26.4.2"
-  resolved "https://registry.yarnpkg.com/jest-config/-/jest-config-26.4.2.tgz#da0cbb7dc2c131ffe831f0f7f2a36256e6086558"
-  integrity sha512-QBf7YGLuToiM8PmTnJEdRxyYy3mHWLh24LJZKVdXZ2PNdizSe1B/E8bVm+HYcjbEzGuVXDv/di+EzdO/6Gq80A==
+jest-config@^26.5.3:
+  version "26.5.3"
+  resolved "https://registry.yarnpkg.com/jest-config/-/jest-config-26.5.3.tgz#baf51c9be078c2c755c8f8a51ec0f06c762c1d3f"
+  integrity sha512-NVhZiIuN0GQM6b6as4CI5FSCyXKxdrx5ACMCcv/7Pf+TeCajJhJc+6dwgdAVPyerUFB9pRBIz3bE7clSrRge/w==
   dependencies:
     "@babel/core" "^7.1.0"
-    "@jest/test-sequencer" "^26.4.2"
-    "@jest/types" "^26.3.0"
-    babel-jest "^26.3.0"
+    "@jest/test-sequencer" "^26.5.3"
+    "@jest/types" "^26.5.2"
+    babel-jest "^26.5.2"
     chalk "^4.0.0"
     deepmerge "^4.2.2"
     glob "^7.1.1"
     graceful-fs "^4.2.4"
-    jest-environment-jsdom "^26.3.0"
-    jest-environment-node "^26.3.0"
+    jest-environment-jsdom "^26.5.2"
+    jest-environment-node "^26.5.2"
     jest-get-type "^26.3.0"
-    jest-jasmine2 "^26.4.2"
+    jest-jasmine2 "^26.5.3"
     jest-regex-util "^26.0.0"
-    jest-resolve "^26.4.0"
-    jest-util "^26.3.0"
-    jest-validate "^26.4.2"
+    jest-resolve "^26.5.2"
+    jest-util "^26.5.2"
+    jest-validate "^26.5.3"
     micromatch "^4.0.2"
-    pretty-format "^26.4.2"
+    pretty-format "^26.5.2"
 
 jest-diff@^25.2.1:
   version "25.5.0"
@@ -6071,15 +6108,15 @@ jest-diff@^25.2.1:
     jest-get-type "^25.2.6"
     pretty-format "^25.5.0"
 
-jest-diff@^26.4.2:
-  version "26.4.2"
-  resolved "https://registry.yarnpkg.com/jest-diff/-/jest-diff-26.4.2.tgz#a1b7b303bcc534aabdb3bd4a7caf594ac059f5aa"
-  integrity sha512-6T1XQY8U28WH0Z5rGpQ+VqZSZz8EN8rZcBtfvXaOkbwxIEeRre6qnuZQlbY1AJ4MKDxQF8EkrCvK+hL/VkyYLQ==
+jest-diff@^26.5.2:
+  version "26.5.2"
+  resolved "https://registry.yarnpkg.com/jest-diff/-/jest-diff-26.5.2.tgz#8e26cb32dc598e8b8a1b9deff55316f8313c8053"
+  integrity sha512-HCSWDUGwsov5oTlGzrRM+UPJI/Dpqi9jzeV0fdRNi3Ch5bnoXhnyJMmVg2juv9081zLIy3HGPI5mcuGgXM2xRA==
   dependencies:
     chalk "^4.0.0"
-    diff-sequences "^26.3.0"
+    diff-sequences "^26.5.0"
     jest-get-type "^26.3.0"
-    pretty-format "^26.4.2"
+    pretty-format "^26.5.2"
 
 jest-docblock@^26.0.0:
   version "26.0.0"
@@ -6088,41 +6125,41 @@ jest-docblock@^26.0.0:
   dependencies:
     detect-newline "^3.0.0"
 
-jest-each@^26.4.2:
-  version "26.4.2"
-  resolved "https://registry.yarnpkg.com/jest-each/-/jest-each-26.4.2.tgz#bb14f7f4304f2bb2e2b81f783f989449b8b6ffae"
-  integrity sha512-p15rt8r8cUcRY0Mvo1fpkOGYm7iI8S6ySxgIdfh3oOIv+gHwrHTy5VWCGOecWUhDsit4Nz8avJWdT07WLpbwDA==
+jest-each@^26.5.2:
+  version "26.5.2"
+  resolved "https://registry.yarnpkg.com/jest-each/-/jest-each-26.5.2.tgz#35e68d6906a7f826d3ca5803cfe91d17a5a34c31"
+  integrity sha512-w7D9FNe0m2D3yZ0Drj9CLkyF/mGhmBSULMQTypzAKR746xXnjUrK8GUJdlLTWUF6dd0ks3MtvGP7/xNFr9Aphg==
   dependencies:
-    "@jest/types" "^26.3.0"
+    "@jest/types" "^26.5.2"
     chalk "^4.0.0"
     jest-get-type "^26.3.0"
-    jest-util "^26.3.0"
-    pretty-format "^26.4.2"
+    jest-util "^26.5.2"
+    pretty-format "^26.5.2"
 
-jest-environment-jsdom@^26.3.0:
-  version "26.3.0"
-  resolved "https://registry.yarnpkg.com/jest-environment-jsdom/-/jest-environment-jsdom-26.3.0.tgz#3b749ba0f3a78e92ba2c9ce519e16e5dd515220c"
-  integrity sha512-zra8He2btIMJkAzvLaiZ9QwEPGEetbxqmjEBQwhH3CA+Hhhu0jSiEJxnJMbX28TGUvPLxBt/zyaTLrOPF4yMJA==
+jest-environment-jsdom@^26.5.2:
+  version "26.5.2"
+  resolved "https://registry.yarnpkg.com/jest-environment-jsdom/-/jest-environment-jsdom-26.5.2.tgz#5feab05b828fd3e4b96bee5e0493464ddd2bb4bc"
+  integrity sha512-fWZPx0bluJaTQ36+PmRpvUtUlUFlGGBNyGX1SN3dLUHHMcQ4WseNEzcGGKOw4U5towXgxI4qDoI3vwR18H0RTw==
   dependencies:
-    "@jest/environment" "^26.3.0"
-    "@jest/fake-timers" "^26.3.0"
-    "@jest/types" "^26.3.0"
+    "@jest/environment" "^26.5.2"
+    "@jest/fake-timers" "^26.5.2"
+    "@jest/types" "^26.5.2"
     "@types/node" "*"
-    jest-mock "^26.3.0"
-    jest-util "^26.3.0"
-    jsdom "^16.2.2"
-
-jest-environment-node@^26.3.0:
-  version "26.3.0"
-  resolved "https://registry.yarnpkg.com/jest-environment-node/-/jest-environment-node-26.3.0.tgz#56c6cfb506d1597f94ee8d717072bda7228df849"
-  integrity sha512-c9BvYoo+FGcMj5FunbBgtBnbR5qk3uky8PKyRVpSfe2/8+LrNQMiXX53z6q2kY+j15SkjQCOSL/6LHnCPLVHNw==
-  dependencies:
-    "@jest/environment" "^26.3.0"
-    "@jest/fake-timers" "^26.3.0"
-    "@jest/types" "^26.3.0"
+    jest-mock "^26.5.2"
+    jest-util "^26.5.2"
+    jsdom "^16.4.0"
+
+jest-environment-node@^26.5.2:
+  version "26.5.2"
+  resolved "https://registry.yarnpkg.com/jest-environment-node/-/jest-environment-node-26.5.2.tgz#275a0f01b5e47447056f1541a15ed4da14acca03"
+  integrity sha512-YHjnDsf/GKFCYMGF1V+6HF7jhY1fcLfLNBDjhAOvFGvt6d8vXvNdJGVM7uTZ2VO/TuIyEFhPGaXMX5j3h7fsrA==
+  dependencies:
+    "@jest/environment" "^26.5.2"
+    "@jest/fake-timers" "^26.5.2"
+    "@jest/types" "^26.5.2"
     "@types/node" "*"
-    jest-mock "^26.3.0"
-    jest-util "^26.3.0"
+    jest-mock "^26.5.2"
+    jest-util "^26.5.2"
 
 jest-get-type@^25.2.6:
   version "25.2.6"
@@ -6134,89 +6171,89 @@ jest-get-type@^26.3.0:
   resolved "https://registry.yarnpkg.com/jest-get-type/-/jest-get-type-26.3.0.tgz#e97dc3c3f53c2b406ca7afaed4493b1d099199e0"
   integrity sha512-TpfaviN1R2pQWkIihlfEanwOXK0zcxrKEE4MlU6Tn7keoXdN6/3gK/xl0yEh8DOunn5pOVGKf8hB4R9gVh04ig==
 
-jest-haste-map@^26.3.0:
-  version "26.3.0"
-  resolved "https://registry.yarnpkg.com/jest-haste-map/-/jest-haste-map-26.3.0.tgz#c51a3b40100d53ab777bfdad382d2e7a00e5c726"
-  integrity sha512-DHWBpTJgJhLLGwE5Z1ZaqLTYqeODQIZpby0zMBsCU9iRFHYyhklYqP4EiG73j5dkbaAdSZhgB938mL51Q5LeZA==
+jest-haste-map@^26.5.2:
+  version "26.5.2"
+  resolved "https://registry.yarnpkg.com/jest-haste-map/-/jest-haste-map-26.5.2.tgz#a15008abfc502c18aa56e4919ed8c96304ceb23d"
+  integrity sha512-lJIAVJN3gtO3k4xy+7i2Xjtwh8CfPcH08WYjZpe9xzveDaqGw9fVNCpkYu6M525wKFVkLmyi7ku+DxCAP1lyMA==
   dependencies:
-    "@jest/types" "^26.3.0"
+    "@jest/types" "^26.5.2"
     "@types/graceful-fs" "^4.1.2"
     "@types/node" "*"
     anymatch "^3.0.3"
     fb-watchman "^2.0.0"
     graceful-fs "^4.2.4"
     jest-regex-util "^26.0.0"
-    jest-serializer "^26.3.0"
-    jest-util "^26.3.0"
-    jest-worker "^26.3.0"
+    jest-serializer "^26.5.0"
+    jest-util "^26.5.2"
+    jest-worker "^26.5.0"
     micromatch "^4.0.2"
     sane "^4.0.3"
     walker "^1.0.7"
   optionalDependencies:
     fsevents "^2.1.2"
 
-jest-jasmine2@^26.4.2:
-  version "26.4.2"
-  resolved "https://registry.yarnpkg.com/jest-jasmine2/-/jest-jasmine2-26.4.2.tgz#18a9d5bec30904267ac5e9797570932aec1e2257"
-  integrity sha512-z7H4EpCldHN1J8fNgsja58QftxBSL+JcwZmaXIvV9WKIM+x49F4GLHu/+BQh2kzRKHAgaN/E82od+8rTOBPyPA==
+jest-jasmine2@^26.5.3:
+  version "26.5.3"
+  resolved "https://registry.yarnpkg.com/jest-jasmine2/-/jest-jasmine2-26.5.3.tgz#baad2114ce32d16aff25aeb877d18bb4e332dc4c"
+  integrity sha512-nFlZOpnGlNc7y/+UkkeHnvbOM+rLz4wB1AimgI9QhtnqSZte0wYjbAm8hf7TCwXlXgDwZxAXo6z0a2Wzn9FoOg==
   dependencies:
     "@babel/traverse" "^7.1.0"
-    "@jest/environment" "^26.3.0"
-    "@jest/source-map" "^26.3.0"
-    "@jest/test-result" "^26.3.0"
-    "@jest/types" "^26.3.0"
+    "@jest/environment" "^26.5.2"
+    "@jest/source-map" "^26.5.0"
+    "@jest/test-result" "^26.5.2"
+    "@jest/types" "^26.5.2"
     "@types/node" "*"
     chalk "^4.0.0"
     co "^4.6.0"
-    expect "^26.4.2"
+    expect "^26.5.3"
     is-generator-fn "^2.0.0"
-    jest-each "^26.4.2"
-    jest-matcher-utils "^26.4.2"
-    jest-message-util "^26.3.0"
-    jest-runtime "^26.4.2"
-    jest-snapshot "^26.4.2"
-    jest-util "^26.3.0"
-    pretty-format "^26.4.2"
+    jest-each "^26.5.2"
+    jest-matcher-utils "^26.5.2"
+    jest-message-util "^26.5.2"
+    jest-runtime "^26.5.3"
+    jest-snapshot "^26.5.3"
+    jest-util "^26.5.2"
+    pretty-format "^26.5.2"
     throat "^5.0.0"
 
-jest-leak-detector@^26.4.2:
-  version "26.4.2"
-  resolved "https://registry.yarnpkg.com/jest-leak-detector/-/jest-leak-detector-26.4.2.tgz#c73e2fa8757bf905f6f66fb9e0070b70fa0f573f"
-  integrity sha512-akzGcxwxtE+9ZJZRW+M2o+nTNnmQZxrHJxX/HjgDaU5+PLmY1qnQPnMjgADPGCRPhB+Yawe1iij0REe+k/aHoA==
+jest-leak-detector@^26.5.2:
+  version "26.5.2"
+  resolved "https://registry.yarnpkg.com/jest-leak-detector/-/jest-leak-detector-26.5.2.tgz#83fcf9a4a6ef157549552cb4f32ca1d6221eea69"
+  integrity sha512-h7ia3dLzBFItmYERaLPEtEKxy3YlcbcRSjj0XRNJgBEyODuu+3DM2o62kvIFvs3PsaYoIIv+e+nLRI61Dj1CNw==
   dependencies:
     jest-get-type "^26.3.0"
-    pretty-format "^26.4.2"
+    pretty-format "^26.5.2"
 
-jest-matcher-utils@^26.4.2:
-  version "26.4.2"
-  resolved "https://registry.yarnpkg.com/jest-matcher-utils/-/jest-matcher-utils-26.4.2.tgz#fa81f3693f7cb67e5fc1537317525ef3b85f4b06"
-  integrity sha512-KcbNqWfWUG24R7tu9WcAOKKdiXiXCbMvQYT6iodZ9k1f7065k0keUOW6XpJMMvah+hTfqkhJhRXmA3r3zMAg0Q==
+jest-matcher-utils@^26.5.2:
+  version "26.5.2"
+  resolved "https://registry.yarnpkg.com/jest-matcher-utils/-/jest-matcher-utils-26.5.2.tgz#6aa2c76ce8b9c33e66f8856ff3a52bab59e6c85a"
+  integrity sha512-W9GO9KBIC4gIArsNqDUKsLnhivaqf8MSs6ujO/JDcPIQrmY+aasewweXVET8KdrJ6ADQaUne5UzysvF/RR7JYA==
   dependencies:
     chalk "^4.0.0"
-    jest-diff "^26.4.2"
+    jest-diff "^26.5.2"
     jest-get-type "^26.3.0"
-    pretty-format "^26.4.2"
+    pretty-format "^26.5.2"
 
-jest-message-util@^26.3.0:
-  version "26.3.0"
-  resolved "https://registry.yarnpkg.com/jest-message-util/-/jest-message-util-26.3.0.tgz#3bdb538af27bb417f2d4d16557606fd082d5841a"
-  integrity sha512-xIavRYqr4/otGOiLxLZGj3ieMmjcNE73Ui+LdSW/Y790j5acqCsAdDiLIbzHCZMpN07JOENRWX5DcU+OQ+TjTA==
+jest-message-util@^26.5.2:
+  version "26.5.2"
+  resolved "https://registry.yarnpkg.com/jest-message-util/-/jest-message-util-26.5.2.tgz#6c4c4c46dcfbabb47cd1ba2f6351559729bc11bb"
+  integrity sha512-Ocp9UYZ5Jl15C5PNsoDiGEk14A4NG0zZKknpWdZGoMzJuGAkVt10e97tnEVMYpk7LnQHZOfuK2j/izLBMcuCZw==
   dependencies:
     "@babel/code-frame" "^7.0.0"
-    "@jest/types" "^26.3.0"
-    "@types/stack-utils" "^1.0.1"
+    "@jest/types" "^26.5.2"
+    "@types/stack-utils" "^2.0.0"
     chalk "^4.0.0"
     graceful-fs "^4.2.4"
     micromatch "^4.0.2"
     slash "^3.0.0"
     stack-utils "^2.0.2"
 
-jest-mock@^26.3.0:
-  version "26.3.0"
-  resolved "https://registry.yarnpkg.com/jest-mock/-/jest-mock-26.3.0.tgz#ee62207c3c5ebe5f35b760e1267fee19a1cfdeba"
-  integrity sha512-PeaRrg8Dc6mnS35gOo/CbZovoDPKAeB1FICZiuagAgGvbWdNNyjQjkOaGUa/3N3JtpQ/Mh9P4A2D4Fv51NnP8Q==
+jest-mock@^26.5.2:
+  version "26.5.2"
+  resolved "https://registry.yarnpkg.com/jest-mock/-/jest-mock-26.5.2.tgz#c9302e8ef807f2bfc749ee52e65ad11166a1b6a1"
+  integrity sha512-9SiU4b5PtO51v0MtJwVRqeGEroH66Bnwtq4ARdNP7jNXbpT7+ByeWNAk4NeT/uHfNSVDXEXgQo1XRuwEqS6Rdw==
   dependencies:
-    "@jest/types" "^26.3.0"
+    "@jest/types" "^26.5.2"
     "@types/node" "*"
 
 jest-pnp-resolver@^1.2.2:
@@ -6229,170 +6266,171 @@ jest-regex-util@^26.0.0:
   resolved "https://registry.yarnpkg.com/jest-regex-util/-/jest-regex-util-26.0.0.tgz#d25e7184b36e39fd466c3bc41be0971e821fee28"
   integrity sha512-Gv3ZIs/nA48/Zvjrl34bf+oD76JHiGDUxNOVgUjh3j890sblXryjY4rss71fPtD/njchl6PSE2hIhvyWa1eT0A==
 
-jest-resolve-dependencies@^26.4.2:
-  version "26.4.2"
-  resolved "https://registry.yarnpkg.com/jest-resolve-dependencies/-/jest-resolve-dependencies-26.4.2.tgz#739bdb027c14befb2fe5aabbd03f7bab355f1dc5"
-  integrity sha512-ADHaOwqEcVc71uTfySzSowA/RdxUpCxhxa2FNLiin9vWLB1uLPad3we+JSSROq5+SrL9iYPdZZF8bdKM7XABTQ==
+jest-resolve-dependencies@^26.5.3:
+  version "26.5.3"
+  resolved "https://registry.yarnpkg.com/jest-resolve-dependencies/-/jest-resolve-dependencies-26.5.3.tgz#11483f91e534bdcd257ab21e8622799e59701aba"
+  integrity sha512-+KMDeke/BFK+mIQ2IYSyBz010h7zQaVt4Xie6cLqUGChorx66vVeQVv4ErNoMwInnyYHi1Ud73tDS01UbXbfLQ==
   dependencies:
-    "@jest/types" "^26.3.0"
+    "@jest/types" "^26.5.2"
     jest-regex-util "^26.0.0"
-    jest-snapshot "^26.4.2"
+    jest-snapshot "^26.5.3"
 
-jest-resolve@^26.4.0:
-  version "26.4.0"
-  resolved "https://registry.yarnpkg.com/jest-resolve/-/jest-resolve-26.4.0.tgz#6dc0af7fb93e65b73fec0368ca2b76f3eb59a6d7"
-  integrity sha512-bn/JoZTEXRSlEx3+SfgZcJAVuTMOksYq9xe9O6s4Ekg84aKBObEaVXKOEilULRqviSLAYJldnoWV9c07kwtiCg==
+jest-resolve@^26.5.2:
+  version "26.5.2"
+  resolved "https://registry.yarnpkg.com/jest-resolve/-/jest-resolve-26.5.2.tgz#0d719144f61944a428657b755a0e5c6af4fc8602"
+  integrity sha512-XsPxojXGRA0CoDD7Vis59ucz2p3cQFU5C+19tz3tLEAlhYKkK77IL0cjYjikY9wXnOaBeEdm1rOgSJjbZWpcZg==
   dependencies:
-    "@jest/types" "^26.3.0"
+    "@jest/types" "^26.5.2"
     chalk "^4.0.0"
     graceful-fs "^4.2.4"
     jest-pnp-resolver "^1.2.2"
-    jest-util "^26.3.0"
+    jest-util "^26.5.2"
     read-pkg-up "^7.0.1"
     resolve "^1.17.0"
     slash "^3.0.0"
 
-jest-runner@^26.4.2:
-  version "26.4.2"
-  resolved "https://registry.yarnpkg.com/jest-runner/-/jest-runner-26.4.2.tgz#c3ec5482c8edd31973bd3935df5a449a45b5b853"
-  integrity sha512-FgjDHeVknDjw1gRAYaoUoShe1K3XUuFMkIaXbdhEys+1O4bEJS8Avmn4lBwoMfL8O5oFTdWYKcf3tEJyyYyk8g==
+jest-runner@^26.5.3:
+  version "26.5.3"
+  resolved "https://registry.yarnpkg.com/jest-runner/-/jest-runner-26.5.3.tgz#800787459ea59c68e7505952933e33981dc3db38"
+  integrity sha512-qproP0Pq7IIule+263W57k2+8kWCszVJTC9TJWGUz0xJBr+gNiniGXlG8rotd0XxwonD5UiJloYoSO5vbUr5FQ==
   dependencies:
-    "@jest/console" "^26.3.0"
-    "@jest/environment" "^26.3.0"
-    "@jest/test-result" "^26.3.0"
-    "@jest/types" "^26.3.0"
+    "@jest/console" "^26.5.2"
+    "@jest/environment" "^26.5.2"
+    "@jest/test-result" "^26.5.2"
+    "@jest/types" "^26.5.2"
     "@types/node" "*"
     chalk "^4.0.0"
     emittery "^0.7.1"
     exit "^0.1.2"
     graceful-fs "^4.2.4"
-    jest-config "^26.4.2"
+    jest-config "^26.5.3"
     jest-docblock "^26.0.0"
-    jest-haste-map "^26.3.0"
-    jest-leak-detector "^26.4.2"
-    jest-message-util "^26.3.0"
-    jest-resolve "^26.4.0"
-    jest-runtime "^26.4.2"
-    jest-util "^26.3.0"
-    jest-worker "^26.3.0"
+    jest-haste-map "^26.5.2"
+    jest-leak-detector "^26.5.2"
+    jest-message-util "^26.5.2"
+    jest-resolve "^26.5.2"
+    jest-runtime "^26.5.3"
+    jest-util "^26.5.2"
+    jest-worker "^26.5.0"
     source-map-support "^0.5.6"
     throat "^5.0.0"
 
-jest-runtime@^26.4.2:
-  version "26.4.2"
-  resolved "https://registry.yarnpkg.com/jest-runtime/-/jest-runtime-26.4.2.tgz#94ce17890353c92e4206580c73a8f0c024c33c42"
-  integrity sha512-4Pe7Uk5a80FnbHwSOk7ojNCJvz3Ks2CNQWT5Z7MJo4tX0jb3V/LThKvD9tKPNVNyeMH98J/nzGlcwc00R2dSHQ==
-  dependencies:
-    "@jest/console" "^26.3.0"
-    "@jest/environment" "^26.3.0"
-    "@jest/fake-timers" "^26.3.0"
-    "@jest/globals" "^26.4.2"
-    "@jest/source-map" "^26.3.0"
-    "@jest/test-result" "^26.3.0"
-    "@jest/transform" "^26.3.0"
-    "@jest/types" "^26.3.0"
+jest-runtime@^26.5.3:
+  version "26.5.3"
+  resolved "https://registry.yarnpkg.com/jest-runtime/-/jest-runtime-26.5.3.tgz#5882ae91fd88304310f069549e6bf82f3f198bea"
+  integrity sha512-IDjalmn2s/Tc4GvUwhPHZ0iaXCdMRq5p6taW9P8RpU+FpG01O3+H8z+p3rDCQ9mbyyyviDgxy/LHPLzrIOKBkQ==
+  dependencies:
+    "@jest/console" "^26.5.2"
+    "@jest/environment" "^26.5.2"
+    "@jest/fake-timers" "^26.5.2"
+    "@jest/globals" "^26.5.3"
+    "@jest/source-map" "^26.5.0"
+    "@jest/test-result" "^26.5.2"
+    "@jest/transform" "^26.5.2"
+    "@jest/types" "^26.5.2"
     "@types/yargs" "^15.0.0"
     chalk "^4.0.0"
     collect-v8-coverage "^1.0.0"
     exit "^0.1.2"
     glob "^7.1.3"
     graceful-fs "^4.2.4"
-    jest-config "^26.4.2"
-    jest-haste-map "^26.3.0"
-    jest-message-util "^26.3.0"
-    jest-mock "^26.3.0"
+    jest-config "^26.5.3"
+    jest-haste-map "^26.5.2"
+    jest-message-util "^26.5.2"
+    jest-mock "^26.5.2"
     jest-regex-util "^26.0.0"
-    jest-resolve "^26.4.0"
-    jest-snapshot "^26.4.2"
-    jest-util "^26.3.0"
-    jest-validate "^26.4.2"
+    jest-resolve "^26.5.2"
+    jest-snapshot "^26.5.3"
+    jest-util "^26.5.2"
+    jest-validate "^26.5.3"
     slash "^3.0.0"
     strip-bom "^4.0.0"
-    yargs "^15.3.1"
+    yargs "^15.4.1"
 
-jest-serializer@^26.3.0:
-  version "26.3.0"
-  resolved "https://registry.yarnpkg.com/jest-serializer/-/jest-serializer-26.3.0.tgz#1c9d5e1b74d6e5f7e7f9627080fa205d976c33ef"
-  integrity sha512-IDRBQBLPlKa4flg77fqg0n/pH87tcRKwe8zxOVTWISxGpPHYkRZ1dXKyh04JOja7gppc60+soKVZ791mruVdow==
+jest-serializer@^26.5.0:
+  version "26.5.0"
+  resolved "https://registry.yarnpkg.com/jest-serializer/-/jest-serializer-26.5.0.tgz#f5425cc4c5f6b4b355f854b5f0f23ec6b962bc13"
+  integrity sha512-+h3Gf5CDRlSLdgTv7y0vPIAoLgX/SI7T4v6hy+TEXMgYbv+ztzbg5PSN6mUXAT/hXYHvZRWm+MaObVfqkhCGxA==
   dependencies:
     "@types/node" "*"
     graceful-fs "^4.2.4"
 
-jest-snapshot@^26.4.2:
-  version "26.4.2"
-  resolved "https://registry.yarnpkg.com/jest-snapshot/-/jest-snapshot-26.4.2.tgz#87d3ac2f2bd87ea8003602fbebd8fcb9e94104f6"
-  integrity sha512-N6Uub8FccKlf5SBFnL2Ri/xofbaA68Cc3MGjP/NuwgnsvWh+9hLIR/DhrxbSiKXMY9vUW5dI6EW1eHaDHqe9sg==
+jest-snapshot@^26.5.3:
+  version "26.5.3"
+  resolved "https://registry.yarnpkg.com/jest-snapshot/-/jest-snapshot-26.5.3.tgz#f6b4b4b845f85d4b0dadd7cf119c55d0c1688601"
+  integrity sha512-ZgAk0Wm0JJ75WS4lGaeRfa0zIgpL0KD595+XmtwlIEMe8j4FaYHyZhP1LNOO+8fXq7HJ3hll54+sFV9X4+CGVw==
   dependencies:
     "@babel/types" "^7.0.0"
-    "@jest/types" "^26.3.0"
+    "@jest/types" "^26.5.2"
+    "@types/babel__traverse" "^7.0.4"
     "@types/prettier" "^2.0.0"
     chalk "^4.0.0"
-    expect "^26.4.2"
+    expect "^26.5.3"
     graceful-fs "^4.2.4"
-    jest-diff "^26.4.2"
+    jest-diff "^26.5.2"
     jest-get-type "^26.3.0"
-    jest-haste-map "^26.3.0"
-    jest-matcher-utils "^26.4.2"
-    jest-message-util "^26.3.0"
-    jest-resolve "^26.4.0"
+    jest-haste-map "^26.5.2"
+    jest-matcher-utils "^26.5.2"
+    jest-message-util "^26.5.2"
+    jest-resolve "^26.5.2"
     natural-compare "^1.4.0"
-    pretty-format "^26.4.2"
+    pretty-format "^26.5.2"
     semver "^7.3.2"
 
-jest-util@^26.3.0:
-  version "26.3.0"
-  resolved "https://registry.yarnpkg.com/jest-util/-/jest-util-26.3.0.tgz#a8974b191df30e2bf523ebbfdbaeb8efca535b3e"
-  integrity sha512-4zpn6bwV0+AMFN0IYhH/wnzIQzRaYVrz1A8sYnRnj4UXDXbOVtWmlaZkO9mipFqZ13okIfN87aDoJWB7VH6hcw==
+jest-util@^26.5.2:
+  version "26.5.2"
+  resolved "https://registry.yarnpkg.com/jest-util/-/jest-util-26.5.2.tgz#8403f75677902cc52a1b2140f568e91f8ed4f4d7"
+  integrity sha512-WTL675bK+GSSAYgS8z9FWdCT2nccO1yTIplNLPlP0OD8tUk/H5IrWKMMRudIQQ0qp8bb4k+1Qa8CxGKq9qnYdg==
   dependencies:
-    "@jest/types" "^26.3.0"
+    "@jest/types" "^26.5.2"
     "@types/node" "*"
     chalk "^4.0.0"
     graceful-fs "^4.2.4"
     is-ci "^2.0.0"
     micromatch "^4.0.2"
 
-jest-validate@^26.4.2:
-  version "26.4.2"
-  resolved "https://registry.yarnpkg.com/jest-validate/-/jest-validate-26.4.2.tgz#e871b0dfe97747133014dcf6445ee8018398f39c"
-  integrity sha512-blft+xDX7XXghfhY0mrsBCYhX365n8K5wNDC4XAcNKqqjEzsRUSXP44m6PL0QJEW2crxQFLLztVnJ4j7oPlQrQ==
+jest-validate@^26.5.3:
+  version "26.5.3"
+  resolved "https://registry.yarnpkg.com/jest-validate/-/jest-validate-26.5.3.tgz#eefd5a5c87059550548c5ad8d6589746c66929e3"
+  integrity sha512-LX07qKeAtY+lsU0o3IvfDdN5KH9OulEGOMN1sFo6PnEf5/qjS1LZIwNk9blcBeW94pQUI9dLN9FlDYDWI5tyaA==
   dependencies:
-    "@jest/types" "^26.3.0"
+    "@jest/types" "^26.5.2"
     camelcase "^6.0.0"
     chalk "^4.0.0"
     jest-get-type "^26.3.0"
     leven "^3.1.0"
-    pretty-format "^26.4.2"
+    pretty-format "^26.5.2"
 
-jest-watcher@^26.3.0:
-  version "26.3.0"
-  resolved "https://registry.yarnpkg.com/jest-watcher/-/jest-watcher-26.3.0.tgz#f8ef3068ddb8af160ef868400318dc4a898eed08"
-  integrity sha512-XnLdKmyCGJ3VoF6G/p5ohbJ04q/vv5aH9ENI+i6BL0uu9WWB6Z7Z2lhQQk0d2AVZcRGp1yW+/TsoToMhBFPRdQ==
+jest-watcher@^26.5.2:
+  version "26.5.2"
+  resolved "https://registry.yarnpkg.com/jest-watcher/-/jest-watcher-26.5.2.tgz#2957f4461007e0769d74b537379ecf6b7c696916"
+  integrity sha512-i3m1NtWzF+FXfJ3ljLBB/WQEp4uaNhX7QcQUWMokcifFTUQBDFyUMEwk0JkJ1kopHbx7Een3KX0Q7+9koGM/Pw==
   dependencies:
-    "@jest/test-result" "^26.3.0"
-    "@jest/types" "^26.3.0"
+    "@jest/test-result" "^26.5.2"
+    "@jest/types" "^26.5.2"
     "@types/node" "*"
     ansi-escapes "^4.2.1"
     chalk "^4.0.0"
-    jest-util "^26.3.0"
+    jest-util "^26.5.2"
     string-length "^4.0.1"
 
-jest-worker@^26.3.0:
-  version "26.3.0"
-  resolved "https://registry.yarnpkg.com/jest-worker/-/jest-worker-26.3.0.tgz#7c8a97e4f4364b4f05ed8bca8ca0c24de091871f"
-  integrity sha512-Vmpn2F6IASefL+DVBhPzI2J9/GJUsqzomdeN+P+dK8/jKxbh8R3BtFnx3FIta7wYlPU62cpJMJQo4kuOowcMnw==
+jest-worker@^26.5.0:
+  version "26.5.0"
+  resolved "https://registry.yarnpkg.com/jest-worker/-/jest-worker-26.5.0.tgz#87deee86dbbc5f98d9919e0dadf2c40e3152fa30"
+  integrity sha512-kTw66Dn4ZX7WpjZ7T/SUDgRhapFRKWmisVAF0Rv4Fu8SLFD7eLbqpLvbxVqYhSgaWa7I+bW7pHnbyfNsH6stug==
   dependencies:
     "@types/node" "*"
     merge-stream "^2.0.0"
     supports-color "^7.0.0"
 
-jest@^26.4.2:
-  version "26.4.2"
-  resolved "https://registry.yarnpkg.com/jest/-/jest-26.4.2.tgz#7e8bfb348ec33f5459adeaffc1a25d5752d9d312"
-  integrity sha512-LLCjPrUh98Ik8CzW8LLVnSCfLaiY+wbK53U7VxnFSX7Q+kWC4noVeDvGWIFw0Amfq1lq2VfGm7YHWSLBV62MJw==
+jest@^26.5.3:
+  version "26.5.3"
+  resolved "https://registry.yarnpkg.com/jest/-/jest-26.5.3.tgz#5e7a322d16f558dc565ca97639e85993ef5affe6"
+  integrity sha512-uJi3FuVSLmkZrWvaDyaVTZGLL8WcfynbRnFXyAHuEtYiSZ+ijDDIMOw1ytmftK+y/+OdAtsG9QrtbF7WIBmOyA==
   dependencies:
-    "@jest/core" "^26.4.2"
+    "@jest/core" "^26.5.3"
     import-local "^3.0.2"
-    jest-cli "^26.4.2"
+    jest-cli "^26.5.3"
 
 js-base64@^2.1.9:
   version "2.6.4"
@@ -6422,7 +6460,7 @@ jsbn@~0.1.0:
   resolved "https://registry.yarnpkg.com/jsbn/-/jsbn-0.1.1.tgz#a5e654c2e5a2deb5f201d96cefbca80c0ef2f513"
   integrity sha1-peZUwuWi3rXyAdls77yoDA7y9RM=
 
-jsdom@^16.2.2:
+jsdom@^16.4.0:
   version "16.4.0"
   resolved "https://registry.yarnpkg.com/jsdom/-/jsdom-16.4.0.tgz#36005bde2d136f73eee1a830c6d45e55408edddb"
   integrity sha512-lYMm3wYdgPhrl7pDcRmvzPhhrGVBeVhPIqeHjzeiHN3DFmD1RBpbExbi8vU7BJdH8VAZYovR8DMt0PNNDM7k8w==
@@ -6562,6 +6600,14 @@ jsx-ast-utils@^2.4.1:
     array-includes "^3.1.1"
     object.assign "^4.1.0"
 
+"jsx-ast-utils@^2.4.1 || ^3.0.0":
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/jsx-ast-utils/-/jsx-ast-utils-3.0.0.tgz#0f49d5093bafa4b45d3fe02147d8b40ffc6c7438"
+  integrity sha512-sPuicm6EPKYI/UnWpOatvg4pI50qaBo4dSOMGUPutmJ26ttedFKXr0It0XXPk4HKnQ/1X0st4eSS2w2jhFk9Ow==
+  dependencies:
+    array-includes "^3.1.1"
+    object.assign "^4.1.1"
+
 keycode@^2.1.7:
   version "2.2.0"
   resolved "https://registry.yarnpkg.com/keycode/-/keycode-2.2.0.tgz#3d0af56dc7b8b8e5cba8d0a97f107204eec22b04"
@@ -6582,10 +6628,10 @@ kleur@^3.0.3:
   resolved "https://registry.yarnpkg.com/kleur/-/kleur-3.0.3.tgz#a79c9ecc86ee1ce3fa6206d1216c501f147fc07e"
   integrity sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==
 
-klona@^2.0.3:
-  version "2.0.3"
-  resolved "https://registry.yarnpkg.com/klona/-/klona-2.0.3.tgz#98274552c513583ad7a01456a789a2a0b4a2a538"
-  integrity sha512-CgPOT3ZadDpXxKcfV56lEQ9OQSZ42Mk26gnozI+uN/k39vzD8toUhRQoqsX0m9Q3eMPEfsLWmtyUpK/yqST4yg==
+klona@^2.0.4:
+  version "2.0.4"
+  resolved "https://registry.yarnpkg.com/klona/-/klona-2.0.4.tgz#7bb1e3affb0cb8624547ef7e8f6708ea2e39dfc0"
+  integrity sha512-ZRbnvdg/NxqzC7L9Uyqzf4psi1OM4Cuc+sJAkQPjO6XkQIJTNbfK2Rsmbw8fx1p2mkZdp2FZYo2+LwXYY/uwIA==
 
 knot.js@^1.1.5:
   version "1.1.5"
@@ -6978,10 +7024,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@^0.11.0:
-  version "0.11.0"
-  resolved "https://registry.yarnpkg.com/mini-css-extract-plugin/-/mini-css-extract-plugin-0.11.0.tgz#3918953075109d4ca204bf1e8a393a78d3cc821f"
-  integrity sha512-dVWGuWJlQw2lZxsxBI3hOsoxg1k3DruLR0foHQLSkQMfk+qLJbv9dUk8fjmjWQKN9ef2n54ehA2FjClAsQhrWQ==
+mini-css-extract-plugin@^0.11.3:
+  version "0.11.3"
+  resolved "https://registry.yarnpkg.com/mini-css-extract-plugin/-/mini-css-extract-plugin-0.11.3.tgz#15b0910a7f32e62ffde4a7430cfefbd700724ea6"
+  integrity sha512-n9BA8LonkOkW1/zn+IbLPQmovsL0wMb9yx75fMJQZf2X1Zoec9yTZtyMePcyu19wPkmFbzZZA6fLTotpFhQsOA==
   dependencies:
     loader-utils "^1.1.0"
     normalize-url "1.9.1"
@@ -7367,18 +7413,18 @@ object-fit-images@^3.2.3:
   resolved "https://registry.yarnpkg.com/object-fit-images/-/object-fit-images-3.2.4.tgz#6c299d38fdf207746e5d2d46c2877f6f25d15b52"
   integrity sha512-G+7LzpYfTfqUyrZlfrou/PLLLAPNC52FTy5y1CBywX+1/FkxIloOyQXBmZ3Zxa2AWO+lMF0JTuvqbr7G5e5CWg==
 
-object-inspect@^1.7.0, object-inspect@^1.8.0:
+object-inspect@^1.8.0:
   version "1.8.0"
   resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.8.0.tgz#df807e5ecf53a609cc6bfe93eac3cc7be5b3a9d0"
   integrity sha512-jLdtEOB112fORuypAyl/50VRVIBIdVQOSUUGQHzJ4xBSbit81zRarz7GThkEFZy1RceYrWYcPcBFPQwHyAc1gA==
 
 object-is@^1.0.1:
-  version "1.1.2"
-  resolved "https://registry.yarnpkg.com/object-is/-/object-is-1.1.2.tgz#c5d2e87ff9e119f78b7a088441519e2eec1573b6"
-  integrity sha512-5lHCz+0uufF6wZ7CRFWJN3hp8Jqblpgve06U5CMQ3f//6iDjPr2PEo9MWCjEssDsa+UZEL4PkFpr+BMop6aKzQ==
+  version "1.1.3"
+  resolved "https://registry.yarnpkg.com/object-is/-/object-is-1.1.3.tgz#2e3b9e65560137455ee3bd62aec4d90a2ea1cc81"
+  integrity sha512-teyqLvFWzLkq5B9ki8FVWA902UER2qkxmdA4nLf+wjOLAWgxzCWZNCxpDq9MvE8MmhWNr+I8w3BN49Vx36Y6Xg==
   dependencies:
     define-properties "^1.1.3"
-    es-abstract "^1.17.5"
+    es-abstract "^1.18.0-next.1"
 
 object-keys@^1.0.12, object-keys@^1.1.1:
   version "1.1.1"
@@ -7392,7 +7438,7 @@ object-visit@^1.0.0:
   dependencies:
     isobject "^3.0.0"
 
-object.assign@^4.1.0:
+object.assign@^4.1.0, object.assign@^4.1.1:
   version "4.1.1"
   resolved "https://registry.yarnpkg.com/object.assign/-/object.assign-4.1.1.tgz#303867a666cdd41936ecdedfb1f8f3e32a478cdd"
   integrity sha512-VT/cxmx5yaoHSOTSyrCygIDFco+RsibY2NM0a4RdEeY/4KgqezwFtK1yr3U67xYhqJSlASm2pKhLVzPj2lr4bA==
@@ -8014,9 +8060,9 @@ postcss-discard-overridden@^4.0.1:
     postcss "^7.0.0"
 
 postcss-load-config@^2.0.0:
-  version "2.1.0"
-  resolved "https://registry.yarnpkg.com/postcss-load-config/-/postcss-load-config-2.1.0.tgz#c84d692b7bb7b41ddced94ee62e8ab31b417b003"
-  integrity sha512-4pV3JJVPLd5+RueiVVB+gFOAa7GWc25XQcMp86Zexzke69mKf6Nx9LRcQywdz7yZI9n1udOxmLuAwTBypypF8Q==
+  version "2.1.2"
+  resolved "https://registry.yarnpkg.com/postcss-load-config/-/postcss-load-config-2.1.2.tgz#c5ea504f2c4aef33c7359a34de3573772ad7502a"
+  integrity sha512-/rDeGV6vMUo3mwJZmeHfEDvwnTKKqQ0S7OHUi/kJvvtx3aWtyWG2/0ZWnzCt2keEclwN6Tf0DST2v9kITdOKYw==
   dependencies:
     cosmiconfig "^5.0.0"
     import-cwd "^2.0.0"
@@ -8368,6 +8414,16 @@ pretty-format@^26.4.2:
     ansi-styles "^4.0.0"
     react-is "^16.12.0"
 
+pretty-format@^26.5.2:
+  version "26.5.2"
+  resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-26.5.2.tgz#5d896acfdaa09210683d34b6dc0e6e21423cd3e1"
+  integrity sha512-VizyV669eqESlkOikKJI8Ryxl/kPpbdLwNdPs2GrbQs18MpySB5S0Yo0N7zkg2xTRiFq4CFw8ct5Vg4a0xP0og==
+  dependencies:
+    "@jest/types" "^26.5.2"
+    ansi-regex "^5.0.0"
+    ansi-styles "^4.0.0"
+    react-is "^16.12.0"
+
 process-nextick-args@~2.0.0:
   version "2.0.1"
   resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2"
@@ -8689,10 +8745,10 @@ react-notification@^6.8.5:
   dependencies:
     prop-types "^15.6.2"
 
-react-overlays@^0.9.1:
-  version "0.9.1"
-  resolved "https://registry.yarnpkg.com/react-overlays/-/react-overlays-0.9.1.tgz#d4702bfe5b5e9335b676ff5a940253771fdeed12"
-  integrity sha512-b0asy/zHtRd0i2+2/uNxe3YVprF3bRT1guyr791DORjCzE/HSBMog+ul83CdtKQ1kZ+pLnxWCu5W3BMysFhHdQ==
+react-overlays@^0.9.2:
+  version "0.9.2"
+  resolved "https://registry.yarnpkg.com/react-overlays/-/react-overlays-0.9.2.tgz#51ab1c62ded5af4d279bd3b943999531bbd648da"
+  integrity sha512-wOi+WqO0acnUAMCbTTW06/GRkYjHdlvIoyX4bYkLvxKrLgl2kX9WzFVyBdwukl2jvN7I7oX7ZXAz7MNOWYdgCA==
   dependencies:
     classnames "^2.2.5"
     dom-helpers "^3.2.1"
@@ -9062,9 +9118,9 @@ regexpp@^3.1.0:
   integrity sha512-ZOIzd8yVsQQA7j8GCSlPGXwg5PfmA1mrq0JP4nGhh54LaKN3xdai/vHUDu74pKwV8OxseMS65u2NImosQcSD0Q==
 
 regexpu-core@^4.7.0:
-  version "4.7.0"
-  resolved "https://registry.yarnpkg.com/regexpu-core/-/regexpu-core-4.7.0.tgz#fcbf458c50431b0bb7b45d6967b8192d91f3d938"
-  integrity sha512-TQ4KXRnIn6tz6tjnrXEkD/sshygKH/j5KzK86X8MkeHyZ8qst/LZ89j3X4/8HEIfHANTFIP/AbXakeRhWIl5YQ==
+  version "4.7.1"
+  resolved "https://registry.yarnpkg.com/regexpu-core/-/regexpu-core-4.7.1.tgz#2dea5a9a07233298fbf0db91fa9abc4c6e0f8ad6"
+  integrity sha512-ywH2VUraA44DZQuRKzARmw6S66mr48pQVva4LBeRhcOltJ6hExvWly5ZjFLYo67xbIxb6W1q4bAGtgfEl20zfQ==
   dependencies:
     regenerate "^1.4.0"
     regenerate-unicode-properties "^8.2.0"
@@ -9392,21 +9448,21 @@ sass-lint@^1.13.1:
     path-is-absolute "^1.0.0"
     util "^0.10.3"
 
-sass-loader@^10.0.2:
-  version "10.0.2"
-  resolved "https://registry.yarnpkg.com/sass-loader/-/sass-loader-10.0.2.tgz#c7b73010848b264792dd45372eea0b87cba4401e"
-  integrity sha512-wV6NDUVB8/iEYMalV/+139+vl2LaRFlZGEd5/xmdcdzQcgmis+npyco6NsDTVOlNA3y2NV9Gcz+vHyFMIT+ffg==
+sass-loader@^10.0.3:
+  version "10.0.3"
+  resolved "https://registry.yarnpkg.com/sass-loader/-/sass-loader-10.0.3.tgz#9e2f1bfdd6355f2adde4e4835d838b020bf800b0"
+  integrity sha512-W4+FV5oUdYy0PnC11ZoPrcAexODgDCa3ngxoy5X5qBhZYoPz9FPjb6Oox8Aa0ZYEyx34k8AQfOVuvqefOSAAUQ==
   dependencies:
-    klona "^2.0.3"
+    klona "^2.0.4"
     loader-utils "^2.0.0"
     neo-async "^2.6.2"
-    schema-utils "^2.7.1"
+    schema-utils "^3.0.0"
     semver "^7.3.2"
 
-sass@^1.26.10:
-  version "1.26.10"
-  resolved "https://registry.yarnpkg.com/sass/-/sass-1.26.10.tgz#851d126021cdc93decbf201d1eca2a20ee434760"
-  integrity sha512-bzN0uvmzfsTvjz0qwccN1sPm2HxxpNI/Xa+7PlUEMS+nQvbyuEK7Y0qFqxlPHhiNHb1Ze8WQJtU31olMObkAMw==
+sass@^1.27.0:
+  version "1.27.0"
+  resolved "https://registry.yarnpkg.com/sass/-/sass-1.27.0.tgz#0657ff674206b95ec20dc638a93e179c78f6ada2"
+  integrity sha512-0gcrER56OkzotK/GGwgg4fPrKuiFlPNitO7eUJ18Bs+/NBlofJfMxmxqpqJxjae9vu0Wq8TZzrSyxZal00WDig==
   dependencies:
     chokidar ">=2.0.0 <4.0.0"
 
@@ -9439,7 +9495,7 @@ schema-utils@^1.0.0:
     ajv-errors "^1.0.0"
     ajv-keywords "^3.1.0"
 
-schema-utils@^2.2.0, schema-utils@^2.6.5, schema-utils@^2.7.0, schema-utils@^2.7.1:
+schema-utils@^2.2.0, schema-utils@^2.6.5, schema-utils@^2.7.1:
   version "2.7.1"
   resolved "https://registry.yarnpkg.com/schema-utils/-/schema-utils-2.7.1.tgz#1ca4f32d1b24c590c203b8e7a50bf0ea4cd394d7"
   integrity sha512-SHiNtMOUGWBQJwzISiVYKu82GiV4QYGePp3odlY1tuKO7gPtphAT5R/py0fA6xtbgLL/RvtJZnU9b8s0F1q0Xg==
@@ -9448,6 +9504,15 @@ schema-utils@^2.2.0, schema-utils@^2.6.5, schema-utils@^2.7.0, schema-utils@^2.7
     ajv "^6.12.4"
     ajv-keywords "^3.5.2"
 
+schema-utils@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/schema-utils/-/schema-utils-3.0.0.tgz#67502f6aa2b66a2d4032b4279a2944978a0913ef"
+  integrity sha512-6D82/xSzO094ajanoOSbe4YvXWMfn2A//8Y1+MUqFAJul5Bs+yn36xbK9OtNDcRVSBJ9jjeoXftM6CfztsjOAA==
+  dependencies:
+    "@types/json-schema" "^7.0.6"
+    ajv "^6.12.5"
+    ajv-keywords "^3.5.2"
+
 scroll-behavior@^0.9.1:
   version "0.9.12"
   resolved "https://registry.yarnpkg.com/scroll-behavior/-/scroll-behavior-0.9.12.tgz#1c22d273ec4ce6cd4714a443fead50227da9424c"
@@ -9517,13 +9582,6 @@ serialize-javascript@^2.1.2:
   resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-2.1.2.tgz#ecec53b0e0317bdc95ef76ab7074b7384785fa61"
   integrity sha512-rs9OggEUF0V4jUSecXazOYsLfu7OGK2qIn3c7IPBiffz32XniEp/TX9Xmc9LQfK2nQ2QKHvZ2oygKUGU0lG4jQ==
 
-serialize-javascript@^4.0.0:
-  version "4.0.0"
-  resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-4.0.0.tgz#b525e1238489a5ecfc42afacc3fe99e666f4b1aa"
-  integrity sha512-GaNA54380uFefWghODBWEGisLZFj00nS5ACs6yHa9nLqlLpVLO8ChDGeKRjZnV4Nh4n0Qi7nhYZD/9fCPzEqkw==
-  dependencies:
-    randombytes "^2.1.0"
-
 serialize-javascript@^5.0.1:
   version "5.0.1"
   resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-5.0.1.tgz#7886ec848049a462467a97d3d918ebb2aaf934f4"
@@ -9762,7 +9820,7 @@ source-map-resolve@^0.6.0:
     atob "^2.1.2"
     decode-uri-component "^0.2.0"
 
-source-map-support@^0.5.6, source-map-support@~0.5.12:
+source-map-support@^0.5.6, source-map-support@~0.5.12, source-map-support@~0.5.19:
   version "0.5.19"
   resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.19.tgz#a98b62f86dcaf4f67399648c085291ab9e8fed61"
   integrity sha512-Wonm7zOCIJzBGQdB+thsPar0kYuCIzYvxZwlBa87yi/Mdjv7Tip2cyVbLj5o0cFPN4EVkuTwb3GDDyUx2DGnGw==
@@ -9790,7 +9848,7 @@ source-map@^0.6.0, source-map@^0.6.1, source-map@~0.6.1:
   resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263"
   integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==
 
-source-map@^0.7.3:
+source-map@^0.7.3, source-map@~0.7.2:
   version "0.7.3"
   resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.7.3.tgz#5302f8169031735226544092e64981f751750383"
   integrity sha512-CkCj6giN3S+n9qrYiBTX5gystlENnRW5jZeNLHpe6aue+SrHcG5VYwujhW9s4dY31mEGsxBDrHR6oI69fTXsaQ==
@@ -10145,7 +10203,7 @@ strip-indent@^3.0.0:
   dependencies:
     min-indent "^1.0.0"
 
-strip-json-comments@^3.1.0:
+strip-json-comments@^3.1.0, strip-json-comments@^3.1.1:
   version "3.1.1"
   resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006"
   integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==
@@ -10306,19 +10364,19 @@ terser-webpack-plugin@^1.4.3:
     webpack-sources "^1.4.0"
     worker-farm "^1.7.0"
 
-terser-webpack-plugin@^4.2.2:
-  version "4.2.2"
-  resolved "https://registry.yarnpkg.com/terser-webpack-plugin/-/terser-webpack-plugin-4.2.2.tgz#d86200c700053bba637913fe4310ba1bdeb5568e"
-  integrity sha512-3qAQpykRTD5DReLu5/cwpsg7EZFzP3Q0Hp2XUWJUw2mpq2jfgOKTZr8IZKKnNieRVVo1UauROTdhbQJZveGKtQ==
+terser-webpack-plugin@^4.2.3:
+  version "4.2.3"
+  resolved "https://registry.yarnpkg.com/terser-webpack-plugin/-/terser-webpack-plugin-4.2.3.tgz#28daef4a83bd17c1db0297070adc07fc8cfc6a9a"
+  integrity sha512-jTgXh40RnvOrLQNgIkwEKnQ8rmHjHK4u+6UBEi+W+FPmvb+uo+chJXntKe7/3lW5mNysgSWD60KyesnhW8D6MQ==
   dependencies:
     cacache "^15.0.5"
     find-cache-dir "^3.3.1"
-    jest-worker "^26.3.0"
+    jest-worker "^26.5.0"
     p-limit "^3.0.2"
-    schema-utils "^2.7.1"
+    schema-utils "^3.0.0"
     serialize-javascript "^5.0.1"
     source-map "^0.6.1"
-    terser "^5.3.2"
+    terser "^5.3.4"
     webpack-sources "^1.4.3"
 
 terser@^4.1.2:
@@ -10330,14 +10388,14 @@ terser@^4.1.2:
     source-map "~0.6.1"
     source-map-support "~0.5.12"
 
-terser@^5.3.2:
-  version "5.3.2"
-  resolved "https://registry.yarnpkg.com/terser/-/terser-5.3.2.tgz#f4bea90eb92945b2a028ceef79181b9bb586e7af"
-  integrity sha512-H67sydwBz5jCUA32ZRL319ULu+Su1cAoZnnc+lXnenGRYWyLE3Scgkt8mNoAsMx0h5kdo758zdoS0LG9rYZXDQ==
+terser@^5.3.4:
+  version "5.3.4"
+  resolved "https://registry.yarnpkg.com/terser/-/terser-5.3.4.tgz#e510e05f86e0bd87f01835c3238839193f77a60c"
+  integrity sha512-dxuB8KQo8Gt6OVOeLg/rxfcxdNZI/V1G6ze1czFUzPeCFWZRtvZMgSzlZZ5OYBZ4HoG607F6pFPNLekJyV+yVw==
   dependencies:
     commander "^2.20.0"
-    source-map "~0.6.1"
-    source-map-support "~0.5.12"
+    source-map "~0.7.2"
+    source-map-support "~0.5.19"
 
 tesseract.js-core@^2.2.0:
   version "2.2.0"
@@ -10804,20 +10862,20 @@ uuid@^3.3.2, uuid@^3.4.0:
   resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.4.0.tgz#b23e4358afa8a202fe7a100af1f5f883f02007ee"
   integrity sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==
 
-uuid@^8.2.0, uuid@^8.3.0:
-  version "8.3.0"
-  resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.0.tgz#ab738085ca22dc9a8c92725e459b1d507df5d6ea"
-  integrity sha512-fX6Z5o4m6XsXBdli9g7DtWgAx+osMsRRZFKma1mIUsLCz6vRvv+pz5VNbyu9UEDzpMWulZfvpgb/cmDXVulYFQ==
+uuid@^8.3.0, uuid@^8.3.1:
+  version "8.3.1"
+  resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.1.tgz#2ba2e6ca000da60fce5a196954ab241131e05a31"
+  integrity sha512-FOmRr+FmWEIG8uhZv6C2bTgEVXsHk08kE7mPlrBbEe+c3r9pjceVPgupIfNIhc4yx55H69OXANrUaSuu9eInKg==
 
 v8-compile-cache@^2.0.3, v8-compile-cache@^2.1.1:
   version "2.1.1"
   resolved "https://registry.yarnpkg.com/v8-compile-cache/-/v8-compile-cache-2.1.1.tgz#54bc3cdd43317bca91e35dcaf305b1a7237de745"
   integrity sha512-8OQ9CL+VWyt3JStj7HX7/ciTL2V3Rl1Wf5OL+SNTm0yK1KvtReVulksyeRnCANHHuUxHlQig+JJDlUhBt1NQDQ==
 
-v8-to-istanbul@^5.0.1:
-  version "5.0.1"
-  resolved "https://registry.yarnpkg.com/v8-to-istanbul/-/v8-to-istanbul-5.0.1.tgz#0608f5b49a481458625edb058488607f25498ba5"
-  integrity sha512-mbDNjuDajqYe3TXFk5qxcQy8L1msXNE37WTlLoqqpBfRsimbNcrlhQlDPntmECEcUvdC+AQ8CyMMf6EUx1r74Q==
+v8-to-istanbul@^6.0.1:
+  version "6.0.1"
+  resolved "https://registry.yarnpkg.com/v8-to-istanbul/-/v8-to-istanbul-6.0.1.tgz#7ef0e32faa10f841fe4c1b0f8de96ed067c0be1e"
+  integrity sha512-PzM1WlqquhBvsV+Gco6WSFeg1AGdD53ccMRkFeyHRE/KRZaVacPOmQYP3EeVgDBtKD2BJ8kgynBQ5OtKiHCH+w==
   dependencies:
     "@types/istanbul-lib-coverage" "^2.0.1"
     convert-source-map "^1.6.0"
@@ -10948,10 +11006,10 @@ webpack-assets-manifest@^3.1.1:
     tapable "^1.0.0"
     webpack-sources "^1.0.0"
 
-webpack-bundle-analyzer@^3.8.0:
-  version "3.8.0"
-  resolved "https://registry.yarnpkg.com/webpack-bundle-analyzer/-/webpack-bundle-analyzer-3.8.0.tgz#ce6b3f908daf069fd1f7266f692cbb3bded9ba16"
-  integrity sha512-PODQhAYVEourCcOuU+NiYI7WdR8QyELZGgPvB1y2tjbUpbmcQOt5Q7jEK+ttd5se0KSBKD9SXHCEozS++Wllmw==
+webpack-bundle-analyzer@^3.9.0:
+  version "3.9.0"
+  resolved "https://registry.yarnpkg.com/webpack-bundle-analyzer/-/webpack-bundle-analyzer-3.9.0.tgz#f6f94db108fb574e415ad313de41a2707d33ef3c"
+  integrity sha512-Ob8amZfCm3rMB1ScjQVlbYYUEJyEjdEtQ92jqiFUYt5VkEeO2v5UMbv49P/gnmCZm3A6yaFQzCBvpZqN4MUsdA==
   dependencies:
     acorn "^7.1.1"
     acorn-walk "^7.1.1"
@@ -10962,7 +11020,7 @@ webpack-bundle-analyzer@^3.8.0:
     express "^4.16.3"
     filesize "^3.6.1"
     gzip-size "^5.0.0"
-    lodash "^4.17.15"
+    lodash "^4.17.19"
     mkdirp "^0.5.1"
     opener "^1.5.1"
     ws "^6.0.0"
@@ -11120,9 +11178,9 @@ whatwg-mimetype@^2.3.0:
   integrity sha512-M4yMwr6mAnQz76TbJm914+gPpB/nCwvZbJU28cUD6dR004SAxDLOOSUaB1JDRqLtaOV/vi0IC5lEAGFgrjGv/g==
 
 whatwg-url@^8.0.0:
-  version "8.2.1"
-  resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-8.2.1.tgz#ed73417230784b281fb2a32c3c501738b46167c3"
-  integrity sha512-ZmVCr6nfBeaMxEHALLEGy0LszYjpJqf6PVNQUQ1qd9Et+q7Jpygd4rGGDXgHjD8e99yLFseD69msHDM4YwPZ4A==
+  version "8.2.2"
+  resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-8.2.2.tgz#85e7f9795108b53d554cec640b2e8aee2a0d4bfd"
+  integrity sha512-PcVnO6NiewhkmzV0qn7A+UZ9Xx4maNTI+O+TShmfE4pqjoCMwUMjkvoNhNHPTvgR7QH9Xt3R13iHuWy2sToFxQ==
   dependencies:
     lodash.sortby "^4.7.0"
     tr46 "^2.0.2"
@@ -11316,7 +11374,7 @@ yargs@^13.3.2:
     y18n "^4.0.0"
     yargs-parser "^13.1.2"
 
-yargs@^15.3.1:
+yargs@^15.4.1:
   version "15.4.1"
   resolved "https://registry.yarnpkg.com/yargs/-/yargs-15.4.1.tgz#0d87a16de01aee9d8bec2bfbf74f67851730f4f8"
   integrity sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==