diff --git a/.github/workflows/build-image.yml b/.github/workflows/build-image.yml
index 97a363d1e6..da4203e357 100644
--- a/.github/workflows/build-image.yml
+++ b/.github/workflows/build-image.yml
@@ -43,9 +43,16 @@ jobs:
             type=edge,branch=main
             type=sha,prefix=,format=long
 
+      - name: Generate version suffix
+        id: version_vars
+        if: github.repository == 'mastodon/mastodon' && github.event_name == 'push' && github.ref_name == 'main'
+        run: |
+          echo mastodon_version_suffix=+edge-$(git rev-parse --short HEAD) >> $GITHUB_OUTPUT
+
       - uses: docker/build-push-action@v4
         with:
           context: .
+          build-args: MASTODON_VERSION_SUFFIX=${{ steps.version_vars.outputs.mastodon_version_suffix }}
           platforms: linux/amd64,linux/arm64
           provenance: false
           builder: ${{ steps.buildx.outputs.name }}
diff --git a/.github/workflows/build-nightly.yml b/.github/workflows/build-nightly.yml
index 501db6e9c6..f07f7447ca 100644
--- a/.github/workflows/build-nightly.yml
+++ b/.github/workflows/build-nightly.yml
@@ -41,9 +41,15 @@ jobs:
           labels: |
             org.opencontainers.image.description=Nightly build image used for testing purposes
 
+      - name: Generate version suffix
+        id: version_vars
+        run: |
+          echo mastodon_version_suffix=+nightly-$(date +'%Y%m%d') >> $GITHUB_OUTPUT
+
       - uses: docker/build-push-action@v4
         with:
           context: .
+          build-args: MASTODON_VERSION_SUFFIX=${{ steps.version_vars.outputs.mastodon_version_suffix }}
           platforms: linux/amd64,linux/arm64
           provenance: false
           builder: ${{ steps.buildx.outputs.name }}
diff --git a/.github/workflows/lint-js.yml b/.github/workflows/lint-js.yml
index e0c309c736..7700e48512 100644
--- a/.github/workflows/lint-js.yml
+++ b/.github/workflows/lint-js.yml
@@ -48,7 +48,7 @@ jobs:
         run: yarn --frozen-lockfile
 
       - name: ESLint
-        run: yarn test:lint:js
+        run: yarn test:lint:js --max-warnings 0
 
       - name: Typecheck
         run: yarn test:typecheck
diff --git a/.github/workflows/test-ruby.yml b/.github/workflows/test-ruby.yml
index 6b8d6fdfcd..f284745ea4 100644
--- a/.github/workflows/test-ruby.yml
+++ b/.github/workflows/test-ruby.yml
@@ -9,7 +9,6 @@ on:
 env:
   BUNDLE_CLEAN: true
   BUNDLE_FROZEN: true
-  BUNDLE_WITHOUT: 'development production'
 
 concurrency:
   group: ${{ github.workflow }}-${{ github.ref }}
@@ -19,8 +18,17 @@ jobs:
   build:
     runs-on: ubuntu-latest
 
+    strategy:
+      fail-fast: true
+      matrix:
+        mode:
+          - production
+          - test
     env:
-      RAILS_ENV: test
+      RAILS_ENV: ${{ matrix.mode }}
+      BUNDLE_WITH: ${{ matrix.mode }}
+      OTP_SECRET: precompile_placeholder
+      SECRET_KEY_BASE: precompile_placeholder
 
     steps:
       - uses: actions/checkout@v3
@@ -50,6 +58,7 @@ jobs:
           ./bin/rails assets:precompile
 
       - uses: actions/upload-artifact@v3
+        if: matrix.mode == 'test'
         with:
           path: |-
             ./public/assets
@@ -97,7 +106,7 @@ jobs:
       PAM_ENABLED: true
       PAM_DEFAULT_SERVICE: pam_test
       PAM_CONTROLLED_SERVICE: pam_test_controlled
-      BUNDLE_WITH: 'pam_authentication'
+      BUNDLE_WITH: 'pam_authentication test'
       CI_JOBS: ${{ matrix.ci_job }}/4
 
     strategy:
diff --git a/.profile b/.profile
index c6d57b609d..f4826ea303 100644
--- a/.profile
+++ b/.profile
@@ -1 +1 @@
-LD_LIBRARY_PATH=$LD_LIBRARY_PATH:/app/.apt/lib/x86_64-linux-gnu:/app/.apt/usr/lib/x86_64-linux-gnu/mesa:/app/.apt/usr/lib/x86_64-linux-gnu/pulseaudio
+LD_LIBRARY_PATH=$LD_LIBRARY_PATH:/app/.apt/lib/x86_64-linux-gnu:/app/.apt/usr/lib/x86_64-linux-gnu/mesa:/app/.apt/usr/lib/x86_64-linux-gnu/pulseaudio:/app/.apt/usr/lib/x86_64-linux-gnu/openblas-pthread
diff --git a/.rubocop.yml b/.rubocop.yml
index b510c43031..966a2a43db 100644
--- a/.rubocop.yml
+++ b/.rubocop.yml
@@ -65,6 +65,7 @@ Metrics/AbcSize:
 Metrics/BlockLength:
   CountAsOne: ['array', 'hash', 'heredoc', 'method_call']
   Exclude:
+    - 'config/routes.rb'
     - 'lib/mastodon/*_cli.rb'
     - 'lib/tasks/*.rake'
     - 'app/models/concerns/account_associations.rb'
@@ -85,6 +86,7 @@ Metrics/BlockLength:
     - 'config/initializers/simple_form.rb'
     - 'config/navigation.rb'
     - 'config/routes.rb'
+    - 'config/routes/*.rb'
     - 'db/post_migrate/20221101190723_backfill_admin_action_logs.rb'
     - 'db/post_migrate/20221206114142_backfill_admin_action_logs_again.rb'
     - 'lib/paperclip/gif_transcoder.rb'
@@ -130,6 +132,7 @@ Metrics/ClassLength:
     - 'app/services/activitypub/process_account_service.rb'
     - 'app/services/activitypub/process_status_update_service.rb'
     - 'app/services/backup_service.rb'
+    - 'app/services/bulk_import_service.rb'
     - 'app/services/delete_account_service.rb'
     - 'app/services/fan_out_on_write_service.rb'
     - 'app/services/fetch_link_card_service.rb'
@@ -158,6 +161,11 @@ Metrics/MethodLength:
 Metrics/ModuleLength:
   CountAsOne: [array, heredoc]
 
+# Reason: Prevailing style is argument file paths
+# https://docs.rubocop.org/rubocop-rails/cops_rails.html#railsfilepath
+Rails/FilePath:
+  EnforcedStyle: arguments
+
 # Reason: Prevailing style uses numeric status codes, matches RSpec/Rails/HttpStatus
 # https://docs.rubocop.org/rubocop-rails/cops_rails.html#railshttpstatus
 Rails/HttpStatus:
diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml
index 58e82a90c7..df1b4718ed 100644
--- a/.rubocop_todo.yml
+++ b/.rubocop_todo.yml
@@ -21,13 +21,6 @@ Layout/ArgumentAlignment:
     - 'config/initializers/cors.rb'
     - 'config/initializers/session_store.rb'
 
-# This cop supports safe autocorrection (--autocorrect).
-# Configuration parameters: EnforcedStyle.
-# SupportedStyles: empty_lines, no_empty_lines
-Layout/EmptyLinesAroundBlockBody:
-  Exclude:
-    - 'config/routes.rb'
-
 # This cop supports safe autocorrection (--autocorrect).
 # Configuration parameters: AllowForAlignment, AllowBeforeTrailingComments, ForceEqualSignAlignment.
 Layout/ExtraSpacing:
@@ -106,28 +99,6 @@ Lint/AmbiguousOperatorPrecedence:
   Exclude:
     - 'config/initializers/rack_attack.rb'
 
-# Configuration parameters: AllowedMethods.
-# AllowedMethods: enums
-Lint/ConstantDefinitionInBlock:
-  Exclude:
-    - 'spec/controllers/api/base_controller_spec.rb'
-    - 'spec/controllers/application_controller_spec.rb'
-    - 'spec/controllers/concerns/accountable_concern_spec.rb'
-    - 'spec/controllers/concerns/signature_verification_spec.rb'
-    - 'spec/lib/activitypub/adapter_spec.rb'
-    - 'spec/lib/connection_pool/shared_connection_pool_spec.rb'
-    - 'spec/lib/connection_pool/shared_timed_stack_spec.rb'
-    - 'spec/models/concerns/remotable_spec.rb'
-
-# Configuration parameters: IgnoreLiteralBranches, IgnoreConstantBranches.
-Lint/DuplicateBranch:
-  Exclude:
-    - 'app/lib/permalink_redirector.rb'
-    - 'app/models/account_statuses_filter.rb'
-    - 'app/validators/email_mx_validator.rb'
-    - 'app/validators/vote_validator.rb'
-    - 'lib/mastodon/maintenance_cli.rb'
-
 # Configuration parameters: AllowComments, AllowEmptyLambdas.
 Lint/EmptyBlock:
   Exclude:
@@ -168,11 +139,6 @@ Lint/EmptyBlock:
     - 'spec/models/user_role_spec.rb'
     - 'spec/models/web/setting_spec.rb'
 
-# Configuration parameters: AllowComments.
-Lint/EmptyClass:
-  Exclude:
-    - 'spec/controllers/api/base_controller_spec.rb'
-
 Lint/NonLocalExitFromIterator:
   Exclude:
     - 'app/helpers/jsonld_helper.rb'
@@ -228,6 +194,12 @@ Metrics/AbcSize:
   Exclude:
     - 'app/serializers/initial_state_serializer.rb'
 
+# Configuration parameters: CountComments, Max, CountAsOne, AllowedMethods, AllowedPatterns, inherit_mode.
+# AllowedMethods: refine
+Metrics/BlockLength:
+  Exclude:
+    - 'app/models/concerns/status_safe_reblog_insert.rb'
+
 # Configuration parameters: CountBlocks, Max.
 Metrics/BlockNesting:
   Exclude:
@@ -305,42 +277,6 @@ Naming/VariableNumber:
     - 'spec/models/user_spec.rb'
     - 'spec/services/activitypub/fetch_featured_collection_service_spec.rb'
 
-# Configuration parameters: MinSize.
-Performance/CollectionLiteralInLoop:
-  Exclude:
-    - 'app/models/admin/appeal_filter.rb'
-    - 'app/models/admin/status_filter.rb'
-    - 'app/models/relationship_filter.rb'
-    - 'app/models/trends/preview_card_filter.rb'
-    - 'app/models/trends/status_filter.rb'
-    - 'app/presenters/status_relationships_presenter.rb'
-    - 'app/services/fetch_resource_service.rb'
-    - 'app/services/suspend_account_service.rb'
-    - 'app/services/unsuspend_account_service.rb'
-    - 'config/deploy.rb'
-    - 'lib/mastodon/media_cli.rb'
-
-# This cop supports unsafe autocorrection (--autocorrect-all).
-Performance/Count:
-  Exclude:
-    - 'app/lib/importer/accounts_index_importer.rb'
-    - 'app/lib/importer/tags_index_importer.rb'
-
-# This cop supports unsafe autocorrection (--autocorrect-all).
-# Configuration parameters: SafeMultiline.
-Performance/DeletePrefix:
-  Exclude:
-    - 'app/controllers/authorize_interactions_controller.rb'
-    - 'app/controllers/concerns/signature_verification.rb'
-    - 'app/controllers/intents_controller.rb'
-    - 'app/lib/activitypub/case_transform.rb'
-    - 'app/lib/permalink_redirector.rb'
-    - 'app/lib/webfinger_resource.rb'
-    - 'app/services/activitypub/fetch_remote_actor_service.rb'
-    - 'app/services/backup_service.rb'
-    - 'app/services/resolve_account_service.rb'
-    - 'app/services/tag_search_service.rb'
-
 # This cop supports unsafe autocorrection (--autocorrect-all).
 Performance/MapCompact:
   Exclude:
@@ -360,46 +296,12 @@ Performance/MapCompact:
     - 'db/migrate/20200407202420_migrate_unavailable_inboxes.rb'
     - 'spec/presenters/status_relationships_presenter_spec.rb'
 
-Performance/MethodObjectAsBlock:
-  Exclude:
-    - 'app/models/account_suggestions/source.rb'
-    - 'spec/models/export_spec.rb'
-
-# This cop supports unsafe autocorrection (--autocorrect-all).
-# Configuration parameters: AllowRegexpMatch.
-Performance/RedundantEqualityComparisonBlock:
-  Exclude:
-    - 'spec/requests/link_headers_spec.rb'
-
-# This cop supports unsafe autocorrection (--autocorrect-all).
-# Configuration parameters: MaxKeyValuePairs.
-Performance/RedundantMerge:
-  Exclude:
-    - 'config/initializers/paperclip.rb'
-
 # This cop supports unsafe autocorrection (--autocorrect-all).
 # Configuration parameters: SafeMultiline.
 Performance/StartWith:
   Exclude:
     - 'app/lib/extractor.rb'
 
-# This cop supports unsafe autocorrection (--autocorrect-all).
-# Configuration parameters: OnlySumOrWithInitialValue.
-Performance/Sum:
-  Exclude:
-    - 'app/lib/activity_tracker.rb'
-    - 'app/models/trends/history.rb'
-    - 'lib/paperclip/color_extractor.rb'
-
-# This cop supports unsafe autocorrection (--autocorrect-all).
-Performance/TimesMap:
-  Exclude:
-    - 'spec/controllers/api/v1/blocks_controller_spec.rb'
-    - 'spec/controllers/api/v1/mutes_controller_spec.rb'
-    - 'spec/lib/feed_manager_spec.rb'
-    - 'spec/lib/request_pool_spec.rb'
-    - 'spec/models/account_spec.rb'
-
 # This cop supports unsafe autocorrection (--autocorrect-all).
 Performance/UnfreezeString:
   Exclude:
@@ -428,116 +330,6 @@ RSpec/AnyInstance:
     - 'spec/workers/activitypub/delivery_worker_spec.rb'
     - 'spec/workers/web/push_notification_worker_spec.rb'
 
-# Configuration parameters: Prefixes, AllowedPatterns.
-# Prefixes: when, with, without
-RSpec/ContextWording:
-  Exclude:
-    - 'spec/config/initializers/rack_attack_spec.rb'
-    - 'spec/controllers/accounts_controller_spec.rb'
-    - 'spec/controllers/activitypub/collections_controller_spec.rb'
-    - 'spec/controllers/activitypub/inboxes_controller_spec.rb'
-    - 'spec/controllers/admin/domain_blocks_controller_spec.rb'
-    - 'spec/controllers/admin/reports/actions_controller_spec.rb'
-    - 'spec/controllers/admin/statuses_controller_spec.rb'
-    - 'spec/controllers/api/v1/accounts/relationships_controller_spec.rb'
-    - 'spec/controllers/api/v1/accounts_controller_spec.rb'
-    - 'spec/controllers/api/v1/admin/domain_blocks_controller_spec.rb'
-    - 'spec/controllers/api/v1/emails/confirmations_controller_spec.rb'
-    - 'spec/controllers/api/v1/instances/activity_controller_spec.rb'
-    - 'spec/controllers/api/v1/instances/peers_controller_spec.rb'
-    - 'spec/controllers/api/v1/media_controller_spec.rb'
-    - 'spec/controllers/api/v2/filters_controller_spec.rb'
-    - 'spec/controllers/application_controller_spec.rb'
-    - 'spec/controllers/auth/registrations_controller_spec.rb'
-    - 'spec/controllers/auth/sessions_controller_spec.rb'
-    - 'spec/controllers/concerns/cache_concern_spec.rb'
-    - 'spec/controllers/concerns/challengable_concern_spec.rb'
-    - 'spec/controllers/concerns/localized_spec.rb'
-    - 'spec/controllers/concerns/rate_limit_headers_spec.rb'
-    - 'spec/controllers/instance_actors_controller_spec.rb'
-    - 'spec/controllers/settings/applications_controller_spec.rb'
-    - 'spec/controllers/settings/two_factor_authentication/webauthn_credentials_controller_spec.rb'
-    - 'spec/controllers/statuses_controller_spec.rb'
-    - 'spec/helpers/admin/account_moderation_notes_helper_spec.rb'
-    - 'spec/helpers/jsonld_helper_spec.rb'
-    - 'spec/helpers/routing_helper_spec.rb'
-    - 'spec/lib/activitypub/activity/accept_spec.rb'
-    - 'spec/lib/activitypub/activity/announce_spec.rb'
-    - 'spec/lib/activitypub/activity/create_spec.rb'
-    - 'spec/lib/activitypub/activity/follow_spec.rb'
-    - 'spec/lib/activitypub/activity/reject_spec.rb'
-    - 'spec/lib/advanced_text_formatter_spec.rb'
-    - 'spec/lib/emoji_formatter_spec.rb'
-    - 'spec/lib/entity_cache_spec.rb'
-    - 'spec/lib/feed_manager_spec.rb'
-    - 'spec/lib/html_aware_formatter_spec.rb'
-    - 'spec/lib/link_details_extractor_spec.rb'
-    - 'spec/lib/ostatus/tag_manager_spec.rb'
-    - 'spec/lib/scope_transformer_spec.rb'
-    - 'spec/lib/status_cache_hydrator_spec.rb'
-    - 'spec/lib/status_reach_finder_spec.rb'
-    - 'spec/lib/text_formatter_spec.rb'
-    - 'spec/models/account/field_spec.rb'
-    - 'spec/models/account_spec.rb'
-    - 'spec/models/admin/account_action_spec.rb'
-    - 'spec/models/concerns/account_interactions_spec.rb'
-    - 'spec/models/concerns/remotable_spec.rb'
-    - 'spec/models/custom_emoji_filter_spec.rb'
-    - 'spec/models/custom_emoji_spec.rb'
-    - 'spec/models/email_domain_block_spec.rb'
-    - 'spec/models/media_attachment_spec.rb'
-    - 'spec/models/notification_spec.rb'
-    - 'spec/models/remote_follow_spec.rb'
-    - 'spec/models/report_spec.rb'
-    - 'spec/models/session_activation_spec.rb'
-    - 'spec/models/setting_spec.rb'
-    - 'spec/models/status_spec.rb'
-    - 'spec/models/web/push_subscription_spec.rb'
-    - 'spec/policies/account_moderation_note_policy_spec.rb'
-    - 'spec/policies/account_policy_spec.rb'
-    - 'spec/policies/backup_policy_spec.rb'
-    - 'spec/policies/custom_emoji_policy_spec.rb'
-    - 'spec/policies/domain_block_policy_spec.rb'
-    - 'spec/policies/email_domain_block_policy_spec.rb'
-    - 'spec/policies/instance_policy_spec.rb'
-    - 'spec/policies/invite_policy_spec.rb'
-    - 'spec/policies/relay_policy_spec.rb'
-    - 'spec/policies/report_note_policy_spec.rb'
-    - 'spec/policies/report_policy_spec.rb'
-    - 'spec/policies/settings_policy_spec.rb'
-    - 'spec/policies/tag_policy_spec.rb'
-    - 'spec/policies/user_policy_spec.rb'
-    - 'spec/presenters/account_relationships_presenter_spec.rb'
-    - 'spec/presenters/status_relationships_presenter_spec.rb'
-    - 'spec/services/account_search_service_spec.rb'
-    - 'spec/services/account_statuses_cleanup_service_spec.rb'
-    - 'spec/services/activitypub/fetch_remote_status_service_spec.rb'
-    - 'spec/services/activitypub/process_account_service_spec.rb'
-    - 'spec/services/activitypub/process_status_update_service_spec.rb'
-    - 'spec/services/fetch_link_card_service_spec.rb'
-    - 'spec/services/fetch_oembed_service_spec.rb'
-    - 'spec/services/fetch_remote_status_service_spec.rb'
-    - 'spec/services/follow_service_spec.rb'
-    - 'spec/services/import_service_spec.rb'
-    - 'spec/services/notify_service_spec.rb'
-    - 'spec/services/process_mentions_service_spec.rb'
-    - 'spec/services/reblog_service_spec.rb'
-    - 'spec/services/report_service_spec.rb'
-    - 'spec/services/resolve_account_service_spec.rb'
-    - 'spec/services/resolve_url_service_spec.rb'
-    - 'spec/services/search_service_spec.rb'
-    - 'spec/services/unallow_domain_service_spec.rb'
-    - 'spec/services/verify_link_service_spec.rb'
-    - 'spec/validators/disallowed_hashtags_validator_spec.rb'
-    - 'spec/validators/email_mx_validator_spec.rb'
-    - 'spec/validators/follow_limit_validator_spec.rb'
-    - 'spec/validators/poll_validator_spec.rb'
-    - 'spec/validators/status_pin_validator_spec.rb'
-    - 'spec/validators/unreserved_username_validator_spec.rb'
-    - 'spec/validators/url_validator_spec.rb'
-    - 'spec/workers/move_worker_spec.rb'
-    - 'spec/workers/scheduler/accounts_statuses_cleanup_scheduler_spec.rb'
-
 # This cop supports unsafe autocorrection (--autocorrect-all).
 # Configuration parameters: SkipBlocks, EnforcedStyle.
 # SupportedStyles: described_class, explicit
@@ -701,7 +493,6 @@ RSpec/InstanceVariable:
     - 'spec/controllers/statuses_cleanup_controller_spec.rb'
     - 'spec/models/concerns/account_finder_concern_spec.rb'
     - 'spec/models/concerns/account_interactions_spec.rb'
-    - 'spec/models/concerns/remotable_spec.rb'
     - 'spec/models/public_feed_spec.rb'
     - 'spec/serializers/activitypub/note_serializer_spec.rb'
     - 'spec/serializers/activitypub/update_poll_serializer_spec.rb'
@@ -709,17 +500,6 @@ RSpec/InstanceVariable:
     - 'spec/services/search_service_spec.rb'
     - 'spec/services/unblock_domain_service_spec.rb'
 
-RSpec/LeakyConstantDeclaration:
-  Exclude:
-    - 'spec/controllers/api/base_controller_spec.rb'
-    - 'spec/controllers/application_controller_spec.rb'
-    - 'spec/controllers/concerns/accountable_concern_spec.rb'
-    - 'spec/controllers/concerns/signature_verification_spec.rb'
-    - 'spec/lib/activitypub/adapter_spec.rb'
-    - 'spec/lib/connection_pool/shared_connection_pool_spec.rb'
-    - 'spec/lib/connection_pool/shared_timed_stack_spec.rb'
-    - 'spec/models/concerns/remotable_spec.rb'
-
 RSpec/LetSetup:
   Exclude:
     - 'spec/controllers/admin/accounts_controller_spec.rb'
@@ -745,6 +525,7 @@ RSpec/LetSetup:
     - 'spec/controllers/following_accounts_controller_spec.rb'
     - 'spec/controllers/oauth/authorized_applications_controller_spec.rb'
     - 'spec/controllers/oauth/tokens_controller_spec.rb'
+    - 'spec/controllers/settings/imports_controller_spec.rb'
     - 'spec/lib/activitypub/activity/delete_spec.rb'
     - 'spec/lib/vacuum/preview_cards_vacuum_spec.rb'
     - 'spec/models/account_spec.rb'
@@ -759,6 +540,7 @@ RSpec/LetSetup:
     - 'spec/services/activitypub/process_collection_service_spec.rb'
     - 'spec/services/batched_remove_status_service_spec.rb'
     - 'spec/services/block_domain_service_spec.rb'
+    - 'spec/services/bulk_import_service_spec.rb'
     - 'spec/services/delete_account_service_spec.rb'
     - 'spec/services/import_service_spec.rb'
     - 'spec/services/notify_service_spec.rb'
@@ -831,17 +613,6 @@ RSpec/MultipleExpectations:
 RSpec/MultipleMemoizedHelpers:
   Max: 21
 
-# This cop supports safe autocorrection (--autocorrect).
-RSpec/MultipleSubjects:
-  Exclude:
-    - 'spec/controllers/activitypub/collections_controller_spec.rb'
-    - 'spec/controllers/activitypub/followers_synchronizations_controller_spec.rb'
-    - 'spec/controllers/activitypub/outboxes_controller_spec.rb'
-    - 'spec/controllers/api/web/embeds_controller_spec.rb'
-    - 'spec/controllers/emojis_controller_spec.rb'
-    - 'spec/controllers/follower_accounts_controller_spec.rb'
-    - 'spec/controllers/following_accounts_controller_spec.rb'
-
 # Configuration parameters: AllowedGroups.
 RSpec/NestedGroups:
   Max: 6
@@ -867,181 +638,6 @@ RSpec/PredicateMatcher:
     - 'spec/models/user_spec.rb'
     - 'spec/services/post_status_service_spec.rb'
 
-# This cop supports unsafe autocorrection (--autocorrect-all).
-# Configuration parameters: Inferences.
-RSpec/Rails/InferredSpecType:
-  Exclude:
-    - 'spec/controllers/about_controller_spec.rb'
-    - 'spec/controllers/accounts_controller_spec.rb'
-    - 'spec/controllers/activitypub/collections_controller_spec.rb'
-    - 'spec/controllers/activitypub/followers_synchronizations_controller_spec.rb'
-    - 'spec/controllers/activitypub/inboxes_controller_spec.rb'
-    - 'spec/controllers/activitypub/outboxes_controller_spec.rb'
-    - 'spec/controllers/activitypub/replies_controller_spec.rb'
-    - 'spec/controllers/admin/account_moderation_notes_controller_spec.rb'
-    - 'spec/controllers/admin/accounts_controller_spec.rb'
-    - 'spec/controllers/admin/action_logs_controller_spec.rb'
-    - 'spec/controllers/admin/base_controller_spec.rb'
-    - 'spec/controllers/admin/change_emails_controller_spec.rb'
-    - 'spec/controllers/admin/confirmations_controller_spec.rb'
-    - 'spec/controllers/admin/dashboard_controller_spec.rb'
-    - 'spec/controllers/admin/disputes/appeals_controller_spec.rb'
-    - 'spec/controllers/admin/domain_allows_controller_spec.rb'
-    - 'spec/controllers/admin/domain_blocks_controller_spec.rb'
-    - 'spec/controllers/admin/email_domain_blocks_controller_spec.rb'
-    - 'spec/controllers/admin/export_domain_allows_controller_spec.rb'
-    - 'spec/controllers/admin/export_domain_blocks_controller_spec.rb'
-    - 'spec/controllers/admin/instances_controller_spec.rb'
-    - 'spec/controllers/admin/settings/branding_controller_spec.rb'
-    - 'spec/controllers/admin/tags_controller_spec.rb'
-    - 'spec/controllers/api/oembed_controller_spec.rb'
-    - 'spec/controllers/api/v1/accounts/pins_controller_spec.rb'
-    - 'spec/controllers/api/v1/accounts/search_controller_spec.rb'
-    - 'spec/controllers/api/v1/accounts_controller_spec.rb'
-    - 'spec/controllers/api/v1/admin/account_actions_controller_spec.rb'
-    - 'spec/controllers/api/v1/admin/accounts_controller_spec.rb'
-    - 'spec/controllers/api/v1/admin/domain_allows_controller_spec.rb'
-    - 'spec/controllers/api/v1/admin/domain_blocks_controller_spec.rb'
-    - 'spec/controllers/api/v1/admin/reports_controller_spec.rb'
-    - 'spec/controllers/api/v1/announcements/reactions_controller_spec.rb'
-    - 'spec/controllers/api/v1/announcements_controller_spec.rb'
-    - 'spec/controllers/api/v1/apps_controller_spec.rb'
-    - 'spec/controllers/api/v1/blocks_controller_spec.rb'
-    - 'spec/controllers/api/v1/bookmarks_controller_spec.rb'
-    - 'spec/controllers/api/v1/conversations_controller_spec.rb'
-    - 'spec/controllers/api/v1/custom_emojis_controller_spec.rb'
-    - 'spec/controllers/api/v1/domain_blocks_controller_spec.rb'
-    - 'spec/controllers/api/v1/emails/confirmations_controller_spec.rb'
-    - 'spec/controllers/api/v1/endorsements_controller_spec.rb'
-    - 'spec/controllers/api/v1/favourites_controller_spec.rb'
-    - 'spec/controllers/api/v1/filters_controller_spec.rb'
-    - 'spec/controllers/api/v1/follow_requests_controller_spec.rb'
-    - 'spec/controllers/api/v1/followed_tags_controller_spec.rb'
-    - 'spec/controllers/api/v1/instances/activity_controller_spec.rb'
-    - 'spec/controllers/api/v1/instances/peers_controller_spec.rb'
-    - 'spec/controllers/api/v1/instances_controller_spec.rb'
-    - 'spec/controllers/api/v1/lists_controller_spec.rb'
-    - 'spec/controllers/api/v1/markers_controller_spec.rb'
-    - 'spec/controllers/api/v1/media_controller_spec.rb'
-    - 'spec/controllers/api/v1/mutes_controller_spec.rb'
-    - 'spec/controllers/api/v1/notifications_controller_spec.rb'
-    - 'spec/controllers/api/v1/polls/votes_controller_spec.rb'
-    - 'spec/controllers/api/v1/polls_controller_spec.rb'
-    - 'spec/controllers/api/v1/reports_controller_spec.rb'
-    - 'spec/controllers/api/v1/statuses/favourited_by_accounts_controller_spec.rb'
-    - 'spec/controllers/api/v1/statuses/reblogged_by_accounts_controller_spec.rb'
-    - 'spec/controllers/api/v1/statuses_controller_spec.rb'
-    - 'spec/controllers/api/v1/suggestions_controller_spec.rb'
-    - 'spec/controllers/api/v1/tags_controller_spec.rb'
-    - 'spec/controllers/api/v1/trends/tags_controller_spec.rb'
-    - 'spec/controllers/api/v2/admin/accounts_controller_spec.rb'
-    - 'spec/controllers/api/v2/filters/keywords_controller_spec.rb'
-    - 'spec/controllers/api/v2/filters/statuses_controller_spec.rb'
-    - 'spec/controllers/api/v2/filters_controller_spec.rb'
-    - 'spec/controllers/api/v2/search_controller_spec.rb'
-    - 'spec/controllers/application_controller_spec.rb'
-    - 'spec/controllers/auth/challenges_controller_spec.rb'
-    - 'spec/controllers/auth/confirmations_controller_spec.rb'
-    - 'spec/controllers/auth/passwords_controller_spec.rb'
-    - 'spec/controllers/auth/registrations_controller_spec.rb'
-    - 'spec/controllers/auth/sessions_controller_spec.rb'
-    - 'spec/controllers/concerns/account_controller_concern_spec.rb'
-    - 'spec/controllers/concerns/cache_concern_spec.rb'
-    - 'spec/controllers/concerns/challengable_concern_spec.rb'
-    - 'spec/controllers/concerns/export_controller_concern_spec.rb'
-    - 'spec/controllers/concerns/localized_spec.rb'
-    - 'spec/controllers/concerns/signature_verification_spec.rb'
-    - 'spec/controllers/concerns/user_tracking_concern_spec.rb'
-    - 'spec/controllers/disputes/appeals_controller_spec.rb'
-    - 'spec/controllers/disputes/strikes_controller_spec.rb'
-    - 'spec/controllers/home_controller_spec.rb'
-    - 'spec/controllers/instance_actors_controller_spec.rb'
-    - 'spec/controllers/intents_controller_spec.rb'
-    - 'spec/controllers/oauth/authorizations_controller_spec.rb'
-    - 'spec/controllers/oauth/tokens_controller_spec.rb'
-    - 'spec/controllers/settings/imports_controller_spec.rb'
-    - 'spec/controllers/settings/profiles_controller_spec.rb'
-    - 'spec/controllers/statuses_cleanup_controller_spec.rb'
-    - 'spec/controllers/tags_controller_spec.rb'
-    - 'spec/controllers/well_known/host_meta_controller_spec.rb'
-    - 'spec/controllers/well_known/nodeinfo_controller_spec.rb'
-    - 'spec/controllers/well_known/webfinger_controller_spec.rb'
-    - 'spec/helpers/accounts_helper_spec.rb'
-    - 'spec/helpers/admin/account_moderation_notes_helper_spec.rb'
-    - 'spec/helpers/admin/action_logs_helper_spec.rb'
-    - 'spec/helpers/flashes_helper_spec.rb'
-    - 'spec/helpers/formatting_helper_spec.rb'
-    - 'spec/helpers/home_helper_spec.rb'
-    - 'spec/helpers/routing_helper_spec.rb'
-    - 'spec/mailers/admin_mailer_spec.rb'
-    - 'spec/mailers/notification_mailer_spec.rb'
-    - 'spec/mailers/user_mailer_spec.rb'
-    - 'spec/models/account/field_spec.rb'
-    - 'spec/models/account_alias_spec.rb'
-    - 'spec/models/account_conversation_spec.rb'
-    - 'spec/models/account_deletion_request_spec.rb'
-    - 'spec/models/account_domain_block_spec.rb'
-    - 'spec/models/account_migration_spec.rb'
-    - 'spec/models/account_moderation_note_spec.rb'
-    - 'spec/models/account_spec.rb'
-    - 'spec/models/account_statuses_cleanup_policy_spec.rb'
-    - 'spec/models/admin/account_action_spec.rb'
-    - 'spec/models/admin/action_log_spec.rb'
-    - 'spec/models/announcement_mute_spec.rb'
-    - 'spec/models/announcement_reaction_spec.rb'
-    - 'spec/models/announcement_spec.rb'
-    - 'spec/models/backup_spec.rb'
-    - 'spec/models/block_spec.rb'
-    - 'spec/models/canonical_email_block_spec.rb'
-    - 'spec/models/conversation_mute_spec.rb'
-    - 'spec/models/conversation_spec.rb'
-    - 'spec/models/custom_emoji_spec.rb'
-    - 'spec/models/custom_filter_keyword_spec.rb'
-    - 'spec/models/custom_filter_spec.rb'
-    - 'spec/models/device_spec.rb'
-    - 'spec/models/domain_block_spec.rb'
-    - 'spec/models/email_domain_block_spec.rb'
-    - 'spec/models/encrypted_message_spec.rb'
-    - 'spec/models/favourite_spec.rb'
-    - 'spec/models/featured_tag_spec.rb'
-    - 'spec/models/follow_recommendation_suppression_spec.rb'
-    - 'spec/models/follow_request_spec.rb'
-    - 'spec/models/follow_spec.rb'
-    - 'spec/models/home_feed_spec.rb'
-    - 'spec/models/identity_spec.rb'
-    - 'spec/models/import_spec.rb'
-    - 'spec/models/invite_spec.rb'
-    - 'spec/models/list_account_spec.rb'
-    - 'spec/models/list_spec.rb'
-    - 'spec/models/login_activity_spec.rb'
-    - 'spec/models/media_attachment_spec.rb'
-    - 'spec/models/mention_spec.rb'
-    - 'spec/models/mute_spec.rb'
-    - 'spec/models/notification_spec.rb'
-    - 'spec/models/poll_vote_spec.rb'
-    - 'spec/models/preview_card_spec.rb'
-    - 'spec/models/preview_card_trend_spec.rb'
-    - 'spec/models/public_feed_spec.rb'
-    - 'spec/models/relay_spec.rb'
-    - 'spec/models/scheduled_status_spec.rb'
-    - 'spec/models/session_activation_spec.rb'
-    - 'spec/models/setting_spec.rb'
-    - 'spec/models/site_upload_spec.rb'
-    - 'spec/models/status_pin_spec.rb'
-    - 'spec/models/status_spec.rb'
-    - 'spec/models/status_stat_spec.rb'
-    - 'spec/models/status_trend_spec.rb'
-    - 'spec/models/system_key_spec.rb'
-    - 'spec/models/tag_follow_spec.rb'
-    - 'spec/models/unavailable_domain_spec.rb'
-    - 'spec/models/user_invite_request_spec.rb'
-    - 'spec/models/user_role_spec.rb'
-    - 'spec/models/user_spec.rb'
-    - 'spec/models/web/push_subscription_spec.rb'
-    - 'spec/models/web/setting_spec.rb'
-    - 'spec/models/webauthn_credentials_spec.rb'
-    - 'spec/models/webhook_spec.rb'
-
 RSpec/RepeatedExample:
   Exclude:
     - 'spec/policies/status_policy_spec.rb'
@@ -1120,7 +716,6 @@ RSpec/VerifiedDoubles:
     - 'spec/controllers/api/web/embeds_controller_spec.rb'
     - 'spec/controllers/auth/sessions_controller_spec.rb'
     - 'spec/controllers/disputes/appeals_controller_spec.rb'
-    - 'spec/controllers/settings/imports_controller_spec.rb'
     - 'spec/helpers/statuses_helper_spec.rb'
     - 'spec/lib/suspicious_sign_in_detector_spec.rb'
     - 'spec/models/account/field_spec.rb'
@@ -1148,19 +743,6 @@ RSpec/VerifiedDoubles:
     - 'spec/workers/feed_insert_worker_spec.rb'
     - 'spec/workers/regeneration_worker_spec.rb'
 
-# This cop supports safe autocorrection (--autocorrect).
-# Configuration parameters: Include.
-# Include: app/models/**/*.rb
-Rails/ActiveRecordCallbacksOrder:
-  Exclude:
-    - 'app/models/account.rb'
-    - 'app/models/account_conversation.rb'
-    - 'app/models/announcement_reaction.rb'
-    - 'app/models/block.rb'
-    - 'app/models/media_attachment.rb'
-    - 'app/models/session_activation.rb'
-    - 'app/models/status.rb'
-
 # This cop supports unsafe autocorrection (--autocorrect-all).
 Rails/ApplicationController:
   Exclude:
@@ -1216,12 +798,6 @@ Rails/CreateTableWithTimestamps:
     - 'db/migrate/20220824233535_create_status_trends.rb'
     - 'db/migrate/20221006061337_create_preview_card_trends.rb'
 
-# This cop supports unsafe autocorrection (--autocorrect-all).
-# Configuration parameters: Severity.
-Rails/DeprecatedActiveModelErrorsMethods:
-  Exclude:
-    - 'lib/mastodon/accounts_cli.rb'
-
 # This cop supports safe autocorrection (--autocorrect).
 # Configuration parameters: Severity.
 Rails/DuplicateAssociation:
@@ -1235,74 +811,6 @@ Rails/Exit:
   Exclude:
     - 'config/boot.rb'
 
-# Configuration parameters: EnforcedStyle.
-# SupportedStyles: slashes, arguments
-Rails/FilePath:
-  Exclude:
-    - 'app/lib/themes.rb'
-    - 'app/models/setting.rb'
-    - 'app/validators/reaction_validator.rb'
-    - 'config/environments/test.rb'
-    - 'config/initializers/locale.rb'
-    - 'db/migrate/20170716191202_add_hide_notifications_to_mute.rb'
-    - 'db/migrate/20171005171936_add_disabled_to_custom_emojis.rb'
-    - 'db/migrate/20171028221157_add_reblogs_to_follows.rb'
-    - 'db/migrate/20171107143332_add_memorial_to_accounts.rb'
-    - 'db/migrate/20171107143624_add_disabled_to_users.rb'
-    - 'db/migrate/20171109012327_add_moderator_to_accounts.rb'
-    - 'db/migrate/20171130000000_add_embed_url_to_preview_cards.rb'
-    - 'db/migrate/20180615122121_add_autofollow_to_invites.rb'
-    - 'db/migrate/20180707154237_add_whole_word_to_custom_filter.rb'
-    - 'db/migrate/20180814171349_add_confidential_to_doorkeeper_application.rb'
-    - 'db/migrate/20181010141500_add_silent_to_mentions.rb'
-    - 'db/migrate/20181017170937_add_reject_reports_to_domain_blocks.rb'
-    - 'db/migrate/20181018205649_add_unread_to_account_conversations.rb'
-    - 'db/migrate/20181127130500_identity_id_to_bigint.rb'
-    - 'db/migrate/20181127165847_add_show_replies_to_lists.rb'
-    - 'db/migrate/20190201012802_add_overwrite_to_imports.rb'
-    - 'db/migrate/20190306145741_add_lock_version_to_polls.rb'
-    - 'db/migrate/20190307234537_add_approved_to_users.rb'
-    - 'db/migrate/20191001213028_add_lock_version_to_account_stats.rb'
-    - 'db/migrate/20191212003415_increase_backup_size.rb'
-    - 'db/migrate/20200312144258_add_title_to_account_warning_presets.rb'
-    - 'db/migrate/20200620164023_add_fixed_lowercase_index_to_accounts.rb'
-    - 'db/migrate/20200917192924_add_notify_to_follows.rb'
-    - 'db/migrate/20201218054746_add_obfuscate_to_domain_blocks.rb'
-    - 'db/migrate/20210421121431_add_case_insensitive_btree_index_to_tags.rb'
-    - 'db/migrate/20211231080958_add_category_to_reports.rb'
-    - 'db/migrate/20220613110834_add_action_to_custom_filters.rb'
-    - 'db/post_migrate/20220307083603_optimize_null_index_conversations_uri.rb'
-    - 'db/post_migrate/20220310060545_optimize_null_index_statuses_in_reply_to_account_id.rb'
-    - 'db/post_migrate/20220310060556_optimize_null_index_statuses_in_reply_to_id.rb'
-    - 'db/post_migrate/20220310060614_optimize_null_index_media_attachments_scheduled_status_id.rb'
-    - 'db/post_migrate/20220310060626_optimize_null_index_media_attachments_shortcode.rb'
-    - 'db/post_migrate/20220310060641_optimize_null_index_users_reset_password_token.rb'
-    - 'db/post_migrate/20220310060653_optimize_null_index_users_created_by_application_id.rb'
-    - 'db/post_migrate/20220310060706_optimize_null_index_statuses_uri.rb'
-    - 'db/post_migrate/20220310060722_optimize_null_index_accounts_moved_to_account_id.rb'
-    - 'db/post_migrate/20220310060740_optimize_null_index_oauth_access_tokens_refresh_token.rb'
-    - 'db/post_migrate/20220310060750_optimize_null_index_accounts_url.rb'
-    - 'db/post_migrate/20220310060809_optimize_null_index_oauth_access_tokens_resource_owner_id.rb'
-    - 'db/post_migrate/20220310060833_optimize_null_index_announcement_reactions_custom_emoji_id.rb'
-    - 'db/post_migrate/20220310060854_optimize_null_index_appeals_approved_by_account_id.rb'
-    - 'db/post_migrate/20220310060913_optimize_null_index_account_migrations_target_account_id.rb'
-    - 'db/post_migrate/20220310060926_optimize_null_index_appeals_rejected_by_account_id.rb'
-    - 'db/post_migrate/20220310060939_optimize_null_index_list_accounts_follow_id.rb'
-    - 'db/post_migrate/20220310060959_optimize_null_index_web_push_subscriptions_access_token_id.rb'
-    - 'db/post_migrate/20220613110802_remove_whole_word_from_custom_filters.rb'
-    - 'db/post_migrate/20220613110903_remove_irreversible_from_custom_filters.rb'
-    - 'db/post_migrate/20220617202502_migrate_roles.rb'
-    - 'db/seeds.rb'
-    - 'db/seeds/03_roles.rb'
-    - 'lib/tasks/branding.rake'
-    - 'lib/tasks/emojis.rake'
-    - 'lib/tasks/repo.rake'
-    - 'spec/controllers/admin/custom_emojis_controller_spec.rb'
-    - 'spec/fabricators/custom_emoji_fabricator.rb'
-    - 'spec/fabricators/site_upload_fabricator.rb'
-    - 'spec/rails_helper.rb'
-    - 'spec/spec_helper.rb'
-
 # Configuration parameters: Include.
 # Include: app/models/**/*.rb
 Rails/HasAndBelongsToMany:
@@ -1445,12 +953,30 @@ Rails/SkipsModelValidations:
     - 'spec/services/follow_service_spec.rb'
     - 'spec/services/update_account_service_spec.rb'
 
-Rails/TransactionExitStatement:
+# Configuration parameters: Include.
+# Include: db/**/*.rb
+Rails/ThreeStateBooleanColumn:
   Exclude:
-    - 'app/lib/activitypub/activity/announce.rb'
-    - 'app/lib/activitypub/activity/create.rb'
-    - 'app/lib/activitypub/activity/delete.rb'
-    - 'app/services/activitypub/process_account_service.rb'
+    - 'db/migrate/20160325130944_add_admin_to_users.rb'
+    - 'db/migrate/20161123093447_add_sensitive_to_statuses.rb'
+    - 'db/migrate/20170123203248_add_reject_media_to_domain_blocks.rb'
+    - 'db/migrate/20170127165745_add_devise_two_factor_to_users.rb'
+    - 'db/migrate/20170209184350_add_reply_to_statuses.rb'
+    - 'db/migrate/20170330163835_create_imports.rb'
+    - 'db/migrate/20170905165803_add_local_to_statuses.rb'
+    - 'db/migrate/20171210213213_add_local_only_flag_to_statuses.rb'
+    - 'db/migrate/20181203021853_add_discoverable_to_accounts.rb'
+    - 'db/migrate/20190509164208_add_by_moderator_to_tombstone.rb'
+    - 'db/migrate/20190805123746_add_capabilities_to_tags.rb'
+    - 'db/migrate/20191212163405_add_hide_collections_to_accounts.rb'
+    - 'db/migrate/20200309150742_add_forwarded_to_reports.rb'
+    - 'db/migrate/20210609202149_create_login_activities.rb'
+    - 'db/migrate/20210621221010_add_skip_sign_in_token_to_users.rb'
+    - 'db/migrate/20211031031021_create_preview_card_providers.rb'
+    - 'db/migrate/20211115032527_add_trendable_to_preview_cards.rb'
+    - 'db/migrate/20220202200743_add_trendable_to_accounts.rb'
+    - 'db/migrate/20220202200926_add_trendable_to_statuses.rb'
+    - 'db/migrate/20220303000827_add_ordered_media_attachment_ids_to_status_edits.rb'
 
 # Configuration parameters: Include.
 # Include: app/models/**/*.rb
@@ -1519,12 +1045,6 @@ Style/CaseEquality:
   Exclude:
     - 'config/initializers/trusted_proxies.rb'
 
-# This cop supports unsafe autocorrection (--autocorrect-all).
-# Configuration parameters: MinBranchesCount.
-Style/CaseLikeIf:
-  Exclude:
-    - 'app/controllers/concerns/signature_verification.rb'
-
 # This cop supports safe autocorrection (--autocorrect).
 # Configuration parameters: AllowedMethods, AllowedPatterns.
 # AllowedMethods: ==, equal?, eql?
@@ -1542,16 +1062,10 @@ Style/CombinableLoops:
     - 'app/models/form/custom_emoji_batch.rb'
     - 'app/models/form/ip_block_batch.rb'
 
-# This cop supports unsafe autocorrection (--autocorrect-all).
-Style/ConcatArrayLiterals:
-  Exclude:
-    - 'app/lib/feed_manager.rb'
-
 # This cop supports safe autocorrection (--autocorrect).
 # Configuration parameters: AllowedVars.
 Style/FetchEnvVar:
   Exclude:
-    - 'app/helpers/application_helper.rb'
     - 'app/lib/redis_configuration.rb'
     - 'app/lib/translation_service.rb'
     - 'config/environments/development.rb'
@@ -2001,7 +1515,6 @@ Style/GuardClause:
     - 'app/controllers/auth/passwords_controller.rb'
     - 'app/controllers/settings/two_factor_authentication/webauthn_credentials_controller.rb'
     - 'app/lib/activitypub/activity/block.rb'
-    - 'app/lib/connection_pool/shared_connection_pool.rb'
     - 'app/lib/request.rb'
     - 'app/lib/request_pool.rb'
     - 'app/lib/webfinger.rb'
@@ -2036,7 +1549,6 @@ Style/HashAsLastArrayItem:
   Exclude:
     - 'app/controllers/admin/statuses_controller.rb'
     - 'app/controllers/api/v1/statuses_controller.rb'
-    - 'app/models/account.rb'
     - 'app/models/concerns/account_counters.rb'
     - 'app/models/concerns/status_threading_concern.rb'
     - 'app/models/status.rb'
@@ -2044,19 +1556,6 @@ Style/HashAsLastArrayItem:
     - 'app/services/notify_service.rb'
     - 'db/migrate/20181024224956_migrate_account_conversations.rb'
 
-# This cop supports safe autocorrection (--autocorrect).
-# Configuration parameters: EnforcedStyle, EnforcedShorthandSyntax, UseHashRocketsWithSymbolValues, PreferHashRocketsForNonAlnumEndingSymbols.
-# SupportedStyles: ruby19, hash_rockets, no_mixed_keys, ruby19_no_mixed_keys
-# SupportedShorthandSyntax: always, never, either, consistent
-Style/HashSyntax:
-  Exclude:
-    - 'app/helpers/application_helper.rb'
-    - 'app/models/media_attachment.rb'
-    - 'lib/terrapin/multi_pipe_extensions.rb'
-    - 'spec/controllers/admin/reports/actions_controller_spec.rb'
-    - 'spec/controllers/admin/statuses_controller_spec.rb'
-    - 'spec/controllers/concerns/signature_verification_spec.rb'
-
 # This cop supports unsafe autocorrection (--autocorrect-all).
 Style/HashTransformValues:
   Exclude:
@@ -2074,22 +1573,8 @@ Style/IfUnlessModifier:
 # Configuration parameters: InverseMethods, InverseBlocks.
 Style/InverseMethods:
   Exclude:
-    - 'app/controllers/concerns/signature_verification.rb'
-    - 'app/helpers/jsonld_helper.rb'
-    - 'app/lib/activitypub/activity/create.rb'
-    - 'app/lib/activitypub/activity/move.rb'
-    - 'app/lib/feed_manager.rb'
-    - 'app/lib/link_details_extractor.rb'
-    - 'app/models/concerns/attachmentable.rb'
-    - 'app/models/concerns/remotable.rb'
     - 'app/models/custom_filter.rb'
-    - 'app/models/webhook.rb'
-    - 'app/services/activitypub/process_status_update_service.rb'
-    - 'app/services/fetch_link_card_service.rb'
-    - 'app/services/search_service.rb'
     - 'app/services/update_account_service.rb'
-    - 'app/workers/web/push_notification_worker.rb'
-    - 'lib/paperclip/color_extractor.rb'
     - 'spec/controllers/activitypub/replies_controller_spec.rb'
 
 # This cop supports safe autocorrection (--autocorrect).
@@ -2110,12 +1595,10 @@ Style/MapToHash:
 # SupportedStyles: literals, strict
 Style/MutableConstant:
   Exclude:
-    - 'app/models/account.rb'
     - 'app/models/tag.rb'
     - 'app/services/delete_account_service.rb'
     - 'config/initializers/twitter_regex.rb'
     - 'lib/mastodon/migration_warning.rb'
-    - 'spec/controllers/api/base_controller_spec.rb'
 
 # This cop supports safe autocorrection (--autocorrect).
 Style/NilLambda:
@@ -2199,7 +1682,6 @@ Style/RedundantRegexpEscape:
 Style/RegexpLiteral:
   Exclude:
     - 'app/lib/link_details_extractor.rb'
-    - 'app/lib/permalink_redirector.rb'
     - 'app/lib/plain_text_formatter.rb'
     - 'app/lib/tag_manager.rb'
     - 'app/lib/text_formatter.rb'
@@ -2321,11 +1803,14 @@ Style/TrailingCommaInHashLiteral:
     - 'config/environments/test.rb'
 
 # This cop supports safe autocorrection (--autocorrect).
-# Configuration parameters: WordRegex.
+# Configuration parameters: EnforcedStyle, MinSize, WordRegex.
 # SupportedStyles: percent, brackets
 Style/WordArray:
-  EnforcedStyle: percent
-  MinSize: 6
+  Exclude:
+    - 'app/helpers/languages_helper.rb'
+    - 'config/initializers/cors.rb'
+    - 'spec/controllers/settings/imports_controller_spec.rb'
+    - 'spec/models/form/import_spec.rb'
 
 # This cop supports safe autocorrection (--autocorrect).
 # Configuration parameters: AllowHeredoc, AllowURI, URISchemes, IgnoreCopDirectives, AllowedPatterns.
diff --git a/Aptfile b/Aptfile
index 8f5bb72a25..5e033f1365 100644
--- a/Aptfile
+++ b/Aptfile
@@ -1,4 +1,5 @@
 ffmpeg
+libopenblas0-pthread
 libpq-dev
 libxdamage1
 libxfixes3
diff --git a/Dockerfile b/Dockerfile
index 9789334211..91c26d2ac0 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -41,6 +41,10 @@ RUN apt-get update && \
 
 FROM node:${NODE_VERSION}
 
+# Use those args to specify your own version flags & suffixes
+ARG MASTODON_VERSION_FLAGS=""
+ARG MASTODON_VERSION_SUFFIX=""
+
 ARG UID="991"
 ARG GID="991"
 
@@ -84,7 +88,9 @@ COPY --chown=mastodon:mastodon --from=build /opt/mastodon /opt/mastodon
 ENV RAILS_ENV="production" \
     NODE_ENV="production" \
     RAILS_SERVE_STATIC_FILES="true" \
-    BIND="0.0.0.0"
+    BIND="0.0.0.0" \
+    MASTODON_VERSION_FLAGS="${MASTODON_VERSION_FLAGS}" \
+    MASTODON_VERSION_SUFFIX="${MASTODON_VERSION_SUFFIX}"
 
 # Set the run user
 USER mastodon
diff --git a/Gemfile b/Gemfile
index 3301b83cc0..e55b21c9e0 100644
--- a/Gemfile
+++ b/Gemfile
@@ -30,10 +30,7 @@ gem 'browser'
 gem 'charlock_holmes', '~> 0.7.7'
 gem 'chewy', '~> 7.3'
 gem 'devise', '~> 4.9'
-# The below `v4.x` branch allows attr_encrypted 4.x, which is required for Rails 7.
-# Once a new gem version is pushed, we can go back to released gem and off of github branch.
-gem 'devise-two-factor', github: 'tinfoil/devise-two-factor', branch: 'v4.x'
-gem 'attr_encrypted', '~> 4.0'
+gem 'devise-two-factor', '~> 4.1'
 
 group :pam_authentication, optional: true do
   gem 'devise_pam_authenticatable2', '~> 9.2'
@@ -164,3 +161,4 @@ gem 'hcaptcha', '~> 7.1'
 gem 'cocoon', '~> 1.2'
 
 gem 'net-http', '~> 0.3.2'
+gem 'rubyzip', '~> 2.3'
diff --git a/Gemfile.lock b/Gemfile.lock
index 7cf23180e0..f22d5b3721 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -27,18 +27,6 @@ GIT
     rails-settings-cached (0.6.6)
       rails (>= 4.2.0)
 
-GIT
-  remote: https://github.com/tinfoil/devise-two-factor.git
-  revision: e685f91ce62d036259885fbe31fcb4fa930bcfcb
-  branch: v4.x
-  specs:
-    devise-two-factor (4.0.2)
-      activesupport (< 7.1)
-      attr_encrypted (>= 1.3, < 5, != 2)
-      devise (~> 4.0)
-      railties (< 7.1)
-      rotp (~> 6.0)
-
 GEM
   remote: https://rubygems.org/
   specs:
@@ -218,6 +206,12 @@ GEM
       railties (>= 4.1.0)
       responders
       warden (~> 1.2.3)
+    devise-two-factor (4.1.0)
+      activesupport (< 7.1)
+      attr_encrypted (>= 1.3, < 5, != 2)
+      devise (~> 4.0)
+      railties (< 7.1)
+      rotp (~> 6.0)
     devise_pam_authenticatable2 (9.2.0)
       devise (>= 4.0.0)
       rpam2 (~> 4.0)
@@ -354,15 +348,15 @@ GEM
     ipaddress (0.8.3)
     jmespath (1.6.2)
     json (2.6.3)
-    json-canonicalization (0.3.1)
+    json-canonicalization (0.3.2)
     json-jwt (1.15.3)
       activesupport (>= 4.2)
       aes_key_wrap
       bindata
       httpclient
-    json-ld (3.2.4)
+    json-ld (3.2.5)
       htmlentities (~> 4.3)
-      json-canonicalization (~> 0.3)
+      json-canonicalization (~> 0.3, >= 0.3.2)
       link_header (~> 0.0, >= 0.0.8)
       multi_json (~> 1.15)
       rack (>= 2.2, < 4)
@@ -492,7 +486,7 @@ GEM
     parslet (2.0.0)
     pastel (0.8.0)
       tty-color (~> 0.5)
-    pg (1.5.2)
+    pg (1.5.3)
     pghero (3.3.3)
       activerecord (>= 6)
     pkg-config (1.5.1)
@@ -626,7 +620,7 @@ GEM
     rubocop-performance (1.17.1)
       rubocop (>= 1.7.0, < 2.0)
       rubocop-ast (>= 0.4.0)
-    rubocop-rails (2.18.0)
+    rubocop-rails (2.19.1)
       activesupport (>= 4.2.0)
       rack (>= 1.1)
       rubocop (>= 1.33.0, < 2.0)
@@ -638,6 +632,7 @@ GEM
       nokogiri (>= 1.10.5)
       rexml
     ruby2_keywords (0.0.5)
+    rubyzip (2.3.2)
     rufus-scheduler (3.8.2)
       fugit (~> 1.1, >= 1.1.6)
     safety_net_attestation (0.4.0)
@@ -777,7 +772,6 @@ DEPENDENCIES
   active_model_serializers (~> 0.10)
   addressable (~> 2.8)
   annotate (~> 3.2)
-  attr_encrypted (~> 4.0)
   aws-sdk-s3 (~> 1.120)
   better_errors (~> 2.9)
   binding_of_caller (~> 1.0)
@@ -799,7 +793,7 @@ DEPENDENCIES
   concurrent-ruby
   connection_pool
   devise (~> 4.9)
-  devise-two-factor!
+  devise-two-factor (~> 4.1)
   devise_pam_authenticatable2 (~> 9.2)
   discard (~> 1.2)
   doorkeeper (~> 5.6)
@@ -879,6 +873,7 @@ DEPENDENCIES
   rubocop-rails
   rubocop-rspec
   ruby-progressbar (~> 1.13)
+  rubyzip (~> 2.3)
   sanitize (~> 6.0)
   scenic (~> 1.7)
   sidekiq (~> 6.5)
diff --git a/app/controllers/admin/domain_blocks_controller.rb b/app/controllers/admin/domain_blocks_controller.rb
index 750f5c995c..081550b762 100644
--- a/app/controllers/admin/domain_blocks_controller.rb
+++ b/app/controllers/admin/domain_blocks_controller.rb
@@ -33,7 +33,7 @@ module Admin
 
       if existing_domain_block.present? && !@domain_block.stricter_than?(existing_domain_block)
         @domain_block.save
-        flash.now[:alert] = I18n.t('admin.domain_blocks.existing_domain_block_html', name: existing_domain_block.domain, unblock_url: admin_domain_block_path(existing_domain_block)).html_safe # rubocop:disable Rails/OutputSafety
+        flash.now[:alert] = I18n.t('admin.domain_blocks.existing_domain_block_html', name: existing_domain_block.domain, unblock_url: admin_domain_block_path(existing_domain_block)).html_safe
         @domain_block.errors.delete(:domain)
         render :new
       else
diff --git a/app/controllers/api/v1/media_controller.rb b/app/controllers/api/v1/media_controller.rb
index 7485438dbf..5ea26d55bd 100644
--- a/app/controllers/api/v1/media_controller.rb
+++ b/app/controllers/api/v1/media_controller.rb
@@ -15,7 +15,8 @@ class Api::V1::MediaController < Api::BaseController
     render json: @media_attachment, serializer: REST::MediaAttachmentSerializer
   rescue Paperclip::Errors::NotIdentifiedByImageMagickError
     render json: file_type_error, status: 422
-  rescue Paperclip::Error
+  rescue Paperclip::Error => e
+    Rails.logger.error "#{e.class}: #{e.message}"
     render json: processing_error, status: 500
   end
 
diff --git a/app/controllers/api/v2/media_controller.rb b/app/controllers/api/v2/media_controller.rb
index 288f847f17..72bc694421 100644
--- a/app/controllers/api/v2/media_controller.rb
+++ b/app/controllers/api/v2/media_controller.rb
@@ -6,7 +6,8 @@ class Api::V2::MediaController < Api::V1::MediaController
     render json: @media_attachment, serializer: REST::MediaAttachmentSerializer, status: @media_attachment.not_processed? ? 202 : 200
   rescue Paperclip::Errors::NotIdentifiedByImageMagickError
     render json: file_type_error, status: 422
-  rescue Paperclip::Error
+  rescue Paperclip::Error => e
+    Rails.logger.error "#{e.class}: #{e.message}"
     render json: processing_error, status: 500
   end
 end
diff --git a/app/controllers/authorize_interactions_controller.rb b/app/controllers/authorize_interactions_controller.rb
index 97fe4a9abd..73f0f2b88d 100644
--- a/app/controllers/authorize_interactions_controller.rb
+++ b/app/controllers/authorize_interactions_controller.rb
@@ -60,7 +60,7 @@ class AuthorizeInteractionsController < ApplicationController
   end
 
   def uri_param
-    params[:uri] || params.fetch(:acct, '').gsub(/\Aacct:/, '')
+    params[:uri] || params.fetch(:acct, '').delete_prefix('acct:')
   end
 
   def set_body_classes
diff --git a/app/controllers/concerns/signature_verification.rb b/app/controllers/concerns/signature_verification.rb
index 9317259433..1d27c92c8c 100644
--- a/app/controllers/concerns/signature_verification.rb
+++ b/app/controllers/concerns/signature_verification.rb
@@ -180,14 +180,15 @@ module SignatureVerification
 
   def build_signed_string
     signed_headers.map do |signed_header|
-      if signed_header == Request::REQUEST_TARGET
+      case signed_header
+      when Request::REQUEST_TARGET
         "#{Request::REQUEST_TARGET}: #{request.method.downcase} #{request.path}"
-      elsif signed_header == '(created)'
+      when '(created)'
         raise SignatureVerificationError, 'Invalid pseudo-header (created) for rsa-sha256' unless signature_algorithm == 'hs2019'
         raise SignatureVerificationError, 'Pseudo-header (created) used but corresponding argument missing' if signature_params['created'].blank?
 
         "(created): #{signature_params['created']}"
-      elsif signed_header == '(expires)'
+      when '(expires)'
         raise SignatureVerificationError, 'Invalid pseudo-header (expires) for rsa-sha256' unless signature_algorithm == 'hs2019'
         raise SignatureVerificationError, 'Pseudo-header (expires) used but corresponding argument missing' if signature_params['expires'].blank?
 
@@ -244,7 +245,7 @@ module SignatureVerification
     end
 
     if key_id.start_with?('acct:')
-      stoplight_wrap_request { ResolveAccountService.new.call(key_id.gsub(/\Aacct:/, ''), suppress_errors: false) }
+      stoplight_wrap_request { ResolveAccountService.new.call(key_id.delete_prefix('acct:'), suppress_errors: false) }
     elsif !ActivityPub::TagManager.instance.local_uri?(key_id)
       account   = ActivityPub::TagManager.instance.uri_to_actor(key_id)
       account ||= stoplight_wrap_request { ActivityPub::FetchRemoteKeyService.new.call(key_id, id: false, suppress_errors: false) }
diff --git a/app/controllers/intents_controller.rb b/app/controllers/intents_controller.rb
index ca89fc7fe6..ea024e30e6 100644
--- a/app/controllers/intents_controller.rb
+++ b/app/controllers/intents_controller.rb
@@ -9,7 +9,7 @@ class IntentsController < ApplicationController
     if uri.scheme == 'web+mastodon'
       case uri.host
       when 'follow'
-        return redirect_to authorize_interaction_path(uri: uri.query_values['uri'].gsub(/\Aacct:/, ''))
+        return redirect_to authorize_interaction_path(uri: uri.query_values['uri'].delete_prefix('acct:'))
       when 'share'
         return redirect_to share_path(text: uri.query_values['text'])
       end
diff --git a/app/controllers/media_proxy_controller.rb b/app/controllers/media_proxy_controller.rb
index 1b5486c122..8d480d704e 100644
--- a/app/controllers/media_proxy_controller.rb
+++ b/app/controllers/media_proxy_controller.rb
@@ -16,7 +16,7 @@ class MediaProxyController < ApplicationController
   rescue_from HTTP::TimeoutError, HTTP::ConnectionError, OpenSSL::SSL::SSLError, with: :internal_server_error
 
   def show
-    with_lock("media_download:#{params[:id]}") do
+    with_redis_lock("media_download:#{params[:id]}") do
       @media_attachment = MediaAttachment.remote.attached.find(params[:id])
       authorize @media_attachment.status, :show?
       redownload! if @media_attachment.needs_redownload? && !reject_media?
diff --git a/app/controllers/settings/exports_controller.rb b/app/controllers/settings/exports_controller.rb
index deaa7940eb..46a340aeb3 100644
--- a/app/controllers/settings/exports_controller.rb
+++ b/app/controllers/settings/exports_controller.rb
@@ -15,7 +15,7 @@ class Settings::ExportsController < Settings::BaseController
   def create
     backup = nil
 
-    with_lock("backup:#{current_user.id}") do
+    with_redis_lock("backup:#{current_user.id}") do
       authorize :backup, :create?
       backup = current_user.backups.create!
     end
diff --git a/app/controllers/settings/imports_controller.rb b/app/controllers/settings/imports_controller.rb
index d4516526ee..bdbf8796fe 100644
--- a/app/controllers/settings/imports_controller.rb
+++ b/app/controllers/settings/imports_controller.rb
@@ -1,31 +1,97 @@
 # frozen_string_literal: true
 
-class Settings::ImportsController < Settings::BaseController
-  before_action :set_account
+require 'csv'
 
-  def show
-    @import = Import.new
+class Settings::ImportsController < Settings::BaseController
+  before_action :set_bulk_import, only: [:show, :confirm, :destroy]
+  before_action :set_recent_imports, only: [:index]
+
+  TYPE_TO_FILENAME_MAP = {
+    following: 'following_accounts_failures.csv',
+    blocking: 'blocked_accounts_failures.csv',
+    muting: 'muted_accounts_failures.csv',
+    domain_blocking: 'blocked_domains_failures.csv',
+    bookmarks: 'bookmarks_failures.csv',
+  }.freeze
+
+  TYPE_TO_HEADERS_MAP = {
+    following: ['Account address', 'Show boosts', 'Notify on new posts', 'Languages'],
+    blocking: false,
+    muting: ['Account address', 'Hide notifications'],
+    domain_blocking: false,
+    bookmarks: false,
+  }.freeze
+
+  def index
+    @import = Form::Import.new(current_account: current_account)
+  end
+
+  def show; end
+
+  def failures
+    @bulk_import = current_account.bulk_imports.where(state: :finished).find(params[:id])
+
+    respond_to do |format|
+      format.csv do
+        filename = TYPE_TO_FILENAME_MAP[@bulk_import.type.to_sym]
+        headers = TYPE_TO_HEADERS_MAP[@bulk_import.type.to_sym]
+
+        export_data = CSV.generate(headers: headers, write_headers: true) do |csv|
+          @bulk_import.rows.find_each do |row|
+            case @bulk_import.type.to_sym
+            when :following
+              csv << [row.data['acct'], row.data.fetch('show_reblogs', true), row.data.fetch('notify', false), row.data['languages']&.join(', ')]
+            when :blocking
+              csv << [row.data['acct']]
+            when :muting
+              csv << [row.data['acct'], row.data.fetch('hide_notifications', true)]
+            when :domain_blocking
+              csv << [row.data['domain']]
+            when :bookmarks
+              csv << [row.data['uri']]
+            end
+          end
+        end
+
+        send_data export_data, filename: filename
+      end
+    end
+  end
+
+  def confirm
+    @bulk_import.update!(state: :scheduled)
+    BulkImportWorker.perform_async(@bulk_import.id)
+    redirect_to settings_imports_path, notice: I18n.t('imports.success')
   end
 
   def create
-    @import = Import.new(import_params)
-    @import.account = @account
+    @import = Form::Import.new(import_params.merge(current_account: current_account))
 
     if @import.save
-      ImportWorker.perform_async(@import.id)
-      redirect_to settings_import_path, notice: I18n.t('imports.success')
+      redirect_to settings_import_path(@import.bulk_import.id)
     else
-      render :show
+      # We need to set recent imports as we are displaying the index again
+      set_recent_imports
+      render :index
     end
   end
 
+  def destroy
+    @bulk_import.destroy!
+    redirect_to settings_imports_path
+  end
+
   private
 
-  def set_account
-    @account = current_user.account
+  def import_params
+    params.require(:form_import).permit(:data, :type, :mode)
   end
 
-  def import_params
-    params.require(:import).permit(:data, :type, :mode)
+  def set_bulk_import
+    @bulk_import = current_account.bulk_imports.where(state: :unconfirmed).find(params[:id])
+  end
+
+  def set_recent_imports
+    @recent_imports = current_account.bulk_imports.reorder(id: :desc).limit(10)
   end
 end
diff --git a/app/controllers/settings/preferences/appearance_controller.rb b/app/controllers/settings/preferences/appearance_controller.rb
index 80ea57bd2d..4d7d12bb7f 100644
--- a/app/controllers/settings/preferences/appearance_controller.rb
+++ b/app/controllers/settings/preferences/appearance_controller.rb
@@ -1,6 +1,6 @@
 # frozen_string_literal: true
 
-class Settings::Preferences::AppearanceController < Settings::PreferencesController
+class Settings::Preferences::AppearanceController < Settings::Preferences::BaseController
   private
 
   def after_update_redirect_path
diff --git a/app/controllers/settings/preferences_controller.rb b/app/controllers/settings/preferences/base_controller.rb
similarity index 81%
rename from app/controllers/settings/preferences_controller.rb
rename to app/controllers/settings/preferences/base_controller.rb
index 281deb64d1..faf778a7e5 100644
--- a/app/controllers/settings/preferences_controller.rb
+++ b/app/controllers/settings/preferences/base_controller.rb
@@ -1,6 +1,6 @@
 # frozen_string_literal: true
 
-class Settings::PreferencesController < Settings::BaseController
+class Settings::Preferences::BaseController < Settings::BaseController
   def show; end
 
   def update
@@ -15,7 +15,7 @@ class Settings::PreferencesController < Settings::BaseController
   private
 
   def after_update_redirect_path
-    settings_preferences_path
+    raise 'Override in controller'
   end
 
   def user_params
diff --git a/app/controllers/settings/preferences/notifications_controller.rb b/app/controllers/settings/preferences/notifications_controller.rb
index a16ae6a672..66d6c9a2f7 100644
--- a/app/controllers/settings/preferences/notifications_controller.rb
+++ b/app/controllers/settings/preferences/notifications_controller.rb
@@ -1,6 +1,6 @@
 # frozen_string_literal: true
 
-class Settings::Preferences::NotificationsController < Settings::PreferencesController
+class Settings::Preferences::NotificationsController < Settings::Preferences::BaseController
   private
 
   def after_update_redirect_path
diff --git a/app/controllers/settings/preferences/other_controller.rb b/app/controllers/settings/preferences/other_controller.rb
index 07eb89a762..a19fbf5c48 100644
--- a/app/controllers/settings/preferences/other_controller.rb
+++ b/app/controllers/settings/preferences/other_controller.rb
@@ -1,6 +1,6 @@
 # frozen_string_literal: true
 
-class Settings::Preferences::OtherController < Settings::PreferencesController
+class Settings::Preferences::OtherController < Settings::Preferences::BaseController
   private
 
   def after_update_redirect_path
diff --git a/app/controllers/well_known/webfinger_controller.rb b/app/controllers/well_known/webfinger_controller.rb
index a06253f456..0d897e8e24 100644
--- a/app/controllers/well_known/webfinger_controller.rb
+++ b/app/controllers/well_known/webfinger_controller.rb
@@ -18,7 +18,14 @@ module WellKnown
     private
 
     def set_account
-      @account = Account.find_local!(username_from_resource)
+      username = username_from_resource
+      @account = begin
+        if username == Rails.configuration.x.local_domain
+          Account.representative
+        else
+          Account.find_local!(username)
+        end
+      end
     end
 
     def username_from_resource
diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb
index 879752cf76..3192c7ab56 100644
--- a/app/helpers/application_helper.rb
+++ b/app/helpers/application_helper.rb
@@ -32,10 +32,6 @@ module ApplicationHelper
     paths.any? { |path| current_page?(path) } ? 'active' : ''
   end
 
-  def active_link_to(label, path, **options)
-    link_to label, path, options.merge(class: active_nav_class(path))
-  end
-
   def show_landing_strip?
     !user_signed_in? && !single_user_mode?
   end
@@ -147,7 +143,7 @@ module ApplicationHelper
     if prefers_autoplay?
       image_tag(custom_emoji.image.url, class: 'emojione', alt: ":#{custom_emoji.shortcode}:")
     else
-      image_tag(custom_emoji.image.url(:static), class: 'emojione custom-emoji', alt: ":#{custom_emoji.shortcode}", 'data-original' => full_asset_url(custom_emoji.image.url), 'data-static' => full_asset_url(custom_emoji.image.url(:static)))
+      image_tag(custom_emoji.image.url(:static), :class => 'emojione custom-emoji', :alt => ":#{custom_emoji.shortcode}", 'data-original' => full_asset_url(custom_emoji.image.url), 'data-static' => full_asset_url(custom_emoji.image.url(:static)))
     end
   end
 
@@ -174,11 +170,11 @@ module ApplicationHelper
   end
 
   def storage_host
-    "https://#{ENV['S3_ALIAS_HOST'].presence || ENV['S3_CLOUDFRONT_HOST']}"
+    URI::HTTPS.build(host: storage_host_name).to_s
   end
 
   def storage_host?
-    ENV['S3_ALIAS_HOST'].present? || ENV['S3_CLOUDFRONT_HOST'].present?
+    storage_host_name.present?
   end
 
   def quote_wrap(text, line_width: 80, break_sequence: "\n")
@@ -236,4 +232,10 @@ module ApplicationHelper
   def prerender_custom_emojis(html, custom_emojis, other_options = {})
     EmojiFormatter.new(html, custom_emojis, other_options.merge(animate: prefers_autoplay?)).to_s
   end
+
+  private
+
+  def storage_host_name
+    ENV.fetch('S3_ALIAS_HOST', nil) || ENV.fetch('S3_CLOUDFRONT_HOST', nil)
+  end
 end
diff --git a/app/javascript/flavours/glitch/actions/app.js b/app/javascript/flavours/glitch/actions/app.js
deleted file mode 100644
index de2d93e292..0000000000
--- a/app/javascript/flavours/glitch/actions/app.js
+++ /dev/null
@@ -1,6 +0,0 @@
-export const APP_LAYOUT_CHANGE = 'APP_LAYOUT_CHANGE';
-
-export const changeLayout = layout => ({
-  type: APP_LAYOUT_CHANGE,
-  layout,
-});
diff --git a/app/javascript/flavours/glitch/actions/app.ts b/app/javascript/flavours/glitch/actions/app.ts
new file mode 100644
index 0000000000..1fc4416090
--- /dev/null
+++ b/app/javascript/flavours/glitch/actions/app.ts
@@ -0,0 +1,7 @@
+import { createAction } from '@reduxjs/toolkit';
+
+type ChangeLayoutPayload = {
+  layout: 'mobile' | 'single-column' | 'multi-column';
+};
+export const changeLayout =
+  createAction<ChangeLayoutPayload>('APP_LAYOUT_CHANGE');
diff --git a/app/javascript/flavours/glitch/blurhash.js b/app/javascript/flavours/glitch/blurhash.ts
similarity index 87%
rename from app/javascript/flavours/glitch/blurhash.js
rename to app/javascript/flavours/glitch/blurhash.ts
index 5adcc3e770..cb1c3b2c82 100644
--- a/app/javascript/flavours/glitch/blurhash.js
+++ b/app/javascript/flavours/glitch/blurhash.ts
@@ -84,7 +84,7 @@ const DIGIT_CHARACTERS = [
   '~',
 ];
 
-export const decode83 = (str) => {
+export const decode83 = (str: string) => {
   let value = 0;
   let c, digit;
 
@@ -97,13 +97,13 @@ export const decode83 = (str) => {
   return value;
 };
 
-export const intToRGB = int => ({
+export const intToRGB = (int: number) => ({
   r: Math.max(0, (int >> 16)),
   g: Math.max(0, (int >> 8) & 255),
   b: Math.max(0, (int & 255)),
 });
 
-export const getAverageFromBlurhash = blurhash => {
+export const getAverageFromBlurhash = (blurhash: string) => {
   if (!blurhash) {
     return null;
   }
diff --git a/app/javascript/mastodon/compare_id.js b/app/javascript/flavours/glitch/compare_id.ts
similarity index 72%
rename from app/javascript/mastodon/compare_id.js
rename to app/javascript/flavours/glitch/compare_id.ts
index d2bd74f447..ae4ac6f897 100644
--- a/app/javascript/mastodon/compare_id.js
+++ b/app/javascript/flavours/glitch/compare_id.ts
@@ -1,4 +1,4 @@
-export default function compareId (id1, id2) {
+export default function compareId (id1: string, id2: string) {
   if (id1 === id2) {
     return 0;
   }
diff --git a/app/javascript/flavours/glitch/components/avatar.tsx b/app/javascript/flavours/glitch/components/avatar.tsx
index a21ffc9888..d6a9621462 100644
--- a/app/javascript/flavours/glitch/components/avatar.tsx
+++ b/app/javascript/flavours/glitch/components/avatar.tsx
@@ -2,7 +2,7 @@ import * as React from 'react';
 import classNames from 'classnames';
 import { autoPlayGif } from 'flavours/glitch/initial_state';
 import { useHovering } from 'hooks/useHovering';
-import type { Account } from 'types/resources';
+import type { Account } from 'flavours/glitch/types/resources';
 
 type Props = {
   account: Account | undefined;
diff --git a/app/javascript/flavours/glitch/components/blurhash.jsx b/app/javascript/flavours/glitch/components/blurhash.jsx
deleted file mode 100644
index f5c58e04ef..0000000000
--- a/app/javascript/flavours/glitch/components/blurhash.jsx
+++ /dev/null
@@ -1,65 +0,0 @@
-// @ts-check
-
-import { decode } from 'blurhash';
-import React, { useRef, useEffect } from 'react';
-import PropTypes from 'prop-types';
-
-/**
- * @typedef BlurhashPropsBase
- * @property {string?} hash Hash to render
- * @property {number} width
- * Width of the blurred region in pixels. Defaults to 32
- * @property {number} [height]
- * Height of the blurred region in pixels. Defaults to width
- * @property {boolean} [dummy]
- * Whether dummy mode is enabled. If enabled, nothing is rendered
- * and canvas left untouched
- */
-
-/** @typedef {JSX.IntrinsicElements['canvas'] & BlurhashPropsBase} BlurhashProps */
-
-/**
- * Component that is used to render blurred of blurhash string
- * @param {BlurhashProps} param1 Props of the component
- * @returns {JSX.Element} Canvas which will render blurred region element to embed
- */
-function Blurhash({
-  hash,
-  width = 32,
-  height = width,
-  dummy = false,
-  ...canvasProps
-}) {
-  const canvasRef = /** @type {import('react').MutableRefObject<HTMLCanvasElement>} */ (useRef());
-
-  useEffect(() => {
-    const { current: canvas } = canvasRef;
-    canvas.width = canvas.width; // resets canvas
-
-    if (dummy || !hash) return;
-
-    try {
-      const pixels = decode(hash, width, height);
-      const ctx = canvas.getContext('2d');
-      const imageData = new ImageData(pixels, width, height);
-
-      // @ts-expect-error
-      ctx.putImageData(imageData, 0, 0);
-    } catch (err) {
-      console.error('Blurhash decoding failure', { err, hash });
-    }
-  }, [dummy, hash, width, height]);
-
-  return (
-    <canvas {...canvasProps} ref={canvasRef} width={width} height={height} />
-  );
-}
-
-Blurhash.propTypes = {
-  hash: PropTypes.string.isRequired,
-  width: PropTypes.number,
-  height: PropTypes.number,
-  dummy: PropTypes.bool,
-};
-
-export default React.memo(Blurhash);
diff --git a/app/javascript/flavours/glitch/components/blurhash.tsx b/app/javascript/flavours/glitch/components/blurhash.tsx
new file mode 100644
index 0000000000..6fec6e1ef7
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/blurhash.tsx
@@ -0,0 +1,45 @@
+import { decode } from 'blurhash';
+import React, { useRef, useEffect } from 'react';
+
+type Props = {
+  hash: string;
+  width?: number;
+  height?: number;
+  dummy?: boolean; // Whether dummy mode is enabled. If enabled, nothing is rendered and canvas left untouched
+  children?: never;
+  [key: string]: any;
+}
+function Blurhash({
+  hash,
+  width = 32,
+  height = width,
+  dummy = false,
+  ...canvasProps
+}: Props) {
+  const canvasRef = useRef<HTMLCanvasElement>(null);
+
+  useEffect(() => {
+    // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+    const canvas = canvasRef.current!;
+    // eslint-disable-next-line no-self-assign
+    canvas.width = canvas.width; // resets canvas
+
+    if (dummy || !hash) return;
+
+    try {
+      const pixels = decode(hash, width, height);
+      const ctx = canvas.getContext('2d');
+      const imageData = new ImageData(pixels, width, height);
+
+      ctx?.putImageData(imageData, 0, 0);
+    } catch (err) {
+      console.error('Blurhash decoding failure', { err, hash });
+    }
+  }, [dummy, hash, width, height]);
+
+  return (
+    <canvas {...canvasProps} ref={canvasRef} width={width} height={height} />
+  );
+}
+
+export default React.memo(Blurhash);
diff --git a/app/javascript/flavours/glitch/components/icon_button.jsx b/app/javascript/flavours/glitch/components/icon_button.tsx
similarity index 69%
rename from app/javascript/flavours/glitch/components/icon_button.jsx
rename to app/javascript/flavours/glitch/components/icon_button.tsx
index 93640dd0f2..2bda4ddf34 100644
--- a/app/javascript/flavours/glitch/components/icon_button.jsx
+++ b/app/javascript/flavours/glitch/components/icon_button.tsx
@@ -1,35 +1,37 @@
 import React from 'react';
-import PropTypes from 'prop-types';
 import classNames from 'classnames';
-import Icon from 'flavours/glitch/components/icon';
-import AnimatedNumber from 'flavours/glitch/components/animated_number';
+import { Icon } from './icon';
+import { AnimatedNumber } from './animated_number';
 
-export default class IconButton extends React.PureComponent {
-
-  static propTypes = {
-    className: PropTypes.string,
-    title: PropTypes.string.isRequired,
-    icon: PropTypes.string.isRequired,
-    onClick: PropTypes.func,
-    onMouseDown: PropTypes.func,
-    onKeyDown: PropTypes.func,
-    onKeyPress: PropTypes.func,
-    size: PropTypes.number,
-    active: PropTypes.bool,
-    expanded: PropTypes.bool,
-    style: PropTypes.object,
-    activeStyle: PropTypes.object,
-    disabled: PropTypes.bool,
-    inverted: PropTypes.bool,
-    animate: PropTypes.bool,
-    overlay: PropTypes.bool,
-    tabIndex: PropTypes.number,
-    label: PropTypes.string,
-    counter: PropTypes.number,
-    obfuscateCount: PropTypes.bool,
-    href: PropTypes.string,
-    ariaHidden: PropTypes.bool,
-  };
+type Props = {
+  className?: string;
+  title: string;
+  icon: string;
+  onClick?: React.MouseEventHandler<HTMLButtonElement>;
+  onMouseDown?: React.MouseEventHandler<HTMLButtonElement>;
+  onKeyDown?: React.KeyboardEventHandler<HTMLButtonElement>;
+  onKeyPress?: React.KeyboardEventHandler<HTMLButtonElement>;
+  size: number;
+  active: boolean;
+  expanded?: boolean;
+  style?: React.CSSProperties;
+  activeStyle?: React.CSSProperties;
+  disabled: boolean;
+  inverted?: boolean;
+  animate: boolean;
+  overlay: boolean;
+  tabIndex: number;
+  label: string;
+  counter?: number;
+  obfuscateCount?: boolean;
+  href?: string;
+  ariaHidden: boolean;
+}
+type States = {
+  activate: boolean,
+  deactivate: boolean,
+}
+export default class IconButton extends React.PureComponent<Props, States> {
 
   static defaultProps = {
     size: 18,
@@ -46,7 +48,7 @@ export default class IconButton extends React.PureComponent {
     deactivate: false,
   };
 
-  componentWillReceiveProps (nextProps) {
+  UNSAFE_componentWillReceiveProps (nextProps: Props) {
     if (!nextProps.animate) return;
 
     if (this.props.active && !nextProps.active) {
@@ -56,27 +58,27 @@ export default class IconButton extends React.PureComponent {
     }
   }
 
-  handleClick = (e) =>  {
+  handleClick: React.MouseEventHandler<HTMLButtonElement> = (e) =>  {
     e.preventDefault();
 
-    if (!this.props.disabled) {
+    if (!this.props.disabled && this.props.onClick != null) {
       this.props.onClick(e);
     }
   };
 
-  handleKeyPress = (e) => {
+  handleKeyPress: React.KeyboardEventHandler<HTMLButtonElement> = (e) => {
     if (this.props.onKeyPress && !this.props.disabled) {
       this.props.onKeyPress(e);
     }
   };
 
-  handleMouseDown = (e) => {
+  handleMouseDown: React.MouseEventHandler<HTMLButtonElement> = (e) => {
     if (!this.props.disabled && this.props.onMouseDown) {
       this.props.onMouseDown(e);
     }
   };
 
-  handleKeyDown = (e) => {
+  handleKeyDown: React.KeyboardEventHandler<HTMLButtonElement> = (e) => {
     if (!this.props.disabled && this.props.onKeyDown) {
       this.props.onKeyDown(e);
     }
@@ -89,7 +91,7 @@ export default class IconButton extends React.PureComponent {
       containerSize = `${this.props.size * 1.28571429}px`;
     }
 
-    let style = {
+    const style = {
       fontSize: `${this.props.size}px`,
       height: containerSize,
       lineHeight: `${this.props.size}px`,
@@ -144,7 +146,7 @@ export default class IconButton extends React.PureComponent {
       </React.Fragment>
     );
 
-    if (href && !this.prop) {
+    if (href != null) {
       contents = (
         <a href={href} target='_blank' rel='noopener noreferrer'>
           {contents}
diff --git a/app/javascript/flavours/glitch/components/media_gallery.jsx b/app/javascript/flavours/glitch/components/media_gallery.jsx
index 4c86341c9b..9bbde3b5e9 100644
--- a/app/javascript/flavours/glitch/components/media_gallery.jsx
+++ b/app/javascript/flavours/glitch/components/media_gallery.jsx
@@ -101,12 +101,10 @@ class Item extends React.PureComponent {
   render () {
     const { attachment, lang, index, size, standalone, letterbox, displayWidth, visible } = this.props;
 
+    let badges = [], thumbnail;
+
     let width  = 50;
     let height = 100;
-    let top    = 'auto';
-    let left   = 'auto';
-    let bottom = 'auto';
-    let right  = 'auto';
 
     if (size === 1) {
       width = 100;
@@ -116,45 +114,13 @@ class Item extends React.PureComponent {
       height = 50;
     }
 
-    if (size === 2) {
-      if (index === 0) {
-        right = '2px';
-      } else {
-        left = '2px';
-      }
-    } else if (size === 3) {
-      if (index === 0) {
-        right = '2px';
-      } else if (index > 0) {
-        left = '2px';
-      }
-
-      if (index === 1) {
-        bottom = '2px';
-      } else if (index > 1) {
-        top = '2px';
-      }
-    } else if (size === 4) {
-      if (index === 0 || index === 2) {
-        right = '2px';
-      }
-
-      if (index === 1 || index === 3) {
-        left = '2px';
-      }
-
-      if (index < 2) {
-        bottom = '2px';
-      } else {
-        top = '2px';
-      }
+    if (attachment.get('description')?.length > 0) {
+      badges.push(<span key='alt' className='media-gallery__gifv__label'>ALT</span>);
     }
 
-    let thumbnail = '';
-
     if (attachment.get('type') === 'unknown') {
       return (
-        <div className={classNames('media-gallery__item', { standalone })} key={attachment.get('id')} style={{ left: left, top: top, right: right, bottom: bottom, width: `${width}%`, height: `${height}%` }}>
+        <div className={classNames('media-gallery__item', { standalone, 'media-gallery__item--tall': height === 100, 'media-gallery__item--wide': width === 100 })} key={attachment.get('id')}>
           <a className='media-gallery__item-thumbnail' href={attachment.get('remote_url') || attachment.get('url')} style={{ cursor: 'pointer' }} title={attachment.get('description')} lang={lang} target='_blank' rel='noopener noreferrer'>
             <Blurhash
               hash={attachment.get('blurhash')}
@@ -205,6 +171,8 @@ class Item extends React.PureComponent {
     } else if (attachment.get('type') === 'gifv') {
       const autoPlay = this.getAutoPlay();
 
+      badges.push(<span key='gif' className='media-gallery__gifv__label'>GIF</span>);
+
       thumbnail = (
         <div className={classNames('media-gallery__gifv', { autoplay: autoPlay })}>
           <video
@@ -222,14 +190,12 @@ class Item extends React.PureComponent {
             loop
             muted
           />
-
-          <span className='media-gallery__gifv__label'>GIF</span>
         </div>
       );
     }
 
     return (
-      <div className={classNames('media-gallery__item', { standalone, letterbox })} key={attachment.get('id')} style={{ left: left, top: top, right: right, bottom: bottom, width: `${width}%`, height: `${height}%` }}>
+      <div className={classNames('media-gallery__item', { standalone, letterbox, 'media-gallery__item--tall': height === 100, 'media-gallery__item--wide': width === 100 })} key={attachment.get('id')}>
         <Blurhash
           hash={attachment.get('blurhash')}
           dummy={!useBlurhash}
@@ -237,7 +203,14 @@ class Item extends React.PureComponent {
             'media-gallery__preview--hidden': visible && this.state.loaded,
           })}
         />
+
         {visible && thumbnail}
+
+        {badges && (
+          <div className='media-gallery__item__badges'>
+            {badges}
+          </div>
+        )}
       </div>
     );
   }
@@ -358,12 +331,10 @@ class MediaGallery extends React.PureComponent {
 
     const computedClass = classNames('media-gallery', { 'full-width': fullwidth });
 
-    if (this.isStandaloneEligible() && width) {
-      style.height = width / this.props.media.getIn([0, 'meta', 'small', 'aspect']);
-    } else if (width) {
-      style.height = width / (16/9);
+    if (this.isStandaloneEligible()) { // TODO: cropImages setting
+      style.aspectRatio = `${this.props.media.getIn([0, 'meta', 'small', 'aspect'])}`;
     } else {
-      return (<div className={computedClass} ref={this.handleRef} />);
+      style.aspectRatio = '16 / 9';
     }
 
     if (this.isStandaloneEligible()) {
diff --git a/app/javascript/flavours/glitch/components/picture_in_picture_placeholder.jsx b/app/javascript/flavours/glitch/components/picture_in_picture_placeholder.jsx
index 9d1b7f55a2..7e02556749 100644
--- a/app/javascript/flavours/glitch/components/picture_in_picture_placeholder.jsx
+++ b/app/javascript/flavours/glitch/components/picture_in_picture_placeholder.jsx
@@ -3,62 +3,22 @@ import PropTypes from 'prop-types';
 import Icon from 'flavours/glitch/components/icon';
 import { removePictureInPicture } from 'flavours/glitch/actions/picture_in_picture';
 import { connect } from 'react-redux';
-import { debounce } from 'lodash';
 import { FormattedMessage } from 'react-intl';
 
 class PictureInPicturePlaceholder extends React.PureComponent {
 
   static propTypes = {
-    width: PropTypes.number,
     dispatch: PropTypes.func.isRequired,
   };
 
-  state = {
-    width: this.props.width,
-    height: this.props.width && (this.props.width / (16/9)),
-  };
-
   handleClick = () => {
     const { dispatch } = this.props;
     dispatch(removePictureInPicture());
   };
 
-  setRef = c => {
-    this.node = c;
-
-    if (this.node) {
-      this._setDimensions();
-    }
-  };
-
-  _setDimensions () {
-    const width  = this.node.offsetWidth;
-    const height = width / (16/9);
-
-    this.setState({ width, height });
-  }
-
-  componentDidMount () {
-    window.addEventListener('resize', this.handleResize, { passive: true });
-  }
-
-  componentWillUnmount () {
-    window.removeEventListener('resize', this.handleResize);
-  }
-
-  handleResize = debounce(() => {
-    if (this.node) {
-      this._setDimensions();
-    }
-  }, 250, {
-    trailing: true,
-  });
-
   render () {
-    const { height } = this.state;
-
     return (
-      <div ref={this.setRef} className='picture-in-picture-placeholder' style={{ height }} role='button' tabIndex={0} onClick={this.handleClick}>
+      <div className='picture-in-picture-placeholder' role='button' tabIndex={0} onClick={this.handleClick}>
         <Icon id='window-restore' />
         <FormattedMessage id='picture_in_picture.restore' defaultMessage='Put it back' />
       </div>
diff --git a/app/javascript/flavours/glitch/components/status.jsx b/app/javascript/flavours/glitch/components/status.jsx
index a5843a4e54..25eac0d452 100644
--- a/app/javascript/flavours/glitch/components/status.jsx
+++ b/app/javascript/flavours/glitch/components/status.jsx
@@ -624,7 +624,7 @@ class Status extends ImmutablePureComponent {
     attachments = status.get('media_attachments');
 
     if (pictureInPicture.get('inUse')) {
-      media.push(<PictureInPicturePlaceholder width={this.props.cachedMediaWidth} />);
+      media.push(<PictureInPicturePlaceholder />);
       mediaIcons.push('video-camera');
     } else if (attachments.size > 0) {
       if (muted || attachments.some(item => item.get('type') === 'unknown')) {
@@ -680,8 +680,6 @@ class Status extends ImmutablePureComponent {
               fullwidth={!rootId && settings.getIn(['media', 'fullwidth'])}
               preventPlayback={isCollapsed || !isExpanded}
               onOpenVideo={this.handleOpenVideo}
-              width={this.props.cachedMediaWidth}
-              cacheWidth={this.props.cacheMediaWidth}
               deployPictureInPicture={pictureInPicture.get('available') ? this.handleDeployPictureInPicture : undefined}
               visible={this.state.showMedia}
               onToggleVisibility={this.handleToggleMediaVisibility}
@@ -721,8 +719,6 @@ class Status extends ImmutablePureComponent {
           onOpenMedia={this.handleOpenMedia}
           card={status.get('card')}
           compact
-          cacheWidth={this.props.cacheMediaWidth}
-          defaultWidth={this.props.cachedMediaWidth}
           sensitive={status.get('sensitive')}
         />,
       );
diff --git a/app/javascript/flavours/glitch/containers/compose_container.jsx b/app/javascript/flavours/glitch/containers/compose_container.jsx
index 1e49b89a0b..dada4cfed7 100644
--- a/app/javascript/flavours/glitch/containers/compose_container.jsx
+++ b/app/javascript/flavours/glitch/containers/compose_container.jsx
@@ -1,7 +1,7 @@
 import React from 'react';
 import { Provider } from 'react-redux';
 import PropTypes from 'prop-types';
-import configureStore from 'flavours/glitch/store/configureStore';
+import { store } from 'flavours/glitch/store/configureStore';
 import { hydrateStore } from 'flavours/glitch/actions/store';
 import { IntlProvider, addLocaleData } from 'react-intl';
 import { getLocale } from 'mastodon/locales';
@@ -12,8 +12,6 @@ import { fetchCustomEmojis } from 'flavours/glitch/actions/custom_emojis';
 const { localeData, messages } = getLocale();
 addLocaleData(localeData);
 
-const store = configureStore();
-
 if (initialState) {
   store.dispatch(hydrateStore(initialState));
 }
diff --git a/app/javascript/flavours/glitch/containers/mastodon.jsx b/app/javascript/flavours/glitch/containers/mastodon.jsx
index dd7623a813..aca7f4dc59 100644
--- a/app/javascript/flavours/glitch/containers/mastodon.jsx
+++ b/app/javascript/flavours/glitch/containers/mastodon.jsx
@@ -5,7 +5,7 @@ import { IntlProvider, addLocaleData } from 'react-intl';
 import { Provider as ReduxProvider } from 'react-redux';
 import { BrowserRouter, Route } from 'react-router-dom';
 import { ScrollContext } from 'react-router-scroll-4';
-import configureStore from 'flavours/glitch/store/configureStore';
+import { store } from 'flavours/glitch/store/configureStore';
 import UI from 'flavours/glitch/features/ui';
 import { fetchCustomEmojis } from 'flavours/glitch/actions/custom_emojis';
 import { hydrateStore } from 'flavours/glitch/actions/store';
@@ -20,7 +20,6 @@ addLocaleData(localeData);
 
 const title = process.env.NODE_ENV === 'production' ? siteTitle : `${siteTitle} (Dev)`;
 
-export const store = configureStore();
 const hydrateAction = hydrateStore(initialState);
 store.dispatch(hydrateAction);
 
diff --git a/app/javascript/flavours/glitch/features/audio/index.jsx b/app/javascript/flavours/glitch/features/audio/index.jsx
index f5bffb9738..c3e4ed5d4f 100644
--- a/app/javascript/flavours/glitch/features/audio/index.jsx
+++ b/app/javascript/flavours/glitch/features/audio/index.jsx
@@ -390,7 +390,7 @@ class Audio extends React.PureComponent {
   }
 
   _getRadius () {
-    return parseInt(((this.state.height || this.props.height) - (PADDING * this._getScaleCoefficient()) * 2) / 2);
+    return parseInt((this.state.height || this.props.height) / 2 - PADDING * this._getScaleCoefficient());
   }
 
   _getScaleCoefficient () {
@@ -402,7 +402,7 @@ class Audio extends React.PureComponent {
   }
 
   _getCY() {
-    return Math.floor(this._getRadius() + (PADDING * this._getScaleCoefficient()));
+    return Math.floor((this.state.height || this.props.height) / 2);
   }
 
   _getAccentColor () {
@@ -476,7 +476,7 @@ class Audio extends React.PureComponent {
     }
 
     return (
-      <div className={classNames('audio-player', { editable, inactive: !revealed })} ref={this.setPlayerRef} style={{ backgroundColor: this._getBackgroundColor(), color: this._getForegroundColor(), height: this.props.fullscreen ? '100%' : (this.state.height || this.props.height) }} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave} tabIndex={0} onKeyDown={this.handleKeyDown}>
+      <div className={classNames('audio-player', { editable, inactive: !revealed })} ref={this.setPlayerRef} style={{ backgroundColor: this._getBackgroundColor(), color: this._getForegroundColor(), aspectRatio: '16 / 9' }} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave} tabIndex={0} onKeyDown={this.handleKeyDown}>
 
         <Blurhash
           hash={blurhash}
@@ -521,9 +521,16 @@ class Audio extends React.PureComponent {
         {(revealed || editable) && <img
           src={this.props.poster}
           alt=''
-          width={(this._getRadius() - TICK_SIZE) * 2}
-          height={(this._getRadius() - TICK_SIZE) * 2}
-          style={{ position: 'absolute', left: this._getCX(), top: this._getCY(), transform: 'translate(-50%, -50%)', borderRadius: '50%', pointerEvents: 'none' }}
+          style={{
+            position: 'absolute',
+            left: '50%',
+            top: '50%',
+            height: `calc(${(100 - 2 * 100 * PADDING / 982)}% - ${TICK_SIZE * 2}px)`,
+            aspectRatio: '1',
+            transform: 'translate(-50%, -50%)',
+            borderRadius: '50%',
+            pointerEvents: 'none',
+          }}
         />}
 
         <div className='video-player__seek' onMouseDown={this.handleMouseDown} ref={this.setSeekRef}>
diff --git a/app/javascript/flavours/glitch/features/status/components/card.jsx b/app/javascript/flavours/glitch/features/status/components/card.jsx
index 0d5f781a02..af3fe4bf09 100644
--- a/app/javascript/flavours/glitch/features/status/components/card.jsx
+++ b/app/javascript/flavours/glitch/features/status/components/card.jsx
@@ -8,7 +8,6 @@ import { decode as decodeIDNA } from 'flavours/glitch/utils/idna';
 import Icon from 'flavours/glitch/components/icon';
 import { useBlurhash } from 'flavours/glitch/initial_state';
 import Blurhash from 'flavours/glitch/components/blurhash';
-import { debounce } from 'lodash';
 
 const getHostname = url => {
   const parser = document.createElement('a');
@@ -45,8 +44,6 @@ export default class Card extends React.PureComponent {
     card: ImmutablePropTypes.map,
     onOpenMedia: PropTypes.func.isRequired,
     compact: PropTypes.bool,
-    defaultWidth: PropTypes.number,
-    cacheWidth: PropTypes.func,
     sensitive: PropTypes.bool,
   };
 
@@ -55,7 +52,6 @@ export default class Card extends React.PureComponent {
   };
 
   state = {
-    width: this.props.defaultWidth || 280,
     previewLoaded: false,
     embedded: false,
     revealed: !this.props.sensitive,
@@ -78,24 +74,6 @@ export default class Card extends React.PureComponent {
     window.removeEventListener('resize', this.handleResize);
   }
 
-  _setDimensions () {
-    const width = this.node.offsetWidth;
-
-    if (this.props.cacheWidth) {
-      this.props.cacheWidth(width);
-    }
-
-    this.setState({ width });
-  }
-
-  handleResize = debounce(() => {
-    if (this.node) {
-      this._setDimensions();
-    }
-  }, 250, {
-    trailing: true,
-  });
-
   handlePhotoClick = () => {
     const { card, onOpenMedia } = this.props;
 
@@ -129,10 +107,6 @@ export default class Card extends React.PureComponent {
 
   setRef = c => {
     this.node = c;
-
-    if (this.node) {
-      this._setDimensions();
-    }
   };
 
   handleImageLoad = () => {
@@ -148,36 +122,31 @@ export default class Card extends React.PureComponent {
   renderVideo () {
     const { card }  = this.props;
     const content   = { __html: addAutoPlay(card.get('html')) };
-    const { width } = this.state;
-    const ratio     = card.get('width') / card.get('height');
-    const height    = width / ratio;
 
     return (
       <div
         ref={this.setRef}
         className='status-card__image status-card-video'
         dangerouslySetInnerHTML={content}
-        style={{ height }}
+        style={{ aspectRatio: `${card.get('width')} / ${card.get('height')}` }}
       />
     );
   }
 
   render () {
     const { card, compact } = this.props;
-    const { width, embedded, revealed } = this.state;
+    const { embedded, revealed } = this.state;
 
     if (card === null) {
       return null;
     }
 
     const provider    = card.get('provider_name').length === 0 ? decodeIDNA(getHostname(card.get('url'))) : card.get('provider_name');
-    const horizontal  = (!compact && card.get('width') > card.get('height') && (card.get('width') + 100 >= width)) || card.get('type') !== 'link' || embedded;
+    const horizontal  = (!compact && card.get('width') > card.get('height')) || card.get('type') !== 'link' || embedded;
     const interactive = card.get('type') !== 'link';
     const className   = classnames('status-card', { horizontal, compact, interactive });
     const title       = interactive ? <a className='status-card__title' href={card.get('url')} title={card.get('title')} rel='noopener noreferrer' target='_blank'><strong>{card.get('title')}</strong></a> : <strong className='status-card__title' title={card.get('title')}>{card.get('title')}</strong>;
     const language    = card.get('language') || '';
-    const ratio       = card.get('width') / card.get('height');
-    const height      = (compact && !embedded) ? (width / (16 / 9)) : (width / ratio);
 
     const description = (
       <div className='status-card__content' lang={language}>
@@ -187,6 +156,14 @@ export default class Card extends React.PureComponent {
       </div>
     );
 
+    const thumbnailStyle = {
+      visibility: revealed? null : 'hidden',
+    };
+
+    if (horizontal) {
+      thumbnailStyle.aspectRatio = (compact && !embedded) ? '16 / 9' : `${card.get('width')} / ${card.get('height')}`;
+    }
+
     let embed     = '';
     let canvas = (
       <Blurhash
@@ -197,7 +174,7 @@ export default class Card extends React.PureComponent {
         dummy={!useBlurhash}
       />
     );
-    let thumbnail = <img src={card.get('image')} alt='' style={{ width: horizontal ? width : null, height: horizontal ? height : null, visibility: revealed ? null : 'hidden' }} onLoad={this.handleImageLoad} className='status-card__image-image' />;
+    let thumbnail = <img src={card.get('image')} alt='' style={thumbnailStyle} onLoad={this.handleImageLoad} className='status-card__image-image' />;
     let spoilerButton = (
       <button type='button' onClick={this.handleReveal} className='spoiler-button__overlay'>
         <span className='spoiler-button__overlay__label'><FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' /></span>
diff --git a/app/javascript/flavours/glitch/features/ui/index.jsx b/app/javascript/flavours/glitch/features/ui/index.jsx
index aee6743dcd..cc7937e5e2 100644
--- a/app/javascript/flavours/glitch/features/ui/index.jsx
+++ b/app/javascript/flavours/glitch/features/ui/index.jsx
@@ -378,7 +378,7 @@ class UI extends React.Component {
 
     if (layout !== this.props.layout) {
       this.handleLayoutChange.cancel();
-      this.props.dispatch(changeLayout(layout));
+      this.props.dispatch(changeLayout({ layout }));
     } else {
       this.handleLayoutChange();
     }
diff --git a/app/javascript/flavours/glitch/features/video/index.jsx b/app/javascript/flavours/glitch/features/video/index.jsx
index 0a9db059eb..78d36edb70 100644
--- a/app/javascript/flavours/glitch/features/video/index.jsx
+++ b/app/javascript/flavours/glitch/features/video/index.jsx
@@ -2,7 +2,7 @@ import React from 'react';
 import PropTypes from 'prop-types';
 import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
 import { is } from 'immutable';
-import { throttle, debounce } from 'lodash';
+import { throttle } from 'lodash';
 import classNames from 'classnames';
 import { isFullscreen, requestFullscreen, exitFullscreen } from '../ui/util/fullscreen';
 import { displayMedia, useBlurhash } from 'flavours/glitch/initial_state';
@@ -102,8 +102,6 @@ class Video extends React.PureComponent {
     src: PropTypes.string.isRequired,
     alt: PropTypes.string,
     lang: PropTypes.string,
-    width: PropTypes.number,
-    height: PropTypes.number,
     sensitive: PropTypes.bool,
     currentTime: PropTypes.number,
     onOpenVideo: PropTypes.func,
@@ -112,7 +110,6 @@ class Video extends React.PureComponent {
     inline: PropTypes.bool,
     editable: PropTypes.bool,
     alwaysVisible: PropTypes.bool,
-    cacheWidth: PropTypes.func,
     visible: PropTypes.bool,
     letterbox: PropTypes.bool,
     fullwidth: PropTypes.bool,
@@ -138,41 +135,16 @@ class Video extends React.PureComponent {
     volume: 0.5,
     paused: true,
     dragging: false,
-    containerWidth: this.props.width,
     fullscreen: false,
     hovered: false,
     muted: false,
     revealed: this.props.visible !== undefined ? this.props.visible : (displayMedia !== 'hide_all' && !this.props.sensitive || displayMedia === 'show_all'),
   };
 
-  componentWillReceiveProps (nextProps) {
-    if (!is(nextProps.visible, this.props.visible) && nextProps.visible !== undefined) {
-      this.setState({ revealed: nextProps.visible });
-    }
-  }
-
   setPlayerRef = c => {
     this.player = c;
-
-    if (this.player) {
-      this._setDimensions();
-    }
   };
 
-  _setDimensions () {
-    const width = this.player.offsetWidth;
-
-    if (width && width !== this.state.containerWidth) {
-      if (this.props.cacheWidth) {
-        this.props.cacheWidth(width);
-      }
-
-      this.setState({
-        containerWidth: width,
-      });
-    }
-  }
-
   setVideoRef = c => {
     this.video = c;
 
@@ -381,12 +353,10 @@ class Video extends React.PureComponent {
     document.addEventListener('MSFullscreenChange', this.handleFullscreenChange, true);
 
     window.addEventListener('scroll', this.handleScroll);
-    window.addEventListener('resize', this.handleResize, { passive: true });
   }
 
   componentWillUnmount () {
     window.removeEventListener('scroll', this.handleScroll);
-    window.removeEventListener('resize', this.handleResize);
 
     document.removeEventListener('fullscreenchange', this.handleFullscreenChange, true);
     document.removeEventListener('webkitfullscreenchange', this.handleFullscreenChange, true);
@@ -403,26 +373,18 @@ class Video extends React.PureComponent {
     }
   }
 
-  componentDidUpdate (prevProps) {
-    if (this.player && this.player.offsetWidth && this.player.offsetWidth !== this.state.containerWidth && !this.state.fullscreen) {
-      if (this.props.cacheWidth) this.props.cacheWidth(this.player.offsetWidth);
-      this.setState({
-        containerWidth: this.player.offsetWidth,
-      });
+  componentWillReceiveProps (nextProps) {
+    if (!is(nextProps.visible, this.props.visible) && nextProps.visible !== undefined) {
+      this.setState({ revealed: nextProps.visible });
     }
+  }
+
+  componentDidUpdate (prevProps) {
     if (this.video && this.state.revealed && this.props.preventPlayback && !prevProps.preventPlayback) {
       this.video.pause();
     }
   }
 
-  handleResize = debounce(() => {
-    if (this.player) {
-      this._setDimensions();
-    }
-  }, 250, {
-    trailing: true,
-  });
-
   handleScroll = throttle(() => {
     if (!this.video) {
       return;
@@ -540,21 +502,12 @@ class Video extends React.PureComponent {
 
   render () {
     const { preview, src, inline, onOpenVideo, onCloseVideo, intl, alt, lang, letterbox, fullwidth, detailed, sensitive, editable, blurhash, autoFocus } = this.props;
-    const { containerWidth, currentTime, duration, volume, buffer, dragging, paused, fullscreen, hovered, muted, revealed } = this.state;
+    const { currentTime, duration, volume, buffer, dragging, paused, fullscreen, hovered, muted, revealed } = this.state;
     const progress = Math.min((currentTime / duration) * 100, 100);
     const playerStyle = {};
 
-    const computedClass = classNames('video-player', { inactive: !revealed, detailed, inline: inline && !fullscreen, fullscreen, editable, letterbox, 'full-width': fullwidth });
-
-    let { width, height } = this.props;
-
-    if (inline && containerWidth) {
-      width  = containerWidth;
-      height = containerWidth / (16/9);
-
-      playerStyle.height = height;
-    } else if (inline) {
-      return (<div className={computedClass} ref={this.setPlayerRef} tabIndex={0} />);
+    if (inline) {
+      playerStyle.aspectRatio = '16 / 9';
     }
 
     let preload;
@@ -578,7 +531,7 @@ class Video extends React.PureComponent {
     return (
       <div
         role='menuitem'
-        className={computedClass}
+        className={classNames('video-player', { inactive: !revealed, detailed, inline: inline && !fullscreen, fullscreen, editable, letterbox, 'full-width': fullwidth })}
         style={playerStyle}
         ref={this.setPlayerRef}
         onMouseEnter={this.handleMouseEnter}
@@ -605,8 +558,6 @@ class Video extends React.PureComponent {
           aria-label={alt}
           title={alt}
           lang={lang}
-          width={width}
-          height={height}
           volume={volume}
           onClick={this.togglePlay}
           onKeyDown={this.handleVideoKeyDown}
@@ -615,6 +566,7 @@ class Video extends React.PureComponent {
           onLoadedData={this.handleLoadedData}
           onProgress={this.handleProgress}
           onVolumeChange={this.handleVolumeChange}
+          style={{ ...playerStyle, width: '100%' }}
         />}
 
         <div className={classNames('spoiler-button', { 'spoiler-button--hidden': revealed || editable })}>
diff --git a/app/javascript/flavours/glitch/is_mobile.js b/app/javascript/flavours/glitch/is_mobile.ts
similarity index 72%
rename from app/javascript/flavours/glitch/is_mobile.js
rename to app/javascript/flavours/glitch/is_mobile.ts
index f87da2c5de..d33246882d 100644
--- a/app/javascript/flavours/glitch/is_mobile.js
+++ b/app/javascript/flavours/glitch/is_mobile.ts
@@ -1,21 +1,12 @@
-// @ts-check
-
 import { supportsPassiveEvents } from 'detect-passive-events';
 import { forceSingleColumn } from 'flavours/glitch/initial_state';
 
 const LAYOUT_BREAKPOINT = 630;
 
-/**
- * @param {number} width
- * @returns {boolean}
- */
-export const isMobile = width => width <= LAYOUT_BREAKPOINT;
+export const isMobile = (width: number) => width <= LAYOUT_BREAKPOINT;
 
-/**
- * @param {string} layout_local_setting
- * @returns {string}
- */
-export const layoutFromWindow = (layout_local_setting) => {
+export type LayoutType = 'mobile' | 'single-column' | 'multi-column';
+export const layoutFromWindow = (layout_local_setting : string): LayoutType => {
   switch (layout_local_setting) {
   case 'multiple':
     return 'multi-column';
@@ -36,8 +27,9 @@ export const layoutFromWindow = (layout_local_setting) => {
   }
 };
 
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
 // @ts-expect-error
-const iOS = /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream;
+const iOS = /iPad|iPhone|iPod/.test(navigator.userAgent) && window.MSStream != null;
 
 const listenerOptions = supportsPassiveEvents ? { passive: true } : false;
 
diff --git a/app/javascript/flavours/glitch/main.jsx b/app/javascript/flavours/glitch/main.jsx
index 14a6effbbb..e3a98b4843 100644
--- a/app/javascript/flavours/glitch/main.jsx
+++ b/app/javascript/flavours/glitch/main.jsx
@@ -1,7 +1,8 @@
 import React from 'react';
 import ReactDOM from 'react-dom';
 import { setupBrowserNotifications } from 'flavours/glitch/actions/notifications';
-import Mastodon, { store } from 'flavours/glitch/containers/mastodon';
+import Mastodon from 'flavours/glitch/containers/mastodon';
+import { store } from 'flavours/glitch/store/configureStore';
 import { me } from 'flavours/glitch/initial_state';
 import ready from 'flavours/glitch/ready';
 
diff --git a/app/javascript/flavours/glitch/permissions.js b/app/javascript/flavours/glitch/permissions.ts
similarity index 100%
rename from app/javascript/flavours/glitch/permissions.js
rename to app/javascript/flavours/glitch/permissions.ts
diff --git a/app/javascript/flavours/glitch/reducers/meta.js b/app/javascript/flavours/glitch/reducers/meta.js
index 7a38a9090c..c38dc60965 100644
--- a/app/javascript/flavours/glitch/reducers/meta.js
+++ b/app/javascript/flavours/glitch/reducers/meta.js
@@ -1,5 +1,5 @@
 import { STORE_HYDRATE } from 'flavours/glitch/actions/store';
-import { APP_LAYOUT_CHANGE } from 'flavours/glitch/actions/app';
+import { changeLayout } from 'flavours/glitch/actions/app';
 import { Map as ImmutableMap } from 'immutable';
 import { layoutFromWindow } from 'flavours/glitch/is_mobile';
 
@@ -16,8 +16,8 @@ export default function meta(state = initialState, action) {
     return state.merge(action.state.get('meta'))
       .set('permissions', action.state.getIn(['role', 'permissions']))
       .set('layout', layoutFromWindow(action.state.getIn(['local_settings', 'layout'])));
-  case APP_LAYOUT_CHANGE:
-    return state.set('layout', action.layout);
+  case changeLayout.type:
+    return state.set('layout', action.payload.layout);
   default:
     return state;
   }
diff --git a/app/javascript/mastodon/scroll.js b/app/javascript/flavours/glitch/scroll.ts
similarity index 51%
rename from app/javascript/mastodon/scroll.js
rename to app/javascript/flavours/glitch/scroll.ts
index 84fe582699..1e509c4175 100644
--- a/app/javascript/mastodon/scroll.js
+++ b/app/javascript/flavours/glitch/scroll.ts
@@ -1,6 +1,5 @@
-const easingOutQuint = (x, t, b, c, d) => c * ((t = t / d - 1) * t * t * t * t + 1) + b;
-
-const scroll = (node, key, target) => {
+const easingOutQuint = (x: number, t: number, b: number, c: number, d: number) => c * ((t = t / d - 1) * t * t * t * t + 1) + b;
+const scroll = (node: Element, key: 'scrollTop' | 'scrollLeft', target: number) => {
   const startTime = Date.now();
   const offset    = node[key];
   const gap       = target - offset;
@@ -28,5 +27,5 @@ const scroll = (node, key, target) => {
 
 const isScrollBehaviorSupported = 'scrollBehavior' in document.documentElement.style;
 
-export const scrollRight = (node, position) => isScrollBehaviorSupported ? node.scrollTo({ left: position, behavior: 'smooth' }) : scroll(node, 'scrollLeft', position);
-export const scrollTop = (node) => isScrollBehaviorSupported ? node.scrollTo({ top: 0, behavior: 'smooth' }) : scroll(node, 'scrollTop', 0);
+export const scrollRight = (node: Element, position: number) => isScrollBehaviorSupported ? node.scrollTo({ left: position, behavior: 'smooth' }) : scroll(node, 'scrollLeft', position);
+export const scrollTop = (node: Element) => isScrollBehaviorSupported ? node.scrollTo({ top: 0, behavior: 'smooth' }) : scroll(node, 'scrollTop', 0);
diff --git a/app/javascript/flavours/glitch/store/configureStore.js b/app/javascript/flavours/glitch/store/configureStore.js
index 0e0d45c668..cb17dd9ce8 100644
--- a/app/javascript/flavours/glitch/store/configureStore.js
+++ b/app/javascript/flavours/glitch/store/configureStore.js
@@ -1,15 +1,16 @@
-import { createStore, applyMiddleware, compose } from 'redux';
+import { configureStore } from '@reduxjs/toolkit';
 import thunk from 'redux-thunk';
 import appReducer from '../reducers';
 import loadingBarMiddleware from '../middleware/loading_bar';
 import errorsMiddleware from '../middleware/errors';
 import soundsMiddleware from '../middleware/sounds';
 
-export default function configureStore() {
-  return createStore(appReducer, compose(applyMiddleware(
+export const store = configureStore({
+  reducer: appReducer,
+  middleware: [
     thunk,
     loadingBarMiddleware({ promiseTypeSuffixes: ['REQUEST', 'SUCCESS', 'FAIL'] }),
     errorsMiddleware(),
     soundsMiddleware(),
-  ), window.__REDUX_DEVTOOLS_EXTENSION__ ? window.__REDUX_DEVTOOLS_EXTENSION__() : f => f));
-}
+  ],
+});
diff --git a/app/javascript/flavours/glitch/styles/_mixins.scss b/app/javascript/flavours/glitch/styles/_mixins.scss
index b23c4dbb7b..6643cd1aa5 100644
--- a/app/javascript/flavours/glitch/styles/_mixins.scss
+++ b/app/javascript/flavours/glitch/styles/_mixins.scss
@@ -47,7 +47,6 @@
     margin-right: -14px;
     width: inherit;
     max-width: none;
-    height: 250px;
     border-radius: 0;
   }
 }
diff --git a/app/javascript/flavours/glitch/styles/components/media.scss b/app/javascript/flavours/glitch/styles/components/media.scss
index 2f36edd19b..a708d066ae 100644
--- a/app/javascript/flavours/glitch/styles/components/media.scss
+++ b/app/javascript/flavours/glitch/styles/components/media.scss
@@ -43,30 +43,25 @@
   font-weight: 500;
 }
 
-.media-gallery__gifv__label {
-  display: block;
+.media-gallery__item__badges {
   position: absolute;
-  color: $primary-text-color;
-  background: rgba($base-overlay-background, 0.5);
   bottom: 6px;
   inset-inline-start: 6px;
-  padding: 2px 6px;
-  border-radius: 2px;
-  font-size: 11px;
-  font-weight: 600;
-  z-index: 1;
-  pointer-events: none;
-  opacity: 0.9;
-  transition: opacity 0.1s ease;
-  line-height: 18px;
+  display: flex;
+  gap: 2px;
 }
 
-.media-gallery__gifv {
-  &:hover {
-    .media-gallery__gifv__label {
-      opacity: 1;
-    }
-  }
+.media-gallery__gifv__label {
+  display: block;
+  color: $white;
+  background: rgba($black, 0.65);
+  padding: 2px 6px;
+  border-radius: 4px;
+  font-size: 11px;
+  font-weight: 700;
+  z-index: 1;
+  pointer-events: none;
+  line-height: 18px;
 }
 
 .media-gallery {
@@ -77,6 +72,10 @@
   position: relative;
   width: 100%;
   min-height: 64px;
+  display: grid;
+  grid-template-columns: 50% 50%;
+  grid-template-rows: 50% 50%;
+  gap: 2px;
 
   @include fullwidth-gallery;
 }
@@ -85,13 +84,16 @@
   border: 0;
   box-sizing: border-box;
   display: block;
-  float: left;
   position: relative;
   border-radius: 4px;
   overflow: hidden;
 
-  .full-width & {
-    border-radius: 0;
+  &--tall {
+    grid-row: span 2;
+  }
+
+  &--wide {
+    grid-column: span 2;
   }
 
   &.standalone {
@@ -101,6 +103,10 @@
     }
   }
 
+  .full-width & {
+    border-radius: 0;
+  }
+
   &.letterbox {
     background: $base-shadow-color;
   }
diff --git a/app/javascript/flavours/glitch/styles/components/status.scss b/app/javascript/flavours/glitch/styles/components/status.scss
index 558f7cd451..ddf691dddf 100644
--- a/app/javascript/flavours/glitch/styles/components/status.scss
+++ b/app/javascript/flavours/glitch/styles/components/status.scss
@@ -691,7 +691,6 @@ a.status__display-name,
   margin-inline-end: 10px;
   height: 48px;
   width: 48px;
-  box-shadow: 0 0 0 2px $ui-base-color;
 }
 
 .muted {
@@ -809,6 +808,10 @@ a.status-card {
 }
 
 .status-card-video {
+  // Firefox has a bug where frameborder=0 iframes add some extra blank space
+  // see https://bugzilla.mozilla.org/show_bug.cgi?id=155174
+  overflow: hidden;
+
   iframe {
     width: 100%;
     height: 100%;
@@ -1154,6 +1157,7 @@ a.status-card.compact:hover {
   font-weight: 500;
   cursor: pointer;
   color: $darker-text-color;
+  aspect-ratio: 16 / 9;
 
   i {
     display: block;
diff --git a/app/javascript/flavours/glitch/types/resources.ts b/app/javascript/flavours/glitch/types/resources.ts
new file mode 100644
index 0000000000..28fad2719a
--- /dev/null
+++ b/app/javascript/flavours/glitch/types/resources.ts
@@ -0,0 +1,10 @@
+import type { Record } from 'immutable';
+
+type AccountValues = {
+  id: number;
+  avatar: string;
+  avatar_static: string;
+  [key: string]: any;
+};
+
+export type Account = Record<AccountValues>;
diff --git a/app/javascript/flavours/glitch/types/util.ts b/app/javascript/flavours/glitch/types/util.ts
new file mode 100644
index 0000000000..5f2cf2cf07
--- /dev/null
+++ b/app/javascript/flavours/glitch/types/util.ts
@@ -0,0 +1 @@
+export type ValueOf<T> = T[keyof T];
diff --git a/app/javascript/flavours/glitch/utils/base64.js b/app/javascript/flavours/glitch/utils/base64.ts
similarity index 79%
rename from app/javascript/flavours/glitch/utils/base64.js
rename to app/javascript/flavours/glitch/utils/base64.ts
index 8226e2c54e..5a595ee12b 100644
--- a/app/javascript/flavours/glitch/utils/base64.js
+++ b/app/javascript/flavours/glitch/utils/base64.ts
@@ -1,4 +1,4 @@
-export const decode = base64 => {
+export const decode = (base64: string): Uint8Array => {
   const rawData = window.atob(base64);
   const outputArray = new Uint8Array(rawData.length);
 
diff --git a/app/javascript/mastodon/utils/filters.js b/app/javascript/flavours/glitch/utils/filters.ts
similarity index 83%
rename from app/javascript/mastodon/utils/filters.js
rename to app/javascript/flavours/glitch/utils/filters.ts
index 97b433a991..5af2aa96a4 100644
--- a/app/javascript/mastodon/utils/filters.js
+++ b/app/javascript/flavours/glitch/utils/filters.ts
@@ -1,4 +1,4 @@
-export const toServerSideType = columnType => {
+export const toServerSideType = (columnType: string) => {
   switch (columnType) {
   case 'home':
   case 'notifications':
diff --git a/app/javascript/flavours/glitch/utils/numbers.js b/app/javascript/flavours/glitch/utils/numbers.ts
similarity index 71%
rename from app/javascript/flavours/glitch/utils/numbers.js
rename to app/javascript/flavours/glitch/utils/numbers.ts
index fa3d58fad1..128ba72f9b 100644
--- a/app/javascript/flavours/glitch/utils/numbers.js
+++ b/app/javascript/flavours/glitch/utils/numbers.ts
@@ -1,23 +1,19 @@
-// @ts-check
+import type { ValueOf } from 'flavours/glitch/types/util';
 
 export const DECIMAL_UNITS = Object.freeze({
   ONE: 1,
   TEN: 10,
-  HUNDRED: Math.pow(10, 2),
-  THOUSAND: Math.pow(10, 3),
-  MILLION: Math.pow(10, 6),
-  BILLION: Math.pow(10, 9),
-  TRILLION: Math.pow(10, 12),
+  HUNDRED: 100,
+  THOUSAND: 1_000,
+  MILLION: 1_000_000,
+  BILLION: 1_000_000_000,
+  TRILLION: 1_000_000_000_000,
 });
+export type DecimalUnits = ValueOf<typeof DECIMAL_UNITS>;
 
 const TEN_THOUSAND = DECIMAL_UNITS.THOUSAND * 10;
 const TEN_MILLIONS = DECIMAL_UNITS.MILLION * 10;
 
-/**
- * @typedef {[number, number, number]} ShortNumber
- * Array of: shorten number, unit of shorten number and maximum fraction digits
- */
-
 /**
  * @param {number} sourceNumber Number to convert to short number
  * @returns {ShortNumber} Calculated short number
@@ -25,7 +21,8 @@ const TEN_MILLIONS = DECIMAL_UNITS.MILLION * 10;
  * shortNumber(5936);
  * // => [5.936, 1000, 1]
  */
-export function toShortNumber(sourceNumber) {
+export type ShortNumber = [number, DecimalUnits, 0 | 1] // Array of: shorten number, unit of shorten number and maximum fraction digits
+export function toShortNumber(sourceNumber: number): ShortNumber {
   if (sourceNumber < DECIMAL_UNITS.THOUSAND) {
     return [sourceNumber, DECIMAL_UNITS.ONE, 0];
   } else if (sourceNumber < DECIMAL_UNITS.MILLION) {
@@ -59,20 +56,16 @@ export function toShortNumber(sourceNumber) {
  * pluralReady(1793, DECIMAL_UNITS.THOUSAND)
  * // => 1790
  */
-export function pluralReady(sourceNumber, division) {
+export function pluralReady(sourceNumber: number, division: DecimalUnits): number {
   if (division == null || division < DECIMAL_UNITS.HUNDRED) {
     return sourceNumber;
   }
 
-  let closestScale = division / DECIMAL_UNITS.TEN;
+  const closestScale = division / DECIMAL_UNITS.TEN;
 
   return Math.trunc(sourceNumber / closestScale) * closestScale;
 }
 
-/**
- * @param {number} num
- * @returns {number}
- */
-export function roundTo10(num) {
+export function roundTo10(num: number): number {
   return Math.round(num * 0.1) / 0.1;
 }
diff --git a/app/javascript/mastodon/actions/app.js b/app/javascript/mastodon/actions/app.js
deleted file mode 100644
index c817c87080..0000000000
--- a/app/javascript/mastodon/actions/app.js
+++ /dev/null
@@ -1,17 +0,0 @@
-export const APP_FOCUS   = 'APP_FOCUS';
-export const APP_UNFOCUS = 'APP_UNFOCUS';
-
-export const focusApp = () => ({
-  type: APP_FOCUS,
-});
-
-export const unfocusApp = () => ({
-  type: APP_UNFOCUS,
-});
-
-export const APP_LAYOUT_CHANGE = 'APP_LAYOUT_CHANGE';
-
-export const changeLayout = layout => ({
-  type: APP_LAYOUT_CHANGE,
-  layout,
-});
diff --git a/app/javascript/mastodon/actions/app.ts b/app/javascript/mastodon/actions/app.ts
new file mode 100644
index 0000000000..0acfbfae7a
--- /dev/null
+++ b/app/javascript/mastodon/actions/app.ts
@@ -0,0 +1,10 @@
+import { createAction } from '@reduxjs/toolkit';
+
+export const focusApp = createAction('APP_FOCUS');
+export const unfocusApp = createAction('APP_UNFOCUS');
+
+type ChangeLayoutPayload = {
+  layout: 'mobile' | 'single-column' | 'multi-column';
+};
+export const changeLayout =
+  createAction<ChangeLayoutPayload>('APP_LAYOUT_CHANGE');
diff --git a/app/javascript/mastodon/blurhash.js b/app/javascript/mastodon/blurhash.ts
similarity index 87%
rename from app/javascript/mastodon/blurhash.js
rename to app/javascript/mastodon/blurhash.ts
index 5adcc3e770..cb1c3b2c82 100644
--- a/app/javascript/mastodon/blurhash.js
+++ b/app/javascript/mastodon/blurhash.ts
@@ -84,7 +84,7 @@ const DIGIT_CHARACTERS = [
   '~',
 ];
 
-export const decode83 = (str) => {
+export const decode83 = (str: string) => {
   let value = 0;
   let c, digit;
 
@@ -97,13 +97,13 @@ export const decode83 = (str) => {
   return value;
 };
 
-export const intToRGB = int => ({
+export const intToRGB = (int: number) => ({
   r: Math.max(0, (int >> 16)),
   g: Math.max(0, (int >> 8) & 255),
   b: Math.max(0, (int & 255)),
 });
 
-export const getAverageFromBlurhash = blurhash => {
+export const getAverageFromBlurhash = (blurhash: string) => {
   if (!blurhash) {
     return null;
   }
diff --git a/app/javascript/flavours/glitch/compare_id.js b/app/javascript/mastodon/compare_id.ts
similarity index 72%
rename from app/javascript/flavours/glitch/compare_id.js
rename to app/javascript/mastodon/compare_id.ts
index d2bd74f447..ae4ac6f897 100644
--- a/app/javascript/flavours/glitch/compare_id.js
+++ b/app/javascript/mastodon/compare_id.ts
@@ -1,4 +1,4 @@
-export default function compareId (id1, id2) {
+export default function compareId (id1: string, id2: string) {
   if (id1 === id2) {
     return 0;
   }
diff --git a/app/javascript/mastodon/components/blurhash.jsx b/app/javascript/mastodon/components/blurhash.jsx
deleted file mode 100644
index f5c58e04ef..0000000000
--- a/app/javascript/mastodon/components/blurhash.jsx
+++ /dev/null
@@ -1,65 +0,0 @@
-// @ts-check
-
-import { decode } from 'blurhash';
-import React, { useRef, useEffect } from 'react';
-import PropTypes from 'prop-types';
-
-/**
- * @typedef BlurhashPropsBase
- * @property {string?} hash Hash to render
- * @property {number} width
- * Width of the blurred region in pixels. Defaults to 32
- * @property {number} [height]
- * Height of the blurred region in pixels. Defaults to width
- * @property {boolean} [dummy]
- * Whether dummy mode is enabled. If enabled, nothing is rendered
- * and canvas left untouched
- */
-
-/** @typedef {JSX.IntrinsicElements['canvas'] & BlurhashPropsBase} BlurhashProps */
-
-/**
- * Component that is used to render blurred of blurhash string
- * @param {BlurhashProps} param1 Props of the component
- * @returns {JSX.Element} Canvas which will render blurred region element to embed
- */
-function Blurhash({
-  hash,
-  width = 32,
-  height = width,
-  dummy = false,
-  ...canvasProps
-}) {
-  const canvasRef = /** @type {import('react').MutableRefObject<HTMLCanvasElement>} */ (useRef());
-
-  useEffect(() => {
-    const { current: canvas } = canvasRef;
-    canvas.width = canvas.width; // resets canvas
-
-    if (dummy || !hash) return;
-
-    try {
-      const pixels = decode(hash, width, height);
-      const ctx = canvas.getContext('2d');
-      const imageData = new ImageData(pixels, width, height);
-
-      // @ts-expect-error
-      ctx.putImageData(imageData, 0, 0);
-    } catch (err) {
-      console.error('Blurhash decoding failure', { err, hash });
-    }
-  }, [dummy, hash, width, height]);
-
-  return (
-    <canvas {...canvasProps} ref={canvasRef} width={width} height={height} />
-  );
-}
-
-Blurhash.propTypes = {
-  hash: PropTypes.string.isRequired,
-  width: PropTypes.number,
-  height: PropTypes.number,
-  dummy: PropTypes.bool,
-};
-
-export default React.memo(Blurhash);
diff --git a/app/javascript/mastodon/components/blurhash.tsx b/app/javascript/mastodon/components/blurhash.tsx
new file mode 100644
index 0000000000..6fec6e1ef7
--- /dev/null
+++ b/app/javascript/mastodon/components/blurhash.tsx
@@ -0,0 +1,45 @@
+import { decode } from 'blurhash';
+import React, { useRef, useEffect } from 'react';
+
+type Props = {
+  hash: string;
+  width?: number;
+  height?: number;
+  dummy?: boolean; // Whether dummy mode is enabled. If enabled, nothing is rendered and canvas left untouched
+  children?: never;
+  [key: string]: any;
+}
+function Blurhash({
+  hash,
+  width = 32,
+  height = width,
+  dummy = false,
+  ...canvasProps
+}: Props) {
+  const canvasRef = useRef<HTMLCanvasElement>(null);
+
+  useEffect(() => {
+    // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+    const canvas = canvasRef.current!;
+    // eslint-disable-next-line no-self-assign
+    canvas.width = canvas.width; // resets canvas
+
+    if (dummy || !hash) return;
+
+    try {
+      const pixels = decode(hash, width, height);
+      const ctx = canvas.getContext('2d');
+      const imageData = new ImageData(pixels, width, height);
+
+      ctx?.putImageData(imageData, 0, 0);
+    } catch (err) {
+      console.error('Blurhash decoding failure', { err, hash });
+    }
+  }, [dummy, hash, width, height]);
+
+  return (
+    <canvas {...canvasProps} ref={canvasRef} width={width} height={height} />
+  );
+}
+
+export default React.memo(Blurhash);
diff --git a/app/javascript/mastodon/components/column_back_button.jsx b/app/javascript/mastodon/components/column_back_button.jsx
index 12926bb253..faa800b2ad 100644
--- a/app/javascript/mastodon/components/column_back_button.jsx
+++ b/app/javascript/mastodon/components/column_back_button.jsx
@@ -21,7 +21,9 @@ export default class ColumnBackButton extends React.PureComponent {
 
     if (onClick) {
       onClick();
-    } else if (window.history && window.history.state) {
+    // Check if there is a previous page in the app to go back to per https://stackoverflow.com/a/70532858/9703201
+    // When upgrading to V6, check `location.key !== 'default'` instead per https://github.com/remix-run/history/blob/main/docs/api-reference.md#location
+    } else if (router.route.location.key) {
       router.history.goBack();
     } else {
       router.history.push('/');
diff --git a/app/javascript/mastodon/components/icon_button.jsx b/app/javascript/mastodon/components/icon_button.tsx
similarity index 68%
rename from app/javascript/mastodon/components/icon_button.jsx
rename to app/javascript/mastodon/components/icon_button.tsx
index 989cae4401..ec11ab7011 100644
--- a/app/javascript/mastodon/components/icon_button.jsx
+++ b/app/javascript/mastodon/components/icon_button.tsx
@@ -1,34 +1,36 @@
 import React from 'react';
-import PropTypes from 'prop-types';
 import classNames from 'classnames';
-import Icon from 'mastodon/components/icon';
-import AnimatedNumber from 'mastodon/components/animated_number';
+import { Icon } from './icon';
+import { AnimatedNumber } from './animated_number';
 
-export default class IconButton extends React.PureComponent {
-
-  static propTypes = {
-    className: PropTypes.string,
-    title: PropTypes.string.isRequired,
-    icon: PropTypes.string.isRequired,
-    onClick: PropTypes.func,
-    onMouseDown: PropTypes.func,
-    onKeyDown: PropTypes.func,
-    onKeyPress: PropTypes.func,
-    size: PropTypes.number,
-    active: PropTypes.bool,
-    expanded: PropTypes.bool,
-    style: PropTypes.object,
-    activeStyle: PropTypes.object,
-    disabled: PropTypes.bool,
-    inverted: PropTypes.bool,
-    animate: PropTypes.bool,
-    overlay: PropTypes.bool,
-    tabIndex: PropTypes.number,
-    counter: PropTypes.number,
-    obfuscateCount: PropTypes.bool,
-    href: PropTypes.string,
-    ariaHidden: PropTypes.bool,
-  };
+type Props = {
+  className?: string;
+  title: string;
+  icon: string;
+  onClick?: React.MouseEventHandler<HTMLButtonElement>;
+  onMouseDown?: React.MouseEventHandler<HTMLButtonElement>;
+  onKeyDown?: React.KeyboardEventHandler<HTMLButtonElement>;
+  onKeyPress?: React.KeyboardEventHandler<HTMLButtonElement>;
+  size: number;
+  active: boolean;
+  expanded?: boolean;
+  style?: React.CSSProperties;
+  activeStyle?: React.CSSProperties;
+  disabled: boolean;
+  inverted?: boolean;
+  animate: boolean;
+  overlay: boolean;
+  tabIndex: number;
+  counter?: number;
+  obfuscateCount?: boolean;
+  href?: string;
+  ariaHidden: boolean;
+}
+type States = {
+  activate: boolean,
+  deactivate: boolean,
+}
+export default class IconButton extends React.PureComponent<Props, States> {
 
   static defaultProps = {
     size: 18,
@@ -45,7 +47,7 @@ export default class IconButton extends React.PureComponent {
     deactivate: false,
   };
 
-  componentWillReceiveProps (nextProps) {
+  UNSAFE_componentWillReceiveProps (nextProps: Props) {
     if (!nextProps.animate) return;
 
     if (this.props.active && !nextProps.active) {
@@ -55,27 +57,27 @@ export default class IconButton extends React.PureComponent {
     }
   }
 
-  handleClick = (e) =>  {
+  handleClick: React.MouseEventHandler<HTMLButtonElement> = (e) =>  {
     e.preventDefault();
 
-    if (!this.props.disabled) {
+    if (!this.props.disabled && this.props.onClick != null) {
       this.props.onClick(e);
     }
   };
 
-  handleKeyPress = (e) => {
+  handleKeyPress: React.KeyboardEventHandler<HTMLButtonElement> = (e) => {
     if (this.props.onKeyPress && !this.props.disabled) {
       this.props.onKeyPress(e);
     }
   };
 
-  handleMouseDown = (e) => {
+  handleMouseDown: React.MouseEventHandler<HTMLButtonElement> = (e) => {
     if (!this.props.disabled && this.props.onMouseDown) {
       this.props.onMouseDown(e);
     }
   };
 
-  handleKeyDown = (e) => {
+  handleKeyDown: React.KeyboardEventHandler<HTMLButtonElement> = (e) => {
     if (!this.props.disabled && this.props.onKeyDown) {
       this.props.onKeyDown(e);
     }
@@ -132,7 +134,7 @@ export default class IconButton extends React.PureComponent {
       </React.Fragment>
     );
 
-    if (href && !this.prop) {
+    if (href != null) {
       contents = (
         <a href={href} target='_blank' rel='noopener noreferrer'>
           {contents}
diff --git a/app/javascript/mastodon/components/media_gallery.jsx b/app/javascript/mastodon/components/media_gallery.jsx
index 5be0070a33..54470f9402 100644
--- a/app/javascript/mastodon/components/media_gallery.jsx
+++ b/app/javascript/mastodon/components/media_gallery.jsx
@@ -81,12 +81,10 @@ class Item extends React.PureComponent {
   render () {
     const { attachment, lang, index, size, standalone, displayWidth, visible } = this.props;
 
+    let badges = [], thumbnail;
+
     let width  = 50;
     let height = 100;
-    let top    = 'auto';
-    let left   = 'auto';
-    let bottom = 'auto';
-    let right  = 'auto';
 
     if (size === 1) {
       width = 100;
@@ -96,45 +94,13 @@ class Item extends React.PureComponent {
       height = 50;
     }
 
-    if (size === 2) {
-      if (index === 0) {
-        right = '2px';
-      } else {
-        left = '2px';
-      }
-    } else if (size === 3) {
-      if (index === 0) {
-        right = '2px';
-      } else if (index > 0) {
-        left = '2px';
-      }
-
-      if (index === 1) {
-        bottom = '2px';
-      } else if (index > 1) {
-        top = '2px';
-      }
-    } else if (size === 4) {
-      if (index === 0 || index === 2) {
-        right = '2px';
-      }
-
-      if (index === 1 || index === 3) {
-        left = '2px';
-      }
-
-      if (index < 2) {
-        bottom = '2px';
-      } else {
-        top = '2px';
-      }
+    if (attachment.get('description')?.length > 0) {
+      badges.push(<span key='alt' className='media-gallery__gifv__label'>ALT</span>);
     }
 
-    let thumbnail = '';
-
     if (attachment.get('type') === 'unknown') {
       return (
-        <div className={classNames('media-gallery__item', { standalone })} key={attachment.get('id')} style={{ left: left, top: top, right: right, bottom: bottom, width: `${width}%`, height: `${height}%` }}>
+        <div className={classNames('media-gallery__item', { standalone, 'media-gallery__item--tall': height === 100, 'media-gallery__item--wide': width === 100 })} key={attachment.get('id')}>
           <a className='media-gallery__item-thumbnail' href={attachment.get('remote_url') || attachment.get('url')} style={{ cursor: 'pointer' }} title={attachment.get('description')} lang={lang} target='_blank' rel='noopener noreferrer'>
             <Blurhash
               hash={attachment.get('blurhash')}
@@ -184,6 +150,8 @@ class Item extends React.PureComponent {
     } else if (attachment.get('type') === 'gifv') {
       const autoPlay = this.getAutoPlay();
 
+      badges.push(<span key='gif' className='media-gallery__gifv__label'>GIF</span>);
+
       thumbnail = (
         <div className={classNames('media-gallery__gifv', { autoplay: autoPlay })}>
           <video
@@ -201,14 +169,12 @@ class Item extends React.PureComponent {
             loop
             muted
           />
-
-          <span className='media-gallery__gifv__label'>GIF</span>
         </div>
       );
     }
 
     return (
-      <div className={classNames('media-gallery__item', { standalone })} key={attachment.get('id')} style={{ left: left, top: top, right: right, bottom: bottom, width: `${width}%`, height: `${height}%` }}>
+      <div className={classNames('media-gallery__item', { standalone, 'media-gallery__item--tall': height === 100, 'media-gallery__item--wide': width === 100 })} key={attachment.get('id')}>
         <Blurhash
           hash={attachment.get('blurhash')}
           dummy={!useBlurhash}
@@ -216,7 +182,14 @@ class Item extends React.PureComponent {
             'media-gallery__preview--hidden': visible && this.state.loaded,
           })}
         />
+
         {visible && thumbnail}
+
+        {badges && (
+          <div className='media-gallery__item__badges'>
+            {badges}
+          </div>
+        )}
       </div>
     );
   }
@@ -313,7 +286,7 @@ class MediaGallery extends React.PureComponent {
   }
 
   render () {
-    const { media, lang, intl, sensitive, height, defaultWidth, standalone, autoplay } = this.props;
+    const { media, lang, intl, sensitive, defaultWidth, standalone, autoplay } = this.props;
     const { visible } = this.state;
     const width = this.state.width || defaultWidth;
 
@@ -322,13 +295,9 @@ class MediaGallery extends React.PureComponent {
     const style = {};
 
     if (this.isFullSizeEligible() && (standalone || !cropImages)) {
-      if (width) {
-        style.height = width / this.props.media.getIn([0, 'meta', 'small', 'aspect']);
-      }
-    } else if (width) {
-      style.height = width / (16/9);
+      style.aspectRatio = `${this.props.media.getIn([0, 'meta', 'small', 'aspect'])}`;
     } else {
-      style.height = height;
+      style.aspectRatio = '16 / 9';
     }
 
     const size     = media.take(4).size;
diff --git a/app/javascript/mastodon/components/picture_in_picture_placeholder.jsx b/app/javascript/mastodon/components/picture_in_picture_placeholder.jsx
index 6322b1c668..a51c974017 100644
--- a/app/javascript/mastodon/components/picture_in_picture_placeholder.jsx
+++ b/app/javascript/mastodon/components/picture_in_picture_placeholder.jsx
@@ -3,62 +3,22 @@ import PropTypes from 'prop-types';
 import Icon from 'mastodon/components/icon';
 import { removePictureInPicture } from 'mastodon/actions/picture_in_picture';
 import { connect } from 'react-redux';
-import { debounce } from 'lodash';
 import { FormattedMessage } from 'react-intl';
 
 class PictureInPicturePlaceholder extends React.PureComponent {
 
   static propTypes = {
-    width: PropTypes.number,
     dispatch: PropTypes.func.isRequired,
   };
 
-  state = {
-    width: this.props.width,
-    height: this.props.width && (this.props.width / (16/9)),
-  };
-
   handleClick = () => {
     const { dispatch } = this.props;
     dispatch(removePictureInPicture());
   };
 
-  setRef = c => {
-    this.node = c;
-
-    if (this.node) {
-      this._setDimensions();
-    }
-  };
-
-  _setDimensions () {
-    const width  = this.node.offsetWidth;
-    const height = width / (16/9);
-
-    this.setState({ width, height });
-  }
-
-  componentDidMount () {
-    window.addEventListener('resize', this.handleResize, { passive: true });
-  }
-
-  componentWillUnmount () {
-    window.removeEventListener('resize', this.handleResize);
-  }
-
-  handleResize = debounce(() => {
-    if (this.node) {
-      this._setDimensions();
-    }
-  }, 250, {
-    trailing: true,
-  });
-
   render () {
-    const { height } = this.state;
-
     return (
-      <div ref={this.setRef} className='picture-in-picture-placeholder' style={{ height }} role='button' tabIndex={0} onClick={this.handleClick}>
+      <div className='picture-in-picture-placeholder' role='button' tabIndex={0} onClick={this.handleClick}>
         <Icon id='window-restore' />
         <FormattedMessage id='picture_in_picture.restore' defaultMessage='Put it back' />
       </div>
diff --git a/app/javascript/mastodon/components/status.jsx b/app/javascript/mastodon/components/status.jsx
index cd8423b2f4..b5242e7691 100644
--- a/app/javascript/mastodon/components/status.jsx
+++ b/app/javascript/mastodon/components/status.jsx
@@ -411,7 +411,7 @@ class Status extends ImmutablePureComponent {
     }
 
     if (pictureInPicture.get('inUse')) {
-      media = <PictureInPicturePlaceholder width={this.props.cachedMediaWidth} />;
+      media = <PictureInPicturePlaceholder />;
     } else if (status.get('media_attachments').size > 0) {
       if (this.props.muted) {
         media = (
@@ -460,12 +460,9 @@ class Status extends ImmutablePureComponent {
                 src={attachment.get('url')}
                 alt={attachment.get('description')}
                 lang={status.get('language')}
-                width={this.props.cachedMediaWidth}
-                height={110}
                 inline
                 sensitive={status.get('sensitive')}
                 onOpenVideo={this.handleOpenVideo}
-                cacheWidth={this.props.cacheMediaWidth}
                 deployPictureInPicture={pictureInPicture.get('available') ? this.handleDeployPictureInPicture : undefined}
                 visible={this.state.showMedia}
                 onToggleVisibility={this.handleToggleMediaVisibility}
@@ -498,8 +495,6 @@ class Status extends ImmutablePureComponent {
           onOpenMedia={this.handleOpenMedia}
           card={status.get('card')}
           compact
-          cacheWidth={this.props.cacheMediaWidth}
-          defaultWidth={this.props.cachedMediaWidth}
           sensitive={status.get('sensitive')}
         />
       );
diff --git a/app/javascript/mastodon/containers/compose_container.jsx b/app/javascript/mastodon/containers/compose_container.jsx
index 7bc7bbaa4d..a4c5f3cb49 100644
--- a/app/javascript/mastodon/containers/compose_container.jsx
+++ b/app/javascript/mastodon/containers/compose_container.jsx
@@ -1,7 +1,7 @@
 import React from 'react';
 import { Provider } from 'react-redux';
 import PropTypes from 'prop-types';
-import configureStore from '../store/configureStore';
+import { store } from '../store/configureStore';
 import { hydrateStore } from '../actions/store';
 import { IntlProvider, addLocaleData } from 'react-intl';
 import { getLocale } from '../locales';
@@ -12,8 +12,6 @@ import { fetchCustomEmojis } from '../actions/custom_emojis';
 const { localeData, messages } = getLocale();
 addLocaleData(localeData);
 
-const store = configureStore();
-
 if (initialState) {
   store.dispatch(hydrateStore(initialState));
 }
diff --git a/app/javascript/mastodon/containers/mastodon.jsx b/app/javascript/mastodon/containers/mastodon.jsx
index 002b71e93d..256ea8e2d9 100644
--- a/app/javascript/mastodon/containers/mastodon.jsx
+++ b/app/javascript/mastodon/containers/mastodon.jsx
@@ -5,7 +5,7 @@ import { IntlProvider, addLocaleData } from 'react-intl';
 import { Provider as ReduxProvider } from 'react-redux';
 import { BrowserRouter, Route } from 'react-router-dom';
 import { ScrollContext } from 'react-router-scroll-4';
-import configureStore from 'mastodon/store/configureStore';
+import { store } from 'mastodon/store/configureStore';
 import UI from 'mastodon/features/ui';
 import { fetchCustomEmojis } from 'mastodon/actions/custom_emojis';
 import { hydrateStore } from 'mastodon/actions/store';
@@ -19,7 +19,6 @@ addLocaleData(localeData);
 
 const title = process.env.NODE_ENV === 'production' ? siteTitle : `${siteTitle} (Dev)`;
 
-export const store = configureStore();
 const hydrateAction = hydrateStore(initialState);
 
 store.dispatch(hydrateAction);
diff --git a/app/javascript/mastodon/features/audio/index.jsx b/app/javascript/mastodon/features/audio/index.jsx
index 53f24c6a39..e8fe2c4d9a 100644
--- a/app/javascript/mastodon/features/audio/index.jsx
+++ b/app/javascript/mastodon/features/audio/index.jsx
@@ -384,7 +384,7 @@ class Audio extends React.PureComponent {
   }
 
   _getRadius () {
-    return parseInt(((this.state.height || this.props.height) - (PADDING * this._getScaleCoefficient()) * 2) / 2);
+    return parseInt((this.state.height || this.props.height) / 2 - PADDING * this._getScaleCoefficient());
   }
 
   _getScaleCoefficient () {
@@ -396,7 +396,7 @@ class Audio extends React.PureComponent {
   }
 
   _getCY() {
-    return Math.floor(this._getRadius() + (PADDING * this._getScaleCoefficient()));
+    return Math.floor((this.state.height || this.props.height) / 2);
   }
 
   _getAccentColor () {
@@ -470,7 +470,7 @@ class Audio extends React.PureComponent {
     }
 
     return (
-      <div className={classNames('audio-player', { editable, inactive: !revealed })} ref={this.setPlayerRef} style={{ backgroundColor: this._getBackgroundColor(), color: this._getForegroundColor(), height: this.props.fullscreen ? '100%' : (this.state.height || this.props.height) }} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave} tabIndex={0} onKeyDown={this.handleKeyDown}>
+      <div className={classNames('audio-player', { editable, inactive: !revealed })} ref={this.setPlayerRef} style={{ backgroundColor: this._getBackgroundColor(), color: this._getForegroundColor(), aspectRatio: '16 / 9' }} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave} tabIndex={0} onKeyDown={this.handleKeyDown}>
 
         <Blurhash
           hash={blurhash}
@@ -515,9 +515,16 @@ class Audio extends React.PureComponent {
         {(revealed || editable) && <img
           src={this.props.poster}
           alt=''
-          width={(this._getRadius() - TICK_SIZE) * 2}
-          height={(this._getRadius() - TICK_SIZE) * 2}
-          style={{ position: 'absolute', left: this._getCX(), top: this._getCY(), transform: 'translate(-50%, -50%)', borderRadius: '50%', pointerEvents: 'none' }}
+          style={{
+            position: 'absolute',
+            left: '50%',
+            top: '50%',
+            height: `calc(${(100 - 2 * 100 * PADDING / 982)}% - ${TICK_SIZE * 2}px)`,
+            aspectRatio: '1',
+            transform: 'translate(-50%, -50%)',
+            borderRadius: '50%',
+            pointerEvents: 'none',
+          }}
         />}
 
         <div className='video-player__seek' onMouseDown={this.handleMouseDown} ref={this.setSeekRef}>
diff --git a/app/javascript/mastodon/features/status/components/card.jsx b/app/javascript/mastodon/features/status/components/card.jsx
index b67f671c50..88b38c65ad 100644
--- a/app/javascript/mastodon/features/status/components/card.jsx
+++ b/app/javascript/mastodon/features/status/components/card.jsx
@@ -8,7 +8,6 @@ import classnames from 'classnames';
 import Icon from 'mastodon/components/icon';
 import { useBlurhash } from 'mastodon/initial_state';
 import Blurhash from 'mastodon/components/blurhash';
-import { debounce } from 'lodash';
 
 const IDNA_PREFIX = 'xn--';
 
@@ -54,8 +53,6 @@ export default class Card extends React.PureComponent {
     card: ImmutablePropTypes.map,
     onOpenMedia: PropTypes.func.isRequired,
     compact: PropTypes.bool,
-    defaultWidth: PropTypes.number,
-    cacheWidth: PropTypes.func,
     sensitive: PropTypes.bool,
   };
 
@@ -64,7 +61,6 @@ export default class Card extends React.PureComponent {
   };
 
   state = {
-    width: this.props.defaultWidth || 280,
     previewLoaded: false,
     embedded: false,
     revealed: !this.props.sensitive,
@@ -87,24 +83,6 @@ export default class Card extends React.PureComponent {
     window.removeEventListener('resize', this.handleResize);
   }
 
-  _setDimensions () {
-    const width = this.node.offsetWidth;
-
-    if (this.props.cacheWidth) {
-      this.props.cacheWidth(width);
-    }
-
-    this.setState({ width });
-  }
-
-  handleResize = debounce(() => {
-    if (this.node) {
-      this._setDimensions();
-    }
-  }, 250, {
-    trailing: true,
-  });
-
   handlePhotoClick = () => {
     const { card, onOpenMedia } = this.props;
 
@@ -138,10 +116,6 @@ export default class Card extends React.PureComponent {
 
   setRef = c => {
     this.node = c;
-
-    if (this.node) {
-      this._setDimensions();
-    }
   };
 
   handleImageLoad = () => {
@@ -157,36 +131,31 @@ export default class Card extends React.PureComponent {
   renderVideo () {
     const { card }  = this.props;
     const content   = { __html: addAutoPlay(card.get('html')) };
-    const { width } = this.state;
-    const ratio     = card.get('width') / card.get('height');
-    const height    = width / ratio;
 
     return (
       <div
         ref={this.setRef}
         className='status-card__image status-card-video'
         dangerouslySetInnerHTML={content}
-        style={{ height }}
+        style={{ aspectRatio: `${card.get('width')} / ${card.get('height')}` }}
       />
     );
   }
 
   render () {
     const { card, compact } = this.props;
-    const { width, embedded, revealed } = this.state;
+    const { embedded, revealed } = this.state;
 
     if (card === null) {
       return null;
     }
 
     const provider    = card.get('provider_name').length === 0 ? decodeIDNA(getHostname(card.get('url'))) : card.get('provider_name');
-    const horizontal  = (!compact && card.get('width') > card.get('height') && (card.get('width') + 100 >= width)) || card.get('type') !== 'link' || embedded;
+    const horizontal  = (!compact && card.get('width') > card.get('height')) || card.get('type') !== 'link' || embedded;
     const interactive = card.get('type') !== 'link';
     const className   = classnames('status-card', { horizontal, compact, interactive });
     const title       = interactive ? <a className='status-card__title' href={card.get('url')} title={card.get('title')} rel='noopener noreferrer' target='_blank'><strong>{card.get('title')}</strong></a> : <strong className='status-card__title' title={card.get('title')}>{card.get('title')}</strong>;
     const language    = card.get('language') || '';
-    const ratio       = card.get('width') / card.get('height');
-    const height      = (compact && !embedded) ? (width / (16 / 9)) : (width / ratio);
 
     const description = (
       <div className='status-card__content' lang={language}>
@@ -196,6 +165,14 @@ export default class Card extends React.PureComponent {
       </div>
     );
 
+    const thumbnailStyle = {
+      visibility: revealed? null : 'hidden',
+    };
+
+    if (horizontal) {
+      thumbnailStyle.aspectRatio = (compact && !embedded) ? '16 / 9' : `${card.get('width')} / ${card.get('height')}`;
+    }
+
     let embed     = '';
     let canvas = (
       <Blurhash
@@ -206,7 +183,7 @@ export default class Card extends React.PureComponent {
         dummy={!useBlurhash}
       />
     );
-    let thumbnail = <img src={card.get('image')} alt='' style={{ width: horizontal ? width : null, height: horizontal ? height : null, visibility: revealed ? null : 'hidden' }} onLoad={this.handleImageLoad} className='status-card__image-image' />;
+    let thumbnail = <img src={card.get('image')} alt='' style={thumbnailStyle} onLoad={this.handleImageLoad} className='status-card__image-image' />;
     let spoilerButton = (
       <button type='button' onClick={this.handleReveal} className='spoiler-button__overlay'>
         <span className='spoiler-button__overlay__label'><FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' /></span>
diff --git a/app/javascript/mastodon/features/ui/index.jsx b/app/javascript/mastodon/features/ui/index.jsx
index 2178687a9e..6dc5177b5c 100644
--- a/app/javascript/mastodon/features/ui/index.jsx
+++ b/app/javascript/mastodon/features/ui/index.jsx
@@ -362,7 +362,7 @@ class UI extends React.PureComponent {
 
     if (layout !== this.props.layout) {
       this.handleLayoutChange.cancel();
-      this.props.dispatch(changeLayout(layout));
+      this.props.dispatch(changeLayout({ layout }));
     } else {
       this.handleLayoutChange();
     }
diff --git a/app/javascript/mastodon/features/video/index.jsx b/app/javascript/mastodon/features/video/index.jsx
index e2637e0bef..d76d34546b 100644
--- a/app/javascript/mastodon/features/video/index.jsx
+++ b/app/javascript/mastodon/features/video/index.jsx
@@ -2,7 +2,7 @@ import React from 'react';
 import PropTypes from 'prop-types';
 import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
 import { is } from 'immutable';
-import { throttle, debounce } from 'lodash';
+import { throttle } from 'lodash';
 import classNames from 'classnames';
 import { isFullscreen, requestFullscreen, exitFullscreen } from '../ui/util/fullscreen';
 import { displayMedia, useBlurhash } from '../../initial_state';
@@ -102,8 +102,6 @@ class Video extends React.PureComponent {
     src: PropTypes.string.isRequired,
     alt: PropTypes.string,
     lang: PropTypes.string,
-    width: PropTypes.number,
-    height: PropTypes.number,
     sensitive: PropTypes.bool,
     currentTime: PropTypes.number,
     onOpenVideo: PropTypes.func,
@@ -112,7 +110,6 @@ class Video extends React.PureComponent {
     inline: PropTypes.bool,
     editable: PropTypes.bool,
     alwaysVisible: PropTypes.bool,
-    cacheWidth: PropTypes.func,
     visible: PropTypes.bool,
     onToggleVisibility: PropTypes.func,
     deployPictureInPicture: PropTypes.func,
@@ -135,7 +132,6 @@ class Video extends React.PureComponent {
     volume: 0.5,
     paused: true,
     dragging: false,
-    containerWidth: this.props.width,
     fullscreen: false,
     hovered: false,
     muted: false,
@@ -144,24 +140,8 @@ class Video extends React.PureComponent {
 
   setPlayerRef = c => {
     this.player = c;
-
-    if (this.player) {
-      this._setDimensions();
-    }
   };
 
-  _setDimensions () {
-    const width = this.player.offsetWidth;
-
-    if (this.props.cacheWidth) {
-      this.props.cacheWidth(width);
-    }
-
-    this.setState({
-      containerWidth: width,
-    });
-  }
-
   setVideoRef = c => {
     this.video = c;
 
@@ -370,12 +350,10 @@ class Video extends React.PureComponent {
     document.addEventListener('MSFullscreenChange', this.handleFullscreenChange, true);
 
     window.addEventListener('scroll', this.handleScroll);
-    window.addEventListener('resize', this.handleResize, { passive: true });
   }
 
   componentWillUnmount () {
     window.removeEventListener('scroll', this.handleScroll);
-    window.removeEventListener('resize', this.handleResize);
 
     document.removeEventListener('fullscreenchange', this.handleFullscreenChange, true);
     document.removeEventListener('webkitfullscreenchange', this.handleFullscreenChange, true);
@@ -404,14 +382,6 @@ class Video extends React.PureComponent {
     }
   }
 
-  handleResize = debounce(() => {
-    if (this.player) {
-      this._setDimensions();
-    }
-  }, 250, {
-    trailing: true,
-  });
-
   handleScroll = throttle(() => {
     if (!this.video) {
       return;
@@ -525,17 +495,12 @@ class Video extends React.PureComponent {
 
   render () {
     const { preview, src, inline, onOpenVideo, onCloseVideo, intl, alt, lang, detailed, sensitive, editable, blurhash, autoFocus } = this.props;
-    const { containerWidth, currentTime, duration, volume, buffer, dragging, paused, fullscreen, hovered, muted, revealed } = this.state;
+    const { currentTime, duration, volume, buffer, dragging, paused, fullscreen, hovered, muted, revealed } = this.state;
     const progress = Math.min((currentTime / duration) * 100, 100);
     const playerStyle = {};
 
-    let { width, height } = this.props;
-
-    if (inline && containerWidth) {
-      width  = containerWidth;
-      height = containerWidth / (16/9);
-
-      playerStyle.height = height;
+    if (inline) {
+      playerStyle.aspectRatio = '16 / 9';
     }
 
     let preload;
@@ -586,8 +551,6 @@ class Video extends React.PureComponent {
           aria-label={alt}
           title={alt}
           lang={lang}
-          width={width}
-          height={height}
           volume={volume}
           onClick={this.togglePlay}
           onKeyDown={this.handleVideoKeyDown}
@@ -596,6 +559,7 @@ class Video extends React.PureComponent {
           onLoadedData={this.handleLoadedData}
           onProgress={this.handleProgress}
           onVolumeChange={this.handleVolumeChange}
+          style={{ ...playerStyle, width: '100%' }}
         />}
 
         <div className={classNames('spoiler-button', { 'spoiler-button--hidden': revealed || editable })}>
diff --git a/app/javascript/mastodon/is_mobile.js b/app/javascript/mastodon/is_mobile.ts
similarity index 62%
rename from app/javascript/mastodon/is_mobile.js
rename to app/javascript/mastodon/is_mobile.ts
index d0669b74bf..43819f85db 100644
--- a/app/javascript/mastodon/is_mobile.js
+++ b/app/javascript/mastodon/is_mobile.ts
@@ -1,21 +1,12 @@
-// @ts-check
-
 import { supportsPassiveEvents } from 'detect-passive-events';
-// @ts-expect-error
-import { forceSingleColumn } from 'mastodon/initial_state';
+import { forceSingleColumn } from './initial_state';
 
 const LAYOUT_BREAKPOINT = 630;
 
-/**
- * @param {number} width
- * @returns {boolean}
- */
-export const isMobile = width => width <= LAYOUT_BREAKPOINT;
+export const isMobile = (width: number) => width <= LAYOUT_BREAKPOINT;
 
-/**
- * @returns {string}
- */
-export const layoutFromWindow = () => {
+export type LayoutType = 'mobile' | 'single-column' | 'multi-column';
+export const layoutFromWindow = (): LayoutType => {
   if (isMobile(window.innerWidth)) {
     return 'mobile';
   } else if (forceSingleColumn) {
@@ -25,8 +16,9 @@ export const layoutFromWindow = () => {
   }
 };
 
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
 // @ts-expect-error
-const iOS = /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream;
+const iOS = /iPad|iPhone|iPod/.test(navigator.userAgent) && window.MSStream != null;
 
 const listenerOptions = supportsPassiveEvents ? { passive: true } : false;
 
diff --git a/app/javascript/mastodon/main.jsx b/app/javascript/mastodon/main.jsx
index 69a7ee91f9..88a205dd24 100644
--- a/app/javascript/mastodon/main.jsx
+++ b/app/javascript/mastodon/main.jsx
@@ -1,7 +1,8 @@
 import React from 'react';
 import ReactDOM from 'react-dom';
 import { setupBrowserNotifications } from 'mastodon/actions/notifications';
-import Mastodon, { store } from 'mastodon/containers/mastodon';
+import Mastodon from 'mastodon/containers/mastodon';
+import { store } from 'mastodon/store/configureStore';
 import { me } from 'mastodon/initial_state';
 import ready from 'mastodon/ready';
 
diff --git a/app/javascript/mastodon/permissions.js b/app/javascript/mastodon/permissions.ts
similarity index 100%
rename from app/javascript/mastodon/permissions.js
rename to app/javascript/mastodon/permissions.ts
diff --git a/app/javascript/mastodon/reducers/meta.js b/app/javascript/mastodon/reducers/meta.js
index 755dd73905..16ce751aad 100644
--- a/app/javascript/mastodon/reducers/meta.js
+++ b/app/javascript/mastodon/reducers/meta.js
@@ -1,5 +1,5 @@
 import { STORE_HYDRATE } from 'mastodon/actions/store';
-import { APP_LAYOUT_CHANGE } from 'mastodon/actions/app';
+import { changeLayout } from 'mastodon/actions/app';
 import { Map as ImmutableMap } from 'immutable';
 import { layoutFromWindow } from 'mastodon/is_mobile';
 
@@ -14,8 +14,8 @@ export default function meta(state = initialState, action) {
   switch(action.type) {
   case STORE_HYDRATE:
     return state.merge(action.state.get('meta')).set('permissions', action.state.getIn(['role', 'permissions']));
-  case APP_LAYOUT_CHANGE:
-    return state.set('layout', action.layout);
+  case changeLayout.type:
+    return state.set('layout', action.payload.layout);
   default:
     return state;
   }
diff --git a/app/javascript/mastodon/reducers/missed_updates.js b/app/javascript/mastodon/reducers/missed_updates.js
deleted file mode 100644
index a3141d854e..0000000000
--- a/app/javascript/mastodon/reducers/missed_updates.js
+++ /dev/null
@@ -1,21 +0,0 @@
-import { Map as ImmutableMap } from 'immutable';
-import { NOTIFICATIONS_UPDATE } from 'mastodon/actions/notifications';
-import { APP_FOCUS, APP_UNFOCUS } from 'mastodon/actions/app';
-
-const initialState = ImmutableMap({
-  focused: true,
-  unread: 0,
-});
-
-export default function missed_updates(state = initialState, action) {
-  switch(action.type) {
-  case APP_FOCUS:
-    return state.set('focused', true).set('unread', 0);
-  case APP_UNFOCUS:
-    return state.set('focused', false);
-  case NOTIFICATIONS_UPDATE:
-    return state.get('focused') ? state : state.update('unread', x => x + 1);
-  default:
-    return state;
-  }
-}
diff --git a/app/javascript/mastodon/reducers/missed_updates.ts b/app/javascript/mastodon/reducers/missed_updates.ts
new file mode 100644
index 0000000000..043fe93faf
--- /dev/null
+++ b/app/javascript/mastodon/reducers/missed_updates.ts
@@ -0,0 +1,31 @@
+import { Record } from 'immutable';
+import type { Action } from 'redux';
+import { NOTIFICATIONS_UPDATE } from '../actions/notifications';
+import { focusApp, unfocusApp } from '../actions/app';
+
+type MissedUpdatesState = {
+  focused: boolean;
+  unread: number;
+};
+const initialState = Record<MissedUpdatesState>({
+  focused: true,
+  unread: 0,
+})();
+
+export default function missed_updates(
+  state = initialState,
+  action: Action<string>,
+) {
+  switch (action.type) {
+  case focusApp.type:
+    return state.set('focused', true).set('unread', 0);
+  case unfocusApp.type:
+    return state.set('focused', false);
+  case NOTIFICATIONS_UPDATE:
+    return state.get('focused')
+      ? state
+      : state.update('unread', (x) => x + 1);
+  default:
+    return state;
+  }
+}
diff --git a/app/javascript/mastodon/reducers/notifications.js b/app/javascript/mastodon/reducers/notifications.js
index 44fa1c6134..0530a52b4c 100644
--- a/app/javascript/mastodon/reducers/notifications.js
+++ b/app/javascript/mastodon/reducers/notifications.js
@@ -23,8 +23,8 @@ import {
   MARKERS_FETCH_SUCCESS,
 } from '../actions/markers';
 import {
-  APP_FOCUS,
-  APP_UNFOCUS,
+  focusApp,
+  unfocusApp,
 } from '../actions/app';
 import { DOMAIN_BLOCK_SUCCESS } from 'mastodon/actions/domain_blocks';
 import { TIMELINE_DELETE, TIMELINE_DISCONNECT } from '../actions/timelines';
@@ -258,9 +258,9 @@ export default function notifications(state = initialState, action) {
     return updateMounted(state);
   case NOTIFICATIONS_UNMOUNT:
     return state.update('mounted', count => count - 1);
-  case APP_FOCUS:
+  case focusApp.type:
     return updateVisibility(state, true);
-  case APP_UNFOCUS:
+  case unfocusApp.type:
     return updateVisibility(state, false);
   case NOTIFICATIONS_LOAD_PENDING:
     return state.update('items', list => state.get('pendingItems').concat(list.take(40))).set('pendingItems', ImmutableList()).set('unread', 0);
diff --git a/app/javascript/flavours/glitch/scroll.js b/app/javascript/mastodon/scroll.ts
similarity index 51%
rename from app/javascript/flavours/glitch/scroll.js
rename to app/javascript/mastodon/scroll.ts
index 84fe582699..1e509c4175 100644
--- a/app/javascript/flavours/glitch/scroll.js
+++ b/app/javascript/mastodon/scroll.ts
@@ -1,6 +1,5 @@
-const easingOutQuint = (x, t, b, c, d) => c * ((t = t / d - 1) * t * t * t * t + 1) + b;
-
-const scroll = (node, key, target) => {
+const easingOutQuint = (x: number, t: number, b: number, c: number, d: number) => c * ((t = t / d - 1) * t * t * t * t + 1) + b;
+const scroll = (node: Element, key: 'scrollTop' | 'scrollLeft', target: number) => {
   const startTime = Date.now();
   const offset    = node[key];
   const gap       = target - offset;
@@ -28,5 +27,5 @@ const scroll = (node, key, target) => {
 
 const isScrollBehaviorSupported = 'scrollBehavior' in document.documentElement.style;
 
-export const scrollRight = (node, position) => isScrollBehaviorSupported ? node.scrollTo({ left: position, behavior: 'smooth' }) : scroll(node, 'scrollLeft', position);
-export const scrollTop = (node) => isScrollBehaviorSupported ? node.scrollTo({ top: 0, behavior: 'smooth' }) : scroll(node, 'scrollTop', 0);
+export const scrollRight = (node: Element, position: number) => isScrollBehaviorSupported ? node.scrollTo({ left: position, behavior: 'smooth' }) : scroll(node, 'scrollLeft', position);
+export const scrollTop = (node: Element) => isScrollBehaviorSupported ? node.scrollTo({ top: 0, behavior: 'smooth' }) : scroll(node, 'scrollTop', 0);
diff --git a/app/javascript/mastodon/store/configureStore.js b/app/javascript/mastodon/store/configureStore.js
index 0e0d45c668..cb17dd9ce8 100644
--- a/app/javascript/mastodon/store/configureStore.js
+++ b/app/javascript/mastodon/store/configureStore.js
@@ -1,15 +1,16 @@
-import { createStore, applyMiddleware, compose } from 'redux';
+import { configureStore } from '@reduxjs/toolkit';
 import thunk from 'redux-thunk';
 import appReducer from '../reducers';
 import loadingBarMiddleware from '../middleware/loading_bar';
 import errorsMiddleware from '../middleware/errors';
 import soundsMiddleware from '../middleware/sounds';
 
-export default function configureStore() {
-  return createStore(appReducer, compose(applyMiddleware(
+export const store = configureStore({
+  reducer: appReducer,
+  middleware: [
     thunk,
     loadingBarMiddleware({ promiseTypeSuffixes: ['REQUEST', 'SUCCESS', 'FAIL'] }),
     errorsMiddleware(),
     soundsMiddleware(),
-  ), window.__REDUX_DEVTOOLS_EXTENSION__ ? window.__REDUX_DEVTOOLS_EXTENSION__() : f => f));
-}
+  ],
+});
diff --git a/app/javascript/mastodon/utils/base64.js b/app/javascript/mastodon/utils/base64.ts
similarity index 79%
rename from app/javascript/mastodon/utils/base64.js
rename to app/javascript/mastodon/utils/base64.ts
index 8226e2c54e..5a595ee12b 100644
--- a/app/javascript/mastodon/utils/base64.js
+++ b/app/javascript/mastodon/utils/base64.ts
@@ -1,4 +1,4 @@
-export const decode = base64 => {
+export const decode = (base64: string): Uint8Array => {
   const rawData = window.atob(base64);
   const outputArray = new Uint8Array(rawData.length);
 
diff --git a/app/javascript/flavours/glitch/utils/filters.js b/app/javascript/mastodon/utils/filters.ts
similarity index 83%
rename from app/javascript/flavours/glitch/utils/filters.js
rename to app/javascript/mastodon/utils/filters.ts
index 97b433a991..5af2aa96a4 100644
--- a/app/javascript/flavours/glitch/utils/filters.js
+++ b/app/javascript/mastodon/utils/filters.ts
@@ -1,4 +1,4 @@
-export const toServerSideType = columnType => {
+export const toServerSideType = (columnType: string) => {
   switch (columnType) {
   case 'home':
   case 'notifications':
diff --git a/app/javascript/mastodon/utils/hashtags.js b/app/javascript/mastodon/utils/hashtags.ts
similarity index 100%
rename from app/javascript/mastodon/utils/hashtags.js
rename to app/javascript/mastodon/utils/hashtags.ts
diff --git a/app/javascript/mastodon/utils/numbers.js b/app/javascript/mastodon/utils/numbers.ts
similarity index 71%
rename from app/javascript/mastodon/utils/numbers.js
rename to app/javascript/mastodon/utils/numbers.ts
index fa3d58fad1..35af8a973c 100644
--- a/app/javascript/mastodon/utils/numbers.js
+++ b/app/javascript/mastodon/utils/numbers.ts
@@ -1,23 +1,19 @@
-// @ts-check
+import type { ValueOf } from '../../types/util';
 
 export const DECIMAL_UNITS = Object.freeze({
   ONE: 1,
   TEN: 10,
-  HUNDRED: Math.pow(10, 2),
-  THOUSAND: Math.pow(10, 3),
-  MILLION: Math.pow(10, 6),
-  BILLION: Math.pow(10, 9),
-  TRILLION: Math.pow(10, 12),
+  HUNDRED: 100,
+  THOUSAND: 1_000,
+  MILLION: 1_000_000,
+  BILLION: 1_000_000_000,
+  TRILLION: 1_000_000_000_000,
 });
+export type DecimalUnits = ValueOf<typeof DECIMAL_UNITS>;
 
 const TEN_THOUSAND = DECIMAL_UNITS.THOUSAND * 10;
 const TEN_MILLIONS = DECIMAL_UNITS.MILLION * 10;
 
-/**
- * @typedef {[number, number, number]} ShortNumber
- * Array of: shorten number, unit of shorten number and maximum fraction digits
- */
-
 /**
  * @param {number} sourceNumber Number to convert to short number
  * @returns {ShortNumber} Calculated short number
@@ -25,7 +21,8 @@ const TEN_MILLIONS = DECIMAL_UNITS.MILLION * 10;
  * shortNumber(5936);
  * // => [5.936, 1000, 1]
  */
-export function toShortNumber(sourceNumber) {
+export type ShortNumber = [number, DecimalUnits, 0 | 1] // Array of: shorten number, unit of shorten number and maximum fraction digits
+export function toShortNumber(sourceNumber: number): ShortNumber {
   if (sourceNumber < DECIMAL_UNITS.THOUSAND) {
     return [sourceNumber, DECIMAL_UNITS.ONE, 0];
   } else if (sourceNumber < DECIMAL_UNITS.MILLION) {
@@ -59,20 +56,16 @@ export function toShortNumber(sourceNumber) {
  * pluralReady(1793, DECIMAL_UNITS.THOUSAND)
  * // => 1790
  */
-export function pluralReady(sourceNumber, division) {
+export function pluralReady(sourceNumber: number, division: DecimalUnits): number {
   if (division == null || division < DECIMAL_UNITS.HUNDRED) {
     return sourceNumber;
   }
 
-  let closestScale = division / DECIMAL_UNITS.TEN;
+  const closestScale = division / DECIMAL_UNITS.TEN;
 
   return Math.trunc(sourceNumber / closestScale) * closestScale;
 }
 
-/**
- * @param {number} num
- * @returns {number}
- */
-export function roundTo10(num) {
+export function roundTo10(num: number): number {
   return Math.round(num * 0.1) / 0.1;
 }
diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss
index bd01d8bd1e..4c36411a33 100644
--- a/app/javascript/styles/mastodon/components.scss
+++ b/app/javascript/styles/mastodon/components.scss
@@ -1784,7 +1784,6 @@ a.account__display-name {
 .status__avatar {
   width: 46px;
   height: 46px;
-  box-shadow: 0 0 0 2px $ui-base-color;
 }
 
 .muted {
@@ -3110,6 +3109,10 @@ $ui-header-height: 55px;
 }
 
 .compose-form__highlightable {
+  display: flex;
+  flex-direction: column;
+  overflow: hidden;
+  flex: 0 1 auto;
   border-radius: 4px;
   transition: box-shadow 300ms linear;
 
@@ -3804,6 +3807,10 @@ a.status-card {
 }
 
 .status-card-video {
+  // Firefox has a bug where frameborder=0 iframes add some extra blank space
+  // see https://bugzilla.mozilla.org/show_bug.cgi?id=155174
+  overflow: hidden;
+
   iframe {
     width: 100%;
     height: 100%;
@@ -6326,30 +6333,25 @@ a.status-card.compact:hover {
   z-index: 9999;
 }
 
-.media-gallery__gifv__label {
-  display: block;
+.media-gallery__item__badges {
   position: absolute;
-  color: $primary-text-color;
-  background: rgba($base-overlay-background, 0.5);
   bottom: 6px;
   inset-inline-start: 6px;
-  padding: 2px 6px;
-  border-radius: 2px;
-  font-size: 11px;
-  font-weight: 600;
-  z-index: 1;
-  pointer-events: none;
-  opacity: 0.9;
-  transition: opacity 0.1s ease;
-  line-height: 18px;
+  display: flex;
+  gap: 2px;
 }
 
-.media-gallery__gifv {
-  &:hover {
-    .media-gallery__gifv__label {
-      opacity: 1;
-    }
-  }
+.media-gallery__gifv__label {
+  display: block;
+  color: $white;
+  background: rgba($black, 0.65);
+  padding: 2px 6px;
+  border-radius: 4px;
+  font-size: 11px;
+  font-weight: 700;
+  z-index: 1;
+  pointer-events: none;
+  line-height: 18px;
 }
 
 .attachment-list {
@@ -6424,17 +6426,28 @@ a.status-card.compact:hover {
   position: relative;
   width: 100%;
   min-height: 64px;
+  display: grid;
+  grid-template-columns: 50% 50%;
+  grid-template-rows: 50% 50%;
+  gap: 2px;
 }
 
 .media-gallery__item {
   border: 0;
   box-sizing: border-box;
   display: block;
-  float: left;
   position: relative;
   border-radius: 4px;
   overflow: hidden;
 
+  &--tall {
+    grid-row: span 2;
+  }
+
+  &--wide {
+    grid-column: span 2;
+  }
+
   &.standalone {
     .media-gallery__item-gifv-thumbnail {
       transform: none;
@@ -8332,6 +8345,7 @@ noscript {
   font-weight: 500;
   cursor: pointer;
   color: $darker-text-color;
+  aspect-ratio: 16 / 9;
 
   i {
     display: block;
diff --git a/app/javascript/types/resources.ts b/app/javascript/types/resources.ts
index 372ff7523b..28fad2719a 100644
--- a/app/javascript/types/resources.ts
+++ b/app/javascript/types/resources.ts
@@ -1,8 +1,4 @@
-interface MastodonMap<T> {
-  get<K extends keyof T>(key: K): T[K];
-  has<K extends keyof T>(key: K): boolean;
-  set<K extends keyof T>(key: K, value: T[K]): this;
-}
+import type { Record } from 'immutable';
 
 type AccountValues = {
   id: number;
@@ -10,4 +6,5 @@ type AccountValues = {
   avatar_static: string;
   [key: string]: any;
 };
-export type Account = MastodonMap<AccountValues>;
+
+export type Account = Record<AccountValues>;
diff --git a/app/javascript/types/util.ts b/app/javascript/types/util.ts
new file mode 100644
index 0000000000..5f2cf2cf07
--- /dev/null
+++ b/app/javascript/types/util.ts
@@ -0,0 +1 @@
+export type ValueOf<T> = T[keyof T];
diff --git a/app/lib/activity_tracker.rb b/app/lib/activity_tracker.rb
index 8829d8605f..9160ef22a6 100644
--- a/app/lib/activity_tracker.rb
+++ b/app/lib/activity_tracker.rb
@@ -43,7 +43,7 @@ class ActivityTracker
 
     case @type
     when :basic
-      redis.mget(*keys).map(&:to_i).sum
+      redis.mget(*keys).sum(&:to_i)
     when :unique
       redis.pfcount(*keys)
     end
diff --git a/app/lib/activitypub/activity/announce.rb b/app/lib/activitypub/activity/announce.rb
index e6674be8ae..9dcafff3ab 100644
--- a/app/lib/activitypub/activity/announce.rb
+++ b/app/lib/activitypub/activity/announce.rb
@@ -4,7 +4,7 @@ class ActivityPub::Activity::Announce < ActivityPub::Activity
   def perform
     return reject_payload! if delete_arrived_first?(@json['id']) || !related_to_local_activity?
 
-    with_lock("announce:#{value_or_id(@object)}") do
+    with_redis_lock("announce:#{value_or_id(@object)}") do
       original_status = status_from_object
 
       return reject_payload! if original_status.nil? || !announceable?(original_status)
diff --git a/app/lib/activitypub/activity/create.rb b/app/lib/activitypub/activity/create.rb
index b1f7a7798f..3fec5a2f7f 100644
--- a/app/lib/activitypub/activity/create.rb
+++ b/app/lib/activitypub/activity/create.rb
@@ -47,7 +47,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
   def create_status
     return reject_payload! if unsupported_object_type? || non_matching_uri_hosts?(@account.uri, object_uri) || tombstone_exists? || !related_to_local_activity?
 
-    with_lock("create:#{object_uri}") do
+    with_redis_lock("create:#{object_uri}") do
       return if delete_arrived_first?(object_uri) || poll_vote?
 
       @status = find_existing_status
@@ -313,7 +313,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
     poll = replied_to_status.preloadable_poll
     already_voted = true
 
-    with_lock("vote:#{replied_to_status.poll_id}:#{@account.id}") do
+    with_redis_lock("vote:#{replied_to_status.poll_id}:#{@account.id}") do
       already_voted = poll.votes.where(account: @account).exists?
       poll.votes.create!(account: @account, choice: poll.options.index(@object['name']), uri: object_uri)
     end
diff --git a/app/lib/activitypub/activity/delete.rb b/app/lib/activitypub/activity/delete.rb
index 3af500f2b1..61f6ca6997 100644
--- a/app/lib/activitypub/activity/delete.rb
+++ b/app/lib/activitypub/activity/delete.rb
@@ -12,7 +12,7 @@ class ActivityPub::Activity::Delete < ActivityPub::Activity
   private
 
   def delete_person
-    with_lock("delete_in_progress:#{@account.id}", autorelease: 2.hours, raise_on_failure: false) do
+    with_redis_lock("delete_in_progress:#{@account.id}", autorelease: 2.hours, raise_on_failure: false) do
       DeleteAccountService.new.call(@account, reserve_username: false, skip_activitypub: true)
     end
   end
@@ -20,14 +20,14 @@ class ActivityPub::Activity::Delete < ActivityPub::Activity
   def delete_note
     return if object_uri.nil?
 
-    with_lock("delete_status_in_progress:#{object_uri}", raise_on_failure: false) do
+    with_redis_lock("delete_status_in_progress:#{object_uri}", raise_on_failure: false) do
       unless non_matching_uri_hosts?(@account.uri, object_uri)
         # This lock ensures a concurrent `ActivityPub::Activity::Create` either
         # does not create a status at all, or has finished saving it to the
         # database before we try to load it.
         # Without the lock, `delete_later!` could be called after `delete_arrived_first?`
         # and `Status.find` before `Status.create!`
-        with_lock("create:#{object_uri}") { delete_later!(object_uri) }
+        with_redis_lock("create:#{object_uri}") { delete_later!(object_uri) }
 
         Tombstone.find_or_create_by(uri: object_uri, account: @account)
       end
diff --git a/app/lib/activitypub/case_transform.rb b/app/lib/activitypub/case_transform.rb
index d36e01b8f2..da2c5eb8b0 100644
--- a/app/lib/activitypub/case_transform.rb
+++ b/app/lib/activitypub/case_transform.rb
@@ -13,7 +13,7 @@ module ActivityPub::CaseTransform
       when Symbol then camel_lower(value.to_s).to_sym
       when String
         camel_lower_cache[value] ||= if value.start_with?('_:')
-                                       "_:#{value.gsub(/\A_:/, '').underscore.camelize(:lower)}"
+                                       "_:#{value.delete_prefix('_:').underscore.camelize(:lower)}"
                                      else
                                        value.underscore.camelize(:lower)
                                      end
diff --git a/app/lib/feed_manager.rb b/app/lib/feed_manager.rb
index 15ff6d15f1..e98ae2d704 100644
--- a/app/lib/feed_manager.rb
+++ b/app/lib/feed_manager.rb
@@ -407,10 +407,10 @@ class FeedManager
     return true  if crutches[:languages][status.account_id].present? && status.language.present? && !crutches[:languages][status.account_id].include?(status.language)
 
     check_for_blocks = crutches[:active_mentions][status.id] || []
-    check_for_blocks.concat([status.account_id])
+    check_for_blocks.push(status.account_id)
 
     if status.reblog?
-      check_for_blocks.concat([status.reblog.account_id])
+      check_for_blocks.push(status.reblog.account_id)
       check_for_blocks.concat(crutches[:active_mentions][status.reblog_of_id] || [])
     end
 
@@ -446,7 +446,7 @@ class FeedManager
     # the notification has been checked for mute/block. Therefore, it's not
     # necessary to check the author of the toot for mute/block again
     check_for_blocks = status.active_mentions.pluck(:account_id)
-    check_for_blocks.concat([status.in_reply_to_account]) if status.reply? && !status.in_reply_to_account_id.nil?
+    check_for_blocks.push(status.in_reply_to_account) if status.reply? && !status.in_reply_to_account_id.nil?
 
     should_filter   = blocks_or_mutes?(receiver_id, check_for_blocks, :mentions)                                                         # Filter if it's from someone I blocked, in reply to someone I blocked, or mentioning someone I blocked (or muted)
     should_filter ||= (status.account.silenced? && !Follow.where(account_id: receiver_id, target_account_id: status.account_id).exists?) # of if the account is silenced and I'm not following them
@@ -593,10 +593,10 @@ class FeedManager
 
     check_for_blocks = statuses.flat_map do |s|
       arr = crutches[:active_mentions][s.id] || []
-      arr.concat([s.account_id])
+      arr.push(s.account_id)
 
       if s.reblog?
-        arr.concat([s.reblog.account_id])
+        arr.push(s.reblog.account_id)
         arr.concat(crutches[:active_mentions][s.reblog_of_id] || [])
       end
 
diff --git a/app/lib/importer/accounts_index_importer.rb b/app/lib/importer/accounts_index_importer.rb
index 792a31b1bd..fd869c3960 100644
--- a/app/lib/importer/accounts_index_importer.rb
+++ b/app/lib/importer/accounts_index_importer.rb
@@ -6,8 +6,8 @@ class Importer::AccountsIndexImporter < Importer::BaseImporter
       in_work_unit(tmp) do |accounts|
         bulk = Chewy::Index::Import::BulkBuilder.new(index, to_index: accounts).bulk_body
 
-        indexed = bulk.select { |entry| entry[:index] }.size
-        deleted = bulk.select { |entry| entry[:delete] }.size
+        indexed = bulk.count { |entry| entry[:index] }
+        deleted = bulk.count { |entry| entry[:delete] }
 
         Chewy::Index::Import::BulkRequest.new(index).perform(bulk)
 
diff --git a/app/lib/importer/tags_index_importer.rb b/app/lib/importer/tags_index_importer.rb
index f5bd8f052b..77710ed7de 100644
--- a/app/lib/importer/tags_index_importer.rb
+++ b/app/lib/importer/tags_index_importer.rb
@@ -6,8 +6,8 @@ class Importer::TagsIndexImporter < Importer::BaseImporter
       in_work_unit(tmp) do |tags|
         bulk = Chewy::Index::Import::BulkBuilder.new(index, to_index: tags).bulk_body
 
-        indexed = bulk.select { |entry| entry[:index] }.size
-        deleted = bulk.select { |entry| entry[:delete] }.size
+        indexed = bulk.count { |entry| entry[:index] }
+        deleted = bulk.count { |entry| entry[:delete] }
 
         Chewy::Index::Import::BulkRequest.new(index).perform(bulk)
 
diff --git a/app/lib/permalink_redirector.rb b/app/lib/permalink_redirector.rb
index cf1a376251..0fcec683d9 100644
--- a/app/lib/permalink_redirector.rb
+++ b/app/lib/permalink_redirector.rb
@@ -8,21 +8,51 @@ class PermalinkRedirector
   end
 
   def redirect_path
-    if path_segments[0].present? && path_segments[0].start_with?('@') && path_segments[1] =~ /\d/
-      find_status_url_by_id(path_segments[1])
-    elsif path_segments[0].present? && path_segments[0].start_with?('@')
-      find_account_url_by_name(path_segments[0])
-    elsif path_segments[0] == 'statuses' && path_segments[1] =~ /\d/
-      find_status_url_by_id(path_segments[1])
-    elsif path_segments[0] == 'accounts' && path_segments[1] =~ /\d/
-      find_account_url_by_id(path_segments[1])
+    if at_username_status_request? || statuses_status_request?
+      find_status_url_by_id(second_segment)
+    elsif at_username_request?
+      find_account_url_by_name(first_segment)
+    elsif accounts_request? && record_integer_id_request?
+      find_account_url_by_id(second_segment)
     end
   end
 
   private
 
+  def at_username_status_request?
+    at_username_request? && record_integer_id_request?
+  end
+
+  def statuses_status_request?
+    statuses_request? && record_integer_id_request?
+  end
+
+  def at_username_request?
+    first_segment.present? && first_segment.start_with?('@')
+  end
+
+  def statuses_request?
+    first_segment == 'statuses'
+  end
+
+  def accounts_request?
+    first_segment == 'accounts'
+  end
+
+  def record_integer_id_request?
+    second_segment =~ /\d/
+  end
+
+  def first_segment
+    path_segments.first
+  end
+
+  def second_segment
+    path_segments.second
+  end
+
   def path_segments
-    @path_segments ||= @path.gsub(/\A\//, '').split('/')
+    @path_segments ||= @path.delete_prefix('/').split('/')
   end
 
   def find_status_url_by_id(id)
diff --git a/app/lib/vacuum/imports_vacuum.rb b/app/lib/vacuum/imports_vacuum.rb
new file mode 100644
index 0000000000..ffb9449a42
--- /dev/null
+++ b/app/lib/vacuum/imports_vacuum.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+class Vacuum::ImportsVacuum
+  def perform
+    clean_unconfirmed_imports!
+    clean_old_imports!
+  end
+
+  private
+
+  def clean_unconfirmed_imports!
+    BulkImport.where(state: :unconfirmed).where('created_at <= ?', 10.minutes.ago).reorder(nil).in_batches.delete_all
+  end
+
+  def clean_old_imports!
+    BulkImport.where('created_at <= ?', 1.week.ago).reorder(nil).in_batches.delete_all
+  end
+end
diff --git a/app/lib/webfinger_resource.rb b/app/lib/webfinger_resource.rb
index 4209454859..7e1a7196d7 100644
--- a/app/lib/webfinger_resource.rb
+++ b/app/lib/webfinger_resource.rb
@@ -57,7 +57,7 @@ class WebfingerResource
   end
 
   def resource_without_acct_string
-    resource.gsub(/\Aacct:/, '')
+    resource.delete_prefix('acct:')
   end
 
   def local_username
diff --git a/app/models/account.rb b/app/models/account.rb
index 9aefbab900..30af67615b 100644
--- a/app/models/account.rb
+++ b/app/models/account.rb
@@ -78,6 +78,7 @@ class Account < ApplicationRecord
   include DomainNormalizable
   include DomainMaterializable
   include AccountMerging
+  include AccountSearch
 
   MAX_DISPLAY_NAME_LENGTH = (ENV['MAX_DISPLAY_NAME_CHARS'] || 30).to_i
   MAX_NOTE_LENGTH = (ENV['MAX_BIO_CHARS'] || 500).to_i
@@ -408,14 +409,6 @@ class Account < ApplicationRecord
   end
 
   class << self
-    DISALLOWED_TSQUERY_CHARACTERS = /['?\\:‘’]/
-    TEXTSEARCH = "(setweight(to_tsvector('simple', accounts.display_name), 'A') || setweight(to_tsvector('simple', accounts.username), 'B') || setweight(to_tsvector('simple', coalesce(accounts.domain, '')), 'C'))"
-
-    REPUTATION_SCORE_FUNCTION = '(greatest(0, coalesce(s.followers_count, 0)) / (greatest(0, coalesce(s.following_count, 0)) + 1.0))'
-    FOLLOWERS_SCORE_FUNCTION  = 'log(greatest(0, coalesce(s.followers_count, 0)) + 2)'
-    TIME_DISTANCE_FUNCTION    = '(case when s.last_status_at is null then 0 else exp(-1.0 * ((greatest(0, abs(extract(DAY FROM age(s.last_status_at))) - 30.0)^2) / (2.0 * ((-1.0 * 30^2) / (2.0 * ln(0.3)))))) end)'
-    BOOST                     = "((#{REPUTATION_SCORE_FUNCTION} + #{FOLLOWERS_SCORE_FUNCTION} + #{TIME_DISTANCE_FUNCTION}) / 3.0)"
-
     def readonly_attributes
       super - %w(statuses_count following_count followers_count)
     end
@@ -425,37 +418,6 @@ class Account < ApplicationRecord
       DeliveryFailureTracker.without_unavailable(urls)
     end
 
-    def search_for(terms, limit: 10, offset: 0)
-      tsquery = generate_query_for_search(terms)
-
-      sql = <<-SQL.squish
-        SELECT
-          accounts.*,
-          #{BOOST} * ts_rank_cd(#{TEXTSEARCH}, to_tsquery('simple', :tsquery), 32) AS rank
-        FROM accounts
-        LEFT JOIN users ON accounts.id = users.account_id
-        LEFT JOIN account_stats AS s ON accounts.id = s.account_id
-        WHERE to_tsquery('simple', :tsquery) @@ #{TEXTSEARCH}
-          AND accounts.suspended_at IS NULL
-          AND accounts.moved_to_account_id IS NULL
-          AND (accounts.domain IS NOT NULL OR (users.approved = TRUE AND users.confirmed_at IS NOT NULL))
-        ORDER BY rank DESC
-        LIMIT :limit OFFSET :offset
-      SQL
-
-      records = find_by_sql([sql, limit: limit, offset: offset, tsquery: tsquery])
-      ActiveRecord::Associations::Preloader.new.preload(records, :account_stat)
-      records
-    end
-
-    def advanced_search_for(terms, account, limit: 10, following: false, offset: 0)
-      tsquery = generate_query_for_search(terms)
-      sql = advanced_search_for_sql_template(following)
-      records = find_by_sql([sql, id: account.id, limit: limit, offset: offset, tsquery: tsquery])
-      ActiveRecord::Associations::Preloader.new.preload(records, :account_stat)
-      records
-    end
-
     def from_text(text)
       return [] if text.blank?
 
@@ -469,73 +431,15 @@ class Account < ApplicationRecord
         EntityCache.instance.mention(username, domain)
       end
     end
-
-    private
-
-    def generate_query_for_search(unsanitized_terms)
-      terms = unsanitized_terms.gsub(DISALLOWED_TSQUERY_CHARACTERS, ' ')
-
-      # The final ":*" is for prefix search.
-      # The trailing space does not seem to fit any purpose, but `to_tsquery`
-      # behaves differently with and without a leading space if the terms start
-      # with `./`, `../`, or `.. `. I don't understand why, so, in doubt, keep
-      # the same query.
-      "' #{terms} ':*"
-    end
-
-    def advanced_search_for_sql_template(following)
-      if following
-        <<-SQL.squish
-          WITH first_degree AS (
-            SELECT target_account_id
-            FROM follows
-            WHERE account_id = :id
-            UNION ALL
-            SELECT :id
-          )
-          SELECT
-            accounts.*,
-            (count(f.id) + 1) * #{BOOST} * ts_rank_cd(#{TEXTSEARCH}, to_tsquery('simple', :tsquery), 32) AS rank
-          FROM accounts
-          LEFT OUTER JOIN follows AS f ON (accounts.id = f.account_id AND f.target_account_id = :id)
-          LEFT JOIN account_stats AS s ON accounts.id = s.account_id
-          WHERE accounts.id IN (SELECT * FROM first_degree)
-            AND to_tsquery('simple', :tsquery) @@ #{TEXTSEARCH}
-            AND accounts.suspended_at IS NULL
-            AND accounts.moved_to_account_id IS NULL
-          GROUP BY accounts.id, s.id
-          ORDER BY rank DESC
-          LIMIT :limit OFFSET :offset
-        SQL
-      else
-        <<-SQL.squish
-          SELECT
-            accounts.*,
-            #{BOOST} * ts_rank_cd(#{TEXTSEARCH}, to_tsquery('simple', :tsquery), 32) AS rank,
-            count(f.id) AS followed
-          FROM accounts
-          LEFT OUTER JOIN follows AS f ON (accounts.id = f.account_id AND f.target_account_id = :id) OR (accounts.id = f.target_account_id AND f.account_id = :id)
-          LEFT JOIN users ON accounts.id = users.account_id
-          LEFT JOIN account_stats AS s ON accounts.id = s.account_id
-          WHERE to_tsquery('simple', :tsquery) @@ #{TEXTSEARCH}
-            AND accounts.suspended_at IS NULL
-            AND accounts.moved_to_account_id IS NULL
-            AND (accounts.domain IS NOT NULL OR (users.approved = TRUE AND users.confirmed_at IS NOT NULL))
-          GROUP BY accounts.id, s.id
-          ORDER BY followed DESC, rank DESC
-          LIMIT :limit OFFSET :offset
-        SQL
-      end
-    end
   end
 
   def emojis
     @emojis ||= CustomEmoji.from_text(emojifiable_text, domain)
   end
 
-  before_create :generate_keys
   before_validation :prepare_contents, if: :local?
   before_validation :prepare_username, on: :create
+  before_create :generate_keys
   before_destroy :clean_feed_manager
 
   def ensure_keys!
diff --git a/app/models/account_conversation.rb b/app/models/account_conversation.rb
index b3ddc04c10..be07879219 100644
--- a/app/models/account_conversation.rb
+++ b/app/models/account_conversation.rb
@@ -17,14 +17,13 @@
 class AccountConversation < ApplicationRecord
   include Redisable
 
+  before_validation :set_last_status
   after_commit :push_to_streaming_api
 
   belongs_to :account
   belongs_to :conversation
   belongs_to :last_status, class_name: 'Status'
 
-  before_validation :set_last_status
-
   def participant_account_ids=(arr)
     self[:participant_account_ids] = arr.sort
   end
diff --git a/app/models/account_migration.rb b/app/models/account_migration.rb
index fa8cb6013c..b9da596172 100644
--- a/app/models/account_migration.rb
+++ b/app/models/account_migration.rb
@@ -42,7 +42,7 @@ class AccountMigration < ApplicationRecord
 
     return false unless errors.empty?
 
-    with_lock("account_migration:#{account.id}") do
+    with_redis_lock("account_migration:#{account.id}") do
       save
     end
   end
diff --git a/app/models/account_statuses_filter.rb b/app/models/account_statuses_filter.rb
index 556aee032e..849183aa28 100644
--- a/app/models/account_statuses_filter.rb
+++ b/app/models/account_statuses_filter.rb
@@ -32,9 +32,9 @@ class AccountStatusesFilter
   private
 
   def initial_scope
-    if suspended?
-      Status.none
-    elsif anonymous?
+    return Status.none if suspended?
+
+    if anonymous?
       account.statuses.not_local_only.where(visibility: %i(public unlisted))
     elsif author?
       account.statuses.all # NOTE: #merge! does not work without the #all
diff --git a/app/models/account_suggestions/source.rb b/app/models/account_suggestions/source.rb
index bd1068d201..be462cd0f5 100644
--- a/app/models/account_suggestions/source.rb
+++ b/app/models/account_suggestions/source.rb
@@ -18,7 +18,7 @@ class AccountSuggestions::Source
   def as_ordered_suggestions(scope, ordered_list)
     return [] if ordered_list.empty?
 
-    map = scope.index_by(&method(:to_ordered_list_key))
+    map = scope.index_by { |account| to_ordered_list_key(account) }
 
     ordered_list.map { |ordered_list_key| map[ordered_list_key] }.compact.map do |account|
       AccountSuggestions::Suggestion.new(
diff --git a/app/models/admin/appeal_filter.rb b/app/models/admin/appeal_filter.rb
index f5dcc0f54d..24f8df059c 100644
--- a/app/models/admin/appeal_filter.rb
+++ b/app/models/admin/appeal_filter.rb
@@ -5,6 +5,8 @@ class Admin::AppealFilter
     status
   ).freeze
 
+  IGNORED_PARAMS = %w(page).freeze
+
   attr_reader :params
 
   def initialize(params)
@@ -15,7 +17,7 @@ class Admin::AppealFilter
     scope = Appeal.order(id: :desc)
 
     params.each do |key, value|
-      next if %w(page).include?(key.to_s)
+      next if IGNORED_PARAMS.include?(key.to_s)
 
       scope.merge!(scope_for(key, value.to_s.strip)) if value.present?
     end
diff --git a/app/models/admin/status_filter.rb b/app/models/admin/status_filter.rb
index 4d439e9a1c..645c2e6207 100644
--- a/app/models/admin/status_filter.rb
+++ b/app/models/admin/status_filter.rb
@@ -6,6 +6,8 @@ class Admin::StatusFilter
     report_id
   ).freeze
 
+  IGNORED_PARAMS = %w(page report_id).freeze
+
   attr_reader :params
 
   def initialize(account, params)
@@ -17,7 +19,7 @@ class Admin::StatusFilter
     scope = @account.statuses.where(visibility: [:public, :unlisted])
 
     params.each do |key, value|
-      next if %w(page report_id).include?(key.to_s)
+      next if IGNORED_PARAMS.include?(key.to_s)
 
       scope.merge!(scope_for(key, value.to_s.strip)) if value.present?
     end
diff --git a/app/models/announcement_reaction.rb b/app/models/announcement_reaction.rb
index d22771034f..9881892c4b 100644
--- a/app/models/announcement_reaction.rb
+++ b/app/models/announcement_reaction.rb
@@ -14,6 +14,7 @@
 #
 
 class AnnouncementReaction < ApplicationRecord
+  before_validation :set_custom_emoji
   after_commit :queue_publish
 
   belongs_to :account
@@ -23,8 +24,6 @@ class AnnouncementReaction < ApplicationRecord
   validates :name, presence: true
   validates_with ReactionValidator
 
-  before_validation :set_custom_emoji
-
   private
 
   def set_custom_emoji
diff --git a/app/models/block.rb b/app/models/block.rb
index b42c1569b9..11156ebab3 100644
--- a/app/models/block.rb
+++ b/app/models/block.rb
@@ -25,8 +25,8 @@ class Block < ApplicationRecord
     false # Force uri_for to use uri attribute
   end
 
-  after_commit :remove_blocking_cache
   before_validation :set_uri, only: :create
+  after_commit :remove_blocking_cache
 
   private
 
diff --git a/app/models/bulk_import.rb b/app/models/bulk_import.rb
new file mode 100644
index 0000000000..af9a9670bf
--- /dev/null
+++ b/app/models/bulk_import.rb
@@ -0,0 +1,53 @@
+# frozen_string_literal: true
+
+# == Schema Information
+#
+# Table name: bulk_imports
+#
+#  id                :bigint(8)        not null, primary key
+#  type              :integer          not null
+#  state             :integer          not null
+#  total_items       :integer          default(0), not null
+#  imported_items    :integer          default(0), not null
+#  processed_items   :integer          default(0), not null
+#  finished_at       :datetime
+#  overwrite         :boolean          default(FALSE), not null
+#  likely_mismatched :boolean          default(FALSE), not null
+#  original_filename :string           default(""), not null
+#  account_id        :bigint(8)        not null
+#  created_at        :datetime         not null
+#  updated_at        :datetime         not null
+#
+class BulkImport < ApplicationRecord
+  self.inheritance_column = false
+
+  belongs_to :account
+  has_many :rows, class_name: 'BulkImportRow', inverse_of: :bulk_import, dependent: :delete_all
+
+  enum type: {
+    following: 0,
+    blocking: 1,
+    muting: 2,
+    domain_blocking: 3,
+    bookmarks: 4,
+  }
+
+  enum state: {
+    unconfirmed: 0,
+    scheduled: 1,
+    in_progress: 2,
+    finished: 3,
+  }
+
+  validates :type, presence: true
+
+  def self.progress!(bulk_import_id, imported: false)
+    # Use `increment_counter` so that the incrementation is done atomically in the database
+    BulkImport.increment_counter(:processed_items, bulk_import_id) # rubocop:disable Rails/SkipsModelValidations
+    BulkImport.increment_counter(:imported_items, bulk_import_id) if imported # rubocop:disable Rails/SkipsModelValidations
+
+    # Since the incrementation has been done atomically, concurrent access to `bulk_import` is now bening
+    bulk_import = BulkImport.find(bulk_import_id)
+    bulk_import.update!(state: :finished, finished_at: Time.now.utc) if bulk_import.processed_items == bulk_import.total_items
+  end
+end
diff --git a/app/models/bulk_import_row.rb b/app/models/bulk_import_row.rb
new file mode 100644
index 0000000000..dd7190c970
--- /dev/null
+++ b/app/models/bulk_import_row.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+# == Schema Information
+#
+# Table name: bulk_import_rows
+#
+#  id             :bigint(8)        not null, primary key
+#  bulk_import_id :bigint(8)        not null
+#  data           :jsonb
+#  created_at     :datetime         not null
+#  updated_at     :datetime         not null
+#
+class BulkImportRow < ApplicationRecord
+  belongs_to :bulk_import
+end
diff --git a/app/models/concerns/account_associations.rb b/app/models/concerns/account_associations.rb
index bbe269e8f0..592812e960 100644
--- a/app/models/concerns/account_associations.rb
+++ b/app/models/concerns/account_associations.rb
@@ -68,5 +68,8 @@ module AccountAssociations
 
     # Account statuses cleanup policy
     has_one :statuses_cleanup_policy, class_name: 'AccountStatusesCleanupPolicy', inverse_of: :account, dependent: :destroy
+
+    # Imports
+    has_many :bulk_imports, inverse_of: :account, dependent: :delete_all
   end
 end
diff --git a/app/models/concerns/account_interactions.rb b/app/models/concerns/account_interactions.rb
index b2ccddef32..3c64ebd9fa 100644
--- a/app/models/concerns/account_interactions.rb
+++ b/app/models/concerns/account_interactions.rb
@@ -271,7 +271,8 @@ module AccountInteractions
   end
 
   def lists_for_local_distribution
-    lists.joins(account: :user)
+    scope = lists.joins(account: :user)
+    scope.where.not(list_accounts: { follow_id: nil }).or(scope.where(account_id: id))
          .where('users.current_sign_in_at > ?', User::ACTIVE_DURATION.ago)
   end
 
diff --git a/app/models/concerns/account_search.rb b/app/models/concerns/account_search.rb
new file mode 100644
index 0000000000..67d77793fe
--- /dev/null
+++ b/app/models/concerns/account_search.rb
@@ -0,0 +1,140 @@
+# frozen_string_literal: true
+
+module AccountSearch
+  extend ActiveSupport::Concern
+
+  DISALLOWED_TSQUERY_CHARACTERS = /['?\\:‘’]/
+
+  TEXT_SEARCH_RANKS = <<~SQL.squish
+    (
+        setweight(to_tsvector('simple', accounts.display_name), 'A') ||
+        setweight(to_tsvector('simple', accounts.username), 'B') ||
+        setweight(to_tsvector('simple', coalesce(accounts.domain, '')), 'C')
+    )
+  SQL
+
+  REPUTATION_SCORE_FUNCTION = <<~SQL.squish
+    (
+        greatest(0, coalesce(s.followers_count, 0)) / (
+            greatest(0, coalesce(s.following_count, 0)) + 1.0
+        )
+    )
+  SQL
+
+  FOLLOWERS_SCORE_FUNCTION = <<~SQL.squish
+    log(
+        greatest(0, coalesce(s.followers_count, 0)) + 2
+    )
+  SQL
+
+  TIME_DISTANCE_FUNCTION = <<~SQL.squish
+    (
+        case
+            when s.last_status_at is null then 0
+            else exp(
+                -1.0 * (
+                    (
+                        greatest(0, abs(extract(DAY FROM age(s.last_status_at))) - 30.0)^2) /#{' '}
+                        (2.0 * ((-1.0 * 30^2) / (2.0 * ln(0.3)))
+                    )
+                )
+            )
+        end
+    )
+  SQL
+
+  BOOST = <<~SQL.squish
+    (
+        (#{REPUTATION_SCORE_FUNCTION} + #{FOLLOWERS_SCORE_FUNCTION} + #{TIME_DISTANCE_FUNCTION}) / 3.0
+    )
+  SQL
+
+  BASIC_SEARCH_SQL = <<~SQL.squish
+    SELECT
+      accounts.*,
+      #{BOOST} * ts_rank_cd(#{TEXT_SEARCH_RANKS}, to_tsquery('simple', :tsquery), 32) AS rank
+    FROM accounts
+    LEFT JOIN users ON accounts.id = users.account_id
+    LEFT JOIN account_stats AS s ON accounts.id = s.account_id
+    WHERE to_tsquery('simple', :tsquery) @@ #{TEXT_SEARCH_RANKS}
+      AND accounts.suspended_at IS NULL
+      AND accounts.moved_to_account_id IS NULL
+      AND (accounts.domain IS NOT NULL OR (users.approved = TRUE AND users.confirmed_at IS NOT NULL))
+    ORDER BY rank DESC
+    LIMIT :limit OFFSET :offset
+  SQL
+
+  ADVANCED_SEARCH_WITH_FOLLOWING = <<~SQL.squish
+    WITH first_degree AS (
+      SELECT target_account_id
+      FROM follows
+      WHERE account_id = :id
+      UNION ALL
+      SELECT :id
+    )
+    SELECT
+      accounts.*,
+      (count(f.id) + 1) * #{BOOST} * ts_rank_cd(#{TEXT_SEARCH_RANKS}, to_tsquery('simple', :tsquery), 32) AS rank
+    FROM accounts
+    LEFT OUTER JOIN follows AS f ON (accounts.id = f.account_id AND f.target_account_id = :id)
+    LEFT JOIN account_stats AS s ON accounts.id = s.account_id
+    WHERE accounts.id IN (SELECT * FROM first_degree)
+      AND to_tsquery('simple', :tsquery) @@ #{TEXT_SEARCH_RANKS}
+      AND accounts.suspended_at IS NULL
+      AND accounts.moved_to_account_id IS NULL
+    GROUP BY accounts.id, s.id
+    ORDER BY rank DESC
+    LIMIT :limit OFFSET :offset
+  SQL
+
+  ADVANCED_SEARCH_WITHOUT_FOLLOWING = <<~SQL.squish
+    SELECT
+      accounts.*,
+      #{BOOST} * ts_rank_cd(#{TEXT_SEARCH_RANKS}, to_tsquery('simple', :tsquery), 32) AS rank,
+      count(f.id) AS followed
+    FROM accounts
+    LEFT OUTER JOIN follows AS f ON
+      (accounts.id = f.account_id AND f.target_account_id = :id) OR (accounts.id = f.target_account_id AND f.account_id = :id)
+    LEFT JOIN users ON accounts.id = users.account_id
+    LEFT JOIN account_stats AS s ON accounts.id = s.account_id
+    WHERE to_tsquery('simple', :tsquery) @@ #{TEXT_SEARCH_RANKS}
+      AND accounts.suspended_at IS NULL
+      AND accounts.moved_to_account_id IS NULL
+      AND (accounts.domain IS NOT NULL OR (users.approved = TRUE AND users.confirmed_at IS NOT NULL))
+    GROUP BY accounts.id, s.id
+    ORDER BY followed DESC, rank DESC
+    LIMIT :limit OFFSET :offset
+  SQL
+
+  class_methods do
+    def search_for(terms, limit: 10, offset: 0)
+      tsquery = generate_query_for_search(terms)
+
+      find_by_sql([BASIC_SEARCH_SQL, { limit: limit, offset: offset, tsquery: tsquery }]).tap do |records|
+        ActiveRecord::Associations::Preloader.new.preload(records, :account_stat)
+      end
+    end
+
+    def advanced_search_for(terms, account, limit: 10, following: false, offset: 0)
+      tsquery = generate_query_for_search(terms)
+      sql_template = following ? ADVANCED_SEARCH_WITH_FOLLOWING : ADVANCED_SEARCH_WITHOUT_FOLLOWING
+
+      find_by_sql([sql_template, { id: account.id, limit: limit, offset: offset, tsquery: tsquery }]).tap do |records|
+        ActiveRecord::Associations::Preloader.new.preload(records, :account_stat)
+      end
+    end
+
+    private
+
+    def generate_query_for_search(unsanitized_terms)
+      terms = unsanitized_terms.gsub(DISALLOWED_TSQUERY_CHARACTERS, ' ')
+
+      # The final ":*" is for prefix search.
+      # The trailing space does not seem to fit any purpose, but `to_tsquery`
+      # behaves differently with and without a leading space if the terms start
+      # with `./`, `../`, or `.. `. I don't understand why, so, in doubt, keep
+      # the same query.
+      "' #{terms} ':*"
+    end
+  end
+end
diff --git a/app/models/concerns/lockable.rb b/app/models/concerns/lockable.rb
index 55a9714ca8..3354ce1a99 100644
--- a/app/models/concerns/lockable.rb
+++ b/app/models/concerns/lockable.rb
@@ -5,7 +5,7 @@ module Lockable
   # @param [ActiveSupport::Duration] autorelease Automatically release the lock after this time
   # @param [Boolean] raise_on_failure Raise an error if a lock cannot be acquired, or fail silently
   # @raise [Mastodon::RaceConditionError]
-  def with_lock(lock_name, autorelease: 15.minutes, raise_on_failure: true)
+  def with_redis_lock(lock_name, autorelease: 15.minutes, raise_on_failure: true)
     with_redis do |redis|
       RedisLock.acquire(redis: redis, key: "lock:#{lock_name}", autorelease: autorelease.seconds) do |lock|
         if lock.acquired?
diff --git a/app/models/concerns/status_safe_reblog_insert.rb b/app/models/concerns/status_safe_reblog_insert.rb
new file mode 100644
index 0000000000..a7ccb52e9a
--- /dev/null
+++ b/app/models/concerns/status_safe_reblog_insert.rb
@@ -0,0 +1,72 @@
+# frozen_string_literal: true
+
+module StatusSafeReblogInsert
+  extend ActiveSupport::Concern
+
+  class_methods do
+    # This is a hack to ensure that no reblogs of discarded statuses are created,
+    # as this cannot be enforced through database constraints the same way we do
+    # for reblogs of deleted statuses.
+    #
+    # To achieve this, we redefine the internal method responsible for issuing
+    # the "INSERT" statement and replace the "INSERT INTO ... VALUES ..." query
+    # with an "INSERT INTO ... SELECT ..." query with a "WHERE deleted_at IS NULL"
+    # clause on the reblogged status to ensure consistency at the database level.
+    #
+    # Otherwise, the code is kept as close as possible to ActiveRecord::Persistence
+    # code, and actually calls it if we are not handling a reblog.
+    def _insert_record(values)
+      return super unless values.is_a?(Hash) && values['reblog_of_id'].present?
+
+      primary_key = self.primary_key
+      primary_key_value = nil
+
+      if primary_key
+        primary_key_value = values[primary_key]
+
+        if !primary_key_value && prefetch_primary_key?
+          primary_key_value = next_sequence_value
+          values[primary_key] = primary_key_value
+        end
+      end
+
+      # The following line is where we differ from stock ActiveRecord implementation
+      im = _compile_reblog_insert(values)
+
+      # Since we are using SELECT instead of VALUES, a non-error `nil` return is possible.
+      # For our purposes, it's equivalent to a foreign key constraint violation
+      result = connection.insert(im, "#{self} Create", primary_key || false, primary_key_value)
+      raise ActiveRecord::InvalidForeignKey, "(reblog_of_id)=(#{values['reblog_of_id']}) is not present in table \"statuses\"" if result.nil?
+
+      result
+    end
+
+    def _compile_reblog_insert(values)
+      # This is somewhat equivalent to the following code of ActiveRecord::Persistence:
+      # `arel_table.compile_insert(_substitute_values(values))`
+      # The main difference is that we use a `SELECT` instead of a `VALUES` clause,
+      # which means we have to build the `SELECT` clause ourselves and do a bit more
+      # manual work.
+
+      # Instead of using Arel::InsertManager#values, we are going to use Arel::InsertManager#select
+      im = Arel::InsertManager.new
+      im.into(arel_table)
+
+      binds = []
+      reblog_bind = nil
+      values.each do |name, value|
+        attr = arel_table[name]
+        bind = predicate_builder.build_bind_attribute(attr.name, value)
+
+        im.columns << attr
+        binds << bind
+
+        reblog_bind = bind if name == 'reblog_of_id'
+      end
+
+      im.select(arel_table.where(arel_table[:id].eq(reblog_bind)).where(arel_table[:deleted_at].eq(nil)).project(*binds))
+
+      im
+    end
+  end
+end
diff --git a/app/models/follow_request.rb b/app/models/follow_request.rb
index 78f79c18f0..a5c23e09d4 100644
--- a/app/models/follow_request.rb
+++ b/app/models/follow_request.rb
@@ -32,7 +32,8 @@ class FollowRequest < ApplicationRecord
   validates :languages, language: true
 
   def authorize!
-    account.follow!(target_account, reblogs: show_reblogs, notify: notify, languages: languages, uri: uri, bypass_limit: true)
+    follow = account.follow!(target_account, reblogs: show_reblogs, notify: notify, languages: languages, uri: uri, bypass_limit: true)
+    ListAccount.where(follow_request: self).update_all(follow_request_id: nil, follow_id: follow.id) # rubocop:disable Rails/SkipsModelValidations
     MergeWorker.perform_async(target_account.id, account.id) if account.local?
     destroy!
   end
diff --git a/app/models/form/import.rb b/app/models/form/import.rb
new file mode 100644
index 0000000000..750ef84be4
--- /dev/null
+++ b/app/models/form/import.rb
@@ -0,0 +1,151 @@
+# frozen_string_literal: true
+
+require 'csv'
+
+# A non-ActiveRecord helper class for CSV uploads.
+# Handles saving contents to database.
+class Form::Import
+  include ActiveModel::Model
+
+  MODES = %i(merge overwrite).freeze
+
+  FILE_SIZE_LIMIT       = 20.megabytes
+  ROWS_PROCESSING_LIMIT = 20_000
+
+  EXPECTED_HEADERS_BY_TYPE = {
+    following: ['Account address', 'Show boosts', 'Notify on new posts', 'Languages'],
+    blocking: ['Account address'],
+    muting: ['Account address', 'Hide notifications'],
+    domain_blocking: ['#domain'],
+    bookmarks: ['#uri'],
+  }.freeze
+
+  KNOWN_FIRST_HEADERS = EXPECTED_HEADERS_BY_TYPE.values.map(&:first).uniq.freeze
+
+  ATTRIBUTE_BY_HEADER = {
+    'Account address' => 'acct',
+    'Show boosts' => 'show_reblogs',
+    'Notify on new posts' => 'notify',
+    'Languages' => 'languages',
+    'Hide notifications' => 'hide_notifications',
+    '#domain' => 'domain',
+    '#uri' => 'uri',
+  }.freeze
+
+  class EmptyFileError < StandardError; end
+
+  attr_accessor :current_account, :data, :type, :overwrite, :bulk_import
+
+  validates :type, presence: true
+  validates :data, presence: true
+  validate :validate_data
+
+  def guessed_type
+    return :muting if csv_data.headers.include?('Hide notifications')
+    return :following if csv_data.headers.include?('Show boosts') || csv_data.headers.include?('Notify on new posts') || csv_data.headers.include?('Languages')
+    return :following if data.original_filename&.start_with?('follows') || data.original_filename&.start_with?('following_accounts')
+    return :blocking if data.original_filename&.start_with?('blocks') || data.original_filename&.start_with?('blocked_accounts')
+    return :muting if data.original_filename&.start_with?('mutes') || data.original_filename&.start_with?('muted_accounts')
+    return :domain_blocking if data.original_filename&.start_with?('domain_blocks') || data.original_filename&.start_with?('blocked_domains')
+    return :bookmarks if data.original_filename&.start_with?('bookmarks')
+  end
+
+  # Whether the uploaded CSV file seems to correspond to a different import type than the one selected
+  def likely_mismatched?
+    guessed_type.present? && guessed_type != type.to_sym
+  end
+
+  def save
+    return false unless valid?
+
+    ApplicationRecord.transaction do
+      now = Time.now.utc
+      @bulk_import = current_account.bulk_imports.create(type: type, overwrite: overwrite || false, state: :unconfirmed, original_filename: data.original_filename, likely_mismatched: likely_mismatched?)
+      nb_items = BulkImportRow.insert_all(parsed_rows.map { |row| { bulk_import_id: bulk_import.id, data: row, created_at: now, updated_at: now } }).length # rubocop:disable Rails/SkipsModelValidations
+      @bulk_import.update(total_items: nb_items)
+    end
+  end
+
+  def mode
+    overwrite ? :overwrite : :merge
+  end
+
+  def mode=(str)
+    self.overwrite = str.to_sym == :overwrite
+  end
+
+  private
+
+  def default_csv_header
+    case type.to_sym
+    when :following, :blocking, :muting
+      'Account address'
+    when :domain_blocking
+      '#domain'
+    when :bookmarks
+      '#uri'
+    end
+  end
+
+  def csv_data
+    return @csv_data if defined?(@csv_data)
+
+    csv_converter = lambda do |field, field_info|
+      case field_info.header
+      when 'Show boosts', 'Notify on new posts', 'Hide notifications'
+        ActiveModel::Type::Boolean.new.cast(field)
+      when 'Languages'
+        field&.split(',')&.map(&:strip)&.presence
+      when 'Account address'
+        field.strip.gsub(/\A@/, '')
+      when '#domain', '#uri'
+        field.strip
+      else
+        field
+      end
+    end
+
+    @csv_data = CSV.open(data.path, encoding: 'UTF-8', skip_blanks: true, headers: true, converters: csv_converter)
+    @csv_data.take(1) # Ensure the headers are read
+    raise EmptyFileError if @csv_data.headers == true
+
+    @csv_data = CSV.open(data.path, encoding: 'UTF-8', skip_blanks: true, headers: [default_csv_header], converters: csv_converter) unless KNOWN_FIRST_HEADERS.include?(@csv_data.headers&.first)
+    @csv_data
+  end
+
+  def csv_row_count
+    return @csv_row_count if defined?(@csv_row_count)
+
+    csv_data.rewind
+    @csv_row_count = csv_data.take(ROWS_PROCESSING_LIMIT + 2).count
+  end
+
+  def parsed_rows
+    csv_data.rewind
+
+    expected_headers = EXPECTED_HEADERS_BY_TYPE[type.to_sym]
+
+    csv_data.take(ROWS_PROCESSING_LIMIT + 1).map do |row|
+      row.to_h.slice(*expected_headers).transform_keys { |key| ATTRIBUTE_BY_HEADER[key] }
+    end
+  end
+
+  def validate_data
+    return if data.nil?
+    return errors.add(:data, I18n.t('imports.errors.too_large')) if data.size > FILE_SIZE_LIMIT
+    return errors.add(:data, I18n.t('imports.errors.incompatible_type')) unless csv_data.headers.include?(default_csv_header)
+
+    errors.add(:data, I18n.t('imports.errors.over_rows_processing_limit', count: ROWS_PROCESSING_LIMIT)) if csv_row_count > ROWS_PROCESSING_LIMIT
+
+    if type.to_sym == :following
+      base_limit = FollowLimitValidator.limit_for_account(current_account)
+      limit = base_limit
+      limit -= current_account.following_count unless overwrite
+      errors.add(:data, I18n.t('users.follow_limit_reached', limit: base_limit)) if csv_row_count > limit
+    end
+  rescue CSV::MalformedCSVError => e
+    errors.add(:data, I18n.t('imports.errors.invalid_csv_file', error: e.message))
+  rescue EmptyFileError
+    errors.add(:data, I18n.t('imports.errors.empty'))
+  end
+end
diff --git a/app/models/import.rb b/app/models/import.rb
index 21634005ed..7cd6cccf7c 100644
--- a/app/models/import.rb
+++ b/app/models/import.rb
@@ -17,6 +17,9 @@
 #  overwrite         :boolean          default(FALSE), not null
 #
 
+# NOTE: This is a deprecated model, only kept to not break ongoing imports
+# on upgrade. See `BulkImport` and `Form::Import` for its replacements.
+
 class Import < ApplicationRecord
   FILE_TYPES = %w(text/plain text/csv application/csv).freeze
   MODES = %i(merge overwrite).freeze
@@ -28,7 +31,6 @@ class Import < ApplicationRecord
   enum type: { following: 0, blocking: 1, muting: 2, domain_blocking: 3, bookmarks: 4 }
 
   validates :type, presence: true
-  validates_with ImportValidator, on: :create
 
   has_attached_file :data
   validates_attachment_content_type :data, content_type: FILE_TYPES
diff --git a/app/models/list_account.rb b/app/models/list_account.rb
index a5767d3d8b..e7016f2714 100644
--- a/app/models/list_account.rb
+++ b/app/models/list_account.rb
@@ -4,24 +4,39 @@
 #
 # Table name: list_accounts
 #
-#  id         :bigint(8)        not null, primary key
-#  list_id    :bigint(8)        not null
-#  account_id :bigint(8)        not null
-#  follow_id  :bigint(8)
+#  id                :bigint(8)        not null, primary key
+#  list_id           :bigint(8)        not null
+#  account_id        :bigint(8)        not null
+#  follow_id         :bigint(8)
+#  follow_request_id :bigint(8)
 #
 
 class ListAccount < ApplicationRecord
   belongs_to :list
   belongs_to :account
   belongs_to :follow, optional: true
+  belongs_to :follow_request, optional: true
 
   validates :account_id, uniqueness: { scope: :list_id }
+  validate :validate_relationship
 
   before_validation :set_follow
 
   private
 
   def set_follow
-    self.follow = Follow.find_by!(account_id: list.account_id, target_account_id: account.id) unless list.account_id == account.id
+    return if list.account_id == account.id
+
+    self.follow = Follow.find_by!(account_id: list.account_id, target_account_id: account.id)
+  rescue ActiveRecord::RecordNotFound
+    self.follow_request = FollowRequest.find_by!(account_id: list.account_id, target_account_id: account.id)
+  end
+
+  def validate_relationship
+    return if list.account_id == account_id
+
+    errors.add(:account_id, 'follow relationship missing') if follow_id.nil? && follow_request_id.nil?
+    errors.add(:follow, 'mismatched accounts') if follow_id.present? && follow.target_account_id != account_id
+    errors.add(:follow_request, 'mismatched accounts') if follow_request_id.present? && follow_request.target_account_id != account_id
   end
 end
diff --git a/app/models/media_attachment.rb b/app/models/media_attachment.rb
index 0367b4af7b..1d5f3c2880 100644
--- a/app/models/media_attachment.rb
+++ b/app/models/media_attachment.rb
@@ -34,8 +34,8 @@ class MediaAttachment < ApplicationRecord
 
   include Attachmentable
 
-  enum type: { :image => 0, :gifv => 1, :video => 2, :unknown => 3, :audio => 4 }
-  enum processing: { :queued => 0, :in_progress => 1, :complete => 2, :failed => 3 }, _prefix: true
+  enum type: { image: 0, gifv: 1, video: 2, unknown: 3, audio: 4 }
+  enum processing: { queued: 0, in_progress: 1, complete: 2, failed: 3 }, _prefix: true
 
   MAX_DESCRIPTION_LENGTH = 1_500
 
@@ -135,7 +135,7 @@ class MediaAttachment < ApplicationRecord
       convert_options: {
         output: {
           'loglevel' => 'fatal',
-          vf: 'scale=\'min(400\, iw):min(400\, ih)\':force_original_aspect_ratio=decrease',
+          :vf => 'scale=\'min(400\, iw):min(400\, ih)\':force_original_aspect_ratio=decrease',
         }.freeze,
       }.freeze,
       format: 'png',
@@ -169,6 +169,8 @@ class MediaAttachment < ApplicationRecord
     original: IMAGE_STYLES[:small].freeze,
   }.freeze
 
+  DEFAULT_STYLES = [:original].freeze
+
   GLOBAL_CONVERT_OPTIONS = {
     all: '-quality 90 +profile "!icc,*" +set modify-date +set create-date',
   }.freeze
@@ -271,12 +273,12 @@ class MediaAttachment < ApplicationRecord
     delay_processing? && attachment_name == :file
   end
 
-  after_commit :enqueue_processing, on: :create
-  after_commit :reset_parent_cache, on: :update
-
   before_create :set_unknown_type
   before_create :set_processing
 
+  after_commit :enqueue_processing, on: :create
+  after_commit :reset_parent_cache, on: :update
+
   after_post_process :set_meta
 
   class << self
diff --git a/app/models/relationship_filter.rb b/app/models/relationship_filter.rb
index 249fe3df8e..3d75dce05e 100644
--- a/app/models/relationship_filter.rb
+++ b/app/models/relationship_filter.rb
@@ -10,6 +10,8 @@ class RelationshipFilter
     location
   ).freeze
 
+  IGNORED_PARAMS = %w(relationship page).freeze
+
   attr_reader :params, :account
 
   def initialize(account, params)
@@ -23,7 +25,7 @@ class RelationshipFilter
     scope = scope_for('relationship', params['relationship'].to_s.strip)
 
     params.each do |key, value|
-      next if %w(relationship page).include?(key)
+      next if IGNORED_PARAMS.include?(key)
 
       scope.merge!(scope_for(key.to_s, value.to_s.strip)) if value.present?
     end
diff --git a/app/models/session_activation.rb b/app/models/session_activation.rb
index 10c3a6c250..7f5f0d9a9a 100644
--- a/app/models/session_activation.rb
+++ b/app/models/session_activation.rb
@@ -36,8 +36,8 @@ class SessionActivation < ApplicationRecord
     detection.platform.id
   end
 
-  before_create :assign_access_token
   before_save   :assign_user_agent
+  before_create :assign_access_token
 
   class << self
     def active?(id)
diff --git a/app/models/status.rb b/app/models/status.rb
index b69d2b0649..a52098b602 100644
--- a/app/models/status.rb
+++ b/app/models/status.rb
@@ -32,14 +32,13 @@
 #
 
 class Status < ApplicationRecord
-  before_destroy :unlink_from_conversations!
-
   include Discard::Model
   include Paginable
   include Cacheable
   include StatusThreadingConcern
   include StatusSnapshotConcern
   include RateLimitable
+  include StatusSafeReblogInsert
 
   rate_limit by: :account, family: :statuses
 
@@ -119,6 +118,28 @@ class Status < ApplicationRecord
   after_create_commit :trigger_create_webhooks
   after_update_commit :trigger_update_webhooks
 
+  after_create_commit  :increment_counter_caches
+  after_destroy_commit :decrement_counter_caches
+
+  after_create_commit :store_uri, if: :local?
+  after_create_commit :update_statistics, if: :local?
+
+  before_validation :prepare_contents, if: :local?
+  before_validation :set_reblog
+  before_validation :set_visibility
+  before_validation :set_conversation
+  before_validation :set_local
+
+  before_create :set_local_only
+
+  around_create Mastodon::Snowflake::Callbacks
+
+  after_create :set_poll_id
+
+  # The `prepend: true` option below ensures this runs before
+  # the `dependent: destroy` callbacks remove relevant records
+  before_destroy :unlink_from_conversations!, prepend: true
+
   cache_associated :application,
                    :media_attachments,
                    :conversation,
@@ -316,23 +337,6 @@ class Status < ApplicationRecord
     attributes['trendable'].nil? && account.requires_review_notification?
   end
 
-  after_create_commit  :increment_counter_caches
-  after_destroy_commit :decrement_counter_caches
-
-  after_create_commit :store_uri, if: :local?
-  after_create_commit :update_statistics, if: :local?
-
-  before_validation :prepare_contents, if: :local?
-  before_validation :set_reblog
-  before_validation :set_visibility
-  before_validation :set_conversation
-  before_validation :set_local
-  before_create :set_locality
-
-  around_create Mastodon::Snowflake::Callbacks
-
-  after_create :set_poll_id
-
   class << self
     def selectable_visibilities
       visibilities.keys - %w(direct limited)
@@ -442,71 +446,6 @@ class Status < ApplicationRecord
     super || build_status_stat
   end
 
-  # This is a hack to ensure that no reblogs of discarded statuses are created,
-  # as this cannot be enforced through database constraints the same way we do
-  # for reblogs of deleted statuses.
-  #
-  # To achieve this, we redefine the internal method responsible for issuing
-  # the "INSERT" statement and replace the "INSERT INTO ... VALUES ..." query
-  # with an "INSERT INTO ... SELECT ..." query with a "WHERE deleted_at IS NULL"
-  # clause on the reblogged status to ensure consistency at the database level.
-  #
-  # Otherwise, the code is kept as close as possible to ActiveRecord::Persistence
-  # code, and actually calls it if we are not handling a reblog.
-  def self._insert_record(values)
-    return super unless values.is_a?(Hash) && values['reblog_of_id'].present?
-
-    primary_key = self.primary_key
-    primary_key_value = nil
-
-    if primary_key
-      primary_key_value = values[primary_key]
-
-      if !primary_key_value && prefetch_primary_key?
-        primary_key_value = next_sequence_value
-        values[primary_key] = primary_key_value
-      end
-    end
-
-    # The following line is where we differ from stock ActiveRecord implementation
-    im = _compile_reblog_insert(values)
-
-    # Since we are using SELECT instead of VALUES, a non-error `nil` return is possible.
-    # For our purposes, it's equivalent to a foreign key constraint violation
-    result = connection.insert(im, "#{self} Create", primary_key || false, primary_key_value)
-    raise ActiveRecord::InvalidForeignKey, "(reblog_of_id)=(#{values['reblog_of_id']}) is not present in table \"statuses\"" if result.nil?
-
-    result
-  end
-
-  def self._compile_reblog_insert(values)
-    # This is somewhat equivalent to the following code of ActiveRecord::Persistence:
-    # `arel_table.compile_insert(_substitute_values(values))`
-    # The main difference is that we use a `SELECT` instead of a `VALUES` clause,
-    # which means we have to build the `SELECT` clause ourselves and do a bit more
-    # manual work.
-
-    # Instead of using Arel::InsertManager#values, we are going to use Arel::InsertManager#select
-    im = Arel::InsertManager.new
-    im.into(arel_table)
-
-    binds = []
-    reblog_bind = nil
-    values.each do |name, value|
-      attr = arel_table[name]
-      bind = predicate_builder.build_bind_attribute(attr.name, value)
-
-      im.columns << attr
-      binds << bind
-
-      reblog_bind = bind if name == 'reblog_of_id'
-    end
-
-    im.select(arel_table.where(arel_table[:id].eq(reblog_bind)).where(arel_table[:deleted_at].eq(nil)).project(*binds))
-
-    im
-  end
-
   def discard_with_reblogs
     discard_time = Time.current
     Status.unscoped.where(reblog_of_id: id, deleted_at: [nil, deleted_at]).in_batches.update_all(deleted_at: discard_time) unless reblog?
@@ -555,7 +494,7 @@ class Status < ApplicationRecord
     self.sensitive  = false if sensitive.nil?
   end
 
-  def set_locality
+  def set_local_only
     return unless account.domain.nil? && !attribute_changed?(:local_only)
 
     self.local_only = marked_local_only?
diff --git a/app/models/trends/history.rb b/app/models/trends/history.rb
index 83532e7bc8..db36899337 100644
--- a/app/models/trends/history.rb
+++ b/app/models/trends/history.rb
@@ -11,7 +11,7 @@ class Trends::History
     end
 
     def uses
-      with_redis { |redis| redis.mget(*@days.map { |day| day.key_for(:uses) }).map(&:to_i).sum }
+      with_redis { |redis| redis.mget(*@days.map { |day| day.key_for(:uses) }).sum(&:to_i) }
     end
 
     def accounts
diff --git a/app/models/trends/preview_card_filter.rb b/app/models/trends/preview_card_filter.rb
index f0214c3f0f..ef36ba9878 100644
--- a/app/models/trends/preview_card_filter.rb
+++ b/app/models/trends/preview_card_filter.rb
@@ -6,6 +6,8 @@ class Trends::PreviewCardFilter
     locale
   ).freeze
 
+  IGNORED_PARAMS = %w(page).freeze
+
   attr_reader :params
 
   def initialize(params)
@@ -16,7 +18,7 @@ class Trends::PreviewCardFilter
     scope = initial_scope
 
     params.each do |key, value|
-      next if %w(page).include?(key.to_s)
+      next if IGNORED_PARAMS.include?(key.to_s)
 
       scope.merge!(scope_for(key, value.to_s.strip)) if value.present?
     end
diff --git a/app/models/trends/status_filter.rb b/app/models/trends/status_filter.rb
index de435a0266..da240251fd 100644
--- a/app/models/trends/status_filter.rb
+++ b/app/models/trends/status_filter.rb
@@ -6,6 +6,8 @@ class Trends::StatusFilter
     locale
   ).freeze
 
+  IGNORED_PARAMS = %w(page).freeze
+
   attr_reader :params
 
   def initialize(params)
@@ -16,7 +18,7 @@ class Trends::StatusFilter
     scope = initial_scope
 
     params.each do |key, value|
-      next if %w(page).include?(key.to_s)
+      next if IGNORED_PARAMS.include?(key.to_s)
 
       scope.merge!(scope_for(key, value.to_s.strip)) if value.present?
     end
diff --git a/app/presenters/status_relationships_presenter.rb b/app/presenters/status_relationships_presenter.rb
index be818a2de7..50d1fb31bd 100644
--- a/app/presenters/status_relationships_presenter.rb
+++ b/app/presenters/status_relationships_presenter.rb
@@ -1,6 +1,8 @@
 # frozen_string_literal: true
 
 class StatusRelationshipsPresenter
+  PINNABLE_VISIBILITIES = %w(public unlisted private).freeze
+
   attr_reader :reblogs_map, :favourites_map, :mutes_map, :pins_map,
               :bookmarks_map, :filters_map
 
@@ -16,7 +18,7 @@ class StatusRelationshipsPresenter
       statuses            = statuses.compact
       status_ids          = statuses.flat_map { |s| [s.id, s.reblog_of_id] }.uniq.compact
       conversation_ids    = statuses.filter_map(&:conversation_id).uniq
-      pinnable_status_ids = statuses.map(&:proper).filter_map { |s| s.id if s.account_id == current_account_id && %w(public unlisted private).include?(s.visibility) }
+      pinnable_status_ids = statuses.map(&:proper).filter_map { |s| s.id if s.account_id == current_account_id && PINNABLE_VISIBILITIES.include?(s.visibility) }
 
       @filters_map     = build_filters_map(statuses, current_account_id).merge(options[:filters_map] || {})
       @reblogs_map     = Status.reblogs_map(status_ids, current_account_id).merge(options[:reblogs_map] || {})
diff --git a/app/services/activitypub/fetch_remote_actor_service.rb b/app/services/activitypub/fetch_remote_actor_service.rb
index c295700860..8df8c75876 100644
--- a/app/services/activitypub/fetch_remote_actor_service.rb
+++ b/app/services/activitypub/fetch_remote_actor_service.rb
@@ -67,7 +67,7 @@ class ActivityPub::FetchRemoteActorService < BaseService
   end
 
   def split_acct(acct)
-    acct.gsub(/\Aacct:/, '').split('@')
+    acct.delete_prefix('acct:').split('@')
   end
 
   def supported_context?
diff --git a/app/services/activitypub/fetch_remote_status_service.rb b/app/services/activitypub/fetch_remote_status_service.rb
index ab0acf7f0f..a491b32b26 100644
--- a/app/services/activitypub/fetch_remote_status_service.rb
+++ b/app/services/activitypub/fetch_remote_status_service.rb
@@ -2,12 +2,15 @@
 
 class ActivityPub::FetchRemoteStatusService < BaseService
   include JsonLdHelper
+  include DomainControlHelper
   include Redisable
 
   DISCOVERIES_PER_REQUEST = 1000
 
   # Should be called when uri has already been checked for locality
   def call(uri, id: true, prefetched_body: nil, on_behalf_of: nil, expected_actor_uri: nil, request_id: nil)
+    return if domain_not_allowed?(uri)
+
     @request_id = request_id || "#{Time.now.utc.to_i}-status-#{uri}"
     @json = if prefetched_body.nil?
               fetch_resource(uri, id, on_behalf_of)
diff --git a/app/services/activitypub/process_account_service.rb b/app/services/activitypub/process_account_service.rb
index 603e4cf48b..ca0083b167 100644
--- a/app/services/activitypub/process_account_service.rb
+++ b/app/services/activitypub/process_account_service.rb
@@ -24,7 +24,7 @@ class ActivityPub::ProcessAccountService < BaseService
     # The key does not need to be unguessable, it just needs to be somewhat unique
     @options[:request_id] ||= "#{Time.now.utc.to_i}-#{username}@#{domain}"
 
-    with_lock("process_account:#{@uri}") do
+    with_redis_lock("process_account:#{@uri}") do
       @account            = Account.remote.find_by(uri: @uri) if @options[:only_key]
       @account          ||= Account.find_remote(@username, @domain)
       @old_public_key     = @account&.public_key
diff --git a/app/services/activitypub/process_status_update_service.rb b/app/services/activitypub/process_status_update_service.rb
index ac7372f745..38f6bf2514 100644
--- a/app/services/activitypub/process_status_update_service.rb
+++ b/app/services/activitypub/process_status_update_service.rb
@@ -35,7 +35,7 @@ class ActivityPub::ProcessStatusUpdateService < BaseService
     last_edit_date = @status.edited_at.presence || @status.created_at
 
     # Only allow processing one create/update per status at a time
-    with_lock("create:#{@uri}") do
+    with_redis_lock("create:#{@uri}") do
       Status.transaction do
         record_previous_edit!
         update_media_attachments!
@@ -58,7 +58,7 @@ class ActivityPub::ProcessStatusUpdateService < BaseService
   end
 
   def handle_implicit_update!
-    with_lock("create:#{@uri}") do
+    with_redis_lock("create:#{@uri}") do
       update_poll!(allow_significant_changes: false)
       queue_poll_notifications!
     end
diff --git a/app/services/backup_service.rb b/app/services/backup_service.rb
index c5e7a8e580..89b8d06cd1 100644
--- a/app/services/backup_service.rb
+++ b/app/services/backup_service.rb
@@ -1,59 +1,67 @@
 # frozen_string_literal: true
 
-require 'rubygems/package'
+require 'zip'
 
 class BackupService < BaseService
   include Payloadable
+  include ContextHelper
 
-  attr_reader :account, :backup, :collection
+  attr_reader :account, :backup
 
   def call(backup)
     @backup  = backup
     @account = backup.user.account
 
-    build_json!
     build_archive!
   end
 
   private
 
-  def build_json!
-    @collection = serialize(collection_presenter, ActivityPub::CollectionSerializer)
+  def build_outbox_json!(file)
+    skeleton = serialize(collection_presenter, ActivityPub::CollectionSerializer)
+    skeleton[:@context] = full_context
+    skeleton[:orderedItems] = ['!PLACEHOLDER!']
+    skeleton = Oj.dump(skeleton)
+    prepend, append = skeleton.split('"!PLACEHOLDER!"')
+    add_comma = false
+
+    file.write(prepend)
 
     account.statuses.with_includes.reorder(nil).find_in_batches do |statuses|
-      statuses.each do |status|
-        item = serialize_payload(ActivityPub::ActivityPresenter.from_status(status), ActivityPub::ActivitySerializer, signer: @account, allow_local_only: true)
-        item.delete(:@context)
+      file.write(',') if add_comma
+      add_comma = true
+
+      file.write(statuses.map do |status|
+        item = serialize_payload(ActivityPub::ActivityPresenter.from_status(status), ActivityPub::ActivitySerializer, allow_local_only: true)
+        item.delete('@context')
 
         unless item[:type] == 'Announce' || item[:object][:attachment].blank?
           item[:object][:attachment].each do |attachment|
-            attachment[:url] = Addressable::URI.parse(attachment[:url]).path.gsub(/\A\/system\//, '')
+            attachment[:url] = Addressable::URI.parse(attachment[:url]).path.delete_prefix('/system/')
           end
         end
 
-        @collection[:orderedItems] << item
-      end
+        Oj.dump(item)
+      end.join(','))
 
       GC.start
     end
+
+    file.write(append)
   end
 
   def build_archive!
-    tmp_file = Tempfile.new(%w(archive .tar.gz))
+    tmp_file = Tempfile.new(%w(archive .zip))
 
-    File.open(tmp_file, 'wb') do |file|
-      Zlib::GzipWriter.wrap(file) do |gz|
-        Gem::Package::TarWriter.new(gz) do |tar|
-          dump_media_attachments!(tar)
-          dump_outbox!(tar)
-          dump_likes!(tar)
-          dump_bookmarks!(tar)
-          dump_actor!(tar)
-        end
-      end
+    Zip::File.open(tmp_file, create: true) do |zipfile|
+      dump_outbox!(zipfile)
+      dump_media_attachments!(zipfile)
+      dump_likes!(zipfile)
+      dump_bookmarks!(zipfile)
+      dump_actor!(zipfile)
     end
 
-    archive_filename = "#{['archive', Time.now.utc.strftime('%Y%m%d%H%M%S'), SecureRandom.hex(16)].join('-')}.tar.gz"
+    archive_filename = "#{['archive', Time.now.utc.strftime('%Y%m%d%H%M%S'), SecureRandom.hex(16)].join('-')}.zip"
 
     @backup.dump      = ActionDispatch::Http::UploadedFile.new(tempfile: tmp_file, filename: archive_filename)
     @backup.processed = true
@@ -63,27 +71,28 @@ class BackupService < BaseService
     tmp_file.unlink
   end
 
-  def dump_media_attachments!(tar)
+  def dump_media_attachments!(zipfile)
     MediaAttachment.attached.where(account: account).reorder(nil).find_in_batches do |media_attachments|
       media_attachments.each do |m|
-        next unless m.file&.path
+        path = m.file&.path
+        next unless path
 
-        download_to_tar(tar, m.file, m.file.path)
+        path = path.gsub(/\A.*\/system\//, '')
+        path = path.gsub(/\A\/+/, '')
+        download_to_zip(zipfile, m.file, path)
       end
 
       GC.start
     end
   end
 
-  def dump_outbox!(tar)
-    json = Oj.dump(collection)
-
-    tar.add_file_simple('outbox.json', 0o444, json.bytesize) do |io|
-      io.write(json)
+  def dump_outbox!(zipfile)
+    zipfile.get_output_stream('outbox.json') do |io|
+      build_outbox_json!(io)
     end
   end
 
-  def dump_actor!(tar)
+  def dump_actor!(zipfile)
     actor = serialize(account, ActivityPub::ActorSerializer)
 
     actor[:icon][:url]  = "avatar#{File.extname(actor[:icon][:url])}"  if actor[:icon]
@@ -92,51 +101,66 @@ class BackupService < BaseService
     actor[:likes]       = 'likes.json'
     actor[:bookmarks]   = 'bookmarks.json'
 
-    download_to_tar(tar, account.avatar, "avatar#{File.extname(account.avatar.path)}") if account.avatar.exists?
-    download_to_tar(tar, account.header, "header#{File.extname(account.header.path)}") if account.header.exists?
+    download_to_zip(tar, account.avatar, "avatar#{File.extname(account.avatar.path)}") if account.avatar.exists?
+    download_to_zip(tar, account.header, "header#{File.extname(account.header.path)}") if account.header.exists?
 
     json = Oj.dump(actor)
 
-    tar.add_file_simple('actor.json', 0o444, json.bytesize) do |io|
+    zipfile.get_output_stream('actor.json') do |io|
       io.write(json)
     end
   end
 
-  def dump_likes!(tar)
-    collection = serialize(ActivityPub::CollectionPresenter.new(id: 'likes.json', type: :ordered, size: 0, items: []), ActivityPub::CollectionSerializer)
+  def dump_likes!(zipfile)
+    skeleton = serialize(ActivityPub::CollectionPresenter.new(id: 'likes.json', type: :ordered, size: 0, items: []), ActivityPub::CollectionSerializer)
+    skeleton.delete(:totalItems)
+    skeleton[:orderedItems] = ['!PLACEHOLDER!']
+    skeleton = Oj.dump(skeleton)
+    prepend, append = skeleton.split('"!PLACEHOLDER!"')
 
-    Status.reorder(nil).joins(:favourites).includes(:account).merge(account.favourites).find_in_batches do |statuses|
-      statuses.each do |status|
-        collection[:totalItems] += 1
-        collection[:orderedItems] << ActivityPub::TagManager.instance.uri_for(status)
+    zipfile.get_output_stream('likes.json') do |io|
+      io.write(prepend)
+
+      add_comma = false
+
+      Status.reorder(nil).joins(:favourites).includes(:account).merge(account.favourites).find_in_batches do |statuses|
+        io.write(',') if add_comma
+        add_comma = true
+
+        io.write(statuses.map do |status|
+          Oj.dump(ActivityPub::TagManager.instance.uri_for(status))
+        end.join(','))
+
+        GC.start
       end
 
-      GC.start
-    end
-
-    json = Oj.dump(collection)
-
-    tar.add_file_simple('likes.json', 0o444, json.bytesize) do |io|
-      io.write(json)
+      io.write(append)
     end
   end
 
-  def dump_bookmarks!(tar)
-    collection = serialize(ActivityPub::CollectionPresenter.new(id: 'bookmarks.json', type: :ordered, size: 0, items: []), ActivityPub::CollectionSerializer)
+  def dump_bookmarks!(zipfile)
+    skeleton = serialize(ActivityPub::CollectionPresenter.new(id: 'bookmarks.json', type: :ordered, size: 0, items: []), ActivityPub::CollectionSerializer)
+    skeleton.delete(:totalItems)
+    skeleton[:orderedItems] = ['!PLACEHOLDER!']
+    skeleton = Oj.dump(skeleton)
+    prepend, append = skeleton.split('"!PLACEHOLDER!"')
 
-    Status.reorder(nil).joins(:bookmarks).includes(:account).merge(account.bookmarks).find_in_batches do |statuses|
-      statuses.each do |status|
-        collection[:totalItems] += 1
-        collection[:orderedItems] << ActivityPub::TagManager.instance.uri_for(status)
+    zipfile.get_output_stream('bookmarks.json') do |io|
+      io.write(prepend)
+
+      add_comma = false
+      Status.reorder(nil).joins(:bookmarks).includes(:account).merge(account.bookmarks).find_in_batches do |statuses|
+        io.write(',') if add_comma
+        add_comma = true
+
+        io.write(statuses.map do |status|
+          Oj.dump(ActivityPub::TagManager.instance.uri_for(status))
+        end.join(','))
+
+        GC.start
       end
 
-      GC.start
-    end
-
-    json = Oj.dump(collection)
-
-    tar.add_file_simple('bookmarks.json', 0o444, json.bytesize) do |io|
-      io.write(json)
+      io.write(append)
     end
   end
 
@@ -160,10 +184,10 @@ class BackupService < BaseService
 
   CHUNK_SIZE = 1.megabyte
 
-  def download_to_tar(tar, attachment, filename)
+  def download_to_zip(zipfile, attachment, filename)
     adapter = Paperclip.io_adapters.for(attachment)
 
-    tar.add_file_simple(filename, 0o444, adapter.size) do |io|
+    zipfile.get_output_stream(filename) do |io|
       while (buffer = adapter.read(CHUNK_SIZE))
         io.write(buffer)
       end
diff --git a/app/services/bulk_import_row_service.rb b/app/services/bulk_import_row_service.rb
new file mode 100644
index 0000000000..4046ef4eed
--- /dev/null
+++ b/app/services/bulk_import_row_service.rb
@@ -0,0 +1,60 @@
+# frozen_string_literal: true
+
+class BulkImportRowService
+  def call(row)
+    @account = row.bulk_import.account
+    @data    = row.data
+    @type    = row.bulk_import.type.to_sym
+
+    case @type
+    when :following, :blocking, :muting
+      target_acct     = @data['acct']
+      target_domain   = domain(target_acct)
+      @target_account = stoplight_wrap_request(target_domain) { ResolveAccountService.new.call(target_acct, { check_delivery_availability: true }) }
+      return false if @target_account.nil?
+    when :bookmarks
+      target_uri      = @data['uri']
+      target_domain   = Addressable::URI.parse(target_uri).normalized_host
+      @target_status = ActivityPub::TagManager.instance.uri_to_resource(target_uri, Status)
+      return false if @target_status.nil? && ActivityPub::TagManager.instance.local_uri?(target_uri)
+
+      @target_status ||= stoplight_wrap_request(target_domain) { ActivityPub::FetchRemoteStatusService.new.call(target_uri) }
+      return false if @target_status.nil?
+    end
+
+    case @type
+    when :following
+      FollowService.new.call(@account, @target_account, reblogs: @data['show_reblogs'], notify: @data['notify'], languages: @data['languages'])
+    when :blocking
+      BlockService.new.call(@account, @target_account)
+    when :muting
+      MuteService.new.call(@account, @target_account, notifications: @data['hide_notifications'])
+    when :bookmarks
+      return false unless StatusPolicy.new(@account, @target_status).show?
+
+      @account.bookmarks.find_or_create_by!(status: @target_status)
+    end
+
+    true
+  rescue ActiveRecord::RecordNotFound
+    false
+  end
+
+  def domain(uri)
+    domain = uri.is_a?(Account) ? uri.domain : uri.split('@')[1]
+    TagManager.instance.local_domain?(domain) ? nil : TagManager.instance.normalize_domain(domain)
+  end
+
+  def stoplight_wrap_request(domain, &block)
+    if domain.present?
+      Stoplight("source:#{domain}", &block)
+        .with_fallback { nil }
+        .with_threshold(1)
+        .with_cool_off_time(5.minutes.seconds)
+        .with_error_handler { |error, handle| error.is_a?(HTTP::Error) || error.is_a?(OpenSSL::SSL::SSLError) ? handle.call(error) : raise(error) }
+        .run
+    else
+      yield
+    end
+  end
+end
diff --git a/app/services/bulk_import_service.rb b/app/services/bulk_import_service.rb
new file mode 100644
index 0000000000..2701b0c7e0
--- /dev/null
+++ b/app/services/bulk_import_service.rb
@@ -0,0 +1,160 @@
+# frozen_string_literal: true
+
+class BulkImportService < BaseService
+  def call(import)
+    @import  = import
+    @account = @import.account
+
+    case @import.type.to_sym
+    when :following
+      import_follows!
+    when :blocking
+      import_blocks!
+    when :muting
+      import_mutes!
+    when :domain_blocking
+      import_domain_blocks!
+    when :bookmarks
+      import_bookmarks!
+    end
+
+    @import.update!(state: :finished, finished_at: Time.now.utc) if @import.processed_items == @import.total_items
+  rescue
+    @import.update!(state: :finished, finished_at: Time.now.utc)
+
+    raise
+  end
+
+  private
+
+  def extract_rows_by_acct
+    local_domain_suffix = "@#{Rails.configuration.x.local_domain}"
+    @import.rows.to_a.index_by { |row| row.data['acct'].delete_suffix(local_domain_suffix) }
+  end
+
+  def import_follows!
+    rows_by_acct = extract_rows_by_acct
+
+    if @import.overwrite?
+      @account.following.find_each do |followee|
+        row = rows_by_acct.delete(followee.acct)
+
+        if row.nil?
+          UnfollowService.new.call(@account, followee)
+        else
+          row.destroy
+          @import.processed_items += 1
+          @import.imported_items += 1
+
+          # Since we're updating the settings of an existing relationship, we can safely call
+          # FollowService directly
+          FollowService.new.call(@account, followee, reblogs: row.data['show_reblogs'], notify: row.data['notify'], languages: row.data['languages'])
+        end
+      end
+
+      # Save pending infos due to `overwrite?` handling
+      @import.save!
+    end
+
+    Import::RowWorker.push_bulk(rows_by_acct.values) do |row|
+      [row.id]
+    end
+  end
+
+  def import_blocks!
+    rows_by_acct = extract_rows_by_acct
+
+    if @import.overwrite?
+      @account.blocking.find_each do |blocked_account|
+        row = rows_by_acct.delete(blocked_account.acct)
+
+        if row.nil?
+          UnblockService.new.call(@account, blocked_account)
+        else
+          row.destroy
+          @import.processed_items += 1
+          @import.imported_items += 1
+          BlockService.new.call(@account, blocked_account)
+        end
+      end
+
+      # Save pending infos due to `overwrite?` handling
+      @import.save!
+    end
+
+    Import::RowWorker.push_bulk(rows_by_acct.values) do |row|
+      [row.id]
+    end
+  end
+
+  def import_mutes!
+    rows_by_acct = extract_rows_by_acct
+
+    if @import.overwrite?
+      @account.muting.find_each do |muted_account|
+        row = rows_by_acct.delete(muted_account.acct)
+
+        if row.nil?
+          UnmuteService.new.call(@account, muted_account)
+        else
+          row.destroy
+          @import.processed_items += 1
+          @import.imported_items += 1
+          MuteService.new.call(@account, muted_account, notifications: row.data['hide_notifications'])
+        end
+      end
+
+      # Save pending infos due to `overwrite?` handling
+      @import.save!
+    end
+
+    Import::RowWorker.push_bulk(rows_by_acct.values) do |row|
+      [row.id]
+    end
+  end
+
+  def import_domain_blocks!
+    domains = @import.rows.map { |row| row.data['domain'] }
+
+    if @import.overwrite?
+      @account.domain_blocks.find_each do |domain_block|
+        domain = domains.delete(domain_block)
+
+        @account.unblock_domain!(domain_block.domain) if domain.nil?
+      end
+    end
+
+    @import.rows.delete_all
+    domains.each { |domain| @account.block_domain!(domain) }
+    @import.update!(processed_items: @import.total_items, imported_items: @import.total_items)
+
+    AfterAccountDomainBlockWorker.push_bulk(domains) do |domain|
+      [@account.id, domain]
+    end
+  end
+
+  def import_bookmarks!
+    rows_by_uri = @import.rows.index_by { |row| row.data['uri'] }
+
+    if @import.overwrite?
+      @account.bookmarks.includes(:status).find_each do |bookmark|
+        row = rows_by_uri.delete(ActivityPub::TagManager.instance.uri_for(bookmark.status))
+
+        if row.nil?
+          bookmark.destroy!
+        else
+          row.destroy
+          @import.processed_items += 1
+          @import.imported_items += 1
+        end
+      end
+
+      # Save pending infos due to `overwrite?` handling
+      @import.save!
+    end
+
+    Import::RowWorker.push_bulk(rows_by_uri.values) do |row|
+      [row.id]
+    end
+  end
+end
diff --git a/app/services/fetch_link_card_service.rb b/app/services/fetch_link_card_service.rb
index 8d07958b73..9c56c862ec 100644
--- a/app/services/fetch_link_card_service.rb
+++ b/app/services/fetch_link_card_service.rb
@@ -23,7 +23,7 @@ class FetchLinkCardService < BaseService
 
     @url = @original_url.to_s
 
-    with_lock("fetch:#{@original_url}") do
+    with_redis_lock("fetch:#{@original_url}") do
       @card = PreviewCard.find_by(url: @url)
       process_url if @card.nil? || @card.updated_at <= 2.weeks.ago || @card.missing_image?
     end
diff --git a/app/services/fetch_resource_service.rb b/app/services/fetch_resource_service.rb
index 4470fca010..a2000e5967 100644
--- a/app/services/fetch_resource_service.rb
+++ b/app/services/fetch_resource_service.rb
@@ -4,6 +4,7 @@ class FetchResourceService < BaseService
   include JsonLdHelper
 
   ACCEPT_HEADER = 'application/activity+json, application/ld+json; profile="https://www.w3.org/ns/activitystreams", text/html;q=0.1'
+  ACTIVITY_STREAM_LINK_TYPES = ['application/activity+json', 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"'].freeze
 
   attr_reader :response_code
 
@@ -65,7 +66,7 @@ class FetchResourceService < BaseService
 
   def process_html(response)
     page      = Nokogiri::HTML(response.body_with_limit)
-    json_link = page.xpath('//link[@rel="alternate"]').find { |link| ['application/activity+json', 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"'].include?(link['type']) }
+    json_link = page.xpath('//link[@rel="alternate"]').find { |link| ACTIVITY_STREAM_LINK_TYPES.include?(link['type']) }
 
     process(json_link['href'], terminal: true) unless json_link.nil?
   end
diff --git a/app/services/follow_migration_service.rb b/app/services/follow_migration_service.rb
index cfe9093cbe..12ed96f984 100644
--- a/app/services/follow_migration_service.rb
+++ b/app/services/follow_migration_service.rb
@@ -9,10 +9,10 @@ class FollowMigrationService < FollowService
   def call(source_account, target_account, old_target_account, bypass_locked: false)
     @old_target_account = old_target_account
 
-    follow    = source_account.active_relationships.find_by(target_account: old_target_account)
-    reblogs   = follow&.show_reblogs?
-    notify    = follow&.notify?
-    languages = follow&.languages
+    @original_follow = source_account.active_relationships.find_by(target_account: old_target_account)
+    reblogs          = @original_follow&.show_reblogs?
+    notify           = @original_follow&.notify?
+    languages        = @original_follow&.languages
 
     super(source_account, target_account, reblogs: reblogs, notify: notify, languages: languages, bypass_locked: bypass_locked, bypass_limit: true)
   end
@@ -21,6 +21,7 @@ class FollowMigrationService < FollowService
 
   def request_follow!
     follow_request = @source_account.request_follow!(@target_account, **follow_options.merge(rate_limit: @options[:with_rate_limit], bypass_limit: @options[:bypass_limit]))
+    migrate_list_accounts!
 
     if @target_account.local?
       LocalNotificationWorker.perform_async(@target_account.id, follow_request.id, follow_request.class.name, 'follow_request')
@@ -32,9 +33,30 @@ class FollowMigrationService < FollowService
     follow_request
   end
 
+  def change_follow_options!
+    migrate_list_accounts!
+    super
+  end
+
+  def change_follow_request_options!
+    migrate_list_accounts!
+    super
+  end
+
   def direct_follow!
     follow = super
+
+    migrate_list_accounts!
     UnfollowService.new.call(@source_account, @old_target_account, skip_unmerge: true)
+
     follow
   end
+
+  def migrate_list_accounts!
+    ListAccount.where(follow_id: @original_follow.id).includes(:list).find_each do |list_account|
+      list_account.list.accounts << @target_account
+    rescue ActiveRecord::RecordInvalid
+      nil
+    end
+  end
 end
diff --git a/app/services/import_service.rb b/app/services/import_service.rb
index f6c94cbb6f..133c081be5 100644
--- a/app/services/import_service.rb
+++ b/app/services/import_service.rb
@@ -2,6 +2,9 @@
 
 require 'csv'
 
+# NOTE: This is a deprecated service, only kept to not break ongoing imports
+# on upgrade. See `BulkImportService` for its replacement.
+
 class ImportService < BaseService
   ROWS_PROCESSING_LIMIT = 20_000
 
diff --git a/app/services/remove_status_service.rb b/app/services/remove_status_service.rb
index 4f98ccea70..e1c1c35fcf 100644
--- a/app/services/remove_status_service.rb
+++ b/app/services/remove_status_service.rb
@@ -18,7 +18,7 @@ class RemoveStatusService < BaseService
     @account  = status.account
     @options  = options
 
-    with_lock("distribute:#{@status.id}") do
+    with_redis_lock("distribute:#{@status.id}") do
       @status.discard_with_reblogs
 
       StatusPin.find_by(status: @status)&.destroy
diff --git a/app/services/resolve_account_service.rb b/app/services/resolve_account_service.rb
index abe1534a55..6204fefd6f 100644
--- a/app/services/resolve_account_service.rb
+++ b/app/services/resolve_account_service.rb
@@ -100,13 +100,13 @@ class ResolveAccountService < BaseService
   end
 
   def split_acct(acct)
-    acct.gsub(/\Aacct:/, '').split('@')
+    acct.delete_prefix('acct:').split('@')
   end
 
   def fetch_account!
     return unless activitypub_ready?
 
-    with_lock("resolve:#{@username}@#{@domain}") do
+    with_redis_lock("resolve:#{@username}@#{@domain}") do
       @account = ActivityPub::FetchRemoteAccountService.new.call(actor_url, suppress_errors: @options[:suppress_errors])
     end
 
diff --git a/app/services/suspend_account_service.rb b/app/services/suspend_account_service.rb
index cfb3eb5831..e9e23a219b 100644
--- a/app/services/suspend_account_service.rb
+++ b/app/services/suspend_account_service.rb
@@ -68,7 +68,7 @@ class SuspendAccountService < BaseService
     @account.media_attachments.find_each do |media_attachment|
       attachment_names.each do |attachment_name|
         attachment = media_attachment.public_send(attachment_name)
-        styles     = [:original] | attachment.styles.keys
+        styles     = MediaAttachment::DEFAULT_STYLES | attachment.styles.keys
 
         next if attachment.blank?
 
diff --git a/app/services/tag_search_service.rb b/app/services/tag_search_service.rb
index b66ccced9d..d5d1974275 100644
--- a/app/services/tag_search_service.rb
+++ b/app/services/tag_search_service.rb
@@ -2,7 +2,7 @@
 
 class TagSearchService < BaseService
   def call(query, options = {})
-    @query   = query.strip.gsub(/\A#/, '')
+    @query   = query.strip.delete_prefix('#')
     @offset  = options.delete(:offset).to_i
     @limit   = options.delete(:limit).to_i
     @options = options
diff --git a/app/services/unfollow_service.rb b/app/services/unfollow_service.rb
index d83a60e4e7..fe9a7f0d87 100644
--- a/app/services/unfollow_service.rb
+++ b/app/services/unfollow_service.rb
@@ -15,7 +15,7 @@ class UnfollowService < BaseService
     @target_account = target_account
     @options        = options
 
-    with_lock("relationship:#{[source_account.id, target_account.id].sort.join(':')}") do
+    with_redis_lock("relationship:#{[source_account.id, target_account.id].sort.join(':')}") do
       unfollow! || undo_follow_request!
     end
   end
diff --git a/app/services/unsuspend_account_service.rb b/app/services/unsuspend_account_service.rb
index d851a0f708..51665eab94 100644
--- a/app/services/unsuspend_account_service.rb
+++ b/app/services/unsuspend_account_service.rb
@@ -64,7 +64,7 @@ class UnsuspendAccountService < BaseService
     @account.media_attachments.find_each do |media_attachment|
       attachment_names.each do |attachment_name|
         attachment = media_attachment.public_send(attachment_name)
-        styles     = [:original] | attachment.styles.keys
+        styles     = MediaAttachment::DEFAULT_STYLES | attachment.styles.keys
 
         next if attachment.blank?
 
diff --git a/app/services/vote_service.rb b/app/services/vote_service.rb
index 9ebf5a98d9..3e92a1690a 100644
--- a/app/services/vote_service.rb
+++ b/app/services/vote_service.rb
@@ -18,7 +18,7 @@ class VoteService < BaseService
 
     already_voted = true
 
-    with_lock("vote:#{@poll.id}:#{@account.id}") do
+    with_redis_lock("vote:#{@poll.id}:#{@account.id}") do
       already_voted = @poll.votes.where(account: @account).exists?
 
       ApplicationRecord.transaction do
diff --git a/app/validators/email_mx_validator.rb b/app/validators/email_mx_validator.rb
index 19c57bdf66..a30a0c820d 100644
--- a/app/validators/email_mx_validator.rb
+++ b/app/validators/email_mx_validator.rb
@@ -8,9 +8,7 @@ class EmailMxValidator < ActiveModel::Validator
 
     domain = get_domain(user.email)
 
-    if domain.blank?
-      user.errors.add(:email, :invalid)
-    elsif domain.include?('..')
+    if domain.blank? || domain.include?('..')
       user.errors.add(:email, :invalid)
     elsif !on_allowlist?(domain)
       resolved_ips, resolved_domains = resolve_mx(domain)
diff --git a/app/validators/import_validator.rb b/app/validators/import_validator.rb
deleted file mode 100644
index 782baf5d6b..0000000000
--- a/app/validators/import_validator.rb
+++ /dev/null
@@ -1,46 +0,0 @@
-# frozen_string_literal: true
-
-require 'csv'
-
-class ImportValidator < ActiveModel::Validator
-  KNOWN_HEADERS = [
-    'Account address',
-    '#domain',
-    '#uri',
-  ].freeze
-
-  def validate(import)
-    return if import.type.blank? || import.data.blank?
-
-    # We parse because newlines could be part of individual rows. This
-    # runs on create so we should be reading the local file here before
-    # it is uploaded to object storage or moved anywhere...
-    csv_data = CSV.parse(import.data.queued_for_write[:original].read)
-
-    row_count  = csv_data.size
-    row_count -= 1 if KNOWN_HEADERS.include?(csv_data.first&.first)
-
-    import.errors.add(:data, I18n.t('imports.errors.over_rows_processing_limit', count: ImportService::ROWS_PROCESSING_LIMIT)) if row_count > ImportService::ROWS_PROCESSING_LIMIT
-
-    case import.type
-    when 'following'
-      validate_following_import(import, row_count)
-    end
-  rescue CSV::MalformedCSVError
-    import.errors.add(:data, :malformed)
-  end
-
-  private
-
-  def validate_following_import(import, row_count)
-    base_limit = FollowLimitValidator.limit_for_account(import.account)
-
-    limit = if import.overwrite?
-              base_limit
-            else
-              base_limit - import.account.following_count
-            end
-
-    import.errors.add(:data, I18n.t('users.follow_limit_reached', limit: base_limit)) if row_count > limit
-  end
-end
diff --git a/app/validators/vote_validator.rb b/app/validators/vote_validator.rb
index 9c55f9ab6d..9bd17fbe80 100644
--- a/app/validators/vote_validator.rb
+++ b/app/validators/vote_validator.rb
@@ -6,15 +6,23 @@ class VoteValidator < ActiveModel::Validator
 
     vote.errors.add(:base, I18n.t('polls.errors.invalid_choice')) if invalid_choice?(vote)
 
-    if vote.poll_multiple? && already_voted_for_same_choice_on_multiple_poll?(vote)
-      vote.errors.add(:base, I18n.t('polls.errors.already_voted'))
-    elsif !vote.poll_multiple? && already_voted_on_non_multiple_poll?(vote)
-      vote.errors.add(:base, I18n.t('polls.errors.already_voted'))
-    end
+    vote.errors.add(:base, I18n.t('polls.errors.already_voted')) if additional_voting_not_allowed?(vote)
   end
 
   private
 
+  def additional_voting_not_allowed?(vote)
+    poll_multiple_and_already_voted?(vote) || poll_non_multiple_and_already_voted?(vote)
+  end
+
+  def poll_multiple_and_already_voted?(vote)
+    vote.poll_multiple? && already_voted_for_same_choice_on_multiple_poll?(vote)
+  end
+
+  def poll_non_multiple_and_already_voted?(vote)
+    !vote.poll_multiple? && already_voted_on_non_multiple_poll?(vote)
+  end
+
   def invalid_choice?(vote)
     vote.choice.negative? || vote.choice >= vote.poll.options.size
   end
diff --git a/app/views/settings/imports/index.html.haml b/app/views/settings/imports/index.html.haml
new file mode 100644
index 0000000000..02c3f4eb3f
--- /dev/null
+++ b/app/views/settings/imports/index.html.haml
@@ -0,0 +1,49 @@
+- content_for :page_title do
+  = t('settings.import')
+
+= simple_form_for @import, url: settings_imports_path do |f|
+  .field-group
+    = f.input :type, as: :grouped_select, collection: { constructive: %i(following bookmarks), destructive: %i(muting blocking domain_blocking) }, wrapper: :with_block_label, include_blank: false, label_method: ->(type) { I18n.t("imports.types.#{type}") }, group_label_method: ->(group) { I18n.t("imports.type_groups.#{group.first}") }, group_method: :last, hint: t('imports.preface')
+
+  .fields-row
+    .fields-group.fields-row__column.fields-row__column-6
+      = f.input :data, as: :file, wrapper: :with_block_label, hint: t('simple_form.hints.imports.data')
+    .fields-group.fields-row__column.fields-row__column-6
+      = f.input :mode, as: :radio_buttons, collection: Import::MODES, label_method: ->(mode) { safe_join([I18n.t("imports.modes.#{mode}"), content_tag(:span, I18n.t("imports.modes.#{mode}_long"), class: 'hint')]) }, collection_wrapper_tag: 'ul', item_wrapper_tag: 'li'
+
+  .actions
+    = f.button :button, t('imports.upload'), type: :submit
+
+- unless @recent_imports.empty?
+  %hr.spacer/
+
+  %h3= t('imports.recent_imports')
+
+  .table-wrapper
+    %table.table
+      %thead
+        %tr
+          %th= t('imports.type')
+          %th= t('imports.status')
+          %th= t('imports.imported')
+          %th= t('imports.time_started')
+          %th= t('imports.failures')
+      %tbody
+        - @recent_imports.each do |import|
+          %tr
+            %td= t("imports.types.#{import.type}")
+            %td
+              - if import.unconfirmed?
+                = link_to t("imports.states.#{import.state}"), settings_import_path(import)
+              - else
+                = t("imports.states.#{import.state}")
+            %td
+              #{import.imported_items} / #{import.total_items}
+            %td= l(import.created_at)
+            %td
+              - num_failed = import.processed_items - import.imported_items
+              - if num_failed.positive?
+                - if import.finished?
+                  = link_to num_failed, failures_settings_import_path(import, format: 'csv')
+                - else
+                  = num_failed
diff --git a/app/views/settings/imports/show.html.haml b/app/views/settings/imports/show.html.haml
index 7bb4beb01c..65954e3e1e 100644
--- a/app/views/settings/imports/show.html.haml
+++ b/app/views/settings/imports/show.html.haml
@@ -1,15 +1,15 @@
 - content_for :page_title do
-  = t('settings.import')
+  = t("imports.titles.#{@bulk_import.type.to_s}")
 
-= simple_form_for @import, url: settings_import_path do |f|
-  .field-group
-    = f.input :type, collection: Import.types.keys, wrapper: :with_block_label, include_blank: false, label_method: lambda { |type| I18n.t("imports.types.#{type}") }, hint: t('imports.preface')
+- if @bulk_import.likely_mismatched?
+  .flash-message.warning= t("imports.mismatched_types_warning")
 
-  .fields-row
-    .fields-group.fields-row__column.fields-row__column-6
-      = f.input :data, wrapper: :with_block_label, hint: t('simple_form.hints.imports.data')
-    .fields-group.fields-row__column.fields-row__column-6
-      = f.input :mode, as: :radio_buttons, collection: Import::MODES, label_method: lambda { |mode| safe_join([I18n.t("imports.modes.#{mode}"), content_tag(:span, I18n.t("imports.modes.#{mode}_long"), class: 'hint')]) }, collection_wrapper_tag: 'ul', item_wrapper_tag: 'li'
+- if @bulk_import.overwrite?
+  %p.hint= t("imports.overwrite_preambles.#{@bulk_import.type.to_s}_html", filename: @bulk_import.original_filename, total_items: @bulk_import.total_items)
+- else
+  %p.hint= t("imports.preambles.#{@bulk_import.type.to_s}_html", filename: @bulk_import.original_filename, total_items: @bulk_import.total_items)
 
+.simple_form
   .actions
-    = f.button :button, t('imports.upload'), type: :submit
+    = link_to t('generic.cancel'), settings_import_path(@bulk_import), method: :delete, class: 'button button-tertiary'
+    = link_to t('generic.confirm'), confirm_settings_import_path(@bulk_import), method: :post, class: 'button'
diff --git a/app/workers/bulk_import_worker.rb b/app/workers/bulk_import_worker.rb
new file mode 100644
index 0000000000..54571a95c0
--- /dev/null
+++ b/app/workers/bulk_import_worker.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+class BulkImportWorker
+  include Sidekiq::Worker
+
+  sidekiq_options queue: 'pull', retry: false
+
+  def perform(import_id)
+    import = BulkImport.find(import_id)
+    import.update!(state: :in_progress)
+    BulkImportService.new.call(import)
+  end
+end
diff --git a/app/workers/distribution_worker.rb b/app/workers/distribution_worker.rb
index 59cdbc7b25..1d58e53e94 100644
--- a/app/workers/distribution_worker.rb
+++ b/app/workers/distribution_worker.rb
@@ -6,7 +6,7 @@ class DistributionWorker
   include Lockable
 
   def perform(status_id, options = {})
-    with_lock("distribute:#{status_id}") do
+    with_redis_lock("distribute:#{status_id}") do
       FanOutOnWriteService.new.call(Status.find(status_id), **options.symbolize_keys)
     end
   rescue ActiveRecord::RecordNotFound
diff --git a/app/workers/import/relationship_worker.rb b/app/workers/import/relationship_worker.rb
index c2728c1961..a411ab5876 100644
--- a/app/workers/import/relationship_worker.rb
+++ b/app/workers/import/relationship_worker.rb
@@ -1,5 +1,8 @@
 # frozen_string_literal: true
 
+# NOTE: This is a deprecated worker, only kept to not break ongoing imports
+# on upgrade. See `Import::RowWorker` for its replacement.
+
 class Import::RelationshipWorker
   include Sidekiq::Worker
 
diff --git a/app/workers/import/row_worker.rb b/app/workers/import/row_worker.rb
new file mode 100644
index 0000000000..09dd6ce736
--- /dev/null
+++ b/app/workers/import/row_worker.rb
@@ -0,0 +1,33 @@
+# frozen_string_literal: true
+
+class Import::RowWorker
+  include Sidekiq::Worker
+
+  sidekiq_options queue: 'pull', retry: 6, dead: false
+
+  sidekiq_retries_exhausted do |msg, _exception|
+    ActiveRecord::Base.connection_pool.with_connection do
+      # Increment the total number of processed items, and bump the state of the import if needed
+      bulk_import_id = BulkImportRow.where(id: msg['args'][0]).pick(:id)
+      BulkImport.progress!(bulk_import_id) unless bulk_import_id.nil?
+    end
+  end
+
+  def perform(row_id)
+    row = BulkImportRow.eager_load(bulk_import: :account).find_by(id: row_id)
+    return true if row.nil?
+
+    imported = BulkImportRowService.new.call(row)
+
+    mark_as_processed!(row, imported)
+  end
+
+  private
+
+  def mark_as_processed!(row, imported)
+    bulk_import_id = row.bulk_import_id
+    row.destroy! if imported
+
+    BulkImport.progress!(bulk_import_id, imported: imported)
+  end
+end
diff --git a/app/workers/import_worker.rb b/app/workers/import_worker.rb
index dfa71b29ec..b6afb972a9 100644
--- a/app/workers/import_worker.rb
+++ b/app/workers/import_worker.rb
@@ -1,5 +1,8 @@
 # frozen_string_literal: true
 
+# NOTE: This is a deprecated worker, only kept to not break ongoing imports
+# on upgrade. See `ImportWorker` for its replacement.
+
 class ImportWorker
   include Sidekiq::Worker
 
diff --git a/app/workers/move_worker.rb b/app/workers/move_worker.rb
index 3b429928ea..cb091671df 100644
--- a/app/workers/move_worker.rb
+++ b/app/workers/move_worker.rb
@@ -8,9 +8,9 @@ class MoveWorker
     @target_account = Account.find(target_account_id)
 
     if @target_account.local? && @source_account.local?
-      nb_moved = rewrite_follows!
-      @source_account.update_count!(:followers_count, -nb_moved)
-      @target_account.update_count!(:followers_count, nb_moved)
+      num_moved = rewrite_follows!
+      @source_account.update_count!(:followers_count, -num_moved)
+      @target_account.update_count!(:followers_count, num_moved)
     else
       queue_follow_unfollows!
     end
@@ -29,12 +29,44 @@ class MoveWorker
   private
 
   def rewrite_follows!
+    num_moved = 0
+
+    # First, approve pending follow requests for the new account,
+    # this allows correctly processing list memberships with pending
+    # follow requests
+    FollowRequest.where(account: @source_account.followers, target_account_id: @target_account.id).find_each do |follow_request|
+      ListAccount.where(follow_id: follow_request.id).includes(:list).find_each do |list_account|
+        list_account.list.accounts << @target_account
+      rescue ActiveRecord::RecordInvalid
+        nil
+      end
+
+      follow_request.authorize!
+    end
+
+    # Then handle accounts that follow both the old and new account
+    @source_account.passive_relationships
+                   .where(account: Account.local)
+                   .where(account: @target_account.followers.local)
+                   .in_batches do |follows|
+      ListAccount.where(follow: follows).includes(:list).find_each do |list_account|
+        list_account.list.accounts << @target_account
+      rescue ActiveRecord::RecordInvalid
+        nil
+      end
+    end
+
+    # Finally, handle the common case of accounts not following the new account
     @source_account.passive_relationships
                    .where(account: Account.local)
                    .where.not(account: @target_account.followers.local)
                    .where.not(account_id: @target_account.id)
-                   .in_batches
-                   .update_all(target_account_id: @target_account.id)
+                   .in_batches do |follows|
+      ListAccount.where(follow: follows).in_batches.update_all(account_id: @target_account.id)
+      num_moved += follows.update_all(target_account_id: @target_account.id)
+    end
+
+    num_moved
   end
 
   def queue_follow_unfollows!
diff --git a/app/workers/scheduler/accounts_statuses_cleanup_scheduler.rb b/app/workers/scheduler/accounts_statuses_cleanup_scheduler.rb
index a7737622db..a2ab31cc5d 100644
--- a/app/workers/scheduler/accounts_statuses_cleanup_scheduler.rb
+++ b/app/workers/scheduler/accounts_statuses_cleanup_scheduler.rb
@@ -38,17 +38,37 @@ class Scheduler::AccountsStatusesCleanupScheduler
     return if under_load?
 
     budget = compute_budget
-    first_policy_id = last_processed_id
+
+    # If the budget allows it, we want to consider all accounts with enabled
+    # auto cleanup at least once.
+    #
+    # We start from `first_policy_id` (the last processed id in the previous
+    # run) and process each policy until we loop to `first_policy_id`,
+    # recording into `affected_policies` any policy that caused posts to be
+    # deleted.
+    #
+    # After that, we set `full_iteration` to `false` and continue looping on
+    # policies from `affected_policies`.
+    first_policy_id   = last_processed_id || 0
+    first_iteration   = true
+    full_iteration    = true
+    affected_policies = []
 
     loop do
       num_processed_accounts = 0
 
-      scope = AccountStatusesCleanupPolicy.where(enabled: true)
-      scope = scope.where(id: first_policy_id...) if first_policy_id.present?
+      scope = cleanup_policies(first_policy_id, affected_policies, first_iteration, full_iteration)
       scope.find_each(order: :asc) do |policy|
         num_deleted = AccountStatusesCleanupService.new.call(policy, [budget, PER_ACCOUNT_BUDGET].min)
-        num_processed_accounts += 1 unless num_deleted.zero?
         budget -= num_deleted
+
+        unless num_deleted.zero?
+          num_processed_accounts += 1
+          affected_policies << policy.id if full_iteration
+        end
+
+        full_iteration = false if !first_iteration && policy.id >= first_policy_id
+
         if budget.zero?
           save_last_processed_id(policy.id)
           break
@@ -57,9 +77,10 @@ class Scheduler::AccountsStatusesCleanupScheduler
 
       # The idea here is to loop through all policies at least once until the budget is exhausted
       # and start back after the last processed account otherwise
-      break if budget.zero? || (num_processed_accounts.zero? && first_policy_id.nil?)
+      break if budget.zero? || (num_processed_accounts.zero? && !full_iteration)
 
-      first_policy_id = nil
+      full_iteration  = false unless first_iteration
+      first_iteration = false
     end
   end
 
@@ -76,12 +97,28 @@ class Scheduler::AccountsStatusesCleanupScheduler
 
   private
 
+  def cleanup_policies(first_policy_id, affected_policies, first_iteration, full_iteration)
+    scope = AccountStatusesCleanupPolicy.where(enabled: true)
+
+    if full_iteration
+      # If we are doing a full iteration, examine all policies we have not examined yet
+      if first_iteration
+        scope.where(id: first_policy_id...)
+      else
+        scope.where(id: ..first_policy_id).or(scope.where(id: affected_policies))
+      end
+    else
+      # Otherwise, examine only policies that previously yielded posts to delete
+      scope.where(id: affected_policies)
+    end
+  end
+
   def queue_under_load?(name, max_latency)
     Sidekiq::Queue.new(name).latency > max_latency
   end
 
   def last_processed_id
-    redis.get('account_statuses_cleanup_scheduler:last_policy_id')
+    redis.get('account_statuses_cleanup_scheduler:last_policy_id')&.to_i
   end
 
   def save_last_processed_id(id)
diff --git a/app/workers/scheduler/vacuum_scheduler.rb b/app/workers/scheduler/vacuum_scheduler.rb
index 9544f808bf..9e884caefd 100644
--- a/app/workers/scheduler/vacuum_scheduler.rb
+++ b/app/workers/scheduler/vacuum_scheduler.rb
@@ -23,6 +23,7 @@ class Scheduler::VacuumScheduler
       backups_vacuum,
       access_tokens_vacuum,
       feeds_vacuum,
+      imports_vacuum,
     ]
   end
 
@@ -50,6 +51,10 @@ class Scheduler::VacuumScheduler
     Vacuum::FeedsVacuum.new
   end
 
+  def imports_vacuum
+    Vacuum::ImportsVacuum.new
+  end
+
   def content_retention_policy
     ContentRetentionPolicy.current
   end
diff --git a/babel.config.js b/babel.config.js
index 0b81f1453f..abfdc5b2ca 100644
--- a/babel.config.js
+++ b/babel.config.js
@@ -9,6 +9,9 @@ module.exports = (api) => {
     loose: true,
     modules: false,
     debug: false,
+    include: [
+      'proposal-numeric-separator',
+    ],
   };
 
   const config = {
diff --git a/config/deploy.rb b/config/deploy.rb
index b69a830674..b19567a729 100644
--- a/config/deploy.rb
+++ b/config/deploy.rb
@@ -1,6 +1,6 @@
 # frozen_string_literal: true
 
-lock '3.17.1'
+lock '3.17.2'
 
 set :repo_url, ENV.fetch('REPO', 'https://github.com/mastodon/mastodon.git')
 set :branch, ENV.fetch('BRANCH', 'main')
@@ -13,9 +13,12 @@ set :migration_role, :app
 append :linked_files, '.env.production', 'public/robots.txt'
 append :linked_dirs, 'vendor/bundle', 'node_modules', 'public/system'
 
+SYSTEMD_SERVICES = %i[sidekiq streaming web].freeze
+SERVICE_ACTIONS = %i[reload restart status].freeze
+
 namespace :systemd do
-  %i[sidekiq streaming web].each do |service|
-    %i[reload restart status].each do |action|
+  SYSTEMD_SERVICES.each do |service|
+    SERVICE_ACTIONS.each do |action|
       desc "Perform a #{action} on #{service} service"
       task "#{service}:#{action}".to_sym do
         on roles(:app) do
diff --git a/config/environments/development.rb b/config/environments/development.rb
index a633dfce51..306324c046 100644
--- a/config/environments/development.rb
+++ b/config/environments/development.rb
@@ -14,7 +14,7 @@ Rails.application.configure do
 
   # Enable/disable caching. By default caching is disabled.
   # Run rails dev:cache to toggle caching.
-  if Rails.root.join('tmp/caching-dev.txt').exist?
+  if Rails.root.join('tmp', 'caching-dev.txt').exist?
     config.action_controller.perform_caching = true
     config.cache_store = :redis_cache_store, REDIS_CACHE_PARAMS
   else
diff --git a/config/environments/test.rb b/config/environments/test.rb
index 493b041ebb..08cc4c4d3c 100644
--- a/config/environments/test.rb
+++ b/config/environments/test.rb
@@ -59,7 +59,7 @@ Rails.application.configure do
   end
 end
 
-Paperclip::Attachment.default_options[:path] = "#{Rails.root}/spec/test_files/:class/:id_partition/:style.:extension"
+Paperclip::Attachment.default_options[:path] = Rails.root.join('spec', 'test_files', ':class', ':id_partition', ':style.:extension')
 
 # set fake_data for pam, don't do real calls, just use fake data
 if ENV['PAM_ENABLED'] == 'true'
diff --git a/config/i18n-tasks.yml b/config/i18n-tasks.yml
index a3b5e5c724..92e1c8c7b0 100644
--- a/config/i18n-tasks.yml
+++ b/config/i18n-tasks.yml
@@ -72,6 +72,8 @@ ignore_unused:
   - 'themes.*'
   - 'move_handler.carry_{mutes,blocks}_over_text'
   - 'notification_mailer.*'
+  - 'imports.overwrite_preambles.{following,blocking,muting,domain_blocking,bookmarks}_html'
+  - 'imports.preambles.{following,blocking,muting,domain_blocking,bookmarks}_html'
 
 ignore_inconsistent_interpolations:
   - '*.one'
diff --git a/config/initializers/paperclip.rb b/config/initializers/paperclip.rb
index b72cbe1927..9f0ffc6dc7 100644
--- a/config/initializers/paperclip.rb
+++ b/config/initializers/paperclip.rb
@@ -68,11 +68,7 @@ if ENV['S3_ENABLED'] == 'true'
     }
   )
   
-  if ENV['S3_PERMISSION'] == ''
-    Paperclip::Attachment.default_options.merge!(
-      s3_permissions: ->(*) { nil }
-    )
-  end
+  Paperclip::Attachment.default_options[:s3_permissions] = ->(*) { nil } if ENV['S3_PERMISSION'] == ''
 
   if ENV.has_key?('S3_ENDPOINT')
     Paperclip::Attachment.default_options[:s3_options].merge!(
@@ -90,11 +86,7 @@ if ENV['S3_ENABLED'] == 'true'
     )
   end
 
-  if ENV.has_key?('S3_STORAGE_CLASS')
-    Paperclip::Attachment.default_options[:s3_headers].merge!(
-      'X-Amz-Storage-Class' => ENV['S3_STORAGE_CLASS']
-    )
-  end
+  Paperclip::Attachment.default_options[:s3_headers]['X-Amz-Storage-Class'] = ENV['S3_STORAGE_CLASS'] if ENV.has_key?('S3_STORAGE_CLASS')
 
   # Some S3-compatible providers might not actually be compatible with some APIs
   # used by kt-paperclip, see https://github.com/mastodon/mastodon/issues/16822
diff --git a/config/locales/en.yml b/config/locales/en.yml
index ddefbc49b1..0188519c26 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -1218,7 +1218,9 @@ en:
     all_matching_items_selected_html:
       one: "<strong>%{count}</strong> item matching your search is selected."
       other: All <strong>%{count}</strong> items matching your search are selected.
+    cancel: Cancel
     changes_saved_msg: Changes successfully saved!
+    confirm: Confirm
     copy: Copy
     delete: Delete
     deselect: Deselect all
@@ -1234,15 +1236,51 @@ en:
       other: Something isn't quite right yet! Please review %{count} errors below
   imports:
     errors:
+      empty: Empty CSV file
+      incompatible_type: Incompatible with the selected import type
       invalid_csv_file: 'Invalid CSV file. Error: %{error}'
       over_rows_processing_limit: contains more than %{count} rows
+      too_large: File is too large
+    failures: Failures
+    imported: Imported
+    mismatched_types_warning: It appears you may have selected the wrong type for this import, please double-check.
     modes:
       merge: Merge
       merge_long: Keep existing records and add new ones
       overwrite: Overwrite
       overwrite_long: Replace current records with the new ones
+    overwrite_preambles:
+      blocking_html: You are about to <strong>replace your block list</strong> with up to <strong>%{total_items} accounts</strong> from <strong>%{filename}</strong>.
+      bookmarks_html: You are about to <strong>replace your bookmarks</strong> with up to <strong>%{total_items} posts</strong> from <strong>%{filename}</strong>.
+      domain_blocking_html: You are about to <strong>replace your domain block list</strong> with up to <strong>%{total_items} domains</strong> from <strong>%{filename}</strong>.
+      following_html: You are about to <strong>follow</strong> up to <strong>%{total_items} accounts</strong> from <strong>%{filename}</strong> and <strong>stop following anyone else</strong>.
+      muting_html: You are about to <strong>replace your list of muted accounts</strong> with up to <strong>%{total_items} accounts</strong> from <strong>%{filename}</strong>.
+    preambles:
+      blocking_html: You are about to <strong>block</strong> up to <strong>%{total_items} accounts</strong> from <strong>%{filename}</strong>.
+      bookmarks_html: You are about to add up to <strong>%{total_items} posts</strong> from <strong>%{filename}</strong> to your <strong>bookmarks</strong>.
+      domain_blocking_html: You are about to <strong>block</strong> up to <strong>%{total_items} domains</strong> from <strong>%{filename}</strong>.
+      following_html: You are about to <strong>follow</strong> up to <strong>%{total_items} accounts</strong> from <strong>%{filename}</strong>.
+      muting_html: You are about to <strong>mute</strong> up to <strong>%{total_items} accounts</strong> from <strong>%{filename}</strong>.
     preface: You can import data that you have exported from another server, such as a list of the people you are following or blocking.
+    recent_imports: Recent imports
+    states:
+      finished: Finished
+      in_progress: In progress
+      scheduled: Scheduled
+      unconfirmed: Unconfirmed
+    status: Status
     success: Your data was successfully uploaded and will be processed in due time
+    time_started: Started at
+    titles:
+      blocking: Importing blocked accounts
+      bookmarks: Importing bookmarks
+      domain_blocking: Importing blocked domains
+      following: Importing followed accounts
+      muting: Importing muted accounts
+    type: Import type
+    type_groups:
+      constructive: Follows & Bookmarks
+      destructive: Blocks & mutes
     types:
       blocking: Blocking list
       bookmarks: Bookmarks
diff --git a/config/navigation.rb b/config/navigation.rb
index aab72d27c4..d6a8d13690 100644
--- a/config/navigation.rb
+++ b/config/navigation.rb
@@ -32,7 +32,7 @@ SimpleNavigation::Configuration.run do |navigation|
     end
 
     n.item :data, safe_join([fa_icon('cloud-download fw'), t('settings.import_and_export')]), settings_export_path do |s|
-      s.item :import, safe_join([fa_icon('cloud-upload fw'), t('settings.import')]), settings_import_path, if: -> { current_user.functional? }
+      s.item :import, safe_join([fa_icon('cloud-upload fw'), t('settings.import')]), settings_imports_path, if: -> { current_user.functional? }
       s.item :export, safe_join([fa_icon('cloud-download fw'), t('settings.export')]), settings_export_path
     end
 
diff --git a/config/routes.rb b/config/routes.rb
index 89f3fd18b9..3286d16106 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -133,73 +133,7 @@ Rails.application.routes.draw do
   get '/@:username_with_domain/(*any)', to: 'home#index', constraints: { username_with_domain: /([^\/])+?/ }, format: false
   get '/settings', to: redirect('/settings/profile')
 
-  namespace :settings do
-    resource :profile, only: [:show, :update] do
-      resources :pictures, only: :destroy
-    end
-
-    get :preferences, to: redirect('/settings/preferences/appearance')
-
-    namespace :preferences do
-      resource :appearance, only: [:show, :update], controller: :appearance
-      resource :notifications, only: [:show, :update]
-      resource :other, only: [:show, :update], controller: :other
-    end
-
-    resource :import, only: [:show, :create]
-    resource :export, only: [:show, :create]
-
-    namespace :exports, constraints: { format: :csv } do
-      resources :follows, only: :index, controller: :following_accounts
-      resources :blocks, only: :index, controller: :blocked_accounts
-      resources :mutes, only: :index, controller: :muted_accounts
-      resources :lists, only: :index, controller: :lists
-      resources :domain_blocks, only: :index, controller: :blocked_domains
-      resources :bookmarks, only: :index, controller: :bookmarks
-    end
-
-    resources :two_factor_authentication_methods, only: [:index] do
-      collection do
-        post :disable
-      end
-    end
-
-    resource :otp_authentication, only: [:show, :create], controller: 'two_factor_authentication/otp_authentication'
-
-    resources :webauthn_credentials, only: [:index, :new, :create, :destroy],
-              path: 'security_keys',
-              controller: 'two_factor_authentication/webauthn_credentials' do
-
-      collection do
-        get :options
-      end
-    end
-
-    namespace :two_factor_authentication do
-      resources :recovery_codes, only: [:create]
-      resource :confirmation, only: [:new, :create]
-    end
-
-    resources :applications, except: [:edit] do
-      member do
-        post :regenerate
-      end
-    end
-
-    resources :flavours, only: [:index, :show, :update], param: :flavour
-
-    resource :delete, only: [:show, :destroy]
-    resource :migration, only: [:show, :create]
-
-    namespace :migration do
-      resource :redirect, only: [:new, :create, :destroy]
-    end
-
-    resources :aliases, only: [:index, :create, :destroy]
-    resources :sessions, only: [:destroy]
-    resources :featured_tags, only: [:index, :create, :destroy]
-    resources :login_activities, only: [:index]
-  end
+  draw(:settings)
 
   namespace :disputes do
     resources :strikes, only: [:show, :index] do
@@ -231,516 +165,11 @@ Rails.application.routes.draw do
   resource :authorize_interaction, only: [:show, :create]
   resource :share, only: [:show, :create]
 
-  namespace :admin do
-    get '/dashboard', to: 'dashboard#index'
-
-    resources :domain_allows, only: [:new, :create, :show, :destroy]
-    resources :domain_blocks, only: [:new, :create, :destroy, :update, :edit] do
-      collection do
-        post :batch
-      end
-    end
-
-    resources :export_domain_allows, only: [:new] do
-      collection do
-        get :export, constraints: { format: :csv }
-        post :import
-      end
-    end
-
-    resources :export_domain_blocks, only: [:new] do
-      collection do
-        get :export, constraints: { format: :csv }
-        post :import
-      end
-    end
-
-    resources :email_domain_blocks, only: [:index, :new, :create] do
-      collection do
-        post :batch
-      end
-    end
-
-    resources :action_logs, only: [:index]
-    resources :warning_presets, except: [:new]
-
-    resources :announcements, except: [:show] do
-      member do
-        post :publish
-        post :unpublish
-      end
-    end
-
-    get '/settings', to: redirect('/admin/settings/branding')
-    get '/settings/edit', to: redirect('/admin/settings/branding')
-
-    namespace :settings do
-      resource :branding, only: [:show, :update], controller: 'branding'
-      resource :registrations, only: [:show, :update], controller: 'registrations'
-      resource :content_retention, only: [:show, :update], controller: 'content_retention'
-      resource :about, only: [:show, :update], controller: 'about'
-      resource :appearance, only: [:show, :update], controller: 'appearance'
-      resource :discovery, only: [:show, :update], controller: 'discovery'
-      resource :other, only: [:show, :update], controller: 'other'
-    end
-
-    resources :site_uploads, only: [:destroy]
-
-    resources :invites, only: [:index, :create, :destroy] do
-      collection do
-        post :deactivate_all
-      end
-    end
-
-    resources :relays, only: [:index, :new, :create, :destroy] do
-      member do
-        post :enable
-        post :disable
-      end
-    end
-
-    resources :instances, only: [:index, :show, :destroy], constraints: { id: /[^\/]+/ } do
-      member do
-        post :clear_delivery_errors
-        post :restart_delivery
-        post :stop_delivery
-      end
-    end
-
-    resources :rules
-
-    resources :webhooks do
-      member do
-        post :enable
-        post :disable
-      end
-
-      resource :secret, only: [], controller: 'webhooks/secrets' do
-        post :rotate
-      end
-    end
-
-    resources :reports, only: [:index, :show] do
-      resources :actions, only: [:create], controller: 'reports/actions' do
-        collection do
-          post :preview
-        end
-      end
-
-      member do
-        post :assign_to_self
-        post :unassign
-        post :reopen
-        post :resolve
-      end
-    end
-
-    resources :report_notes, only: [:create, :destroy]
-
-    resources :accounts, only: [:index, :show, :destroy] do
-      member do
-        post :enable
-        post :unsensitive
-        post :unsilence
-        post :unsuspend
-        post :redownload
-        post :remove_avatar
-        post :remove_header
-        post :memorialize
-        post :approve
-        post :reject
-        post :unblock_email
-      end
-
-      collection do
-        post :batch
-      end
-
-      resource :change_email, only: [:show, :update]
-      resource :reset, only: [:create]
-      resource :action, only: [:new, :create], controller: 'account_actions'
-
-      resources :statuses, only: [:index, :show] do
-        collection do
-          post :batch
-        end
-      end
-
-      resources :relationships, only: [:index]
-
-      resource :confirmation, only: [:create] do
-        collection do
-          post :resend
-        end
-      end
-    end
-
-    resources :users, only: [] do
-      resource :two_factor_authentication, only: [:destroy], controller: 'users/two_factor_authentications'
-      resource :role, only: [:show, :update], controller: 'users/roles'
-    end
-
-    resources :custom_emojis, only: [:index, :new, :create] do
-      collection do
-        post :batch
-      end
-    end
-
-    resources :ip_blocks, only: [:index, :new, :create] do
-      collection do
-        post :batch
-      end
-    end
-
-    resources :roles, except: [:show]
-    resources :account_moderation_notes, only: [:create, :destroy]
-    resource :follow_recommendations, only: [:show, :update]
-    resources :tags, only: [:show, :update]
-
-    namespace :trends do
-      resources :links, only: [:index] do
-        collection do
-          post :batch
-        end
-      end
-
-      resources :tags, only: [:index] do
-        collection do
-          post :batch
-        end
-      end
-
-      resources :statuses, only: [:index] do
-        collection do
-          post :batch
-        end
-      end
-
-      namespace :links do
-        resources :preview_card_providers, only: [:index], path: :publishers do
-          collection do
-            post :batch
-          end
-        end
-      end
-    end
-
-    namespace :disputes do
-      resources :appeals, only: [:index] do
-        member do
-          post :approve
-          post :reject
-        end
-      end
-    end
-  end
+  draw(:admin)
 
   get '/admin', to: redirect('/admin/dashboard', status: 302)
 
-  namespace :api, format: false do
-    # OEmbed
-    get '/oembed', to: 'oembed#show', as: :oembed
-
-    # JSON / REST API
-    namespace :v1 do
-      resources :statuses, only: [:create, :show, :update, :destroy] do
-        scope module: :statuses do
-          resources :reblogged_by, controller: :reblogged_by_accounts, only: :index
-          resources :favourited_by, controller: :favourited_by_accounts, only: :index
-          resource :reblog, only: :create
-          post :unreblog, to: 'reblogs#destroy'
-
-          resource :favourite, only: :create
-          post :unfavourite, to: 'favourites#destroy'
-
-          resource :bookmark, only: :create
-          post :unbookmark, to: 'bookmarks#destroy'
-
-          resource :mute, only: :create
-          post :unmute, to: 'mutes#destroy'
-
-          resource :pin, only: :create
-          post :unpin, to: 'pins#destroy'
-
-          resource :history, only: :show
-          resource :source, only: :show
-
-          post :translate, to: 'translations#create'
-        end
-
-        member do
-          get :context
-        end
-      end
-
-      namespace :timelines do
-        resource :direct, only: :show, controller: :direct
-        resource :home, only: :show, controller: :home
-        resource :public, only: :show, controller: :public
-        resources :tag, only: :show
-        resources :list, only: :show
-      end
-
-      get '/streaming', to: 'streaming#index'
-      get '/streaming/(*any)', to: 'streaming#index'
-
-      resources :custom_emojis, only: [:index]
-      resources :suggestions, only: [:index, :destroy]
-      resources :scheduled_statuses, only: [:index, :show, :update, :destroy]
-      resources :preferences, only: [:index]
-
-      resources :announcements, only: [:index] do
-        scope module: :announcements do
-          resources :reactions, only: [:update, :destroy]
-        end
-
-        member do
-          post :dismiss
-        end
-      end
-
-      # namespace :crypto do
-      #   resources :deliveries, only: :create
-
-      #   namespace :keys do
-      #     resource :upload, only: [:create]
-      #     resource :query,  only: [:create]
-      #     resource :claim,  only: [:create]
-      #     resource :count,  only: [:show]
-      #   end
-
-      #   resources :encrypted_messages, only: [:index] do
-      #     collection do
-      #       post :clear
-      #     end
-      #   end
-      # end
-
-      resources :conversations, only: [:index, :destroy] do
-        member do
-          post :read
-        end
-      end
-
-      resources :media,        only: [:create, :update, :show]
-      resources :blocks,       only: [:index]
-      resources :mutes,        only: [:index]
-      resources :favourites,   only: [:index]
-      resources :bookmarks,    only: [:index]
-      resources :reports,      only: [:create]
-      resources :trends,       only: [:index], controller: 'trends/tags'
-      resources :filters,      only: [:index, :create, :show, :update, :destroy]
-      resources :endorsements, only: [:index]
-      resources :markers,      only: [:index, :create]
-
-      namespace :apps do
-        get :verify_credentials, to: 'credentials#show'
-      end
-
-      resources :apps, only: [:create]
-
-      namespace :trends do
-        resources :links, only: [:index]
-        resources :tags, only: [:index]
-        resources :statuses, only: [:index]
-      end
-
-      namespace :emails do
-        resources :confirmations, only: [:create]
-      end
-
-      resource :instance, only: [:show] do
-        resources :peers, only: [:index], controller: 'instances/peers'
-        resources :rules, only: [:index], controller: 'instances/rules'
-        resources :domain_blocks, only: [:index], controller: 'instances/domain_blocks'
-        resource :privacy_policy, only: [:show], controller: 'instances/privacy_policies'
-        resource :extended_description, only: [:show], controller: 'instances/extended_descriptions'
-        resource :translation_languages, only: [:show], controller: 'instances/translation_languages'
-        resource :activity, only: [:show], controller: 'instances/activity'
-      end
-
-      resource :domain_blocks, only: [:show, :create, :destroy]
-
-      resource :directory, only: [:show]
-
-      resources :follow_requests, only: [:index] do
-        member do
-          post :authorize
-          post :reject
-        end
-      end
-
-      resources :notifications, only: [:index, :show, :destroy] do
-        collection do
-          post :clear
-          delete :destroy_multiple
-        end
-
-        member do
-          post :dismiss
-        end
-      end
-
-      namespace :accounts do
-        get :verify_credentials, to: 'credentials#show'
-        patch :update_credentials, to: 'credentials#update'
-        resource :search, only: :show, controller: :search
-        resource :lookup, only: :show, controller: :lookup
-        resources :relationships, only: :index
-        resources :familiar_followers, only: :index
-      end
-
-      resources :accounts, only: [:create, :show] do
-        resources :statuses, only: :index, controller: 'accounts/statuses'
-        resources :followers, only: :index, controller: 'accounts/follower_accounts'
-        resources :following, only: :index, controller: 'accounts/following_accounts'
-        resources :lists, only: :index, controller: 'accounts/lists'
-        resources :identity_proofs, only: :index, controller: 'accounts/identity_proofs'
-        resources :featured_tags, only: :index, controller: 'accounts/featured_tags'
-
-        member do
-          post :follow
-          post :unfollow
-          post :remove_from_followers
-          post :block
-          post :unblock
-          post :mute
-          post :unmute
-        end
-
-        resource :pin, only: :create, controller: 'accounts/pins'
-        post :unpin, to: 'accounts/pins#destroy'
-        resource :note, only: :create, controller: 'accounts/notes'
-      end
-
-      resources :tags, only: [:show] do
-        member do
-          post :follow
-          post :unfollow
-        end
-      end
-
-      resources :followed_tags, only: [:index]
-
-      resources :lists, only: [:index, :create, :show, :update, :destroy] do
-        resource :accounts, only: [:show, :create, :destroy], controller: 'lists/accounts'
-      end
-
-      namespace :featured_tags do
-        get :suggestions, to: 'suggestions#index'
-      end
-
-      resources :featured_tags, only: [:index, :create, :destroy]
-
-      resources :polls, only: [:create, :show] do
-        resources :votes, only: :create, controller: 'polls/votes'
-      end
-
-      namespace :push do
-        resource :subscription, only: [:create, :show, :update, :destroy]
-      end
-
-      namespace :admin do
-        resources :accounts, only: [:index, :show, :destroy] do
-          member do
-            post :enable
-            post :unsensitive
-            post :unsilence
-            post :unsuspend
-            post :approve
-            post :reject
-          end
-
-          resource :action, only: [:create], controller: 'account_actions'
-        end
-
-        resources :reports, only: [:index, :update, :show] do
-          member do
-            post :assign_to_self
-            post :unassign
-            post :reopen
-            post :resolve
-          end
-        end
-
-        resources :domain_allows, only: [:index, :show, :create, :destroy]
-        resources :domain_blocks, only: [:index, :show, :update, :create, :destroy]
-        resources :email_domain_blocks, only: [:index, :show, :create, :destroy]
-        resources :ip_blocks, only: [:index, :show, :update, :create, :destroy]
-
-        namespace :trends do
-          resources :tags, only: [:index] do
-            member do
-              post :approve
-              post :reject
-            end
-          end
-          resources :links, only: [:index] do
-            member do
-              post :approve
-              post :reject
-            end
-          end
-          resources :statuses, only: [:index] do
-            member do
-              post :approve
-              post :reject
-            end
-          end
-
-          namespace :links do
-            resources :preview_card_providers, only: [:index], path: :publishers do
-              member do
-                post :approve
-                post :reject
-              end
-            end
-          end
-        end
-
-        post :measures, to: 'measures#create'
-        post :dimensions, to: 'dimensions#create'
-        post :retention, to: 'retention#create'
-
-        resources :canonical_email_blocks, only: [:index, :create, :show, :destroy] do
-          collection do
-            post :test
-          end
-        end
-      end
-    end
-
-    namespace :v2 do
-      get '/search', to: 'search#index', as: :search
-
-      resources :media,       only: [:create]
-      resources :suggestions, only: [:index]
-      resource  :instance,    only: [:show]
-      resources :filters,     only: [:index, :create, :show, :update, :destroy] do
-        resources :keywords, only: [:index, :create], controller: 'filters/keywords'
-        resources :statuses, only: [:index, :create], controller: 'filters/statuses'
-      end
-
-      namespace :filters do
-        resources :keywords, only: [:show, :update, :destroy]
-        resources :statuses, only: [:show, :destroy]
-      end
-
-      namespace :admin do
-        resources :accounts, only: [:index]
-      end
-    end
-
-    namespace :web do
-      resource :settings, only: [:update]
-      resource :embed, only: [:create]
-      resources :push_subscriptions, only: [:create] do
-        member do
-          put :update
-        end
-      end
-    end
-  end
+  draw(:api)
 
   web_app_paths.each do |path|
     get path, to: 'home#index'
diff --git a/config/routes/admin.rb b/config/routes/admin.rb
new file mode 100644
index 0000000000..850994b6d0
--- /dev/null
+++ b/config/routes/admin.rb
@@ -0,0 +1,205 @@
+# frozen_string_literal: true
+
+namespace :admin do
+  get '/dashboard', to: 'dashboard#index'
+
+  resources :domain_allows, only: [:new, :create, :show, :destroy]
+  resources :domain_blocks, only: [:new, :create, :destroy, :update, :edit] do
+    collection do
+      post :batch
+    end
+  end
+
+  resources :export_domain_allows, only: [:new] do
+    collection do
+      get :export, constraints: { format: :csv }
+      post :import
+    end
+  end
+
+  resources :export_domain_blocks, only: [:new] do
+    collection do
+      get :export, constraints: { format: :csv }
+      post :import
+    end
+  end
+
+  resources :email_domain_blocks, only: [:index, :new, :create] do
+    collection do
+      post :batch
+    end
+  end
+
+  resources :action_logs, only: [:index]
+  resources :warning_presets, except: [:new]
+
+  resources :announcements, except: [:show] do
+    member do
+      post :publish
+      post :unpublish
+    end
+  end
+
+  get '/settings', to: redirect('/admin/settings/branding')
+  get '/settings/edit', to: redirect('/admin/settings/branding')
+
+  namespace :settings do
+    resource :branding, only: [:show, :update], controller: 'branding'
+    resource :registrations, only: [:show, :update], controller: 'registrations'
+    resource :content_retention, only: [:show, :update], controller: 'content_retention'
+    resource :about, only: [:show, :update], controller: 'about'
+    resource :appearance, only: [:show, :update], controller: 'appearance'
+    resource :discovery, only: [:show, :update], controller: 'discovery'
+    resource :other, only: [:show, :update], controller: 'other'
+  end
+
+  resources :site_uploads, only: [:destroy]
+
+  resources :invites, only: [:index, :create, :destroy] do
+    collection do
+      post :deactivate_all
+    end
+  end
+
+  resources :relays, only: [:index, :new, :create, :destroy] do
+    member do
+      post :enable
+      post :disable
+    end
+  end
+
+  resources :instances, only: [:index, :show, :destroy], constraints: { id: %r{[^/]+} } do
+    member do
+      post :clear_delivery_errors
+      post :restart_delivery
+      post :stop_delivery
+    end
+  end
+
+  resources :rules
+
+  resources :webhooks do
+    member do
+      post :enable
+      post :disable
+    end
+
+    resource :secret, only: [], controller: 'webhooks/secrets' do
+      post :rotate
+    end
+  end
+
+  resources :reports, only: [:index, :show] do
+    resources :actions, only: [:create], controller: 'reports/actions' do
+      collection do
+        post :preview
+      end
+    end
+
+    member do
+      post :assign_to_self
+      post :unassign
+      post :reopen
+      post :resolve
+    end
+  end
+
+  resources :report_notes, only: [:create, :destroy]
+
+  resources :accounts, only: [:index, :show, :destroy] do
+    member do
+      post :enable
+      post :unsensitive
+      post :unsilence
+      post :unsuspend
+      post :redownload
+      post :remove_avatar
+      post :remove_header
+      post :memorialize
+      post :approve
+      post :reject
+      post :unblock_email
+    end
+
+    collection do
+      post :batch
+    end
+
+    resource :change_email, only: [:show, :update]
+    resource :reset, only: [:create]
+    resource :action, only: [:new, :create], controller: 'account_actions'
+
+    resources :statuses, only: [:index, :show] do
+      collection do
+        post :batch
+      end
+    end
+
+    resources :relationships, only: [:index]
+
+    resource :confirmation, only: [:create] do
+      collection do
+        post :resend
+      end
+    end
+  end
+
+  resources :users, only: [] do
+    resource :two_factor_authentication, only: [:destroy], controller: 'users/two_factor_authentications'
+    resource :role, only: [:show, :update], controller: 'users/roles'
+  end
+
+  resources :custom_emojis, only: [:index, :new, :create] do
+    collection do
+      post :batch
+    end
+  end
+
+  resources :ip_blocks, only: [:index, :new, :create] do
+    collection do
+      post :batch
+    end
+  end
+
+  resources :roles, except: [:show]
+  resources :account_moderation_notes, only: [:create, :destroy]
+  resource :follow_recommendations, only: [:show, :update]
+  resources :tags, only: [:show, :update]
+
+  namespace :trends do
+    resources :links, only: [:index] do
+      collection do
+        post :batch
+      end
+    end
+
+    resources :tags, only: [:index] do
+      collection do
+        post :batch
+      end
+    end
+
+    resources :statuses, only: [:index] do
+      collection do
+        post :batch
+      end
+    end
+
+    namespace :links do
+      resources :preview_card_providers, only: [:index], path: :publishers do
+        collection do
+          post :batch
+        end
+      end
+    end
+  end
+
+  namespace :disputes do
+    resources :appeals, only: [:index] do
+      member do
+        post :approve
+        post :reject
+      end
+    end
+  end
+end
diff --git a/config/routes/api.rb b/config/routes/api.rb
new file mode 100644
index 0000000000..1ca4d41beb
--- /dev/null
+++ b/config/routes/api.rb
@@ -0,0 +1,306 @@
+# frozen_string_literal: true
+
+namespace :api, format: false do
+  # OEmbed
+  get '/oembed', to: 'oembed#show', as: :oembed
+
+  # JSON / REST API
+  namespace :v1 do
+    resources :statuses, only: [:create, :show, :update, :destroy] do
+      scope module: :statuses do
+        resources :reblogged_by, controller: :reblogged_by_accounts, only: :index
+        resources :favourited_by, controller: :favourited_by_accounts, only: :index
+        resource :reblog, only: :create
+        post :unreblog, to: 'reblogs#destroy'
+
+        resource :favourite, only: :create
+        post :unfavourite, to: 'favourites#destroy'
+
+        resource :bookmark, only: :create
+        post :unbookmark, to: 'bookmarks#destroy'
+
+        resource :mute, only: :create
+        post :unmute, to: 'mutes#destroy'
+
+        resource :pin, only: :create
+        post :unpin, to: 'pins#destroy'
+
+        resource :history, only: :show
+        resource :source, only: :show
+
+        post :translate, to: 'translations#create'
+      end
+
+      member do
+        get :context
+      end
+    end
+
+    namespace :timelines do
+      resource :direct, only: :show, controller: :direct
+      resource :home, only: :show, controller: :home
+      resource :public, only: :show, controller: :public
+      resources :tag, only: :show
+      resources :list, only: :show
+    end
+
+    get '/streaming', to: 'streaming#index'
+    get '/streaming/(*any)', to: 'streaming#index'
+
+    resources :custom_emojis, only: [:index]
+    resources :suggestions, only: [:index, :destroy]
+    resources :scheduled_statuses, only: [:index, :show, :update, :destroy]
+    resources :preferences, only: [:index]
+
+    resources :announcements, only: [:index] do
+      scope module: :announcements do
+        resources :reactions, only: [:update, :destroy]
+      end
+
+      member do
+        post :dismiss
+      end
+    end
+
+    # namespace :crypto do
+    #   resources :deliveries, only: :create
+
+    #   namespace :keys do
+    #     resource :upload, only: [:create]
+    #     resource :query,  only: [:create]
+    #     resource :claim,  only: [:create]
+    #     resource :count,  only: [:show]
+    #   end
+
+    #   resources :encrypted_messages, only: [:index] do
+    #     collection do
+    #       post :clear
+    #     end
+    #   end
+    # end
+
+    resources :conversations, only: [:index, :destroy] do
+      member do
+        post :read
+      end
+    end
+
+    resources :media, only: [:create, :update, :show]
+    resources :blocks, only: [:index]
+    resources :mutes, only: [:index]
+    resources :favourites, only: [:index]
+    resources :bookmarks, only: [:index]
+    resources :reports, only: [:create]
+    resources :trends, only: [:index], controller: 'trends/tags'
+    resources :filters, only: [:index, :create, :show, :update, :destroy]
+    resources :endorsements, only: [:index]
+    resources :markers, only: [:index, :create]
+
+    namespace :apps do
+      get :verify_credentials, to: 'credentials#show'
+    end
+
+    resources :apps, only: [:create]
+
+    namespace :trends do
+      resources :tags, only: [:index]
+      resources :links, only: [:index]
+      resources :statuses, only: [:index]
+    end
+
+    namespace :emails do
+      resources :confirmations, only: [:create]
+    end
+
+    resource :instance, only: [:show] do
+      resources :peers, only: [:index], controller: 'instances/peers'
+      resources :rules, only: [:index], controller: 'instances/rules'
+      resources :domain_blocks, only: [:index], controller: 'instances/domain_blocks'
+      resource :privacy_policy, only: [:show], controller: 'instances/privacy_policies'
+      resource :extended_description, only: [:show], controller: 'instances/extended_descriptions'
+      resource :translation_languages, only: [:show], controller: 'instances/translation_languages'
+      resource :activity, only: [:show], controller: 'instances/activity'
+    end
+
+    resource :domain_blocks, only: [:show, :create, :destroy]
+
+    resource :directory, only: [:show]
+
+    resources :follow_requests, only: [:index] do
+      member do
+        post :authorize
+        post :reject
+      end
+    end
+
+    resources :notifications, only: [:index, :show, :destroy] do
+      collection do
+        post :clear
+        delete :destroy_multiple
+      end
+
+      member do
+        post :dismiss
+      end
+    end
+
+    namespace :accounts do
+      get :verify_credentials, to: 'credentials#show'
+      patch :update_credentials, to: 'credentials#update'
+      resource :search, only: :show, controller: :search
+      resource :lookup, only: :show, controller: :lookup
+      resources :relationships, only: :index
+      resources :familiar_followers, only: :index
+    end
+
+    resources :accounts, only: [:create, :show] do
+      resources :statuses, only: :index, controller: 'accounts/statuses'
+      resources :followers, only: :index, controller: 'accounts/follower_accounts'
+      resources :following, only: :index, controller: 'accounts/following_accounts'
+      resources :lists, only: :index, controller: 'accounts/lists'
+      resources :identity_proofs, only: :index, controller: 'accounts/identity_proofs'
+      resources :featured_tags, only: :index, controller: 'accounts/featured_tags'
+
+      member do
+        post :follow
+        post :unfollow
+        post :remove_from_followers
+        post :block
+        post :unblock
+        post :mute
+        post :unmute
+      end
+
+      resource :pin, only: :create, controller: 'accounts/pins'
+      post :unpin, to: 'accounts/pins#destroy'
+      resource :note, only: :create, controller: 'accounts/notes'
+    end
+
+    resources :tags, only: [:show] do
+      member do
+        post :follow
+        post :unfollow
+      end
+    end
+
+    resources :followed_tags, only: [:index]
+
+    resources :lists, only: [:index, :create, :show, :update, :destroy] do
+      resource :accounts, only: [:show, :create, :destroy], controller: 'lists/accounts'
+    end
+
+    namespace :featured_tags do
+      get :suggestions, to: 'suggestions#index'
+    end
+
+    resources :featured_tags, only: [:index, :create, :destroy]
+
+    resources :polls, only: [:create, :show] do
+      resources :votes, only: :create, controller: 'polls/votes'
+    end
+
+    namespace :push do
+      resource :subscription, only: [:create, :show, :update, :destroy]
+    end
+
+    namespace :admin do
+      resources :accounts, only: [:index, :show, :destroy] do
+        member do
+          post :enable
+          post :unsensitive
+          post :unsilence
+          post :unsuspend
+          post :approve
+          post :reject
+        end
+
+        resource :action, only: [:create], controller: 'account_actions'
+      end
+
+      resources :reports, only: [:index, :update, :show] do
+        member do
+          post :assign_to_self
+          post :unassign
+          post :reopen
+          post :resolve
+        end
+      end
+
+      resources :domain_allows, only: [:index, :show, :create, :destroy]
+      resources :domain_blocks, only: [:index, :show, :update, :create, :destroy]
+      resources :email_domain_blocks, only: [:index, :show, :create, :destroy]
+      resources :ip_blocks, only: [:index, :show, :update, :create, :destroy]
+
+      namespace :trends do
+        resources :tags, only: [:index] do
+          member do
+            post :approve
+            post :reject
+          end
+        end
+        resources :links, only: [:index] do
+          member do
+            post :approve
+            post :reject
+          end
+        end
+        resources :statuses, only: [:index] do
+          member do
+            post :approve
+            post :reject
+          end
+        end
+
+        namespace :links do
+          resources :preview_card_providers, only: [:index], path: :publishers do
+            member do
+              post :approve
+              post :reject
+            end
+          end
+        end
+      end
+
+      post :measures, to: 'measures#create'
+      post :dimensions, to: 'dimensions#create'
+      post :retention, to: 'retention#create'
+
+      resources :canonical_email_blocks, only: [:index, :create, :show, :destroy] do
+        collection do
+          post :test
+        end
+      end
+    end
+  end
+
+  namespace :v2 do
+    get '/search', to: 'search#index', as: :search
+
+    resources :media, only: [:create]
+    resources :suggestions, only: [:index]
+    resource :instance, only: [:show]
+    resources :filters, only: [:index, :create, :show, :update, :destroy] do
+      resources :keywords, only: [:index, :create], controller: 'filters/keywords'
+      resources :statuses, only: [:index, :create], controller: 'filters/statuses'
+    end
+
+    namespace :filters do
+      resources :keywords, only: [:show, :update, :destroy]
+      resources :statuses, only: [:show, :destroy]
+    end
+
+    namespace :admin do
+      resources :accounts, only: [:index]
+    end
+  end
+
+  namespace :web do
+    resource :settings, only: [:update]
+    resource :embed, only: [:create]
+    resources :push_subscriptions, only: [:create] do
+      member do
+        put :update
+      end
+    end
+  end
+end
diff --git a/config/routes/settings.rb b/config/routes/settings.rb
new file mode 100644
index 0000000000..ad1f7fce64
--- /dev/null
+++ b/config/routes/settings.rb
@@ -0,0 +1,74 @@
+# frozen_string_literal: true
+
+namespace :settings do
+  resource :profile, only: [:show, :update] do
+    resources :pictures, only: :destroy
+  end
+
+  get :preferences, to: redirect('/settings/preferences/appearance')
+
+  namespace :preferences do
+    resource :appearance, only: [:show, :update], controller: :appearance
+    resource :notifications, only: [:show, :update]
+    resource :other, only: [:show, :update], controller: :other
+  end
+
+  resources :imports, only: [:index, :show, :destroy, :create] do
+    member do
+      post :confirm
+      get :failures
+    end
+  end
+
+  resource :export, only: [:show, :create]
+
+  namespace :exports, constraints: { format: :csv } do
+    resources :follows, only: :index, controller: :following_accounts
+    resources :blocks, only: :index, controller: :blocked_accounts
+    resources :mutes, only: :index, controller: :muted_accounts
+    resources :lists, only: :index, controller: :lists
+    resources :domain_blocks, only: :index, controller: :blocked_domains
+    resources :bookmarks, only: :index, controller: :bookmarks
+  end
+
+  resources :two_factor_authentication_methods, only: [:index] do
+    collection do
+      post :disable
+    end
+  end
+
+  resource :otp_authentication, only: [:show, :create], controller: 'two_factor_authentication/otp_authentication'
+
+  resources :webauthn_credentials, only: [:index, :new, :create, :destroy],
+                                   path: 'security_keys',
+                                   controller: 'two_factor_authentication/webauthn_credentials' do
+    collection do
+      get :options
+    end
+  end
+
+  namespace :two_factor_authentication do
+    resources :recovery_codes, only: [:create]
+    resource :confirmation, only: [:new, :create]
+  end
+
+  resources :applications, except: [:edit] do
+    member do
+      post :regenerate
+    end
+  end
+
+  resources :flavours, only: [:index, :show, :update], param: :flavour
+
+  resource :delete, only: [:show, :destroy]
+  resource :migration, only: [:show, :create]
+
+  namespace :migration do
+    resource :redirect, only: [:new, :create, :destroy]
+  end
+
+  resources :aliases, only: [:index, :create, :destroy]
+  resources :sessions, only: [:destroy]
+  resources :featured_tags, only: [:index, :create, :destroy]
+  resources :login_activities, only: [:index]
+end
diff --git a/db/migrate/20230330135507_create_bulk_imports.rb b/db/migrate/20230330135507_create_bulk_imports.rb
new file mode 100644
index 0000000000..117a135086
--- /dev/null
+++ b/db/migrate/20230330135507_create_bulk_imports.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+class CreateBulkImports < ActiveRecord::Migration[6.1]
+  def change
+    create_table :bulk_imports do |t|
+      t.integer :type, null: false
+      t.integer :state, null: false
+      t.integer :total_items, null: false, default: 0
+      t.integer :imported_items, null: false, default: 0
+      t.integer :processed_items, null: false, default: 0
+      t.datetime :finished_at
+      t.boolean :overwrite, null: false, default: false
+      t.boolean :likely_mismatched, null: false, default: false
+      t.string :original_filename, null: false, default: ''
+      t.references :account, null: false, foreign_key: { on_delete: :cascade }
+
+      t.timestamps
+    end
+
+    add_index :bulk_imports, [:id], name: :index_bulk_imports_unconfirmed, where: 'state = 0'
+  end
+end
diff --git a/db/migrate/20230330140036_create_bulk_import_rows.rb b/db/migrate/20230330140036_create_bulk_import_rows.rb
new file mode 100644
index 0000000000..ef6ad7069b
--- /dev/null
+++ b/db/migrate/20230330140036_create_bulk_import_rows.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+class CreateBulkImportRows < ActiveRecord::Migration[6.1]
+  def change
+    create_table :bulk_import_rows do |t|
+      t.references :bulk_import, null: false, foreign_key: { on_delete: :cascade }
+      t.jsonb :data
+
+      t.timestamps
+    end
+  end
+end
diff --git a/db/migrate/20230330155710_add_follow_request_id_to_list_accounts.rb b/db/migrate/20230330155710_add_follow_request_id_to_list_accounts.rb
new file mode 100644
index 0000000000..5f4ac374ca
--- /dev/null
+++ b/db/migrate/20230330155710_add_follow_request_id_to_list_accounts.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+class AddFollowRequestIdToListAccounts < ActiveRecord::Migration[6.1]
+  disable_ddl_transaction!
+
+  def change
+    safety_assured { add_reference :list_accounts, :follow_request, foreign_key: { on_delete: :cascade }, index: false }
+    add_index :list_accounts, :follow_request_id, algorithm: :concurrently, where: 'follow_request_id IS NOT NULL'
+  end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 7d894b1aa1..e834d7e09b 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: 2023_02_15_074424) do
+ActiveRecord::Schema.define(version: 2023_03_30_155710) do
 
   # These are extensions that must be enabled in order to support this database
   enable_extension "plpgsql"
@@ -294,6 +294,31 @@ ActiveRecord::Schema.define(version: 2023_02_15_074424) do
     t.index ["status_id"], name: "index_bookmarks_on_status_id"
   end
 
+  create_table "bulk_import_rows", force: :cascade do |t|
+    t.bigint "bulk_import_id", null: false
+    t.jsonb "data"
+    t.datetime "created_at", precision: 6, null: false
+    t.datetime "updated_at", precision: 6, null: false
+    t.index ["bulk_import_id"], name: "index_bulk_import_rows_on_bulk_import_id"
+  end
+
+  create_table "bulk_imports", force: :cascade do |t|
+    t.integer "type", null: false
+    t.integer "state", null: false
+    t.integer "total_items", default: 0, null: false
+    t.integer "imported_items", default: 0, null: false
+    t.integer "processed_items", default: 0, null: false
+    t.datetime "finished_at"
+    t.boolean "overwrite", default: false, null: false
+    t.boolean "likely_mismatched", default: false, null: false
+    t.string "original_filename", default: "", null: false
+    t.bigint "account_id", null: false
+    t.datetime "created_at", precision: 6, null: false
+    t.datetime "updated_at", precision: 6, null: false
+    t.index ["account_id"], name: "index_bulk_imports_on_account_id"
+    t.index ["id"], name: "index_bulk_imports_unconfirmed", where: "(state = 0)"
+  end
+
   create_table "canonical_email_blocks", force: :cascade do |t|
     t.string "canonical_email_hash", default: "", null: false
     t.bigint "reference_account_id"
@@ -529,8 +554,10 @@ ActiveRecord::Schema.define(version: 2023_02_15_074424) do
     t.bigint "list_id", null: false
     t.bigint "account_id", null: false
     t.bigint "follow_id"
+    t.bigint "follow_request_id"
     t.index ["account_id", "list_id"], name: "index_list_accounts_on_account_id_and_list_id", unique: true
     t.index ["follow_id"], name: "index_list_accounts_on_follow_id", where: "(follow_id IS NOT NULL)"
+    t.index ["follow_request_id"], name: "index_list_accounts_on_follow_request_id", where: "(follow_request_id IS NOT NULL)"
     t.index ["list_id", "account_id"], name: "index_list_accounts_on_list_id_and_account_id"
   end
 
@@ -1149,6 +1176,8 @@ ActiveRecord::Schema.define(version: 2023_02_15_074424) do
   add_foreign_key "blocks", "accounts", name: "fk_4269e03e65", on_delete: :cascade
   add_foreign_key "bookmarks", "accounts", on_delete: :cascade
   add_foreign_key "bookmarks", "statuses", on_delete: :cascade
+  add_foreign_key "bulk_import_rows", "bulk_imports", on_delete: :cascade
+  add_foreign_key "bulk_imports", "accounts", on_delete: :cascade
   add_foreign_key "canonical_email_blocks", "accounts", column: "reference_account_id", on_delete: :cascade
   add_foreign_key "conversation_mutes", "accounts", name: "fk_225b4212bb", on_delete: :cascade
   add_foreign_key "conversation_mutes", "conversations", on_delete: :cascade
@@ -1174,6 +1203,7 @@ ActiveRecord::Schema.define(version: 2023_02_15_074424) do
   add_foreign_key "imports", "accounts", name: "fk_6db1b6e408", on_delete: :cascade
   add_foreign_key "invites", "users", on_delete: :cascade
   add_foreign_key "list_accounts", "accounts", on_delete: :cascade
+  add_foreign_key "list_accounts", "follow_requests", on_delete: :cascade
   add_foreign_key "list_accounts", "follows", on_delete: :cascade
   add_foreign_key "list_accounts", "lists", on_delete: :cascade
   add_foreign_key "lists", "accounts", on_delete: :cascade
diff --git a/lib/mastodon/accounts_cli.rb b/lib/mastodon/accounts_cli.rb
index 43269334a5..c09577c9ef 100644
--- a/lib/mastodon/accounts_cli.rb
+++ b/lib/mastodon/accounts_cli.rb
@@ -121,10 +121,10 @@ module Mastodon
         say('OK', :green)
         say("New password: #{password}")
       else
-        user.errors.to_h.each do |key, error|
+        user.errors.each do |error|
           say('Failure/Error: ', :red)
-          say(key)
-          say("    #{error}", :red)
+          say(error.attribute)
+          say("    #{error.type}", :red)
         end
 
         exit(1)
@@ -197,10 +197,10 @@ module Mastodon
         say('OK', :green)
         say("New password: #{password}") if options[:reset_password]
       else
-        user.errors.to_h.each do |key, error|
+        user.errors.each do |error|
           say('Failure/Error: ', :red)
-          say(key)
-          say("    #{error}", :red)
+          say(error.attribute)
+          say("    #{error.type}", :red)
         end
 
         exit(1)
@@ -353,7 +353,7 @@ module Mastodon
 
         begin
           code = Request.new(:head, account.uri).perform(&:code)
-        rescue HTTP::TimeoutError, HTTP::ConnectionError, OpenSSL::SSL::SSLError
+        rescue HTTP::TimeoutError, HTTP::ConnectionError, OpenSSL::SSL::SSLError, Mastodon::PrivateNetworkAddressError
           skip_domains << account.domain
         end
 
diff --git a/lib/mastodon/emoji_cli.rb b/lib/mastodon/emoji_cli.rb
index 88065c2a39..8f2432a3e2 100644
--- a/lib/mastodon/emoji_cli.rb
+++ b/lib/mastodon/emoji_cli.rb
@@ -73,7 +73,6 @@ module Mastodon
         end
       end
 
-      puts
       say("Imported #{imported}, skipped #{skipped}, failed to import #{failed}", color(imported, skipped, failed))
     end
 
diff --git a/lib/mastodon/maintenance_cli.rb b/lib/mastodon/maintenance_cli.rb
index ff8f6ddda9..88bd191ea9 100644
--- a/lib/mastodon/maintenance_cli.rb
+++ b/lib/mastodon/maintenance_cli.rb
@@ -664,9 +664,7 @@ module Mastodon
 
     def remove_index_if_exists!(table, name)
       ActiveRecord::Base.connection.remove_index(table, name: name)
-    rescue ArgumentError
-      nil
-    rescue ActiveRecord::StatementInvalid
+    rescue ArgumentError, ActiveRecord::StatementInvalid
       nil
     end
   end
diff --git a/lib/mastodon/media_cli.rb b/lib/mastodon/media_cli.rb
index b2dfe58d53..7dacd8d3d4 100644
--- a/lib/mastodon/media_cli.rb
+++ b/lib/mastodon/media_cli.rb
@@ -9,6 +9,8 @@ module Mastodon
     include ActionView::Helpers::NumberHelper
     include CLIHelper
 
+    VALID_PATH_SEGMENTS_SIZE = [7, 10].freeze
+
     def self.exit_on_failure?
       true
     end
@@ -133,7 +135,7 @@ module Mastodon
             path_segments = object.key.split('/')
             path_segments.delete('cache')
 
-            unless [7, 10].include?(path_segments.size)
+            unless VALID_PATH_SEGMENTS_SIZE.include?(path_segments.size)
               progress.log(pastel.yellow("Unrecognized file found: #{object.key}"))
               next
             end
@@ -177,7 +179,7 @@ module Mastodon
           path_segments = key.split(File::SEPARATOR)
           path_segments.delete('cache')
 
-          unless [7, 10].include?(path_segments.size)
+          unless VALID_PATH_SEGMENTS_SIZE.include?(path_segments.size)
             progress.log(pastel.yellow("Unrecognized file found: #{key}"))
             next
           end
@@ -310,7 +312,7 @@ module Mastodon
       path_segments = path.split('/')[2..]
       path_segments.delete('cache')
 
-      unless [7, 10].include?(path_segments.size)
+      unless VALID_PATH_SEGMENTS_SIZE.include?(path_segments.size)
         say('Not a media URL', :red)
         exit(1)
       end
@@ -363,7 +365,7 @@ module Mastodon
         segments = object.key.split('/')
         segments.delete('cache')
 
-        next unless [7, 10].include?(segments.size)
+        next unless VALID_PATH_SEGMENTS_SIZE.include?(segments.size)
 
         model_name = segments.first.classify
         record_id  = segments[2..-2].join.to_i
diff --git a/lib/mastodon/version.rb b/lib/mastodon/version.rb
index 408f60185b..f71762d993 100644
--- a/lib/mastodon/version.rb
+++ b/lib/mastodon/version.rb
@@ -17,11 +17,11 @@ module Mastodon
     end
 
     def flags
-      ''
+      ENV.fetch('MASTODON_VERSION_FLAGS', '')
     end
 
     def suffix
-      '+glitch'
+      "+glitch#{ENV.fetch('MASTODON_VERSION_SUFFIX', '')}"
     end
 
     def to_a
diff --git a/lib/paperclip/color_extractor.rb b/lib/paperclip/color_extractor.rb
index 2e8dc04fd8..19529fb8a1 100644
--- a/lib/paperclip/color_extractor.rb
+++ b/lib/paperclip/color_extractor.rb
@@ -173,7 +173,7 @@ module Paperclip
     def palette_from_histogram(result, quantity)
       frequencies       = result.scan(/([0-9]+)\:/).flatten.map(&:to_f)
       hex_values        = result.scan(/\#([0-9A-Fa-f]{6,8})/).flatten
-      total_frequencies = frequencies.reduce(&:+).to_f
+      total_frequencies = frequencies.sum.to_f
 
       frequencies.map.with_index { |f, i| [f / total_frequencies, hex_values[i]] }
                  .sort_by { |r| -r[0] }
diff --git a/lib/sanitize_ext/sanitize_config.rb b/lib/sanitize_ext/sanitize_config.rb
index 4c0e9b8580..0ee3a519b6 100644
--- a/lib/sanitize_ext/sanitize_config.rb
+++ b/lib/sanitize_ext/sanitize_config.rb
@@ -127,7 +127,7 @@ class Sanitize
       node = env[:node]
 
       rel = (node['rel'] || '').split & ['tag']
-      rel += ['nofollow', 'noopener', 'noreferrer'] unless TagManager.instance.local_url?(node['href'])
+      rel += %w(nofollow noopener noreferrer) unless TagManager.instance.local_url?(node['href'])
 
       if rel.empty?
         node.remove_attribute('rel')
diff --git a/lib/terrapin/multi_pipe_extensions.rb b/lib/terrapin/multi_pipe_extensions.rb
index 209f4ad6ce..bddba4a2b1 100644
--- a/lib/terrapin/multi_pipe_extensions.rb
+++ b/lib/terrapin/multi_pipe_extensions.rb
@@ -13,7 +13,7 @@ module Terrapin
 
     def pipe_options
       # Add some flags to explicitly close the other end of the pipes
-      { out: @stdout_out, err: @stderr_out, @stdout_in => :close, @stderr_in => :close }
+      { :out => @stdout_out, :err => @stderr_out, @stdout_in => :close, @stderr_in => :close }
     end
 
     def read
diff --git a/package.json b/package.json
index 32c8a3f8d6..f5db9e94f2 100644
--- a/package.json
+++ b/package.json
@@ -26,22 +26,23 @@
   },
   "private": true,
   "dependencies": {
-    "@babel/core": "^7.21.4",
+    "@babel/core": "^7.21.8",
     "@babel/plugin-proposal-nullish-coalescing-operator": "^7.18.6",
     "@babel/plugin-transform-react-inline-elements": "^7.21.0",
     "@babel/plugin-transform-runtime": "^7.21.4",
-    "@babel/preset-env": "^7.21.4",
+    "@babel/preset-env": "^7.21.5",
     "@babel/preset-react": "^7.18.6",
-    "@babel/preset-typescript": "^7.21.4",
-    "@babel/runtime": "^7.21.0",
+    "@babel/preset-typescript": "^7.21.5",
+    "@babel/runtime": "^7.21.5",
     "@gamestdio/websocket": "^0.3.2",
     "@github/webauthn-json": "^2.1.1",
     "@rails/ujs": "^6.1.7",
+    "@reduxjs/toolkit": "^1.9.5",
     "abortcontroller-polyfill": "^1.7.5",
     "atrament": "0.2.4",
     "arrow-key-navigation": "^1.2.0",
     "autoprefixer": "^10.4.14",
-    "axios": "^1.3.6",
+    "axios": "^1.4.0",
     "babel-loader": "^8.3.0",
     "babel-plugin-lodash": "^3.3.4",
     "babel-plugin-preval": "^5.1.0",
@@ -54,7 +55,7 @@
     "compression-webpack-plugin": "^6.1.1",
     "cross-env": "^7.0.3",
     "css-loader": "^5.2.7",
-    "cssnano": "^6.0.0",
+    "cssnano": "^6.0.1",
     "detect-passive-events": "^2.0.3",
     "dotenv": "^16.0.3",
     "emoji-mart": "npm:emoji-mart-lazyload@latest",
@@ -75,7 +76,7 @@
     "intl-messageformat": "^2.2.0",
     "intl-relativeformat": "^6.4.3",
     "js-yaml": "^4.1.0",
-    "jsdom": "^21.1.1",
+    "jsdom": "^21.1.2",
     "lodash": "^4.17.21",
     "mark-loader": "^0.1.6",
     "mini-css-extract-plugin": "^1.6.2",
@@ -105,7 +106,7 @@
     "react-redux-loading-bar": "^5.0.4",
     "react-router-dom": "^4.1.1",
     "react-router-scroll-4": "^1.0.0-beta.1",
-    "react-select": "^5.7.2",
+    "react-select": "^5.7.3",
     "react-sparklines": "^1.7.0",
     "react-swipeable-views": "^0.14.0",
     "react-textarea-autosize": "^8.4.1",
@@ -180,11 +181,11 @@
     "@types/uuid": "^8.3.4",
     "@types/webpack": "^4.41.33",
     "@types/yargs": "^17.0.24",
-    "@typescript-eslint/eslint-plugin": "^5.59.1",
-    "@typescript-eslint/parser": "^5.59.1",
+    "@typescript-eslint/eslint-plugin": "^5.59.2",
+    "@typescript-eslint/parser": "^5.59.2",
     "babel-jest": "^29.5.0",
     "eslint": "^8.39.0",
-    "eslint-plugin-formatjs": "^4.9.0",
+    "eslint-plugin-formatjs": "^4.10.1",
     "eslint-plugin-import": "~2.27.5",
     "eslint-plugin-jsdoc": "^43.1.1",
     "eslint-plugin-jsx-a11y": "~6.7.1",
@@ -199,11 +200,11 @@
     "raf": "^3.4.1",
     "react-intl-translations-manager": "^5.0.3",
     "react-test-renderer": "^16.14.0",
-    "stylelint": "^15.6.0",
+    "stylelint": "^15.6.1",
     "stylelint-config-standard-scss": "^9.0.0",
     "typescript": "^5.0.4",
     "webpack-dev-server": "^3.11.3",
-    "yargs": "^17.7.1"
+    "yargs": "^17.7.2"
   },
   "resolutions": {
     "kind-of": "^6.0.3",
diff --git a/spec/config/initializers/rack_attack_spec.rb b/spec/config/initializers/rack_attack_spec.rb
index 0411a48d2a..7cd4ac76bb 100644
--- a/spec/config/initializers/rack_attack_spec.rb
+++ b/spec/config/initializers/rack_attack_spec.rb
@@ -46,36 +46,36 @@ describe Rack::Attack, type: :request do
   let(:remote_ip) { '1.2.3.5' }
 
   describe 'throttle excessive sign-up requests by IP address' do
-    context 'through the website' do
+    context 'when accessed through the website' do
       let(:limit)  { 25 }
       let(:period) { 5.minutes }
       let(:request) { -> { post path, headers: { 'REMOTE_ADDR' => remote_ip } } }
 
-      context 'for exact path' do
+      context 'with exact path' do
         let(:path) { '/auth' }
 
         it_behaves_like 'throttled endpoint'
       end
 
-      context 'for path with format' do
+      context 'with path with format' do
         let(:path) { '/auth.html' }
 
         it_behaves_like 'throttled endpoint'
       end
     end
 
-    context 'through the API' do
+    context 'when accessed through the API' do
       let(:limit)  { 5 }
       let(:period) { 30.minutes }
       let(:request) { -> { post path, headers: { 'REMOTE_ADDR' => remote_ip } } }
 
-      context 'for exact path' do
+      context 'with exact path' do
         let(:path) { '/api/v1/accounts' }
 
         it_behaves_like 'throttled endpoint'
       end
 
-      context 'for path with format' do
+      context 'with path with format' do
         let(:path)  { '/api/v1/accounts.json' }
 
         it 'returns http not found' do
@@ -91,13 +91,13 @@ describe Rack::Attack, type: :request do
     let(:period) { 5.minutes }
     let(:request) { -> { post path, headers: { 'REMOTE_ADDR' => remote_ip } } }
 
-    context 'for exact path' do
+    context 'with exact path' do
       let(:path) { '/auth/sign_in' }
 
       it_behaves_like 'throttled endpoint'
     end
 
-    context 'for path with format' do
+    context 'with path with format' do
       let(:path) { '/auth/sign_in.html' }
 
       it_behaves_like 'throttled endpoint'
diff --git a/spec/controllers/about_controller_spec.rb b/spec/controllers/about_controller_spec.rb
index ccd28a96ce..8db6d80b0b 100644
--- a/spec/controllers/about_controller_spec.rb
+++ b/spec/controllers/about_controller_spec.rb
@@ -2,7 +2,7 @@
 
 require 'rails_helper'
 
-RSpec.describe AboutController, type: :controller do
+RSpec.describe AboutController do
   render_views
 
   describe 'GET #show' do
diff --git a/spec/controllers/accounts_controller_spec.rb b/spec/controllers/accounts_controller_spec.rb
index 53fc75659a..3667564802 100644
--- a/spec/controllers/accounts_controller_spec.rb
+++ b/spec/controllers/accounts_controller_spec.rb
@@ -2,7 +2,7 @@
 
 require 'rails_helper'
 
-RSpec.describe AccountsController, type: :controller do
+RSpec.describe AccountsController do
   render_views
 
   let(:account) { Fabricate(:account) }
@@ -57,7 +57,7 @@ RSpec.describe AccountsController, type: :controller do
       end
     end
 
-    context 'as HTML' do
+    context 'with HTML' do
       let(:format) { 'html' }
 
       it_behaves_like 'preliminary checks'
@@ -140,7 +140,7 @@ RSpec.describe AccountsController, type: :controller do
       end
     end
 
-    context 'as JSON' do
+    context 'with JSON' do
       let(:authorized_fetch_mode) { false }
       let(:format) { 'json' }
 
@@ -193,7 +193,7 @@ RSpec.describe AccountsController, type: :controller do
           expect(json).to include(:id, :type, :preferredUsername, :inbox, :publicKey, :name, :summary)
         end
 
-        context 'in authorized fetch mode' do
+        context 'with authorized fetch mode' do
           let(:authorized_fetch_mode) { true }
 
           it 'returns http unauthorized' do
@@ -251,7 +251,7 @@ RSpec.describe AccountsController, type: :controller do
           expect(json).to include(:id, :type, :preferredUsername, :inbox, :publicKey, :name, :summary)
         end
 
-        context 'in authorized fetch mode' do
+        context 'with authorized fetch mode' do
           let(:authorized_fetch_mode) { true }
 
           it 'returns http success' do
@@ -278,7 +278,7 @@ RSpec.describe AccountsController, type: :controller do
       end
     end
 
-    context 'as RSS' do
+    context 'with RSS' do
       let(:format) { 'rss' }
 
       it_behaves_like 'preliminary checks'
diff --git a/spec/controllers/activitypub/collections_controller_spec.rb b/spec/controllers/activitypub/collections_controller_spec.rb
index 77901131e7..8878474885 100644
--- a/spec/controllers/activitypub/collections_controller_spec.rb
+++ b/spec/controllers/activitypub/collections_controller_spec.rb
@@ -2,7 +2,7 @@
 
 require 'rails_helper'
 
-RSpec.describe ActivityPub::CollectionsController, type: :controller do
+RSpec.describe ActivityPub::CollectionsController do
   let!(:account) { Fabricate(:account) }
   let!(:private_pinned) { Fabricate(:status, account: account, text: 'secret private stuff', visibility: :private) }
   let(:remote_account) { nil }
@@ -35,10 +35,9 @@ RSpec.describe ActivityPub::CollectionsController, type: :controller do
   describe 'GET #show' do
     context 'when id is "featured"' do
       context 'without signature' do
-        subject(:body) { body_as_json }
-
         subject(:response) { get :show, params: { id: 'featured', account_username: account.username } }
 
+        let(:body) { body_as_json }
         let(:remote_account) { nil }
 
         it 'returns http success' do
@@ -120,7 +119,7 @@ RSpec.describe ActivityPub::CollectionsController, type: :controller do
           end
         end
 
-        context 'in authorized fetch mode' do
+        context 'with authorized fetch mode' do
           before do
             allow(controller).to receive(:authorized_fetch_mode?).and_return(true)
           end
diff --git a/spec/controllers/activitypub/followers_synchronizations_controller_spec.rb b/spec/controllers/activitypub/followers_synchronizations_controller_spec.rb
index c99d59edaa..8fcce165b3 100644
--- a/spec/controllers/activitypub/followers_synchronizations_controller_spec.rb
+++ b/spec/controllers/activitypub/followers_synchronizations_controller_spec.rb
@@ -2,7 +2,7 @@
 
 require 'rails_helper'
 
-RSpec.describe ActivityPub::FollowersSynchronizationsController, type: :controller do
+RSpec.describe ActivityPub::FollowersSynchronizationsController 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') }
@@ -34,10 +34,9 @@ RSpec.describe ActivityPub::FollowersSynchronizationsController, type: :controll
     end
 
     context 'with signature from example.com' do
-      subject(:body) { body_as_json }
-
       subject(:response) { get :show, params: { account_username: account.username } }
 
+      let(:body) { body_as_json }
       let(:remote_account) { Fabricate(:account, domain: 'example.com', uri: 'https://example.com/instance') }
 
       it 'returns http success' do
diff --git a/spec/controllers/activitypub/inboxes_controller_spec.rb b/spec/controllers/activitypub/inboxes_controller_spec.rb
index 8d4084648d..030a303266 100644
--- a/spec/controllers/activitypub/inboxes_controller_spec.rb
+++ b/spec/controllers/activitypub/inboxes_controller_spec.rb
@@ -2,7 +2,7 @@
 
 require 'rails_helper'
 
-RSpec.describe ActivityPub::InboxesController, type: :controller do
+RSpec.describe ActivityPub::InboxesController do
   let(:remote_account) { nil }
 
   before do
@@ -21,7 +21,7 @@ RSpec.describe ActivityPub::InboxesController, type: :controller do
         expect(response).to have_http_status(202)
       end
 
-      context 'for a specific account' do
+      context 'with a specific account' do
         subject(:response) { post :create, params: { account_username: account.username }, body: '{}' }
 
         let(:account) { Fabricate(:account) }
diff --git a/spec/controllers/activitypub/outboxes_controller_spec.rb b/spec/controllers/activitypub/outboxes_controller_spec.rb
index 167bbcc21d..8823d9fe7e 100644
--- a/spec/controllers/activitypub/outboxes_controller_spec.rb
+++ b/spec/controllers/activitypub/outboxes_controller_spec.rb
@@ -2,7 +2,7 @@
 
 require 'rails_helper'
 
-RSpec.describe ActivityPub::OutboxesController, type: :controller do
+RSpec.describe ActivityPub::OutboxesController do
   let!(:account) { Fabricate(:account) }
 
   shared_examples 'cacheable response' do
@@ -35,10 +35,9 @@ RSpec.describe ActivityPub::OutboxesController, type: :controller do
 
   describe 'GET #show' do
     context 'without signature' do
-      subject(:body) { body_as_json }
-
       subject(:response) { get :show, params: { account_username: account.username, page: page } }
 
+      let(:body) { body_as_json }
       let(:remote_account) { nil }
 
       context 'with page not requested' do
diff --git a/spec/controllers/activitypub/replies_controller_spec.rb b/spec/controllers/activitypub/replies_controller_spec.rb
index 582ef863f2..c7b65f004d 100644
--- a/spec/controllers/activitypub/replies_controller_spec.rb
+++ b/spec/controllers/activitypub/replies_controller_spec.rb
@@ -2,7 +2,7 @@
 
 require 'rails_helper'
 
-RSpec.describe ActivityPub::RepliesController, type: :controller do
+RSpec.describe ActivityPub::RepliesController do
   let(:status) { Fabricate(:status, visibility: parent_visibility) }
   let(:remote_account)  { Fabricate(:account, domain: 'foobar.com') }
   let(:remote_reply_id) { 'https://foobar.com/statuses/1234' }
diff --git a/spec/controllers/admin/account_moderation_notes_controller_spec.rb b/spec/controllers/admin/account_moderation_notes_controller_spec.rb
index d2c52f5940..848281c290 100644
--- a/spec/controllers/admin/account_moderation_notes_controller_spec.rb
+++ b/spec/controllers/admin/account_moderation_notes_controller_spec.rb
@@ -2,7 +2,7 @@
 
 require 'rails_helper'
 
-RSpec.describe Admin::AccountModerationNotesController, type: :controller do
+RSpec.describe Admin::AccountModerationNotesController do
   render_views
 
   let(:user) { Fabricate(:user, role: UserRole.find_by(name: 'Admin')) }
diff --git a/spec/controllers/admin/accounts_controller_spec.rb b/spec/controllers/admin/accounts_controller_spec.rb
index b182715b0b..7d001c4cbc 100644
--- a/spec/controllers/admin/accounts_controller_spec.rb
+++ b/spec/controllers/admin/accounts_controller_spec.rb
@@ -2,7 +2,7 @@
 
 require 'rails_helper'
 
-RSpec.describe Admin::AccountsController, type: :controller do
+RSpec.describe Admin::AccountsController do
   render_views
 
   before { sign_in current_user, scope: :user }
diff --git a/spec/controllers/admin/action_logs_controller_spec.rb b/spec/controllers/admin/action_logs_controller_spec.rb
index 7cd8cdf462..044ddf2c42 100644
--- a/spec/controllers/admin/action_logs_controller_spec.rb
+++ b/spec/controllers/admin/action_logs_controller_spec.rb
@@ -2,7 +2,7 @@
 
 require 'rails_helper'
 
-describe Admin::ActionLogsController, type: :controller do
+describe Admin::ActionLogsController do
   render_views
 
   # Action logs typically cause issues when their targets are not in the database
diff --git a/spec/controllers/admin/base_controller_spec.rb b/spec/controllers/admin/base_controller_spec.rb
index bfb9d2c7d4..1f1fa8441a 100644
--- a/spec/controllers/admin/base_controller_spec.rb
+++ b/spec/controllers/admin/base_controller_spec.rb
@@ -2,7 +2,7 @@
 
 require 'rails_helper'
 
-describe Admin::BaseController, type: :controller do
+describe Admin::BaseController do
   controller do
     def success
       authorize :dashboard, :index?
diff --git a/spec/controllers/admin/change_emails_controller_spec.rb b/spec/controllers/admin/change_emails_controller_spec.rb
index 8329984715..503862a7b9 100644
--- a/spec/controllers/admin/change_emails_controller_spec.rb
+++ b/spec/controllers/admin/change_emails_controller_spec.rb
@@ -2,7 +2,7 @@
 
 require 'rails_helper'
 
-RSpec.describe Admin::ChangeEmailsController, type: :controller do
+RSpec.describe Admin::ChangeEmailsController do
   render_views
 
   let(:admin) { Fabricate(:user, role: UserRole.find_by(name: 'Admin')) }
diff --git a/spec/controllers/admin/confirmations_controller_spec.rb b/spec/controllers/admin/confirmations_controller_spec.rb
index d05711e272..ffab56d9aa 100644
--- a/spec/controllers/admin/confirmations_controller_spec.rb
+++ b/spec/controllers/admin/confirmations_controller_spec.rb
@@ -2,7 +2,7 @@
 
 require 'rails_helper'
 
-RSpec.describe Admin::ConfirmationsController, type: :controller do
+RSpec.describe Admin::ConfirmationsController do
   render_views
 
   before do
diff --git a/spec/controllers/admin/dashboard_controller_spec.rb b/spec/controllers/admin/dashboard_controller_spec.rb
index ab3738fcd7..25300fdd90 100644
--- a/spec/controllers/admin/dashboard_controller_spec.rb
+++ b/spec/controllers/admin/dashboard_controller_spec.rb
@@ -2,7 +2,7 @@
 
 require 'rails_helper'
 
-describe Admin::DashboardController, type: :controller do
+describe Admin::DashboardController do
   render_views
 
   describe 'GET #index' do
diff --git a/spec/controllers/admin/disputes/appeals_controller_spec.rb b/spec/controllers/admin/disputes/appeals_controller_spec.rb
index 576a0c12b9..371c4f483d 100644
--- a/spec/controllers/admin/disputes/appeals_controller_spec.rb
+++ b/spec/controllers/admin/disputes/appeals_controller_spec.rb
@@ -2,7 +2,7 @@
 
 require 'rails_helper'
 
-RSpec.describe Admin::Disputes::AppealsController, type: :controller do
+RSpec.describe Admin::Disputes::AppealsController do
   render_views
 
   before { sign_in current_user, scope: :user }
diff --git a/spec/controllers/admin/domain_allows_controller_spec.rb b/spec/controllers/admin/domain_allows_controller_spec.rb
index 2a0f47145a..6b0453476a 100644
--- a/spec/controllers/admin/domain_allows_controller_spec.rb
+++ b/spec/controllers/admin/domain_allows_controller_spec.rb
@@ -2,7 +2,7 @@
 
 require 'rails_helper'
 
-RSpec.describe Admin::DomainAllowsController, type: :controller do
+RSpec.describe Admin::DomainAllowsController do
   render_views
 
   before do
diff --git a/spec/controllers/admin/domain_blocks_controller_spec.rb b/spec/controllers/admin/domain_blocks_controller_spec.rb
index ef13f76762..6aed172ac5 100644
--- a/spec/controllers/admin/domain_blocks_controller_spec.rb
+++ b/spec/controllers/admin/domain_blocks_controller_spec.rb
@@ -2,7 +2,7 @@
 
 require 'rails_helper'
 
-RSpec.describe Admin::DomainBlocksController, type: :controller do
+RSpec.describe Admin::DomainBlocksController do
   render_views
 
   before do
@@ -83,7 +83,7 @@ RSpec.describe Admin::DomainBlocksController, type: :controller do
       BlockDomainService.new.call(domain_block)
     end
 
-    context 'downgrading a domain suspension to silence' do
+    context 'when downgrading a domain suspension to silence' do
       let(:original_severity) { 'suspend' }
       let(:new_severity)      { 'silence' }
 
@@ -100,7 +100,7 @@ RSpec.describe Admin::DomainBlocksController, type: :controller do
       end
     end
 
-    context 'upgrading a domain silence to suspend' do
+    context 'when upgrading a domain silence to suspend' do
       let(:original_severity) { 'silence' }
       let(:new_severity)      { 'suspend' }
 
diff --git a/spec/controllers/admin/email_domain_blocks_controller_spec.rb b/spec/controllers/admin/email_domain_blocks_controller_spec.rb
index e9cef4a94c..4286600144 100644
--- a/spec/controllers/admin/email_domain_blocks_controller_spec.rb
+++ b/spec/controllers/admin/email_domain_blocks_controller_spec.rb
@@ -2,7 +2,7 @@
 
 require 'rails_helper'
 
-RSpec.describe Admin::EmailDomainBlocksController, type: :controller do
+RSpec.describe Admin::EmailDomainBlocksController do
   render_views
 
   before do
diff --git a/spec/controllers/admin/export_domain_allows_controller_spec.rb b/spec/controllers/admin/export_domain_allows_controller_spec.rb
index f12bd1344f..9d50c04aad 100644
--- a/spec/controllers/admin/export_domain_allows_controller_spec.rb
+++ b/spec/controllers/admin/export_domain_allows_controller_spec.rb
@@ -2,7 +2,7 @@
 
 require 'rails_helper'
 
-RSpec.describe Admin::ExportDomainAllowsController, type: :controller do
+RSpec.describe Admin::ExportDomainAllowsController do
   render_views
 
   before do
diff --git a/spec/controllers/admin/export_domain_blocks_controller_spec.rb b/spec/controllers/admin/export_domain_blocks_controller_spec.rb
index 6e7475ed12..1a63077736 100644
--- a/spec/controllers/admin/export_domain_blocks_controller_spec.rb
+++ b/spec/controllers/admin/export_domain_blocks_controller_spec.rb
@@ -2,7 +2,7 @@
 
 require 'rails_helper'
 
-RSpec.describe Admin::ExportDomainBlocksController, type: :controller do
+RSpec.describe Admin::ExportDomainBlocksController do
   render_views
 
   before do
diff --git a/spec/controllers/admin/instances_controller_spec.rb b/spec/controllers/admin/instances_controller_spec.rb
index 33174b9921..ce062085f4 100644
--- a/spec/controllers/admin/instances_controller_spec.rb
+++ b/spec/controllers/admin/instances_controller_spec.rb
@@ -2,7 +2,7 @@
 
 require 'rails_helper'
 
-RSpec.describe Admin::InstancesController, type: :controller do
+RSpec.describe Admin::InstancesController do
   render_views
 
   let(:current_user) { Fabricate(:user, role: UserRole.find_by(name: 'Admin')) }
diff --git a/spec/controllers/admin/reports/actions_controller_spec.rb b/spec/controllers/admin/reports/actions_controller_spec.rb
index 4c2624a408..a84f2324e1 100644
--- a/spec/controllers/admin/reports/actions_controller_spec.rb
+++ b/spec/controllers/admin/reports/actions_controller_spec.rb
@@ -15,7 +15,7 @@ describe Admin::Reports::ActionsController do
     let(:report) { Fabricate(:report) }
 
     before do
-      post :preview, params: { report_id: report.id, action => '' }
+      post :preview, params: { :report_id => report.id, action => '' }
     end
 
     context 'when the action is "suspend"' do
@@ -146,13 +146,13 @@ describe Admin::Reports::ActionsController do
       end
     end
 
-    context 'action as submit button' do
+    context 'with Action as submit button' do
       subject { post :create, params: common_params.merge({ action => '' }) }
 
       it_behaves_like 'all action types'
     end
 
-    context 'action as submit button' do
+    context 'with Action as submit button' do
       subject { post :create, params: common_params.merge({ moderation_action: action }) }
 
       it_behaves_like 'all action types'
diff --git a/spec/controllers/admin/settings/branding_controller_spec.rb b/spec/controllers/admin/settings/branding_controller_spec.rb
index ee1c441bc5..4b0f1e21e0 100644
--- a/spec/controllers/admin/settings/branding_controller_spec.rb
+++ b/spec/controllers/admin/settings/branding_controller_spec.rb
@@ -2,7 +2,7 @@
 
 require 'rails_helper'
 
-RSpec.describe Admin::Settings::BrandingController, type: :controller do
+RSpec.describe Admin::Settings::BrandingController do
   render_views
 
   describe 'When signed in as an admin' do
diff --git a/spec/controllers/admin/statuses_controller_spec.rb b/spec/controllers/admin/statuses_controller_spec.rb
index 79d83db97d..872aed9998 100644
--- a/spec/controllers/admin/statuses_controller_spec.rb
+++ b/spec/controllers/admin/statuses_controller_spec.rb
@@ -30,7 +30,7 @@ describe Admin::StatusesController do
       end
     end
 
-    context 'filtering by media' do
+    context 'when filtering by media' do
       before do
         get :index, params: { account_id: account.id, media: '1' }
       end
@@ -43,7 +43,7 @@ describe Admin::StatusesController do
 
   describe 'POST #batch' do
     before do
-      post :batch, params: { account_id: account.id, action => '', admin_status_batch_action: { status_ids: status_ids } }
+      post :batch, params: { :account_id => account.id, action => '', :admin_status_batch_action => { status_ids: status_ids } }
     end
 
     let(:status_ids) { [media_attached_status.id] }
diff --git a/spec/controllers/admin/tags_controller_spec.rb b/spec/controllers/admin/tags_controller_spec.rb
index 52fd09eb10..313298f14a 100644
--- a/spec/controllers/admin/tags_controller_spec.rb
+++ b/spec/controllers/admin/tags_controller_spec.rb
@@ -2,7 +2,7 @@
 
 require 'rails_helper'
 
-RSpec.describe Admin::TagsController, type: :controller do
+RSpec.describe Admin::TagsController do
   render_views
 
   before do
diff --git a/spec/controllers/api/base_controller_spec.rb b/spec/controllers/api/base_controller_spec.rb
index 080eab3c05..9f3ba81474 100644
--- a/spec/controllers/api/base_controller_spec.rb
+++ b/spec/controllers/api/base_controller_spec.rb
@@ -2,9 +2,11 @@
 
 require 'rails_helper'
 
-class FakeService; end
-
 describe Api::BaseController do
+  before do
+    stub_const('FakeService', Class.new)
+  end
+
   controller do
     def success
       head 200
@@ -72,7 +74,11 @@ describe Api::BaseController do
   end
 
   describe 'error handling' do
-    ERRORS_WITH_CODES = {
+    before do
+      routes.draw { get 'error' => 'api/base#error' }
+    end
+
+    {
       ActiveRecord::RecordInvalid => 422,
       Mastodon::ValidationError => 422,
       ActiveRecord::RecordNotFound => 404,
@@ -80,13 +86,7 @@ describe Api::BaseController do
       HTTP::Error => 503,
       OpenSSL::SSL::SSLError => 503,
       Mastodon::NotPermittedError => 403,
-    }
-
-    before do
-      routes.draw { get 'error' => 'api/base#error' }
-    end
-
-    ERRORS_WITH_CODES.each do |error, code|
+    }.each do |error, code|
       it "Handles error class of #{error}" do
         expect(FakeService).to receive(:new).and_raise(error)
 
diff --git a/spec/controllers/api/oembed_controller_spec.rb b/spec/controllers/api/oembed_controller_spec.rb
index 02875ee9f3..70248c3982 100644
--- a/spec/controllers/api/oembed_controller_spec.rb
+++ b/spec/controllers/api/oembed_controller_spec.rb
@@ -2,7 +2,7 @@
 
 require 'rails_helper'
 
-RSpec.describe Api::OEmbedController, type: :controller do
+RSpec.describe Api::OEmbedController do
   render_views
 
   let(:alice)  { Fabricate(:account, username: 'alice') }
diff --git a/spec/controllers/api/v1/accounts/pins_controller_spec.rb b/spec/controllers/api/v1/accounts/pins_controller_spec.rb
index 19bba093e4..b4aa9b7116 100644
--- a/spec/controllers/api/v1/accounts/pins_controller_spec.rb
+++ b/spec/controllers/api/v1/accounts/pins_controller_spec.rb
@@ -2,7 +2,7 @@
 
 require 'rails_helper'
 
-RSpec.describe Api::V1::Accounts::PinsController, type: :controller do
+RSpec.describe Api::V1::Accounts::PinsController do
   let(:john)  { Fabricate(:user) }
   let(:kevin) { Fabricate(:user) }
   let(:token) { Fabricate(:accessible_access_token, resource_owner_id: john.id, scopes: 'write:accounts') }
diff --git a/spec/controllers/api/v1/accounts/relationships_controller_spec.rb b/spec/controllers/api/v1/accounts/relationships_controller_spec.rb
index da8d7fe3f0..6bc07fa9e0 100644
--- a/spec/controllers/api/v1/accounts/relationships_controller_spec.rb
+++ b/spec/controllers/api/v1/accounts/relationships_controller_spec.rb
@@ -21,7 +21,7 @@ describe Api::V1::Accounts::RelationshipsController do
       lewis.follow!(user.account)
     end
 
-    context 'provided only one ID' do
+    context 'when provided only one ID' do
       before do
         get :index, params: { id: simon.id }
       end
@@ -39,7 +39,7 @@ describe Api::V1::Accounts::RelationshipsController do
       end
     end
 
-    context 'provided multiple IDs' do
+    context 'when provided multiple IDs' do
       before do
         get :index, params: { id: [simon.id, lewis.id] }
       end
diff --git a/spec/controllers/api/v1/accounts/search_controller_spec.rb b/spec/controllers/api/v1/accounts/search_controller_spec.rb
index d2b675a3c8..aa9455a4a3 100644
--- a/spec/controllers/api/v1/accounts/search_controller_spec.rb
+++ b/spec/controllers/api/v1/accounts/search_controller_spec.rb
@@ -2,7 +2,7 @@
 
 require 'rails_helper'
 
-RSpec.describe Api::V1::Accounts::SearchController, type: :controller do
+RSpec.describe Api::V1::Accounts::SearchController do
   render_views
 
   let(:user)  { Fabricate(:user) }
diff --git a/spec/controllers/api/v1/accounts_controller_spec.rb b/spec/controllers/api/v1/accounts_controller_spec.rb
index 5fbb650213..1cca69de73 100644
--- a/spec/controllers/api/v1/accounts_controller_spec.rb
+++ b/spec/controllers/api/v1/accounts_controller_spec.rb
@@ -2,7 +2,7 @@
 
 require 'rails_helper'
 
-RSpec.describe Api::V1::AccountsController, type: :controller do
+RSpec.describe Api::V1::AccountsController do
   render_views
 
   let(:user)   { Fabricate(:user) }
@@ -30,7 +30,7 @@ RSpec.describe Api::V1::AccountsController, type: :controller do
       post :create, params: { username: 'test', password: '12345678', email: 'hello@world.tld', agreement: agreement }
     end
 
-    context 'given truthy agreement' do
+    context 'when given truthy agreement' do
       let(:agreement) { 'true' }
 
       it 'returns http success' do
@@ -48,7 +48,7 @@ RSpec.describe Api::V1::AccountsController, type: :controller do
       end
     end
 
-    context 'given no agreement' do
+    context 'when given no agreement' do
       it 'returns http unprocessable entity' do
         expect(response).to have_http_status(422)
       end
@@ -121,7 +121,7 @@ RSpec.describe Api::V1::AccountsController, type: :controller do
       end
     end
 
-    context 'modifying follow options' do
+    context 'when modifying follow options' do
       let(:locked) { false }
 
       before do
diff --git a/spec/controllers/api/v1/admin/account_actions_controller_spec.rb b/spec/controllers/api/v1/admin/account_actions_controller_spec.rb
index cafbee212d..1c976455e1 100644
--- a/spec/controllers/api/v1/admin/account_actions_controller_spec.rb
+++ b/spec/controllers/api/v1/admin/account_actions_controller_spec.rb
@@ -2,7 +2,7 @@
 
 require 'rails_helper'
 
-RSpec.describe Api::V1::Admin::AccountActionsController, type: :controller do
+RSpec.describe Api::V1::Admin::AccountActionsController do
   render_views
 
   let(:role)   { UserRole.find_by(name: 'Moderator') }
diff --git a/spec/controllers/api/v1/admin/accounts_controller_spec.rb b/spec/controllers/api/v1/admin/accounts_controller_spec.rb
index 9ffcdb34fb..852a521021 100644
--- a/spec/controllers/api/v1/admin/accounts_controller_spec.rb
+++ b/spec/controllers/api/v1/admin/accounts_controller_spec.rb
@@ -2,7 +2,7 @@
 
 require 'rails_helper'
 
-RSpec.describe Api::V1::Admin::AccountsController, type: :controller do
+RSpec.describe Api::V1::Admin::AccountsController do
   render_views
 
   let(:role)   { UserRole.find_by(name: 'Moderator') }
diff --git a/spec/controllers/api/v1/admin/domain_allows_controller_spec.rb b/spec/controllers/api/v1/admin/domain_allows_controller_spec.rb
index 15567907e4..9db8a35b46 100644
--- a/spec/controllers/api/v1/admin/domain_allows_controller_spec.rb
+++ b/spec/controllers/api/v1/admin/domain_allows_controller_spec.rb
@@ -2,7 +2,7 @@
 
 require 'rails_helper'
 
-RSpec.describe Api::V1::Admin::DomainAllowsController, type: :controller do
+RSpec.describe Api::V1::Admin::DomainAllowsController do
   render_views
 
   let(:role)   { UserRole.find_by(name: 'Admin') }
diff --git a/spec/controllers/api/v1/admin/domain_blocks_controller_spec.rb b/spec/controllers/api/v1/admin/domain_blocks_controller_spec.rb
index 0460c701a4..5659843f7a 100644
--- a/spec/controllers/api/v1/admin/domain_blocks_controller_spec.rb
+++ b/spec/controllers/api/v1/admin/domain_blocks_controller_spec.rb
@@ -2,7 +2,7 @@
 
 require 'rails_helper'
 
-RSpec.describe Api::V1::Admin::DomainBlocksController, type: :controller do
+RSpec.describe Api::V1::Admin::DomainBlocksController do
   render_views
 
   let(:role)   { UserRole.find_by(name: 'Admin') }
@@ -84,7 +84,7 @@ RSpec.describe Api::V1::Admin::DomainBlocksController, type: :controller do
       BlockDomainService.new.call(domain_block)
     end
 
-    context 'downgrading a domain suspension to silence' do
+    context 'when downgrading a domain suspension to silence' do
       let(:original_severity) { 'suspend' }
       let(:new_severity)      { 'silence' }
 
@@ -101,7 +101,7 @@ RSpec.describe Api::V1::Admin::DomainBlocksController, type: :controller do
       end
     end
 
-    context 'upgrading a domain silence to suspend' do
+    context 'when upgrading a domain silence to suspend' do
       let(:original_severity) { 'silence' }
       let(:new_severity)      { 'suspend' }
 
diff --git a/spec/controllers/api/v1/admin/reports_controller_spec.rb b/spec/controllers/api/v1/admin/reports_controller_spec.rb
index 3d61fe5c3a..4f0c484e59 100644
--- a/spec/controllers/api/v1/admin/reports_controller_spec.rb
+++ b/spec/controllers/api/v1/admin/reports_controller_spec.rb
@@ -2,7 +2,7 @@
 
 require 'rails_helper'
 
-RSpec.describe Api::V1::Admin::ReportsController, type: :controller do
+RSpec.describe Api::V1::Admin::ReportsController do
   render_views
 
   let(:role)   { UserRole.find_by(name: 'Moderator') }
diff --git a/spec/controllers/api/v1/announcements/reactions_controller_spec.rb b/spec/controllers/api/v1/announcements/reactions_controller_spec.rb
index 25c52aa1d3..10aaa553f5 100644
--- a/spec/controllers/api/v1/announcements/reactions_controller_spec.rb
+++ b/spec/controllers/api/v1/announcements/reactions_controller_spec.rb
@@ -2,7 +2,7 @@
 
 require 'rails_helper'
 
-RSpec.describe Api::V1::Announcements::ReactionsController, type: :controller do
+RSpec.describe Api::V1::Announcements::ReactionsController do
   render_views
 
   let(:user)   { Fabricate(:user) }
diff --git a/spec/controllers/api/v1/announcements_controller_spec.rb b/spec/controllers/api/v1/announcements_controller_spec.rb
index eaab2abd80..15d94b4512 100644
--- a/spec/controllers/api/v1/announcements_controller_spec.rb
+++ b/spec/controllers/api/v1/announcements_controller_spec.rb
@@ -2,7 +2,7 @@
 
 require 'rails_helper'
 
-RSpec.describe Api::V1::AnnouncementsController, type: :controller do
+RSpec.describe Api::V1::AnnouncementsController do
   render_views
 
   let(:user)   { Fabricate(:user) }
diff --git a/spec/controllers/api/v1/apps_controller_spec.rb b/spec/controllers/api/v1/apps_controller_spec.rb
index bde132c52e..de2a07f20a 100644
--- a/spec/controllers/api/v1/apps_controller_spec.rb
+++ b/spec/controllers/api/v1/apps_controller_spec.rb
@@ -2,7 +2,7 @@
 
 require 'rails_helper'
 
-RSpec.describe Api::V1::AppsController, type: :controller do
+RSpec.describe Api::V1::AppsController do
   render_views
 
   describe 'POST #create' do
diff --git a/spec/controllers/api/v1/blocks_controller_spec.rb b/spec/controllers/api/v1/blocks_controller_spec.rb
index a746389ca2..eaafc1b4fa 100644
--- a/spec/controllers/api/v1/blocks_controller_spec.rb
+++ b/spec/controllers/api/v1/blocks_controller_spec.rb
@@ -2,7 +2,7 @@
 
 require 'rails_helper'
 
-RSpec.describe Api::V1::BlocksController, type: :controller do
+RSpec.describe Api::V1::BlocksController do
   render_views
 
   let(:user)   { Fabricate(:user) }
@@ -13,13 +13,13 @@ RSpec.describe Api::V1::BlocksController, type: :controller do
 
   describe 'GET #index' do
     it 'limits according to limit parameter' do
-      2.times.map { Fabricate(:block, account: user.account) }
+      Array.new(2) { Fabricate(:block, account: user.account) }
       get :index, params: { limit: 1 }
       expect(body_as_json.size).to eq 1
     end
 
     it 'queries blocks in range according to max_id' do
-      blocks = 2.times.map { Fabricate(:block, account: user.account) }
+      blocks = Array.new(2) { Fabricate(:block, account: user.account) }
 
       get :index, params: { max_id: blocks[1] }
 
@@ -28,7 +28,7 @@ RSpec.describe Api::V1::BlocksController, type: :controller do
     end
 
     it 'queries blocks in range according to since_id' do
-      blocks = 2.times.map { Fabricate(:block, account: user.account) }
+      blocks = Array.new(2) { Fabricate(:block, account: user.account) }
 
       get :index, params: { since_id: blocks[0] }
 
@@ -37,7 +37,7 @@ RSpec.describe Api::V1::BlocksController, type: :controller do
     end
 
     it 'sets pagination header for next path' do
-      blocks = 2.times.map { Fabricate(:block, account: user.account) }
+      blocks = Array.new(2) { Fabricate(:block, account: user.account) }
       get :index, params: { limit: 1, since_id: blocks[0] }
       expect(response.headers['Link'].find_link(%w(rel next)).href).to eq api_v1_blocks_url(limit: 1, max_id: blocks[1])
     end
diff --git a/spec/controllers/api/v1/bookmarks_controller_spec.rb b/spec/controllers/api/v1/bookmarks_controller_spec.rb
index bbf92c1539..69a37388ea 100644
--- a/spec/controllers/api/v1/bookmarks_controller_spec.rb
+++ b/spec/controllers/api/v1/bookmarks_controller_spec.rb
@@ -2,7 +2,7 @@
 
 require 'rails_helper'
 
-RSpec.describe Api::V1::BookmarksController, type: :controller do
+RSpec.describe Api::V1::BookmarksController do
   render_views
 
   let(:user)  { Fabricate(:user) }
diff --git a/spec/controllers/api/v1/conversations_controller_spec.rb b/spec/controllers/api/v1/conversations_controller_spec.rb
index 36c4cb56f9..f888517154 100644
--- a/spec/controllers/api/v1/conversations_controller_spec.rb
+++ b/spec/controllers/api/v1/conversations_controller_spec.rb
@@ -2,7 +2,7 @@
 
 require 'rails_helper'
 
-RSpec.describe Api::V1::ConversationsController, type: :controller do
+RSpec.describe Api::V1::ConversationsController do
   render_views
 
   let!(:user) { Fabricate(:user, account_attributes: { username: 'alice' }) }
diff --git a/spec/controllers/api/v1/custom_emojis_controller_spec.rb b/spec/controllers/api/v1/custom_emojis_controller_spec.rb
index fe8daa7c5a..08af57f405 100644
--- a/spec/controllers/api/v1/custom_emojis_controller_spec.rb
+++ b/spec/controllers/api/v1/custom_emojis_controller_spec.rb
@@ -2,7 +2,7 @@
 
 require 'rails_helper'
 
-RSpec.describe Api::V1::CustomEmojisController, type: :controller do
+RSpec.describe Api::V1::CustomEmojisController do
   render_views
 
   describe 'GET #index' do
diff --git a/spec/controllers/api/v1/domain_blocks_controller_spec.rb b/spec/controllers/api/v1/domain_blocks_controller_spec.rb
index 467ddbccce..aa98ec4c32 100644
--- a/spec/controllers/api/v1/domain_blocks_controller_spec.rb
+++ b/spec/controllers/api/v1/domain_blocks_controller_spec.rb
@@ -2,7 +2,7 @@
 
 require 'rails_helper'
 
-RSpec.describe Api::V1::DomainBlocksController, type: :controller do
+RSpec.describe Api::V1::DomainBlocksController do
   render_views
 
   let(:user)  { Fabricate(:user) }
diff --git a/spec/controllers/api/v1/emails/confirmations_controller_spec.rb b/spec/controllers/api/v1/emails/confirmations_controller_spec.rb
index fc9843fef3..2a0703ed9c 100644
--- a/spec/controllers/api/v1/emails/confirmations_controller_spec.rb
+++ b/spec/controllers/api/v1/emails/confirmations_controller_spec.rb
@@ -2,7 +2,7 @@
 
 require 'rails_helper'
 
-RSpec.describe Api::V1::Emails::ConfirmationsController, type: :controller do
+RSpec.describe Api::V1::Emails::ConfirmationsController do
   let(:confirmed_at) { nil }
   let(:user)         { Fabricate(:user, confirmed_at: confirmed_at) }
   let(:app)          { Fabricate(:application) }
@@ -15,14 +15,14 @@ RSpec.describe Api::V1::Emails::ConfirmationsController, type: :controller do
         allow(controller).to receive(:doorkeeper_token) { token }
       end
 
-      context 'from a random app' do
+      context 'when from a random app' do
         it 'returns http forbidden' do
           post :create
           expect(response).to have_http_status(403)
         end
       end
 
-      context 'from an app that created the account' do
+      context 'when from an app that created the account' do
         before do
           user.update(created_by_application: token.application)
         end
@@ -35,7 +35,7 @@ RSpec.describe Api::V1::Emails::ConfirmationsController, type: :controller do
             expect(response).to have_http_status(403)
           end
 
-          context 'but user changed e-mail and has not confirmed it' do
+          context 'with user changed e-mail and has not confirmed it' do
             before do
               user.update(email: 'foo@bar.com')
             end
diff --git a/spec/controllers/api/v1/endorsements_controller_spec.rb b/spec/controllers/api/v1/endorsements_controller_spec.rb
index ad5ff400f5..738804bb7b 100644
--- a/spec/controllers/api/v1/endorsements_controller_spec.rb
+++ b/spec/controllers/api/v1/endorsements_controller_spec.rb
@@ -2,7 +2,7 @@
 
 require 'rails_helper'
 
-RSpec.describe Api::V1::EndorsementsController, type: :controller do
+RSpec.describe Api::V1::EndorsementsController do
   let(:user)  { Fabricate(:user) }
   let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: 'read:accounts') }
 
diff --git a/spec/controllers/api/v1/favourites_controller_spec.rb b/spec/controllers/api/v1/favourites_controller_spec.rb
index dd07bbb6e1..c9ca046be0 100644
--- a/spec/controllers/api/v1/favourites_controller_spec.rb
+++ b/spec/controllers/api/v1/favourites_controller_spec.rb
@@ -2,7 +2,7 @@
 
 require 'rails_helper'
 
-RSpec.describe Api::V1::FavouritesController, type: :controller do
+RSpec.describe Api::V1::FavouritesController do
   render_views
 
   let(:user)  { Fabricate(:user) }
diff --git a/spec/controllers/api/v1/filters_controller_spec.rb b/spec/controllers/api/v1/filters_controller_spec.rb
index d583365cc1..8ccd2f4d66 100644
--- a/spec/controllers/api/v1/filters_controller_spec.rb
+++ b/spec/controllers/api/v1/filters_controller_spec.rb
@@ -2,7 +2,7 @@
 
 require 'rails_helper'
 
-RSpec.describe Api::V1::FiltersController, type: :controller do
+RSpec.describe Api::V1::FiltersController do
   render_views
 
   let(:user)  { Fabricate(:user) }
diff --git a/spec/controllers/api/v1/follow_requests_controller_spec.rb b/spec/controllers/api/v1/follow_requests_controller_spec.rb
index 0220e02770..0a2c27d9ed 100644
--- a/spec/controllers/api/v1/follow_requests_controller_spec.rb
+++ b/spec/controllers/api/v1/follow_requests_controller_spec.rb
@@ -2,7 +2,7 @@
 
 require 'rails_helper'
 
-RSpec.describe Api::V1::FollowRequestsController, type: :controller do
+RSpec.describe Api::V1::FollowRequestsController do
   render_views
 
   let(:user)     { Fabricate(:user, account_attributes: { locked: true }) }
diff --git a/spec/controllers/api/v1/followed_tags_controller_spec.rb b/spec/controllers/api/v1/followed_tags_controller_spec.rb
index e990065a9d..c1a366d4e3 100644
--- a/spec/controllers/api/v1/followed_tags_controller_spec.rb
+++ b/spec/controllers/api/v1/followed_tags_controller_spec.rb
@@ -2,7 +2,7 @@
 
 require 'rails_helper'
 
-RSpec.describe Api::V1::FollowedTagsController, type: :controller do
+RSpec.describe Api::V1::FollowedTagsController do
   render_views
 
   let(:user)   { Fabricate(:user) }
diff --git a/spec/controllers/api/v1/instances/activity_controller_spec.rb b/spec/controllers/api/v1/instances/activity_controller_spec.rb
index 159792ee01..b446a521f8 100644
--- a/spec/controllers/api/v1/instances/activity_controller_spec.rb
+++ b/spec/controllers/api/v1/instances/activity_controller_spec.rb
@@ -2,14 +2,14 @@
 
 require 'rails_helper'
 
-RSpec.describe Api::V1::Instances::ActivityController, type: :controller do
+RSpec.describe Api::V1::Instances::ActivityController do
   describe 'GET #show' do
     it 'returns 200' do
       get :show
       expect(response).to have_http_status(200)
     end
 
-    context '!Setting.activity_api_enabled' do
+    context 'with !Setting.activity_api_enabled' do
       it 'returns 404' do
         Setting.activity_api_enabled = false
 
diff --git a/spec/controllers/api/v1/instances/peers_controller_spec.rb b/spec/controllers/api/v1/instances/peers_controller_spec.rb
index 12a214a83a..92b1019154 100644
--- a/spec/controllers/api/v1/instances/peers_controller_spec.rb
+++ b/spec/controllers/api/v1/instances/peers_controller_spec.rb
@@ -2,14 +2,14 @@
 
 require 'rails_helper'
 
-RSpec.describe Api::V1::Instances::PeersController, type: :controller do
+RSpec.describe Api::V1::Instances::PeersController do
   describe 'GET #index' do
     it 'returns 200' do
       get :index
       expect(response).to have_http_status(200)
     end
 
-    context '!Setting.peers_api_enabled' do
+    context 'with !Setting.peers_api_enabled' do
       it 'returns 404' do
         Setting.peers_api_enabled = false
 
diff --git a/spec/controllers/api/v1/instances_controller_spec.rb b/spec/controllers/api/v1/instances_controller_spec.rb
index 842669d965..fcc2c9288c 100644
--- a/spec/controllers/api/v1/instances_controller_spec.rb
+++ b/spec/controllers/api/v1/instances_controller_spec.rb
@@ -2,7 +2,7 @@
 
 require 'rails_helper'
 
-RSpec.describe Api::V1::InstancesController, type: :controller do
+RSpec.describe Api::V1::InstancesController do
   render_views
 
   let(:user)  { Fabricate(:user) }
diff --git a/spec/controllers/api/v1/lists/accounts_controller_spec.rb b/spec/controllers/api/v1/lists/accounts_controller_spec.rb
index 337a5645c0..d4550dd769 100644
--- a/spec/controllers/api/v1/lists/accounts_controller_spec.rb
+++ b/spec/controllers/api/v1/lists/accounts_controller_spec.rb
@@ -29,17 +29,48 @@ describe Api::V1::Lists::AccountsController do
     let(:scopes) { 'write:lists' }
     let(:bob) { Fabricate(:account, username: 'bob') }
 
-    before do
-      user.account.follow!(bob)
-      post :create, params: { list_id: list.id, account_ids: [bob.id] }
+    context 'when the added account is followed' do
+      before do
+        user.account.follow!(bob)
+        post :create, params: { list_id: list.id, account_ids: [bob.id] }
+      end
+
+      it 'returns http success' do
+        expect(response).to have_http_status(200)
+      end
+
+      it 'adds account to the list' do
+        expect(list.accounts.include?(bob)).to be true
+      end
     end
 
-    it 'returns http success' do
-      expect(response).to have_http_status(200)
+    context 'when the added account has been sent a follow request' do
+      before do
+        user.account.follow_requests.create!(target_account: bob)
+        post :create, params: { list_id: list.id, account_ids: [bob.id] }
+      end
+
+      it 'returns http success' do
+        expect(response).to have_http_status(200)
+      end
+
+      it 'adds account to the list' do
+        expect(list.accounts.include?(bob)).to be true
+      end
     end
 
-    it 'adds account to the list' do
-      expect(list.accounts.include?(bob)).to be true
+    context 'when the added account is not followed' do
+      before do
+        post :create, params: { list_id: list.id, account_ids: [bob.id] }
+      end
+
+      it 'returns http not found' do
+        expect(response).to have_http_status(404)
+      end
+
+      it 'does not add the account to the list' do
+        expect(list.accounts.include?(bob)).to be false
+      end
     end
   end
 
diff --git a/spec/controllers/api/v1/lists_controller_spec.rb b/spec/controllers/api/v1/lists_controller_spec.rb
index f54d27e42b..15b9840ce1 100644
--- a/spec/controllers/api/v1/lists_controller_spec.rb
+++ b/spec/controllers/api/v1/lists_controller_spec.rb
@@ -2,7 +2,7 @@
 
 require 'rails_helper'
 
-RSpec.describe Api::V1::ListsController, type: :controller do
+RSpec.describe Api::V1::ListsController do
   render_views
 
   let!(:user)  { Fabricate(:user) }
diff --git a/spec/controllers/api/v1/markers_controller_spec.rb b/spec/controllers/api/v1/markers_controller_spec.rb
index fb5f59a7cf..64e9dcafb6 100644
--- a/spec/controllers/api/v1/markers_controller_spec.rb
+++ b/spec/controllers/api/v1/markers_controller_spec.rb
@@ -2,7 +2,7 @@
 
 require 'rails_helper'
 
-RSpec.describe Api::V1::MarkersController, type: :controller do
+RSpec.describe Api::V1::MarkersController do
   render_views
 
   let!(:user)  { Fabricate(:user) }
diff --git a/spec/controllers/api/v1/media_controller_spec.rb b/spec/controllers/api/v1/media_controller_spec.rb
index 90379dd92d..20d58e8c03 100644
--- a/spec/controllers/api/v1/media_controller_spec.rb
+++ b/spec/controllers/api/v1/media_controller_spec.rb
@@ -2,7 +2,7 @@
 
 require 'rails_helper'
 
-RSpec.describe Api::V1::MediaController, type: :controller do
+RSpec.describe Api::V1::MediaController do
   render_views
 
   let(:user)  { Fabricate(:user) }
@@ -37,7 +37,7 @@ RSpec.describe Api::V1::MediaController, type: :controller do
       end
     end
 
-    context 'image/jpeg' do
+    context 'with image/jpeg' do
       before do
         post :create, params: { file: fixture_file_upload('attachment.jpg', 'image/jpeg') }
       end
@@ -59,7 +59,7 @@ RSpec.describe Api::V1::MediaController, type: :controller do
       end
     end
 
-    context 'image/gif' do
+    context 'with image/gif' do
       before do
         post :create, params: { file: fixture_file_upload('attachment.gif', 'image/gif') }
       end
@@ -81,7 +81,7 @@ RSpec.describe Api::V1::MediaController, type: :controller do
       end
     end
 
-    context 'video/webm' do
+    context 'with video/webm' do
       before do
         post :create, params: { file: fixture_file_upload('attachment.webm', 'video/webm') }
       end
diff --git a/spec/controllers/api/v1/mutes_controller_spec.rb b/spec/controllers/api/v1/mutes_controller_spec.rb
index 122d9d1c56..2645ed4e9d 100644
--- a/spec/controllers/api/v1/mutes_controller_spec.rb
+++ b/spec/controllers/api/v1/mutes_controller_spec.rb
@@ -2,7 +2,7 @@
 
 require 'rails_helper'
 
-RSpec.describe Api::V1::MutesController, type: :controller do
+RSpec.describe Api::V1::MutesController do
   render_views
 
   let(:user)   { Fabricate(:user) }
@@ -13,13 +13,13 @@ RSpec.describe Api::V1::MutesController, type: :controller do
 
   describe 'GET #index' do
     it 'limits according to limit parameter' do
-      2.times.map { Fabricate(:mute, account: user.account) }
+      Array.new(2) { Fabricate(:mute, account: user.account) }
       get :index, params: { limit: 1 }
       expect(body_as_json.size).to eq 1
     end
 
     it 'queries mutes in range according to max_id' do
-      mutes = 2.times.map { Fabricate(:mute, account: user.account) }
+      mutes = Array.new(2) { Fabricate(:mute, account: user.account) }
 
       get :index, params: { max_id: mutes[1] }
 
@@ -28,7 +28,7 @@ RSpec.describe Api::V1::MutesController, type: :controller do
     end
 
     it 'queries mutes in range according to since_id' do
-      mutes = 2.times.map { Fabricate(:mute, account: user.account) }
+      mutes = Array.new(2) { Fabricate(:mute, account: user.account) }
 
       get :index, params: { since_id: mutes[0] }
 
@@ -37,7 +37,7 @@ RSpec.describe Api::V1::MutesController, type: :controller do
     end
 
     it 'sets pagination header for next path' do
-      mutes = 2.times.map { Fabricate(:mute, account: user.account) }
+      mutes = Array.new(2) { Fabricate(:mute, account: user.account) }
       get :index, params: { limit: 1, since_id: mutes[0] }
       expect(response.headers['Link'].find_link(%w(rel next)).href).to eq api_v1_mutes_url(limit: 1, max_id: mutes[1])
     end
diff --git a/spec/controllers/api/v1/notifications_controller_spec.rb b/spec/controllers/api/v1/notifications_controller_spec.rb
index f6cbd105e3..28b8e656ab 100644
--- a/spec/controllers/api/v1/notifications_controller_spec.rb
+++ b/spec/controllers/api/v1/notifications_controller_spec.rb
@@ -2,7 +2,7 @@
 
 require 'rails_helper'
 
-RSpec.describe Api::V1::NotificationsController, type: :controller do
+RSpec.describe Api::V1::NotificationsController do
   render_views
 
   let(:user)  { Fabricate(:user, account_attributes: { username: 'alice' }) }
diff --git a/spec/controllers/api/v1/polls/votes_controller_spec.rb b/spec/controllers/api/v1/polls/votes_controller_spec.rb
index 9d9b14e81c..7abd2a1b17 100644
--- a/spec/controllers/api/v1/polls/votes_controller_spec.rb
+++ b/spec/controllers/api/v1/polls/votes_controller_spec.rb
@@ -2,7 +2,7 @@
 
 require 'rails_helper'
 
-RSpec.describe Api::V1::Polls::VotesController, type: :controller do
+RSpec.describe Api::V1::Polls::VotesController do
   render_views
 
   let(:user)   { Fabricate(:user) }
diff --git a/spec/controllers/api/v1/polls_controller_spec.rb b/spec/controllers/api/v1/polls_controller_spec.rb
index 0602e44eef..3aae5496db 100644
--- a/spec/controllers/api/v1/polls_controller_spec.rb
+++ b/spec/controllers/api/v1/polls_controller_spec.rb
@@ -2,7 +2,7 @@
 
 require 'rails_helper'
 
-RSpec.describe Api::V1::PollsController, type: :controller do
+RSpec.describe Api::V1::PollsController do
   render_views
 
   let(:user)   { Fabricate(:user) }
diff --git a/spec/controllers/api/v1/reports_controller_spec.rb b/spec/controllers/api/v1/reports_controller_spec.rb
index 06afaf0a75..0eb9ce1709 100644
--- a/spec/controllers/api/v1/reports_controller_spec.rb
+++ b/spec/controllers/api/v1/reports_controller_spec.rb
@@ -2,7 +2,7 @@
 
 require 'rails_helper'
 
-RSpec.describe Api::V1::ReportsController, type: :controller do
+RSpec.describe Api::V1::ReportsController do
   render_views
 
   let(:user)  { Fabricate(:user) }
diff --git a/spec/controllers/api/v1/statuses/favourited_by_accounts_controller_spec.rb b/spec/controllers/api/v1/statuses/favourited_by_accounts_controller_spec.rb
index cac7b42b50..01816743e5 100644
--- a/spec/controllers/api/v1/statuses/favourited_by_accounts_controller_spec.rb
+++ b/spec/controllers/api/v1/statuses/favourited_by_accounts_controller_spec.rb
@@ -2,7 +2,7 @@
 
 require 'rails_helper'
 
-RSpec.describe Api::V1::Statuses::FavouritedByAccountsController, type: :controller do
+RSpec.describe Api::V1::Statuses::FavouritedByAccountsController do
   render_views
 
   let(:user)  { Fabricate(:user) }
diff --git a/spec/controllers/api/v1/statuses/reblogged_by_accounts_controller_spec.rb b/spec/controllers/api/v1/statuses/reblogged_by_accounts_controller_spec.rb
index c5fedcefa3..756010af87 100644
--- a/spec/controllers/api/v1/statuses/reblogged_by_accounts_controller_spec.rb
+++ b/spec/controllers/api/v1/statuses/reblogged_by_accounts_controller_spec.rb
@@ -2,7 +2,7 @@
 
 require 'rails_helper'
 
-RSpec.describe Api::V1::Statuses::RebloggedByAccountsController, type: :controller do
+RSpec.describe Api::V1::Statuses::RebloggedByAccountsController do
   render_views
 
   let(:user)  { Fabricate(:user) }
diff --git a/spec/controllers/api/v1/statuses_controller_spec.rb b/spec/controllers/api/v1/statuses_controller_spec.rb
index f011bfd473..ab6f80a61b 100644
--- a/spec/controllers/api/v1/statuses_controller_spec.rb
+++ b/spec/controllers/api/v1/statuses_controller_spec.rb
@@ -2,7 +2,7 @@
 
 require 'rails_helper'
 
-RSpec.describe Api::V1::StatusesController, type: :controller do
+RSpec.describe Api::V1::StatusesController do
   render_views
 
   let(:user)  { Fabricate(:user) }
diff --git a/spec/controllers/api/v1/suggestions_controller_spec.rb b/spec/controllers/api/v1/suggestions_controller_spec.rb
index c99380c58b..c61ce0ec05 100644
--- a/spec/controllers/api/v1/suggestions_controller_spec.rb
+++ b/spec/controllers/api/v1/suggestions_controller_spec.rb
@@ -2,7 +2,7 @@
 
 require 'rails_helper'
 
-RSpec.describe Api::V1::SuggestionsController, type: :controller do
+RSpec.describe Api::V1::SuggestionsController do
   render_views
 
   let(:user)  { Fabricate(:user) }
diff --git a/spec/controllers/api/v1/tags_controller_spec.rb b/spec/controllers/api/v1/tags_controller_spec.rb
index ed17a4fbfb..e914f5992d 100644
--- a/spec/controllers/api/v1/tags_controller_spec.rb
+++ b/spec/controllers/api/v1/tags_controller_spec.rb
@@ -2,7 +2,7 @@
 
 require 'rails_helper'
 
-RSpec.describe Api::V1::TagsController, type: :controller do
+RSpec.describe Api::V1::TagsController do
   render_views
 
   let(:user)   { Fabricate(:user) }
diff --git a/spec/controllers/api/v1/trends/tags_controller_spec.rb b/spec/controllers/api/v1/trends/tags_controller_spec.rb
index d29551c56c..84370d8412 100644
--- a/spec/controllers/api/v1/trends/tags_controller_spec.rb
+++ b/spec/controllers/api/v1/trends/tags_controller_spec.rb
@@ -2,7 +2,7 @@
 
 require 'rails_helper'
 
-RSpec.describe Api::V1::Trends::TagsController, type: :controller do
+RSpec.describe Api::V1::Trends::TagsController do
   render_views
 
   describe 'GET #index' do
diff --git a/spec/controllers/api/v2/admin/accounts_controller_spec.rb b/spec/controllers/api/v2/admin/accounts_controller_spec.rb
index 5766fd549e..762c84af94 100644
--- a/spec/controllers/api/v2/admin/accounts_controller_spec.rb
+++ b/spec/controllers/api/v2/admin/accounts_controller_spec.rb
@@ -2,7 +2,7 @@
 
 require 'rails_helper'
 
-RSpec.describe Api::V2::Admin::AccountsController, type: :controller do
+RSpec.describe Api::V2::Admin::AccountsController do
   render_views
 
   let(:role)   { UserRole.find_by(name: 'Moderator') }
diff --git a/spec/controllers/api/v2/filters/keywords_controller_spec.rb b/spec/controllers/api/v2/filters/keywords_controller_spec.rb
index 8c61059c64..057a9c3d00 100644
--- a/spec/controllers/api/v2/filters/keywords_controller_spec.rb
+++ b/spec/controllers/api/v2/filters/keywords_controller_spec.rb
@@ -2,7 +2,7 @@
 
 require 'rails_helper'
 
-RSpec.describe Api::V2::Filters::KeywordsController, type: :controller do
+RSpec.describe Api::V2::Filters::KeywordsController do
   render_views
 
   let(:user)         { Fabricate(:user) }
diff --git a/spec/controllers/api/v2/filters/statuses_controller_spec.rb b/spec/controllers/api/v2/filters/statuses_controller_spec.rb
index 330cf45a60..588532ffd2 100644
--- a/spec/controllers/api/v2/filters/statuses_controller_spec.rb
+++ b/spec/controllers/api/v2/filters/statuses_controller_spec.rb
@@ -2,7 +2,7 @@
 
 require 'rails_helper'
 
-RSpec.describe Api::V2::Filters::StatusesController, type: :controller do
+RSpec.describe Api::V2::Filters::StatusesController do
   render_views
 
   let(:user)         { Fabricate(:user) }
diff --git a/spec/controllers/api/v2/filters_controller_spec.rb b/spec/controllers/api/v2/filters_controller_spec.rb
index 2b5610a4d6..722037eeb2 100644
--- a/spec/controllers/api/v2/filters_controller_spec.rb
+++ b/spec/controllers/api/v2/filters_controller_spec.rb
@@ -2,7 +2,7 @@
 
 require 'rails_helper'
 
-RSpec.describe Api::V2::FiltersController, type: :controller do
+RSpec.describe Api::V2::FiltersController do
   render_views
 
   let(:user)  { Fabricate(:user) }
@@ -66,7 +66,7 @@ RSpec.describe Api::V2::FiltersController, type: :controller do
     let!(:filter)  { Fabricate(:custom_filter, account: user.account) }
     let!(:keyword) { Fabricate(:custom_filter_keyword, custom_filter: filter) }
 
-    context 'updating filter parameters' do
+    context 'when updating filter parameters' do
       before do
         put :update, params: { id: filter.id, title: 'updated', context: %w(home public) }
       end
@@ -84,7 +84,7 @@ RSpec.describe Api::V2::FiltersController, type: :controller do
       end
     end
 
-    context 'updating keywords in bulk' do
+    context 'when updating keywords in bulk' do
       before do
         allow(redis).to receive_messages(publish: nil)
         put :update, params: { id: filter.id, keywords_attributes: [{ id: keyword.id, keyword: 'updated' }] }
diff --git a/spec/controllers/api/v2/search_controller_spec.rb b/spec/controllers/api/v2/search_controller_spec.rb
index d417ea58ca..bfabe8cc17 100644
--- a/spec/controllers/api/v2/search_controller_spec.rb
+++ b/spec/controllers/api/v2/search_controller_spec.rb
@@ -2,7 +2,7 @@
 
 require 'rails_helper'
 
-RSpec.describe Api::V2::SearchController, type: :controller do
+RSpec.describe Api::V2::SearchController do
   render_views
 
   context 'with token' do
diff --git a/spec/controllers/api/web/embeds_controller_spec.rb b/spec/controllers/api/web/embeds_controller_spec.rb
index e03f5a3714..b0c48a5aed 100644
--- a/spec/controllers/api/web/embeds_controller_spec.rb
+++ b/spec/controllers/api/web/embeds_controller_spec.rb
@@ -10,10 +10,10 @@ describe Api::Web::EmbedsController do
   before { sign_in user }
 
   describe 'POST #create' do
-    subject(:response) { post :create, params: { url: url } }
-
     subject(:body) { JSON.parse(response.body, symbolize_names: true) }
 
+    let(:response) { post :create, params: { url: url } }
+
     context 'when successfully finds status' do
       let(:status) { Fabricate(:status) }
       let(:url) { "http://#{Rails.configuration.x.web_domain}/@#{status.account.username}/#{status.id}" }
diff --git a/spec/controllers/application_controller_spec.rb b/spec/controllers/application_controller_spec.rb
index bab17fa211..bbe6856e27 100644
--- a/spec/controllers/application_controller_spec.rb
+++ b/spec/controllers/application_controller_spec.rb
@@ -2,7 +2,7 @@
 
 require 'rails_helper'
 
-describe ApplicationController, type: :controller do
+describe ApplicationController do
   controller do
     def success
       head 200
@@ -32,7 +32,7 @@ describe ApplicationController, type: :controller do
     end
   end
 
-  context 'forgery' do
+  context 'with a forgery' do
     subject do
       ActionController::Base.allow_forgery_protection = true
       routes.draw { post 'success' => 'anonymous#success' }
@@ -112,7 +112,7 @@ describe ApplicationController, type: :controller do
     end
   end
 
-  context 'ActionController::RoutingError' do
+  context 'with ActionController::RoutingError' do
     subject do
       routes.draw { get 'routing_error' => 'anonymous#routing_error' }
       get 'routing_error'
@@ -121,7 +121,7 @@ describe ApplicationController, type: :controller do
     include_examples 'respond_with_error', 404
   end
 
-  context 'ActiveRecord::RecordNotFound' do
+  context 'with ActiveRecord::RecordNotFound' do
     subject do
       routes.draw { get 'record_not_found' => 'anonymous#record_not_found' }
       get 'record_not_found'
@@ -130,7 +130,7 @@ describe ApplicationController, type: :controller do
     include_examples 'respond_with_error', 404
   end
 
-  context 'ActionController::InvalidAuthenticityToken' do
+  context 'with ActionController::InvalidAuthenticityToken' do
     subject do
       routes.draw { get 'invalid_authenticity_token' => 'anonymous#invalid_authenticity_token' }
       get 'invalid_authenticity_token'
@@ -230,14 +230,16 @@ describe ApplicationController, type: :controller do
   end
 
   describe 'cache_collection' do
-    class C < ApplicationController
-      public :cache_collection
+    subject do
+      Class.new(ApplicationController) do
+        public :cache_collection
+      end
     end
 
     shared_examples 'receives :with_includes' do |fabricator, klass|
       it 'uses raw if it is not an ActiveRecord::Relation' do
         record = Fabricate(fabricator)
-        expect(C.new.cache_collection([record], klass)).to eq [record]
+        expect(subject.new.cache_collection([record], klass)).to eq [record]
       end
     end
 
@@ -248,16 +250,16 @@ describe ApplicationController, type: :controller do
         record = Fabricate(fabricator)
         relation = klass.none
         allow(relation).to receive(:cache_ids).and_return([record])
-        expect(C.new.cache_collection(relation, klass)).to eq [record]
+        expect(subject.new.cache_collection(relation, klass)).to eq [record]
       end
     end
 
     it 'returns raw unless class responds to :with_includes' do
       raw = Object.new
-      expect(C.new.cache_collection(raw, Object)).to eq raw
+      expect(subject.new.cache_collection(raw, Object)).to eq raw
     end
 
-    context 'Status' do
+    context 'with a Status' do
       include_examples 'cacheable', :status, Status
     end
   end
diff --git a/spec/controllers/auth/challenges_controller_spec.rb b/spec/controllers/auth/challenges_controller_spec.rb
index 2a6ca301ef..32bbedde63 100644
--- a/spec/controllers/auth/challenges_controller_spec.rb
+++ b/spec/controllers/auth/challenges_controller_spec.rb
@@ -2,7 +2,7 @@
 
 require 'rails_helper'
 
-describe Auth::ChallengesController, type: :controller do
+describe Auth::ChallengesController do
   render_views
 
   let(:password) { 'foobar12345' }
diff --git a/spec/controllers/auth/confirmations_controller_spec.rb b/spec/controllers/auth/confirmations_controller_spec.rb
index 8469119d23..799d3857e4 100644
--- a/spec/controllers/auth/confirmations_controller_spec.rb
+++ b/spec/controllers/auth/confirmations_controller_spec.rb
@@ -2,7 +2,7 @@
 
 require 'rails_helper'
 
-describe Auth::ConfirmationsController, type: :controller do
+describe Auth::ConfirmationsController do
   render_views
 
   describe 'GET #new' do
diff --git a/spec/controllers/auth/passwords_controller_spec.rb b/spec/controllers/auth/passwords_controller_spec.rb
index 1c6874f08c..38fbed27a7 100644
--- a/spec/controllers/auth/passwords_controller_spec.rb
+++ b/spec/controllers/auth/passwords_controller_spec.rb
@@ -2,7 +2,7 @@
 
 require 'rails_helper'
 
-describe Auth::PasswordsController, type: :controller do
+describe Auth::PasswordsController do
   include Devise::Test::ControllerHelpers
 
   describe 'GET #new' do
diff --git a/spec/controllers/auth/registrations_controller_spec.rb b/spec/controllers/auth/registrations_controller_spec.rb
index 5c422bdffc..ad8465e2ac 100644
--- a/spec/controllers/auth/registrations_controller_spec.rb
+++ b/spec/controllers/auth/registrations_controller_spec.rb
@@ -2,7 +2,7 @@
 
 require 'rails_helper'
 
-RSpec.describe Auth::RegistrationsController, type: :controller do
+RSpec.describe Auth::RegistrationsController do
   render_views
 
   shared_examples 'checks for enabled registrations' do |path|
@@ -157,7 +157,7 @@ RSpec.describe Auth::RegistrationsController, type: :controller do
       end
     end
 
-    context 'approval-based registrations without invite' do
+    context 'with Approval-based registrations without invite' do
       subject do
         Setting.registrations_mode = 'approved'
         request.headers['Accept-Language'] = accept_language
@@ -184,7 +184,7 @@ RSpec.describe Auth::RegistrationsController, type: :controller do
       end
     end
 
-    context 'approval-based registrations with expired invite' do
+    context 'with Approval-based registrations with expired invite' do
       subject do
         Setting.registrations_mode = 'approved'
         request.headers['Accept-Language'] = accept_language
@@ -212,7 +212,7 @@ RSpec.describe Auth::RegistrationsController, type: :controller do
       end
     end
 
-    context 'approval-based registrations with valid invite and required invite text' do
+    context 'with Approval-based registrations with valid invite and required invite text' do
       subject do
         inviter = Fabricate(:user, confirmed_at: 2.days.ago)
         Setting.registrations_mode = 'approved'
diff --git a/spec/controllers/auth/sessions_controller_spec.rb b/spec/controllers/auth/sessions_controller_spec.rb
index 58befa124c..5b7d5d5cd4 100644
--- a/spec/controllers/auth/sessions_controller_spec.rb
+++ b/spec/controllers/auth/sessions_controller_spec.rb
@@ -3,7 +3,7 @@
 require 'rails_helper'
 require 'webauthn/fake_client'
 
-RSpec.describe Auth::SessionsController, type: :controller do
+RSpec.describe Auth::SessionsController do
   render_views
 
   before do
@@ -51,8 +51,8 @@ RSpec.describe Auth::SessionsController, type: :controller do
   end
 
   describe 'POST #create' do
-    context 'using PAM authentication', if: ENV['PAM_ENABLED'] == 'true' do
-      context 'using a valid password' do
+    context 'when using PAM authentication', if: ENV['PAM_ENABLED'] == 'true' do
+      context 'when using a valid password' do
         before do
           post :create, params: { user: { email: 'pam_user1', password: '123456' } }
         end
@@ -66,7 +66,7 @@ RSpec.describe Auth::SessionsController, type: :controller do
         end
       end
 
-      context 'using an invalid password' do
+      context 'when using an invalid password' do
         before do
           post :create, params: { user: { email: 'pam_user1', password: 'WRONGPW' } }
         end
@@ -80,7 +80,7 @@ RSpec.describe Auth::SessionsController, type: :controller do
         end
       end
 
-      context 'using a valid email and existing user' do
+      context 'when using a valid email and existing user' do
         let!(:user) do
           account = Fabricate.build(:account, username: 'pam_user1', user: nil)
           account.save!(validate: false)
@@ -102,10 +102,10 @@ RSpec.describe Auth::SessionsController, type: :controller do
       end
     end
 
-    context 'using password authentication' do
+    context 'when using password authentication' do
       let(:user) { Fabricate(:user, email: 'foo@bar.com', password: 'abcdefgh') }
 
-      context 'using a valid password' do
+      context 'when using a valid password' do
         before do
           post :create, params: { user: { email: user.email, password: user.password } }
         end
@@ -119,7 +119,7 @@ RSpec.describe Auth::SessionsController, type: :controller do
         end
       end
 
-      context 'using a valid password on a previously-used account with a new IP address' do
+      context 'when using a valid password on a previously-used account with a new IP address' do
         let(:previous_ip) { '1.2.3.4' }
         let(:current_ip)  { '4.3.2.1' }
 
@@ -145,7 +145,7 @@ RSpec.describe Auth::SessionsController, type: :controller do
         end
       end
 
-      context 'using email with uppercase letters' do
+      context 'when using email with uppercase letters' do
         before do
           post :create, params: { user: { email: user.email.upcase, password: user.password } }
         end
@@ -159,7 +159,7 @@ RSpec.describe Auth::SessionsController, type: :controller do
         end
       end
 
-      context 'using an invalid password' do
+      context 'when using an invalid password' do
         before do
           post :create, params: { user: { email: user.email, password: 'wrongpw' } }
         end
@@ -173,7 +173,7 @@ RSpec.describe Auth::SessionsController, type: :controller do
         end
       end
 
-      context 'using an unconfirmed password' do
+      context 'when using an unconfirmed password' do
         before do
           request.headers['Accept-Language'] = accept_language
           post :create, params: { user: { email: unconfirmed_user.email, password: unconfirmed_user.password } }
@@ -187,14 +187,14 @@ RSpec.describe Auth::SessionsController, type: :controller do
         end
       end
 
-      context "logging in from the user's page" do
+      context "when logging in from the user's page" do
         before do
           allow(controller).to receive(:single_user_mode?).and_return(single_user_mode)
           allow(controller).to receive(:stored_location_for).with(:user).and_return("/@#{user.account.username}")
           post :create, params: { user: { email: user.email, password: user.password } }
         end
 
-        context 'in single user mode' do
+        context 'with single user mode' do
           let(:single_user_mode) { true }
 
           it 'redirects to home' do
@@ -202,7 +202,7 @@ RSpec.describe Auth::SessionsController, type: :controller do
           end
         end
 
-        context 'in non-single user mode' do
+        context 'with non-single user mode' do
           let(:single_user_mode) { false }
 
           it "redirects back to the user's page" do
@@ -212,7 +212,7 @@ RSpec.describe Auth::SessionsController, type: :controller do
       end
     end
 
-    context 'using two-factor authentication' do
+    context 'when using two-factor authentication' do
       context 'with OTP enabled as second factor' do
         let!(:user) do
           Fabricate(:user, email: 'x@y.com', password: 'abcdefgh', otp_required_for_login: true, otp_secret: User.generate_otp_secret(32))
@@ -224,7 +224,7 @@ RSpec.describe Auth::SessionsController, type: :controller do
           return codes
         end
 
-        context 'using email and password' do
+        context 'when using email and password' do
           before do
             post :create, params: { user: { email: user.email, password: user.password } }
           end
@@ -235,7 +235,7 @@ RSpec.describe Auth::SessionsController, type: :controller do
           end
         end
 
-        context 'using email and password after an unfinished log-in attempt to a 2FA-protected account' do
+        context 'when using email and password after an unfinished log-in attempt to a 2FA-protected account' do
           let!(:other_user) do
             Fabricate(:user, email: 'z@y.com', password: 'abcdefgh', otp_required_for_login: true, otp_secret: User.generate_otp_secret(32))
           end
@@ -251,7 +251,7 @@ RSpec.describe Auth::SessionsController, type: :controller do
           end
         end
 
-        context 'using upcase email and password' do
+        context 'when using upcase email and password' do
           before do
             post :create, params: { user: { email: user.email.upcase, password: user.password } }
           end
@@ -262,7 +262,7 @@ RSpec.describe Auth::SessionsController, type: :controller do
           end
         end
 
-        context 'using a valid OTP' do
+        context 'when using a valid OTP' do
           before do
             post :create, params: { user: { otp_attempt: user.current_otp } }, session: { attempt_user_id: user.id, attempt_user_updated_at: user.updated_at.to_s }
           end
@@ -291,7 +291,7 @@ RSpec.describe Auth::SessionsController, type: :controller do
           end
         end
 
-        context 'using a valid recovery code' do
+        context 'when using a valid recovery code' do
           before do
             post :create, params: { user: { otp_attempt: recovery_codes.first } }, session: { attempt_user_id: user.id, attempt_user_updated_at: user.updated_at.to_s }
           end
@@ -305,7 +305,7 @@ RSpec.describe Auth::SessionsController, type: :controller do
           end
         end
 
-        context 'using an invalid OTP' do
+        context 'when using an invalid OTP' do
           before do
             post :create, params: { user: { otp_attempt: 'wrongotp' } }, session: { attempt_user_id: user.id, attempt_user_updated_at: user.updated_at.to_s }
           end
@@ -353,7 +353,7 @@ RSpec.describe Auth::SessionsController, type: :controller do
 
         let(:fake_credential) { fake_client.get(challenge: challenge, sign_count: sign_count) }
 
-        context 'using email and password' do
+        context 'when using email and password' do
           before do
             post :create, params: { user: { email: user.email, password: user.password } }
           end
@@ -364,7 +364,7 @@ RSpec.describe Auth::SessionsController, type: :controller do
           end
         end
 
-        context 'using upcase email and password' do
+        context 'when using upcase email and password' do
           before do
             post :create, params: { user: { email: user.email.upcase, password: user.password } }
           end
@@ -375,7 +375,7 @@ RSpec.describe Auth::SessionsController, type: :controller do
           end
         end
 
-        context 'using a valid webauthn credential' do
+        context 'when using a valid webauthn credential' do
           before do
             @controller.session[:webauthn_challenge] = challenge
 
diff --git a/spec/controllers/concerns/account_controller_concern_spec.rb b/spec/controllers/concerns/account_controller_concern_spec.rb
index 99975f4c44..57fc6f9653 100644
--- a/spec/controllers/concerns/account_controller_concern_spec.rb
+++ b/spec/controllers/concerns/account_controller_concern_spec.rb
@@ -2,7 +2,7 @@
 
 require 'rails_helper'
 
-describe ApplicationController, type: :controller do
+describe ApplicationController do
   controller do
     include AccountControllerConcern
 
diff --git a/spec/controllers/concerns/accountable_concern_spec.rb b/spec/controllers/concerns/accountable_concern_spec.rb
index 5c5180bc24..3c10082c34 100644
--- a/spec/controllers/concerns/accountable_concern_spec.rb
+++ b/spec/controllers/concerns/accountable_concern_spec.rb
@@ -3,18 +3,20 @@
 require 'rails_helper'
 
 RSpec.describe AccountableConcern do
-  class Hoge
-    include AccountableConcern
-    attr_reader :current_account
+  let(:hoge_class) do
+    Class.new do
+      include AccountableConcern
+      attr_reader :current_account
 
-    def initialize(current_account)
-      @current_account = current_account
+      def initialize(current_account)
+        @current_account = current_account
+      end
     end
   end
 
   let(:user)   { Fabricate(:account) }
   let(:target) { Fabricate(:account) }
-  let(:hoge)   { Hoge.new(user) }
+  let(:hoge)   { hoge_class.new(user) }
 
   describe '#log_action' do
     it 'creates Admin::ActionLog' do
diff --git a/spec/controllers/concerns/cache_concern_spec.rb b/spec/controllers/concerns/cache_concern_spec.rb
index a34d7d7267..bf328d679d 100644
--- a/spec/controllers/concerns/cache_concern_spec.rb
+++ b/spec/controllers/concerns/cache_concern_spec.rb
@@ -2,7 +2,7 @@
 
 require 'rails_helper'
 
-RSpec.describe CacheConcern, type: :controller do
+RSpec.describe CacheConcern do
   controller(ApplicationController) do
     include CacheConcern
 
@@ -23,14 +23,14 @@ RSpec.describe CacheConcern, type: :controller do
   end
 
   describe '#cache_collection' do
-    context 'given an empty array' do
+    context 'when given an empty array' do
       it 'returns an empty array' do
         get :empty_array
         expect(response.body).to eq '0'
       end
     end
 
-    context 'given an empty relation' do
+    context 'when given an empty relation' do
       it 'returns an empty array' do
         get :empty_relation
         expect(response.body).to eq '0'
diff --git a/spec/controllers/concerns/challengable_concern_spec.rb b/spec/controllers/concerns/challengable_concern_spec.rb
index 4db3b740db..3324bdd24f 100644
--- a/spec/controllers/concerns/challengable_concern_spec.rb
+++ b/spec/controllers/concerns/challengable_concern_spec.rb
@@ -2,7 +2,7 @@
 
 require 'rails_helper'
 
-RSpec.describe ChallengableConcern, type: :controller do
+RSpec.describe ChallengableConcern do
   controller(ApplicationController) do
     include ChallengableConcern
 
@@ -31,7 +31,7 @@ RSpec.describe ChallengableConcern, type: :controller do
       sign_in user
     end
 
-    context 'for GET requests' do
+    context 'with GET requests' do
       before { get :foo }
 
       it 'does not ask for password' do
@@ -39,7 +39,7 @@ RSpec.describe ChallengableConcern, type: :controller do
       end
     end
 
-    context 'for POST requests' do
+    context 'with POST requests' do
       before { post :bar }
 
       it 'does not ask for password' do
@@ -56,7 +56,7 @@ RSpec.describe ChallengableConcern, type: :controller do
       sign_in user
     end
 
-    context 'for GET requests' do
+    context 'with GET requests' do
       before { get :foo, session: { challenge_passed_at: Time.now.utc } }
 
       it 'does not ask for password' do
@@ -64,7 +64,7 @@ RSpec.describe ChallengableConcern, type: :controller do
       end
     end
 
-    context 'for POST requests' do
+    context 'with POST requests' do
       before { post :bar, session: { challenge_passed_at: Time.now.utc } }
 
       it 'does not ask for password' do
@@ -81,7 +81,7 @@ RSpec.describe ChallengableConcern, type: :controller do
       sign_in user
     end
 
-    context 'for GET requests' do
+    context 'with GET requests' do
       before { get :foo }
 
       it 'renders challenge' do
@@ -91,7 +91,7 @@ RSpec.describe ChallengableConcern, type: :controller do
       # See Auth::ChallengesControllerSpec
     end
 
-    context 'for POST requests' do
+    context 'with POST requests' do
       before { post :bar }
 
       it 'renders challenge' do
diff --git a/spec/controllers/concerns/export_controller_concern_spec.rb b/spec/controllers/concerns/export_controller_concern_spec.rb
index 003fd17f6f..b380246e7e 100644
--- a/spec/controllers/concerns/export_controller_concern_spec.rb
+++ b/spec/controllers/concerns/export_controller_concern_spec.rb
@@ -2,7 +2,7 @@
 
 require 'rails_helper'
 
-describe ApplicationController, type: :controller do
+describe ApplicationController do
   controller do
     include ExportControllerConcern
 
diff --git a/spec/controllers/concerns/localized_spec.rb b/spec/controllers/concerns/localized_spec.rb
index a89e24af04..caac94ea95 100644
--- a/spec/controllers/concerns/localized_spec.rb
+++ b/spec/controllers/concerns/localized_spec.rb
@@ -2,7 +2,7 @@
 
 require 'rails_helper'
 
-describe ApplicationController, type: :controller do
+describe ApplicationController do
   controller do
     include Localized
 
@@ -41,7 +41,7 @@ describe ApplicationController, type: :controller do
     end
   end
 
-  context 'user with valid locale has signed in' do
+  context 'with a user with valid locale has signed in' do
     it "sets user's locale" do
       user = Fabricate(:user, locale: :ca)
 
@@ -52,7 +52,7 @@ describe ApplicationController, type: :controller do
     end
   end
 
-  context 'user with invalid locale has signed in' do
+  context 'with a user with invalid locale has signed in' do
     before do
       user = Fabricate.build(:user, locale: :invalid)
       user.save!(validate: false)
@@ -62,7 +62,7 @@ describe ApplicationController, type: :controller do
     include_examples 'default locale'
   end
 
-  context 'user has not signed in' do
+  context 'with a user who has not signed in' do
     include_examples 'default locale'
   end
 end
diff --git a/spec/controllers/concerns/rate_limit_headers_spec.rb b/spec/controllers/concerns/rate_limit_headers_spec.rb
index 00a9a2080d..7e1f92546d 100644
--- a/spec/controllers/concerns/rate_limit_headers_spec.rb
+++ b/spec/controllers/concerns/rate_limit_headers_spec.rb
@@ -16,7 +16,7 @@ describe ApplicationController do
   end
 
   describe 'rate limiting' do
-    context 'throttling is off' do
+    context 'when throttling is off' do
       before do
         request.env['rack.attack.throttle_data'] = nil
       end
@@ -30,7 +30,7 @@ describe ApplicationController do
       end
     end
 
-    context 'throttling is on' do
+    context 'when throttling is on' do
       let(:start_time) { DateTime.new(2017, 1, 1, 12, 0, 0).utc }
 
       before do
diff --git a/spec/controllers/concerns/signature_verification_spec.rb b/spec/controllers/concerns/signature_verification_spec.rb
index 13655f3133..df20a5d7ef 100644
--- a/spec/controllers/concerns/signature_verification_spec.rb
+++ b/spec/controllers/concerns/signature_verification_spec.rb
@@ -2,15 +2,17 @@
 
 require 'rails_helper'
 
-describe ApplicationController, type: :controller do
-  class WrappedActor
-    attr_reader :wrapped_account
+describe ApplicationController do
+  let(:wrapped_actor_class) do
+    Class.new do
+      attr_reader :wrapped_account
 
-    def initialize(wrapped_account)
-      @wrapped_account = wrapped_account
+      def initialize(wrapped_account)
+        @wrapped_account = wrapped_account
+      end
+
+      delegate :uri, :keypair, to: :wrapped_account
     end
-
-    delegate :uri, :keypair, to: :wrapped_account
   end
 
   controller do
@@ -33,8 +35,8 @@ describe ApplicationController, type: :controller do
 
   before do
     routes.draw do
-      match via: [:get, :post], 'success' => 'anonymous#success'
-      match via: [:get, :post], 'signature_required' => 'anonymous#signature_required'
+      match :via => [:get, :post], 'success' => 'anonymous#success'
+      match :via => [:get, :post], 'signature_required' => 'anonymous#signature_required'
     end
   end
 
@@ -93,7 +95,7 @@ describe ApplicationController, type: :controller do
     end
 
     context 'with a valid actor that is not an Account' do
-      let(:actor) { WrappedActor.new(author) }
+      let(:actor) { wrapped_actor_class.new(author) }
 
       before do
         get :success
diff --git a/spec/controllers/concerns/user_tracking_concern_spec.rb b/spec/controllers/concerns/user_tracking_concern_spec.rb
index b2548d5c00..8e272468b6 100644
--- a/spec/controllers/concerns/user_tracking_concern_spec.rb
+++ b/spec/controllers/concerns/user_tracking_concern_spec.rb
@@ -2,7 +2,7 @@
 
 require 'rails_helper'
 
-describe ApplicationController, type: :controller do
+describe ApplicationController do
   controller do
     include UserTrackingConcern
 
diff --git a/spec/controllers/disputes/appeals_controller_spec.rb b/spec/controllers/disputes/appeals_controller_spec.rb
index affe63c59b..d0e1cd3908 100644
--- a/spec/controllers/disputes/appeals_controller_spec.rb
+++ b/spec/controllers/disputes/appeals_controller_spec.rb
@@ -2,7 +2,7 @@
 
 require 'rails_helper'
 
-RSpec.describe Disputes::AppealsController, type: :controller do
+RSpec.describe Disputes::AppealsController do
   render_views
 
   before { sign_in current_user, scope: :user }
diff --git a/spec/controllers/disputes/strikes_controller_spec.rb b/spec/controllers/disputes/strikes_controller_spec.rb
index 1d678875c6..f6d28fc09a 100644
--- a/spec/controllers/disputes/strikes_controller_spec.rb
+++ b/spec/controllers/disputes/strikes_controller_spec.rb
@@ -2,7 +2,7 @@
 
 require 'rails_helper'
 
-RSpec.describe Disputes::StrikesController, type: :controller do
+RSpec.describe Disputes::StrikesController do
   render_views
 
   before { sign_in current_user, scope: :user }
diff --git a/spec/controllers/emojis_controller_spec.rb b/spec/controllers/emojis_controller_spec.rb
index 710d23d924..249dfd9d51 100644
--- a/spec/controllers/emojis_controller_spec.rb
+++ b/spec/controllers/emojis_controller_spec.rb
@@ -8,10 +8,10 @@ describe EmojisController do
   let(:emoji) { Fabricate(:custom_emoji) }
 
   describe 'GET #show' do
-    subject(:response) { get :show, params: { id: emoji.id, format: :json } }
-
     subject(:body) { JSON.parse(response.body, symbolize_names: true) }
 
+    let(:response) { get :show, params: { id: emoji.id, format: :json } }
+
     it 'returns the right response' do
       expect(response).to have_http_status 200
       expect(body[:name]).to eq ':coolcat:'
diff --git a/spec/controllers/follower_accounts_controller_spec.rb b/spec/controllers/follower_accounts_controller_spec.rb
index 0551dfcdec..b5b8ff9cb4 100644
--- a/spec/controllers/follower_accounts_controller_spec.rb
+++ b/spec/controllers/follower_accounts_controller_spec.rb
@@ -39,10 +39,10 @@ describe FollowerAccountsController do
     end
 
     context 'when format is json' do
-      subject(:response) { get :index, params: { account_username: alice.username, page: page, format: :json } }
-
       subject(:body) { response.parsed_body }
 
+      let(:response) { get :index, params: { account_username: alice.username, page: page, format: :json } }
+
       context 'with page' do
         let(:page) { 1 }
 
diff --git a/spec/controllers/following_accounts_controller_spec.rb b/spec/controllers/following_accounts_controller_spec.rb
index b049df890f..d1efeec251 100644
--- a/spec/controllers/following_accounts_controller_spec.rb
+++ b/spec/controllers/following_accounts_controller_spec.rb
@@ -39,10 +39,10 @@ describe FollowingAccountsController do
     end
 
     context 'when format is json' do
-      subject(:response) { get :index, params: { account_username: alice.username, page: page, format: :json } }
-
       subject(:body) { response.parsed_body }
 
+      let(:response) { get :index, params: { account_username: alice.username, page: page, format: :json } }
+
       context 'with page' do
         let(:page) { 1 }
 
diff --git a/spec/controllers/home_controller_spec.rb b/spec/controllers/home_controller_spec.rb
index 0d3722920c..3ddc5691a9 100644
--- a/spec/controllers/home_controller_spec.rb
+++ b/spec/controllers/home_controller_spec.rb
@@ -2,7 +2,7 @@
 
 require 'rails_helper'
 
-RSpec.describe HomeController, type: :controller do
+RSpec.describe HomeController do
   render_views
 
   describe 'GET #index' do
diff --git a/spec/controllers/instance_actors_controller_spec.rb b/spec/controllers/instance_actors_controller_spec.rb
index 84a07d4970..8406094311 100644
--- a/spec/controllers/instance_actors_controller_spec.rb
+++ b/spec/controllers/instance_actors_controller_spec.rb
@@ -2,9 +2,9 @@
 
 require 'rails_helper'
 
-RSpec.describe InstanceActorsController, type: :controller do
+RSpec.describe InstanceActorsController do
   describe 'GET #show' do
-    context 'as JSON' do
+    context 'with JSON' do
       let(:format) { 'json' }
 
       shared_examples 'shared behavior' do
diff --git a/spec/controllers/intents_controller_spec.rb b/spec/controllers/intents_controller_spec.rb
index 02b46ddc79..668d833ea7 100644
--- a/spec/controllers/intents_controller_spec.rb
+++ b/spec/controllers/intents_controller_spec.rb
@@ -2,7 +2,7 @@
 
 require 'rails_helper'
 
-RSpec.describe IntentsController, type: :controller do
+RSpec.describe IntentsController do
   render_views
 
   let(:user) { Fabricate(:user) }
diff --git a/spec/controllers/oauth/authorizations_controller_spec.rb b/spec/controllers/oauth/authorizations_controller_spec.rb
index 6f087625c5..9b3ae251e6 100644
--- a/spec/controllers/oauth/authorizations_controller_spec.rb
+++ b/spec/controllers/oauth/authorizations_controller_spec.rb
@@ -2,7 +2,7 @@
 
 require 'rails_helper'
 
-RSpec.describe Oauth::AuthorizationsController, type: :controller do
+RSpec.describe Oauth::AuthorizationsController do
   render_views
 
   let(:app) { Doorkeeper::Application.create!(name: 'test', redirect_uri: 'http://localhost/', scopes: 'read') }
diff --git a/spec/controllers/oauth/tokens_controller_spec.rb b/spec/controllers/oauth/tokens_controller_spec.rb
index 3804e035bb..973393bcf2 100644
--- a/spec/controllers/oauth/tokens_controller_spec.rb
+++ b/spec/controllers/oauth/tokens_controller_spec.rb
@@ -2,7 +2,7 @@
 
 require 'rails_helper'
 
-RSpec.describe Oauth::TokensController, type: :controller do
+RSpec.describe Oauth::TokensController do
   describe 'POST #revoke' do
     let!(:user) { Fabricate(:user) }
     let!(:application) { Fabricate(:application, confidential: false) }
diff --git a/spec/controllers/settings/applications_controller_spec.rb b/spec/controllers/settings/applications_controller_spec.rb
index e12628a18b..c0a57380e8 100644
--- a/spec/controllers/settings/applications_controller_spec.rb
+++ b/spec/controllers/settings/applications_controller_spec.rb
@@ -50,7 +50,7 @@ describe Settings::ApplicationsController do
   end
 
   describe 'POST #create' do
-    context 'success (passed scopes as a String)' do
+    context 'when success (passed scopes as a String)' do
       def call_create
         post :create, params: {
           doorkeeper_application: {
@@ -72,7 +72,7 @@ describe Settings::ApplicationsController do
       end
     end
 
-    context 'success (passed scopes as an Array)' do
+    context 'when success (passed scopes as an Array)' do
       def call_create
         post :create, params: {
           doorkeeper_application: {
@@ -94,7 +94,7 @@ describe Settings::ApplicationsController do
       end
     end
 
-    context 'failure' do
+    context 'with failure request' do
       before do
         post :create, params: {
           doorkeeper_application: {
@@ -117,7 +117,7 @@ describe Settings::ApplicationsController do
   end
 
   describe 'PATCH #update' do
-    context 'success' do
+    context 'when success' do
       let(:opts) do
         {
           website: 'https://foo.bar/',
@@ -142,7 +142,7 @@ describe Settings::ApplicationsController do
       end
     end
 
-    context 'failure' do
+    context 'with failure request' do
       before do
         patch :update, params: {
           id: app.id,
diff --git a/spec/controllers/settings/imports_controller_spec.rb b/spec/controllers/settings/imports_controller_spec.rb
index 98ba897e41..76e1e4ecb0 100644
--- a/spec/controllers/settings/imports_controller_spec.rb
+++ b/spec/controllers/settings/imports_controller_spec.rb
@@ -2,16 +2,25 @@
 
 require 'rails_helper'
 
-RSpec.describe Settings::ImportsController, type: :controller do
+RSpec.describe Settings::ImportsController do
   render_views
 
+  let(:user) { Fabricate(:user) }
+
   before do
-    sign_in Fabricate(:user), scope: :user
+    sign_in user, scope: :user
   end
 
-  describe 'GET #show' do
+  describe 'GET #index' do
+    let!(:import)       { Fabricate(:bulk_import, account: user.account) }
+    let!(:other_import) { Fabricate(:bulk_import) }
+
     before do
-      get :show
+      get :index
+    end
+
+    it 'assigns the expected imports' do
+      expect(assigns(:recent_imports)).to eq [import]
     end
 
     it 'returns http success' do
@@ -23,31 +32,288 @@ RSpec.describe Settings::ImportsController, type: :controller do
     end
   end
 
-  describe 'POST #create' do
-    it 'redirects to settings path with successful following import' do
-      service = double(call: nil)
-      allow(ResolveAccountService).to receive(:new).and_return(service)
-      post :create, params: {
-        import: {
-          type: 'following',
-          data: fixture_file_upload('imports.txt'),
-        },
-      }
-
-      expect(response).to redirect_to(settings_import_path)
+  describe 'GET #show' do
+    before do
+      get :show, params: { id: bulk_import.id }
     end
 
-    it 'redirects to settings path with successful blocking import' do
-      service = double(call: nil)
-      allow(ResolveAccountService).to receive(:new).and_return(service)
-      post :create, params: {
-        import: {
-          type: 'blocking',
-          data: fixture_file_upload('imports.txt'),
-        },
-      }
+    context 'with someone else\'s import' do
+      let(:bulk_import) { Fabricate(:bulk_import, state: :unconfirmed) }
 
-      expect(response).to redirect_to(settings_import_path)
+      it 'returns http not found' do
+        expect(response).to have_http_status(404)
+      end
+    end
+
+    context 'with an already-confirmed import' do
+      let(:bulk_import) { Fabricate(:bulk_import, account: user.account, state: :in_progress) }
+
+      it 'returns http not found' do
+        expect(response).to have_http_status(404)
+      end
+    end
+
+    context 'with an unconfirmed import' do
+      let(:bulk_import) { Fabricate(:bulk_import, account: user.account, state: :unconfirmed) }
+
+      it 'returns http success' do
+        expect(response).to have_http_status(200)
+      end
     end
   end
+
+  describe 'POST #confirm' do
+    subject { post :confirm, params: { id: bulk_import.id } }
+
+    before do
+      allow(BulkImportWorker).to receive(:perform_async)
+    end
+
+    context 'with someone else\'s import' do
+      let(:bulk_import) { Fabricate(:bulk_import, state: :unconfirmed) }
+
+      it 'does not change the import\'s state' do
+        expect { subject }.to_not(change { bulk_import.reload.state })
+      end
+
+      it 'does not fire the import worker' do
+        subject
+        expect(BulkImportWorker).to_not have_received(:perform_async)
+      end
+
+      it 'returns http not found' do
+        subject
+        expect(response).to have_http_status(404)
+      end
+    end
+
+    context 'with an already-confirmed import' do
+      let(:bulk_import) { Fabricate(:bulk_import, account: user.account, state: :in_progress) }
+
+      it 'does not change the import\'s state' do
+        expect { subject }.to_not(change { bulk_import.reload.state })
+      end
+
+      it 'does not fire the import worker' do
+        subject
+        expect(BulkImportWorker).to_not have_received(:perform_async)
+      end
+
+      it 'returns http not found' do
+        subject
+        expect(response).to have_http_status(404)
+      end
+    end
+
+    context 'with an unconfirmed import' do
+      let(:bulk_import) { Fabricate(:bulk_import, account: user.account, state: :unconfirmed) }
+
+      it 'changes the import\'s state to scheduled' do
+        expect { subject }.to change { bulk_import.reload.state.to_sym }.from(:unconfirmed).to(:scheduled)
+      end
+
+      it 'fires the import worker on the expected import' do
+        subject
+        expect(BulkImportWorker).to have_received(:perform_async).with(bulk_import.id)
+      end
+
+      it 'redirects to imports path' do
+        subject
+        expect(response).to redirect_to(settings_imports_path)
+      end
+    end
+  end
+
+  describe 'DELETE #destroy' do
+    subject { delete :destroy, params: { id: bulk_import.id } }
+
+    context 'with someone else\'s import' do
+      let(:bulk_import) { Fabricate(:bulk_import, state: :unconfirmed) }
+
+      it 'does not delete the import' do
+        expect { subject }.to_not(change { BulkImport.exists?(bulk_import.id) })
+      end
+
+      it 'returns http not found' do
+        subject
+        expect(response).to have_http_status(404)
+      end
+    end
+
+    context 'with an already-confirmed import' do
+      let(:bulk_import) { Fabricate(:bulk_import, account: user.account, state: :in_progress) }
+
+      it 'does not delete the import' do
+        expect { subject }.to_not(change { BulkImport.exists?(bulk_import.id) })
+      end
+
+      it 'returns http not found' do
+        subject
+        expect(response).to have_http_status(404)
+      end
+    end
+
+    context 'with an unconfirmed import' do
+      let(:bulk_import) { Fabricate(:bulk_import, account: user.account, state: :unconfirmed) }
+
+      it 'deletes the import' do
+        expect { subject }.to change { BulkImport.exists?(bulk_import.id) }.from(true).to(false)
+      end
+
+      it 'redirects to imports path' do
+        subject
+        expect(response).to redirect_to(settings_imports_path)
+      end
+    end
+  end
+
+  describe 'GET #failures' do
+    subject { get :failures, params: { id: bulk_import.id }, format: :csv }
+
+    shared_examples 'export failed rows' do |expected_contents|
+      let(:bulk_import) { Fabricate(:bulk_import, account: user.account, type: import_type, state: :finished) }
+
+      before do
+        bulk_import.update(total_items: bulk_import.rows.count, processed_items: bulk_import.rows.count, imported_items: 0)
+      end
+
+      it 'returns http success' do
+        subject
+        expect(response).to have_http_status(200)
+      end
+
+      it 'returns expected contents' do
+        subject
+        expect(response.body).to eq expected_contents
+      end
+    end
+
+    context 'with follows' do
+      let(:import_type) { 'following' }
+
+      let!(:rows) do
+        [
+          { 'acct' => 'foo@bar' },
+          { 'acct' => 'user@bar', 'show_reblogs' => false, 'notify' => true, 'languages' => ['fr', 'de'] },
+        ].map { |data| Fabricate(:bulk_import_row, bulk_import: bulk_import, data: data) }
+      end
+
+      include_examples 'export failed rows', "Account address,Show boosts,Notify on new posts,Languages\nfoo@bar,true,false,\nuser@bar,false,true,\"fr, de\"\n"
+    end
+
+    context 'with blocks' do
+      let(:import_type) { 'blocking' }
+
+      let!(:rows) do
+        [
+          { 'acct' => 'foo@bar' },
+          { 'acct' => 'user@bar' },
+        ].map { |data| Fabricate(:bulk_import_row, bulk_import: bulk_import, data: data) }
+      end
+
+      include_examples 'export failed rows', "foo@bar\nuser@bar\n"
+    end
+
+    context 'with mutes' do
+      let(:import_type) { 'muting' }
+
+      let!(:rows) do
+        [
+          { 'acct' => 'foo@bar' },
+          { 'acct' => 'user@bar', 'hide_notifications' => false },
+        ].map { |data| Fabricate(:bulk_import_row, bulk_import: bulk_import, data: data) }
+      end
+
+      include_examples 'export failed rows', "Account address,Hide notifications\nfoo@bar,true\nuser@bar,false\n"
+    end
+
+    context 'with domain blocks' do
+      let(:import_type) { 'domain_blocking' }
+
+      let!(:rows) do
+        [
+          { 'domain' => 'bad.domain' },
+          { 'domain' => 'evil.domain' },
+        ].map { |data| Fabricate(:bulk_import_row, bulk_import: bulk_import, data: data) }
+      end
+
+      include_examples 'export failed rows', "bad.domain\nevil.domain\n"
+    end
+
+    context 'with bookmarks' do
+      let(:import_type) { 'bookmarks' }
+
+      let!(:rows) do
+        [
+          { 'uri' => 'https://foo.com/1' },
+          { 'uri' => 'https://foo.com/2' },
+        ].map { |data| Fabricate(:bulk_import_row, bulk_import: bulk_import, data: data) }
+      end
+
+      include_examples 'export failed rows', "https://foo.com/1\nhttps://foo.com/2\n"
+    end
+  end
+
+  describe 'POST #create' do
+    subject do
+      post :create, params: {
+        form_import: {
+          type: import_type,
+          mode: import_mode,
+          data: fixture_file_upload(import_file),
+        },
+      }
+    end
+
+    shared_examples 'successful import' do |type, file, mode|
+      let(:import_type) { type }
+      let(:import_file) { file }
+      let(:import_mode) { mode }
+
+      it 'creates an unconfirmed bulk_import with expected type' do
+        expect { subject }.to change { user.account.bulk_imports.pluck(:state, :type) }.from([]).to([['unconfirmed', import_type]])
+      end
+
+      it 'redirects to confirmation page for the import' do
+        subject
+        expect(response).to redirect_to(settings_import_path(user.account.bulk_imports.first))
+      end
+    end
+
+    shared_examples 'unsuccessful import' do |type, file, mode|
+      let(:import_type) { type }
+      let(:import_file) { file }
+      let(:import_mode) { mode }
+
+      it 'does not creates an unconfirmed bulk_import' do
+        expect { subject }.to_not(change { user.account.bulk_imports.count })
+      end
+
+      it 'sets error to the import' do
+        subject
+        expect(assigns(:import).errors).to_not be_empty
+      end
+    end
+
+    it_behaves_like 'successful import', 'following', 'imports.txt', 'merge'
+    it_behaves_like 'successful import', 'following', 'imports.txt', 'overwrite'
+    it_behaves_like 'successful import', 'blocking', 'imports.txt', 'merge'
+    it_behaves_like 'successful import', 'blocking', 'imports.txt', 'overwrite'
+    it_behaves_like 'successful import', 'muting', 'imports.txt', 'merge'
+    it_behaves_like 'successful import', 'muting', 'imports.txt', 'overwrite'
+    it_behaves_like 'successful import', 'domain_blocking', 'domain_blocks.csv', 'merge'
+    it_behaves_like 'successful import', 'domain_blocking', 'domain_blocks.csv', 'overwrite'
+    it_behaves_like 'successful import', 'bookmarks', 'bookmark-imports.txt', 'merge'
+    it_behaves_like 'successful import', 'bookmarks', 'bookmark-imports.txt', 'overwrite'
+
+    it_behaves_like 'unsuccessful import', 'following', 'domain_blocks.csv', 'merge'
+    it_behaves_like 'unsuccessful import', 'following', 'domain_blocks.csv', 'overwrite'
+    it_behaves_like 'unsuccessful import', 'blocking', 'domain_blocks.csv', 'merge'
+    it_behaves_like 'unsuccessful import', 'blocking', 'domain_blocks.csv', 'overwrite'
+    it_behaves_like 'unsuccessful import', 'muting', 'domain_blocks.csv', 'merge'
+    it_behaves_like 'unsuccessful import', 'muting', 'domain_blocks.csv', 'overwrite'
+
+    it_behaves_like 'unsuccessful import', 'following', 'empty.csv', 'merge'
+    it_behaves_like 'unsuccessful import', 'following', 'empty.csv', 'overwrite'
+  end
 end
diff --git a/spec/controllers/settings/preferences/appearance_controller_spec.rb b/spec/controllers/settings/preferences/appearance_controller_spec.rb
index 083bf49544..9a98a41886 100644
--- a/spec/controllers/settings/preferences/appearance_controller_spec.rb
+++ b/spec/controllers/settings/preferences/appearance_controller_spec.rb
@@ -31,5 +31,11 @@ describe Settings::Preferences::AppearanceController do
 
       expect(response).to redirect_to(settings_preferences_appearance_path)
     end
+
+    it 'renders show on failure' do
+      put :update, params: { user: { locale: 'fake option' } }
+
+      expect(response).to render_template('preferences/appearance/show')
+    end
   end
 end
diff --git a/spec/controllers/settings/preferences/base_controller_spec.rb b/spec/controllers/settings/preferences/base_controller_spec.rb
new file mode 100644
index 0000000000..53b3a461ed
--- /dev/null
+++ b/spec/controllers/settings/preferences/base_controller_spec.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+describe Settings::Preferences::BaseController do
+  describe 'after_update_redirect_path' do
+    it 'raises error when called' do
+      expect { described_class.new.send(:after_update_redirect_path) }.to raise_error(/Override/)
+    end
+  end
+end
diff --git a/spec/controllers/settings/profiles_controller_spec.rb b/spec/controllers/settings/profiles_controller_spec.rb
index 52ae1f5191..806fad19a8 100644
--- a/spec/controllers/settings/profiles_controller_spec.rb
+++ b/spec/controllers/settings/profiles_controller_spec.rb
@@ -2,7 +2,7 @@
 
 require 'rails_helper'
 
-RSpec.describe Settings::ProfilesController, type: :controller do
+RSpec.describe Settings::ProfilesController do
   render_views
 
   let!(:user) { Fabricate(:user) }
diff --git a/spec/controllers/settings/two_factor_authentication/webauthn_credentials_controller_spec.rb b/spec/controllers/settings/two_factor_authentication/webauthn_credentials_controller_spec.rb
index 719f70f16b..48dea62765 100644
--- a/spec/controllers/settings/two_factor_authentication/webauthn_credentials_controller_spec.rb
+++ b/spec/controllers/settings/two_factor_authentication/webauthn_credentials_controller_spec.rb
@@ -275,7 +275,7 @@ describe Settings::TwoFactorAuthentication::WebauthnCredentialsController do
         end
 
         context 'when user have not enabled webauthn' do
-          context 'creation succeeds' do
+          context 'when creation succeeds' do
             it 'creates a webauthn credential' do
               @controller.session[:webauthn_challenge] = challenge
 
diff --git a/spec/controllers/statuses_cleanup_controller_spec.rb b/spec/controllers/statuses_cleanup_controller_spec.rb
index 693260f92b..e082b69c51 100644
--- a/spec/controllers/statuses_cleanup_controller_spec.rb
+++ b/spec/controllers/statuses_cleanup_controller_spec.rb
@@ -2,7 +2,7 @@
 
 require 'rails_helper'
 
-RSpec.describe StatusesCleanupController, type: :controller do
+RSpec.describe StatusesCleanupController do
   render_views
 
   before do
diff --git a/spec/controllers/statuses_controller_spec.rb b/spec/controllers/statuses_controller_spec.rb
index 4ac6a68bb2..c846dd1d63 100644
--- a/spec/controllers/statuses_controller_spec.rb
+++ b/spec/controllers/statuses_controller_spec.rb
@@ -72,7 +72,7 @@ describe StatusesController do
         get :show, params: { account_username: status.account.username, id: status.id, format: format }
       end
 
-      context 'as HTML' do
+      context 'with HTML' do
         let(:format) { 'html' }
 
         it 'returns http success' do
@@ -97,7 +97,7 @@ describe StatusesController do
         end
       end
 
-      context 'as JSON' do
+      context 'with JSON' do
         let(:format) { 'json' }
 
         it 'returns http success' do
@@ -132,7 +132,7 @@ describe StatusesController do
         get :show, params: { account_username: status.account.username, id: status.id, format: format }
       end
 
-      context 'as JSON' do
+      context 'with JSON' do
         let(:format) { 'json' }
 
         it 'returns http not found' do
@@ -140,7 +140,7 @@ describe StatusesController do
         end
       end
 
-      context 'as HTML' do
+      context 'with HTML' do
         let(:format) { 'html' }
 
         it 'returns http not found' do
@@ -156,7 +156,7 @@ describe StatusesController do
         get :show, params: { account_username: status.account.username, id: status.id, format: format }
       end
 
-      context 'as JSON' do
+      context 'with JSON' do
         let(:format) { 'json' }
 
         it 'returns http not found' do
@@ -164,7 +164,7 @@ describe StatusesController do
         end
       end
 
-      context 'as HTML' do
+      context 'with HTML' do
         let(:format) { 'html' }
 
         it 'returns http not found' do
@@ -196,7 +196,7 @@ describe StatusesController do
           get :show, params: { account_username: status.account.username, id: status.id, format: format }
         end
 
-        context 'as HTML' do
+        context 'with HTML' do
           let(:format) { 'html' }
 
           it 'returns http success' do
@@ -221,7 +221,7 @@ describe StatusesController do
           end
         end
 
-        context 'as JSON' do
+        context 'with JSON' do
           let(:format) { 'json' }
 
           it 'returns http success' do
@@ -260,7 +260,7 @@ describe StatusesController do
             get :show, params: { account_username: status.account.username, id: status.id, format: format }
           end
 
-          context 'as HTML' do
+          context 'with HTML' do
             let(:format) { 'html' }
 
             it 'returns http success' do
@@ -285,7 +285,7 @@ describe StatusesController do
             end
           end
 
-          context 'as JSON' do
+          context 'with JSON' do
             let(:format) { 'json' }
 
             it 'returns http success' do
@@ -320,7 +320,7 @@ describe StatusesController do
             get :show, params: { account_username: status.account.username, id: status.id, format: format }
           end
 
-          context 'as JSON' do
+          context 'with JSON' do
             let(:format) { 'json' }
 
             it 'returns http not found' do
@@ -328,7 +328,7 @@ describe StatusesController do
             end
           end
 
-          context 'as HTML' do
+          context 'with HTML' do
             let(:format) { 'html' }
 
             it 'returns http not found' do
@@ -347,7 +347,7 @@ describe StatusesController do
             get :show, params: { account_username: status.account.username, id: status.id, format: format }
           end
 
-          context 'as HTML' do
+          context 'with HTML' do
             let(:format) { 'html' }
 
             it 'returns http success' do
@@ -372,7 +372,7 @@ describe StatusesController do
             end
           end
 
-          context 'as JSON' do
+          context 'with JSON' do
             let(:format) { 'json' }
 
             it 'returns http success' do
@@ -407,7 +407,7 @@ describe StatusesController do
             get :show, params: { account_username: status.account.username, id: status.id, format: format }
           end
 
-          context 'as JSON' do
+          context 'with JSON' do
             let(:format) { 'json' }
 
             it 'returns http not found' do
@@ -415,7 +415,7 @@ describe StatusesController do
             end
           end
 
-          context 'as HTML' do
+          context 'with HTML' do
             let(:format) { 'html' }
 
             it 'returns http not found' do
@@ -460,7 +460,7 @@ describe StatusesController do
           get :show, params: { account_username: status.account.username, id: status.id, format: format }
         end
 
-        context 'as HTML' do
+        context 'with HTML' do
           let(:format) { 'html' }
 
           it 'returns http success' do
@@ -485,7 +485,7 @@ describe StatusesController do
           end
         end
 
-        context 'as JSON' do
+        context 'with JSON' do
           let(:format) { 'json' }
 
           it 'returns http success' do
@@ -522,7 +522,7 @@ describe StatusesController do
             get :show, params: { account_username: status.account.username, id: status.id, format: format }
           end
 
-          context 'as HTML' do
+          context 'with HTML' do
             let(:format) { 'html' }
 
             it 'returns http success' do
@@ -547,7 +547,7 @@ describe StatusesController do
             end
           end
 
-          context 'as JSON' do
+          context 'with JSON' do
             let(:format) { 'json' }
 
             it 'returns http success' do
@@ -582,7 +582,7 @@ describe StatusesController do
             get :show, params: { account_username: status.account.username, id: status.id, format: format }
           end
 
-          context 'as JSON' do
+          context 'with JSON' do
             let(:format) { 'json' }
 
             it 'returns http not found' do
@@ -590,7 +590,7 @@ describe StatusesController do
             end
           end
 
-          context 'as HTML' do
+          context 'with HTML' do
             let(:format) { 'html' }
 
             it 'returns http not found' do
@@ -609,7 +609,7 @@ describe StatusesController do
             get :show, params: { account_username: status.account.username, id: status.id, format: format }
           end
 
-          context 'as HTML' do
+          context 'with HTML' do
             let(:format) { 'html' }
 
             it 'returns http success' do
@@ -634,7 +634,7 @@ describe StatusesController do
             end
           end
 
-          context 'as JSON' do
+          context 'with JSON' do
             let(:format) { 'json' }
 
             it 'returns http success' do
@@ -669,7 +669,7 @@ describe StatusesController do
             get :show, params: { account_username: status.account.username, id: status.id, format: format }
           end
 
-          context 'as JSON' do
+          context 'with JSON' do
             let(:format) { 'json' }
 
             it 'returns http not found' do
@@ -677,7 +677,7 @@ describe StatusesController do
             end
           end
 
-          context 'as HTML' do
+          context 'with HTML' do
             let(:format) { 'html' }
 
             it 'returns http not found' do
diff --git a/spec/controllers/tags_controller_spec.rb b/spec/controllers/tags_controller_spec.rb
index 7a07801be7..d41e707d43 100644
--- a/spec/controllers/tags_controller_spec.rb
+++ b/spec/controllers/tags_controller_spec.rb
@@ -2,7 +2,7 @@
 
 require 'rails_helper'
 
-RSpec.describe TagsController, type: :controller do
+RSpec.describe TagsController do
   render_views
 
   describe 'GET #show' do
diff --git a/spec/controllers/well_known/host_meta_controller_spec.rb b/spec/controllers/well_known/host_meta_controller_spec.rb
index d537043708..4bd161cd9d 100644
--- a/spec/controllers/well_known/host_meta_controller_spec.rb
+++ b/spec/controllers/well_known/host_meta_controller_spec.rb
@@ -2,7 +2,7 @@
 
 require 'rails_helper'
 
-describe WellKnown::HostMetaController, type: :controller do
+describe WellKnown::HostMetaController do
   render_views
 
   describe 'GET #show' do
diff --git a/spec/controllers/well_known/nodeinfo_controller_spec.rb b/spec/controllers/well_known/nodeinfo_controller_spec.rb
index f5cde150da..6ec34afd04 100644
--- a/spec/controllers/well_known/nodeinfo_controller_spec.rb
+++ b/spec/controllers/well_known/nodeinfo_controller_spec.rb
@@ -2,7 +2,7 @@
 
 require 'rails_helper'
 
-describe WellKnown::NodeInfoController, type: :controller do
+describe WellKnown::NodeInfoController do
   render_views
 
   describe 'GET #index' do
diff --git a/spec/controllers/well_known/webfinger_controller_spec.rb b/spec/controllers/well_known/webfinger_controller_spec.rb
index 00103df706..8dc0f329b6 100644
--- a/spec/controllers/well_known/webfinger_controller_spec.rb
+++ b/spec/controllers/well_known/webfinger_controller_spec.rb
@@ -2,11 +2,11 @@
 
 require 'rails_helper'
 
-describe WellKnown::WebfingerController, type: :controller do
+describe WellKnown::WebfingerController do
   render_views
 
   describe 'GET #show' do
-    subject do
+    subject(:perform_show!) do
       get :show, params: { resource: resource }, format: :json
     end
 
@@ -45,7 +45,7 @@ describe WellKnown::WebfingerController, type: :controller do
       let(:resource) { alice.to_webfinger_s }
 
       before do
-        subject
+        perform_show!
       end
 
       it_behaves_like 'a successful response'
@@ -56,7 +56,7 @@ describe WellKnown::WebfingerController, type: :controller do
 
       before do
         alice.suspend!
-        subject
+        perform_show!
       end
 
       it_behaves_like 'a successful response'
@@ -68,7 +68,7 @@ describe WellKnown::WebfingerController, type: :controller do
       before do
         alice.suspend!
         alice.deletion_request.destroy
-        subject
+        perform_show!
       end
 
       it 'returns http gone' do
@@ -80,7 +80,7 @@ describe WellKnown::WebfingerController, type: :controller do
       let(:resource) { 'acct:not@existing.com' }
 
       before do
-        subject
+        perform_show!
       end
 
       it 'returns http not found' do
@@ -92,7 +92,7 @@ describe WellKnown::WebfingerController, type: :controller do
       let(:alternate_domains) { ['foo.org'] }
 
       before do
-        subject
+        perform_show!
       end
 
       context 'when an account exists' do
@@ -116,11 +116,39 @@ describe WellKnown::WebfingerController, type: :controller do
       end
     end
 
+    context 'when the old name scheme is used to query the instance actor' do
+      let(:resource) do
+        "#{Rails.configuration.x.local_domain}@#{Rails.configuration.x.local_domain}"
+      end
+
+      before do
+        perform_show!
+      end
+
+      it 'returns http success' do
+        expect(response).to have_http_status(200)
+      end
+
+      it 'does not set a Vary header' do
+        expect(response.headers['Vary']).to be_nil
+      end
+
+      it 'returns application/jrd+json' do
+        expect(response.media_type).to eq 'application/jrd+json'
+      end
+
+      it 'returns links for the internal account' do
+        json = body_as_json
+        expect(json[:subject]).to eq 'acct:mastodon.internal@cb6e6126.ngrok.io'
+        expect(json[:aliases]).to eq ['https://cb6e6126.ngrok.io/actor']
+      end
+    end
+
     context 'with no resource parameter' do
       let(:resource) { nil }
 
       before do
-        subject
+        perform_show!
       end
 
       it 'returns http bad request' do
@@ -132,7 +160,7 @@ describe WellKnown::WebfingerController, type: :controller do
       let(:resource) { 'df/:dfkj' }
 
       before do
-        subject
+        perform_show!
       end
 
       it 'returns http bad request' do
diff --git a/spec/fabricators/bulk_import_fabricator.rb b/spec/fabricators/bulk_import_fabricator.rb
new file mode 100644
index 0000000000..673b7960d9
--- /dev/null
+++ b/spec/fabricators/bulk_import_fabricator.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+Fabricator(:bulk_import) do
+  type            1
+  state           1
+  total_items     1
+  processed_items 1
+  imported_items  1
+  finished_at     '2022-11-18 14:55:07'
+  overwrite       false
+  account
+end
diff --git a/spec/fabricators/bulk_import_row_fabricator.rb b/spec/fabricators/bulk_import_row_fabricator.rb
new file mode 100644
index 0000000000..f8358e734d
--- /dev/null
+++ b/spec/fabricators/bulk_import_row_fabricator.rb
@@ -0,0 +1,6 @@
+# frozen_string_literal: true
+
+Fabricator(:bulk_import_row) do
+  bulk_import
+  data ''
+end
diff --git a/spec/fixtures/files/empty.csv b/spec/fixtures/files/empty.csv
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/spec/fixtures/files/following_accounts.csv b/spec/fixtures/files/following_accounts.csv
new file mode 100644
index 0000000000..c7917443f8
--- /dev/null
+++ b/spec/fixtures/files/following_accounts.csv
@@ -0,0 +1,5 @@
+Account address,Show boosts,Notify on new posts,Languages
+
+user@example.com,true,false,
+
+user@test.com,true,true,"en,fr"
diff --git a/spec/fixtures/files/muted_accounts.csv b/spec/fixtures/files/muted_accounts.csv
new file mode 100644
index 0000000000..66f4315bce
--- /dev/null
+++ b/spec/fixtures/files/muted_accounts.csv
@@ -0,0 +1,5 @@
+Account address,Hide notifications
+
+user@example.com,true
+
+user@test.com,false
diff --git a/spec/helpers/accounts_helper_spec.rb b/spec/helpers/accounts_helper_spec.rb
index 184b47dec6..2c949cde69 100644
--- a/spec/helpers/accounts_helper_spec.rb
+++ b/spec/helpers/accounts_helper_spec.rb
@@ -2,7 +2,7 @@
 
 require 'rails_helper'
 
-RSpec.describe AccountsHelper, type: :helper do
+RSpec.describe AccountsHelper do
   def set_not_embedded_view
     params[:controller] = "not_#{StatusesHelper::EMBEDDED_CONTROLLER}"
     params[:action] = "not_#{StatusesHelper::EMBEDDED_ACTION}"
diff --git a/spec/helpers/admin/account_moderation_notes_helper_spec.rb b/spec/helpers/admin/account_moderation_notes_helper_spec.rb
index e01eba51da..6386f07ac9 100644
--- a/spec/helpers/admin/account_moderation_notes_helper_spec.rb
+++ b/spec/helpers/admin/account_moderation_notes_helper_spec.rb
@@ -2,11 +2,11 @@
 
 require 'rails_helper'
 
-RSpec.describe Admin::AccountModerationNotesHelper, type: :helper do
+RSpec.describe Admin::AccountModerationNotesHelper do
   include AccountsHelper
 
   describe '#admin_account_link_to' do
-    context 'account is nil' do
+    context 'when Account is nil' do
       let(:account) { nil }
 
       it 'returns nil' do
@@ -30,7 +30,7 @@ RSpec.describe Admin::AccountModerationNotesHelper, type: :helper do
   end
 
   describe '#admin_account_inline_link_to' do
-    context 'account is nil' do
+    context 'when Account is nil' do
       let(:account) { nil }
 
       it 'returns nil' do
diff --git a/spec/helpers/admin/action_logs_helper_spec.rb b/spec/helpers/admin/action_logs_helper_spec.rb
index 9d7ed4ab76..4e9d08f09d 100644
--- a/spec/helpers/admin/action_logs_helper_spec.rb
+++ b/spec/helpers/admin/action_logs_helper_spec.rb
@@ -2,5 +2,5 @@
 
 require 'rails_helper'
 
-RSpec.describe Admin::ActionLogsHelper, type: :helper do
+RSpec.describe Admin::ActionLogsHelper do
 end
diff --git a/spec/helpers/application_helper_spec.rb b/spec/helpers/application_helper_spec.rb
index 5e2c4f7422..8bfca078de 100644
--- a/spec/helpers/application_helper_spec.rb
+++ b/spec/helpers/application_helper_spec.rb
@@ -124,6 +124,164 @@ describe ApplicationHelper do
     end
   end
 
+  describe 'available_sign_up_path' do
+    context 'when registrations are closed' do
+      before do
+        without_partial_double_verification do
+          allow(Setting).to receive(:registrations_mode).and_return('none')
+        end
+      end
+
+      it 'redirects to joinmastodon site' do
+        expect(helper.available_sign_up_path).to match(/joinmastodon.org/)
+      end
+    end
+
+    context 'when in omniauth only mode' do
+      around do |example|
+        ClimateControl.modify OMNIAUTH_ONLY: 'true' do
+          example.run
+        end
+      end
+
+      it 'redirects to joinmastodon site' do
+        expect(helper.available_sign_up_path).to match(/joinmastodon.org/)
+      end
+    end
+
+    context 'when registrations are allowed' do
+      it 'returns a link to the registration page' do
+        expect(helper.available_sign_up_path).to eq(new_user_registration_path)
+      end
+    end
+  end
+
+  describe 'omniauth_only?' do
+    context 'when env var is set to true' do
+      around do |example|
+        ClimateControl.modify OMNIAUTH_ONLY: 'true' do
+          example.run
+        end
+      end
+
+      it 'returns true' do
+        expect(helper).to be_omniauth_only
+      end
+    end
+
+    context 'when env var is not set' do
+      around do |example|
+        ClimateControl.modify OMNIAUTH_ONLY: nil do
+          example.run
+        end
+      end
+
+      it 'returns false' do
+        expect(helper).to_not be_omniauth_only
+      end
+    end
+  end
+
+  describe 'quote_wrap' do
+    it 'indents and quote wraps text' do
+      text = <<~TEXT
+        Hello this is a nice message for you to quote.
+        Be careful because it has two lines.
+      TEXT
+
+      expect(helper.quote_wrap(text)).to eq <<~EXPECTED.strip
+        > Hello this is a nice message for you to quote.
+        > Be careful because it has two lines.
+      EXPECTED
+    end
+  end
+
+  describe 'storage_host' do
+    context 'when S3 alias is present' do
+      around do |example|
+        ClimateControl.modify S3_ALIAS_HOST: 's3.alias' do
+          example.run
+        end
+      end
+
+      it 'returns true' do
+        expect(helper.storage_host).to eq('https://s3.alias')
+      end
+    end
+
+    context 'when S3 cloudfront is present' do
+      around do |example|
+        ClimateControl.modify S3_CLOUDFRONT_HOST: 's3.cloudfront' do
+          example.run
+        end
+      end
+
+      it 'returns true' do
+        expect(helper.storage_host).to eq('https://s3.cloudfront')
+      end
+    end
+
+    context 'when neither env value is present' do
+      it 'returns false' do
+        expect(helper.storage_host).to eq('https:')
+      end
+    end
+  end
+
+  describe 'storage_host?' do
+    context 'when S3 alias is present' do
+      around do |example|
+        ClimateControl.modify S3_ALIAS_HOST: 's3.alias' do
+          example.run
+        end
+      end
+
+      it 'returns true' do
+        expect(helper.storage_host?).to be true
+      end
+    end
+
+    context 'when S3 cloudfront is present' do
+      around do |example|
+        ClimateControl.modify S3_CLOUDFRONT_HOST: 's3.cloudfront' do
+          example.run
+        end
+      end
+
+      it 'returns true' do
+        expect(helper.storage_host?).to be true
+      end
+    end
+
+    context 'when neither env value is present' do
+      it 'returns false' do
+        expect(helper.storage_host?).to be false
+      end
+    end
+  end
+
+  describe 'visibility_icon' do
+    it 'returns a globe icon for a public visible status' do
+      result = helper.visibility_icon Status.new(visibility: 'public')
+      expect(result).to match(/globe/)
+    end
+
+    it 'returns an unlock icon for a unlisted visible status' do
+      result = helper.visibility_icon Status.new(visibility: 'unlisted')
+      expect(result).to match(/unlock/)
+    end
+
+    it 'returns a lock icon for a private visible status' do
+      result = helper.visibility_icon Status.new(visibility: 'private')
+      expect(result).to match(/lock/)
+    end
+
+    it 'returns an at icon for a direct visible status' do
+      result = helper.visibility_icon Status.new(visibility: 'direct')
+      expect(result).to match(/at/)
+    end
+  end
+
   describe 'title' do
     around do |example|
       site_title = Setting.site_title
diff --git a/spec/helpers/flashes_helper_spec.rb b/spec/helpers/flashes_helper_spec.rb
index ea143eed72..035e8a1de0 100644
--- a/spec/helpers/flashes_helper_spec.rb
+++ b/spec/helpers/flashes_helper_spec.rb
@@ -2,7 +2,7 @@
 
 require 'rails_helper'
 
-describe FlashesHelper, type: :helper do
+describe FlashesHelper do
   describe 'user_facing_flashes' do
     it 'returns user facing flashes' do
       flash[:alert] = 'an alert'
diff --git a/spec/helpers/formatting_helper_spec.rb b/spec/helpers/formatting_helper_spec.rb
index af604a87b5..d6e7631f66 100644
--- a/spec/helpers/formatting_helper_spec.rb
+++ b/spec/helpers/formatting_helper_spec.rb
@@ -2,7 +2,7 @@
 
 require 'rails_helper'
 
-describe FormattingHelper, type: :helper do
+describe FormattingHelper do
   include Devise::Test::ControllerHelpers
 
   describe '#rss_status_content_format' do
diff --git a/spec/helpers/home_helper_spec.rb b/spec/helpers/home_helper_spec.rb
index 3d2c5fe248..15067471ed 100644
--- a/spec/helpers/home_helper_spec.rb
+++ b/spec/helpers/home_helper_spec.rb
@@ -2,7 +2,7 @@
 
 require 'rails_helper'
 
-RSpec.describe HomeHelper, type: :helper do
+RSpec.describe HomeHelper do
   describe 'default_props' do
     it 'returns default properties according to the context' do
       expect(helper.default_props).to eq locale: I18n.locale
diff --git a/spec/helpers/jsonld_helper_spec.rb b/spec/helpers/jsonld_helper_spec.rb
index ddd4bfe629..3575bba859 100644
--- a/spec/helpers/jsonld_helper_spec.rb
+++ b/spec/helpers/jsonld_helper_spec.rb
@@ -22,14 +22,14 @@ describe JsonLdHelper do
   end
 
   describe '#first_of_value' do
-    context 'value.is_a?(Array)' do
+    context 'when value.is_a?(Array)' do
       it 'returns value.first' do
         value = ['a']
         expect(helper.first_of_value(value)).to be 'a'
       end
     end
 
-    context '!value.is_a?(Array)' do
+    context 'with !value.is_a?(Array)' do
       it 'returns value' do
         value = 'a'
         expect(helper.first_of_value(value)).to be 'a'
@@ -38,14 +38,14 @@ describe JsonLdHelper do
   end
 
   describe '#supported_context?' do
-    context "!json.nil? && equals_or_includes?(json['@context'], ActivityPub::TagManager::CONTEXT)" do
+    context 'when json is present and in an activitypub tagmanager context' do
       it 'returns true' do
         json = { '@context' => ActivityPub::TagManager::CONTEXT }.as_json
         expect(helper.supported_context?(json)).to be true
       end
     end
 
-    context 'else' do
+    context 'when not in activitypub tagmanager context' do
       it 'returns false' do
         json = nil
         expect(helper.supported_context?(json)).to be false
@@ -90,7 +90,7 @@ describe JsonLdHelper do
     end
   end
 
-  context 'compaction and forwarding' do
+  context 'with compaction and forwarding' do
     let(:json) do
       {
         '@context' => [
diff --git a/spec/helpers/routing_helper_spec.rb b/spec/helpers/routing_helper_spec.rb
index 940392c9b0..852d02cebc 100644
--- a/spec/helpers/routing_helper_spec.rb
+++ b/spec/helpers/routing_helper_spec.rb
@@ -2,7 +2,7 @@
 
 require 'rails_helper'
 
-RSpec.describe RoutingHelper, type: :helper do
+RSpec.describe RoutingHelper do
   describe '.full_asset_url' do
     around do |example|
       use_s3 = Rails.configuration.x.use_s3
@@ -24,7 +24,7 @@ RSpec.describe RoutingHelper, type: :helper do
       end
     end
 
-    context 'Do not use S3' do
+    context 'when not using S3' do
       before do
         Rails.configuration.x.use_s3 = false
       end
@@ -32,7 +32,7 @@ RSpec.describe RoutingHelper, type: :helper do
       it_behaves_like 'returns full path URL'
     end
 
-    context 'Use S3' do
+    context 'when using S3' do
       before do
         Rails.configuration.x.use_s3 = true
       end
diff --git a/spec/lib/activitypub/activity/accept_spec.rb b/spec/lib/activitypub/activity/accept_spec.rb
index 890a07be54..d6b6071279 100644
--- a/spec/lib/activitypub/activity/accept_spec.rb
+++ b/spec/lib/activitypub/activity/accept_spec.rb
@@ -43,7 +43,7 @@ RSpec.describe ActivityPub::Activity::Accept do
     end
   end
 
-  context 'given a relay' do
+  context 'when given a relay' do
     subject { described_class.new(json, sender) }
 
     let!(:relay) { Fabricate(:relay, state: :pending, follow_activity_id: 'https://abc-123/456') }
diff --git a/spec/lib/activitypub/activity/announce_spec.rb b/spec/lib/activitypub/activity/announce_spec.rb
index 394b1d7b93..365861bcd8 100644
--- a/spec/lib/activitypub/activity/announce_spec.rb
+++ b/spec/lib/activitypub/activity/announce_spec.rb
@@ -39,7 +39,7 @@ RSpec.describe ActivityPub::Activity::Announce do
         subject.perform
       end
 
-      context 'a known status' do
+      context 'with known status' do
         let(:object_json) do
           ActivityPub::TagManager.instance.uri_for(status)
         end
@@ -49,7 +49,7 @@ RSpec.describe ActivityPub::Activity::Announce do
         end
       end
 
-      context 'an unknown status' do
+      context 'with unknown status' do
         let(:object_json) { 'https://example.com/actor/hello-world' }
 
         it 'creates a reblog by sender of status' do
@@ -60,7 +60,7 @@ RSpec.describe ActivityPub::Activity::Announce do
         end
       end
 
-      context 'self-boost of a previously unknown status with correct attributedTo' do
+      context 'when self-boost of a previously unknown status with correct attributedTo' do
         let(:object_json) do
           {
             id: 'https://example.com/actor#bar',
@@ -76,7 +76,7 @@ RSpec.describe ActivityPub::Activity::Announce do
         end
       end
 
-      context 'self-boost of a previously unknown status with correct attributedTo, inlined Collection in audience' do
+      context 'when self-boost of a previously unknown status with correct attributedTo, inlined Collection in audience' do
         let(:object_json) do
           {
             id: 'https://example.com/actor#bar',
@@ -123,7 +123,7 @@ RSpec.describe ActivityPub::Activity::Announce do
         stub_request(:get, 'https://example.com/actor/hello-world').to_return(body: Oj.dump(unknown_object_json))
       end
 
-      context 'and the relay is enabled' do
+      context 'when the relay is enabled' do
         before do
           relay.update(state: :accepted)
           subject.perform
@@ -135,7 +135,7 @@ RSpec.describe ActivityPub::Activity::Announce do
         end
       end
 
-      context 'and the relay is disabled' do
+      context 'when the relay is disabled' do
         before do
           subject.perform
         end
diff --git a/spec/lib/activitypub/activity/create_spec.rb b/spec/lib/activitypub/activity/create_spec.rb
index 1226cfd8ef..d7c4c131a2 100644
--- a/spec/lib/activitypub/activity/create_spec.rb
+++ b/spec/lib/activitypub/activity/create_spec.rb
@@ -31,7 +31,7 @@ RSpec.describe ActivityPub::Activity::Create do
         subject.perform
       end
 
-      context 'object has been edited' do
+      context 'when object has been edited' do
         let(:object_json) do
           {
             id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join,
@@ -57,7 +57,7 @@ RSpec.describe ActivityPub::Activity::Create do
         end
       end
 
-      context 'object has update date equal to creation date' do
+      context 'when object has update date equal to creation date' do
         let(:object_json) do
           {
             id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join,
@@ -83,7 +83,7 @@ RSpec.describe ActivityPub::Activity::Create do
         end
       end
 
-      context 'unknown object type' do
+      context 'with an unknown object type' do
         let(:object_json) do
           {
             id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join,
@@ -97,7 +97,7 @@ RSpec.describe ActivityPub::Activity::Create do
         end
       end
 
-      context 'standalone' do
+      context 'with a standalone' do
         let(:object_json) do
           {
             id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join,
@@ -121,7 +121,7 @@ RSpec.describe ActivityPub::Activity::Create do
         end
       end
 
-      context 'public with explicit public address' do
+      context 'when public with explicit public address' do
         let(:object_json) do
           {
             id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join,
@@ -139,7 +139,7 @@ RSpec.describe ActivityPub::Activity::Create do
         end
       end
 
-      context 'public with as:Public' do
+      context 'when public with as:Public' do
         let(:object_json) do
           {
             id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join,
@@ -157,7 +157,7 @@ RSpec.describe ActivityPub::Activity::Create do
         end
       end
 
-      context 'public with Public' do
+      context 'when public with Public' do
         let(:object_json) do
           {
             id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join,
@@ -175,7 +175,7 @@ RSpec.describe ActivityPub::Activity::Create do
         end
       end
 
-      context 'unlisted with explicit public address' do
+      context 'when unlisted with explicit public address' do
         let(:object_json) do
           {
             id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join,
@@ -193,7 +193,7 @@ RSpec.describe ActivityPub::Activity::Create do
         end
       end
 
-      context 'unlisted with as:Public' do
+      context 'when unlisted with as:Public' do
         let(:object_json) do
           {
             id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join,
@@ -211,7 +211,7 @@ RSpec.describe ActivityPub::Activity::Create do
         end
       end
 
-      context 'unlisted with Public' do
+      context 'when unlisted with Public' do
         let(:object_json) do
           {
             id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join,
@@ -229,7 +229,7 @@ RSpec.describe ActivityPub::Activity::Create do
         end
       end
 
-      context 'private' do
+      context 'when private' do
         let(:object_json) do
           {
             id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join,
@@ -247,7 +247,7 @@ RSpec.describe ActivityPub::Activity::Create do
         end
       end
 
-      context 'private with inlined Collection in audience' do
+      context 'when private with inlined Collection in audience' do
         let(:object_json) do
           {
             id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join,
@@ -269,7 +269,7 @@ RSpec.describe ActivityPub::Activity::Create do
         end
       end
 
-      context 'limited' do
+      context 'when limited' do
         let(:recipient) { Fabricate(:account) }
 
         let(:object_json) do
@@ -294,7 +294,7 @@ RSpec.describe ActivityPub::Activity::Create do
         end
       end
 
-      context 'limited when direct message assertion is false' do
+      context 'when directMessage attribute is false' do
         let(:recipient) { Fabricate(:account) }
 
         let(:object_json) do
@@ -311,7 +311,7 @@ RSpec.describe ActivityPub::Activity::Create do
           }
         end
 
-        it 'creates status' do
+        it 'creates status with limited visibility' do
           status = sender.statuses.first
 
           expect(status).to_not be_nil
@@ -319,7 +319,7 @@ RSpec.describe ActivityPub::Activity::Create do
         end
       end
 
-      context 'direct' do
+      context 'when direct' do
         let(:recipient) { Fabricate(:account) }
 
         let(:object_json) do
@@ -335,7 +335,7 @@ RSpec.describe ActivityPub::Activity::Create do
           }
         end
 
-        it 'creates status' do
+        it 'creates status with direct visibility' do
           status = sender.statuses.first
 
           expect(status).to_not be_nil
@@ -343,7 +343,7 @@ RSpec.describe ActivityPub::Activity::Create do
         end
       end
 
-      context 'direct when direct message assertion is true' do
+      context 'when directMessage attribute is true' do
         let(:recipient) { Fabricate(:account) }
 
         let(:object_json) do
@@ -364,7 +364,7 @@ RSpec.describe ActivityPub::Activity::Create do
         end
       end
 
-      context 'as a reply' do
+      context 'with a reply' do
         let(:original_status) { Fabricate(:status) }
 
         let(:object_json) do
diff --git a/spec/lib/activitypub/activity/follow_spec.rb b/spec/lib/activitypub/activity/follow_spec.rb
index eb8b17d615..c1829cb8d7 100644
--- a/spec/lib/activitypub/activity/follow_spec.rb
+++ b/spec/lib/activitypub/activity/follow_spec.rb
@@ -20,7 +20,7 @@ RSpec.describe ActivityPub::Activity::Follow do
     subject { described_class.new(json, sender) }
 
     context 'with no prior follow' do
-      context 'unlocked account' do
+      context 'with an unlocked account' do
         before do
           subject.perform
         end
@@ -35,7 +35,7 @@ RSpec.describe ActivityPub::Activity::Follow do
         end
       end
 
-      context 'silenced account following an unlocked account' do
+      context 'when silenced account following an unlocked account' do
         before do
           sender.touch(:silenced_at)
           subject.perform
@@ -51,7 +51,7 @@ RSpec.describe ActivityPub::Activity::Follow do
         end
       end
 
-      context 'unlocked account muting the sender' do
+      context 'with an unlocked account muting the sender' do
         before do
           recipient.mute!(sender)
           subject.perform
@@ -67,7 +67,7 @@ RSpec.describe ActivityPub::Activity::Follow do
         end
       end
 
-      context 'locked account' do
+      context 'when locked account' do
         before do
           recipient.update(locked: true)
           subject.perform
@@ -89,7 +89,7 @@ RSpec.describe ActivityPub::Activity::Follow do
         sender.active_relationships.create!(target_account: recipient, uri: 'bar')
       end
 
-      context 'unlocked account' do
+      context 'with an unlocked account' do
         before do
           subject.perform
         end
@@ -103,7 +103,7 @@ RSpec.describe ActivityPub::Activity::Follow do
         end
       end
 
-      context 'silenced account following an unlocked account' do
+      context 'when silenced account following an unlocked account' do
         before do
           sender.touch(:silenced_at)
           subject.perform
@@ -118,7 +118,7 @@ RSpec.describe ActivityPub::Activity::Follow do
         end
       end
 
-      context 'unlocked account muting the sender' do
+      context 'with an unlocked account muting the sender' do
         before do
           recipient.mute!(sender)
           subject.perform
@@ -133,7 +133,7 @@ RSpec.describe ActivityPub::Activity::Follow do
         end
       end
 
-      context 'locked account' do
+      context 'when locked account' do
         before do
           recipient.update(locked: true)
           subject.perform
@@ -154,7 +154,7 @@ RSpec.describe ActivityPub::Activity::Follow do
         sender.follow_requests.create!(target_account: recipient, uri: 'bar')
       end
 
-      context 'silenced account following an unlocked account' do
+      context 'when silenced account following an unlocked account' do
         before do
           sender.touch(:silenced_at)
           subject.perform
@@ -170,7 +170,7 @@ RSpec.describe ActivityPub::Activity::Follow do
         end
       end
 
-      context 'locked account' do
+      context 'when locked account' do
         before do
           recipient.update(locked: true)
           subject.perform
diff --git a/spec/lib/activitypub/activity/reject_spec.rb b/spec/lib/activitypub/activity/reject_spec.rb
index 5e0f09bfe8..0a4243cd16 100644
--- a/spec/lib/activitypub/activity/reject_spec.rb
+++ b/spec/lib/activitypub/activity/reject_spec.rb
@@ -27,7 +27,7 @@ RSpec.describe ActivityPub::Activity::Reject do
   describe '#perform' do
     subject { described_class.new(json, sender) }
 
-    context 'rejecting a pending follow request by target' do
+    context 'when rejecting a pending follow request by target' do
       before do
         Fabricate(:follow_request, account: recipient, target_account: sender)
         subject.perform
@@ -42,7 +42,7 @@ RSpec.describe ActivityPub::Activity::Reject do
       end
     end
 
-    context 'rejecting a pending follow request by uri' do
+    context 'when rejecting a pending follow request by uri' do
       before do
         Fabricate(:follow_request, account: recipient, target_account: sender, uri: 'bar')
         subject.perform
@@ -57,7 +57,7 @@ RSpec.describe ActivityPub::Activity::Reject do
       end
     end
 
-    context 'rejecting a pending follow request by uri only' do
+    context 'when rejecting a pending follow request by uri only' do
       let(:object_json) { 'bar' }
 
       before do
@@ -74,7 +74,7 @@ RSpec.describe ActivityPub::Activity::Reject do
       end
     end
 
-    context 'rejecting an existing follow relationship by target' do
+    context 'when rejecting an existing follow relationship by target' do
       before do
         Fabricate(:follow, account: recipient, target_account: sender)
         subject.perform
@@ -89,7 +89,7 @@ RSpec.describe ActivityPub::Activity::Reject do
       end
     end
 
-    context 'rejecting an existing follow relationship by uri' do
+    context 'when rejecting an existing follow relationship by uri' do
       before do
         Fabricate(:follow, account: recipient, target_account: sender, uri: 'bar')
         subject.perform
@@ -104,7 +104,7 @@ RSpec.describe ActivityPub::Activity::Reject do
       end
     end
 
-    context 'rejecting an existing follow relationship by uri only' do
+    context 'when rejecting an existing follow relationship by uri only' do
       let(:object_json) { 'bar' }
 
       before do
@@ -122,7 +122,7 @@ RSpec.describe ActivityPub::Activity::Reject do
     end
   end
 
-  context 'given a relay' do
+  context 'when given a relay' do
     subject { described_class.new(json, sender) }
 
     let!(:relay) { Fabricate(:relay, state: :pending, follow_activity_id: 'https://abc-123/456') }
diff --git a/spec/lib/activitypub/adapter_spec.rb b/spec/lib/activitypub/adapter_spec.rb
index b981ea9c68..f9f8b8dce0 100644
--- a/spec/lib/activitypub/adapter_spec.rb
+++ b/spec/lib/activitypub/adapter_spec.rb
@@ -3,43 +3,51 @@
 require 'rails_helper'
 
 RSpec.describe ActivityPub::Adapter do
-  class TestObject < ActiveModelSerializers::Model
-    attributes :foo
-  end
-
-  class TestWithBasicContextSerializer < ActivityPub::Serializer
-    attributes :foo
-  end
-
-  class TestWithNamedContextSerializer < ActivityPub::Serializer
-    context :security
-    attributes :foo
-  end
-
-  class TestWithNestedNamedContextSerializer < ActivityPub::Serializer
-    attributes :foo
-
-    has_one :virtual_object, key: :baz, serializer: TestWithNamedContextSerializer
-
-    def virtual_object
-      object
+  before do
+    test_object_class = Class.new(ActiveModelSerializers::Model) do
+      attributes :foo
     end
-  end
+    stub_const('TestObject', test_object_class)
 
-  class TestWithContextExtensionSerializer < ActivityPub::Serializer
-    context_extensions :sensitive
-    attributes :foo
-  end
-
-  class TestWithNestedContextExtensionSerializer < ActivityPub::Serializer
-    context_extensions :manually_approves_followers
-    attributes :foo
-
-    has_one :virtual_object, key: :baz, serializer: TestWithContextExtensionSerializer
-
-    def virtual_object
-      object
+    test_with_basic_context_serializer = Class.new(ActivityPub::Serializer) do
+      attributes :foo
     end
+    stub_const('TestWithBasicContextSerializer', test_with_basic_context_serializer)
+
+    test_with_named_context_serializer = Class.new(ActivityPub::Serializer) do
+      context :security
+      attributes :foo
+    end
+    stub_const('TestWithNamedContextSerializer', test_with_named_context_serializer)
+
+    test_with_nested_named_context_serializer = Class.new(ActivityPub::Serializer) do
+      attributes :foo
+
+      has_one :virtual_object, key: :baz, serializer: TestWithNamedContextSerializer
+
+      def virtual_object
+        object
+      end
+    end
+    stub_const('TestWithNestedNamedContextSerializer', test_with_nested_named_context_serializer)
+
+    test_with_context_extension_serializer = Class.new(ActivityPub::Serializer) do
+      context_extensions :sensitive
+      attributes :foo
+    end
+    stub_const('TestWithContextExtensionSerializer', test_with_context_extension_serializer)
+
+    test_with_nested_context_extension_serializer = Class.new(ActivityPub::Serializer) do
+      context_extensions :manually_approves_followers
+      attributes :foo
+
+      has_one :virtual_object, key: :baz, serializer: TestWithContextExtensionSerializer
+
+      def virtual_object
+        object
+      end
+    end
+    stub_const('TestWithNestedContextExtensionSerializer', test_with_nested_context_extension_serializer)
   end
 
   describe '#serializable_hash' do
diff --git a/spec/lib/advanced_text_formatter_spec.rb b/spec/lib/advanced_text_formatter_spec.rb
index 8b27b56a15..f923852196 100644
--- a/spec/lib/advanced_text_formatter_spec.rb
+++ b/spec/lib/advanced_text_formatter_spec.rb
@@ -9,10 +9,10 @@ RSpec.describe AdvancedTextFormatter do
     let(:preloaded_accounts) { nil }
     let(:content_type) { 'text/markdown' }
 
-    context 'given a markdown source' do
+    context 'with a markdown source' do
       let(:content_type) { 'text/markdown' }
 
-      context 'given text containing plain text' do
+      context 'with text containing plain text' do
         let(:text) { 'text' }
 
         it 'paragraphizes the text' do
@@ -20,7 +20,7 @@ RSpec.describe AdvancedTextFormatter do
         end
       end
 
-      context 'given text containing line feeds' do
+      context 'with text containing line feeds' do
         let(:text) { "line\nfeed" }
 
         it 'removes line feeds' do
@@ -28,7 +28,7 @@ RSpec.describe AdvancedTextFormatter do
         end
       end
 
-      context 'given some inline code using backticks' do
+      context 'with some inline code using backticks' do
         let(:text) { 'test `foo` bar' }
 
         it 'formats code using <code>' do
@@ -36,7 +36,7 @@ RSpec.describe AdvancedTextFormatter do
         end
       end
 
-      context 'given a block code' do
+      context 'with a block code' do
         let(:text) { "test\n\n```\nint main(void) {\n  return 0; // https://joinmastodon.org/foo\n}\n```\n" }
 
         it 'formats code using <pre> and <code>' do
@@ -52,7 +52,7 @@ RSpec.describe AdvancedTextFormatter do
         end
       end
 
-      context 'given a link in inline code using backticks' do
+      context 'with a link in inline code using backticks' do
         let(:text) { 'test `https://foo.bar/bar` bar' }
 
         it 'does not rewrite the link' do
@@ -60,7 +60,7 @@ RSpec.describe AdvancedTextFormatter do
         end
       end
 
-      context 'given text with a local-domain mention' do
+      context 'with text with a local-domain mention' do
         let(:text) { 'foo https://cb6e6126.ngrok.io/about/more' }
 
         it 'creates a link' do
@@ -68,7 +68,7 @@ RSpec.describe AdvancedTextFormatter do
         end
       end
 
-      context 'given text containing linkable mentions' do
+      context 'with text containing linkable mentions' do
         let(:preloaded_accounts) { [Fabricate(:account, username: 'alice')] }
         let(:text) { '@alice' }
 
@@ -77,7 +77,7 @@ RSpec.describe AdvancedTextFormatter do
         end
       end
 
-      context 'given text containing unlinkable mentions' do
+      context 'with text containing unlinkable mentions' do
         let(:preloaded_accounts) { [] }
         let(:text) { '@alice' }
 
@@ -86,7 +86,7 @@ RSpec.describe AdvancedTextFormatter do
         end
       end
 
-      context 'given a stand-alone medium URL' do
+      context 'with a stand-alone medium URL' do
         let(:text) { 'https://hackernoon.com/the-power-to-build-communities-a-response-to-mark-zuckerberg-3f2cac9148a4' }
 
         it 'matches the full URL' do
@@ -94,7 +94,7 @@ RSpec.describe AdvancedTextFormatter do
         end
       end
 
-      context 'given a stand-alone google URL' do
+      context 'with a stand-alone google URL' do
         let(:text) { 'http://google.com' }
 
         it 'matches the full URL' do
@@ -102,7 +102,7 @@ RSpec.describe AdvancedTextFormatter do
         end
       end
 
-      context 'given a stand-alone URL with a newer TLD' do
+      context 'with a stand-alone URL with a newer TLD' do
         let(:text) { 'http://example.gay' }
 
         it 'matches the full URL' do
@@ -110,7 +110,7 @@ RSpec.describe AdvancedTextFormatter do
         end
       end
 
-      context 'given a stand-alone IDN URL' do
+      context 'with a stand-alone IDN URL' do
         let(:text) { 'https://nic.みんな/' }
 
         it 'matches the full URL' do
@@ -122,7 +122,7 @@ RSpec.describe AdvancedTextFormatter do
         end
       end
 
-      context 'given a URL with a trailing period' do
+      context 'with a URL with a trailing period' do
         let(:text) { 'http://www.mcmansionhell.com/post/156408871451/50-states-of-mcmansion-hell-scottsdale-arizona. ' }
 
         it 'matches the full URL but not the period' do
@@ -130,7 +130,7 @@ RSpec.describe AdvancedTextFormatter do
         end
       end
 
-      context 'given a URL enclosed with parentheses' do
+      context 'with a URL enclosed with parentheses' do
         let(:text) { '(http://google.com/)' }
 
         it 'matches the full URL but not the parentheses' do
@@ -138,7 +138,7 @@ RSpec.describe AdvancedTextFormatter do
         end
       end
 
-      context 'given a URL with a trailing exclamation point' do
+      context 'with a URL with a trailing exclamation point' do
         let(:text) { 'http://www.google.com!' }
 
         it 'matches the full URL but not the exclamation point' do
@@ -146,7 +146,7 @@ RSpec.describe AdvancedTextFormatter do
         end
       end
 
-      context 'given a URL with a trailing single quote' do
+      context 'with a URL with a trailing single quote' do
         let(:text) { "http://www.google.com'" }
 
         it 'matches the full URL but not the single quote' do
@@ -155,7 +155,7 @@ RSpec.describe AdvancedTextFormatter do
       end
     end
 
-    context 'given a URL with a trailing angle bracket' do
+    context 'with a URL with a trailing angle bracket' do
       let(:text) { 'http://www.google.com>' }
 
       it 'matches the full URL but not the angle bracket' do
@@ -163,7 +163,7 @@ RSpec.describe AdvancedTextFormatter do
       end
     end
 
-    context 'given a URL with a query string' do
+    context 'with a URL with a query string' do
       context 'with escaped unicode character' do
         let(:text) { 'https://www.ruby-toolbox.com/search?utf8=%E2%9C%93&q=autolink' }
 
@@ -196,7 +196,7 @@ RSpec.describe AdvancedTextFormatter do
         end
       end
 
-      context 'given a URL with parentheses in it' do
+      context 'with a URL with parentheses in it' do
         let(:text) { 'https://en.wikipedia.org/wiki/Diaspora_(software)' }
 
         it 'matches the full URL' do
@@ -204,7 +204,7 @@ RSpec.describe AdvancedTextFormatter do
         end
       end
 
-      context 'given a URL in quotation marks' do
+      context 'with a URL in quotation marks' do
         let(:text) { '"https://example.com/"' }
 
         it 'does not match the quotation marks' do
@@ -212,7 +212,7 @@ RSpec.describe AdvancedTextFormatter do
         end
       end
 
-      context 'given a URL in angle brackets' do
+      context 'with a URL in angle brackets' do
         let(:text) { '<https://example.com/>' }
 
         it 'does not match the angle brackets' do
@@ -220,7 +220,7 @@ RSpec.describe AdvancedTextFormatter do
         end
       end
 
-      context 'given a URL containing unsafe code (XSS attack, invisible part)' do
+      context 'with a URL containing unsafe code (XSS attack, invisible part)' do
         let(:text) { 'http://example.com/blahblahblahblah/a<script>alert("Hello")</script>' }
 
         it 'does not include the HTML in the URL' do
@@ -232,7 +232,7 @@ RSpec.describe AdvancedTextFormatter do
         end
       end
 
-      context 'given text containing HTML code (script tag)' do
+      context 'with text containing HTML code (script tag)' do
         let(:text) { '<script>alert("Hello")</script>' }
 
         it 'does not include a script tag' do
@@ -240,7 +240,7 @@ RSpec.describe AdvancedTextFormatter do
         end
       end
 
-      context 'given text containing HTML (XSS attack)' do
+      context 'with text containing HTML (XSS attack)' do
         let(:text) { %q{<img src="javascript:alert('XSS');">} }
 
         it 'does not include the javascript' do
@@ -248,7 +248,7 @@ RSpec.describe AdvancedTextFormatter do
         end
       end
 
-      context 'given an invalid URL' do
+      context 'with an invalid URL' do
         let(:text) { 'http://www\.google\.com' }
 
         it 'outputs the raw URL' do
@@ -256,7 +256,7 @@ RSpec.describe AdvancedTextFormatter do
         end
       end
 
-      context 'given text containing a hashtag' do
+      context 'with text containing a hashtag' do
         let(:text)  { '#hashtag' }
 
         it 'creates a hashtag link' do
@@ -264,7 +264,7 @@ RSpec.describe AdvancedTextFormatter do
         end
       end
 
-      context 'given text containing a hashtag with Unicode chars' do
+      context 'with text containing a hashtag with Unicode chars' do
         let(:text)  { '#hashtagタグ' }
 
         it 'creates a hashtag link' do
@@ -272,7 +272,7 @@ RSpec.describe AdvancedTextFormatter do
         end
       end
 
-      context 'given text with a stand-alone xmpp: URI' do
+      context 'with text with a stand-alone xmpp: URI' do
         let(:text) { 'xmpp:user@instance.com' }
 
         it 'matches the full URI' do
@@ -280,7 +280,7 @@ RSpec.describe AdvancedTextFormatter do
         end
       end
 
-      context 'given text with an xmpp: URI with a query-string' do
+      context 'with text with an xmpp: URI with a query-string' do
         let(:text) { 'please join xmpp:muc@instance.com?join right now' }
 
         it 'matches the full URI' do
@@ -288,7 +288,7 @@ RSpec.describe AdvancedTextFormatter do
         end
       end
 
-      context 'given text containing a magnet: URI' do
+      context 'with text containing a magnet: URI' do
         let(:text) { 'wikipedia gives this example of a magnet uri: magnet:?xt=urn:btih:c12fe1c06bba254a9dc9f519b335aa7c1367a88a' }
 
         it 'matches the full URI' do
diff --git a/spec/lib/connection_pool/shared_connection_pool_spec.rb b/spec/lib/connection_pool/shared_connection_pool_spec.rb
index 1144645580..a2fe75f742 100644
--- a/spec/lib/connection_pool/shared_connection_pool_spec.rb
+++ b/spec/lib/connection_pool/shared_connection_pool_spec.rb
@@ -3,22 +3,24 @@
 require 'rails_helper'
 
 describe ConnectionPool::SharedConnectionPool do
-  class MiniConnection
-    attr_reader :site
+  subject { described_class.new(size: 5, timeout: 5) { |site| mini_connection_class.new(site) } }
 
-    def initialize(site)
-      @site = site
+  let(:mini_connection_class) do
+    Class.new do
+      attr_reader :site
+
+      def initialize(site)
+        @site = site
+      end
     end
   end
 
-  subject { described_class.new(size: 5, timeout: 5) { |site| MiniConnection.new(site) } }
-
   describe '#with' do
     it 'runs a block with a connection' do
       block_run = false
 
       subject.with('foo') do |connection|
-        expect(connection).to be_a MiniConnection
+        expect(connection).to be_a mini_connection_class
         block_run = true
       end
 
diff --git a/spec/lib/connection_pool/shared_timed_stack_spec.rb b/spec/lib/connection_pool/shared_timed_stack_spec.rb
index f680c59667..04d550eec5 100644
--- a/spec/lib/connection_pool/shared_timed_stack_spec.rb
+++ b/spec/lib/connection_pool/shared_timed_stack_spec.rb
@@ -3,30 +3,32 @@
 require 'rails_helper'
 
 describe ConnectionPool::SharedTimedStack do
-  class MiniConnection
-    attr_reader :site
+  subject { described_class.new(5) { |site| mini_connection_class.new(site) } }
 
-    def initialize(site)
-      @site = site
+  let(:mini_connection_class) do
+    Class.new do
+      attr_reader :site
+
+      def initialize(site)
+        @site = site
+      end
     end
   end
 
-  subject { described_class.new(5) { |site| MiniConnection.new(site) } }
-
   describe '#push' do
     it 'keeps the connection in the stack' do
-      subject.push(MiniConnection.new('foo'))
+      subject.push(mini_connection_class.new('foo'))
       expect(subject.size).to eq 1
     end
   end
 
   describe '#pop' do
     it 'returns a connection' do
-      expect(subject.pop('foo')).to be_a MiniConnection
+      expect(subject.pop('foo')).to be_a mini_connection_class
     end
 
     it 'returns the same connection that was pushed in' do
-      connection = MiniConnection.new('foo')
+      connection = mini_connection_class.new('foo')
       subject.push(connection)
       expect(subject.pop('foo')).to be connection
     end
@@ -36,8 +38,8 @@ describe ConnectionPool::SharedTimedStack do
     end
 
     it 'repurposes a connection for a different site when maximum amount is reached' do
-      5.times { subject.push(MiniConnection.new('foo')) }
-      expect(subject.pop('bar')).to be_a MiniConnection
+      5.times { subject.push(mini_connection_class.new('foo')) }
+      expect(subject.pop('bar')).to be_a mini_connection_class
     end
   end
 
@@ -47,14 +49,14 @@ describe ConnectionPool::SharedTimedStack do
     end
 
     it 'returns false when there are connections on the stack' do
-      subject.push(MiniConnection.new('foo'))
+      subject.push(mini_connection_class.new('foo'))
       expect(subject.empty?).to be false
     end
   end
 
   describe '#size' do
     it 'returns the number of connections on the stack' do
-      2.times { subject.push(MiniConnection.new('foo')) }
+      2.times { subject.push(mini_connection_class.new('foo')) }
       expect(subject.size).to eq 2
     end
   end
diff --git a/spec/lib/emoji_formatter_spec.rb b/spec/lib/emoji_formatter_spec.rb
index b73d5be4b9..e5accfbb0c 100644
--- a/spec/lib/emoji_formatter_spec.rb
+++ b/spec/lib/emoji_formatter_spec.rb
@@ -14,7 +14,7 @@ RSpec.describe EmojiFormatter do
 
     let(:emojis) { [emoji] }
 
-    context 'given text that is not marked as html-safe' do
+    context 'when given text that is not marked as html-safe' do
       let(:text) { 'Foo' }
 
       it 'raises an argument error' do
@@ -22,7 +22,7 @@ RSpec.describe EmojiFormatter do
       end
     end
 
-    context 'given text with an emoji shortcode at the start' do
+    context 'when given text with an emoji shortcode at the start' do
       let(:text) { preformat_text(':coolcat: Beep boop') }
 
       it 'converts the shortcode to an image tag' do
@@ -30,7 +30,7 @@ RSpec.describe EmojiFormatter do
       end
     end
 
-    context 'given text with an emoji shortcode in the middle' do
+    context 'when given text with an emoji shortcode in the middle' do
       let(:text) { preformat_text('Beep :coolcat: boop') }
 
       it 'converts the shortcode to an image tag' do
@@ -38,7 +38,7 @@ RSpec.describe EmojiFormatter do
       end
     end
 
-    context 'given text with concatenated emoji shortcodes' do
+    context 'when given text with concatenated emoji shortcodes' do
       let(:text) { preformat_text(':coolcat::coolcat:') }
 
       it 'does not touch the shortcodes' do
@@ -46,7 +46,7 @@ RSpec.describe EmojiFormatter do
       end
     end
 
-    context 'given text with an emoji shortcode at the end' do
+    context 'when given text with an emoji shortcode at the end' do
       let(:text) { preformat_text('Beep boop :coolcat:') }
 
       it 'converts the shortcode to an image tag' do
diff --git a/spec/lib/entity_cache_spec.rb b/spec/lib/entity_cache_spec.rb
index c750cddf3c..6d9afa4740 100644
--- a/spec/lib/entity_cache_spec.rb
+++ b/spec/lib/entity_cache_spec.rb
@@ -9,7 +9,7 @@ RSpec.describe EntityCache do
   describe '#emoji' do
     subject { EntityCache.instance.emoji(shortcodes, domain) }
 
-    context 'called with an empty list of shortcodes' do
+    context 'when called with an empty list of shortcodes' do
       let(:shortcodes) { [] }
       let(:domain)     { 'example.org' }
 
diff --git a/spec/lib/feed_manager_spec.rb b/spec/lib/feed_manager_spec.rb
index d1e0d60e00..ccaa10dee9 100644
--- a/spec/lib/feed_manager_spec.rb
+++ b/spec/lib/feed_manager_spec.rb
@@ -27,7 +27,7 @@ RSpec.describe FeedManager do
     let(:bob)   { Fabricate(:account, username: 'bob', domain: 'example.com') }
     let(:jeff)  { Fabricate(:account, username: 'jeff') }
 
-    context 'for home feed' do
+    context 'with home feed' do
       it 'returns false for followee\'s status' do
         status = Fabricate(:status, text: 'Hello world', account: alice)
         bob.follow!(alice)
@@ -162,7 +162,7 @@ RSpec.describe FeedManager do
       end
     end
 
-    context 'for mentions feed' do
+    context 'with mentions feed' do
       it 'returns true for status that mentions blocked account' do
         bob.block!(jeff)
         status = PostStatusService.new.call(alice, text: 'Hey @jeff')
@@ -195,7 +195,7 @@ RSpec.describe FeedManager do
     it 'trims timelines if they will have more than FeedManager::MAX_ITEMS' do
       account = Fabricate(:account)
       status = Fabricate(:status)
-      members = FeedManager::MAX_ITEMS.times.map { |count| [count, count] }
+      members = Array.new(FeedManager::MAX_ITEMS) { |count| [count, count] }
       redis.zadd("feed:home:#{account.id}", members)
 
       FeedManager.instance.push_to_home(account, status)
@@ -203,7 +203,7 @@ RSpec.describe FeedManager do
       expect(redis.zcard("feed:home:#{account.id}")).to eq FeedManager::MAX_ITEMS
     end
 
-    context 'reblogs' do
+    context 'with reblogs' do
       it 'saves reblogs of unseen statuses' do
         account = Fabricate(:account)
         reblogged = Fabricate(:status)
@@ -240,7 +240,7 @@ RSpec.describe FeedManager do
       it 'does not save a new reblog of a recently-reblogged status' do
         account = Fabricate(:account)
         reblogged = Fabricate(:status)
-        reblogs = 2.times.map { Fabricate(:status, reblog: reblogged) }
+        reblogs = Array.new(2) { Fabricate(:status, reblog: reblogged) }
 
         # The first reblog will be accepted
         FeedManager.instance.push_to_home(account, reblogs.first)
@@ -269,7 +269,7 @@ RSpec.describe FeedManager do
       it 'does not save a new reblog of a multiply-reblogged-then-unreblogged status' do
         account   = Fabricate(:account)
         reblogged = Fabricate(:status)
-        reblogs = 3.times.map { Fabricate(:status, reblog: reblogged) }
+        reblogs = Array.new(3) { Fabricate(:status, reblog: reblogged) }
 
         # Accept the reblogs
         FeedManager.instance.push_to_home(account, reblogs[0])
@@ -285,7 +285,7 @@ RSpec.describe FeedManager do
       it 'saves a new reblog of a long-ago-reblogged status' do
         account = Fabricate(:account)
         reblogged = Fabricate(:status)
-        reblogs = 2.times.map { Fabricate(:status, reblog: reblogged) }
+        reblogs = Array.new(2) { Fabricate(:status, reblog: reblogged) }
 
         # The first reblog will be accepted
         FeedManager.instance.push_to_home(account, reblogs.first)
@@ -466,7 +466,7 @@ RSpec.describe FeedManager do
 
     it 'leaves a multiply-reblogged status if another reblog was in feed' do
       reblogged = Fabricate(:status)
-      reblogs   = 3.times.map { Fabricate(:status, reblog: reblogged) }
+      reblogs   = Array.new(3) { Fabricate(:status, reblog: reblogged) }
 
       reblogs.each do |reblog|
         FeedManager.instance.push_to_home(receiver, reblog)
diff --git a/spec/lib/html_aware_formatter_spec.rb b/spec/lib/html_aware_formatter_spec.rb
index 315035957b..a20902d4f9 100644
--- a/spec/lib/html_aware_formatter_spec.rb
+++ b/spec/lib/html_aware_formatter_spec.rb
@@ -18,7 +18,7 @@ RSpec.describe HtmlAwareFormatter do
     context 'when remote' do
       let(:local) { false }
 
-      context 'given plain text' do
+      context 'when given plain text' do
         let(:text) { 'Beep boop' }
 
         it 'keeps the plain text' do
@@ -26,7 +26,7 @@ RSpec.describe HtmlAwareFormatter do
         end
       end
 
-      context 'given text containing script tags' do
+      context 'when given text containing script tags' do
         let(:text) { '<script>alert("Hello")</script>' }
 
         it 'strips the scripts' do
@@ -34,7 +34,7 @@ RSpec.describe HtmlAwareFormatter do
         end
       end
 
-      context 'given text containing malicious classes' do
+      context 'when given text containing malicious classes' do
         let(:text) { '<span class="mention  status__content__spoiler-link">Show more</span>' }
 
         it 'strips the malicious classes' do
diff --git a/spec/lib/link_details_extractor_spec.rb b/spec/lib/link_details_extractor_spec.rb
index a46dd743a9..ef501efbff 100644
--- a/spec/lib/link_details_extractor_spec.rb
+++ b/spec/lib/link_details_extractor_spec.rb
@@ -40,7 +40,7 @@ RSpec.describe LinkDetailsExtractor do
   context 'when structured data is present' do
     let(:original_url) { 'https://example.com/page.html' }
 
-    context 'and is wrapped in CDATA tags' do
+    context 'when is wrapped in CDATA tags' do
       let(:html) { <<~HTML }
         <!doctype html>
         <html>
@@ -79,7 +79,7 @@ RSpec.describe LinkDetailsExtractor do
       end
     end
 
-    context 'but the first tag is invalid JSON' do
+    context 'with the first tag is invalid JSON' do
       let(:html) { <<~HTML }
         <!doctype html>
         <html>
diff --git a/spec/lib/mastodon/settings_cli_spec.rb b/spec/lib/mastodon/settings_cli_spec.rb
new file mode 100644
index 0000000000..713cb7e437
--- /dev/null
+++ b/spec/lib/mastodon/settings_cli_spec.rb
@@ -0,0 +1,64 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+require 'mastodon/settings_cli'
+
+RSpec.describe Mastodon::SettingsCLI do
+  describe 'subcommand "registrations"' do
+    let(:cli) { Mastodon::RegistrationsCLI.new }
+
+    before do
+      Setting.registrations_mode = nil
+    end
+
+    describe '#open' do
+      it 'changes "registrations_mode" to "open"' do
+        expect { cli.open }.to change(Setting, :registrations_mode).from(nil).to('open')
+      end
+
+      it 'displays success message' do
+        expect { cli.open }.to output(
+          a_string_including('OK')
+        ).to_stdout
+      end
+    end
+
+    describe '#approved' do
+      it 'changes "registrations_mode" to "approved"' do
+        expect { cli.approved }.to change(Setting, :registrations_mode).from(nil).to('approved')
+      end
+
+      it 'displays success message' do
+        expect { cli.approved }.to output(
+          a_string_including('OK')
+        ).to_stdout
+      end
+
+      context 'with --require-reason' do
+        before do
+          cli.options = { require_reason: true }
+        end
+
+        it 'changes "registrations_mode" to "approved"' do
+          expect { cli.approved }.to change(Setting, :registrations_mode).from(nil).to('approved')
+        end
+
+        it 'sets "require_invite_text" to "true"' do
+          expect { cli.approved }.to change(Setting, :require_invite_text).from(false).to(true)
+        end
+      end
+    end
+
+    describe '#close' do
+      it 'changes "registrations_mode" to "none"' do
+        expect { cli.close }.to change(Setting, :registrations_mode).from(nil).to('none')
+      end
+
+      it 'displays success message' do
+        expect { cli.close }.to output(
+          a_string_including('OK')
+        ).to_stdout
+      end
+    end
+  end
+end
diff --git a/spec/lib/ostatus/tag_manager_spec.rb b/spec/lib/ostatus/tag_manager_spec.rb
index 8104a7e791..fb9740ce3f 100644
--- a/spec/lib/ostatus/tag_manager_spec.rb
+++ b/spec/lib/ostatus/tag_manager_spec.rb
@@ -40,7 +40,7 @@ describe OStatus::TagManager do
   describe '#uri_for' do
     subject { OStatus::TagManager.instance.uri_for(target) }
 
-    context 'comment object' do
+    context 'with comment object' do
       let(:target) { Fabricate(:status, created_at: '2000-01-01T00:00:00Z', reply: true) }
 
       it 'returns the unique tag for status' do
@@ -49,7 +49,7 @@ describe OStatus::TagManager do
       end
     end
 
-    context 'note object' do
+    context 'with note object' do
       let(:target) { Fabricate(:status, created_at: '2000-01-01T00:00:00Z', reply: false, thread: nil) }
 
       it 'returns the unique tag for status' do
@@ -58,7 +58,7 @@ describe OStatus::TagManager do
       end
     end
 
-    context 'person object' do
+    context 'when person object' do
       let(:target) { Fabricate(:account, username: 'alice') }
 
       it 'returns the URL for account' do
diff --git a/spec/lib/request_pool_spec.rb b/spec/lib/request_pool_spec.rb
index 63dc9c5dd2..395268fe43 100644
--- a/spec/lib/request_pool_spec.rb
+++ b/spec/lib/request_pool_spec.rb
@@ -33,7 +33,7 @@ describe RequestPool do
 
       subject
 
-      threads = 20.times.map do |_i|
+      threads = Array.new(20) do |_i|
         Thread.new do
           20.times do
             subject.with('http://example.com') do |http_client|
diff --git a/spec/lib/scope_transformer_spec.rb b/spec/lib/scope_transformer_spec.rb
index e5a992144d..8a9c7cf967 100644
--- a/spec/lib/scope_transformer_spec.rb
+++ b/spec/lib/scope_transformer_spec.rb
@@ -20,67 +20,67 @@ describe ScopeTransformer do
       end
     end
 
-    context 'for scope "read"' do
+    context 'with scope "read"' do
       let(:input) { 'read' }
 
       it_behaves_like 'a scope', nil, 'all', 'read'
     end
 
-    context 'for scope "write"' do
+    context 'with scope "write"' do
       let(:input) { 'write' }
 
       it_behaves_like 'a scope', nil, 'all', 'write'
     end
 
-    context 'for scope "follow"' do
+    context 'with scope "follow"' do
       let(:input) { 'follow' }
 
       it_behaves_like 'a scope', nil, 'follow', 'read/write'
     end
 
-    context 'for scope "crypto"' do
+    context 'with scope "crypto"' do
       let(:input) { 'crypto' }
 
       it_behaves_like 'a scope', nil, 'crypto', 'read/write'
     end
 
-    context 'for scope "push"' do
+    context 'with scope "push"' do
       let(:input) { 'push' }
 
       it_behaves_like 'a scope', nil, 'push', 'read/write'
     end
 
-    context 'for scope "admin:read"' do
+    context 'with scope "admin:read"' do
       let(:input) { 'admin:read' }
 
       it_behaves_like 'a scope', 'admin', 'all', 'read'
     end
 
-    context 'for scope "admin:write"' do
+    context 'with scope "admin:write"' do
       let(:input) { 'admin:write' }
 
       it_behaves_like 'a scope', 'admin', 'all', 'write'
     end
 
-    context 'for scope "admin:read:accounts"' do
+    context 'with scope "admin:read:accounts"' do
       let(:input) { 'admin:read:accounts' }
 
       it_behaves_like 'a scope', 'admin', 'accounts', 'read'
     end
 
-    context 'for scope "admin:write:accounts"' do
+    context 'with scope "admin:write:accounts"' do
       let(:input) { 'admin:write:accounts' }
 
       it_behaves_like 'a scope', 'admin', 'accounts', 'write'
     end
 
-    context 'for scope "read:accounts"' do
+    context 'with scope "read:accounts"' do
       let(:input) { 'read:accounts' }
 
       it_behaves_like 'a scope', nil, 'accounts', 'read'
     end
 
-    context 'for scope "write:accounts"' do
+    context 'with scope "write:accounts"' do
       let(:input) { 'write:accounts' }
 
       it_behaves_like 'a scope', nil, 'accounts', 'write'
diff --git a/spec/lib/status_cache_hydrator_spec.rb b/spec/lib/status_cache_hydrator_spec.rb
index 5c78de7116..5b80ccb970 100644
--- a/spec/lib/status_cache_hydrator_spec.rb
+++ b/spec/lib/status_cache_hydrator_spec.rb
@@ -44,7 +44,7 @@ describe StatusCacheHydrator do
         let(:reblog) { Fabricate(:status) }
         let(:status) { Fabricate(:status, reblog: reblog) }
 
-        context 'that has been favourited' do
+        context 'when it has been favourited' do
           before do
             FavouriteService.new.call(account, reblog)
           end
@@ -54,7 +54,7 @@ describe StatusCacheHydrator do
           end
         end
 
-        context 'that has been reblogged' do
+        context 'when it has been reblogged' do
           before do
             ReblogService.new.call(account, reblog)
           end
@@ -64,7 +64,7 @@ describe StatusCacheHydrator do
           end
         end
 
-        context 'that has been pinned' do
+        context 'when it has been pinned' do
           let(:reblog) { Fabricate(:status, account: account) }
 
           before do
@@ -76,7 +76,7 @@ describe StatusCacheHydrator do
           end
         end
 
-        context 'that has been followed tags' do
+        context 'when it has been followed tags' do
           let(:followed_tag) { Fabricate(:tag) }
 
           before do
@@ -90,7 +90,7 @@ describe StatusCacheHydrator do
           end
         end
 
-        context 'that has a poll authored by the user' do
+        context 'when it has a poll authored by the user' do
           let(:poll) { Fabricate(:poll, account: account) }
           let(:reblog) { Fabricate(:status, poll: poll, account: account) }
 
@@ -99,7 +99,7 @@ describe StatusCacheHydrator do
           end
         end
 
-        context 'that has been voted in' do
+        context 'when it has been voted in' do
           let(:poll) { Fabricate(:poll, options: %w(Yellow Blue)) }
           let(:reblog) { Fabricate(:status, poll: poll) }
 
@@ -112,7 +112,7 @@ describe StatusCacheHydrator do
           end
         end
 
-        context 'that matches account filters' do
+        context 'when it matches account filters' do
           let(:reblog) { Fabricate(:status, text: 'this toot is about that banned word') }
 
           before do
diff --git a/spec/lib/status_reach_finder_spec.rb b/spec/lib/status_reach_finder_spec.rb
index 785ce28a0e..c53ac27992 100644
--- a/spec/lib/status_reach_finder_spec.rb
+++ b/spec/lib/status_reach_finder_spec.rb
@@ -4,7 +4,7 @@ require 'rails_helper'
 
 describe StatusReachFinder do
   describe '#inboxes' do
-    context 'for a local status' do
+    context 'with a local status' do
       subject { described_class.new(status) }
 
       let(:parent_status) { nil }
diff --git a/spec/lib/text_formatter_spec.rb b/spec/lib/text_formatter_spec.rb
index 3417b450c6..8b922c018b 100644
--- a/spec/lib/text_formatter_spec.rb
+++ b/spec/lib/text_formatter_spec.rb
@@ -8,7 +8,7 @@ RSpec.describe TextFormatter do
 
     let(:preloaded_accounts) { nil }
 
-    context 'given text containing plain text' do
+    context 'when given text containing plain text' do
       let(:text) { 'text' }
 
       it 'paragraphizes the text' do
@@ -16,7 +16,7 @@ RSpec.describe TextFormatter do
       end
     end
 
-    context 'given text containing line feeds' do
+    context 'when given text containing line feeds' do
       let(:text) { "line\nfeed" }
 
       it 'removes line feeds' do
@@ -24,7 +24,7 @@ RSpec.describe TextFormatter do
       end
     end
 
-    context 'given text containing linkable mentions' do
+    context 'when given text containing linkable mentions' do
       let(:preloaded_accounts) { [Fabricate(:account, username: 'alice')] }
       let(:text) { '@alice' }
 
@@ -33,7 +33,7 @@ RSpec.describe TextFormatter do
       end
     end
 
-    context 'given text containing unlinkable mentions' do
+    context 'when given text containing unlinkable mentions' do
       let(:preloaded_accounts) { [] }
       let(:text) { '@alice' }
 
@@ -42,7 +42,7 @@ RSpec.describe TextFormatter do
       end
     end
 
-    context 'given a stand-alone medium URL' do
+    context 'when given a stand-alone medium URL' do
       let(:text) { 'https://hackernoon.com/the-power-to-build-communities-a-response-to-mark-zuckerberg-3f2cac9148a4' }
 
       it 'matches the full URL' do
@@ -50,7 +50,7 @@ RSpec.describe TextFormatter do
       end
     end
 
-    context 'given a stand-alone google URL' do
+    context 'when given a stand-alone google URL' do
       let(:text) { 'http://google.com' }
 
       it 'matches the full URL' do
@@ -58,7 +58,7 @@ RSpec.describe TextFormatter do
       end
     end
 
-    context 'given a stand-alone URL with a newer TLD' do
+    context 'when given a stand-alone URL with a newer TLD' do
       let(:text) { 'http://example.gay' }
 
       it 'matches the full URL' do
@@ -66,7 +66,7 @@ RSpec.describe TextFormatter do
       end
     end
 
-    context 'given a stand-alone IDN URL' do
+    context 'when given a stand-alone IDN URL' do
       let(:text) { 'https://nic.みんな/' }
 
       it 'matches the full URL' do
@@ -78,7 +78,7 @@ RSpec.describe TextFormatter do
       end
     end
 
-    context 'given a URL with a trailing period' do
+    context 'when given a URL with a trailing period' do
       let(:text) { 'http://www.mcmansionhell.com/post/156408871451/50-states-of-mcmansion-hell-scottsdale-arizona. ' }
 
       it 'matches the full URL but not the period' do
@@ -86,7 +86,7 @@ RSpec.describe TextFormatter do
       end
     end
 
-    context 'given a URL enclosed with parentheses' do
+    context 'when given a URL enclosed with parentheses' do
       let(:text) { '(http://google.com/)' }
 
       it 'matches the full URL but not the parentheses' do
@@ -94,7 +94,7 @@ RSpec.describe TextFormatter do
       end
     end
 
-    context 'given a URL with a trailing exclamation point' do
+    context 'when given a URL with a trailing exclamation point' do
       let(:text) { 'http://www.google.com!' }
 
       it 'matches the full URL but not the exclamation point' do
@@ -102,7 +102,7 @@ RSpec.describe TextFormatter do
       end
     end
 
-    context 'given a URL with a trailing single quote' do
+    context 'when given a URL with a trailing single quote' do
       let(:text) { "http://www.google.com'" }
 
       it 'matches the full URL but not the single quote' do
@@ -110,7 +110,7 @@ RSpec.describe TextFormatter do
       end
     end
 
-    context 'given a URL with a trailing angle bracket' do
+    context 'when given a URL with a trailing angle bracket' do
       let(:text) { 'http://www.google.com>' }
 
       it 'matches the full URL but not the angle bracket' do
@@ -118,7 +118,7 @@ RSpec.describe TextFormatter do
       end
     end
 
-    context 'given a URL with a query string' do
+    context 'when given a URL with a query string' do
       context 'with escaped unicode character' do
         let(:text) { 'https://www.ruby-toolbox.com/search?utf8=%E2%9C%93&q=autolink' }
 
@@ -152,7 +152,7 @@ RSpec.describe TextFormatter do
       end
     end
 
-    context 'given a URL with parentheses in it' do
+    context 'when given a URL with parentheses in it' do
       let(:text) { 'https://en.wikipedia.org/wiki/Diaspora_(software)' }
 
       it 'matches the full URL' do
@@ -160,7 +160,7 @@ RSpec.describe TextFormatter do
       end
     end
 
-    context 'given a URL in quotation marks' do
+    context 'when given a URL in quotation marks' do
       let(:text) { '"https://example.com/"' }
 
       it 'does not match the quotation marks' do
@@ -168,7 +168,7 @@ RSpec.describe TextFormatter do
       end
     end
 
-    context 'given a URL in angle brackets' do
+    context 'when given a URL in angle brackets' do
       let(:text) { '<https://example.com/>' }
 
       it 'does not match the angle brackets' do
@@ -176,7 +176,7 @@ RSpec.describe TextFormatter do
       end
     end
 
-    context 'given a URL with Japanese path string' do
+    context 'when given a URL with Japanese path string' do
       let(:text) { 'https://ja.wikipedia.org/wiki/日本' }
 
       it 'matches the full URL' do
@@ -184,7 +184,7 @@ RSpec.describe TextFormatter do
       end
     end
 
-    context 'given a URL with Korean path string' do
+    context 'when given a URL with Korean path string' do
       let(:text) { 'https://ko.wikipedia.org/wiki/대한민국' }
 
       it 'matches the full URL' do
@@ -192,7 +192,7 @@ RSpec.describe TextFormatter do
       end
     end
 
-    context 'given a URL with a full-width space' do
+    context 'when given a URL with a full-width space' do
       let(:text) { 'https://example.com/ abc123' }
 
       it 'does not match the full-width space' do
@@ -200,7 +200,7 @@ RSpec.describe TextFormatter do
       end
     end
 
-    context 'given a URL in Japanese quotation marks' do
+    context 'when given a URL in Japanese quotation marks' do
       let(:text) { '「[https://example.org/」' }
 
       it 'does not match the quotation marks' do
@@ -208,7 +208,7 @@ RSpec.describe TextFormatter do
       end
     end
 
-    context 'given a URL with Simplified Chinese path string' do
+    context 'when given a URL with Simplified Chinese path string' do
       let(:text) { 'https://baike.baidu.com/item/中华人民共和国' }
 
       it 'matches the full URL' do
@@ -216,7 +216,7 @@ RSpec.describe TextFormatter do
       end
     end
 
-    context 'given a URL with Traditional Chinese path string' do
+    context 'when given a URL with Traditional Chinese path string' do
       let(:text) { 'https://zh.wikipedia.org/wiki/臺灣' }
 
       it 'matches the full URL' do
@@ -224,7 +224,7 @@ RSpec.describe TextFormatter do
       end
     end
 
-    context 'given a URL containing unsafe code (XSS attack, visible part)' do
+    context 'when given a URL containing unsafe code (XSS attack, visible part)' do
       let(:text) { 'http://example.com/b<del>b</del>' }
 
       it 'does not include the HTML in the URL' do
@@ -236,7 +236,7 @@ RSpec.describe TextFormatter do
       end
     end
 
-    context 'given a URL containing unsafe code (XSS attack, invisible part)' do
+    context 'when given a URL containing unsafe code (XSS attack, invisible part)' do
       let(:text) { 'http://example.com/blahblahblahblah/a<script>alert("Hello")</script>' }
 
       it 'does not include the HTML in the URL' do
@@ -248,7 +248,7 @@ RSpec.describe TextFormatter do
       end
     end
 
-    context 'given text containing HTML code (script tag)' do
+    context 'when given text containing HTML code (script tag)' do
       let(:text) { '<script>alert("Hello")</script>' }
 
       it 'escapes the HTML' do
@@ -256,7 +256,7 @@ RSpec.describe TextFormatter do
       end
     end
 
-    context 'given text containing HTML (XSS attack)' do
+    context 'when given text containing HTML (XSS attack)' do
       let(:text) { %q{<img src="javascript:alert('XSS');">} }
 
       it 'escapes the HTML' do
@@ -264,7 +264,7 @@ RSpec.describe TextFormatter do
       end
     end
 
-    context 'given an invalid URL' do
+    context 'when given an invalid URL' do
       let(:text) { 'http://www\.google\.com' }
 
       it 'outputs the raw URL' do
@@ -272,7 +272,7 @@ RSpec.describe TextFormatter do
       end
     end
 
-    context 'given text containing a hashtag' do
+    context 'when given text containing a hashtag' do
       let(:text)  { '#hashtag' }
 
       it 'creates a hashtag link' do
@@ -280,7 +280,7 @@ RSpec.describe TextFormatter do
       end
     end
 
-    context 'given text containing a hashtag with Unicode chars' do
+    context 'when given text containing a hashtag with Unicode chars' do
       let(:text)  { '#hashtagタグ' }
 
       it 'creates a hashtag link' do
@@ -288,7 +288,7 @@ RSpec.describe TextFormatter do
       end
     end
 
-    context 'given text with a stand-alone xmpp: URI' do
+    context 'when given text with a stand-alone xmpp: URI' do
       let(:text) { 'xmpp:user@instance.com' }
 
       it 'matches the full URI' do
@@ -296,7 +296,7 @@ RSpec.describe TextFormatter do
       end
     end
 
-    context 'given text with an xmpp: URI with a query-string' do
+    context 'when given text with an xmpp: URI with a query-string' do
       let(:text) { 'please join xmpp:muc@instance.com?join right now' }
 
       it 'matches the full URI' do
@@ -304,7 +304,7 @@ RSpec.describe TextFormatter do
       end
     end
 
-    context 'given text containing a magnet: URI' do
+    context 'when given text containing a magnet: URI' do
       let(:text) { 'wikipedia gives this example of a magnet uri: magnet:?xt=urn:btih:c12fe1c06bba254a9dc9f519b335aa7c1367a88a' }
 
       it 'matches the full URI' do
diff --git a/spec/lib/vacuum/imports_vacuum_spec.rb b/spec/lib/vacuum/imports_vacuum_spec.rb
new file mode 100644
index 0000000000..1e0abc5e01
--- /dev/null
+++ b/spec/lib/vacuum/imports_vacuum_spec.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe Vacuum::ImportsVacuum do
+  subject { described_class.new }
+
+  let!(:old_unconfirmed) { Fabricate(:bulk_import, state: :unconfirmed, created_at: 2.days.ago) }
+  let!(:new_unconfirmed) { Fabricate(:bulk_import, state: :unconfirmed, created_at: 10.seconds.ago) }
+  let!(:recent_ongoing)  { Fabricate(:bulk_import, state: :in_progress, created_at: 20.minutes.ago) }
+  let!(:recent_finished) { Fabricate(:bulk_import, state: :finished, created_at: 1.day.ago) }
+  let!(:old_finished)    { Fabricate(:bulk_import, state: :finished, created_at: 2.months.ago) }
+
+  describe '#perform' do
+    it 'cleans up the expected imports' do
+      expect { subject.perform }.to change { BulkImport.all.pluck(:id) }.from([old_unconfirmed, new_unconfirmed, recent_ongoing, recent_finished, old_finished].map(&:id)).to([new_unconfirmed, recent_ongoing, recent_finished].map(&:id))
+    end
+  end
+end
diff --git a/spec/mailers/admin_mailer_spec.rb b/spec/mailers/admin_mailer_spec.rb
index 132c6c7586..8e2eec40fd 100644
--- a/spec/mailers/admin_mailer_spec.rb
+++ b/spec/mailers/admin_mailer_spec.rb
@@ -2,7 +2,7 @@
 
 require 'rails_helper'
 
-RSpec.describe AdminMailer, type: :mailer do
+RSpec.describe AdminMailer do
   describe '.new_report' do
     let(:sender)    { Fabricate(:account, username: 'John') }
     let(:recipient) { Fabricate(:account, username: 'Mike') }
diff --git a/spec/mailers/notification_mailer_spec.rb b/spec/mailers/notification_mailer_spec.rb
index ab98bac388..3113d05c37 100644
--- a/spec/mailers/notification_mailer_spec.rb
+++ b/spec/mailers/notification_mailer_spec.rb
@@ -2,7 +2,7 @@
 
 require 'rails_helper'
 
-RSpec.describe NotificationMailer, type: :mailer do
+RSpec.describe NotificationMailer do
   let(:receiver)       { Fabricate(:user) }
   let(:sender)         { Fabricate(:account, username: 'bob') }
   let(:foreign_status) { Fabricate(:status, account: sender, text: 'The body of the foreign status') }
diff --git a/spec/mailers/user_mailer_spec.rb b/spec/mailers/user_mailer_spec.rb
index 30824e7b4d..6144b2bbb8 100644
--- a/spec/mailers/user_mailer_spec.rb
+++ b/spec/mailers/user_mailer_spec.rb
@@ -2,7 +2,7 @@
 
 require 'rails_helper'
 
-describe UserMailer, type: :mailer do
+describe UserMailer do
   let(:receiver) { Fabricate(:user) }
 
   shared_examples 'localized subject' do |*args, **kwrest|
diff --git a/spec/models/account/field_spec.rb b/spec/models/account/field_spec.rb
index 6745fbb261..5715a53791 100644
--- a/spec/models/account/field_spec.rb
+++ b/spec/models/account/field_spec.rb
@@ -2,7 +2,7 @@
 
 require 'rails_helper'
 
-RSpec.describe Account::Field, type: :model do
+RSpec.describe Account::Field do
   describe '#verified?' do
     subject { described_class.new(account, 'name' => 'Foo', 'value' => 'Bar', 'verified_at' => verified_at) }
 
@@ -49,10 +49,10 @@ RSpec.describe Account::Field, type: :model do
 
     let(:account) { double('Account', local?: local) }
 
-    context 'for local accounts' do
+    context 'with local accounts' do
       let(:local) { true }
 
-      context 'for a URL with misleading authentication' do
+      context 'with a URL with misleading authentication' do
         let(:value) { 'https://spacex.com                                                                                            @h.43z.one' }
 
         it 'returns false' do
@@ -60,7 +60,7 @@ RSpec.describe Account::Field, type: :model do
         end
       end
 
-      context 'for a URL' do
+      context 'with a URL' do
         let(:value) { 'https://example.com' }
 
         it 'returns true' do
@@ -68,7 +68,7 @@ RSpec.describe Account::Field, type: :model do
         end
       end
 
-      context 'for an IDN URL' do
+      context 'with an IDN URL' do
         let(:value) { 'https://twitter.com∕dougallj∕status∕1590357240443437057.ê.cc/twitter.html' }
 
         it 'returns false' do
@@ -76,7 +76,7 @@ RSpec.describe Account::Field, type: :model do
         end
       end
 
-      context 'for a URL with a non-normalized path' do
+      context 'with a URL with a non-normalized path' do
         let(:value) { 'https://github.com/octocatxxxxxxxx/../mastodon' }
 
         it 'returns false' do
@@ -84,7 +84,7 @@ RSpec.describe Account::Field, type: :model do
         end
       end
 
-      context 'for text that is not a URL' do
+      context 'with text that is not a URL' do
         let(:value) { 'Hello world' }
 
         it 'returns false' do
@@ -92,7 +92,7 @@ RSpec.describe Account::Field, type: :model do
         end
       end
 
-      context 'for text that contains a URL' do
+      context 'with text that contains a URL' do
         let(:value) { 'Hello https://example.com world' }
 
         it 'returns false' do
@@ -100,7 +100,7 @@ RSpec.describe Account::Field, type: :model do
         end
       end
 
-      context 'for text which is blank' do
+      context 'with text which is blank' do
         let(:value) { '' }
 
         it 'returns false' do
@@ -109,10 +109,10 @@ RSpec.describe Account::Field, type: :model do
       end
     end
 
-    context 'for remote accounts' do
+    context 'with remote accounts' do
       let(:local) { false }
 
-      context 'for a link' do
+      context 'with a link' do
         let(:value) { '<a href="https://www.patreon.com/mastodon" target="_blank" rel="nofollow noopener noreferrer me"><span class="invisible">https://www.</span><span class="">patreon.com/mastodon</span><span class="invisible"></span></a>' }
 
         it 'returns true' do
@@ -120,7 +120,7 @@ RSpec.describe Account::Field, type: :model do
         end
       end
 
-      context 'for a link with misleading authentication' do
+      context 'with a link with misleading authentication' do
         let(:value) { '<a href="https://google.com                                                                                            @h.43z.one" target="_blank" rel="nofollow noopener noreferrer me"><span class="invisible">https://</span><span class="">google.com</span><span class="invisible">                                                                                            @h.43z.one</span></a>' }
 
         it 'returns false' do
@@ -128,7 +128,7 @@ RSpec.describe Account::Field, type: :model do
         end
       end
 
-      context 'for HTML that has more than just a link' do
+      context 'with HTML that has more than just a link' do
         let(:value) { '<a href="https://google.com" target="_blank" rel="nofollow noopener noreferrer me"><span class="invisible">https://</span><span class="">google.com</span><span class="invisible"></span></a>                                                                                            @h.43z.one' }
 
         it 'returns false' do
@@ -136,7 +136,7 @@ RSpec.describe Account::Field, type: :model do
         end
       end
 
-      context 'for a link with different visible text' do
+      context 'with a link with different visible text' do
         let(:value) { '<a href="https://google.com/bar">https://example.com/foo</a>' }
 
         it 'returns false' do
@@ -144,7 +144,7 @@ RSpec.describe Account::Field, type: :model do
         end
       end
 
-      context 'for text that is a URL but is not linked' do
+      context 'with text that is a URL but is not linked' do
         let(:value) { 'https://example.com/foo' }
 
         it 'returns false' do
@@ -152,7 +152,7 @@ RSpec.describe Account::Field, type: :model do
         end
       end
 
-      context 'for text which is blank' do
+      context 'with text which is blank' do
         let(:value) { '' }
 
         it 'returns false' do
diff --git a/spec/models/account_alias_spec.rb b/spec/models/account_alias_spec.rb
index 08c3eaff43..f0cd274ea9 100644
--- a/spec/models/account_alias_spec.rb
+++ b/spec/models/account_alias_spec.rb
@@ -2,5 +2,5 @@
 
 require 'rails_helper'
 
-RSpec.describe AccountAlias, type: :model do
+RSpec.describe AccountAlias do
 end
diff --git a/spec/models/account_conversation_spec.rb b/spec/models/account_conversation_spec.rb
index c4e8918ad2..a16aa500cf 100644
--- a/spec/models/account_conversation_spec.rb
+++ b/spec/models/account_conversation_spec.rb
@@ -2,7 +2,7 @@
 
 require 'rails_helper'
 
-RSpec.describe AccountConversation, type: :model do
+RSpec.describe AccountConversation do
   let!(:alice) { Fabricate(:account, username: 'alice') }
   let!(:bob)   { Fabricate(:account, username: 'bob') }
   let!(:mark)  { Fabricate(:account, username: 'mark') }
diff --git a/spec/models/account_deletion_request_spec.rb b/spec/models/account_deletion_request_spec.rb
index db332f14cb..8bbfb695d3 100644
--- a/spec/models/account_deletion_request_spec.rb
+++ b/spec/models/account_deletion_request_spec.rb
@@ -2,5 +2,5 @@
 
 require 'rails_helper'
 
-RSpec.describe AccountDeletionRequest, type: :model do
+RSpec.describe AccountDeletionRequest do
 end
diff --git a/spec/models/account_domain_block_spec.rb b/spec/models/account_domain_block_spec.rb
index bc46f44ba7..f3246d04c5 100644
--- a/spec/models/account_domain_block_spec.rb
+++ b/spec/models/account_domain_block_spec.rb
@@ -2,7 +2,7 @@
 
 require 'rails_helper'
 
-RSpec.describe AccountDomainBlock, type: :model do
+RSpec.describe AccountDomainBlock do
   it 'removes blocking cache after creation' do
     account = Fabricate(:account)
     Rails.cache.write("exclude_domains_for:#{account.id}", 'a.domain.already.blocked')
diff --git a/spec/models/account_migration_spec.rb b/spec/models/account_migration_spec.rb
index a91ba5dc56..0d97ea7e77 100644
--- a/spec/models/account_migration_spec.rb
+++ b/spec/models/account_migration_spec.rb
@@ -2,7 +2,7 @@
 
 require 'rails_helper'
 
-RSpec.describe AccountMigration, type: :model do
+RSpec.describe AccountMigration do
   describe 'validations' do
     let(:source_account) { Fabricate(:account) }
     let(:target_acct)    { target_account.acct }
diff --git a/spec/models/account_moderation_note_spec.rb b/spec/models/account_moderation_note_spec.rb
index b7f5701e6a..9d683ca528 100644
--- a/spec/models/account_moderation_note_spec.rb
+++ b/spec/models/account_moderation_note_spec.rb
@@ -2,5 +2,5 @@
 
 require 'rails_helper'
 
-RSpec.describe AccountModerationNote, type: :model do
+RSpec.describe AccountModerationNote do
 end
diff --git a/spec/models/account_spec.rb b/spec/models/account_spec.rb
index 458b2ce52a..bbe35f5793 100644
--- a/spec/models/account_spec.rb
+++ b/spec/models/account_spec.rb
@@ -2,7 +2,7 @@
 
 require 'rails_helper'
 
-RSpec.describe Account, type: :model do
+RSpec.describe Account do
   context do
     subject { Fabricate(:account) }
 
@@ -171,7 +171,7 @@ RSpec.describe Account, type: :model do
   describe '#possibly_stale?' do
     let(:account) { Fabricate(:account, last_webfingered_at: last_webfingered_at) }
 
-    context 'last_webfingered_at is nil' do
+    context 'when last_webfingered_at is nil' do
       let(:last_webfingered_at) { nil }
 
       it 'returns true' do
@@ -179,7 +179,7 @@ RSpec.describe Account, type: :model do
       end
     end
 
-    context 'last_webfingered_at is more than 24 hours before' do
+    context 'when last_webfingered_at is more than 24 hours before' do
       let(:last_webfingered_at) { 25.hours.ago }
 
       it 'returns true' do
@@ -187,7 +187,7 @@ RSpec.describe Account, type: :model do
       end
     end
 
-    context 'last_webfingered_at is less than 24 hours before' do
+    context 'when last_webfingered_at is less than 24 hours before' do
       let(:last_webfingered_at) { 23.hours.ago }
 
       it 'returns false' do
@@ -200,7 +200,7 @@ RSpec.describe Account, type: :model do
     let(:account) { Fabricate(:account, domain: domain) }
     let(:acct)    { account.acct }
 
-    context 'domain is nil' do
+    context 'when domain is nil' do
       let(:domain) { nil }
 
       it 'returns nil' do
@@ -213,7 +213,7 @@ RSpec.describe Account, type: :model do
       end
     end
 
-    context 'domain is present' do
+    context 'when domain is present' do
       let(:domain) { 'example.com' }
 
       it 'calls ResolveAccountService#call' do
@@ -902,7 +902,7 @@ RSpec.describe Account, type: :model do
 
     describe 'recent' do
       it 'returns a relation of accounts sorted by recent creation' do
-        matches = 2.times.map { Fabricate(:account) }
+        matches = Array.new(2) { Fabricate(:account) }
         expect(Account.where('id > 0').recent).to match_array(matches)
       end
     end
diff --git a/spec/models/account_statuses_cleanup_policy_spec.rb b/spec/models/account_statuses_cleanup_policy_spec.rb
index dcdd97bda0..391b99ff47 100644
--- a/spec/models/account_statuses_cleanup_policy_spec.rb
+++ b/spec/models/account_statuses_cleanup_policy_spec.rb
@@ -2,7 +2,7 @@
 
 require 'rails_helper'
 
-RSpec.describe AccountStatusesCleanupPolicy, type: :model do
+RSpec.describe AccountStatusesCleanupPolicy do
   let(:account) { Fabricate(:account, username: 'alice', domain: nil) }
 
   describe 'validation' do
diff --git a/spec/models/admin/account_action_spec.rb b/spec/models/admin/account_action_spec.rb
index 9f41b7c8e5..442815c889 100644
--- a/spec/models/admin/account_action_spec.rb
+++ b/spec/models/admin/account_action_spec.rb
@@ -2,7 +2,7 @@
 
 require 'rails_helper'
 
-RSpec.describe Admin::AccountAction, type: :model do
+RSpec.describe Admin::AccountAction do
   let(:account_action) { described_class.new }
 
   describe '#save!' do
@@ -20,7 +20,7 @@ RSpec.describe Admin::AccountAction, type: :model do
       )
     end
 
-    context 'type is "disable"' do
+    context 'when type is "disable"' do
       let(:type) { 'disable' }
 
       it 'disable user' do
@@ -29,7 +29,7 @@ RSpec.describe Admin::AccountAction, type: :model do
       end
     end
 
-    context 'type is "silence"' do
+    context 'when type is "silence"' do
       let(:type) { 'silence' }
 
       it 'silences account' do
@@ -38,7 +38,7 @@ RSpec.describe Admin::AccountAction, type: :model do
       end
     end
 
-    context 'type is "suspend"' do
+    context 'when type is "suspend"' do
       let(:type) { 'suspend' }
 
       it 'suspends account' do
@@ -75,7 +75,7 @@ RSpec.describe Admin::AccountAction, type: :model do
   describe '#report' do
     subject { account_action.report }
 
-    context 'report_id.present?' do
+    context 'with report_id.present?' do
       before do
         account_action.report_id = Fabricate(:report).id
       end
@@ -85,7 +85,7 @@ RSpec.describe Admin::AccountAction, type: :model do
       end
     end
 
-    context '!report_id.present?' do
+    context 'with !report_id.present?' do
       it 'returns nil' do
         expect(subject).to be_nil
       end
@@ -95,7 +95,7 @@ RSpec.describe Admin::AccountAction, type: :model do
   describe '#with_report?' do
     subject { account_action.with_report? }
 
-    context '!report.nil?' do
+    context 'with !report.nil?' do
       before do
         account_action.report_id = Fabricate(:report).id
       end
@@ -105,7 +105,7 @@ RSpec.describe Admin::AccountAction, type: :model do
       end
     end
 
-    context '!(!report.nil?)' do
+    context 'with !(!report.nil?)' do
       it 'returns false' do
         expect(subject).to be false
       end
@@ -115,7 +115,7 @@ RSpec.describe Admin::AccountAction, type: :model do
   describe '.types_for_account' do
     subject { described_class.types_for_account(account) }
 
-    context 'account.local?' do
+    context 'when Account.local?' do
       let(:account) { Fabricate(:account, domain: nil) }
 
       it 'returns ["none", "disable", "sensitive", "silence", "suspend"]' do
@@ -123,7 +123,7 @@ RSpec.describe Admin::AccountAction, type: :model do
       end
     end
 
-    context '!account.local?' do
+    context 'with !account.local?' do
       let(:account) { Fabricate(:account, domain: 'hoge.com') }
 
       it 'returns ["sensitive", "silence", "suspend"]' do
diff --git a/spec/models/admin/action_log_spec.rb b/spec/models/admin/action_log_spec.rb
index 3495cc5141..1e3649b833 100644
--- a/spec/models/admin/action_log_spec.rb
+++ b/spec/models/admin/action_log_spec.rb
@@ -2,7 +2,7 @@
 
 require 'rails_helper'
 
-RSpec.describe Admin::ActionLog, type: :model do
+RSpec.describe Admin::ActionLog do
   describe '#action' do
     it 'returns action' do
       action_log = described_class.new(action: 'hoge')
diff --git a/spec/models/announcement_mute_spec.rb b/spec/models/announcement_mute_spec.rb
index f4a7a5dc97..1937da3aa7 100644
--- a/spec/models/announcement_mute_spec.rb
+++ b/spec/models/announcement_mute_spec.rb
@@ -2,5 +2,5 @@
 
 require 'rails_helper'
 
-RSpec.describe AnnouncementMute, type: :model do
+RSpec.describe AnnouncementMute do
 end
diff --git a/spec/models/announcement_reaction_spec.rb b/spec/models/announcement_reaction_spec.rb
index 38095b0154..43cc0e1489 100644
--- a/spec/models/announcement_reaction_spec.rb
+++ b/spec/models/announcement_reaction_spec.rb
@@ -2,5 +2,5 @@
 
 require 'rails_helper'
 
-RSpec.describe AnnouncementReaction, type: :model do
+RSpec.describe AnnouncementReaction do
 end
diff --git a/spec/models/announcement_spec.rb b/spec/models/announcement_spec.rb
index 024fa28880..32d398213c 100644
--- a/spec/models/announcement_spec.rb
+++ b/spec/models/announcement_spec.rb
@@ -2,5 +2,5 @@
 
 require 'rails_helper'
 
-RSpec.describe Announcement, type: :model do
+RSpec.describe Announcement do
 end
diff --git a/spec/models/backup_spec.rb b/spec/models/backup_spec.rb
index 239e7aef7b..1303117528 100644
--- a/spec/models/backup_spec.rb
+++ b/spec/models/backup_spec.rb
@@ -2,5 +2,5 @@
 
 require 'rails_helper'
 
-RSpec.describe Backup, type: :model do
+RSpec.describe Backup do
 end
diff --git a/spec/models/block_spec.rb b/spec/models/block_spec.rb
index 6e31786d04..de3410fd58 100644
--- a/spec/models/block_spec.rb
+++ b/spec/models/block_spec.rb
@@ -2,7 +2,7 @@
 
 require 'rails_helper'
 
-RSpec.describe Block, type: :model do
+RSpec.describe Block do
   describe 'validations' do
     it 'is invalid without an account' do
       block = Fabricate.build(:block, account: nil)
diff --git a/spec/models/canonical_email_block_spec.rb b/spec/models/canonical_email_block_spec.rb
index 2b3fd6d6a7..0acff82377 100644
--- a/spec/models/canonical_email_block_spec.rb
+++ b/spec/models/canonical_email_block_spec.rb
@@ -2,7 +2,7 @@
 
 require 'rails_helper'
 
-RSpec.describe CanonicalEmailBlock, type: :model do
+RSpec.describe CanonicalEmailBlock do
   describe '#email=' do
     let(:target_hash) { '973dfe463ec85785f5f95af5ba3906eedb2d931c24e69824a89ea65dba4e813b' }
 
diff --git a/spec/models/concerns/account_interactions_spec.rb b/spec/models/concerns/account_interactions_spec.rb
index 32e08d5f7d..89da661b21 100644
--- a/spec/models/concerns/account_interactions_spec.rb
+++ b/spec/models/concerns/account_interactions_spec.rb
@@ -13,21 +13,21 @@ describe AccountInteractions do
   describe '.following_map' do
     subject { Account.following_map(target_account_ids, account_id) }
 
-    context 'account with Follow' do
-      it 'returns { target_account_id => { reblogs: true } }' do
+    context 'when Account with Follow' do
+      it 'returns { target_account_id => true }' do
         Fabricate(:follow, account: account, target_account: target_account)
         expect(subject).to eq(target_account_id => { reblogs: true, notify: false, languages: nil })
       end
     end
 
-    context 'account with Follow but with reblogs disabled' do
+    context 'when Account with Follow but with reblogs disabled' do
       it 'returns { target_account_id => { reblogs: false } }' do
         Fabricate(:follow, account: account, target_account: target_account, show_reblogs: false)
         expect(subject).to eq(target_account_id => { reblogs: false, notify: false, languages: nil })
       end
     end
 
-    context 'account without Follow' do
+    context 'when Account without Follow' do
       it 'returns {}' do
         expect(subject).to eq({})
       end
@@ -37,14 +37,14 @@ describe AccountInteractions do
   describe '.followed_by_map' do
     subject { Account.followed_by_map(target_account_ids, account_id) }
 
-    context 'account with Follow' do
+    context 'when Account with Follow' do
       it 'returns { target_account_id => true }' do
         Fabricate(:follow, account: target_account, target_account: account)
         expect(subject).to eq(target_account_id => true)
       end
     end
 
-    context 'account without Follow' do
+    context 'when Account without Follow' do
       it 'returns {}' do
         expect(subject).to eq({})
       end
@@ -54,14 +54,14 @@ describe AccountInteractions do
   describe '.blocking_map' do
     subject { Account.blocking_map(target_account_ids, account_id) }
 
-    context 'account with Block' do
+    context 'when Account with Block' do
       it 'returns { target_account_id => true }' do
         Fabricate(:block, account: account, target_account: target_account)
         expect(subject).to eq(target_account_id => true)
       end
     end
 
-    context 'account without Block' do
+    context 'when Account without Block' do
       it 'returns {}' do
         expect(subject).to eq({})
       end
@@ -71,12 +71,12 @@ describe AccountInteractions do
   describe '.muting_map' do
     subject { Account.muting_map(target_account_ids, account_id) }
 
-    context 'account with Mute' do
+    context 'when Account with Mute' do
       before do
         Fabricate(:mute, target_account: target_account, account: account, hide_notifications: hide)
       end
 
-      context 'if Mute#hide_notifications?' do
+      context 'when Mute#hide_notifications?' do
         let(:hide) { true }
 
         it 'returns { target_account_id => { notifications: true } }' do
@@ -84,7 +84,7 @@ describe AccountInteractions do
         end
       end
 
-      context 'unless Mute#hide_notifications?' do
+      context 'when not Mute#hide_notifications?' do
         let(:hide) { false }
 
         it 'returns { target_account_id => { notifications: false } }' do
@@ -93,7 +93,7 @@ describe AccountInteractions do
       end
     end
 
-    context 'account without Mute' do
+    context 'when Account without Mute' do
       it 'returns {}' do
         expect(subject).to eq({})
       end
@@ -119,8 +119,8 @@ describe AccountInteractions do
   describe '#mute!' do
     subject { account.mute!(target_account, notifications: arg_notifications) }
 
-    context 'Mute does not exist yet' do
-      context 'arg :notifications is nil' do
+    context 'when Mute does not exist yet' do
+      context 'when arg :notifications is nil' do
         let(:arg_notifications) { nil }
 
         it 'creates Mute, and returns Mute' do
@@ -130,7 +130,7 @@ describe AccountInteractions do
         end
       end
 
-      context 'arg :notifications is false' do
+      context 'when arg :notifications is false' do
         let(:arg_notifications) { false }
 
         it 'creates Mute, and returns Mute' do
@@ -140,7 +140,7 @@ describe AccountInteractions do
         end
       end
 
-      context 'arg :notifications is true' do
+      context 'when arg :notifications is true' do
         let(:arg_notifications) { true }
 
         it 'creates Mute, and returns Mute' do
@@ -151,7 +151,7 @@ describe AccountInteractions do
       end
     end
 
-    context 'Mute already exists' do
+    context 'when Mute already exists' do
       before do
         account.mute_relationships << mute
       end
@@ -163,10 +163,10 @@ describe AccountInteractions do
                   hide_notifications: hide_notifications)
       end
 
-      context 'mute.hide_notifications is true' do
+      context 'when mute.hide_notifications is true' do
         let(:hide_notifications) { true }
 
-        context 'arg :notifications is nil' do
+        context 'when arg :notifications is nil' do
           let(:arg_notifications) { nil }
 
           it 'returns Mute without updating mute.hide_notifications' do
@@ -176,7 +176,7 @@ describe AccountInteractions do
           end
         end
 
-        context 'arg :notifications is false' do
+        context 'when arg :notifications is false' do
           let(:arg_notifications) { false }
 
           it 'returns Mute, and updates mute.hide_notifications false' do
@@ -186,7 +186,7 @@ describe AccountInteractions do
           end
         end
 
-        context 'arg :notifications is true' do
+        context 'when arg :notifications is true' do
           let(:arg_notifications) { true }
 
           it 'returns Mute without updating mute.hide_notifications' do
@@ -197,10 +197,10 @@ describe AccountInteractions do
         end
       end
 
-      context 'mute.hide_notifications is false' do
+      context 'when mute.hide_notifications is false' do
         let(:hide_notifications) { false }
 
-        context 'arg :notifications is nil' do
+        context 'when arg :notifications is nil' do
           let(:arg_notifications) { nil }
 
           it 'returns Mute, and updates mute.hide_notifications true' do
@@ -210,7 +210,7 @@ describe AccountInteractions do
           end
         end
 
-        context 'arg :notifications is false' do
+        context 'when arg :notifications is false' do
           let(:arg_notifications) { false }
 
           it 'returns Mute without updating mute.hide_notifications' do
@@ -220,7 +220,7 @@ describe AccountInteractions do
           end
         end
 
-        context 'arg :notifications is true' do
+        context 'when arg :notifications is true' do
           let(:arg_notifications) { true }
 
           it 'returns Mute, and updates mute.hide_notifications true' do
@@ -260,7 +260,7 @@ describe AccountInteractions do
   describe '#unfollow!' do
     subject { account.unfollow!(target_account) }
 
-    context 'following target_account' do
+    context 'when following target_account' do
       it 'returns destroyed Follow' do
         account.active_relationships.create(target_account: target_account)
         expect(subject).to be_a Follow
@@ -268,7 +268,7 @@ describe AccountInteractions do
       end
     end
 
-    context 'not following target_account' do
+    context 'when not following target_account' do
       it 'returns nil' do
         expect(subject).to be_nil
       end
@@ -278,7 +278,7 @@ describe AccountInteractions do
   describe '#unblock!' do
     subject { account.unblock!(target_account) }
 
-    context 'blocking target_account' do
+    context 'when blocking target_account' do
       it 'returns destroyed Block' do
         account.block_relationships.create(target_account: target_account)
         expect(subject).to be_a Block
@@ -286,7 +286,7 @@ describe AccountInteractions do
       end
     end
 
-    context 'not blocking target_account' do
+    context 'when not blocking target_account' do
       it 'returns nil' do
         expect(subject).to be_nil
       end
@@ -296,7 +296,7 @@ describe AccountInteractions do
   describe '#unmute!' do
     subject { account.unmute!(target_account) }
 
-    context 'muting target_account' do
+    context 'when muting target_account' do
       it 'returns destroyed Mute' do
         account.mute_relationships.create(target_account: target_account)
         expect(subject).to be_a Mute
@@ -304,7 +304,7 @@ describe AccountInteractions do
       end
     end
 
-    context 'not muting target_account' do
+    context 'when not muting target_account' do
       it 'returns nil' do
         expect(subject).to be_nil
       end
@@ -316,7 +316,7 @@ describe AccountInteractions do
 
     let(:conversation) { Fabricate(:conversation) }
 
-    context 'muting the conversation' do
+    context 'when muting the conversation' do
       it 'returns destroyed ConversationMute' do
         account.conversation_mutes.create(conversation: conversation)
         expect(subject).to be_a ConversationMute
@@ -324,7 +324,7 @@ describe AccountInteractions do
       end
     end
 
-    context 'not muting the conversation' do
+    context 'when not muting the conversation' do
       it 'returns nil' do
         expect(subject).to be_nil
       end
@@ -336,7 +336,7 @@ describe AccountInteractions do
 
     let(:domain) { 'example.com' }
 
-    context 'blocking the domain' do
+    context 'when blocking the domain' do
       it 'returns destroyed AccountDomainBlock' do
         account_domain_block = Fabricate(:account_domain_block, domain: domain)
         account.domain_blocks << account_domain_block
@@ -345,7 +345,7 @@ describe AccountInteractions do
       end
     end
 
-    context 'unblocking the domain' do
+    context 'when unblocking the domain' do
       it 'returns nil' do
         expect(subject).to be_nil
       end
@@ -355,14 +355,14 @@ describe AccountInteractions do
   describe '#following?' do
     subject { account.following?(target_account) }
 
-    context 'following target_account' do
+    context 'when following target_account' do
       it 'returns true' do
         account.active_relationships.create(target_account: target_account)
         expect(subject).to be true
       end
     end
 
-    context 'not following target_account' do
+    context 'when not following target_account' do
       it 'returns false' do
         expect(subject).to be false
       end
@@ -372,14 +372,14 @@ describe AccountInteractions do
   describe '#followed_by?' do
     subject { account.followed_by?(target_account) }
 
-    context 'followed by target_account' do
+    context 'when followed by target_account' do
       it 'returns true' do
         account.passive_relationships.create(account: target_account)
         expect(subject).to be true
       end
     end
 
-    context 'not followed by target_account' do
+    context 'when not followed by target_account' do
       it 'returns false' do
         expect(subject).to be false
       end
@@ -389,14 +389,14 @@ describe AccountInteractions do
   describe '#blocking?' do
     subject { account.blocking?(target_account) }
 
-    context 'blocking target_account' do
+    context 'when blocking target_account' do
       it 'returns true' do
         account.block_relationships.create(target_account: target_account)
         expect(subject).to be true
       end
     end
 
-    context 'not blocking target_account' do
+    context 'when not blocking target_account' do
       it 'returns false' do
         expect(subject).to be false
       end
@@ -408,7 +408,7 @@ describe AccountInteractions do
 
     let(:domain) { 'example.com' }
 
-    context 'blocking the domain' do
+    context 'when blocking the domain' do
       it 'returns true' do
         account_domain_block = Fabricate(:account_domain_block, domain: domain)
         account.domain_blocks << account_domain_block
@@ -416,7 +416,7 @@ describe AccountInteractions do
       end
     end
 
-    context 'not blocking the domain' do
+    context 'when not blocking the domain' do
       it 'returns false' do
         expect(subject).to be false
       end
@@ -426,7 +426,7 @@ describe AccountInteractions do
   describe '#muting?' do
     subject { account.muting?(target_account) }
 
-    context 'muting target_account' do
+    context 'when muting target_account' do
       it 'returns true' do
         mute = Fabricate(:mute, account: account, target_account: target_account)
         account.mute_relationships << mute
@@ -434,7 +434,7 @@ describe AccountInteractions do
       end
     end
 
-    context 'not muting target_account' do
+    context 'when not muting target_account' do
       it 'returns false' do
         expect(subject).to be false
       end
@@ -446,14 +446,14 @@ describe AccountInteractions do
 
     let(:conversation) { Fabricate(:conversation) }
 
-    context 'muting the conversation' do
+    context 'when muting the conversation' do
       it 'returns true' do
         account.conversation_mutes.create(conversation: conversation)
         expect(subject).to be true
       end
     end
 
-    context 'not muting the conversation' do
+    context 'when not muting the conversation' do
       it 'returns false' do
         expect(subject).to be false
       end
@@ -468,7 +468,7 @@ describe AccountInteractions do
       account.mute_relationships << mute
     end
 
-    context 'muting notifications of target_account' do
+    context 'when muting notifications of target_account' do
       let(:hide) { true }
 
       it 'returns true' do
@@ -476,7 +476,7 @@ describe AccountInteractions do
       end
     end
 
-    context 'not muting notifications of target_account' do
+    context 'when not muting notifications of target_account' do
       let(:hide) { false }
 
       it 'returns false' do
@@ -488,14 +488,14 @@ describe AccountInteractions do
   describe '#requested?' do
     subject { account.requested?(target_account) }
 
-    context 'requested by target_account' do
+    context 'with requested by target_account' do
       it 'returns true' do
         Fabricate(:follow_request, account: account, target_account: target_account)
         expect(subject).to be true
       end
     end
 
-    context 'not requested by target_account' do
+    context 'when not requested by target_account' do
       it 'returns false' do
         expect(subject).to be false
       end
@@ -507,7 +507,7 @@ describe AccountInteractions do
 
     let(:status) { Fabricate(:status, account: account, favourites: favourites) }
 
-    context 'favorited' do
+    context 'when favorited' do
       let(:favourites) { [Fabricate(:favourite, account: account)] }
 
       it 'returns true' do
@@ -515,7 +515,7 @@ describe AccountInteractions do
       end
     end
 
-    context 'not favorited' do
+    context 'when not favorited' do
       let(:favourites) { [] }
 
       it 'returns false' do
@@ -529,7 +529,7 @@ describe AccountInteractions do
 
     let(:status) { Fabricate(:status, account: account, reblogs: reblogs) }
 
-    context 'reblogged' do
+    context 'with reblogged' do
       let(:reblogs) { [Fabricate(:status, account: account)] }
 
       it 'returns true' do
@@ -537,7 +537,7 @@ describe AccountInteractions do
       end
     end
 
-    context 'not reblogged' do
+    context 'when not reblogged' do
       let(:reblogs) { [] }
 
       it 'returns false' do
@@ -551,14 +551,14 @@ describe AccountInteractions do
 
     let(:status) { Fabricate(:status, account: account) }
 
-    context 'pinned' do
+    context 'when pinned' do
       it 'returns true' do
         Fabricate(:status_pin, account: account, status: status)
         expect(subject).to be true
       end
     end
 
-    context 'not pinned' do
+    context 'when not pinned' do
       it 'returns false' do
         expect(subject).to be false
       end
@@ -690,4 +690,32 @@ describe AccountInteractions do
       end
     end
   end
+
+  describe '#lists_for_local_distribution' do
+    let(:account)                 { Fabricate(:user, current_sign_in_at: Time.now.utc).account }
+    let!(:inactive_follower_user) { Fabricate(:user, current_sign_in_at: 5.years.ago) }
+    let!(:follower_user)          { Fabricate(:user, current_sign_in_at: Time.now.utc) }
+    let!(:follow_request_user)    { Fabricate(:user, current_sign_in_at: Time.now.utc) }
+
+    let!(:inactive_follower_list) { Fabricate(:list, account: inactive_follower_user.account) }
+    let!(:follower_list)          { Fabricate(:list, account: follower_user.account) }
+    let!(:follow_request_list)    { Fabricate(:list, account: follow_request_user.account) }
+
+    let!(:self_list)              { Fabricate(:list, account: account) }
+
+    before do
+      inactive_follower_user.account.follow!(account)
+      follower_user.account.follow!(account)
+      follow_request_user.account.follow_requests.create!(target_account: account)
+
+      inactive_follower_list.accounts << account
+      follower_list.accounts << account
+      follow_request_list.accounts << account
+      self_list.accounts << account
+    end
+
+    it 'includes only the list from the active follower and from oneself' do
+      expect(account.lists_for_local_distribution.to_a).to contain_exactly(follower_list, self_list)
+    end
+  end
 end
diff --git a/spec/models/concerns/remotable_spec.rb b/spec/models/concerns/remotable_spec.rb
index 9645204276..b2aa56a704 100644
--- a/spec/models/concerns/remotable_spec.rb
+++ b/spec/models/concerns/remotable_spec.rb
@@ -3,48 +3,47 @@
 require 'rails_helper'
 
 RSpec.describe Remotable do
-  class Foo
-    def initialize
-      @attrs = {}
-    end
+  let(:foo_class) do
+    Class.new do
+      def initialize
+        @attrs = {}
+      end
 
-    def [](arg)
-      @attrs[arg]
-    end
+      def [](arg)
+        @attrs[arg]
+      end
 
-    def []=(arg1, arg2)
-      @attrs[arg1] = arg2
-    end
+      def []=(arg1, arg2)
+        @attrs[arg1] = arg2
+      end
 
-    def hoge=(arg); end
+      def hoge=(arg); end
 
-    def hoge_file_name; end
+      def hoge_file_name; end
 
-    def hoge_file_name=(arg); end
+      def hoge_file_name=(arg); end
 
-    def has_attribute?(arg); end
+      def has_attribute?(arg); end
 
-    def self.attachment_definitions
-      { hoge: nil }
-    end
-  end
-
-  before do
-    class Foo
-      include Remotable
-
-      remotable_attachment :hoge, 1.kilobyte
+      def self.attachment_definitions
+        { hoge: nil }
+      end
     end
   end
 
   let(:attribute_name) { "#{hoge}_remote_url".to_sym }
   let(:code)           { 200 }
   let(:file)           { 'filename="foo.txt"' }
-  let(:foo)            { Foo.new }
+  let(:foo)            { foo_class.new }
   let(:headers)        { { 'content-disposition' => file } }
   let(:hoge)           { :hoge }
   let(:url)            { 'https://google.com' }
 
+  before do
+    foo_class.include described_class
+    foo_class.remotable_attachment :hoge, 1.kilobyte
+  end
+
   it 'defines a method #hoge_remote_url=' do
     expect(foo).to respond_to(:hoge_remote_url=)
   end
@@ -157,7 +156,7 @@ RSpec.describe Remotable do
       context 'when the response is successful' do
         let(:code) { 200 }
 
-        context 'and contains Content-Disposition header' do
+        context 'when contains Content-Disposition header' do
           let(:file)      { 'filename="foo.txt"' }
           let(:headers)   { { 'content-disposition' => file } }
 
diff --git a/spec/models/conversation_mute_spec.rb b/spec/models/conversation_mute_spec.rb
index 6439b0ecdf..3d5504a65c 100644
--- a/spec/models/conversation_mute_spec.rb
+++ b/spec/models/conversation_mute_spec.rb
@@ -2,5 +2,5 @@
 
 require 'rails_helper'
 
-RSpec.describe ConversationMute, type: :model do
+RSpec.describe ConversationMute do
 end
diff --git a/spec/models/conversation_spec.rb b/spec/models/conversation_spec.rb
index 9d58ad0ac6..c1d6659aa7 100644
--- a/spec/models/conversation_spec.rb
+++ b/spec/models/conversation_spec.rb
@@ -2,7 +2,7 @@
 
 require 'rails_helper'
 
-RSpec.describe Conversation, type: :model do
+RSpec.describe Conversation do
   describe '#local?' do
     it 'returns true when URI is nil' do
       expect(Fabricate(:conversation).local?).to be true
diff --git a/spec/models/custom_emoji_filter_spec.rb b/spec/models/custom_emoji_filter_spec.rb
index 8324a490bc..d8a2bea6b8 100644
--- a/spec/models/custom_emoji_filter_spec.rb
+++ b/spec/models/custom_emoji_filter_spec.rb
@@ -10,8 +10,8 @@ RSpec.describe CustomEmojiFilter do
     let!(:custom_emoji_1) { Fabricate(:custom_emoji, domain: 'b') }
     let!(:custom_emoji_2) { Fabricate(:custom_emoji, domain: nil, shortcode: 'hoge') }
 
-    context 'params have values' do
-      context 'local' do
+    context 'when params have values' do
+      context 'when local' do
         let(:params) { { local: true } }
 
         it 'returns ActiveRecord::Relation' do
@@ -20,7 +20,7 @@ RSpec.describe CustomEmojiFilter do
         end
       end
 
-      context 'remote' do
+      context 'when remote' do
         let(:params) { { remote: true } }
 
         it 'returns ActiveRecord::Relation' do
@@ -29,7 +29,7 @@ RSpec.describe CustomEmojiFilter do
         end
       end
 
-      context 'by_domain' do
+      context 'with by_domain' do
         let(:params) { { by_domain: 'a' } }
 
         it 'returns ActiveRecord::Relation' do
@@ -38,7 +38,7 @@ RSpec.describe CustomEmojiFilter do
         end
       end
 
-      context 'shortcode' do
+      context 'when shortcode' do
         let(:params) { { shortcode: 'hoge' } }
 
         it 'returns ActiveRecord::Relation' do
@@ -47,7 +47,7 @@ RSpec.describe CustomEmojiFilter do
         end
       end
 
-      context 'else' do
+      context 'when some other case' do
         let(:params) { { else: 'else' } }
 
         it 'raises Mastodon::InvalidParameterError' do
@@ -58,7 +58,7 @@ RSpec.describe CustomEmojiFilter do
       end
     end
 
-    context 'params without value' do
+    context 'when params without value' do
       let(:params) { { hoge: nil } }
 
       it 'returns ActiveRecord::Relation' do
diff --git a/spec/models/custom_emoji_spec.rb b/spec/models/custom_emoji_spec.rb
index ef5f39aca4..8a6487c321 100644
--- a/spec/models/custom_emoji_spec.rb
+++ b/spec/models/custom_emoji_spec.rb
@@ -2,13 +2,13 @@
 
 require 'rails_helper'
 
-RSpec.describe CustomEmoji, type: :model do
+RSpec.describe CustomEmoji do
   describe '#search' do
     subject { described_class.search(search_term) }
 
     let(:custom_emoji) { Fabricate(:custom_emoji, shortcode: shortcode) }
 
-    context 'shortcode is exact' do
+    context 'when shortcode is exact' do
       let(:shortcode) { 'blobpats' }
       let(:search_term) { 'blobpats' }
 
@@ -17,7 +17,7 @@ RSpec.describe CustomEmoji, type: :model do
       end
     end
 
-    context 'shortcode is partial' do
+    context 'when shortcode is partial' do
       let(:shortcode) { 'blobpats' }
       let(:search_term) { 'blob' }
 
@@ -32,7 +32,7 @@ RSpec.describe CustomEmoji, type: :model do
 
     let(:custom_emoji) { Fabricate(:custom_emoji, domain: domain) }
 
-    context 'domain is nil' do
+    context 'when domain is nil' do
       let(:domain) { nil }
 
       it 'returns true' do
@@ -40,7 +40,7 @@ RSpec.describe CustomEmoji, type: :model do
       end
     end
 
-    context 'domain is present' do
+    context 'when domain is present' do
       let(:domain) { 'example.com' }
 
       it 'returns false' do
diff --git a/spec/models/custom_filter_keyword_spec.rb b/spec/models/custom_filter_keyword_spec.rb
index bbc4b9c2ee..09a36fa3ed 100644
--- a/spec/models/custom_filter_keyword_spec.rb
+++ b/spec/models/custom_filter_keyword_spec.rb
@@ -2,5 +2,5 @@
 
 require 'rails_helper'
 
-RSpec.describe CustomFilterKeyword, type: :model do
+RSpec.describe CustomFilterKeyword do
 end
diff --git a/spec/models/custom_filter_spec.rb b/spec/models/custom_filter_spec.rb
index d2bc090ab0..f5e9b7ea96 100644
--- a/spec/models/custom_filter_spec.rb
+++ b/spec/models/custom_filter_spec.rb
@@ -2,5 +2,5 @@
 
 require 'rails_helper'
 
-RSpec.describe CustomFilter, type: :model do
+RSpec.describe CustomFilter do
 end
diff --git a/spec/models/device_spec.rb b/spec/models/device_spec.rb
index cb214b9cbc..4e7b8f2e20 100644
--- a/spec/models/device_spec.rb
+++ b/spec/models/device_spec.rb
@@ -2,5 +2,5 @@
 
 require 'rails_helper'
 
-RSpec.describe Device, type: :model do
+RSpec.describe Device do
 end
diff --git a/spec/models/domain_block_spec.rb b/spec/models/domain_block_spec.rb
index 9839ee9d4e..f10f470279 100644
--- a/spec/models/domain_block_spec.rb
+++ b/spec/models/domain_block_spec.rb
@@ -2,7 +2,7 @@
 
 require 'rails_helper'
 
-RSpec.describe DomainBlock, type: :model do
+RSpec.describe DomainBlock do
   describe 'validations' do
     it 'is invalid without a domain' do
       domain_block = Fabricate.build(:domain_block, domain: nil)
diff --git a/spec/models/email_domain_block_spec.rb b/spec/models/email_domain_block_spec.rb
index 3321ffc819..a7232eb6b4 100644
--- a/spec/models/email_domain_block_spec.rb
+++ b/spec/models/email_domain_block_spec.rb
@@ -2,11 +2,11 @@
 
 require 'rails_helper'
 
-RSpec.describe EmailDomainBlock, type: :model do
+RSpec.describe EmailDomainBlock do
   describe 'block?' do
     let(:input) { nil }
 
-    context 'given an e-mail address' do
+    context 'when given an e-mail address' do
       let(:input) { "foo@#{domain}" }
 
       context do
@@ -33,7 +33,7 @@ RSpec.describe EmailDomainBlock, type: :model do
       end
     end
 
-    context 'given an array of domains' do
+    context 'when given an array of domains' do
       let(:input) { %w(foo.com mail.foo.com) }
 
       it 'returns true if the domain is blocked' do
diff --git a/spec/models/encrypted_message_spec.rb b/spec/models/encrypted_message_spec.rb
index bf7a406ffd..c38142be1e 100644
--- a/spec/models/encrypted_message_spec.rb
+++ b/spec/models/encrypted_message_spec.rb
@@ -2,5 +2,5 @@
 
 require 'rails_helper'
 
-RSpec.describe EncryptedMessage, type: :model do
+RSpec.describe EncryptedMessage do
 end
diff --git a/spec/models/export_spec.rb b/spec/models/export_spec.rb
index 3fb5fc3a5b..a863678a33 100644
--- a/spec/models/export_spec.rb
+++ b/spec/models/export_spec.rb
@@ -10,7 +10,7 @@ describe Export do
 
   describe 'to_csv' do
     it 'returns a csv of the blocked accounts' do
-      target_accounts.each(&account.method(:block!))
+      target_accounts.each { |target_account| account.block!(target_account) }
 
       export = Export.new(account).to_blocked_accounts_csv
       results = export.strip.split
@@ -20,7 +20,7 @@ describe Export do
     end
 
     it 'returns a csv of the muted accounts' do
-      target_accounts.each(&account.method(:mute!))
+      target_accounts.each { |target_account| account.mute!(target_account) }
 
       export = Export.new(account).to_muted_accounts_csv
       results = export.strip.split("\n")
@@ -31,7 +31,7 @@ describe Export do
     end
 
     it 'returns a csv of the following accounts' do
-      target_accounts.each(&account.method(:follow!))
+      target_accounts.each { |target_account| account.follow!(target_account) }
 
       export = Export.new(account).to_following_accounts_csv
       results = export.strip.split("\n")
@@ -51,17 +51,17 @@ describe Export do
 
   describe 'total_follows' do
     it 'returns the total number of the followed accounts' do
-      target_accounts.each(&account.method(:follow!))
+      target_accounts.each { |target_account| account.follow!(target_account) }
       expect(Export.new(account.reload).total_follows).to eq 2
     end
 
     it 'returns the total number of the blocked accounts' do
-      target_accounts.each(&account.method(:block!))
+      target_accounts.each { |target_account| account.block!(target_account) }
       expect(Export.new(account.reload).total_blocks).to eq 2
     end
 
     it 'returns the total number of the muted accounts' do
-      target_accounts.each(&account.method(:mute!))
+      target_accounts.each { |target_account| account.mute!(target_account) }
       expect(Export.new(account.reload).total_mutes).to eq 2
     end
   end
diff --git a/spec/models/favourite_spec.rb b/spec/models/favourite_spec.rb
index f7e2812a6c..9e69570a01 100644
--- a/spec/models/favourite_spec.rb
+++ b/spec/models/favourite_spec.rb
@@ -2,7 +2,7 @@
 
 require 'rails_helper'
 
-RSpec.describe Favourite, type: :model do
+RSpec.describe Favourite do
   let(:account) { Fabricate(:account) }
 
   context 'when status is a reblog' do
diff --git a/spec/models/featured_tag_spec.rb b/spec/models/featured_tag_spec.rb
index 4bf087c828..58865f94a5 100644
--- a/spec/models/featured_tag_spec.rb
+++ b/spec/models/featured_tag_spec.rb
@@ -2,5 +2,5 @@
 
 require 'rails_helper'
 
-RSpec.describe FeaturedTag, type: :model do
+RSpec.describe FeaturedTag do
 end
diff --git a/spec/models/follow_recommendation_suppression_spec.rb b/spec/models/follow_recommendation_suppression_spec.rb
index 4c1d8281b2..d437d170d3 100644
--- a/spec/models/follow_recommendation_suppression_spec.rb
+++ b/spec/models/follow_recommendation_suppression_spec.rb
@@ -2,5 +2,5 @@
 
 require 'rails_helper'
 
-RSpec.describe FollowRecommendationSuppression, type: :model do
+RSpec.describe FollowRecommendationSuppression do
 end
diff --git a/spec/models/follow_request_spec.rb b/spec/models/follow_request_spec.rb
index ff81cd78d9..01faec0e70 100644
--- a/spec/models/follow_request_spec.rb
+++ b/spec/models/follow_request_spec.rb
@@ -2,15 +2,29 @@
 
 require 'rails_helper'
 
-RSpec.describe FollowRequest, type: :model do
+RSpec.describe FollowRequest do
   describe '#authorize!' do
-    let(:follow_request) { Fabricate(:follow_request, account: account, target_account: target_account) }
-    let(:account)        { Fabricate(:account) }
-    let(:target_account) { Fabricate(:account) }
+    let!(:follow_request) { Fabricate(:follow_request, account: account, target_account: target_account) }
+    let(:account)         { Fabricate(:account) }
+    let(:target_account)  { Fabricate(:account) }
+
+    context 'when the to-be-followed person has been added to a list' do
+      let!(:list) { Fabricate(:list, account: account) }
+
+      before do
+        list.accounts << target_account
+      end
+
+      it 'updates the ListAccount' do
+        expect { follow_request.authorize! }.to change { [list.list_accounts.first.follow_request_id, list.list_accounts.first.follow_id] }.from([follow_request.id, nil]).to([nil, anything])
+      end
+    end
 
     it 'calls Account#follow!, MergeWorker.perform_async, and #destroy!' do
-      expect(account).to        receive(:follow!).with(target_account, reblogs: true, notify: false, uri: follow_request.uri, languages: nil, bypass_limit: true)
-      expect(MergeWorker).to    receive(:perform_async).with(target_account.id, account.id)
+      expect(account).to receive(:follow!).with(target_account, reblogs: true, notify: false, uri: follow_request.uri, languages: nil, bypass_limit: true) do
+        account.active_relationships.create!(target_account: target_account)
+      end
+      expect(MergeWorker).to receive(:perform_async).with(target_account.id, account.id)
       expect(follow_request).to receive(:destroy!)
       follow_request.authorize!
     end
@@ -36,4 +50,22 @@ RSpec.describe FollowRequest, type: :model do
       expect(follow_request.account.muting_reblogs?(target)).to be true
     end
   end
+
+  describe '#reject!' do
+    let!(:follow_request) { Fabricate(:follow_request, account: account, target_account: target_account) }
+    let(:account)         { Fabricate(:account) }
+    let(:target_account)  { Fabricate(:account) }
+
+    context 'when the to-be-followed person has been added to a list' do
+      let!(:list) { Fabricate(:list, account: account) }
+
+      before do
+        list.accounts << target_account
+      end
+
+      it 'deletes the ListAccount record' do
+        expect { follow_request.reject! }.to change { list.accounts.count }.from(1).to(0)
+      end
+    end
+  end
 end
diff --git a/spec/models/follow_spec.rb b/spec/models/follow_spec.rb
index a9a9af88ad..79c0048f9f 100644
--- a/spec/models/follow_spec.rb
+++ b/spec/models/follow_spec.rb
@@ -2,7 +2,7 @@
 
 require 'rails_helper'
 
-RSpec.describe Follow, type: :model do
+RSpec.describe Follow do
   let(:alice) { Fabricate(:account, username: 'alice') }
   let(:bob)   { Fabricate(:account, username: 'bob') }
 
diff --git a/spec/models/form/import_spec.rb b/spec/models/form/import_spec.rb
new file mode 100644
index 0000000000..e1fea4205c
--- /dev/null
+++ b/spec/models/form/import_spec.rb
@@ -0,0 +1,281 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe Form::Import do
+  subject { described_class.new(current_account: account, type: import_type, mode: import_mode, data: data) }
+
+  let(:account)     { Fabricate(:account) }
+  let(:data)        { fixture_file_upload(import_file) }
+  let(:import_mode) { 'merge' }
+
+  describe 'validations' do
+    shared_examples 'incompatible import type' do |type, file|
+      let(:import_file) { file }
+      let(:import_type) { type }
+
+      it 'has errors' do
+        subject.validate
+        expect(subject.errors[:data]).to include(I18n.t('imports.errors.incompatible_type'))
+      end
+    end
+
+    shared_examples 'too many CSV rows' do |type, file, allowed_rows|
+      let(:import_file) { file }
+      let(:import_type) { type }
+
+      before do
+        stub_const 'Form::Import::ROWS_PROCESSING_LIMIT', allowed_rows
+      end
+
+      it 'has errors' do
+        subject.validate
+        expect(subject.errors[:data]).to include(I18n.t('imports.errors.over_rows_processing_limit', count: Form::Import::ROWS_PROCESSING_LIMIT))
+      end
+    end
+
+    shared_examples 'valid import' do |type, file|
+      let(:import_file) { file }
+      let(:import_type) { type }
+
+      it 'passes validation' do
+        expect(subject).to be_valid
+      end
+    end
+
+    context 'when the file too large' do
+      let(:import_type) { 'following' }
+      let(:import_file) { 'imports.txt' }
+
+      before do
+        stub_const 'Form::Import::FILE_SIZE_LIMIT', 5
+      end
+
+      it 'has errors' do
+        subject.validate
+        expect(subject.errors[:data]).to include(I18n.t('imports.errors.too_large'))
+      end
+    end
+
+    context 'when the CSV file is malformed CSV' do
+      let(:import_type) { 'following' }
+      let(:import_file) { 'boop.ogg' }
+
+      it 'has errors' do
+        # NOTE: not testing more specific error because we don't know the string to match
+        expect(subject).to model_have_error_on_field(:data)
+      end
+    end
+
+    context 'when importing more follows than allowed' do
+      let(:import_type) { 'following' }
+      let(:import_file) { 'imports.txt' }
+
+      before do
+        allow(FollowLimitValidator).to receive(:limit_for_account).with(account).and_return(1)
+      end
+
+      it 'has errors' do
+        subject.validate
+        expect(subject.errors[:data]).to include(I18n.t('users.follow_limit_reached', limit: 1))
+      end
+    end
+
+    it_behaves_like 'too many CSV rows', 'following', 'imports.txt', 1
+    it_behaves_like 'too many CSV rows', 'blocking', 'imports.txt', 1
+    it_behaves_like 'too many CSV rows', 'muting', 'imports.txt', 1
+    it_behaves_like 'too many CSV rows', 'domain_blocking', 'domain_blocks.csv', 2
+    it_behaves_like 'too many CSV rows', 'bookmarks', 'bookmark-imports.txt', 3
+
+    # Importing list of addresses with no headers into various types
+    it_behaves_like 'valid import', 'following', 'imports.txt'
+    it_behaves_like 'valid import', 'blocking', 'imports.txt'
+    it_behaves_like 'valid import', 'muting', 'imports.txt'
+
+    # Importing domain blocks with headers into expected type
+    it_behaves_like 'valid import', 'domain_blocking', 'domain_blocks.csv'
+
+    # Importing bookmarks list with no headers into expected type
+    it_behaves_like 'valid import', 'bookmarks', 'bookmark-imports.txt'
+
+    # Importing followed accounts with headers into various compatible types
+    it_behaves_like 'valid import', 'following', 'following_accounts.csv'
+    it_behaves_like 'valid import', 'blocking', 'following_accounts.csv'
+    it_behaves_like 'valid import', 'muting', 'following_accounts.csv'
+
+    # Importing domain blocks with headers into incompatible types
+    it_behaves_like 'incompatible import type', 'following', 'domain_blocks.csv'
+    it_behaves_like 'incompatible import type', 'blocking', 'domain_blocks.csv'
+    it_behaves_like 'incompatible import type', 'muting', 'domain_blocks.csv'
+    it_behaves_like 'incompatible import type', 'bookmarks', 'domain_blocks.csv'
+
+    # Importing followed accounts with headers into incompatible types
+    it_behaves_like 'incompatible import type', 'domain_blocking', 'following_accounts.csv'
+    it_behaves_like 'incompatible import type', 'bookmarks', 'following_accounts.csv'
+  end
+
+  describe '#guessed_type' do
+    shared_examples 'with enough information' do |type, file, original_filename, expected_guess|
+      let(:import_file) { file }
+      let(:import_type) { type }
+
+      before do
+        allow(data).to receive(:original_filename).and_return(original_filename)
+      end
+
+      it 'guesses the expected type' do
+        expect(subject.guessed_type).to eq expected_guess
+      end
+    end
+
+    context 'when the headers are enough to disambiguate' do
+      it_behaves_like 'with enough information', 'following', 'following_accounts.csv', 'import.csv', :following
+      it_behaves_like 'with enough information', 'blocking', 'following_accounts.csv', 'import.csv', :following
+      it_behaves_like 'with enough information', 'muting', 'following_accounts.csv', 'import.csv', :following
+
+      it_behaves_like 'with enough information', 'following', 'muted_accounts.csv', 'imports.csv', :muting
+      it_behaves_like 'with enough information', 'blocking', 'muted_accounts.csv', 'imports.csv', :muting
+      it_behaves_like 'with enough information', 'muting', 'muted_accounts.csv', 'imports.csv', :muting
+    end
+
+    context 'when the file name is enough to disambiguate' do
+      it_behaves_like 'with enough information', 'following', 'imports.txt', 'following_accounts.csv', :following
+      it_behaves_like 'with enough information', 'blocking', 'imports.txt', 'following_accounts.csv', :following
+      it_behaves_like 'with enough information', 'muting', 'imports.txt', 'following_accounts.csv', :following
+
+      it_behaves_like 'with enough information', 'following', 'imports.txt', 'follows.csv', :following
+      it_behaves_like 'with enough information', 'blocking', 'imports.txt', 'follows.csv', :following
+      it_behaves_like 'with enough information', 'muting', 'imports.txt', 'follows.csv', :following
+
+      it_behaves_like 'with enough information', 'following', 'imports.txt', 'blocked_accounts.csv', :blocking
+      it_behaves_like 'with enough information', 'blocking', 'imports.txt', 'blocked_accounts.csv', :blocking
+      it_behaves_like 'with enough information', 'muting', 'imports.txt', 'blocked_accounts.csv', :blocking
+
+      it_behaves_like 'with enough information', 'following', 'imports.txt', 'blocks.csv', :blocking
+      it_behaves_like 'with enough information', 'blocking', 'imports.txt', 'blocks.csv', :blocking
+      it_behaves_like 'with enough information', 'muting', 'imports.txt', 'blocks.csv', :blocking
+
+      it_behaves_like 'with enough information', 'following', 'imports.txt', 'muted_accounts.csv', :muting
+      it_behaves_like 'with enough information', 'blocking', 'imports.txt', 'muted_accounts.csv', :muting
+      it_behaves_like 'with enough information', 'muting', 'imports.txt', 'muted_accounts.csv', :muting
+
+      it_behaves_like 'with enough information', 'following', 'imports.txt', 'mutes.csv', :muting
+      it_behaves_like 'with enough information', 'blocking', 'imports.txt', 'mutes.csv', :muting
+      it_behaves_like 'with enough information', 'muting', 'imports.txt', 'mutes.csv', :muting
+    end
+  end
+
+  describe '#likely_mismatched?' do
+    shared_examples 'with matching types' do |type, file, original_filename = nil|
+      let(:import_file) { file }
+      let(:import_type) { type }
+
+      before do
+        allow(data).to receive(:original_filename).and_return(original_filename) if original_filename.present?
+      end
+
+      it 'returns false' do
+        expect(subject.likely_mismatched?).to be false
+      end
+    end
+
+    shared_examples 'with mismatching types' do |type, file, original_filename = nil|
+      let(:import_file) { file }
+      let(:import_type) { type }
+
+      before do
+        allow(data).to receive(:original_filename).and_return(original_filename) if original_filename.present?
+      end
+
+      it 'returns true' do
+        expect(subject.likely_mismatched?).to be true
+      end
+    end
+
+    it_behaves_like 'with matching types', 'following', 'following_accounts.csv'
+    it_behaves_like 'with matching types', 'following', 'following_accounts.csv', 'imports.txt'
+    it_behaves_like 'with matching types', 'following', 'imports.txt'
+    it_behaves_like 'with matching types', 'blocking', 'imports.txt', 'blocks.csv'
+    it_behaves_like 'with matching types', 'blocking', 'imports.txt'
+    it_behaves_like 'with matching types', 'muting', 'muted_accounts.csv'
+    it_behaves_like 'with matching types', 'muting', 'muted_accounts.csv', 'imports.txt'
+    it_behaves_like 'with matching types', 'muting', 'imports.txt'
+    it_behaves_like 'with matching types', 'domain_blocking', 'domain_blocks.csv'
+    it_behaves_like 'with matching types', 'domain_blocking', 'domain_blocks.csv', 'imports.txt'
+    it_behaves_like 'with matching types', 'bookmarks', 'bookmark-imports.txt'
+    it_behaves_like 'with matching types', 'bookmarks', 'bookmark-imports.txt', 'imports.txt'
+
+    it_behaves_like 'with mismatching types', 'following', 'imports.txt', 'blocks.csv'
+    it_behaves_like 'with mismatching types', 'following', 'imports.txt', 'blocked_accounts.csv'
+    it_behaves_like 'with mismatching types', 'following', 'imports.txt', 'mutes.csv'
+    it_behaves_like 'with mismatching types', 'following', 'imports.txt', 'muted_accounts.csv'
+    it_behaves_like 'with mismatching types', 'following', 'muted_accounts.csv'
+    it_behaves_like 'with mismatching types', 'following', 'muted_accounts.csv', 'imports.txt'
+    it_behaves_like 'with mismatching types', 'blocking', 'following_accounts.csv'
+    it_behaves_like 'with mismatching types', 'blocking', 'following_accounts.csv', 'imports.txt'
+    it_behaves_like 'with mismatching types', 'blocking', 'muted_accounts.csv'
+    it_behaves_like 'with mismatching types', 'blocking', 'muted_accounts.csv', 'imports.txt'
+    it_behaves_like 'with mismatching types', 'blocking', 'imports.txt', 'follows.csv'
+    it_behaves_like 'with mismatching types', 'blocking', 'imports.txt', 'following_accounts.csv'
+    it_behaves_like 'with mismatching types', 'blocking', 'imports.txt', 'mutes.csv'
+    it_behaves_like 'with mismatching types', 'blocking', 'imports.txt', 'muted_accounts.csv'
+    it_behaves_like 'with mismatching types', 'muting', 'following_accounts.csv'
+    it_behaves_like 'with mismatching types', 'muting', 'following_accounts.csv', 'imports.txt'
+    it_behaves_like 'with mismatching types', 'muting', 'imports.txt', 'follows.csv'
+    it_behaves_like 'with mismatching types', 'muting', 'imports.txt', 'following_accounts.csv'
+    it_behaves_like 'with mismatching types', 'muting', 'imports.txt', 'blocks.csv'
+    it_behaves_like 'with mismatching types', 'muting', 'imports.txt', 'blocked_accounts.csv'
+  end
+
+  describe 'save' do
+    shared_examples 'on successful import' do |type, mode, file, expected_rows|
+      let(:import_type) { type }
+      let(:import_file) { file }
+      let(:import_mode) { mode }
+
+      before do
+        subject.save
+      end
+
+      it 'creates the expected rows' do
+        expect(account.bulk_imports.first.rows.pluck(:data)).to match_array(expected_rows)
+      end
+
+      it 'creates a BulkImport with expected attributes' do
+        bulk_import = account.bulk_imports.first
+        expect(bulk_import).to_not be_nil
+        expect(bulk_import.type.to_sym).to eq subject.type.to_sym
+        expect(bulk_import.original_filename).to eq subject.data.original_filename
+        expect(bulk_import.likely_mismatched?).to eq subject.likely_mismatched?
+        expect(bulk_import.overwrite?).to eq !!subject.overwrite # rubocop:disable Style/DoubleNegation
+        expect(bulk_import.processed_items).to eq 0
+        expect(bulk_import.imported_items).to eq 0
+        expect(bulk_import.total_items).to eq bulk_import.rows.count
+        expect(bulk_import.unconfirmed?).to be true
+      end
+    end
+
+    it_behaves_like 'on successful import', 'following', 'merge', 'imports.txt', (%w(user@example.com user@test.com).map { |acct| { 'acct' => acct } })
+    it_behaves_like 'on successful import', 'following', 'overwrite', 'imports.txt', (%w(user@example.com user@test.com).map { |acct| { 'acct' => acct } })
+    it_behaves_like 'on successful import', 'blocking', 'merge', 'imports.txt', (%w(user@example.com user@test.com).map { |acct| { 'acct' => acct } })
+    it_behaves_like 'on successful import', 'blocking', 'overwrite', 'imports.txt', (%w(user@example.com user@test.com).map { |acct| { 'acct' => acct } })
+    it_behaves_like 'on successful import', 'muting', 'merge', 'imports.txt', (%w(user@example.com user@test.com).map { |acct| { 'acct' => acct } })
+    it_behaves_like 'on successful import', 'domain_blocking', 'merge', 'domain_blocks.csv', (%w(bad.domain worse.domain reject.media).map { |domain| { 'domain' => domain } })
+    it_behaves_like 'on successful import', 'bookmarks', 'merge', 'bookmark-imports.txt', (%w(https://example.com/statuses/1312 https://local.com/users/foo/statuses/42 https://unknown-remote.com/users/bar/statuses/1 https://example.com/statuses/direct).map { |uri| { 'uri' => uri } })
+
+    it_behaves_like 'on successful import', 'following', 'merge', 'following_accounts.csv', [
+      { 'acct' => 'user@example.com', 'show_reblogs' => true, 'notify' => false, 'languages' => nil },
+      { 'acct' => 'user@test.com', 'show_reblogs' => true, 'notify' => true, 'languages' => ['en', 'fr'] },
+    ]
+
+    it_behaves_like 'on successful import', 'muting', 'merge', 'muted_accounts.csv', [
+      { 'acct' => 'user@example.com', 'hide_notifications' => true },
+      { 'acct' => 'user@test.com', 'hide_notifications' => false },
+    ]
+
+    # Based on the bug report 20571 where UTF-8 encoded domains were rejecting import of their users
+    #
+    # https://github.com/mastodon/mastodon/issues/20571
+    it_behaves_like 'on successful import', 'following', 'merge', 'utf8-followers.txt', [{ 'acct' => 'nare@թութ.հայ' }]
+  end
+end
diff --git a/spec/models/home_feed_spec.rb b/spec/models/home_feed_spec.rb
index d7034f3f0b..bd649d8269 100644
--- a/spec/models/home_feed_spec.rb
+++ b/spec/models/home_feed_spec.rb
@@ -2,7 +2,7 @@
 
 require 'rails_helper'
 
-RSpec.describe HomeFeed, type: :model do
+RSpec.describe HomeFeed do
   subject { described_class.new(account) }
 
   let(:account) { Fabricate(:account) }
diff --git a/spec/models/identity_spec.rb b/spec/models/identity_spec.rb
index 6eab5a2e18..59155781c7 100644
--- a/spec/models/identity_spec.rb
+++ b/spec/models/identity_spec.rb
@@ -2,7 +2,7 @@
 
 require 'rails_helper'
 
-RSpec.describe Identity, type: :model do
+RSpec.describe Identity do
   describe '.find_for_oauth' do
     let(:auth) { Fabricate(:identity, user: Fabricate(:user)) }
 
diff --git a/spec/models/import_spec.rb b/spec/models/import_spec.rb
index 1c84744138..1dae40a739 100644
--- a/spec/models/import_spec.rb
+++ b/spec/models/import_spec.rb
@@ -2,7 +2,7 @@
 
 require 'rails_helper'
 
-RSpec.describe Import, type: :model do
+RSpec.describe Import do
   let(:account) { Fabricate(:account) }
   let(:type) { 'following' }
   let(:data) { attachment_fixture('imports.txt') }
@@ -22,20 +22,5 @@ RSpec.describe Import, type: :model do
       import = Import.create(account: account, type: type)
       expect(import).to model_have_error_on_field(:data)
     end
-
-    it 'is invalid with malformed data' do
-      import = Import.create(account: account, type: type, data: StringIO.new('\"test'))
-      expect(import).to model_have_error_on_field(:data)
-    end
-
-    it 'is invalid with too many rows in data' do
-      import = Import.create(account: account, type: type, data: StringIO.new("foo@bar.com\n" * (ImportService::ROWS_PROCESSING_LIMIT + 10)))
-      expect(import).to model_have_error_on_field(:data)
-    end
-
-    it 'is invalid when there are more rows when following limit' do
-      import = Import.create(account: account, type: type, data: StringIO.new("foo@bar.com\n" * (FollowLimitValidator.limit_for_account(account) + 10)))
-      expect(import).to model_have_error_on_field(:data)
-    end
   end
 end
diff --git a/spec/models/invite_spec.rb b/spec/models/invite_spec.rb
index dac4b6431b..4ad589f2c7 100644
--- a/spec/models/invite_spec.rb
+++ b/spec/models/invite_spec.rb
@@ -2,7 +2,7 @@
 
 require 'rails_helper'
 
-RSpec.describe Invite, type: :model do
+RSpec.describe Invite do
   describe '#valid_for_use?' do
     it 'returns true when there are no limitations' do
       invite = Fabricate(:invite, max_uses: nil, expires_at: nil)
diff --git a/spec/models/list_account_spec.rb b/spec/models/list_account_spec.rb
index 8312defaca..c9853d4a04 100644
--- a/spec/models/list_account_spec.rb
+++ b/spec/models/list_account_spec.rb
@@ -2,5 +2,5 @@
 
 require 'rails_helper'
 
-RSpec.describe ListAccount, type: :model do
+RSpec.describe ListAccount do
 end
diff --git a/spec/models/list_spec.rb b/spec/models/list_spec.rb
index 8167f8a7ec..621ad3968d 100644
--- a/spec/models/list_spec.rb
+++ b/spec/models/list_spec.rb
@@ -2,5 +2,5 @@
 
 require 'rails_helper'
 
-RSpec.describe List, type: :model do
+RSpec.describe List do
 end
diff --git a/spec/models/login_activity_spec.rb b/spec/models/login_activity_spec.rb
index 1c3111a20d..2214d3ef39 100644
--- a/spec/models/login_activity_spec.rb
+++ b/spec/models/login_activity_spec.rb
@@ -2,5 +2,5 @@
 
 require 'rails_helper'
 
-RSpec.describe LoginActivity, type: :model do
+RSpec.describe LoginActivity do
 end
diff --git a/spec/models/media_attachment_spec.rb b/spec/models/media_attachment_spec.rb
index 63edfc1524..becc748244 100644
--- a/spec/models/media_attachment_spec.rb
+++ b/spec/models/media_attachment_spec.rb
@@ -2,13 +2,13 @@
 
 require 'rails_helper'
 
-RSpec.describe MediaAttachment, type: :model do
+RSpec.describe MediaAttachment do
   describe 'local?' do
     subject { media_attachment.local? }
 
     let(:media_attachment) { Fabricate(:media_attachment, remote_url: remote_url) }
 
-    context 'remote_url is blank' do
+    context 'when remote_url is blank' do
       let(:remote_url) { '' }
 
       it 'returns true' do
@@ -16,7 +16,7 @@ RSpec.describe MediaAttachment, type: :model do
       end
     end
 
-    context 'remote_url is present' do
+    context 'when remote_url is present' do
       let(:remote_url) { 'remote_url' }
 
       it 'returns false' do
@@ -30,10 +30,10 @@ RSpec.describe MediaAttachment, type: :model do
 
     let(:media_attachment) { Fabricate(:media_attachment, remote_url: remote_url, file: file) }
 
-    context 'file is blank' do
+    context 'when file is blank' do
       let(:file) { nil }
 
-      context 'remote_url is present' do
+      context 'when remote_url is present' do
         let(:remote_url) { 'remote_url' }
 
         it 'returns true' do
@@ -42,10 +42,10 @@ RSpec.describe MediaAttachment, type: :model do
       end
     end
 
-    context 'file is present' do
+    context 'when file is present' do
       let(:file) { attachment_fixture('avatar.gif') }
 
-      context 'remote_url is blank' do
+      context 'when remote_url is blank' do
         let(:remote_url) { '' }
 
         it 'returns false' do
@@ -53,7 +53,7 @@ RSpec.describe MediaAttachment, type: :model do
         end
       end
 
-      context 'remote_url is present' do
+      context 'when remote_url is present' do
         let(:remote_url) { 'remote_url' }
 
         it 'returns true' do
diff --git a/spec/models/mention_spec.rb b/spec/models/mention_spec.rb
index 044bb80cf6..b241049a54 100644
--- a/spec/models/mention_spec.rb
+++ b/spec/models/mention_spec.rb
@@ -2,7 +2,7 @@
 
 require 'rails_helper'
 
-RSpec.describe Mention, type: :model do
+RSpec.describe Mention do
   describe 'validations' do
     it 'is invalid without an account' do
       mention = Fabricate.build(:mention, account: nil)
diff --git a/spec/models/mute_spec.rb b/spec/models/mute_spec.rb
index 48b5a37ab9..050083d0f8 100644
--- a/spec/models/mute_spec.rb
+++ b/spec/models/mute_spec.rb
@@ -2,5 +2,5 @@
 
 require 'rails_helper'
 
-RSpec.describe Mute, type: :model do
+RSpec.describe Mute do
 end
diff --git a/spec/models/notification_spec.rb b/spec/models/notification_spec.rb
index 64527e3d77..795491546c 100644
--- a/spec/models/notification_spec.rb
+++ b/spec/models/notification_spec.rb
@@ -2,7 +2,7 @@
 
 require 'rails_helper'
 
-RSpec.describe Notification, type: :model do
+RSpec.describe Notification do
   describe '#target_status' do
     let(:notification) { Fabricate(:notification, activity: activity) }
     let(:status)       { Fabricate(:status) }
@@ -10,7 +10,7 @@ RSpec.describe Notification, type: :model do
     let(:favourite)    { Fabricate(:favourite, status: status) }
     let(:mention)      { Fabricate(:mention, status: status) }
 
-    context 'activity is reblog' do
+    context 'when Activity is reblog' do
       let(:activity) { reblog }
 
       it 'returns status' do
@@ -18,7 +18,7 @@ RSpec.describe Notification, type: :model do
       end
     end
 
-    context 'activity is favourite' do
+    context 'when Activity is favourite' do
       let(:type)     { :favourite }
       let(:activity) { favourite }
 
@@ -27,7 +27,7 @@ RSpec.describe Notification, type: :model do
       end
     end
 
-    context 'activity is mention' do
+    context 'when Activity is mention' do
       let(:activity) { mention }
 
       it 'returns status' do
@@ -66,7 +66,7 @@ RSpec.describe Notification, type: :model do
       end
     end
 
-    context 'notifications are empty' do
+    context 'when notifications are empty' do
       let(:notifications) { [] }
 
       it 'returns []' do
@@ -74,7 +74,7 @@ RSpec.describe Notification, type: :model do
       end
     end
 
-    context 'notifications are present' do
+    context 'when notifications are present' do
       before do
         notifications.each(&:reload)
       end
diff --git a/spec/models/poll_vote_spec.rb b/spec/models/poll_vote_spec.rb
index 6886a82aa8..b017ea5279 100644
--- a/spec/models/poll_vote_spec.rb
+++ b/spec/models/poll_vote_spec.rb
@@ -2,7 +2,7 @@
 
 require 'rails_helper'
 
-RSpec.describe PollVote, type: :model do
+RSpec.describe PollVote do
   describe '#object_type' do
     let(:poll_vote) { Fabricate.build(:poll_vote) }
 
diff --git a/spec/models/preview_card_spec.rb b/spec/models/preview_card_spec.rb
index 1858644c91..7d687d16ff 100644
--- a/spec/models/preview_card_spec.rb
+++ b/spec/models/preview_card_spec.rb
@@ -2,5 +2,5 @@
 
 require 'rails_helper'
 
-RSpec.describe PreviewCard, type: :model do
+RSpec.describe PreviewCard do
 end
diff --git a/spec/models/preview_card_trend_spec.rb b/spec/models/preview_card_trend_spec.rb
index 97ad05e754..a31bf71cc0 100644
--- a/spec/models/preview_card_trend_spec.rb
+++ b/spec/models/preview_card_trend_spec.rb
@@ -2,5 +2,5 @@
 
 require 'rails_helper'
 
-RSpec.describe PreviewCardTrend, type: :model do
+RSpec.describe PreviewCardTrend do
 end
diff --git a/spec/models/public_feed_spec.rb b/spec/models/public_feed_spec.rb
index d31aba084d..fbbdf62584 100644
--- a/spec/models/public_feed_spec.rb
+++ b/spec/models/public_feed_spec.rb
@@ -2,7 +2,7 @@
 
 require 'rails_helper'
 
-RSpec.describe PublicFeed, type: :model do
+RSpec.describe PublicFeed do
   let(:account) { Fabricate(:account) }
 
   describe '#get' do
diff --git a/spec/models/relay_spec.rb b/spec/models/relay_spec.rb
index 86c1762c15..7ed49e7334 100644
--- a/spec/models/relay_spec.rb
+++ b/spec/models/relay_spec.rb
@@ -2,5 +2,5 @@
 
 require 'rails_helper'
 
-RSpec.describe Relay, type: :model do
+RSpec.describe Relay do
 end
diff --git a/spec/models/remote_follow_spec.rb b/spec/models/remote_follow_spec.rb
index ea36b00769..81c726a40b 100644
--- a/spec/models/remote_follow_spec.rb
+++ b/spec/models/remote_follow_spec.rb
@@ -13,7 +13,7 @@ RSpec.describe RemoteFollow do
   describe '.initialize' do
     subject { remote_follow.acct }
 
-    context 'attrs with acct' do
+    context 'when attrs with acct' do
       let(:attrs) { { acct: 'gargron@quitter.no' } }
 
       it 'returns acct' do
@@ -21,7 +21,7 @@ RSpec.describe RemoteFollow do
       end
     end
 
-    context 'attrs without acct' do
+    context 'when attrs without acct' do
       let(:attrs) { {} }
 
       it do
@@ -33,7 +33,7 @@ RSpec.describe RemoteFollow do
   describe '#valid?' do
     subject { remote_follow.valid? }
 
-    context 'attrs with acct' do
+    context 'when attrs with acct' do
       let(:attrs) { { acct: 'gargron@quitter.no' } }
 
       it do
@@ -41,7 +41,7 @@ RSpec.describe RemoteFollow do
       end
     end
 
-    context 'attrs without acct' do
+    context 'when attrs without acct' do
       let(:attrs) { {} }
 
       it do
diff --git a/spec/models/report_spec.rb b/spec/models/report_spec.rb
index 20a048c334..b006f60bb6 100644
--- a/spec/models/report_spec.rb
+++ b/spec/models/report_spec.rb
@@ -89,13 +89,13 @@ describe Report do
 
     let(:report) { Fabricate(:report, action_taken_at: action_taken) }
 
-    context 'if action is taken' do
+    context 'when action is taken' do
       let(:action_taken) { Time.now.utc }
 
       it { is_expected.to be false }
     end
 
-    context 'if action not is taken' do
+    context 'when action not is taken' do
       let(:action_taken) { nil }
 
       it { is_expected.to be true }
diff --git a/spec/models/scheduled_status_spec.rb b/spec/models/scheduled_status_spec.rb
index 294fa9f36c..286c17e696 100644
--- a/spec/models/scheduled_status_spec.rb
+++ b/spec/models/scheduled_status_spec.rb
@@ -2,5 +2,5 @@
 
 require 'rails_helper'
 
-RSpec.describe ScheduledStatus, type: :model do
+RSpec.describe ScheduledStatus do
 end
diff --git a/spec/models/session_activation_spec.rb b/spec/models/session_activation_spec.rb
index 375199d575..51c6aa5cb0 100644
--- a/spec/models/session_activation_spec.rb
+++ b/spec/models/session_activation_spec.rb
@@ -2,7 +2,7 @@
 
 require 'rails_helper'
 
-RSpec.describe SessionActivation, type: :model do
+RSpec.describe SessionActivation do
   describe '#detection' do
     let(:session_activation) { Fabricate(:session_activation, user_agent: 'Chrome/62.0.3202.89') }
 
@@ -40,7 +40,7 @@ RSpec.describe SessionActivation, type: :model do
   describe '.active?' do
     subject { described_class.active?(id) }
 
-    context 'id is absent' do
+    context 'when id is absent' do
       let(:id) { nil }
 
       it 'returns nil' do
@@ -48,17 +48,17 @@ RSpec.describe SessionActivation, type: :model do
       end
     end
 
-    context 'id is present' do
+    context 'when id is present' do
       let(:id) { '1' }
       let!(:session_activation) { Fabricate(:session_activation, session_id: id) }
 
-      context 'id exists as session_id' do
+      context 'when id exists as session_id' do
         it 'returns true' do
           expect(subject).to be true
         end
       end
 
-      context 'id does not exist as session_id' do
+      context 'when id does not exist as session_id' do
         before do
           session_activation.update!(session_id: '2')
         end
@@ -85,7 +85,7 @@ RSpec.describe SessionActivation, type: :model do
   end
 
   describe '.deactivate' do
-    context 'id is absent' do
+    context 'when id is absent' do
       let(:id) { nil }
 
       it 'returns nil' do
@@ -93,7 +93,7 @@ RSpec.describe SessionActivation, type: :model do
       end
     end
 
-    context 'id exists' do
+    context 'when id exists' do
       let(:id) { '1' }
 
       it 'calls where.destroy_all' do
diff --git a/spec/models/setting_spec.rb b/spec/models/setting_spec.rb
index 826a13878f..accce10f86 100644
--- a/spec/models/setting_spec.rb
+++ b/spec/models/setting_spec.rb
@@ -2,7 +2,7 @@
 
 require 'rails_helper'
 
-RSpec.describe Setting, type: :model do
+RSpec.describe Setting do
   describe '#to_param' do
     let(:setting) { Fabricate(:setting, var: var) }
     let(:var)     { 'var' }
@@ -19,7 +19,7 @@ RSpec.describe Setting, type: :model do
 
     let(:key) { 'key' }
 
-    context 'rails_initialized? is falsey' do
+    context 'when rails_initialized? is falsey' do
       let(:rails_initialized) { false }
 
       it 'calls RailsSettings::Base#[]' do
@@ -28,7 +28,7 @@ RSpec.describe Setting, type: :model do
       end
     end
 
-    context 'rails_initialized? is truthy' do
+    context 'when rails_initialized? is truthy' do
       before do
         allow(RailsSettings::Base).to receive(:cache_key).with(key, nil).and_return(cache_key)
       end
@@ -42,7 +42,7 @@ RSpec.describe Setting, type: :model do
         described_class[key]
       end
 
-      context 'Rails.cache does not exists' do
+      context 'when Rails.cache does not exists' do
         before do
           allow(RailsSettings::Settings).to receive(:object).with(key).and_return(object)
           allow(described_class).to receive(:default_settings).and_return(default_settings)
@@ -60,11 +60,11 @@ RSpec.describe Setting, type: :model do
           described_class[key]
         end
 
-        context 'RailsSettings::Settings.object returns truthy' do
+        context 'when RailsSettings::Settings.object returns truthy' do
           let(:object) { db_val }
           let(:db_val) { double(value: 'db_val') }
 
-          context 'default_value is a Hash' do
+          context 'when default_value is a Hash' do
             let(:default_value) { { default_value: 'default_value' } }
 
             it 'calls default_value.with_indifferent_access.merge!' do
@@ -75,7 +75,7 @@ RSpec.describe Setting, type: :model do
             end
           end
 
-          context 'default_value is not a Hash' do
+          context 'when default_value is not a Hash' do
             let(:default_value) { 'default_value' }
 
             it 'returns db_val.value' do
@@ -84,7 +84,7 @@ RSpec.describe Setting, type: :model do
           end
         end
 
-        context 'RailsSettings::Settings.object returns falsey' do
+        context 'when RailsSettings::Settings.object returns falsey' do
           let(:object) { nil }
 
           it 'returns default_settings[key]' do
@@ -93,7 +93,7 @@ RSpec.describe Setting, type: :model do
         end
       end
 
-      context 'Rails.cache exists' do
+      context 'when Rails.cache exists' do
         before do
           Rails.cache.write(cache_key, cache_value)
         end
@@ -130,7 +130,7 @@ RSpec.describe Setting, type: :model do
       expect(described_class.all_as_records).to be_a Hash
     end
 
-    context 'records includes Setting with var as the key' do
+    context 'when records includes Setting with var as the key' do
       let(:records) { [original_setting] }
 
       it 'includes the original Setting' do
@@ -139,10 +139,10 @@ RSpec.describe Setting, type: :model do
       end
     end
 
-    context 'records includes nothing' do
+    context 'when records includes nothing' do
       let(:records) { [] }
 
-      context 'default_value is not a Hash' do
+      context 'when default_value is not a Hash' do
         it 'includes Setting with value of default_value' do
           setting = described_class.all_as_records[key]
 
@@ -152,7 +152,7 @@ RSpec.describe Setting, type: :model do
         end
       end
 
-      context 'default_value is a Hash' do
+      context 'when default_value is a Hash' do
         let(:default_value) { { 'foo' => 'fuga' } }
 
         it 'returns {}' do
@@ -169,7 +169,7 @@ RSpec.describe Setting, type: :model do
       allow(RailsSettings::Default).to receive(:enabled?).and_return(enabled)
     end
 
-    context 'RailsSettings::Default.enabled? is false' do
+    context 'when RailsSettings::Default.enabled? is false' do
       let(:enabled) { false }
 
       it 'returns {}' do
@@ -177,7 +177,7 @@ RSpec.describe Setting, type: :model do
       end
     end
 
-    context 'RailsSettings::Settings.enabled? is true' do
+    context 'when RailsSettings::Settings.enabled? is true' do
       let(:enabled) { true }
 
       it 'returns instance of RailsSettings::Default' do
diff --git a/spec/models/site_upload_spec.rb b/spec/models/site_upload_spec.rb
index f7ea069213..d4a9293115 100644
--- a/spec/models/site_upload_spec.rb
+++ b/spec/models/site_upload_spec.rb
@@ -2,7 +2,7 @@
 
 require 'rails_helper'
 
-RSpec.describe SiteUpload, type: :model do
+RSpec.describe SiteUpload do
   describe '#cache_key' do
     let(:site_upload) { SiteUpload.new(var: 'var') }
 
diff --git a/spec/models/status_pin_spec.rb b/spec/models/status_pin_spec.rb
index c4ebf96da9..52ce0847c4 100644
--- a/spec/models/status_pin_spec.rb
+++ b/spec/models/status_pin_spec.rb
@@ -2,7 +2,7 @@
 
 require 'rails_helper'
 
-RSpec.describe StatusPin, type: :model do
+RSpec.describe StatusPin do
   describe 'validations' do
     it 'allows pins of own statuses' do
       account = Fabricate(:account)
diff --git a/spec/models/status_spec.rb b/spec/models/status_spec.rb
index 8fa1db1917..3b612ded14 100644
--- a/spec/models/status_spec.rb
+++ b/spec/models/status_spec.rb
@@ -2,7 +2,7 @@
 
 require 'rails_helper'
 
-RSpec.describe Status, type: :model do
+RSpec.describe Status do
   subject { Fabricate(:status, account: alice) }
 
   let(:alice) { Fabricate(:account, username: 'alice') }
@@ -49,22 +49,22 @@ RSpec.describe Status, type: :model do
   end
 
   describe '#verb' do
-    context 'if destroyed?' do
+    context 'when destroyed?' do
       it 'returns :delete' do
         subject.destroy!
         expect(subject.verb).to be :delete
       end
     end
 
-    context 'unless destroyed?' do
-      context 'if reblog?' do
+    context 'when not destroyed?' do
+      context 'when reblog?' do
         it 'returns :share' do
           subject.reblog = other
           expect(subject.verb).to be :share
         end
       end
 
-      context 'unless reblog?' do
+      context 'when not reblog?' do
         it 'returns :post' do
           subject.reblog = nil
           expect(subject.verb).to be :post
@@ -85,28 +85,28 @@ RSpec.describe Status, type: :model do
   end
 
   describe '#hidden?' do
-    context 'if private_visibility?' do
+    context 'when private_visibility?' do
       it 'returns true' do
         subject.visibility = :private
         expect(subject.hidden?).to be true
       end
     end
 
-    context 'if direct_visibility?' do
+    context 'when direct_visibility?' do
       it 'returns true' do
         subject.visibility = :direct
         expect(subject.hidden?).to be true
       end
     end
 
-    context 'if public_visibility?' do
+    context 'when public_visibility?' do
       it 'returns false' do
         subject.visibility = :public
         expect(subject.hidden?).to be false
       end
     end
 
-    context 'if unlisted_visibility?' do
+    context 'when unlisted_visibility?' do
       it 'returns false' do
         subject.visibility = :unlisted
         expect(subject.hidden?).to be false
@@ -216,7 +216,7 @@ RSpec.describe Status, type: :model do
         subject.text = "A toot #{subject.local_only_emoji}"
       end
 
-      context 'if the status originates from this instance' do
+      context 'when the status originates from this instance' do
         before do
           subject.account = local_account
         end
@@ -228,7 +228,7 @@ RSpec.describe Status, type: :model do
         end
       end
 
-      context 'if the status is remote' do
+      context 'when the status is remote' do
         before do
           subject.account = remote_account
         end
diff --git a/spec/models/status_stat_spec.rb b/spec/models/status_stat_spec.rb
index 749ca097d6..9679c836a7 100644
--- a/spec/models/status_stat_spec.rb
+++ b/spec/models/status_stat_spec.rb
@@ -2,5 +2,5 @@
 
 require 'rails_helper'
 
-RSpec.describe StatusStat, type: :model do
+RSpec.describe StatusStat do
 end
diff --git a/spec/models/status_trend_spec.rb b/spec/models/status_trend_spec.rb
index 9678b838a7..dbb3d4bb3f 100644
--- a/spec/models/status_trend_spec.rb
+++ b/spec/models/status_trend_spec.rb
@@ -2,5 +2,5 @@
 
 require 'rails_helper'
 
-RSpec.describe StatusTrend, type: :model do
+RSpec.describe StatusTrend do
 end
diff --git a/spec/models/system_key_spec.rb b/spec/models/system_key_spec.rb
index a4e8b77844..5bd630aaa5 100644
--- a/spec/models/system_key_spec.rb
+++ b/spec/models/system_key_spec.rb
@@ -2,5 +2,5 @@
 
 require 'rails_helper'
 
-RSpec.describe SystemKey, type: :model do
+RSpec.describe SystemKey do
 end
diff --git a/spec/models/tag_follow_spec.rb b/spec/models/tag_follow_spec.rb
index 88409bb28a..240147ecc8 100644
--- a/spec/models/tag_follow_spec.rb
+++ b/spec/models/tag_follow_spec.rb
@@ -2,5 +2,5 @@
 
 require 'rails_helper'
 
-RSpec.describe TagFollow, type: :model do
+RSpec.describe TagFollow do
 end
diff --git a/spec/models/unavailable_domain_spec.rb b/spec/models/unavailable_domain_spec.rb
index 5469ff6939..b868779f2b 100644
--- a/spec/models/unavailable_domain_spec.rb
+++ b/spec/models/unavailable_domain_spec.rb
@@ -2,5 +2,5 @@
 
 require 'rails_helper'
 
-RSpec.describe UnavailableDomain, type: :model do
+RSpec.describe UnavailableDomain do
 end
diff --git a/spec/models/user_invite_request_spec.rb b/spec/models/user_invite_request_spec.rb
index 95e1284399..ee0efbb431 100644
--- a/spec/models/user_invite_request_spec.rb
+++ b/spec/models/user_invite_request_spec.rb
@@ -2,5 +2,5 @@
 
 require 'rails_helper'
 
-RSpec.describe UserInviteRequest, type: :model do
+RSpec.describe UserInviteRequest do
 end
diff --git a/spec/models/user_role_spec.rb b/spec/models/user_role_spec.rb
index 97456c1060..16bb4358e6 100644
--- a/spec/models/user_role_spec.rb
+++ b/spec/models/user_role_spec.rb
@@ -2,7 +2,7 @@
 
 require 'rails_helper'
 
-RSpec.describe UserRole, type: :model do
+RSpec.describe UserRole do
   subject { described_class.create(name: 'Foo', position: 1) }
 
   describe '#can?' do
diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb
index b7754e9265..ae46f0ae45 100644
--- a/spec/models/user_spec.rb
+++ b/spec/models/user_spec.rb
@@ -3,7 +3,7 @@
 require 'rails_helper'
 require 'devise_two_factor/spec_helpers'
 
-RSpec.describe User, type: :model do
+RSpec.describe User do
   let(:password) { 'abcd1234' }
   let(:account) { Fabricate(:account, username: 'alice') }
 
diff --git a/spec/models/web/push_subscription_spec.rb b/spec/models/web/push_subscription_spec.rb
index e925e4c4cd..3c2cd3bac1 100644
--- a/spec/models/web/push_subscription_spec.rb
+++ b/spec/models/web/push_subscription_spec.rb
@@ -2,7 +2,7 @@
 
 require 'rails_helper'
 
-RSpec.describe Web::PushSubscription, type: :model do
+RSpec.describe Web::PushSubscription do
   subject { described_class.new(data: data) }
 
   let(:account) { Fabricate(:account) }
@@ -56,7 +56,7 @@ RSpec.describe Web::PushSubscription, type: :model do
     context 'when policy is followed' do
       let(:policy) { 'followed' }
 
-      context 'and notification is from someone you follow' do
+      context 'when notification is from someone you follow' do
         before do
           account.follow!(notification.from_account)
         end
@@ -66,7 +66,7 @@ RSpec.describe Web::PushSubscription, type: :model do
         end
       end
 
-      context 'and notification is not from someone you follow' do
+      context 'when notification is not from someone you follow' do
         it 'returns false' do
           expect(subject.pushable?(notification)).to be false
         end
@@ -76,7 +76,7 @@ RSpec.describe Web::PushSubscription, type: :model do
     context 'when policy is follower' do
       let(:policy) { 'follower' }
 
-      context 'and notification is from someone who follows you' do
+      context 'when notification is from someone who follows you' do
         before do
           notification.from_account.follow!(account)
         end
@@ -86,7 +86,7 @@ RSpec.describe Web::PushSubscription, type: :model do
         end
       end
 
-      context 'and notification is not from someone who follows you' do
+      context 'when notification is not from someone who follows you' do
         it 'returns false' do
           expect(subject.pushable?(notification)).to be false
         end
diff --git a/spec/models/web/setting_spec.rb b/spec/models/web/setting_spec.rb
index b7ff3c8684..3182c67217 100644
--- a/spec/models/web/setting_spec.rb
+++ b/spec/models/web/setting_spec.rb
@@ -2,5 +2,5 @@
 
 require 'rails_helper'
 
-RSpec.describe Web::Setting, type: :model do
+RSpec.describe Web::Setting do
 end
diff --git a/spec/models/webauthn_credentials_spec.rb b/spec/models/webauthn_credentials_spec.rb
index 1a2a2f9099..4579ebb82e 100644
--- a/spec/models/webauthn_credentials_spec.rb
+++ b/spec/models/webauthn_credentials_spec.rb
@@ -2,7 +2,7 @@
 
 require 'rails_helper'
 
-RSpec.describe WebauthnCredential, type: :model do
+RSpec.describe WebauthnCredential do
   describe 'validations' do
     it 'is invalid without an external id' do
       webauthn_credential = Fabricate.build(:webauthn_credential, external_id: nil)
diff --git a/spec/models/webhook_spec.rb b/spec/models/webhook_spec.rb
index fcf3dd14ff..715dd7574f 100644
--- a/spec/models/webhook_spec.rb
+++ b/spec/models/webhook_spec.rb
@@ -2,7 +2,7 @@
 
 require 'rails_helper'
 
-RSpec.describe Webhook, type: :model do
+RSpec.describe Webhook do
   let(:webhook) { Fabricate(:webhook) }
 
   describe '#rotate_secret!' do
diff --git a/spec/policies/account_moderation_note_policy_spec.rb b/spec/policies/account_moderation_note_policy_spec.rb
index 8467473465..03d18250b2 100644
--- a/spec/policies/account_moderation_note_policy_spec.rb
+++ b/spec/policies/account_moderation_note_policy_spec.rb
@@ -9,13 +9,13 @@ RSpec.describe AccountModerationNotePolicy do
   let(:john)    { Fabricate(:account) }
 
   permissions :create? do
-    context 'staff' do
+    context 'when staff' do
       it 'grants to create' do
         expect(subject).to permit(admin, AccountModerationNotePolicy)
       end
     end
 
-    context 'not staff' do
+    context 'when not staff' do
       it 'denies to create' do
         expect(subject).to_not permit(john, AccountModerationNotePolicy)
       end
@@ -29,19 +29,19 @@ RSpec.describe AccountModerationNotePolicy do
                 target_account: Fabricate(:account))
     end
 
-    context 'admin' do
+    context 'when admin' do
       it 'grants to destroy' do
         expect(subject).to permit(admin, account_moderation_note)
       end
     end
 
-    context 'owner' do
+    context 'when owner' do
       it 'grants to destroy' do
         expect(subject).to permit(john, account_moderation_note)
       end
     end
 
-    context 'neither admin nor owner' do
+    context 'when neither admin nor owner' do
       let(:kevin) { Fabricate(:account) }
 
       it 'denies to destroy' do
diff --git a/spec/policies/account_policy_spec.rb b/spec/policies/account_policy_spec.rb
index d961532332..9f4e94a6c8 100644
--- a/spec/policies/account_policy_spec.rb
+++ b/spec/policies/account_policy_spec.rb
@@ -10,13 +10,13 @@ RSpec.describe AccountPolicy do
   let(:alice)   { Fabricate(:account) }
 
   permissions :index? do
-    context 'staff' do
+    context 'when staff' do
       it 'permits' do
         expect(subject).to permit(admin)
       end
     end
 
-    context 'not staff' do
+    context 'when not staff' do
       it 'denies' do
         expect(subject).to_not permit(john)
       end
@@ -24,13 +24,13 @@ RSpec.describe AccountPolicy do
   end
 
   permissions :show?, :unsilence?, :unsensitive?, :remove_avatar?, :remove_header? do
-    context 'staff' do
+    context 'when staff' do
       it 'permits' do
         expect(subject).to permit(admin, alice)
       end
     end
 
-    context 'not staff' do
+    context 'when not staff' do
       it 'denies' do
         expect(subject).to_not permit(john, alice)
       end
@@ -42,13 +42,13 @@ RSpec.describe AccountPolicy do
       alice.suspend!
     end
 
-    context 'staff' do
+    context 'when staff' do
       it 'permits' do
         expect(subject).to permit(admin, alice)
       end
     end
 
-    context 'not staff' do
+    context 'when not staff' do
       it 'denies' do
         expect(subject).to_not permit(john, alice)
       end
@@ -56,13 +56,13 @@ RSpec.describe AccountPolicy do
   end
 
   permissions :redownload? do
-    context 'admin' do
+    context 'when admin' do
       it 'permits' do
         expect(subject).to permit(admin)
       end
     end
 
-    context 'not admin' do
+    context 'when not admin' do
       it 'denies' do
         expect(subject).to_not permit(john)
       end
@@ -72,21 +72,21 @@ RSpec.describe AccountPolicy do
   permissions :suspend?, :silence? do
     let(:staff) { Fabricate(:user, role: UserRole.find_by(name: 'Admin')).account }
 
-    context 'staff' do
-      context 'record is staff' do
+    context 'when staff' do
+      context 'when record is staff' do
         it 'denies' do
           expect(subject).to_not permit(admin, staff)
         end
       end
 
-      context 'record is not staff' do
+      context 'when record is not staff' do
         it 'permits' do
           expect(subject).to permit(admin, john)
         end
       end
     end
 
-    context 'not staff' do
+    context 'when not staff' do
       it 'denies' do
         expect(subject).to_not permit(john, Account)
       end
@@ -96,21 +96,21 @@ RSpec.describe AccountPolicy do
   permissions :memorialize? do
     let(:other_admin) { Fabricate(:user, role: UserRole.find_by(name: 'Admin')).account }
 
-    context 'admin' do
-      context 'record is admin' do
+    context 'when admin' do
+      context 'when record is admin' do
         it 'denies' do
           expect(subject).to_not permit(admin, other_admin)
         end
       end
 
-      context 'record is not admin' do
+      context 'when record is not admin' do
         it 'permits' do
           expect(subject).to permit(admin, john)
         end
       end
     end
 
-    context 'not admin' do
+    context 'when not admin' do
       it 'denies' do
         expect(subject).to_not permit(john, Account)
       end
@@ -118,13 +118,13 @@ RSpec.describe AccountPolicy do
   end
 
   permissions :review? do
-    context 'admin' do
+    context 'when admin' do
       it 'permits' do
         expect(subject).to permit(admin)
       end
     end
 
-    context 'not admin' do
+    context 'when not admin' do
       it 'denies' do
         expect(subject).to_not permit(john)
       end
@@ -132,7 +132,7 @@ RSpec.describe AccountPolicy do
   end
 
   permissions :destroy? do
-    context 'admin' do
+    context 'when admin' do
       context 'with a temporarily suspended account' do
         before { allow(alice).to receive(:suspended_temporarily?).and_return(true) }
 
@@ -150,7 +150,7 @@ RSpec.describe AccountPolicy do
       end
     end
 
-    context 'not admin' do
+    context 'when not admin' do
       it 'denies' do
         expect(subject).to_not permit(john, alice)
       end
diff --git a/spec/policies/backup_policy_spec.rb b/spec/policies/backup_policy_spec.rb
index 6b31c6f7c7..488d48f52a 100644
--- a/spec/policies/backup_policy_spec.rb
+++ b/spec/policies/backup_policy_spec.rb
@@ -8,20 +8,20 @@ RSpec.describe BackupPolicy do
   let(:john)    { Fabricate(:account) }
 
   permissions :create? do
-    context 'not user_signed_in?' do
+    context 'when not user_signed_in?' do
       it 'denies' do
         expect(subject).to_not permit(nil, Backup)
       end
     end
 
-    context 'user_signed_in?' do
-      context 'no backups' do
+    context 'when user_signed_in?' do
+      context 'with no backups' do
         it 'permits' do
           expect(subject).to permit(john, Backup)
         end
       end
 
-      context 'backups are too old' do
+      context 'when backups are too old' do
         it 'permits' do
           travel(-8.days) do
             Fabricate(:backup, user: john.user)
@@ -31,7 +31,7 @@ RSpec.describe BackupPolicy do
         end
       end
 
-      context 'backups are newer' do
+      context 'when backups are newer' do
         it 'denies' do
           travel(-3.days) do
             Fabricate(:backup, user: john.user)
diff --git a/spec/policies/custom_emoji_policy_spec.rb b/spec/policies/custom_emoji_policy_spec.rb
index 6a6ef6694d..cf7e7d924b 100644
--- a/spec/policies/custom_emoji_policy_spec.rb
+++ b/spec/policies/custom_emoji_policy_spec.rb
@@ -9,13 +9,13 @@ RSpec.describe CustomEmojiPolicy do
   let(:john)    { Fabricate(:account) }
 
   permissions :index?, :enable?, :disable? do
-    context 'staff' do
+    context 'when staff' do
       it 'permits' do
         expect(subject).to permit(admin, CustomEmoji)
       end
     end
 
-    context 'not staff' do
+    context 'when not staff' do
       it 'denies' do
         expect(subject).to_not permit(john, CustomEmoji)
       end
@@ -23,13 +23,13 @@ RSpec.describe CustomEmojiPolicy do
   end
 
   permissions :create?, :update?, :copy?, :destroy? do
-    context 'admin' do
+    context 'when admin' do
       it 'permits' do
         expect(subject).to permit(admin, CustomEmoji)
       end
     end
 
-    context 'not admin' do
+    context 'when not admin' do
       it 'denies' do
         expect(subject).to_not permit(john, CustomEmoji)
       end
diff --git a/spec/policies/domain_block_policy_spec.rb b/spec/policies/domain_block_policy_spec.rb
index 01b97e823a..e254e2cf4d 100644
--- a/spec/policies/domain_block_policy_spec.rb
+++ b/spec/policies/domain_block_policy_spec.rb
@@ -9,13 +9,13 @@ RSpec.describe DomainBlockPolicy do
   let(:john)    { Fabricate(:account) }
 
   permissions :index?, :show?, :create?, :destroy? do
-    context 'admin' do
+    context 'when admin' do
       it 'permits' do
         expect(subject).to permit(admin, DomainBlock)
       end
     end
 
-    context 'not admin' do
+    context 'when not admin' do
       it 'denies' do
         expect(subject).to_not permit(john, DomainBlock)
       end
diff --git a/spec/policies/email_domain_block_policy_spec.rb b/spec/policies/email_domain_block_policy_spec.rb
index e7c455907a..6e57b1372f 100644
--- a/spec/policies/email_domain_block_policy_spec.rb
+++ b/spec/policies/email_domain_block_policy_spec.rb
@@ -9,13 +9,13 @@ RSpec.describe EmailDomainBlockPolicy do
   let(:john)    { Fabricate(:account) }
 
   permissions :index?, :show?, :create?, :destroy? do
-    context 'admin' do
+    context 'when admin' do
       it 'permits' do
         expect(subject).to permit(admin, EmailDomainBlock)
       end
     end
 
-    context 'not admin' do
+    context 'when not admin' do
       it 'denies' do
         expect(subject).to_not permit(john, EmailDomainBlock)
       end
diff --git a/spec/policies/instance_policy_spec.rb b/spec/policies/instance_policy_spec.rb
index f6f51af068..3e047bbe9e 100644
--- a/spec/policies/instance_policy_spec.rb
+++ b/spec/policies/instance_policy_spec.rb
@@ -9,13 +9,13 @@ RSpec.describe InstancePolicy do
   let(:john)    { Fabricate(:account) }
 
   permissions :index?, :show?, :destroy? do
-    context 'admin' do
+    context 'when admin' do
       it 'permits' do
         expect(subject).to permit(admin, Instance)
       end
     end
 
-    context 'not admin' do
+    context 'when not admin' do
       it 'denies' do
         expect(subject).to_not permit(john, Instance)
       end
diff --git a/spec/policies/invite_policy_spec.rb b/spec/policies/invite_policy_spec.rb
index 01660322f1..50a312f44f 100644
--- a/spec/policies/invite_policy_spec.rb
+++ b/spec/policies/invite_policy_spec.rb
@@ -9,7 +9,7 @@ RSpec.describe InvitePolicy do
   let(:john)    { Fabricate(:user).account }
 
   permissions :index? do
-    context 'staff?' do
+    context 'when staff?' do
       it 'permits' do
         expect(subject).to permit(admin, Invite)
       end
@@ -17,7 +17,7 @@ RSpec.describe InvitePolicy do
   end
 
   permissions :create? do
-    context 'has privilege' do
+    context 'with privilege' do
       before do
         UserRole.everyone.update(permissions: UserRole::FLAGS[:invite_users])
       end
@@ -27,7 +27,7 @@ RSpec.describe InvitePolicy do
       end
     end
 
-    context 'does not have privilege' do
+    context 'when does not have privilege' do
       before do
         UserRole.everyone.update(permissions: UserRole::Flags::NONE)
       end
@@ -39,13 +39,13 @@ RSpec.describe InvitePolicy do
   end
 
   permissions :deactivate_all? do
-    context 'admin?' do
+    context 'when admin?' do
       it 'permits' do
         expect(subject).to permit(admin, Invite)
       end
     end
 
-    context 'not admin?' do
+    context 'when not admin?' do
       it 'denies' do
         expect(subject).to_not permit(john, Invite)
       end
@@ -53,20 +53,20 @@ RSpec.describe InvitePolicy do
   end
 
   permissions :destroy? do
-    context 'owner?' do
+    context 'when owner?' do
       it 'permits' do
         expect(subject).to permit(john, Fabricate(:invite, user: john.user))
       end
     end
 
-    context 'not owner?' do
-      context 'admin?' do
+    context 'when not owner?' do
+      context 'when admin?' do
         it 'permits' do
           expect(subject).to permit(admin, Fabricate(:invite))
         end
       end
 
-      context 'not admin?' do
+      context 'when not admin?' do
         it 'denies' do
           expect(subject).to_not permit(john, Fabricate(:invite))
         end
diff --git a/spec/policies/relay_policy_spec.rb b/spec/policies/relay_policy_spec.rb
index 2c50ba1e9f..0d479e0ca7 100644
--- a/spec/policies/relay_policy_spec.rb
+++ b/spec/policies/relay_policy_spec.rb
@@ -9,13 +9,13 @@ RSpec.describe RelayPolicy do
   let(:john)    { Fabricate(:account) }
 
   permissions :update? do
-    context 'admin?' do
+    context 'when admin?' do
       it 'permits' do
         expect(subject).to permit(admin, Relay)
       end
     end
 
-    context '!admin?' do
+    context 'with !admin?' do
       it 'denies' do
         expect(subject).to_not permit(john, Relay)
       end
diff --git a/spec/policies/report_note_policy_spec.rb b/spec/policies/report_note_policy_spec.rb
index 99f5ffb8e3..ea2a62ada9 100644
--- a/spec/policies/report_note_policy_spec.rb
+++ b/spec/policies/report_note_policy_spec.rb
@@ -9,13 +9,13 @@ RSpec.describe ReportNotePolicy do
   let(:john)    { Fabricate(:account) }
 
   permissions :create? do
-    context 'staff?' do
+    context 'when staff?' do
       it 'permits' do
         expect(subject).to permit(admin, ReportNote)
       end
     end
 
-    context '!staff?' do
+    context 'with !staff?' do
       it 'denies' do
         expect(subject).to_not permit(john, ReportNote)
       end
@@ -23,22 +23,22 @@ RSpec.describe ReportNotePolicy do
   end
 
   permissions :destroy? do
-    context 'admin?' do
+    context 'when admin?' do
       it 'permit' do
         report_note = Fabricate(:report_note, account: john)
         expect(subject).to permit(admin, report_note)
       end
     end
 
-    context 'admin?' do
-      context 'owner?' do
+    context 'when admin?' do
+      context 'when owner?' do
         it 'permit' do
           report_note = Fabricate(:report_note, account: john)
           expect(subject).to permit(john, report_note)
         end
       end
 
-      context '!owner?' do
+      context 'with !owner?' do
         it 'denies' do
           report_note = Fabricate(:report_note)
           expect(subject).to_not permit(john, report_note)
diff --git a/spec/policies/report_policy_spec.rb b/spec/policies/report_policy_spec.rb
index 8b005d8ddd..8f2533fa6b 100644
--- a/spec/policies/report_policy_spec.rb
+++ b/spec/policies/report_policy_spec.rb
@@ -9,13 +9,13 @@ RSpec.describe ReportPolicy do
   let(:john)    { Fabricate(:account) }
 
   permissions :update?, :index?, :show? do
-    context 'staff?' do
+    context 'when staff?' do
       it 'permits' do
         expect(subject).to permit(admin, Report)
       end
     end
 
-    context '!staff?' do
+    context 'with !staff?' do
       it 'denies' do
         expect(subject).to_not permit(john, Report)
       end
diff --git a/spec/policies/settings_policy_spec.rb b/spec/policies/settings_policy_spec.rb
index 3268c16225..576bfa4ab7 100644
--- a/spec/policies/settings_policy_spec.rb
+++ b/spec/policies/settings_policy_spec.rb
@@ -9,13 +9,13 @@ RSpec.describe SettingsPolicy do
   let(:john)    { Fabricate(:account) }
 
   permissions :update?, :show?, :destroy? do
-    context 'admin?' do
+    context 'when admin?' do
       it 'permits' do
         expect(subject).to permit(admin, Settings)
       end
     end
 
-    context '!admin?' do
+    context 'with !admin?' do
       it 'denies' do
         expect(subject).to_not permit(john, Settings)
       end
diff --git a/spec/policies/tag_policy_spec.rb b/spec/policies/tag_policy_spec.rb
index fb09fdd3be..7791cde152 100644
--- a/spec/policies/tag_policy_spec.rb
+++ b/spec/policies/tag_policy_spec.rb
@@ -9,13 +9,13 @@ RSpec.describe TagPolicy do
   let(:john)    { Fabricate(:account) }
 
   permissions :index?, :show?, :update?, :review? do
-    context 'staff?' do
+    context 'when staff?' do
       it 'permits' do
         expect(subject).to permit(admin, Tag)
       end
     end
 
-    context '!staff?' do
+    context 'with !staff?' do
       it 'denies' do
         expect(subject).to_not permit(john, Tag)
       end
diff --git a/spec/policies/user_policy_spec.rb b/spec/policies/user_policy_spec.rb
index ff0916674e..384119f250 100644
--- a/spec/policies/user_policy_spec.rb
+++ b/spec/policies/user_policy_spec.rb
@@ -9,21 +9,21 @@ RSpec.describe UserPolicy do
   let(:john)    { Fabricate(:account) }
 
   permissions :reset_password?, :change_email? do
-    context 'staff?' do
-      context '!record.staff?' do
+    context 'when staff?' do
+      context 'with !record.staff?' do
         it 'permits' do
           expect(subject).to permit(admin, john.user)
         end
       end
 
-      context 'record.staff?' do
+      context 'when record.staff?' do
         it 'denies' do
           expect(subject).to_not permit(admin, admin.user)
         end
       end
     end
 
-    context '!staff?' do
+    context 'with !staff?' do
       it 'denies' do
         expect(subject).to_not permit(john, User)
       end
@@ -31,21 +31,21 @@ RSpec.describe UserPolicy do
   end
 
   permissions :disable_2fa? do
-    context 'admin?' do
-      context '!record.staff?' do
+    context 'when admin?' do
+      context 'with !record.staff?' do
         it 'permits' do
           expect(subject).to permit(admin, john.user)
         end
       end
 
-      context 'record.staff?' do
+      context 'when record.staff?' do
         it 'denies' do
           expect(subject).to_not permit(admin, admin.user)
         end
       end
     end
 
-    context '!admin?' do
+    context 'with !admin?' do
       it 'denies' do
         expect(subject).to_not permit(john, User)
       end
@@ -53,15 +53,15 @@ RSpec.describe UserPolicy do
   end
 
   permissions :confirm? do
-    context 'staff?' do
-      context '!record.confirmed?' do
+    context 'when staff?' do
+      context 'with !record.confirmed?' do
         it 'permits' do
           john.user.update(confirmed_at: nil)
           expect(subject).to permit(admin, john.user)
         end
       end
 
-      context 'record.confirmed?' do
+      context 'when record.confirmed?' do
         it 'denies' do
           john.user.confirm!
           expect(subject).to_not permit(admin, john.user)
@@ -69,7 +69,7 @@ RSpec.describe UserPolicy do
       end
     end
 
-    context '!staff?' do
+    context 'with !staff?' do
       it 'denies' do
         expect(subject).to_not permit(john, User)
       end
@@ -77,13 +77,13 @@ RSpec.describe UserPolicy do
   end
 
   permissions :enable? do
-    context 'staff?' do
+    context 'when staff?' do
       it 'permits' do
         expect(subject).to permit(admin, User)
       end
     end
 
-    context '!staff?' do
+    context 'with !staff?' do
       it 'denies' do
         expect(subject).to_not permit(john, User)
       end
@@ -91,21 +91,21 @@ RSpec.describe UserPolicy do
   end
 
   permissions :disable? do
-    context 'staff?' do
-      context '!record.admin?' do
+    context 'when staff?' do
+      context 'with !record.admin?' do
         it 'permits' do
           expect(subject).to permit(admin, john.user)
         end
       end
 
-      context 'record.admin?' do
+      context 'when record.admin?' do
         it 'denies' do
           expect(subject).to_not permit(admin, admin.user)
         end
       end
     end
 
-    context '!staff?' do
+    context 'with !staff?' do
       it 'denies' do
         expect(subject).to_not permit(john, User)
       end
diff --git a/spec/presenters/account_relationships_presenter_spec.rb b/spec/presenters/account_relationships_presenter_spec.rb
index 8a485d2b9a..d59060bd5c 100644
--- a/spec/presenters/account_relationships_presenter_spec.rb
+++ b/spec/presenters/account_relationships_presenter_spec.rb
@@ -19,7 +19,7 @@ RSpec.describe AccountRelationshipsPresenter do
     let(:account_ids)        { [Fabricate(:account).id] }
     let(:default_map)        { { 1 => true } }
 
-    context 'options are not set' do
+    context 'when options are not set' do
       let(:options) { {} }
 
       it 'sets default maps' do
@@ -32,7 +32,7 @@ RSpec.describe AccountRelationshipsPresenter do
       end
     end
 
-    context 'options[:following_map] is set' do
+    context 'when options[:following_map] is set' do
       let(:options) { { following_map: { 2 => true } } }
 
       it 'sets @following merged with default_map and options[:following_map]' do
@@ -40,7 +40,7 @@ RSpec.describe AccountRelationshipsPresenter do
       end
     end
 
-    context 'options[:followed_by_map] is set' do
+    context 'when options[:followed_by_map] is set' do
       let(:options) { { followed_by_map: { 3 => true } } }
 
       it 'sets @followed_by merged with default_map and options[:followed_by_map]' do
@@ -48,7 +48,7 @@ RSpec.describe AccountRelationshipsPresenter do
       end
     end
 
-    context 'options[:blocking_map] is set' do
+    context 'when options[:blocking_map] is set' do
       let(:options) { { blocking_map: { 4 => true } } }
 
       it 'sets @blocking merged with default_map and options[:blocking_map]' do
@@ -56,7 +56,7 @@ RSpec.describe AccountRelationshipsPresenter do
       end
     end
 
-    context 'options[:muting_map] is set' do
+    context 'when options[:muting_map] is set' do
       let(:options) { { muting_map: { 5 => true } } }
 
       it 'sets @muting merged with default_map and options[:muting_map]' do
@@ -64,7 +64,7 @@ RSpec.describe AccountRelationshipsPresenter do
       end
     end
 
-    context 'options[:requested_map] is set' do
+    context 'when options[:requested_map] is set' do
       let(:options) { { requested_map: { 6 => true } } }
 
       it 'sets @requested merged with default_map and options[:requested_map]' do
@@ -72,7 +72,7 @@ RSpec.describe AccountRelationshipsPresenter do
       end
     end
 
-    context 'options[:requested_by_map] is set' do
+    context 'when options[:requested_by_map] is set' do
       let(:options) { { requested_by_map: { 6 => true } } }
 
       it 'sets @requested merged with default_map and options[:requested_by_map]' do
@@ -80,7 +80,7 @@ RSpec.describe AccountRelationshipsPresenter do
       end
     end
 
-    context 'options[:domain_blocking_map] is set' do
+    context 'when options[:domain_blocking_map] is set' do
       let(:options) { { domain_blocking_map: { 7 => true } } }
 
       it 'sets @domain_blocking merged with default_map and options[:domain_blocking_map]' do
diff --git a/spec/presenters/status_relationships_presenter_spec.rb b/spec/presenters/status_relationships_presenter_spec.rb
index eaab922fd9..11116cabd2 100644
--- a/spec/presenters/status_relationships_presenter_spec.rb
+++ b/spec/presenters/status_relationships_presenter_spec.rb
@@ -18,7 +18,7 @@ RSpec.describe StatusRelationshipsPresenter do
     let(:status_ids)         { statuses.map(&:id) + statuses.map(&:reblog_of_id).compact }
     let(:default_map)        { { 1 => true } }
 
-    context 'options are not set' do
+    context 'when options are not set' do
       let(:options) { {} }
 
       it 'sets default maps' do
@@ -30,7 +30,7 @@ RSpec.describe StatusRelationshipsPresenter do
       end
     end
 
-    context 'options[:reblogs_map] is set' do
+    context 'when options[:reblogs_map] is set' do
       let(:options) { { reblogs_map: { 2 => true } } }
 
       it 'sets @reblogs_map merged with default_map and options[:reblogs_map]' do
@@ -38,7 +38,7 @@ RSpec.describe StatusRelationshipsPresenter do
       end
     end
 
-    context 'options[:favourites_map] is set' do
+    context 'when options[:favourites_map] is set' do
       let(:options) { { favourites_map: { 3 => true } } }
 
       it 'sets @favourites_map merged with default_map and options[:favourites_map]' do
@@ -46,7 +46,7 @@ RSpec.describe StatusRelationshipsPresenter do
       end
     end
 
-    context 'options[:bookmarks_map] is set' do
+    context 'when options[:bookmarks_map] is set' do
       let(:options) { { bookmarks_map: { 4 => true } } }
 
       it 'sets @bookmarks_map merged with default_map and options[:bookmarks_map]' do
@@ -54,7 +54,7 @@ RSpec.describe StatusRelationshipsPresenter do
       end
     end
 
-    context 'options[:mutes_map] is set' do
+    context 'when options[:mutes_map] is set' do
       let(:options) { { mutes_map: { 5 => true } } }
 
       it 'sets @mutes_map merged with default_map and options[:mutes_map]' do
@@ -62,7 +62,7 @@ RSpec.describe StatusRelationshipsPresenter do
       end
     end
 
-    context 'options[:pins_map] is set' do
+    context 'when options[:pins_map] is set' do
       let(:options) { { pins_map: { 6 => true } } }
 
       it 'sets @pins_map merged with default_map and options[:pins_map]' do
diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb
index 26fc3d9fdf..22078a6cbc 100644
--- a/spec/rails_helper.rb
+++ b/spec/rails_helper.rb
@@ -12,7 +12,7 @@ require 'paperclip/matchers'
 require 'capybara/rspec'
 require 'chewy/rspec'
 
-Dir[Rails.root.join('spec/support/**/*.rb')].each { |f| require f }
+Dir[Rails.root.join('spec', 'support', '**', '*.rb')].each { |f| require f }
 
 ActiveRecord::Migration.maintain_test_schema!
 WebMock.disable_net_connect!(allow: Chewy.settings[:host])
@@ -35,8 +35,28 @@ Devise::Test::ControllerHelpers.module_eval do
   end
 end
 
+module SignedRequestHelpers
+  def get(path, headers: nil, sign_with: nil, **args)
+    return super path, headers: headers, **args if sign_with.nil?
+
+    headers ||= {}
+    headers['Date'] = Time.now.utc.httpdate
+    headers['Host'] = ENV.fetch('LOCAL_DOMAIN')
+    signed_headers = headers.merge('(request-target)' => "get #{path}").slice('(request-target)', 'Host', 'Date')
+
+    key_id = ActivityPub::TagManager.instance.key_uri_for(sign_with)
+    keypair = sign_with.keypair
+    signed_string = signed_headers.map { |key, value| "#{key.downcase}: #{value}" }.join("\n")
+    signature = Base64.strict_encode64(keypair.sign(OpenSSL::Digest.new('SHA256'), signed_string))
+
+    headers['Signature'] = "keyId=\"#{key_id}\",algorithm=\"rsa-sha256\",headers=\"#{signed_headers.keys.join(' ').downcase}\",signature=\"#{signature}\""
+
+    super path, headers: headers, **args
+  end
+end
+
 RSpec.configure do |config|
-  config.fixture_path = "#{Rails.root}/spec/fixtures"
+  config.fixture_path = Rails.root.join('spec', 'fixtures')
   config.use_transactional_fixtures = true
   config.order = 'random'
   config.infer_spec_type_from_file_location!
@@ -46,10 +66,12 @@ RSpec.configure do |config|
   config.include Devise::Test::ControllerHelpers, type: :helper
   config.include Devise::Test::ControllerHelpers, type: :view
   config.include Devise::Test::IntegrationHelpers, type: :feature
+  config.include Devise::Test::IntegrationHelpers, type: :request
   config.include Paperclip::Shoulda::Matchers
   config.include ActiveSupport::Testing::TimeHelpers
   config.include Chewy::Rspec::Helpers
   config.include Redisable
+  config.include SignedRequestHelpers, type: :request
 
   config.before :each, type: :feature do
     https = ENV['LOCAL_HTTPS'] == 'true'
diff --git a/spec/requests/cache_spec.rb b/spec/requests/cache_spec.rb
new file mode 100644
index 0000000000..902f21db4b
--- /dev/null
+++ b/spec/requests/cache_spec.rb
@@ -0,0 +1,685 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+module TestEndpoints
+  # Endpoints that do not include authorization-dependent results
+  # and should be cacheable no matter what.
+  ALWAYS_CACHED = %w(
+    /.well-known/host-meta
+    /.well-known/nodeinfo
+    /nodeinfo/2.0
+    /manifest
+    /custom.css
+    /actor
+    /api/v1/instance/extended_description
+    /api/v1/instance/rules
+    /api/v1/instance/peers
+    /api/v1/instance
+    /api/v2/instance
+  ).freeze
+
+  # Endpoints that should be cachable when accessed anonymously but have a Vary
+  # on Cookie to prevent logged-in users from getting values from logged-out cache.
+  COOKIE_DEPENDENT_CACHABLE = %w(
+    /
+    /explore
+    /public
+    /about
+    /privacy-policy
+    /directory
+    /@alice
+    /@alice/110224538612341312
+  ).freeze
+
+  # Endpoints that should be cachable when accessed anonymously but have a Vary
+  # on Authorization to prevent logged-in users from getting values from logged-out cache.
+  AUTHORIZATION_DEPENDENT_CACHABLE = %w(
+    /api/v1/accounts/lookup?acct=alice
+    /api/v1/statuses/110224538612341312
+    /api/v1/statuses/110224538612341312/context
+    /api/v1/polls/12345
+    /api/v1/trends/statuses
+    /api/v1/directory
+  ).freeze
+
+  # Private status that should only be returned with to a valid signature from
+  # a specific user.
+  # Should never be cached.
+  REQUIRE_SIGNATURE = %w(
+    /users/alice/statuses/110224538643211312
+  ).freeze
+
+  # Pages only available to logged-in users.
+  # Should never be cached.
+  REQUIRE_LOGIN = %w(
+    /settings/preferences/appearance
+    /settings/profile
+    /settings/featured_tags
+    /settings/export
+    /relationships
+    /filters
+    /statuses_cleanup
+    /auth/edit
+    /oauth/authorized_applications
+    /admin/dashboard
+  ).freeze
+
+  # API endpoints only available to logged-in users.
+  # Should never be cached.
+  REQUIRE_TOKEN = %w(
+    /api/v1/announcements
+    /api/v1/timelines/home
+    /api/v1/notifications
+    /api/v1/bookmarks
+    /api/v1/favourites
+    /api/v1/follow_requests
+    /api/v1/conversations
+    /api/v1/statuses/110224538643211312
+    /api/v1/statuses/110224538643211312/context
+    /api/v1/lists
+    /api/v2/filters
+  ).freeze
+
+  # Pages that are only shown to logged-out users, and should never get cached
+  # because of CSRF protection.
+  REQUIRE_LOGGED_OUT = %w(
+    /invite/abcdef
+    /auth/sign_in
+    /auth/sign_up
+    /auth/password/new
+    /auth/confirmation/new
+  ).freeze
+
+  # Non-exhaustive list of endpoints that feature language-dependent results
+  # and thus need to have a Vary on Accept-Language
+  LANGUAGE_DEPENDENT = %w(
+    /
+    /explore
+    /about
+    /api/v1/trends/statuses
+  ).freeze
+
+  module AuthorizedFetch
+    # Endpoints that require a signature with AUTHORIZED_FETCH and LIMITED_FEDERATION_MODE
+    # and thus should not be cached in those modes.
+    REQUIRE_SIGNATURE = %w(
+      /users/alice
+    ).freeze
+  end
+
+  module DisabledAnonymousAPI
+    # Endpoints that require a signature with DISALLOW_UNAUTHENTICATED_API_ACCESS
+    # and thus should not be cached in this mode.
+    REQUIRE_TOKEN = %w(
+      /api/v1/custom_emojis
+    ).freeze
+  end
+end
+
+describe 'Caching behavior' do
+  shared_examples 'cachable response' do
+    it 'does not set cookies' do
+      expect(response.cookies).to be_empty
+    end
+
+    it 'sets public cache control' do
+      # expect(response.cache_control[:max_age]&.to_i).to be_positive
+      expect(response.cache_control[:public]).to be_truthy
+      expect(response.cache_control[:private]).to be_falsy
+      expect(response.cache_control[:no_store]).to be_falsy
+      expect(response.cache_control[:no_cache]).to be_falsy
+    end
+  end
+
+  shared_examples 'non-cacheable response' do
+    it 'sets private cache control' do
+      expect(response.cache_control[:private]).to be_truthy
+      expect(response.cache_control[:no_store]).to be_truthy
+    end
+  end
+
+  shared_examples 'non-cacheable error' do
+    it 'does not return HTTP success' do
+      expect(response).to_not have_http_status(200)
+    end
+
+    it 'does not have cache headers' do
+      expect(response.cache_control[:public]).to be_falsy
+    end
+  end
+
+  shared_examples 'language-dependent' do
+    it 'has a Vary on Accept-Language' do
+      expect(response.headers['Vary']&.split(',')&.map { |x| x.strip.downcase }).to include('accept-language')
+    end
+  end
+
+  # Enable CSRF protection like it is in production, as it can cause cookies
+  # to be set and thus mess with cache.
+  around do |example|
+    old = ActionController::Base.allow_forgery_protection
+    ActionController::Base.allow_forgery_protection = true
+
+    example.run
+
+    ActionController::Base.allow_forgery_protection = old
+  end
+
+  let(:alice) { Fabricate(:account, username: 'alice') }
+  let(:user)  { Fabricate(:user, role: UserRole.find_by(name: 'Moderator')) }
+
+  before do
+    # rubocop:disable Style/NumericLiterals
+    status = Fabricate(:status, account: alice, id: 110224538612341312)
+    Fabricate(:status, account: alice, id: 110224538643211312, visibility: :private)
+    Fabricate(:invite, code: 'abcdef')
+    Fabricate(:poll, status: status, account: alice, id: 12345)
+    # rubocop:enable Style/NumericLiterals
+
+    user.account.follow!(alice)
+  end
+
+  context 'when anonymously accessed' do
+    TestEndpoints::ALWAYS_CACHED.each do |endpoint|
+      describe endpoint do
+        before { get endpoint }
+
+        it_behaves_like 'cachable response'
+        it_behaves_like 'language-dependent' if TestEndpoints::LANGUAGE_DEPENDENT.include?(endpoint)
+      end
+    end
+
+    TestEndpoints::COOKIE_DEPENDENT_CACHABLE.each do |endpoint|
+      describe endpoint do
+        before { get endpoint }
+
+        it_behaves_like 'cachable response'
+
+        it 'has a Vary on Cookie' do
+          expect(response.headers['Vary']&.split(',')&.map { |x| x.strip.downcase }).to include('cookie')
+        end
+
+        it_behaves_like 'language-dependent' if TestEndpoints::LANGUAGE_DEPENDENT.include?(endpoint)
+      end
+    end
+
+    TestEndpoints::AUTHORIZATION_DEPENDENT_CACHABLE.each do |endpoint|
+      describe endpoint do
+        before { get endpoint }
+
+        it_behaves_like 'cachable response'
+
+        it 'has a Vary on Authorization' do
+          expect(response.headers['Vary']&.split(',')&.map { |x| x.strip.downcase }).to include('authorization')
+        end
+
+        it_behaves_like 'language-dependent' if TestEndpoints::LANGUAGE_DEPENDENT.include?(endpoint)
+      end
+    end
+
+    TestEndpoints::REQUIRE_LOGGED_OUT.each do |endpoint|
+      describe endpoint do
+        before { get endpoint }
+
+        it_behaves_like 'non-cacheable response'
+      end
+    end
+
+    (TestEndpoints::REQUIRE_SIGNATURE + TestEndpoints::REQUIRE_LOGIN + TestEndpoints::REQUIRE_TOKEN).each do |endpoint|
+      describe endpoint do
+        before { get endpoint }
+
+        it_behaves_like 'non-cacheable error'
+      end
+    end
+
+    describe '/api/v1/instance/domain_blocks' do
+      around do |example|
+        old_setting = Setting.show_domain_blocks
+        Setting.show_domain_blocks = show_domain_blocks
+
+        example.run
+
+        Setting.show_domain_blocks = old_setting
+      end
+
+      before { get '/api/v1/instance/domain_blocks' }
+
+      context 'when set to be publicly-available' do
+        let(:show_domain_blocks) { 'all' }
+
+        it_behaves_like 'cachable response'
+      end
+
+      context 'when allowed for local users only' do
+        let(:show_domain_blocks) { 'users' }
+
+        it_behaves_like 'non-cacheable error'
+      end
+
+      context 'when disabled' do
+        let(:show_domain_blocks) { 'disabled' }
+
+        it_behaves_like 'non-cacheable error'
+      end
+    end
+  end
+
+  context 'when logged in' do
+    before do
+      sign_in user, scope: :user
+
+      # Unfortunately, devise's `sign_in` helper causes the `session` to be
+      # loaded in the next request regardless of whether it's actually accessed
+      # by the client code.
+      #
+      # So, we make an extra query to clear issue a session cookie instead.
+      #
+      # A less resource-intensive way to deal with that would be to generate the
+      # session cookie manually, but this seems pretty involved.
+      get '/'
+    end
+
+    TestEndpoints::ALWAYS_CACHED.each do |endpoint|
+      describe endpoint do
+        before { get endpoint }
+
+        it_behaves_like 'cachable response'
+        it_behaves_like 'language-dependent' if TestEndpoints::LANGUAGE_DEPENDENT.include?(endpoint)
+      end
+    end
+
+    TestEndpoints::COOKIE_DEPENDENT_CACHABLE.each do |endpoint|
+      describe endpoint do
+        before { get endpoint }
+
+        it_behaves_like 'non-cacheable response'
+
+        it 'has a Vary on Cookie' do
+          expect(response.headers['Vary']&.split(',')&.map { |x| x.strip.downcase }).to include('cookie')
+        end
+      end
+    end
+
+    TestEndpoints::REQUIRE_LOGIN.each do |endpoint|
+      describe endpoint do
+        before { get endpoint }
+
+        it_behaves_like 'non-cacheable response'
+
+        it 'returns HTTP success' do
+          expect(response).to have_http_status(200)
+        end
+      end
+    end
+
+    TestEndpoints::REQUIRE_LOGGED_OUT.each do |endpoint|
+      describe endpoint do
+        before { get endpoint }
+
+        it_behaves_like 'non-cacheable error'
+      end
+    end
+  end
+
+  context 'with an auth token' do
+    let!(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: 'read') }
+
+    TestEndpoints::ALWAYS_CACHED.each do |endpoint|
+      describe endpoint do
+        before do
+          get endpoint, headers: { 'Authorization' => "Bearer #{token.token}" }
+        end
+
+        it_behaves_like 'cachable response'
+        it_behaves_like 'language-dependent' if TestEndpoints::LANGUAGE_DEPENDENT.include?(endpoint)
+      end
+    end
+
+    TestEndpoints::AUTHORIZATION_DEPENDENT_CACHABLE.each do |endpoint|
+      describe endpoint do
+        before do
+          get endpoint, headers: { 'Authorization' => "Bearer #{token.token}" }
+        end
+
+        it_behaves_like 'non-cacheable response'
+
+        it 'has a Vary on Authorization' do
+          expect(response.headers['Vary']&.split(',')&.map { |x| x.strip.downcase }).to include('authorization')
+        end
+      end
+    end
+
+    (TestEndpoints::REQUIRE_LOGGED_OUT + TestEndpoints::REQUIRE_TOKEN).each do |endpoint|
+      describe endpoint do
+        before do
+          get endpoint, headers: { 'Authorization' => "Bearer #{token.token}" }
+        end
+
+        it_behaves_like 'non-cacheable response'
+
+        it 'returns HTTP success' do
+          expect(response).to have_http_status(200)
+        end
+      end
+    end
+
+    describe '/api/v1/instance/domain_blocks' do
+      around do |example|
+        old_setting = Setting.show_domain_blocks
+        Setting.show_domain_blocks = show_domain_blocks
+
+        example.run
+
+        Setting.show_domain_blocks = old_setting
+      end
+
+      before do
+        get '/api/v1/instance/domain_blocks', headers: { 'Authorization' => "Bearer #{token.token}" }
+      end
+
+      context 'when set to be publicly-available' do
+        let(:show_domain_blocks) { 'all' }
+
+        it_behaves_like 'cachable response'
+      end
+
+      context 'when allowed for local users only' do
+        let(:show_domain_blocks) { 'users' }
+
+        it_behaves_like 'non-cacheable response'
+
+        it 'returns HTTP success' do
+          expect(response).to have_http_status(200)
+        end
+      end
+
+      context 'when disabled' do
+        let(:show_domain_blocks) { 'disabled' }
+
+        it_behaves_like 'non-cacheable error'
+      end
+    end
+  end
+
+  context 'with a Signature header' do
+    let(:remote_actor)    { Fabricate(:account, domain: 'example.org', uri: 'https://example.org/remote', protocol: :activitypub) }
+    let(:dummy_signature) { 'dummy-signature' }
+
+    before do
+      remote_actor.follow!(alice)
+    end
+
+    describe '/actor' do
+      before do
+        get '/actor', sign_with: remote_actor, headers: { 'Accept' => 'application/activity+json' }
+      end
+
+      it_behaves_like 'cachable response'
+
+      it 'returns HTTP success' do
+        expect(response).to have_http_status(200)
+      end
+    end
+
+    TestEndpoints::REQUIRE_SIGNATURE.each do |endpoint|
+      describe endpoint do
+        before do
+          get endpoint, sign_with: remote_actor, headers: { 'Accept' => 'application/activity+json' }
+        end
+
+        it_behaves_like 'non-cacheable response'
+
+        it 'returns HTTP success' do
+          expect(response).to have_http_status(200)
+        end
+      end
+    end
+  end
+
+  context 'when enabling AUTHORIZED_FETCH mode' do
+    around do |example|
+      ClimateControl.modify AUTHORIZED_FETCH: 'true' do
+        example.run
+      end
+    end
+
+    context 'when not providing a Signature' do
+      describe '/actor' do
+        before do
+          get '/actor', headers: { 'Accept' => 'application/activity+json' }
+        end
+
+        it_behaves_like 'cachable response'
+
+        it 'returns HTTP success' do
+          expect(response).to have_http_status(200)
+        end
+      end
+
+      (TestEndpoints::REQUIRE_SIGNATURE + TestEndpoints::AuthorizedFetch::REQUIRE_SIGNATURE).each do |endpoint|
+        describe endpoint do
+          before do
+            get endpoint, headers: { 'Accept' => 'application/activity+json' }
+          end
+
+          it_behaves_like 'non-cacheable error'
+        end
+      end
+    end
+
+    context 'when providing a Signature' do
+      let(:remote_actor)    { Fabricate(:account, domain: 'example.org', uri: 'https://example.org/remote', protocol: :activitypub) }
+      let(:dummy_signature) { 'dummy-signature' }
+
+      before do
+        remote_actor.follow!(alice)
+      end
+
+      describe '/actor' do
+        before do
+          get '/actor', sign_with: remote_actor, headers: { 'Accept' => 'application/activity+json' }
+        end
+
+        it_behaves_like 'cachable response'
+
+        it 'returns HTTP success' do
+          expect(response).to have_http_status(200)
+        end
+      end
+
+      (TestEndpoints::REQUIRE_SIGNATURE + TestEndpoints::AuthorizedFetch::REQUIRE_SIGNATURE).each do |endpoint|
+        describe endpoint do
+          before do
+            get endpoint, sign_with: remote_actor, headers: { 'Accept' => 'application/activity+json' }
+          end
+
+          it_behaves_like 'non-cacheable response'
+
+          it 'returns HTTP success' do
+            expect(response).to have_http_status(200)
+          end
+        end
+      end
+    end
+  end
+
+  context 'when enabling LIMITED_FEDERATION_MODE mode' do
+    around do |example|
+      ClimateControl.modify LIMITED_FEDERATION_MODE: 'true' do
+        old_whitelist_mode = Rails.configuration.x.whitelist_mode
+        Rails.configuration.x.whitelist_mode = true
+
+        example.run
+
+        Rails.configuration.x.whitelist_mode = old_whitelist_mode
+      end
+    end
+
+    context 'when not providing a Signature' do
+      describe '/actor' do
+        before do
+          get '/actor', headers: { 'Accept' => 'application/activity+json' }
+        end
+
+        it_behaves_like 'cachable response'
+
+        it 'returns HTTP success' do
+          expect(response).to have_http_status(200)
+        end
+      end
+
+      (TestEndpoints::REQUIRE_SIGNATURE + TestEndpoints::AuthorizedFetch::REQUIRE_SIGNATURE).each do |endpoint|
+        describe endpoint do
+          before do
+            get endpoint, headers: { 'Accept' => 'application/activity+json' }
+          end
+
+          it_behaves_like 'non-cacheable error'
+        end
+      end
+    end
+
+    context 'when providing a Signature from an allowed domain' do
+      let(:remote_actor)    { Fabricate(:account, domain: 'example.org', uri: 'https://example.org/remote', protocol: :activitypub) }
+      let(:dummy_signature) { 'dummy-signature' }
+
+      before do
+        DomainAllow.create!(domain: remote_actor.domain)
+        remote_actor.follow!(alice)
+      end
+
+      describe '/actor' do
+        before do
+          get '/actor', sign_with: remote_actor, headers: { 'Accept' => 'application/activity+json' }
+        end
+
+        it_behaves_like 'cachable response'
+
+        it 'returns HTTP success' do
+          expect(response).to have_http_status(200)
+        end
+      end
+
+      (TestEndpoints::REQUIRE_SIGNATURE + TestEndpoints::AuthorizedFetch::REQUIRE_SIGNATURE).each do |endpoint|
+        describe endpoint do
+          before do
+            get endpoint, sign_with: remote_actor, headers: { 'Accept' => 'application/activity+json' }
+          end
+
+          it_behaves_like 'non-cacheable response'
+
+          it 'returns HTTP success' do
+            expect(response).to have_http_status(200)
+          end
+        end
+      end
+    end
+
+    context 'when providing a Signature from a non-allowed domain' do
+      let(:remote_actor)    { Fabricate(:account, domain: 'example.org', uri: 'https://example.org/remote', protocol: :activitypub) }
+      let(:dummy_signature) { 'dummy-signature' }
+
+      describe '/actor' do
+        before do
+          get '/actor', sign_with: remote_actor, headers: { 'Accept' => 'application/activity+json' }
+        end
+
+        it_behaves_like 'cachable response'
+
+        it 'returns HTTP success' do
+          expect(response).to have_http_status(200)
+        end
+      end
+
+      (TestEndpoints::REQUIRE_SIGNATURE + TestEndpoints::AuthorizedFetch::REQUIRE_SIGNATURE).each do |endpoint|
+        describe endpoint do
+          before do
+            get endpoint, sign_with: remote_actor, headers: { 'Accept' => 'application/activity+json' }
+          end
+
+          it_behaves_like 'non-cacheable error'
+        end
+      end
+    end
+  end
+
+  context 'when enabling DISALLOW_UNAUTHENTICATED_API_ACCESS' do
+    around do |example|
+      ClimateControl.modify DISALLOW_UNAUTHENTICATED_API_ACCESS: 'true' do
+        example.run
+      end
+    end
+
+    context 'when anonymously accessed' do
+      TestEndpoints::ALWAYS_CACHED.each do |endpoint|
+        describe endpoint do
+          before { get endpoint }
+
+          it_behaves_like 'cachable response'
+          it_behaves_like 'language-dependent' if TestEndpoints::LANGUAGE_DEPENDENT.include?(endpoint)
+        end
+      end
+
+      TestEndpoints::REQUIRE_LOGGED_OUT.each do |endpoint|
+        describe endpoint do
+          before { get endpoint }
+
+          it_behaves_like 'non-cacheable response'
+        end
+      end
+
+      (TestEndpoints::REQUIRE_TOKEN + TestEndpoints::AUTHORIZATION_DEPENDENT_CACHABLE + TestEndpoints::DisabledAnonymousAPI::REQUIRE_TOKEN).each do |endpoint|
+        describe endpoint do
+          before { get endpoint }
+
+          it_behaves_like 'non-cacheable error'
+        end
+      end
+    end
+
+    context 'with an auth token' do
+      let!(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: 'read') }
+
+      TestEndpoints::ALWAYS_CACHED.each do |endpoint|
+        describe endpoint do
+          before do
+            get endpoint, headers: { 'Authorization' => "Bearer #{token.token}" }
+          end
+
+          it_behaves_like 'cachable response'
+          it_behaves_like 'language-dependent' if TestEndpoints::LANGUAGE_DEPENDENT.include?(endpoint)
+        end
+      end
+
+      TestEndpoints::AUTHORIZATION_DEPENDENT_CACHABLE.each do |endpoint|
+        describe endpoint do
+          before do
+            get endpoint, headers: { 'Authorization' => "Bearer #{token.token}" }
+          end
+
+          it_behaves_like 'non-cacheable response'
+
+          it 'has a Vary on Authorization' do
+            expect(response.headers['Vary']&.split(',')&.map { |x| x.strip.downcase }).to include('authorization')
+          end
+        end
+      end
+
+      (TestEndpoints::REQUIRE_LOGGED_OUT + TestEndpoints::REQUIRE_TOKEN + TestEndpoints::DisabledAnonymousAPI::REQUIRE_TOKEN).each do |endpoint|
+        describe endpoint do
+          before do
+            get endpoint, headers: { 'Authorization' => "Bearer #{token.token}" }
+          end
+
+          it_behaves_like 'non-cacheable response'
+
+          it 'returns HTTP success' do
+            expect(response).to have_http_status(200)
+          end
+        end
+      end
+    end
+  end
+end
diff --git a/spec/requests/link_headers_spec.rb b/spec/requests/link_headers_spec.rb
index c32e0f79a9..b822adbfb8 100644
--- a/spec/requests/link_headers_spec.rb
+++ b/spec/requests/link_headers_spec.rb
@@ -13,7 +13,7 @@ describe 'Link headers' do
     it 'contains webfinger url in link header' do
       link_header = link_header_with_type('application/jrd+json')
 
-      expect(link_header.href).to match 'http://www.example.com/.well-known/webfinger?resource=acct%3Atest%40cb6e6126.ngrok.io'
+      expect(link_header.href).to eq 'http://www.example.com/.well-known/webfinger?resource=acct%3Atest%40cb6e6126.ngrok.io'
       expect(link_header.attr_pairs.first).to eq %w(rel lrdd)
     end
 
@@ -26,7 +26,7 @@ describe 'Link headers' do
 
     def link_header_with_type(type)
       LinkHeader.parse(response.headers['Link'].to_s).links.find do |link|
-        link.attr_pairs.any? { |pair| pair == ['type', type] }
+        link.attr_pairs.any?(['type', type])
       end
     end
   end
diff --git a/spec/services/account_search_service_spec.rb b/spec/services/account_search_service_spec.rb
index bb819bb6c0..98264e6e13 100644
--- a/spec/services/account_search_service_spec.rb
+++ b/spec/services/account_search_service_spec.rb
@@ -20,7 +20,7 @@ describe AccountSearchService, type: :service do
       end
     end
 
-    context 'searching for a simple term that is not an exact match' do
+    context 'when searching for a simple term that is not an exact match' do
       it 'does not return a nil entry in the array for the exact match' do
         account = Fabricate(:account, username: 'matchingusername')
         results = subject.call('match', nil, limit: 5)
diff --git a/spec/services/account_statuses_cleanup_service_spec.rb b/spec/services/account_statuses_cleanup_service_spec.rb
index e83063f734..f7a88a9172 100644
--- a/spec/services/account_statuses_cleanup_service_spec.rb
+++ b/spec/services/account_statuses_cleanup_service_spec.rb
@@ -20,13 +20,13 @@ describe AccountStatusesCleanupService, type: :service do
       let!(:another_old_status) { Fabricate(:status, created_at: 1.year.ago, account: account) }
       let!(:recent_status)      { Fabricate(:status, created_at: 1.day.ago, account: account) }
 
-      context 'given a budget of 1' do
+      context 'when given a budget of 1' do
         it 'reports 1 deleted toot' do
           expect(subject.call(account_policy, 1)).to eq 1
         end
       end
 
-      context 'given a normal budget of 10' do
+      context 'when given a normal budget of 10' do
         it 'reports 3 deleted statuses' do
           expect(subject.call(account_policy, 10)).to eq 3
         end
diff --git a/spec/services/activitypub/fetch_remote_status_service_spec.rb b/spec/services/activitypub/fetch_remote_status_service_spec.rb
index 1c39db21fc..826b67d884 100644
--- a/spec/services/activitypub/fetch_remote_status_service_spec.rb
+++ b/spec/services/activitypub/fetch_remote_status_service_spec.rb
@@ -226,12 +226,12 @@ RSpec.describe ActivityPub::FetchRemoteStatusService, type: :service do
     end
   end
 
-  context 'statuses referencing other statuses' do
+  context 'with statuses referencing other statuses' do
     before do
       stub_const 'ActivityPub::FetchRemoteStatusService::DISCOVERIES_PER_REQUEST', 5
     end
 
-    context 'using inReplyTo' do
+    context 'when using inReplyTo' do
       let(:object) do
         {
           '@context': 'https://www.w3.org/ns/activitystreams',
@@ -267,7 +267,7 @@ RSpec.describe ActivityPub::FetchRemoteStatusService, type: :service do
       end
     end
 
-    context 'using replies' do
+    context 'when using replies' do
       let(:object) do
         {
           '@context': 'https://www.w3.org/ns/activitystreams',
diff --git a/spec/services/activitypub/process_account_service_spec.rb b/spec/services/activitypub/process_account_service_spec.rb
index 491b8ed5af..ffbc51b641 100644
--- a/spec/services/activitypub/process_account_service_spec.rb
+++ b/spec/services/activitypub/process_account_service_spec.rb
@@ -5,7 +5,7 @@ require 'rails_helper'
 RSpec.describe ActivityPub::ProcessAccountService, type: :service do
   subject { described_class.new }
 
-  context 'property values' do
+  context 'with property values' do
     let(:payload) do
       {
         id: 'https://foo.test',
@@ -82,7 +82,7 @@ RSpec.describe ActivityPub::ProcessAccountService, type: :service do
       account.suspend!(origin: suspension_origin)
     end
 
-    context 'locally' do
+    context 'when locally' do
       let(:suspension_origin) { :local }
 
       it 'does not unsuspend it' do
@@ -94,7 +94,7 @@ RSpec.describe ActivityPub::ProcessAccountService, type: :service do
       end
     end
 
-    context 'remotely' do
+    context 'when remotely' do
       let(:suspension_origin) { :remote }
 
       it 'unsuspends it' do
@@ -112,7 +112,7 @@ RSpec.describe ActivityPub::ProcessAccountService, type: :service do
     end
   end
 
-  context 'discovering many subdomains in a short timeframe' do
+  context 'when discovering many subdomains in a short timeframe' do
     before do
       stub_const 'ActivityPub::ProcessAccountService::SUBDOMAINS_RATELIMIT', 5
     end
@@ -138,7 +138,7 @@ RSpec.describe ActivityPub::ProcessAccountService, type: :service do
     end
   end
 
-  context 'accounts referencing other accounts' do
+  context 'when Accounts referencing other accounts' do
     before do
       stub_const 'ActivityPub::ProcessAccountService::DISCOVERIES_PER_REQUEST', 5
     end
diff --git a/spec/services/activitypub/process_status_update_service_spec.rb b/spec/services/activitypub/process_status_update_service_spec.rb
index e9f23b9cf2..9d90e5eb80 100644
--- a/spec/services/activitypub/process_status_update_service_spec.rb
+++ b/spec/services/activitypub/process_status_update_service_spec.rb
@@ -269,7 +269,7 @@ RSpec.describe ActivityPub::ProcessStatusUpdateService, type: :service do
       end
     end
 
-    context 'originally without tags' do
+    context 'when originally without tags' do
       before do
         subject.call(status, json)
       end
@@ -279,7 +279,7 @@ RSpec.describe ActivityPub::ProcessStatusUpdateService, type: :service do
       end
     end
 
-    context 'originally with tags' do
+    context 'when originally with tags' do
       let(:tags) { [Fabricate(:tag, name: 'test'), Fabricate(:tag, name: 'foo')] }
 
       let(:payload) do
@@ -305,7 +305,7 @@ RSpec.describe ActivityPub::ProcessStatusUpdateService, type: :service do
       end
     end
 
-    context 'originally without mentions' do
+    context 'when originally without mentions' do
       before do
         subject.call(status, json)
       end
@@ -315,7 +315,7 @@ RSpec.describe ActivityPub::ProcessStatusUpdateService, type: :service do
       end
     end
 
-    context 'originally with mentions' do
+    context 'when originally with mentions' do
       let(:mentions) { [alice, bob] }
 
       before do
@@ -327,7 +327,7 @@ RSpec.describe ActivityPub::ProcessStatusUpdateService, type: :service do
       end
     end
 
-    context 'originally without media attachments' do
+    context 'when originally without media attachments' do
       before do
         stub_request(:get, 'https://example.com/foo.png').to_return(body: attachment_fixture('emojo.png'))
         subject.call(status, json)
@@ -362,7 +362,7 @@ RSpec.describe ActivityPub::ProcessStatusUpdateService, type: :service do
       end
     end
 
-    context 'originally with media attachments' do
+    context 'when originally with media attachments' do
       let(:media_attachments) { [Fabricate(:media_attachment, remote_url: 'https://example.com/foo.png'), Fabricate(:media_attachment, remote_url: 'https://example.com/unused.png')] }
 
       let(:payload) do
@@ -404,7 +404,7 @@ RSpec.describe ActivityPub::ProcessStatusUpdateService, type: :service do
       end
     end
 
-    context 'originally with a poll' do
+    context 'when originally with a poll' do
       before do
         poll = Fabricate(:poll, status: status)
         status.update(preloadable_poll: poll)
@@ -420,7 +420,7 @@ RSpec.describe ActivityPub::ProcessStatusUpdateService, type: :service do
       end
     end
 
-    context 'originally without a poll' do
+    context 'when originally without a poll' do
       let(:payload) do
         {
           '@context': 'https://www.w3.org/ns/activitystreams',
diff --git a/spec/services/backup_service_spec.rb b/spec/services/backup_service_spec.rb
new file mode 100644
index 0000000000..b961b7f675
--- /dev/null
+++ b/spec/services/backup_service_spec.rb
@@ -0,0 +1,67 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe BackupService, type: :service do
+  subject(:service_call) { described_class.new.call(backup) }
+
+  let!(:user)           { Fabricate(:user) }
+  let!(:attachment)     { Fabricate(:media_attachment, account: user.account) }
+  let!(:status)         { Fabricate(:status, account: user.account, text: 'Hello', visibility: :public, media_attachments: [attachment]) }
+  let!(:private_status) { Fabricate(:status, account: user.account, text: 'secret', visibility: :private) }
+  let!(:favourite)      { Fabricate(:favourite, account: user.account) }
+  let!(:bookmark)       { Fabricate(:bookmark, account: user.account) }
+  let!(:backup)         { Fabricate(:backup, user: user) }
+
+  def read_zip_file(backup, filename)
+    file = Paperclip.io_adapters.for(backup.dump)
+    Zip::File.open(file) do |zipfile|
+      entry = zipfile.glob(filename).first
+      return entry.get_input_stream.read
+    end
+  end
+
+  it 'marks the backup as processed' do
+    expect { service_call }.to change(backup, :processed).from(false).to(true)
+  end
+
+  it 'exports outbox.json as expected' do
+    service_call
+
+    json = Oj.load(read_zip_file(backup, 'outbox.json'))
+    expect(json['@context']).to_not be_nil
+    expect(json['type']).to eq 'OrderedCollection'
+    expect(json['totalItems']).to eq 2
+    expect(json['orderedItems'][0]['@context']).to be_nil
+    expect(json['orderedItems'][0]).to include({
+      'type' => 'Create',
+      'object' => include({
+        'id' => ActivityPub::TagManager.instance.uri_for(status),
+        'content' => '<p>Hello</p>',
+      }),
+    })
+    expect(json['orderedItems'][1]).to include({
+      'type' => 'Create',
+      'object' => include({
+        'id' => ActivityPub::TagManager.instance.uri_for(private_status),
+        'content' => '<p>secret</p>',
+      }),
+    })
+  end
+
+  it 'exports likes.json as expected' do
+    service_call
+
+    json = Oj.load(read_zip_file(backup, 'likes.json'))
+    expect(json['type']).to eq 'OrderedCollection'
+    expect(json['orderedItems']).to eq [ActivityPub::TagManager.instance.uri_for(favourite.status)]
+  end
+
+  it 'exports bookmarks.json as expected' do
+    service_call
+
+    json = Oj.load(read_zip_file(backup, 'bookmarks.json'))
+    expect(json['type']).to eq 'OrderedCollection'
+    expect(json['orderedItems']).to eq [ActivityPub::TagManager.instance.uri_for(bookmark.status)]
+  end
+end
diff --git a/spec/services/bulk_import_row_service_spec.rb b/spec/services/bulk_import_row_service_spec.rb
new file mode 100644
index 0000000000..5bbe6b0042
--- /dev/null
+++ b/spec/services/bulk_import_row_service_spec.rb
@@ -0,0 +1,95 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe BulkImportRowService do
+  subject { described_class.new }
+
+  let(:account)    { Fabricate(:account) }
+  let(:import)     { Fabricate(:bulk_import, account: account, type: import_type) }
+  let(:import_row) { Fabricate(:bulk_import_row, bulk_import: import, data: data) }
+
+  describe '#call' do
+    context 'when importing a follow' do
+      let(:import_type)    { 'following' }
+      let(:target_account) { Fabricate(:account) }
+      let(:service_double) { instance_double(FollowService, call: nil) }
+      let(:data) do
+        { 'acct' => target_account.acct }
+      end
+
+      before do
+        allow(FollowService).to receive(:new).and_return(service_double)
+      end
+
+      it 'calls FollowService with the expected arguments and returns true' do
+        expect(subject.call(import_row)).to be true
+
+        expect(service_double).to have_received(:call).with(account, target_account, { reblogs: nil, notify: nil, languages: nil })
+      end
+    end
+
+    context 'when importing a block' do
+      let(:import_type)    { 'blocking' }
+      let(:target_account) { Fabricate(:account) }
+      let(:service_double) { instance_double(BlockService, call: nil) }
+      let(:data) do
+        { 'acct' => target_account.acct }
+      end
+
+      before do
+        allow(BlockService).to receive(:new).and_return(service_double)
+      end
+
+      it 'calls BlockService with the expected arguments and returns true' do
+        expect(subject.call(import_row)).to be true
+
+        expect(service_double).to have_received(:call).with(account, target_account)
+      end
+    end
+
+    context 'when importing a mute' do
+      let(:import_type)    { 'muting' }
+      let(:target_account) { Fabricate(:account) }
+      let(:service_double) { instance_double(MuteService, call: nil) }
+      let(:data) do
+        { 'acct' => target_account.acct }
+      end
+
+      before do
+        allow(MuteService).to receive(:new).and_return(service_double)
+      end
+
+      it 'calls MuteService with the expected arguments and returns true' do
+        expect(subject.call(import_row)).to be true
+
+        expect(service_double).to have_received(:call).with(account, target_account, { notifications: nil })
+      end
+    end
+
+    context 'when importing a bookmark' do
+      let(:import_type) { 'bookmarks' }
+      let(:data) do
+        { 'uri' => ActivityPub::TagManager.instance.uri_for(target_status) }
+      end
+
+      context 'when the status is public' do
+        let(:target_status) { Fabricate(:status) }
+
+        it 'bookmarks the status and returns true' do
+          expect(subject.call(import_row)).to be true
+          expect(account.bookmarked?(target_status)).to be true
+        end
+      end
+
+      context 'when the status is not accessible to the user' do
+        let(:target_status) { Fabricate(:status, visibility: :direct) }
+
+        it 'does not bookmark the status and returns false' do
+          expect(subject.call(import_row)).to be false
+          expect(account.bookmarked?(target_status)).to be false
+        end
+      end
+    end
+  end
+end
diff --git a/spec/services/bulk_import_service_spec.rb b/spec/services/bulk_import_service_spec.rb
new file mode 100644
index 0000000000..09dfb0a0b6
--- /dev/null
+++ b/spec/services/bulk_import_service_spec.rb
@@ -0,0 +1,417 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe BulkImportService do
+  subject { described_class.new }
+
+  let(:account) { Fabricate(:account) }
+  let(:import) { Fabricate(:bulk_import, account: account, type: import_type, overwrite: overwrite, state: :in_progress, imported_items: 0, processed_items: 0) }
+
+  before do
+    import.update(total_items: import.rows.count)
+  end
+
+  describe '#call' do
+    around do |example|
+      Sidekiq::Testing.fake! do
+        example.run
+        Sidekiq::Worker.clear_all
+      end
+    end
+
+    context 'when importing follows' do
+      let(:import_type) { 'following' }
+      let(:overwrite)   { false }
+
+      let!(:rows) do
+        [
+          { 'acct' => 'user@foo.bar' },
+          { 'acct' => 'unknown@unknown.bar' },
+        ].map { |data| import.rows.create!(data: data) }
+      end
+
+      before do
+        account.follow!(Fabricate(:account))
+      end
+
+      it 'does not immediately change who the account follows' do
+        expect { subject.call(import) }.to_not(change { account.reload.active_relationships.to_a })
+      end
+
+      it 'enqueues workers for the expected rows' do
+        subject.call(import)
+        expect(Import::RowWorker.jobs.pluck('args').flatten).to match_array(rows.map(&:id))
+      end
+
+      it 'requests to follow all the listed users once the workers have run' do
+        subject.call(import)
+
+        resolve_account_service_double = double
+        allow(ResolveAccountService).to receive(:new).and_return(resolve_account_service_double)
+        allow(resolve_account_service_double).to receive(:call).with('user@foo.bar', any_args) { Fabricate(:account, username: 'user', domain: 'foo.bar', protocol: :activitypub) }
+        allow(resolve_account_service_double).to receive(:call).with('unknown@unknown.bar', any_args) { Fabricate(:account, username: 'unknown', domain: 'unknown.bar', protocol: :activitypub) }
+
+        Import::RowWorker.drain
+
+        expect(FollowRequest.includes(:target_account).where(account: account).map(&:target_account).map(&:acct)).to contain_exactly('user@foo.bar', 'unknown@unknown.bar')
+      end
+    end
+
+    context 'when importing follows with overwrite' do
+      let(:import_type) { 'following' }
+      let(:overwrite)   { true }
+
+      let!(:followed)         { Fabricate(:account, username: 'followed', domain: 'foo.bar', protocol: :activitypub) }
+      let!(:to_be_unfollowed) { Fabricate(:account, username: 'to_be_unfollowed', domain: 'foo.bar', protocol: :activitypub) }
+
+      let!(:rows) do
+        [
+          { 'acct' => 'followed@foo.bar', 'show_reblogs' => false, 'notify' => true, 'languages' => ['en'] },
+          { 'acct' => 'user@foo.bar' },
+          { 'acct' => 'unknown@unknown.bar' },
+        ].map { |data| import.rows.create!(data: data) }
+      end
+
+      before do
+        account.follow!(followed, reblogs: true, notify: false)
+        account.follow!(to_be_unfollowed)
+      end
+
+      it 'unfollows user not present on list' do
+        subject.call(import)
+        expect(account.following?(to_be_unfollowed)).to be false
+      end
+
+      it 'updates the existing follow relationship as expected' do
+        expect { subject.call(import) }.to change { Follow.where(account: account, target_account: followed).pick(:show_reblogs, :notify, :languages) }.from([true, false, nil]).to([false, true, ['en']])
+      end
+
+      it 'enqueues workers for the expected rows' do
+        subject.call(import)
+        expect(Import::RowWorker.jobs.pluck('args').flatten).to match_array(rows[1..].map(&:id))
+      end
+
+      it 'requests to follow all the expected users once the workers have run' do
+        subject.call(import)
+
+        resolve_account_service_double = double
+        allow(ResolveAccountService).to receive(:new).and_return(resolve_account_service_double)
+        allow(resolve_account_service_double).to receive(:call).with('user@foo.bar', any_args) { Fabricate(:account, username: 'user', domain: 'foo.bar', protocol: :activitypub) }
+        allow(resolve_account_service_double).to receive(:call).with('unknown@unknown.bar', any_args) { Fabricate(:account, username: 'unknown', domain: 'unknown.bar', protocol: :activitypub) }
+
+        Import::RowWorker.drain
+
+        expect(FollowRequest.includes(:target_account).where(account: account).map(&:target_account).map(&:acct)).to contain_exactly('user@foo.bar', 'unknown@unknown.bar')
+      end
+    end
+
+    context 'when importing blocks' do
+      let(:import_type) { 'blocking' }
+      let(:overwrite)   { false }
+
+      let!(:rows) do
+        [
+          { 'acct' => 'user@foo.bar' },
+          { 'acct' => 'unknown@unknown.bar' },
+        ].map { |data| import.rows.create!(data: data) }
+      end
+
+      before do
+        account.block!(Fabricate(:account, username: 'already_blocked', domain: 'remote.org'))
+      end
+
+      it 'does not immediately change who the account blocks' do
+        expect { subject.call(import) }.to_not(change { account.reload.blocking.to_a })
+      end
+
+      it 'enqueues workers for the expected rows' do
+        subject.call(import)
+        expect(Import::RowWorker.jobs.pluck('args').flatten).to match_array(rows.map(&:id))
+      end
+
+      it 'blocks all the listed users once the workers have run' do
+        subject.call(import)
+
+        resolve_account_service_double = double
+        allow(ResolveAccountService).to receive(:new).and_return(resolve_account_service_double)
+        allow(resolve_account_service_double).to receive(:call).with('user@foo.bar', any_args) { Fabricate(:account, username: 'user', domain: 'foo.bar', protocol: :activitypub) }
+        allow(resolve_account_service_double).to receive(:call).with('unknown@unknown.bar', any_args) { Fabricate(:account, username: 'unknown', domain: 'unknown.bar', protocol: :activitypub) }
+
+        Import::RowWorker.drain
+
+        expect(account.blocking.map(&:acct)).to contain_exactly('already_blocked@remote.org', 'user@foo.bar', 'unknown@unknown.bar')
+      end
+    end
+
+    context 'when importing blocks with overwrite' do
+      let(:import_type) { 'blocking' }
+      let(:overwrite)   { true }
+
+      let!(:blocked)         { Fabricate(:account, username: 'blocked', domain: 'foo.bar', protocol: :activitypub) }
+      let!(:to_be_unblocked) { Fabricate(:account, username: 'to_be_unblocked', domain: 'foo.bar', protocol: :activitypub) }
+
+      let!(:rows) do
+        [
+          { 'acct' => 'blocked@foo.bar' },
+          { 'acct' => 'user@foo.bar' },
+          { 'acct' => 'unknown@unknown.bar' },
+        ].map { |data| import.rows.create!(data: data) }
+      end
+
+      before do
+        account.block!(blocked)
+        account.block!(to_be_unblocked)
+      end
+
+      it 'unblocks user not present on list' do
+        subject.call(import)
+        expect(account.blocking?(to_be_unblocked)).to be false
+      end
+
+      it 'enqueues workers for the expected rows' do
+        subject.call(import)
+        expect(Import::RowWorker.jobs.pluck('args').flatten).to match_array(rows[1..].map(&:id))
+      end
+
+      it 'requests to follow all the expected users once the workers have run' do
+        subject.call(import)
+
+        resolve_account_service_double = double
+        allow(ResolveAccountService).to receive(:new).and_return(resolve_account_service_double)
+        allow(resolve_account_service_double).to receive(:call).with('user@foo.bar', any_args) { Fabricate(:account, username: 'user', domain: 'foo.bar', protocol: :activitypub) }
+        allow(resolve_account_service_double).to receive(:call).with('unknown@unknown.bar', any_args) { Fabricate(:account, username: 'unknown', domain: 'unknown.bar', protocol: :activitypub) }
+
+        Import::RowWorker.drain
+
+        expect(account.blocking.map(&:acct)).to contain_exactly('blocked@foo.bar', 'user@foo.bar', 'unknown@unknown.bar')
+      end
+    end
+
+    context 'when importing mutes' do
+      let(:import_type) { 'muting' }
+      let(:overwrite)   { false }
+
+      let!(:rows) do
+        [
+          { 'acct' => 'user@foo.bar' },
+          { 'acct' => 'unknown@unknown.bar' },
+        ].map { |data| import.rows.create!(data: data) }
+      end
+
+      before do
+        account.mute!(Fabricate(:account, username: 'already_muted', domain: 'remote.org'))
+      end
+
+      it 'does not immediately change who the account blocks' do
+        expect { subject.call(import) }.to_not(change { account.reload.muting.to_a })
+      end
+
+      it 'enqueues workers for the expected rows' do
+        subject.call(import)
+        expect(Import::RowWorker.jobs.pluck('args').flatten).to match_array(rows.map(&:id))
+      end
+
+      it 'mutes all the listed users once the workers have run' do
+        subject.call(import)
+
+        resolve_account_service_double = double
+        allow(ResolveAccountService).to receive(:new).and_return(resolve_account_service_double)
+        allow(resolve_account_service_double).to receive(:call).with('user@foo.bar', any_args) { Fabricate(:account, username: 'user', domain: 'foo.bar', protocol: :activitypub) }
+        allow(resolve_account_service_double).to receive(:call).with('unknown@unknown.bar', any_args) { Fabricate(:account, username: 'unknown', domain: 'unknown.bar', protocol: :activitypub) }
+
+        Import::RowWorker.drain
+
+        expect(account.muting.map(&:acct)).to contain_exactly('already_muted@remote.org', 'user@foo.bar', 'unknown@unknown.bar')
+      end
+    end
+
+    context 'when importing mutes with overwrite' do
+      let(:import_type) { 'muting' }
+      let(:overwrite)   { true }
+
+      let!(:muted)         { Fabricate(:account, username: 'muted', domain: 'foo.bar', protocol: :activitypub) }
+      let!(:to_be_unmuted) { Fabricate(:account, username: 'to_be_unmuted', domain: 'foo.bar', protocol: :activitypub) }
+
+      let!(:rows) do
+        [
+          { 'acct' => 'muted@foo.bar', 'hide_notifications' => true },
+          { 'acct' => 'user@foo.bar' },
+          { 'acct' => 'unknown@unknown.bar' },
+        ].map { |data| import.rows.create!(data: data) }
+      end
+
+      before do
+        account.mute!(muted, notifications: false)
+        account.mute!(to_be_unmuted)
+      end
+
+      it 'updates the existing mute as expected' do
+        expect { subject.call(import) }.to change { Mute.where(account: account, target_account: muted).pick(:hide_notifications) }.from(false).to(true)
+      end
+
+      it 'unblocks user not present on list' do
+        subject.call(import)
+        expect(account.muting?(to_be_unmuted)).to be false
+      end
+
+      it 'enqueues workers for the expected rows' do
+        subject.call(import)
+        expect(Import::RowWorker.jobs.pluck('args').flatten).to match_array(rows[1..].map(&:id))
+      end
+
+      it 'requests to follow all the expected users once the workers have run' do
+        subject.call(import)
+
+        resolve_account_service_double = double
+        allow(ResolveAccountService).to receive(:new).and_return(resolve_account_service_double)
+        allow(resolve_account_service_double).to receive(:call).with('user@foo.bar', any_args) { Fabricate(:account, username: 'user', domain: 'foo.bar', protocol: :activitypub) }
+        allow(resolve_account_service_double).to receive(:call).with('unknown@unknown.bar', any_args) { Fabricate(:account, username: 'unknown', domain: 'unknown.bar', protocol: :activitypub) }
+
+        Import::RowWorker.drain
+
+        expect(account.muting.map(&:acct)).to contain_exactly('muted@foo.bar', 'user@foo.bar', 'unknown@unknown.bar')
+      end
+    end
+
+    context 'when importing domain blocks' do
+      let(:import_type) { 'domain_blocking' }
+      let(:overwrite)   { false }
+
+      let!(:rows) do
+        [
+          { 'domain' => 'blocked.com' },
+          { 'domain' => 'to_block.com' },
+        ].map { |data| import.rows.create!(data: data) }
+      end
+
+      before do
+        account.block_domain!('alreadyblocked.com')
+        account.block_domain!('blocked.com')
+      end
+
+      it 'blocks all the new domains' do
+        subject.call(import)
+        expect(account.domain_blocks.pluck(:domain)).to contain_exactly('alreadyblocked.com', 'blocked.com', 'to_block.com')
+      end
+
+      it 'marks the import as finished' do
+        subject.call(import)
+        expect(import.reload.finished?).to be true
+      end
+    end
+
+    context 'when importing domain blocks with overwrite' do
+      let(:import_type) { 'domain_blocking' }
+      let(:overwrite)   { true }
+
+      let!(:rows) do
+        [
+          { 'domain' => 'blocked.com' },
+          { 'domain' => 'to_block.com' },
+        ].map { |data| import.rows.create!(data: data) }
+      end
+
+      before do
+        account.block_domain!('alreadyblocked.com')
+        account.block_domain!('blocked.com')
+      end
+
+      it 'blocks all the new domains' do
+        subject.call(import)
+        expect(account.domain_blocks.pluck(:domain)).to contain_exactly('blocked.com', 'to_block.com')
+      end
+
+      it 'marks the import as finished' do
+        subject.call(import)
+        expect(import.reload.finished?).to be true
+      end
+    end
+
+    context 'when importing bookmarks' do
+      let(:import_type) { 'bookmarks' }
+      let(:overwrite)   { false }
+
+      let!(:already_bookmarked)  { Fabricate(:status, uri: 'https://already.bookmarked/1') }
+      let!(:status)              { Fabricate(:status, uri: 'https://foo.bar/posts/1') }
+      let!(:inaccessible_status) { Fabricate(:status, uri: 'https://foo.bar/posts/inaccessible', visibility: :direct) }
+      let!(:bookmarked)          { Fabricate(:status, uri: 'https://foo.bar/posts/already-bookmarked') }
+
+      let!(:rows) do
+        [
+          { 'uri' => status.uri },
+          { 'uri' => inaccessible_status.uri },
+          { 'uri' => bookmarked.uri },
+          { 'uri' => 'https://domain.unknown/foo' },
+          { 'uri' => 'https://domain.unknown/private' },
+        ].map { |data| import.rows.create!(data: data) }
+      end
+
+      before do
+        account.bookmarks.create!(status: already_bookmarked)
+        account.bookmarks.create!(status: bookmarked)
+      end
+
+      it 'enqueues workers for the expected rows' do
+        subject.call(import)
+        expect(Import::RowWorker.jobs.pluck('args').flatten).to match_array(rows.map(&:id))
+      end
+
+      it 'updates the bookmarks as expected once the workers have run' do
+        subject.call(import)
+
+        service_double = double
+        allow(ActivityPub::FetchRemoteStatusService).to receive(:new).and_return(service_double)
+        allow(service_double).to receive(:call).with('https://domain.unknown/foo') { Fabricate(:status, uri: 'https://domain.unknown/foo') }
+        allow(service_double).to receive(:call).with('https://domain.unknown/private') { Fabricate(:status, uri: 'https://domain.unknown/private', visibility: :direct) }
+
+        Import::RowWorker.drain
+
+        expect(account.bookmarks.map(&:status).map(&:uri)).to contain_exactly(already_bookmarked.uri, status.uri, bookmarked.uri, 'https://domain.unknown/foo')
+      end
+    end
+
+    context 'when importing bookmarks with overwrite' do
+      let(:import_type) { 'bookmarks' }
+      let(:overwrite)   { true }
+
+      let!(:already_bookmarked)  { Fabricate(:status, uri: 'https://already.bookmarked/1') }
+      let!(:status)              { Fabricate(:status, uri: 'https://foo.bar/posts/1') }
+      let!(:inaccessible_status) { Fabricate(:status, uri: 'https://foo.bar/posts/inaccessible', visibility: :direct) }
+      let!(:bookmarked)          { Fabricate(:status, uri: 'https://foo.bar/posts/already-bookmarked') }
+
+      let!(:rows) do
+        [
+          { 'uri' => status.uri },
+          { 'uri' => inaccessible_status.uri },
+          { 'uri' => bookmarked.uri },
+          { 'uri' => 'https://domain.unknown/foo' },
+          { 'uri' => 'https://domain.unknown/private' },
+        ].map { |data| import.rows.create!(data: data) }
+      end
+
+      before do
+        account.bookmarks.create!(status: already_bookmarked)
+        account.bookmarks.create!(status: bookmarked)
+      end
+
+      it 'enqueues workers for the expected rows' do
+        subject.call(import)
+        expect(Import::RowWorker.jobs.pluck('args').flatten).to match_array(rows.map(&:id))
+      end
+
+      it 'updates the bookmarks as expected once the workers have run' do
+        subject.call(import)
+
+        service_double = double
+        allow(ActivityPub::FetchRemoteStatusService).to receive(:new).and_return(service_double)
+        allow(service_double).to receive(:call).with('https://domain.unknown/foo') { Fabricate(:status, uri: 'https://domain.unknown/foo') }
+        allow(service_double).to receive(:call).with('https://domain.unknown/private') { Fabricate(:status, uri: 'https://domain.unknown/private', visibility: :direct) }
+
+        Import::RowWorker.drain
+
+        expect(account.bookmarks.map(&:status).map(&:uri)).to contain_exactly(status.uri, bookmarked.uri, 'https://domain.unknown/foo')
+      end
+    end
+  end
+end
diff --git a/spec/services/fetch_link_card_service_spec.rb b/spec/services/fetch_link_card_service_spec.rb
index d79ab7a433..7016ecd3f4 100644
--- a/spec/services/fetch_link_card_service_spec.rb
+++ b/spec/services/fetch_link_card_service_spec.rb
@@ -18,7 +18,7 @@ RSpec.describe FetchLinkCardService, type: :service do
     subject.call(status)
   end
 
-  context 'in a local status' do
+  context 'with a local status' do
     context do
       let(:status) { Fabricate(:status, text: 'Check out http://example.中国') }
 
@@ -89,7 +89,7 @@ RSpec.describe FetchLinkCardService, type: :service do
     end
   end
 
-  context 'in a remote status' do
+  context 'with a remote status' do
     let(:status) { Fabricate(:status, account: Fabricate(:account, domain: 'example.com'), text: 'Habt ihr ein paar gute Links zu <a>foo</a> #<span class="tag"><a href="https://quitter.se/tag/wannacry" target="_blank" rel="tag noopener noreferrer" title="https://quitter.se/tag/wannacry">Wannacry</a></span> herumfliegen?   Ich will mal unter <br> <a href="https://github.com/qbi/WannaCry" target="_blank" rel="noopener noreferrer" title="https://github.com/qbi/WannaCry">https://github.com/qbi/WannaCry</a> was sammeln. !<a href="http://sn.jonkman.ca/group/416/id" target="_blank" rel="noopener noreferrer" title="http://sn.jonkman.ca/group/416/id">security</a>&nbsp;') }
 
     it 'parses out URLs' do
diff --git a/spec/services/fetch_oembed_service_spec.rb b/spec/services/fetch_oembed_service_spec.rb
index 8a0b492223..777cbae3fb 100644
--- a/spec/services/fetch_oembed_service_spec.rb
+++ b/spec/services/fetch_oembed_service_spec.rb
@@ -39,7 +39,7 @@ describe FetchOEmbedService, type: :service do
         end
       end
 
-      context 'Both of JSON and XML provider are discoverable' do
+      context 'when both of JSON and XML provider are discoverable' do
         before do
           stub_request(:get, 'https://host.test/oembed.html').to_return(
             status: 200,
@@ -66,7 +66,7 @@ describe FetchOEmbedService, type: :service do
         end
       end
 
-      context 'JSON provider is discoverable while XML provider is not' do
+      context 'when JSON provider is discoverable while XML provider is not' do
         before do
           stub_request(:get, 'https://host.test/oembed.html').to_return(
             status: 200,
@@ -87,7 +87,7 @@ describe FetchOEmbedService, type: :service do
         end
       end
 
-      context 'XML provider is discoverable while JSON provider is not' do
+      context 'when XML provider is discoverable while JSON provider is not' do
         before do
           stub_request(:get, 'https://host.test/oembed.html').to_return(
             status: 200,
@@ -108,7 +108,7 @@ describe FetchOEmbedService, type: :service do
         end
       end
 
-      context 'Invalid XML provider is discoverable while JSON provider is not' do
+      context 'with Invalid XML provider is discoverable while JSON provider is not' do
         before do
           stub_request(:get, 'https://host.test/oembed.html').to_return(
             status: 200,
@@ -122,7 +122,7 @@ describe FetchOEmbedService, type: :service do
         end
       end
 
-      context 'Neither of JSON and XML provider is discoverable' do
+      context 'with neither of JSON and XML provider is discoverable' do
         before do
           stub_request(:get, 'https://host.test/oembed.html').to_return(
             status: 200,
@@ -136,7 +136,7 @@ describe FetchOEmbedService, type: :service do
         end
       end
 
-      context 'Empty JSON provider is discoverable' do
+      context 'when empty JSON provider is discoverable' do
         before do
           stub_request(:get, 'https://host.test/oembed.html').to_return(
             status: 200,
diff --git a/spec/services/fetch_remote_status_service_spec.rb b/spec/services/fetch_remote_status_service_spec.rb
index 694a75dc29..798740c9b3 100644
--- a/spec/services/fetch_remote_status_service_spec.rb
+++ b/spec/services/fetch_remote_status_service_spec.rb
@@ -16,7 +16,7 @@ RSpec.describe FetchRemoteStatusService, type: :service do
     }
   end
 
-  context 'protocol is :activitypub' do
+  context 'when protocol is :activitypub' do
     subject { described_class.new.call(note[:id], prefetched_body: prefetched_body) }
 
     let(:prefetched_body) { Oj.dump(note) }
diff --git a/spec/services/follow_service_spec.rb b/spec/services/follow_service_spec.rb
index 67a8b2c54e..c9521e3c87 100644
--- a/spec/services/follow_service_spec.rb
+++ b/spec/services/follow_service_spec.rb
@@ -7,7 +7,7 @@ RSpec.describe FollowService, type: :service do
 
   let(:sender) { Fabricate(:account, username: 'alice') }
 
-  context 'local account' do
+  context 'when local account' do
     describe 'locked account' do
       let(:bob) { Fabricate(:account, locked: true, username: 'bob') }
 
@@ -138,7 +138,7 @@ RSpec.describe FollowService, type: :service do
     end
   end
 
-  context 'remote ActivityPub account' do
+  context 'when remote ActivityPub account' do
     let(:bob) { Fabricate(:account, username: 'bob', domain: 'example.com', protocol: :activitypub, inbox_url: 'http://example.com/inbox') }
 
     before do
diff --git a/spec/services/import_service_spec.rb b/spec/services/import_service_spec.rb
index f081f2d9dc..7f8e5855fa 100644
--- a/spec/services/import_service_spec.rb
+++ b/spec/services/import_service_spec.rb
@@ -13,7 +13,7 @@ RSpec.describe ImportService, type: :service do
     stub_request(:post, 'https://example.com/inbox').to_return(status: 200)
   end
 
-  context 'import old-style list of muted users' do
+  context 'when importing old-style list of muted users' do
     subject { ImportService.new }
 
     let(:csv) { attachment_fixture('mute-imports.txt') }
@@ -51,7 +51,7 @@ RSpec.describe ImportService, type: :service do
     end
   end
 
-  context 'import new-style list of muted users' do
+  context 'when importing new-style list of muted users' do
     subject { ImportService.new }
 
     let(:csv) { attachment_fixture('new-mute-imports.txt') }
@@ -92,7 +92,7 @@ RSpec.describe ImportService, type: :service do
     end
   end
 
-  context 'import old-style list of followed users' do
+  context 'when importing old-style list of followed users' do
     subject { ImportService.new }
 
     let(:csv) { attachment_fixture('mute-imports.txt') }
@@ -134,7 +134,7 @@ RSpec.describe ImportService, type: :service do
     end
   end
 
-  context 'import new-style list of followed users' do
+  context 'when importing new-style list of followed users' do
     subject { ImportService.new }
 
     let(:csv) { attachment_fixture('new-following-imports.txt') }
@@ -181,7 +181,7 @@ RSpec.describe ImportService, type: :service do
   # Based on the bug report 20571 where UTF-8 encoded domains were rejecting import of their users
   #
   # https://github.com/mastodon/mastodon/issues/20571
-  context 'utf-8 encoded domains' do
+  context 'with a utf-8 encoded domains' do
     subject { ImportService.new }
 
     let!(:nare) { Fabricate(:account, username: 'nare', domain: 'թութ.հայ', locked: false, protocol: :activitypub, inbox_url: 'https://թութ.հայ/inbox') }
@@ -200,7 +200,7 @@ RSpec.describe ImportService, type: :service do
     end
   end
 
-  context 'import bookmarks' do
+  context 'when importing bookmarks' do
     subject { ImportService.new }
 
     let(:csv) { attachment_fixture('bookmark-imports.txt') }
diff --git a/spec/services/notify_service_spec.rb b/spec/services/notify_service_spec.rb
index 616a7aa204..8c99431fac 100644
--- a/spec/services/notify_service_spec.rb
+++ b/spec/services/notify_service_spec.rb
@@ -49,7 +49,7 @@ RSpec.describe NotifyService, type: :service do
     expect { subject }.to_not change(Notification, :count)
   end
 
-  context 'for direct messages' do
+  context 'with direct messages' do
     let(:activity) { Fabricate(:mention, account: recipient, status: Fabricate(:status, account: sender, visibility: :direct)) }
     let(:type)     { :mention }
 
@@ -58,14 +58,14 @@ RSpec.describe NotifyService, type: :service do
       user.save
     end
 
-    context 'if recipient is supposed to be following sender' do
+    context 'when recipient is supposed to be following sender' do
       let(:enabled) { true }
 
       it 'does not notify' do
         expect { subject }.to_not change(Notification, :count)
       end
 
-      context 'if the message chain is initiated by recipient, but is not direct message' do
+      context 'when the message chain is initiated by recipient, but is not direct message' do
         let(:reply_to) { Fabricate(:status, account: recipient) }
         let!(:mention) { Fabricate(:mention, account: sender, status: reply_to) }
         let(:activity) { Fabricate(:mention, account: recipient, status: Fabricate(:status, account: sender, visibility: :direct, thread: reply_to)) }
@@ -75,7 +75,7 @@ RSpec.describe NotifyService, type: :service do
         end
       end
 
-      context 'if the message chain is initiated by recipient, but without a mention to the sender, even if the sender sends multiple messages in a row' do
+      context 'when the message chain is initiated by recipient, but without a mention to the sender, even if the sender sends multiple messages in a row' do
         let(:reply_to) { Fabricate(:status, account: recipient) }
         let!(:mention) { Fabricate(:mention, account: sender, status: reply_to) }
         let(:dummy_reply) { Fabricate(:status, account: sender, visibility: :direct, thread: reply_to) }
@@ -86,7 +86,7 @@ RSpec.describe NotifyService, type: :service do
         end
       end
 
-      context 'if the message chain is initiated by the recipient with a mention to the sender' do
+      context 'when the message chain is initiated by the recipient with a mention to the sender' do
         let(:reply_to) { Fabricate(:status, account: recipient, visibility: :direct) }
         let!(:mention) { Fabricate(:mention, account: sender, status: reply_to) }
         let(:activity) { Fabricate(:mention, account: recipient, status: Fabricate(:status, account: sender, visibility: :direct, thread: reply_to)) }
@@ -97,7 +97,7 @@ RSpec.describe NotifyService, type: :service do
       end
     end
 
-    context 'if recipient is NOT supposed to be following sender' do
+    context 'when recipient is NOT supposed to be following sender' do
       let(:enabled) { false }
 
       it 'does notify' do
diff --git a/spec/services/process_mentions_service_spec.rb b/spec/services/process_mentions_service_spec.rb
index adc45c60af..399800b2a6 100644
--- a/spec/services/process_mentions_service_spec.rb
+++ b/spec/services/process_mentions_service_spec.rb
@@ -33,10 +33,10 @@ RSpec.describe ProcessMentionsService, type: :service do
     end
   end
 
-  context 'resolving a mention to a remote account' do
+  context 'with resolving a mention to a remote account' do
     let(:status) { Fabricate(:status, account: account, text: "Hello @#{remote_user.acct}", visibility: :public) }
 
-    context 'ActivityPub' do
+    context 'with ActivityPub' do
       context do
         let!(:remote_user) { Fabricate(:account, username: 'remote_user', protocol: :activitypub, domain: 'example.com', inbox_url: 'http://example.com/inbox') }
 
@@ -49,7 +49,7 @@ RSpec.describe ProcessMentionsService, type: :service do
         end
       end
 
-      context 'mentioning a user several times when not saving records' do
+      context 'when mentioning a user several times when not saving records' do
         let!(:remote_user) { Fabricate(:account, username: 'remote_user', protocol: :activitypub, domain: 'example.com', inbox_url: 'http://example.com/inbox') }
         let(:status)       { Fabricate(:status, account: account, text: "Hello @#{remote_user.acct} @#{remote_user.acct} @#{remote_user.acct}", visibility: :public) }
 
@@ -89,7 +89,7 @@ RSpec.describe ProcessMentionsService, type: :service do
       end
     end
 
-    context 'Temporarily-unreachable ActivityPub user' do
+    context 'with a Temporarily-unreachable ActivityPub user' do
       let!(:remote_user) { Fabricate(:account, username: 'remote_user', protocol: :activitypub, domain: 'example.com', inbox_url: 'http://example.com/inbox', last_webfingered_at: nil) }
 
       before do
diff --git a/spec/services/reblog_service_spec.rb b/spec/services/reblog_service_spec.rb
index 2ad6d30f6b..69500848d9 100644
--- a/spec/services/reblog_service_spec.rb
+++ b/spec/services/reblog_service_spec.rb
@@ -5,7 +5,7 @@ require 'rails_helper'
 RSpec.describe ReblogService, type: :service do
   let(:alice)  { Fabricate(:account, username: 'alice') }
 
-  context 'creates a reblog with appropriate visibility' do
+  context 'when creates a reblog with appropriate visibility' do
     subject { ReblogService.new }
 
     let(:visibility)        { :public }
@@ -61,7 +61,7 @@ RSpec.describe ReblogService, type: :service do
     end
   end
 
-  context 'ActivityPub' do
+  context 'with ActivityPub' do
     subject { ReblogService.new }
 
     let(:bob)    { Fabricate(:account, username: 'bob', protocol: :activitypub, domain: 'example.com', inbox_url: 'http://example.com/inbox') }
diff --git a/spec/services/report_service_spec.rb b/spec/services/report_service_spec.rb
index 452400f722..29207462a0 100644
--- a/spec/services/report_service_spec.rb
+++ b/spec/services/report_service_spec.rb
@@ -7,7 +7,7 @@ RSpec.describe ReportService, type: :service do
 
   let(:source_account) { Fabricate(:account) }
 
-  context 'for a remote account' do
+  context 'with a remote account' do
     let(:remote_account) { Fabricate(:account, domain: 'example.com', protocol: :activitypub, inbox_url: 'http://example.com/inbox') }
 
     before do
diff --git a/spec/services/resolve_account_service_spec.rb b/spec/services/resolve_account_service_spec.rb
index 3ce1f7f2ba..ed22a8147a 100644
--- a/spec/services/resolve_account_service_spec.rb
+++ b/spec/services/resolve_account_service_spec.rb
@@ -11,11 +11,11 @@ RSpec.describe ResolveAccountService, type: :service do
     stub_request(:get, 'https://ap.example.com/.well-known/webfinger?resource=acct:foo@ap.example.com').to_return(request_fixture('activitypub-webfinger.txt'))
     stub_request(:get, 'https://ap.example.com/users/foo').to_return(request_fixture('activitypub-actor.txt'))
     stub_request(:get, 'https://ap.example.com/users/foo.atom').to_return(request_fixture('activitypub-feed.txt'))
-    stub_request(:get, %r{https://ap.example.com/users/foo/\w+}).to_return(status: 404)
+    stub_request(:get, %r{https://ap\.example\.com/users/foo/\w+}).to_return(status: 404)
     stub_request(:get, 'https://example.com/.well-known/webfinger?resource=acct:hoge@example.com').to_return(status: 410)
   end
 
-  context 'using skip_webfinger' do
+  context 'when using skip_webfinger' do
     context 'when account is known' do
       let!(:remote_account) { Fabricate(:account, username: 'foo', domain: 'ap.example.com', protocol: 'activitypub') }
 
@@ -78,7 +78,7 @@ RSpec.describe ResolveAccountService, type: :service do
   end
 
   context 'when webfinger returns http gone' do
-    context 'for a previously known account' do
+    context 'with a previously known account' do
       before do
         Fabricate(:account, username: 'hoge', domain: 'example.com', last_webfingered_at: nil)
         allow(AccountDeletionWorker).to receive(:perform_async)
@@ -94,7 +94,7 @@ RSpec.describe ResolveAccountService, type: :service do
       end
     end
 
-    context 'for a previously unknown account' do
+    context 'with a previously unknown account' do
       it 'returns nil' do
         expect(subject.call('hoge@example.com')).to be_nil
       end
diff --git a/spec/services/resolve_url_service_spec.rb b/spec/services/resolve_url_service_spec.rb
index 3598311ee0..8d2af74173 100644
--- a/spec/services/resolve_url_service_spec.rb
+++ b/spec/services/resolve_url_service_spec.rb
@@ -30,7 +30,7 @@ describe ResolveURLService, type: :service do
       expect(subject.call(url)).to eq known_account
     end
 
-    context 'searching for a remote private status' do
+    context 'when searching for a remote private status' do
       let(:account)  { Fabricate(:account) }
       let(:poster)   { Fabricate(:account, domain: 'example.com') }
       let(:url)      { 'https://example.com/@foo/42' }
@@ -95,7 +95,7 @@ describe ResolveURLService, type: :service do
       end
     end
 
-    context 'searching for a local private status' do
+    context 'when searching for a local private status' do
       let(:account) { Fabricate(:account) }
       let(:poster)  { Fabricate(:account) }
       let!(:status) { Fabricate(:status, account: poster, visibility: :private) }
@@ -127,7 +127,7 @@ describe ResolveURLService, type: :service do
       end
     end
 
-    context 'searching for a link that redirects to a local public status' do
+    context 'when searching for a link that redirects to a local public status' do
       let(:account) { Fabricate(:account) }
       let(:poster)  { Fabricate(:account) }
       let!(:status) { Fabricate(:status, account: poster, visibility: :public) }
diff --git a/spec/services/search_service_spec.rb b/spec/services/search_service_spec.rb
index 1ad0efe0af..00f693dfab 100644
--- a/spec/services/search_service_spec.rb
+++ b/spec/services/search_service_spec.rb
@@ -23,7 +23,7 @@ describe SearchService, type: :service do
         @query = 'http://test.host/query'
       end
 
-      context 'that does not find anything' do
+      context 'when it does not find anything' do
         it 'returns the empty results' do
           service = double(call: nil)
           allow(ResolveURLService).to receive(:new).and_return(service)
@@ -34,7 +34,7 @@ describe SearchService, type: :service do
         end
       end
 
-      context 'that finds an account' do
+      context 'when it finds an account' do
         it 'includes the account in the results' do
           account = Account.new
           service = double(call: account)
@@ -46,7 +46,7 @@ describe SearchService, type: :service do
         end
       end
 
-      context 'that finds a status' do
+      context 'when it finds a status' do
         it 'includes the status in the results' do
           status = Status.new
           service = double(call: status)
@@ -60,7 +60,7 @@ describe SearchService, type: :service do
     end
 
     describe 'with a non-url query' do
-      context 'that matches an account' do
+      context 'when it matches an account' do
         it 'includes the account in the results' do
           query = 'username'
           account = Account.new
@@ -73,7 +73,7 @@ describe SearchService, type: :service do
         end
       end
 
-      context 'that matches a tag' do
+      context 'when it matches a tag' do
         it 'includes the tag in the results' do
           query = '#tag'
           tag = Tag.new
diff --git a/spec/services/unallow_domain_service_spec.rb b/spec/services/unallow_domain_service_spec.rb
index 48e310a9d1..fbc1d59592 100644
--- a/spec/services/unallow_domain_service_spec.rb
+++ b/spec/services/unallow_domain_service_spec.rb
@@ -12,7 +12,7 @@ RSpec.describe UnallowDomainService, type: :service do
   let!(:already_banned_account) { Fabricate(:account, username: 'badguy', domain: 'evil.org', suspended: true, silenced: true) }
   let!(:domain_allow) { Fabricate(:domain_allow, domain: 'evil.org') }
 
-  context 'in limited federation mode' do
+  context 'with limited federation mode' do
     before do
       allow(subject).to receive(:whitelist_mode?).and_return(true)
     end
diff --git a/spec/services/verify_link_service_spec.rb b/spec/services/verify_link_service_spec.rb
index ea9ccc3fc7..415788cb58 100644
--- a/spec/services/verify_link_service_spec.rb
+++ b/spec/services/verify_link_service_spec.rb
@@ -5,7 +5,7 @@ require 'rails_helper'
 RSpec.describe VerifyLinkService, type: :service do
   subject { described_class.new }
 
-  context 'given a local account' do
+  context 'when given a local account' do
     let(:account) { Fabricate(:account, username: 'alice') }
     let(:field)   { Account::Field.new(account, 'name' => 'Website', 'value' => 'http://example.com') }
 
@@ -129,7 +129,7 @@ RSpec.describe VerifyLinkService, type: :service do
     end
   end
 
-  context 'given a remote account' do
+  context 'when given a remote account' do
     let(:account) { Fabricate(:account, username: 'alice', domain: 'example.com', url: 'https://profile.example.com/alice') }
     let(:field)   { Account::Field.new(account, 'name' => 'Website', 'value' => '<a href="http://example.com" rel="me"><span class="invisible">http://</span><span class="">example.com</span><span class="invisible"></span></a>') }
 
diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb
index 25f3140026..dedb9719cd 100644
--- a/spec/spec_helper.rb
+++ b/spec/spec_helper.rb
@@ -38,7 +38,7 @@ RSpec.configure do |config|
 
   config.after :suite do
     gc_counter = 0
-    FileUtils.rm_rf(Dir["#{Rails.root}/spec/test_files/"])
+    FileUtils.rm_rf(Dir[Rails.root.join('spec', 'test_files')])
   end
 
   config.after :each do
diff --git a/spec/validators/disallowed_hashtags_validator_spec.rb b/spec/validators/disallowed_hashtags_validator_spec.rb
index 896fd4fc5e..e98db38792 100644
--- a/spec/validators/disallowed_hashtags_validator_spec.rb
+++ b/spec/validators/disallowed_hashtags_validator_spec.rb
@@ -14,7 +14,7 @@ RSpec.describe DisallowedHashtagsValidator, type: :validator do
     let(:status) { double(errors: errors, local?: local, reblog?: reblog, text: disallowed_tags.map { |x| "##{x}" }.join(' ')) }
     let(:errors) { double(add: nil) }
 
-    context 'for a remote reblog' do
+    context 'with a remote reblog' do
       let(:local)  { false }
       let(:reblog) { true }
 
@@ -23,7 +23,7 @@ RSpec.describe DisallowedHashtagsValidator, type: :validator do
       end
     end
 
-    context 'for a local original status' do
+    context 'with a local original status' do
       let(:local)  { true }
       let(:reblog) { false }
 
diff --git a/spec/validators/email_mx_validator_spec.rb b/spec/validators/email_mx_validator_spec.rb
index a11b8e01e0..d9703d81b1 100644
--- a/spec/validators/email_mx_validator_spec.rb
+++ b/spec/validators/email_mx_validator_spec.rb
@@ -6,7 +6,7 @@ describe EmailMxValidator do
   describe '#validate' do
     let(:user) { double(email: 'foo@example.com', sign_up_ip: '1.2.3.4', errors: double(add: nil)) }
 
-    context 'for an e-mail domain that is explicitly allowed' do
+    context 'with an e-mail domain that is explicitly allowed' do
       around do |block|
         tmp = Rails.configuration.x.email_domains_whitelist
         Rails.configuration.x.email_domains_whitelist = 'example.com'
diff --git a/spec/validators/follow_limit_validator_spec.rb b/spec/validators/follow_limit_validator_spec.rb
index 94ba0c47f8..7b9055a27f 100644
--- a/spec/validators/follow_limit_validator_spec.rb
+++ b/spec/validators/follow_limit_validator_spec.rb
@@ -18,7 +18,7 @@ RSpec.describe FollowLimitValidator, type: :validator do
     let(:_nil)    { true }
     let(:local)   { false }
 
-    context 'follow.account.nil? || !follow.account.local?' do
+    context 'with follow.account.nil? || !follow.account.local?' do
       let(:_nil)    { true }
 
       it 'not calls errors.add' do
@@ -26,11 +26,11 @@ RSpec.describe FollowLimitValidator, type: :validator do
       end
     end
 
-    context '!(follow.account.nil? || !follow.account.local?)' do
+    context 'with !(follow.account.nil? || !follow.account.local?)' do
       let(:_nil)    { false }
       let(:local)   { true }
 
-      context 'limit_reached?' do
+      context 'when limit_reached?' do
         let(:limit_reached) { true }
 
         it 'calls errors.add' do
@@ -39,7 +39,7 @@ RSpec.describe FollowLimitValidator, type: :validator do
         end
       end
 
-      context '!limit_reached?' do
+      context 'with !limit_reached?' do
         let(:limit_reached) { false }
 
         it 'not calls errors.add' do
diff --git a/spec/validators/poll_validator_spec.rb b/spec/validators/poll_validator_spec.rb
index f3f4b12881..069a471619 100644
--- a/spec/validators/poll_validator_spec.rb
+++ b/spec/validators/poll_validator_spec.rb
@@ -18,7 +18,7 @@ RSpec.describe PollValidator, type: :validator do
       expect(errors).to_not have_received(:add)
     end
 
-    context 'expires just 5 min ago' do
+    context 'when expires is just 5 min ago' do
       let(:expires_at) { 5.minutes.from_now }
 
       it 'not calls errors add' do
diff --git a/spec/validators/status_pin_validator_spec.rb b/spec/validators/status_pin_validator_spec.rb
index d5bd0d1b83..00b89d702f 100644
--- a/spec/validators/status_pin_validator_spec.rb
+++ b/spec/validators/status_pin_validator_spec.rb
@@ -20,7 +20,7 @@ RSpec.describe StatusPinValidator, type: :validator do
     let(:reblog)      { false }
     let(:count)       { 0 }
 
-    context 'pin.status.reblog?' do
+    context 'when pin.status.reblog?' do
       let(:reblog) { true }
 
       it 'calls errors.add' do
@@ -28,7 +28,7 @@ RSpec.describe StatusPinValidator, type: :validator do
       end
     end
 
-    context 'pin.account_id != pin.status.account_id' do
+    context 'when pin.account_id != pin.status.account_id' do
       let(:pin_account_id)    { 1 }
       let(:status_account_id) { 2 }
 
@@ -37,7 +37,7 @@ RSpec.describe StatusPinValidator, type: :validator do
       end
     end
 
-    context 'if pin.status.direct_visibility?' do
+    context 'when pin.status.direct_visibility?' do
       let(:visibility) { 'direct' }
 
       it 'calls errors.add' do
@@ -45,7 +45,7 @@ RSpec.describe StatusPinValidator, type: :validator do
       end
     end
 
-    context 'pin.account.status_pins.count > 4 && pin.account.local?' do
+    context 'when pin.account.status_pins.count > 4 && pin.account.local?' do
       let(:count) { 5 }
       let(:local) { true }
 
diff --git a/spec/validators/unreserved_username_validator_spec.rb b/spec/validators/unreserved_username_validator_spec.rb
index 3c6f71c590..85bd7dcb6a 100644
--- a/spec/validators/unreserved_username_validator_spec.rb
+++ b/spec/validators/unreserved_username_validator_spec.rb
@@ -13,7 +13,7 @@ RSpec.describe UnreservedUsernameValidator, type: :validator do
     let(:account)   { double(username: username, errors: errors) }
     let(:errors) { double(add: nil) }
 
-    context '@username.blank?' do
+    context 'when @username is blank?' do
       let(:username) { nil }
 
       it 'not calls errors.add' do
@@ -21,10 +21,10 @@ RSpec.describe UnreservedUsernameValidator, type: :validator do
       end
     end
 
-    context '!@username.blank?' do
+    context 'when @username is not blank?' do
       let(:username) { 'f' }
 
-      context 'reserved_username?' do
+      context 'with reserved_username?' do
         let(:reserved_username) { true }
 
         it 'calls errors.add' do
@@ -32,7 +32,7 @@ RSpec.describe UnreservedUsernameValidator, type: :validator do
         end
       end
 
-      context '!reserved_username?' do
+      context 'when username is not reserved' do
         let(:reserved_username) { false }
 
         it 'not calls errors.add' do
diff --git a/spec/validators/url_validator_spec.rb b/spec/validators/url_validator_spec.rb
index 966261b505..a56ccd8e08 100644
--- a/spec/validators/url_validator_spec.rb
+++ b/spec/validators/url_validator_spec.rb
@@ -15,7 +15,7 @@ RSpec.describe URLValidator, type: :validator do
     let(:value)     { '' }
     let(:attribute) { :foo }
 
-    context 'unless compliant?' do
+    context 'when not compliant?' do
       let(:compliant) { false }
 
       it 'calls errors.add' do
@@ -23,7 +23,7 @@ RSpec.describe URLValidator, type: :validator do
       end
     end
 
-    context 'if compliant?' do
+    context 'when compliant?' do
       let(:compliant) { true }
 
       it 'not calls errors.add' do
diff --git a/spec/workers/bulk_import_worker_spec.rb b/spec/workers/bulk_import_worker_spec.rb
new file mode 100644
index 0000000000..91f51fbb42
--- /dev/null
+++ b/spec/workers/bulk_import_worker_spec.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+describe BulkImportWorker do
+  subject { described_class.new }
+
+  let(:import) { Fabricate(:bulk_import, state: :scheduled) }
+
+  describe '#perform' do
+    let(:service_double) { instance_double(BulkImportService, call: nil) }
+
+    before do
+      allow(BulkImportService).to receive(:new).and_return(service_double)
+    end
+
+    it 'changes the import\'s state as appropriate' do
+      expect { subject.perform(import.id) }.to change { import.reload.state.to_sym }.from(:scheduled).to(:in_progress)
+    end
+
+    it 'calls BulkImportService' do
+      subject.perform(import.id)
+      expect(service_double).to have_received(:call).with(import)
+    end
+  end
+end
diff --git a/spec/workers/import/row_worker_spec.rb b/spec/workers/import/row_worker_spec.rb
new file mode 100644
index 0000000000..0a71a838fc
--- /dev/null
+++ b/spec/workers/import/row_worker_spec.rb
@@ -0,0 +1,127 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+describe Import::RowWorker do
+  subject { described_class.new }
+
+  let(:row) { Fabricate(:bulk_import_row, bulk_import: import) }
+
+  describe '#perform' do
+    before do
+      allow(BulkImportRowService).to receive(:new).and_return(service_double)
+    end
+
+    shared_examples 'clean failure' do
+      let(:service_double) { instance_double(BulkImportRowService, call: false) }
+
+      it 'calls BulkImportRowService' do
+        subject.perform(row.id)
+        expect(service_double).to have_received(:call).with(row)
+      end
+
+      it 'increases the number of processed items' do
+        expect { subject.perform(row.id) }.to(change { import.reload.processed_items }.by(+1))
+      end
+
+      it 'does not increase the number of imported items' do
+        expect { subject.perform(row.id) }.to_not(change { import.reload.imported_items })
+      end
+
+      it 'does not delete the row' do
+        subject.perform(row.id)
+        expect(BulkImportRow.exists?(row.id)).to be true
+      end
+    end
+
+    shared_examples 'unclean failure' do
+      let(:service_double) { instance_double(BulkImportRowService) }
+
+      before do
+        allow(service_double).to receive(:call) do
+          raise 'dummy error'
+        end
+      end
+
+      it 'raises an error and does not change processed items count' do
+        expect { subject.perform(row.id) }.to raise_error(StandardError, 'dummy error').and(not_change { import.reload.processed_items })
+      end
+
+      it 'does not delete the row' do
+        expect { subject.perform(row.id) }.to raise_error(StandardError, 'dummy error').and(not_change { BulkImportRow.exists?(row.id) })
+      end
+    end
+
+    shared_examples 'clean success' do
+      let(:service_double) { instance_double(BulkImportRowService, call: true) }
+
+      it 'calls BulkImportRowService' do
+        subject.perform(row.id)
+        expect(service_double).to have_received(:call).with(row)
+      end
+
+      it 'increases the number of processed items' do
+        expect { subject.perform(row.id) }.to(change { import.reload.processed_items }.by(+1))
+      end
+
+      it 'increases the number of imported items' do
+        expect { subject.perform(row.id) }.to(change { import.reload.imported_items }.by(+1))
+      end
+
+      it 'deletes the row' do
+        expect { subject.perform(row.id) }.to change { BulkImportRow.exists?(row.id) }.from(true).to(false)
+      end
+    end
+
+    context 'when there are multiple rows to process' do
+      let(:import) { Fabricate(:bulk_import, total_items: 2, processed_items: 0, imported_items: 0, state: :in_progress) }
+
+      context 'with a clean failure' do
+        include_examples 'clean failure'
+
+        it 'does not mark the import as finished' do
+          expect { subject.perform(row.id) }.to_not(change { import.reload.state.to_sym })
+        end
+      end
+
+      context 'with an unclean failure' do
+        include_examples 'unclean failure'
+
+        it 'does not mark the import as finished' do
+          expect { subject.perform(row.id) }.to raise_error(StandardError).and(not_change { import.reload.state.to_sym })
+        end
+      end
+
+      context 'with a clean success' do
+        include_examples 'clean success'
+
+        it 'does not mark the import as finished' do
+          expect { subject.perform(row.id) }.to_not(change { import.reload.state.to_sym })
+        end
+      end
+    end
+
+    context 'when this is the last row to process' do
+      let(:import) { Fabricate(:bulk_import, total_items: 2, processed_items: 1, imported_items: 0, state: :in_progress) }
+
+      context 'with a clean failure' do
+        include_examples 'clean failure'
+
+        it 'marks the import as finished' do
+          expect { subject.perform(row.id) }.to change { import.reload.state.to_sym }.from(:in_progress).to(:finished)
+        end
+      end
+
+      # NOTE: sidekiq retry logic may be a bit too difficult to test, so leaving this blind spot for now
+      it_behaves_like 'unclean failure'
+
+      context 'with a clean success' do
+        include_examples 'clean success'
+
+        it 'marks the import as finished' do
+          expect { subject.perform(row.id) }.to change { import.reload.state.to_sym }.from(:in_progress).to(:finished)
+        end
+      end
+    end
+  end
+end
diff --git a/spec/workers/move_worker_spec.rb b/spec/workers/move_worker_spec.rb
index e93060adb8..ac7bd506b6 100644
--- a/spec/workers/move_worker_spec.rb
+++ b/spec/workers/move_worker_spec.rb
@@ -5,22 +5,28 @@ require 'rails_helper'
 describe MoveWorker do
   subject { described_class.new }
 
-  let(:local_follower)   { Fabricate(:account) }
+  let(:local_follower)   { Fabricate(:account, domain: nil) }
   let(:blocking_account) { Fabricate(:account) }
   let(:muting_account)   { Fabricate(:account) }
-  let(:source_account)   { Fabricate(:account, protocol: :activitypub, domain: 'example.com') }
-  let(:target_account)   { Fabricate(:account, protocol: :activitypub, domain: 'example.com') }
+  let(:source_account)   { Fabricate(:account, protocol: :activitypub, domain: 'example.com', uri: 'https://example.org/a', inbox_url: 'https://example.org/a/inbox') }
+  let(:target_account)   { Fabricate(:account, protocol: :activitypub, domain: 'example.com', uri: 'https://example.org/b', inbox_url: 'https://example.org/b/inbox') }
   let(:local_user)       { Fabricate(:user) }
   let(:comment)          { 'old note prior to move' }
   let!(:account_note)    { Fabricate(:account_note, account: local_user.account, target_account: source_account, comment: comment) }
+  let(:list)             { Fabricate(:list, account: local_follower) }
 
   let(:block_service) { double }
 
   before do
+    stub_request(:post, 'https://example.org/a/inbox').to_return(status: 200)
+    stub_request(:post, 'https://example.org/b/inbox').to_return(status: 200)
+
     local_follower.follow!(source_account)
     blocking_account.block!(source_account)
     muting_account.mute!(source_account)
 
+    list.accounts << source_account
+
     allow(BlockService).to receive(:new).and_return(block_service)
     allow(block_service).to receive(:call)
   end
@@ -86,55 +92,100 @@ describe MoveWorker do
     end
   end
 
-  context 'both accounts are distant' do
-    describe 'perform' do
-      it 'calls UnfollowFollowWorker' do
-        expect_push_bulk_to_match(UnfollowFollowWorker, [[local_follower.id, source_account.id, target_account.id, false]])
-        subject.perform(source_account.id, target_account.id)
+  shared_examples 'lists handling' do
+    it 'puts the new account on the list' do
+      subject.perform(source_account.id, target_account.id)
+      expect(list.accounts.include?(target_account)).to be true
+    end
+
+    it 'does not create invalid list memberships' do
+      subject.perform(source_account.id, target_account.id)
+      expect(ListAccount.all).to all be_valid
+    end
+  end
+
+  shared_examples 'common tests' do
+    include_examples 'user note handling'
+    include_examples 'block and mute handling'
+    include_examples 'followers count handling'
+    include_examples 'lists handling'
+
+    context 'when a local user already follows both source and target' do
+      before do
+        local_follower.request_follow!(target_account)
       end
 
       include_examples 'user note handling'
       include_examples 'block and mute handling'
       include_examples 'followers count handling'
+      include_examples 'lists handling'
+
+      context 'when the local user already has the target in a list' do
+        before do
+          list.accounts << target_account
+        end
+
+        include_examples 'lists handling'
+      end
     end
-  end
 
-  context 'target account is local' do
-    let(:target_account) { Fabricate(:account) }
-
-    describe 'perform' do
-      it 'calls UnfollowFollowWorker' do
-        expect_push_bulk_to_match(UnfollowFollowWorker, [[local_follower.id, source_account.id, target_account.id, true]])
-        subject.perform(source_account.id, target_account.id)
+    context 'when a local follower already has a pending request to the target' do
+      before do
+        local_follower.follow!(target_account)
       end
 
       include_examples 'user note handling'
       include_examples 'block and mute handling'
       include_examples 'followers count handling'
+      include_examples 'lists handling'
+
+      context 'when the local user already has the target in a list' do
+        before do
+          list.accounts << target_account
+        end
+
+        include_examples 'lists handling'
+      end
     end
   end
 
-  context 'both target and source accounts are local' do
-    let(:target_account) { Fabricate(:account) }
-    let(:source_account) { Fabricate(:account) }
+  describe '#perform' do
+    context 'when both accounts are distant' do
+      it 'calls UnfollowFollowWorker' do
+        Sidekiq::Testing.fake! do
+          subject.perform(source_account.id, target_account.id)
+          expect(UnfollowFollowWorker).to have_enqueued_sidekiq_job(local_follower.id, source_account.id, target_account.id, false)
+          Sidekiq::Worker.drain_all
+        end
+      end
+
+      include_examples 'common tests'
+    end
+
+    context 'when target account is local' do
+      let(:target_account) { Fabricate(:account) }
+
+      it 'calls UnfollowFollowWorker' do
+        Sidekiq::Testing.fake! do
+          subject.perform(source_account.id, target_account.id)
+          expect(UnfollowFollowWorker).to have_enqueued_sidekiq_job(local_follower.id, source_account.id, target_account.id, true)
+          Sidekiq::Worker.clear_all
+        end
+      end
+
+      include_examples 'common tests'
+    end
+
+    context 'when both target and source accounts are local' do
+      let(:target_account) { Fabricate(:account) }
+      let(:source_account) { Fabricate(:account) }
 
-    describe 'perform' do
       it 'calls makes local followers follow the target account' do
         subject.perform(source_account.id, target_account.id)
         expect(local_follower.following?(target_account)).to be true
       end
 
-      include_examples 'user note handling'
-      include_examples 'block and mute handling'
-      include_examples 'followers count handling'
-
-      it 'does not fail when a local user is already following both accounts' do
-        double_follower = Fabricate(:account)
-        double_follower.follow!(source_account)
-        double_follower.follow!(target_account)
-        subject.perform(source_account.id, target_account.id)
-        expect(local_follower.following?(target_account)).to be true
-      end
+      include_examples 'common tests'
 
       it 'does not allow the moved account to follow themselves' do
         source_account.follow!(target_account)
diff --git a/spec/workers/scheduler/accounts_statuses_cleanup_scheduler_spec.rb b/spec/workers/scheduler/accounts_statuses_cleanup_scheduler_spec.rb
index 436f2d93f3..8e747d04f5 100644
--- a/spec/workers/scheduler/accounts_statuses_cleanup_scheduler_spec.rb
+++ b/spec/workers/scheduler/accounts_statuses_cleanup_scheduler_spec.rb
@@ -75,8 +75,8 @@ describe Scheduler::AccountsStatusesCleanupScheduler do
     end
   end
 
-  describe '#get_budget' do
-    context 'on a single thread' do
+  describe '#compute_budget' do
+    context 'with a single thread' do
       let(:process_set_stub) { [{ 'concurrency' => 1, 'queues' => %w(push default) }] }
 
       it 'returns a low value' do
@@ -84,7 +84,7 @@ describe Scheduler::AccountsStatusesCleanupScheduler do
       end
     end
 
-    context 'on a lot of threads' do
+    context 'with a lot of threads' do
       let(:process_set_stub) do
         [
           { 'concurrency' => 2, 'queues' => %w(push default) },
@@ -130,6 +130,33 @@ describe Scheduler::AccountsStatusesCleanupScheduler do
           .and change { account3.statuses.count }
           .and change { account5.statuses.count }
       end
+
+      context 'when given a big budget' do
+        let(:process_set_stub) { [{ 'concurrency' => 400, 'queues' => %w(push default) }] }
+
+        before do
+          stub_const 'Scheduler::AccountsStatusesCleanupScheduler::MAX_BUDGET', 400
+        end
+
+        it 'correctly handles looping in a single run' do
+          expect(subject.compute_budget).to eq(400)
+          expect { subject.perform }.to change { Status.count }.by(-30)
+        end
+      end
+
+      context 'when there is no work to be done' do
+        let(:process_set_stub) { [{ 'concurrency' => 400, 'queues' => %w(push default) }] }
+
+        before do
+          stub_const 'Scheduler::AccountsStatusesCleanupScheduler::MAX_BUDGET', 400
+          subject.perform
+        end
+
+        it 'does not get stuck' do
+          expect(subject.compute_budget).to eq(400)
+          expect { subject.perform }.to_not change { Status.count }
+        end
+      end
     end
   end
 end
diff --git a/yarn.lock b/yarn.lock
index f12a891890..239c13a3e7 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -31,38 +31,38 @@
   dependencies:
     "@babel/highlight" "^7.18.6"
 
-"@babel/compat-data@^7.17.7", "@babel/compat-data@^7.20.5", "@babel/compat-data@^7.21.4":
-  version "7.21.4"
-  resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.21.4.tgz#457ffe647c480dff59c2be092fc3acf71195c87f"
-  integrity sha512-/DYyDpeCfaVinT40FPGdkkb+lYSKvsVuMjDAG7jPOWWiM1ibOaB9CXJAlc4d1QpP/U2q2P9jbrSlClKSErd55g==
+"@babel/compat-data@^7.17.7", "@babel/compat-data@^7.20.5", "@babel/compat-data@^7.21.5":
+  version "7.21.7"
+  resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.21.7.tgz#61caffb60776e49a57ba61a88f02bedd8714f6bc"
+  integrity sha512-KYMqFYTaenzMK4yUtf4EW9wc4N9ef80FsbMtkwool5zpwl4YrT1SdWYSTRcT94KO4hannogdS+LxY7L+arP3gA==
 
-"@babel/core@^7.11.1", "@babel/core@^7.11.6", "@babel/core@^7.12.3", "@babel/core@^7.21.4", "@babel/core@^7.7.2":
-  version "7.21.4"
-  resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.21.4.tgz#c6dc73242507b8e2a27fd13a9c1814f9fa34a659"
-  integrity sha512-qt/YV149Jman/6AfmlxJ04LMIu8bMoyl3RB91yTFrxQmgbrSvQMy7cI8Q62FHx1t8wJ8B5fu0UDoLwHAhUo1QA==
+"@babel/core@^7.11.1", "@babel/core@^7.11.6", "@babel/core@^7.12.3", "@babel/core@^7.21.8", "@babel/core@^7.7.2":
+  version "7.21.8"
+  resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.21.8.tgz#2a8c7f0f53d60100ba4c32470ba0281c92aa9aa4"
+  integrity sha512-YeM22Sondbo523Sz0+CirSPnbj9bG3P0CdHcBZdqUuaeOaYEFbOLoGU7lebvGP6P5J/WE9wOn7u7C4J9HvS1xQ==
   dependencies:
     "@ampproject/remapping" "^2.2.0"
     "@babel/code-frame" "^7.21.4"
-    "@babel/generator" "^7.21.4"
-    "@babel/helper-compilation-targets" "^7.21.4"
-    "@babel/helper-module-transforms" "^7.21.2"
-    "@babel/helpers" "^7.21.0"
-    "@babel/parser" "^7.21.4"
+    "@babel/generator" "^7.21.5"
+    "@babel/helper-compilation-targets" "^7.21.5"
+    "@babel/helper-module-transforms" "^7.21.5"
+    "@babel/helpers" "^7.21.5"
+    "@babel/parser" "^7.21.8"
     "@babel/template" "^7.20.7"
-    "@babel/traverse" "^7.21.4"
-    "@babel/types" "^7.21.4"
+    "@babel/traverse" "^7.21.5"
+    "@babel/types" "^7.21.5"
     convert-source-map "^1.7.0"
     debug "^4.1.0"
     gensync "^1.0.0-beta.2"
     json5 "^2.2.2"
     semver "^6.3.0"
 
-"@babel/generator@^7.21.4", "@babel/generator@^7.7.2":
-  version "7.21.4"
-  resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.21.4.tgz#64a94b7448989f421f919d5239ef553b37bb26bc"
-  integrity sha512-NieM3pVIYW2SwGzKoqfPrQsf4xGs9M9AIG3ThppsSRmO+m7eQhmI6amajKMUeIO37wFfsvnvcxQFx6x6iqxDnA==
+"@babel/generator@^7.21.5", "@babel/generator@^7.7.2":
+  version "7.21.5"
+  resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.21.5.tgz#c0c0e5449504c7b7de8236d99338c3e2a340745f"
+  integrity sha512-SrKK/sRv8GesIW1bDagf9cCG38IOMYZusoe1dfg0D8aiUe3Amvoj1QtjTPAWcfrZFvIwlleLb0gxzQidL9w14w==
   dependencies:
-    "@babel/types" "^7.21.4"
+    "@babel/types" "^7.21.5"
     "@jridgewell/gen-mapping" "^0.3.2"
     "@jridgewell/trace-mapping" "^0.3.17"
     jsesc "^2.5.1"
@@ -90,12 +90,12 @@
     "@babel/helper-annotate-as-pure" "^7.18.6"
     "@babel/types" "^7.19.0"
 
-"@babel/helper-compilation-targets@^7.17.7", "@babel/helper-compilation-targets@^7.18.9", "@babel/helper-compilation-targets@^7.20.7", "@babel/helper-compilation-targets@^7.21.4":
-  version "7.21.4"
-  resolved "https://registry.yarnpkg.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.21.4.tgz#770cd1ce0889097ceacb99418ee6934ef0572656"
-  integrity sha512-Fa0tTuOXZ1iL8IeDFUWCzjZcn+sJGd9RZdH9esYVjEejGmzf+FFYQpMi/kZUk2kPy/q1H3/GPw7np8qar/stfg==
+"@babel/helper-compilation-targets@^7.17.7", "@babel/helper-compilation-targets@^7.18.9", "@babel/helper-compilation-targets@^7.20.7", "@babel/helper-compilation-targets@^7.21.5":
+  version "7.21.5"
+  resolved "https://registry.yarnpkg.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.21.5.tgz#631e6cc784c7b660417421349aac304c94115366"
+  integrity sha512-1RkbFGUKex4lvsB9yhIfWltJM5cZKUftB2eNajaDv3dCMEp49iBG0K14uH8NnX9IPux2+mK7JGEOB0jn48/J6w==
   dependencies:
-    "@babel/compat-data" "^7.21.4"
+    "@babel/compat-data" "^7.21.5"
     "@babel/helper-validator-option" "^7.21.0"
     browserslist "^4.21.3"
     lru-cache "^5.1.1"
@@ -162,6 +162,11 @@
   resolved "https://registry.yarnpkg.com/@babel/helper-environment-visitor/-/helper-environment-visitor-7.18.9.tgz#0c0cee9b35d2ca190478756865bb3528422f51be"
   integrity sha512-3r/aACDJ3fhQ/EVgFy0hpj8oHyHpQc+LPtJoY9SzTThAsStm4Ptegq92vqKoE3vD706ZVFWITnMnxucw+S9Ipg==
 
+"@babel/helper-environment-visitor@^7.21.5":
+  version "7.21.5"
+  resolved "https://registry.yarnpkg.com/@babel/helper-environment-visitor/-/helper-environment-visitor-7.21.5.tgz#c769afefd41d171836f7cb63e295bedf689d48ba"
+  integrity sha512-IYl4gZ3ETsWocUWgsFZLM5i1BYx9SoemminVEXadgLBa9TdeorzgLKm8wWLA6J1N/kT3Kch8XIk1laNzYoHKvQ==
+
 "@babel/helper-explode-assignable-expression@^7.18.6":
   version "7.18.6"
   resolved "https://registry.yarnpkg.com/@babel/helper-explode-assignable-expression/-/helper-explode-assignable-expression-7.18.6.tgz#41f8228ef0a6f1a036b8dfdfec7ce94f9a6bc096"
@@ -213,19 +218,19 @@
   dependencies:
     "@babel/types" "^7.21.4"
 
-"@babel/helper-module-transforms@^7.18.6", "@babel/helper-module-transforms@^7.20.11", "@babel/helper-module-transforms@^7.21.2":
-  version "7.21.2"
-  resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.21.2.tgz#160caafa4978ac8c00ac66636cb0fa37b024e2d2"
-  integrity sha512-79yj2AR4U/Oqq/WOV7Lx6hUjau1Zfo4cI+JLAVYeMV5XIlbOhmjEk5ulbTc9fMpmlojzZHkUUxAiK+UKn+hNQQ==
+"@babel/helper-module-transforms@^7.18.6", "@babel/helper-module-transforms@^7.20.11", "@babel/helper-module-transforms@^7.21.5":
+  version "7.21.5"
+  resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.21.5.tgz#d937c82e9af68d31ab49039136a222b17ac0b420"
+  integrity sha512-bI2Z9zBGY2q5yMHoBvJ2a9iX3ZOAzJPm7Q8Yz6YeoUjU/Cvhmi2G4QyTNyPBqqXSgTjUxRg3L0xV45HvkNWWBw==
   dependencies:
-    "@babel/helper-environment-visitor" "^7.18.9"
-    "@babel/helper-module-imports" "^7.18.6"
-    "@babel/helper-simple-access" "^7.20.2"
+    "@babel/helper-environment-visitor" "^7.21.5"
+    "@babel/helper-module-imports" "^7.21.4"
+    "@babel/helper-simple-access" "^7.21.5"
     "@babel/helper-split-export-declaration" "^7.18.6"
     "@babel/helper-validator-identifier" "^7.19.1"
     "@babel/template" "^7.20.7"
-    "@babel/traverse" "^7.21.2"
-    "@babel/types" "^7.21.2"
+    "@babel/traverse" "^7.21.5"
+    "@babel/types" "^7.21.5"
 
 "@babel/helper-optimise-call-expression@^7.18.6":
   version "7.18.6"
@@ -234,10 +239,10 @@
   dependencies:
     "@babel/types" "^7.18.6"
 
-"@babel/helper-plugin-utils@^7.0.0", "@babel/helper-plugin-utils@^7.10.4", "@babel/helper-plugin-utils@^7.12.13", "@babel/helper-plugin-utils@^7.14.5", "@babel/helper-plugin-utils@^7.16.7", "@babel/helper-plugin-utils@^7.18.6", "@babel/helper-plugin-utils@^7.18.9", "@babel/helper-plugin-utils@^7.19.0", "@babel/helper-plugin-utils@^7.20.2", "@babel/helper-plugin-utils@^7.8.0", "@babel/helper-plugin-utils@^7.8.3":
-  version "7.20.2"
-  resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.20.2.tgz#d1b9000752b18d0877cff85a5c376ce5c3121629"
-  integrity sha512-8RvlJG2mj4huQ4pZ+rU9lqKi9ZKiRmuvGuM2HlWmkmgOhbs6zEAw6IEiJ5cQqGbDzGZOhwuOQNtZMi/ENLjZoQ==
+"@babel/helper-plugin-utils@^7.0.0", "@babel/helper-plugin-utils@^7.10.4", "@babel/helper-plugin-utils@^7.12.13", "@babel/helper-plugin-utils@^7.14.5", "@babel/helper-plugin-utils@^7.16.7", "@babel/helper-plugin-utils@^7.18.6", "@babel/helper-plugin-utils@^7.18.9", "@babel/helper-plugin-utils@^7.19.0", "@babel/helper-plugin-utils@^7.20.2", "@babel/helper-plugin-utils@^7.21.5", "@babel/helper-plugin-utils@^7.8.0", "@babel/helper-plugin-utils@^7.8.3":
+  version "7.21.5"
+  resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.21.5.tgz#345f2377d05a720a4e5ecfa39cbf4474a4daed56"
+  integrity sha512-0WDaIlXKOX/3KfBK/dwP1oQGiPh6rjMkT7HIRv7i5RR2VUMwrx5ZL0dwBkKx7+SW1zwNdgjHd34IMk5ZjTeHVg==
 
 "@babel/helper-remap-async-to-generator@^7.18.9":
   version "7.18.9"
@@ -261,12 +266,12 @@
     "@babel/traverse" "^7.20.7"
     "@babel/types" "^7.20.7"
 
-"@babel/helper-simple-access@^7.20.2":
-  version "7.20.2"
-  resolved "https://registry.yarnpkg.com/@babel/helper-simple-access/-/helper-simple-access-7.20.2.tgz#0ab452687fe0c2cfb1e2b9e0015de07fc2d62dd9"
-  integrity sha512-+0woI/WPq59IrqDYbVGfshjT5Dmk/nnbdpcF8SnMhhXObpTq2KNBdLFRFrkVdbDOyUmHBCxzm5FHV1rACIkIbA==
+"@babel/helper-simple-access@^7.21.5":
+  version "7.21.5"
+  resolved "https://registry.yarnpkg.com/@babel/helper-simple-access/-/helper-simple-access-7.21.5.tgz#d697a7971a5c39eac32c7e63c0921c06c8a249ee"
+  integrity sha512-ENPDAMC1wAjR0uaCUwliBdiSl1KBJAVnMTzXqi64c2MG8MPR6ii4qf7bSXDqSFbr4W6W028/rf5ivoHop5/mkg==
   dependencies:
-    "@babel/types" "^7.20.2"
+    "@babel/types" "^7.21.5"
 
 "@babel/helper-skip-transparent-expression-wrappers@^7.20.0":
   version "7.20.0"
@@ -282,10 +287,10 @@
   dependencies:
     "@babel/types" "^7.18.6"
 
-"@babel/helper-string-parser@^7.19.4":
-  version "7.19.4"
-  resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.19.4.tgz#38d3acb654b4701a9b77fb0615a96f775c3a9e63"
-  integrity sha512-nHtDoQcuqFmwYNYPz3Rah5ph2p8PFeFCsZk9A/48dPc/rGocJ5J3hAAZ7pb76VWX3fZKu+uEr/FhH5jLx7umrw==
+"@babel/helper-string-parser@^7.21.5":
+  version "7.21.5"
+  resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.21.5.tgz#2b3eea65443c6bdc31c22d037c65f6d323b6b2bd"
+  integrity sha512-5pTUx3hAJaZIdW99sJ6ZUUgWq/Y+Hja7TowEnLNMm1VivRgZQL3vpBY3qUACVsvw+yQU6+YgfBVmcbLaZtrA1w==
 
 "@babel/helper-validator-identifier@^7.18.6", "@babel/helper-validator-identifier@^7.19.1":
   version "7.19.1"
@@ -307,14 +312,14 @@
     "@babel/traverse" "^7.18.10"
     "@babel/types" "^7.18.10"
 
-"@babel/helpers@^7.21.0":
-  version "7.21.0"
-  resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.21.0.tgz#9dd184fb5599862037917cdc9eecb84577dc4e7e"
-  integrity sha512-XXve0CBtOW0pd7MRzzmoyuSj0e3SEzj8pgyFxnTT1NJZL38BD1MK7yYrm8yefRPIDvNNe14xR4FdbHwpInD4rA==
+"@babel/helpers@^7.21.5":
+  version "7.21.5"
+  resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.21.5.tgz#5bac66e084d7a4d2d9696bdf0175a93f7fb63c08"
+  integrity sha512-BSY+JSlHxOmGsPTydUkPf1MdMQ3M81x5xGCOVgWM3G8XH77sJ292Y2oqcp0CbbgxhqBuI46iUz1tT7hqP7EfgA==
   dependencies:
     "@babel/template" "^7.20.7"
-    "@babel/traverse" "^7.21.0"
-    "@babel/types" "^7.21.0"
+    "@babel/traverse" "^7.21.5"
+    "@babel/types" "^7.21.5"
 
 "@babel/highlight@^7.18.6":
   version "7.18.6"
@@ -325,10 +330,10 @@
     chalk "^2.0.0"
     js-tokens "^4.0.0"
 
-"@babel/parser@^7.1.0", "@babel/parser@^7.14.7", "@babel/parser@^7.20.7", "@babel/parser@^7.21.4":
-  version "7.21.4"
-  resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.21.4.tgz#94003fdfc520bbe2875d4ae557b43ddb6d880f17"
-  integrity sha512-alVJj7k7zIxqBZ7BTRhz0IqJFxW1VJbm6N8JbcYhQ186df9ZBPbZBmWSqAMXwHGsCJdYks7z/voa3ibiS5bCIw==
+"@babel/parser@^7.1.0", "@babel/parser@^7.14.7", "@babel/parser@^7.20.7", "@babel/parser@^7.21.5", "@babel/parser@^7.21.8":
+  version "7.21.8"
+  resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.21.8.tgz#642af7d0333eab9c0ad70b14ac5e76dbde7bfdf8"
+  integrity sha512-6zavDGdzG3gUqAdWvlLFfk+36RilI+Pwyuuh7HItyeScCWP3k6i8vKclAQ0bM/0y/Kz/xiwvxhMv9MgTJP5gmA==
 
 "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@^7.18.6":
   version "7.18.6"
@@ -524,7 +529,7 @@
   dependencies:
     "@babel/helper-plugin-utils" "^7.19.0"
 
-"@babel/plugin-syntax-import-meta@^7.8.3":
+"@babel/plugin-syntax-import-meta@^7.10.4", "@babel/plugin-syntax-import-meta@^7.8.3":
   version "7.10.4"
   resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz#ee601348c370fa334d2207be158777496521fd51"
   integrity sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==
@@ -615,12 +620,12 @@
   dependencies:
     "@babel/helper-plugin-utils" "^7.14.5"
 
-"@babel/plugin-transform-arrow-functions@^7.20.7":
-  version "7.20.7"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.20.7.tgz#bea332b0e8b2dab3dafe55a163d8227531ab0551"
-  integrity sha512-3poA5E7dzDomxj9WXWwuD6A5F3kc7VXwIJO+E+J8qtDtS+pXPAhrgEyh+9GBwBgPq1Z+bB+/JD60lp5jsN7JPQ==
+"@babel/plugin-transform-arrow-functions@^7.21.5":
+  version "7.21.5"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.21.5.tgz#9bb42a53de447936a57ba256fbf537fc312b6929"
+  integrity sha512-wb1mhwGOCaXHDTcsRYMKF9e5bbMgqwxtqa2Y1ifH96dXJPwbuLX9qHy3clhrxVqgMz7nyNXs8VkxdH8UBcjKqA==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.20.2"
+    "@babel/helper-plugin-utils" "^7.21.5"
 
 "@babel/plugin-transform-async-to-generator@^7.20.7":
   version "7.20.7"
@@ -660,12 +665,12 @@
     "@babel/helper-split-export-declaration" "^7.18.6"
     globals "^11.1.0"
 
-"@babel/plugin-transform-computed-properties@^7.20.7":
-  version "7.20.7"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.20.7.tgz#704cc2fd155d1c996551db8276d55b9d46e4d0aa"
-  integrity sha512-Lz7MvBK6DTjElHAmfu6bfANzKcxpyNPeYBGEafyA6E5HtRpjpZwU+u7Qrgz/2OR0z+5TvKYbPdphfSaAcZBrYQ==
+"@babel/plugin-transform-computed-properties@^7.21.5":
+  version "7.21.5"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.21.5.tgz#3a2d8bb771cd2ef1cd736435f6552fe502e11b44"
+  integrity sha512-TR653Ki3pAwxBxUe8srfF3e4Pe3FTA46uaNHYyQwIoM4oWKSoOZiDNyHJ0oIoDIUPSRQbQG7jzgVBX3FPVne1Q==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.20.2"
+    "@babel/helper-plugin-utils" "^7.21.5"
     "@babel/template" "^7.20.7"
 
 "@babel/plugin-transform-destructuring@^7.21.3":
@@ -698,12 +703,12 @@
     "@babel/helper-builder-binary-assignment-operator-visitor" "^7.18.6"
     "@babel/helper-plugin-utils" "^7.18.6"
 
-"@babel/plugin-transform-for-of@^7.21.0":
-  version "7.21.0"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.21.0.tgz#964108c9988de1a60b4be2354a7d7e245f36e86e"
-  integrity sha512-LlUYlydgDkKpIY7mcBWvyPPmMcOphEyYA27Ef4xpbh1IiDNLr0kZsos2nf92vz3IccvJI25QUwp86Eo5s6HmBQ==
+"@babel/plugin-transform-for-of@^7.21.5":
+  version "7.21.5"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.21.5.tgz#e890032b535f5a2e237a18535f56a9fdaa7b83fc"
+  integrity sha512-nYWpjKW/7j/I/mZkGVgHJXh4bA1sfdFnJoOXwJuj4m3Q2EraO/8ZyrkCau9P5tbHQk01RMSt6KYLCsW7730SXQ==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.20.2"
+    "@babel/helper-plugin-utils" "^7.21.5"
 
 "@babel/plugin-transform-function-name@^7.18.9":
   version "7.18.9"
@@ -736,14 +741,14 @@
     "@babel/helper-module-transforms" "^7.20.11"
     "@babel/helper-plugin-utils" "^7.20.2"
 
-"@babel/plugin-transform-modules-commonjs@^7.21.2":
-  version "7.21.2"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.21.2.tgz#6ff5070e71e3192ef2b7e39820a06fb78e3058e7"
-  integrity sha512-Cln+Yy04Gxua7iPdj6nOV96smLGjpElir5YwzF0LBPKoPlLDNJePNlrGGaybAJkd0zKRnOVXOgizSqPYMNYkzA==
+"@babel/plugin-transform-modules-commonjs@^7.21.5":
+  version "7.21.5"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.21.5.tgz#d69fb947eed51af91de82e4708f676864e5e47bc"
+  integrity sha512-OVryBEgKUbtqMoB7eG2rs6UFexJi6Zj6FDXx+esBLPTCxCNxAY9o+8Di7IsUGJ+AVhp5ncK0fxWUBd0/1gPhrQ==
   dependencies:
-    "@babel/helper-module-transforms" "^7.21.2"
-    "@babel/helper-plugin-utils" "^7.20.2"
-    "@babel/helper-simple-access" "^7.20.2"
+    "@babel/helper-module-transforms" "^7.21.5"
+    "@babel/helper-plugin-utils" "^7.21.5"
+    "@babel/helper-simple-access" "^7.21.5"
 
 "@babel/plugin-transform-modules-systemjs@^7.20.11":
   version "7.20.11"
@@ -841,12 +846,12 @@
     "@babel/helper-annotate-as-pure" "^7.18.6"
     "@babel/helper-plugin-utils" "^7.18.6"
 
-"@babel/plugin-transform-regenerator@^7.20.5":
-  version "7.20.5"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.20.5.tgz#57cda588c7ffb7f4f8483cc83bdcea02a907f04d"
-  integrity sha512-kW/oO7HPBtntbsahzQ0qSE3tFvkFwnbozz3NWFhLGqH75vLEg+sCGngLlhVkePlCs3Jv0dBBHDzCHxNiFAQKCQ==
+"@babel/plugin-transform-regenerator@^7.21.5":
+  version "7.21.5"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.21.5.tgz#576c62f9923f94bcb1c855adc53561fd7913724e"
+  integrity sha512-ZoYBKDb6LyMi5yCsByQ5jmXsHAQDDYeexT1Szvlmui+lADvfSecr5Dxd/PkrTC3pAD182Fcju1VQkB4oCp9M+w==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.20.2"
+    "@babel/helper-plugin-utils" "^7.21.5"
     regenerator-transform "^0.15.1"
 
 "@babel/plugin-transform-reserved-words@^7.18.6":
@@ -914,12 +919,12 @@
     "@babel/helper-plugin-utils" "^7.20.2"
     "@babel/plugin-syntax-typescript" "^7.20.0"
 
-"@babel/plugin-transform-unicode-escapes@^7.18.10":
-  version "7.18.10"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.18.10.tgz#1ecfb0eda83d09bbcb77c09970c2dd55832aa246"
-  integrity sha512-kKAdAI+YzPgGY/ftStBFXTI1LZFju38rYThnfMykS+IXy8BVx+res7s2fxf1l8I35DV2T97ezo6+SGrXz6B3iQ==
+"@babel/plugin-transform-unicode-escapes@^7.21.5":
+  version "7.21.5"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.21.5.tgz#1e55ed6195259b0e9061d81f5ef45a9b009fb7f2"
+  integrity sha512-LYm/gTOwZqsYohlvFUe/8Tujz75LqqVC2w+2qPHLR+WyWHGCZPN1KBpJCJn+4Bk4gOkQy/IXKIge6az5MqwlOg==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.18.9"
+    "@babel/helper-plugin-utils" "^7.21.5"
 
 "@babel/plugin-transform-unicode-regex@^7.18.6":
   version "7.18.6"
@@ -929,14 +934,14 @@
     "@babel/helper-create-regexp-features-plugin" "^7.18.6"
     "@babel/helper-plugin-utils" "^7.18.6"
 
-"@babel/preset-env@^7.11.0", "@babel/preset-env@^7.21.4":
-  version "7.21.4"
-  resolved "https://registry.yarnpkg.com/@babel/preset-env/-/preset-env-7.21.4.tgz#a952482e634a8dd8271a3fe5459a16eb10739c58"
-  integrity sha512-2W57zHs2yDLm6GD5ZpvNn71lZ0B/iypSdIeq25OurDKji6AdzV07qp4s3n1/x5BqtiGaTrPN3nerlSCaC5qNTw==
+"@babel/preset-env@^7.11.0", "@babel/preset-env@^7.21.5":
+  version "7.21.5"
+  resolved "https://registry.yarnpkg.com/@babel/preset-env/-/preset-env-7.21.5.tgz#db2089d99efd2297716f018aeead815ac3decffb"
+  integrity sha512-wH00QnTTldTbf/IefEVyChtRdw5RJvODT/Vb4Vcxq1AZvtXj6T0YeX0cAcXhI6/BdGuiP3GcNIL4OQbI2DVNxg==
   dependencies:
-    "@babel/compat-data" "^7.21.4"
-    "@babel/helper-compilation-targets" "^7.21.4"
-    "@babel/helper-plugin-utils" "^7.20.2"
+    "@babel/compat-data" "^7.21.5"
+    "@babel/helper-compilation-targets" "^7.21.5"
+    "@babel/helper-plugin-utils" "^7.21.5"
     "@babel/helper-validator-option" "^7.21.0"
     "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression" "^7.18.6"
     "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining" "^7.20.7"
@@ -961,6 +966,7 @@
     "@babel/plugin-syntax-dynamic-import" "^7.8.3"
     "@babel/plugin-syntax-export-namespace-from" "^7.8.3"
     "@babel/plugin-syntax-import-assertions" "^7.20.0"
+    "@babel/plugin-syntax-import-meta" "^7.10.4"
     "@babel/plugin-syntax-json-strings" "^7.8.3"
     "@babel/plugin-syntax-logical-assignment-operators" "^7.10.4"
     "@babel/plugin-syntax-nullish-coalescing-operator" "^7.8.3"
@@ -970,22 +976,22 @@
     "@babel/plugin-syntax-optional-chaining" "^7.8.3"
     "@babel/plugin-syntax-private-property-in-object" "^7.14.5"
     "@babel/plugin-syntax-top-level-await" "^7.14.5"
-    "@babel/plugin-transform-arrow-functions" "^7.20.7"
+    "@babel/plugin-transform-arrow-functions" "^7.21.5"
     "@babel/plugin-transform-async-to-generator" "^7.20.7"
     "@babel/plugin-transform-block-scoped-functions" "^7.18.6"
     "@babel/plugin-transform-block-scoping" "^7.21.0"
     "@babel/plugin-transform-classes" "^7.21.0"
-    "@babel/plugin-transform-computed-properties" "^7.20.7"
+    "@babel/plugin-transform-computed-properties" "^7.21.5"
     "@babel/plugin-transform-destructuring" "^7.21.3"
     "@babel/plugin-transform-dotall-regex" "^7.18.6"
     "@babel/plugin-transform-duplicate-keys" "^7.18.9"
     "@babel/plugin-transform-exponentiation-operator" "^7.18.6"
-    "@babel/plugin-transform-for-of" "^7.21.0"
+    "@babel/plugin-transform-for-of" "^7.21.5"
     "@babel/plugin-transform-function-name" "^7.18.9"
     "@babel/plugin-transform-literals" "^7.18.9"
     "@babel/plugin-transform-member-expression-literals" "^7.18.6"
     "@babel/plugin-transform-modules-amd" "^7.20.11"
-    "@babel/plugin-transform-modules-commonjs" "^7.21.2"
+    "@babel/plugin-transform-modules-commonjs" "^7.21.5"
     "@babel/plugin-transform-modules-systemjs" "^7.20.11"
     "@babel/plugin-transform-modules-umd" "^7.18.6"
     "@babel/plugin-transform-named-capturing-groups-regex" "^7.20.5"
@@ -993,17 +999,17 @@
     "@babel/plugin-transform-object-super" "^7.18.6"
     "@babel/plugin-transform-parameters" "^7.21.3"
     "@babel/plugin-transform-property-literals" "^7.18.6"
-    "@babel/plugin-transform-regenerator" "^7.20.5"
+    "@babel/plugin-transform-regenerator" "^7.21.5"
     "@babel/plugin-transform-reserved-words" "^7.18.6"
     "@babel/plugin-transform-shorthand-properties" "^7.18.6"
     "@babel/plugin-transform-spread" "^7.20.7"
     "@babel/plugin-transform-sticky-regex" "^7.18.6"
     "@babel/plugin-transform-template-literals" "^7.18.9"
     "@babel/plugin-transform-typeof-symbol" "^7.18.9"
-    "@babel/plugin-transform-unicode-escapes" "^7.18.10"
+    "@babel/plugin-transform-unicode-escapes" "^7.21.5"
     "@babel/plugin-transform-unicode-regex" "^7.18.6"
     "@babel/preset-modules" "^0.1.5"
-    "@babel/types" "^7.21.4"
+    "@babel/types" "^7.21.5"
     babel-plugin-polyfill-corejs2 "^0.3.3"
     babel-plugin-polyfill-corejs3 "^0.6.0"
     babel-plugin-polyfill-regenerator "^0.4.1"
@@ -1033,15 +1039,15 @@
     "@babel/plugin-transform-react-jsx-development" "^7.18.6"
     "@babel/plugin-transform-react-pure-annotations" "^7.18.6"
 
-"@babel/preset-typescript@^7.21.4":
-  version "7.21.4"
-  resolved "https://registry.yarnpkg.com/@babel/preset-typescript/-/preset-typescript-7.21.4.tgz#b913ac8e6aa8932e47c21b01b4368d8aa239a529"
-  integrity sha512-sMLNWY37TCdRH/bJ6ZeeOH1nPuanED7Ai9Y/vH31IPqalioJ6ZNFUWONsakhv4r4n+I6gm5lmoE0olkgib/j/A==
+"@babel/preset-typescript@^7.21.5":
+  version "7.21.5"
+  resolved "https://registry.yarnpkg.com/@babel/preset-typescript/-/preset-typescript-7.21.5.tgz#68292c884b0e26070b4d66b202072d391358395f"
+  integrity sha512-iqe3sETat5EOrORXiQ6rWfoOg2y68Cs75B9wNxdPW4kixJxh7aXQE1KPdWLDniC24T/6dSnguF33W9j/ZZQcmA==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.20.2"
+    "@babel/helper-plugin-utils" "^7.21.5"
     "@babel/helper-validator-option" "^7.21.0"
     "@babel/plugin-syntax-jsx" "^7.21.4"
-    "@babel/plugin-transform-modules-commonjs" "^7.21.2"
+    "@babel/plugin-transform-modules-commonjs" "^7.21.5"
     "@babel/plugin-transform-typescript" "^7.21.3"
 
 "@babel/regjsgen@^0.8.0":
@@ -1064,10 +1070,10 @@
   dependencies:
     regenerator-runtime "^0.12.0"
 
-"@babel/runtime@^7.0.0", "@babel/runtime@^7.1.2", "@babel/runtime@^7.10.2", "@babel/runtime@^7.11.2", "@babel/runtime@^7.12.0", "@babel/runtime@^7.12.5", "@babel/runtime@^7.13.10", "@babel/runtime@^7.13.8", "@babel/runtime@^7.15.4", "@babel/runtime@^7.2.0", "@babel/runtime@^7.20.13", "@babel/runtime@^7.20.7", "@babel/runtime@^7.21.0", "@babel/runtime@^7.3.1", "@babel/runtime@^7.5.5", "@babel/runtime@^7.6.3", "@babel/runtime@^7.7.2", "@babel/runtime@^7.8.4", "@babel/runtime@^7.8.7", "@babel/runtime@^7.9.2":
-  version "7.21.0"
-  resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.21.0.tgz#5b55c9d394e5fcf304909a8b00c07dc217b56673"
-  integrity sha512-xwII0//EObnq89Ji5AKYQaRYiW/nZ3llSv29d49IuxPhKbtJoLP+9QUUZ4nVragQVtaVGeZrpB+ZtG/Pdy/POw==
+"@babel/runtime@^7.0.0", "@babel/runtime@^7.1.2", "@babel/runtime@^7.10.2", "@babel/runtime@^7.11.2", "@babel/runtime@^7.12.0", "@babel/runtime@^7.12.5", "@babel/runtime@^7.13.10", "@babel/runtime@^7.13.8", "@babel/runtime@^7.15.4", "@babel/runtime@^7.2.0", "@babel/runtime@^7.20.13", "@babel/runtime@^7.20.7", "@babel/runtime@^7.21.5", "@babel/runtime@^7.3.1", "@babel/runtime@^7.5.5", "@babel/runtime@^7.6.3", "@babel/runtime@^7.7.2", "@babel/runtime@^7.8.4", "@babel/runtime@^7.8.7", "@babel/runtime@^7.9.2":
+  version "7.21.5"
+  resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.21.5.tgz#8492dddda9644ae3bda3b45eabe87382caee7200"
+  integrity sha512-8jI69toZqqcsnqGGqwGS4Qb1VwLOEp4hz+CXPywcvjs60u3B4Pom/U/7rm4W8tMOYEB+E9wgD0mW1l3r8qlI9Q==
   dependencies:
     regenerator-runtime "^0.13.11"
 
@@ -1080,28 +1086,28 @@
     "@babel/parser" "^7.20.7"
     "@babel/types" "^7.20.7"
 
-"@babel/traverse@^7.18.10", "@babel/traverse@^7.20.7", "@babel/traverse@^7.21.0", "@babel/traverse@^7.21.2", "@babel/traverse@^7.21.4", "@babel/traverse@^7.7.2":
-  version "7.21.4"
-  resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.21.4.tgz#a836aca7b116634e97a6ed99976236b3282c9d36"
-  integrity sha512-eyKrRHKdyZxqDm+fV1iqL9UAHMoIg0nDaGqfIOd8rKH17m5snv7Gn4qgjBoFfLz9APvjFU/ICT00NVCv1Epp8Q==
+"@babel/traverse@^7.18.10", "@babel/traverse@^7.20.7", "@babel/traverse@^7.21.5", "@babel/traverse@^7.7.2":
+  version "7.21.5"
+  resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.21.5.tgz#ad22361d352a5154b498299d523cf72998a4b133"
+  integrity sha512-AhQoI3YjWi6u/y/ntv7k48mcrCXmus0t79J9qPNlk/lAsFlCiJ047RmbfMOawySTHtywXhbXgpx/8nXMYd+oFw==
   dependencies:
     "@babel/code-frame" "^7.21.4"
-    "@babel/generator" "^7.21.4"
-    "@babel/helper-environment-visitor" "^7.18.9"
+    "@babel/generator" "^7.21.5"
+    "@babel/helper-environment-visitor" "^7.21.5"
     "@babel/helper-function-name" "^7.21.0"
     "@babel/helper-hoist-variables" "^7.18.6"
     "@babel/helper-split-export-declaration" "^7.18.6"
-    "@babel/parser" "^7.21.4"
-    "@babel/types" "^7.21.4"
+    "@babel/parser" "^7.21.5"
+    "@babel/types" "^7.21.5"
     debug "^4.1.0"
     globals "^11.1.0"
 
-"@babel/types@^7.0.0", "@babel/types@^7.0.0-beta.49", "@babel/types@^7.18.10", "@babel/types@^7.18.6", "@babel/types@^7.18.9", "@babel/types@^7.19.0", "@babel/types@^7.20.0", "@babel/types@^7.20.2", "@babel/types@^7.20.7", "@babel/types@^7.21.0", "@babel/types@^7.21.2", "@babel/types@^7.21.4", "@babel/types@^7.3.0", "@babel/types@^7.3.3", "@babel/types@^7.4.4":
-  version "7.21.4"
-  resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.21.4.tgz#2d5d6bb7908699b3b416409ffd3b5daa25b030d4"
-  integrity sha512-rU2oY501qDxE8Pyo7i/Orqma4ziCOrby0/9mvbDUGEfvZjb279Nk9k19e2fiCxHbRRpY2ZyrgW1eq22mvmOIzA==
+"@babel/types@^7.0.0", "@babel/types@^7.0.0-beta.49", "@babel/types@^7.18.10", "@babel/types@^7.18.6", "@babel/types@^7.18.9", "@babel/types@^7.19.0", "@babel/types@^7.20.0", "@babel/types@^7.20.7", "@babel/types@^7.21.0", "@babel/types@^7.21.4", "@babel/types@^7.21.5", "@babel/types@^7.3.0", "@babel/types@^7.3.3", "@babel/types@^7.4.4":
+  version "7.21.5"
+  resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.21.5.tgz#18dfbd47c39d3904d5db3d3dc2cc80bedb60e5b6"
+  integrity sha512-m4AfNvVF2mVC/F7fDEdH2El3HzUg9It/XsCxZiOTTA3m3qYfcSVSbTfM6Q9xG+hYDniZssYhlXKKUMD5m8tF4Q==
   dependencies:
-    "@babel/helper-string-parser" "^7.19.4"
+    "@babel/helper-string-parser" "^7.21.5"
     "@babel/helper-validator-identifier" "^7.19.1"
     to-fast-properties "^2.0.0"
 
@@ -1271,29 +1277,29 @@
   dependencies:
     "@floating-ui/core" "^1.0.1"
 
-"@formatjs/ecma402-abstract@1.14.3":
-  version "1.14.3"
-  resolved "https://registry.yarnpkg.com/@formatjs/ecma402-abstract/-/ecma402-abstract-1.14.3.tgz#6428f243538a11126180d121ce8d4b2f17465738"
-  integrity sha512-SlsbRC/RX+/zg4AApWIFNDdkLtFbkq3LNoZWXZCE/nHVKqoIJyaoQyge/I0Y38vLxowUn9KTtXgusLD91+orbg==
+"@formatjs/ecma402-abstract@1.15.0":
+  version "1.15.0"
+  resolved "https://registry.yarnpkg.com/@formatjs/ecma402-abstract/-/ecma402-abstract-1.15.0.tgz#0a285a5dc69889e15d53803bd5036272e23e5a18"
+  integrity sha512-7bAYAv0w4AIao9DNg0avfOLTCPE9woAgs6SpXuMq11IN3A+l+cq8ghczwqSZBM11myvPSJA7vLn72q0rJ0QK6Q==
   dependencies:
     "@formatjs/intl-localematcher" "0.2.32"
     tslib "^2.4.0"
 
-"@formatjs/icu-messageformat-parser@2.3.1":
-  version "2.3.1"
-  resolved "https://registry.yarnpkg.com/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-2.3.1.tgz#953080ea5c053bc73bdf55d0a524a3c3c133ae6b"
-  integrity sha512-knF2AkAKN4Upv4oIiKY4Wd/dLH68TNMPgV/tJMu/T6FP9aQwbv8fpj7U3lkyniPaNVxvia56Gxax8MKOjtxLSQ==
+"@formatjs/icu-messageformat-parser@2.4.0":
+  version "2.4.0"
+  resolved "https://registry.yarnpkg.com/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-2.4.0.tgz#e165f3594c68416ce15f63793768251de2a85f88"
+  integrity sha512-6Dh5Z/gp4F/HovXXu/vmd0If5NbYLB5dZrmhWVNb+BOGOEU3wt7Z/83KY1dtd7IDhAnYHasbmKE1RbTE0J+3hw==
   dependencies:
-    "@formatjs/ecma402-abstract" "1.14.3"
-    "@formatjs/icu-skeleton-parser" "1.3.18"
+    "@formatjs/ecma402-abstract" "1.15.0"
+    "@formatjs/icu-skeleton-parser" "1.4.0"
     tslib "^2.4.0"
 
-"@formatjs/icu-skeleton-parser@1.3.18":
-  version "1.3.18"
-  resolved "https://registry.yarnpkg.com/@formatjs/icu-skeleton-parser/-/icu-skeleton-parser-1.3.18.tgz#7aed3d60e718c8ad6b0e64820be44daa1e29eeeb"
-  integrity sha512-ND1ZkZfmLPcHjAH1sVpkpQxA+QYfOX3py3SjKWMUVGDow18gZ0WPqz3F+pJLYQMpS2LnnQ5zYR2jPVYTbRwMpg==
+"@formatjs/icu-skeleton-parser@1.4.0":
+  version "1.4.0"
+  resolved "https://registry.yarnpkg.com/@formatjs/icu-skeleton-parser/-/icu-skeleton-parser-1.4.0.tgz#96342eca7c4eef7a309875569e5da973db3465e6"
+  integrity sha512-Qq347VM616rVLkvN6QsKJELazRyNlbCiN47LdH0Mc5U7E2xV0vatiVhGqd3KFgbc055BvtnUXR7XX60dCGFuWg==
   dependencies:
-    "@formatjs/ecma402-abstract" "1.14.3"
+    "@formatjs/ecma402-abstract" "1.15.0"
     tslib "^2.4.0"
 
 "@formatjs/intl-localematcher@0.2.32":
@@ -1315,12 +1321,12 @@
   resolved "https://registry.yarnpkg.com/@formatjs/intl-utils/-/intl-utils-2.2.5.tgz#eaafd94df3d102ee13e54e80f992a33868a6b1e8"
   integrity sha512-p7gcmazKROteL4IECCp03Qrs790fZ8tbemUAjQu0+K0AaAlK49rI1SIFFq3LzDUAqXIshV95JJhRe/yXxkal5g==
 
-"@formatjs/ts-transformer@3.13.0":
-  version "3.13.0"
-  resolved "https://registry.yarnpkg.com/@formatjs/ts-transformer/-/ts-transformer-3.13.0.tgz#61185278fb153d61e56fabbeed6d4fc8a0ee3af5"
-  integrity sha512-TshsXkt2loK2GWFJFYTrlNThfCd4ubcEpokl9FWzGoR5f5e2FOxDPs69nTqw+7jodlKtx4VaTSfpNMtPvD9ZfQ==
+"@formatjs/ts-transformer@3.13.1":
+  version "3.13.1"
+  resolved "https://registry.yarnpkg.com/@formatjs/ts-transformer/-/ts-transformer-3.13.1.tgz#37aa4992aa50740f808f1f888f112b8addb617c7"
+  integrity sha512-U5BuLqFx5wre5Q0NrZhBh7itMJZISYuZGoj8HdN/UO1EKaTL2HQjE1G040GjTpY0k+AAyaHK3b8WPqgHLvIaIQ==
   dependencies:
-    "@formatjs/icu-messageformat-parser" "2.3.1"
+    "@formatjs/icu-messageformat-parser" "2.4.0"
     "@types/json-stable-stringify" "^1.0.32"
     "@types/node" "14 || 16 || 17"
     chalk "^4.0.0"
@@ -1721,6 +1727,16 @@
   resolved "https://registry.yarnpkg.com/@redis/time-series/-/time-series-1.0.4.tgz#af85eb080f6934580e4d3b58046026b6c2b18717"
   integrity sha512-ThUIgo2U/g7cCuZavucQTQzA9g9JbDDY2f64u3AbAoz/8vE2lt2U37LamDUVChhaDA3IRT9R6VvJwqnUfTJzng==
 
+"@reduxjs/toolkit@^1.9.5":
+  version "1.9.5"
+  resolved "https://registry.yarnpkg.com/@reduxjs/toolkit/-/toolkit-1.9.5.tgz#d3987849c24189ca483baa7aa59386c8e52077c4"
+  integrity sha512-Rt97jHmfTeaxL4swLRNPD/zV4OxTes4la07Xc4hetpUW/vc75t5m1ANyxG6ymnEQ2FsLQsoMlYB2vV1sO3m8tQ==
+  dependencies:
+    immer "^9.0.21"
+    redux "^4.2.1"
+    redux-thunk "^2.4.2"
+    reselect "^4.1.8"
+
 "@restart/hooks@^0.4.7":
   version "0.4.7"
   resolved "https://registry.yarnpkg.com/@restart/hooks/-/hooks-0.4.7.tgz#d79ca6472c01ce04389fc73d4a79af1b5e33cd39"
@@ -2459,15 +2475,15 @@
   dependencies:
     "@types/yargs-parser" "*"
 
-"@typescript-eslint/eslint-plugin@^5.59.1":
-  version "5.59.1"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.59.1.tgz#9b09ee1541bff1d2cebdcb87e7ce4a4003acde08"
-  integrity sha512-AVi0uazY5quFB9hlp2Xv+ogpfpk77xzsgsIEWyVS7uK/c7MZ5tw7ZPbapa0SbfkqE0fsAMkz5UwtgMLVk2BQAg==
+"@typescript-eslint/eslint-plugin@^5.59.2":
+  version "5.59.2"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.59.2.tgz#684a2ce7182f3b4dac342eef7caa1c2bae476abd"
+  integrity sha512-yVrXupeHjRxLDcPKL10sGQ/QlVrA8J5IYOEWVqk0lJaSZP7X5DfnP7Ns3cc74/blmbipQ1htFNVGsHX6wsYm0A==
   dependencies:
     "@eslint-community/regexpp" "^4.4.0"
-    "@typescript-eslint/scope-manager" "5.59.1"
-    "@typescript-eslint/type-utils" "5.59.1"
-    "@typescript-eslint/utils" "5.59.1"
+    "@typescript-eslint/scope-manager" "5.59.2"
+    "@typescript-eslint/type-utils" "5.59.2"
+    "@typescript-eslint/utils" "5.59.2"
     debug "^4.3.4"
     grapheme-splitter "^1.0.4"
     ignore "^5.2.0"
@@ -2475,98 +2491,98 @@
     semver "^7.3.7"
     tsutils "^3.21.0"
 
-"@typescript-eslint/parser@^5.59.1":
-  version "5.59.1"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-5.59.1.tgz#73c2c12127c5c1182d2e5b71a8fa2a85d215cbb4"
-  integrity sha512-nzjFAN8WEu6yPRDizIFyzAfgK7nybPodMNFGNH0M9tei2gYnYszRDqVA0xlnRjkl7Hkx2vYrEdb6fP2a21cG1g==
+"@typescript-eslint/parser@^5.59.2":
+  version "5.59.2"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-5.59.2.tgz#c2c443247901d95865b9f77332d9eee7c55655e8"
+  integrity sha512-uq0sKyw6ao1iFOZZGk9F8Nro/8+gfB5ezl1cA06SrqbgJAt0SRoFhb9pXaHvkrxUpZaoLxt8KlovHNk8Gp6/HQ==
   dependencies:
-    "@typescript-eslint/scope-manager" "5.59.1"
-    "@typescript-eslint/types" "5.59.1"
-    "@typescript-eslint/typescript-estree" "5.59.1"
+    "@typescript-eslint/scope-manager" "5.59.2"
+    "@typescript-eslint/types" "5.59.2"
+    "@typescript-eslint/typescript-estree" "5.59.2"
     debug "^4.3.4"
 
-"@typescript-eslint/scope-manager@5.59.1":
-  version "5.59.1"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-5.59.1.tgz#8a20222719cebc5198618a5d44113705b51fd7fe"
-  integrity sha512-mau0waO5frJctPuAzcxiNWqJR5Z8V0190FTSqRw1Q4Euop6+zTwHAf8YIXNwDOT29tyUDrQ65jSg9aTU/H0omA==
+"@typescript-eslint/scope-manager@5.59.2":
+  version "5.59.2"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-5.59.2.tgz#f699fe936ee4e2c996d14f0fdd3a7da5ba7b9a4c"
+  integrity sha512-dB1v7ROySwQWKqQ8rEWcdbTsFjh2G0vn8KUyvTXdPoyzSL6lLGkiXEV5CvpJsEe9xIdKV+8Zqb7wif2issoOFA==
   dependencies:
-    "@typescript-eslint/types" "5.59.1"
-    "@typescript-eslint/visitor-keys" "5.59.1"
+    "@typescript-eslint/types" "5.59.2"
+    "@typescript-eslint/visitor-keys" "5.59.2"
 
-"@typescript-eslint/type-utils@5.59.1":
-  version "5.59.1"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-5.59.1.tgz#63981d61684fd24eda2f9f08c0a47ecb000a2111"
-  integrity sha512-ZMWQ+Oh82jWqWzvM3xU+9y5U7MEMVv6GLioM3R5NJk6uvP47kZ7YvlgSHJ7ERD6bOY7Q4uxWm25c76HKEwIjZw==
+"@typescript-eslint/type-utils@5.59.2":
+  version "5.59.2"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-5.59.2.tgz#0729c237503604cd9a7084b5af04c496c9a4cdcf"
+  integrity sha512-b1LS2phBOsEy/T381bxkkywfQXkV1dWda/z0PhnIy3bC5+rQWQDS7fk9CSpcXBccPY27Z6vBEuaPBCKCgYezyQ==
   dependencies:
-    "@typescript-eslint/typescript-estree" "5.59.1"
-    "@typescript-eslint/utils" "5.59.1"
+    "@typescript-eslint/typescript-estree" "5.59.2"
+    "@typescript-eslint/utils" "5.59.2"
     debug "^4.3.4"
     tsutils "^3.21.0"
 
-"@typescript-eslint/types@5.45.0":
-  version "5.45.0"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-5.45.0.tgz#794760b9037ee4154c09549ef5a96599621109c5"
-  integrity sha512-QQij+u/vgskA66azc9dCmx+rev79PzX8uDHpsqSjEFtfF2gBUTRCpvYMh2gw2ghkJabNkPlSUCimsyBEQZd1DA==
+"@typescript-eslint/types@5.59.0":
+  version "5.59.0"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-5.59.0.tgz#3fcdac7dbf923ec5251545acdd9f1d42d7c4fe32"
+  integrity sha512-yR2h1NotF23xFFYKHZs17QJnB51J/s+ud4PYU4MqdZbzeNxpgUr05+dNeCN/bb6raslHvGdd6BFCkVhpPk/ZeA==
 
-"@typescript-eslint/types@5.59.1":
-  version "5.59.1"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-5.59.1.tgz#03f3fedd1c044cb336ebc34cc7855f121991f41d"
-  integrity sha512-dg0ICB+RZwHlysIy/Dh1SP+gnXNzwd/KS0JprD3Lmgmdq+dJAJnUPe1gNG34p0U19HvRlGX733d/KqscrGC1Pg==
+"@typescript-eslint/types@5.59.2":
+  version "5.59.2"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-5.59.2.tgz#b511d2b9847fe277c5cb002a2318bd329ef4f655"
+  integrity sha512-LbJ/HqoVs2XTGq5shkiKaNTuVv5tTejdHgfdjqRUGdYhjW1crm/M7og2jhVskMt8/4wS3T1+PfFvL1K3wqYj4w==
 
-"@typescript-eslint/typescript-estree@5.45.0":
-  version "5.45.0"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-5.45.0.tgz#f70a0d646d7f38c0dfd6936a5e171a77f1e5291d"
-  integrity sha512-maRhLGSzqUpFcZgXxg1qc/+H0bT36lHK4APhp0AEUVrpSwXiRAomm/JGjSG+kNUio5kAa3uekCYu/47cnGn5EQ==
+"@typescript-eslint/typescript-estree@5.59.0":
+  version "5.59.0"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-5.59.0.tgz#8869156ee1dcfc5a95be3ed0e2809969ea28e965"
+  integrity sha512-sUNnktjmI8DyGzPdZ8dRwW741zopGxltGs/SAPgGL/AAgDpiLsCFLcMNSpbfXfmnNeHmK9h3wGmCkGRGAoUZAg==
   dependencies:
-    "@typescript-eslint/types" "5.45.0"
-    "@typescript-eslint/visitor-keys" "5.45.0"
+    "@typescript-eslint/types" "5.59.0"
+    "@typescript-eslint/visitor-keys" "5.59.0"
     debug "^4.3.4"
     globby "^11.1.0"
     is-glob "^4.0.3"
     semver "^7.3.7"
     tsutils "^3.21.0"
 
-"@typescript-eslint/typescript-estree@5.59.1":
-  version "5.59.1"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-5.59.1.tgz#4aa546d27fd0d477c618f0ca00b483f0ec84c43c"
-  integrity sha512-lYLBBOCsFltFy7XVqzX0Ju+Lh3WPIAWxYpmH/Q7ZoqzbscLiCW00LeYCdsUnnfnj29/s1WovXKh2gwCoinHNGA==
+"@typescript-eslint/typescript-estree@5.59.2":
+  version "5.59.2"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-5.59.2.tgz#6e2fabd3ba01db5d69df44e0b654c0b051fe9936"
+  integrity sha512-+j4SmbwVmZsQ9jEyBMgpuBD0rKwi9RxRpjX71Brr73RsYnEr3Lt5QZ624Bxphp8HUkSKfqGnPJp1kA5nl0Sh7Q==
   dependencies:
-    "@typescript-eslint/types" "5.59.1"
-    "@typescript-eslint/visitor-keys" "5.59.1"
+    "@typescript-eslint/types" "5.59.2"
+    "@typescript-eslint/visitor-keys" "5.59.2"
     debug "^4.3.4"
     globby "^11.1.0"
     is-glob "^4.0.3"
     semver "^7.3.7"
     tsutils "^3.21.0"
 
-"@typescript-eslint/utils@5.59.1":
-  version "5.59.1"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-5.59.1.tgz#d89fc758ad23d2157cfae53f0b429bdf15db9473"
-  integrity sha512-MkTe7FE+K1/GxZkP5gRj3rCztg45bEhsd8HYjczBuYm+qFHP5vtZmjx3B0yUCDotceQ4sHgTyz60Ycl225njmA==
+"@typescript-eslint/utils@5.59.2":
+  version "5.59.2"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-5.59.2.tgz#0c45178124d10cc986115885688db6abc37939f4"
+  integrity sha512-kSuF6/77TZzyGPhGO4uVp+f0SBoYxCDf+lW3GKhtKru/L8k/Hd7NFQxyWUeY7Z/KGB2C6Fe3yf2vVi4V9TsCSQ==
   dependencies:
     "@eslint-community/eslint-utils" "^4.2.0"
     "@types/json-schema" "^7.0.9"
     "@types/semver" "^7.3.12"
-    "@typescript-eslint/scope-manager" "5.59.1"
-    "@typescript-eslint/types" "5.59.1"
-    "@typescript-eslint/typescript-estree" "5.59.1"
+    "@typescript-eslint/scope-manager" "5.59.2"
+    "@typescript-eslint/types" "5.59.2"
+    "@typescript-eslint/typescript-estree" "5.59.2"
     eslint-scope "^5.1.1"
     semver "^7.3.7"
 
-"@typescript-eslint/visitor-keys@5.45.0":
-  version "5.45.0"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-5.45.0.tgz#e0d160e9e7fdb7f8da697a5b78e7a14a22a70528"
-  integrity sha512-jc6Eccbn2RtQPr1s7th6jJWQHBHI6GBVQkCHoJFQ5UreaKm59Vxw+ynQUPPY2u2Amquc+7tmEoC2G52ApsGNNg==
+"@typescript-eslint/visitor-keys@5.59.0":
+  version "5.59.0"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-5.59.0.tgz#a59913f2bf0baeb61b5cfcb6135d3926c3854365"
+  integrity sha512-qZ3iXxQhanchCeaExlKPV3gDQFxMUmU35xfd5eCXB6+kUw1TUAbIy2n7QIrwz9s98DQLzNWyHp61fY0da4ZcbA==
   dependencies:
-    "@typescript-eslint/types" "5.45.0"
+    "@typescript-eslint/types" "5.59.0"
     eslint-visitor-keys "^3.3.0"
 
-"@typescript-eslint/visitor-keys@5.59.1":
-  version "5.59.1"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-5.59.1.tgz#0d96c36efb6560d7fb8eb85de10442c10d8f6058"
-  integrity sha512-6waEYwBTCWryx0VJmP7JaM4FpipLsFl9CvYf2foAE8Qh/Y0s+bxWysciwOs0LTBED4JCaNxTZ5rGadB14M6dwA==
+"@typescript-eslint/visitor-keys@5.59.2":
+  version "5.59.2"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-5.59.2.tgz#37a419dc2723a3eacbf722512b86d6caf7d3b750"
+  integrity sha512-EEpsO8m3RASrKAHI9jpavNv9NlEUebV4qmF1OWxSTtKSFBpC1NCmWazDQHFivRf0O1DV11BA645yrLEVQ0/Lig==
   dependencies:
-    "@typescript-eslint/types" "5.59.1"
+    "@typescript-eslint/types" "5.59.2"
     eslint-visitor-keys "^3.3.0"
 
 "@webassemblyjs/ast@1.9.0":
@@ -3163,10 +3179,10 @@ axe-core@^4.6.2:
   resolved "https://registry.yarnpkg.com/axe-core/-/axe-core-4.6.3.tgz#fc0db6fdb65cc7a80ccf85286d91d64ababa3ece"
   integrity sha512-/BQzOX780JhsxDnPpH4ZiyrJAzcd8AfzFPkv+89veFSr1rcMjuq2JDCwypKaPeB6ljHp9KjXhPpjgCvQlWYuqg==
 
-axios@^1.3.6:
-  version "1.3.6"
-  resolved "https://registry.yarnpkg.com/axios/-/axios-1.3.6.tgz#1ace9a9fb994314b5f6327960918406fa92c6646"
-  integrity sha512-PEcdkk7JcdPiMDkvM4K6ZBRYq9keuVJsToxm2zQIM70Qqo2WHTdJZMXcG9X+RmRp2VPNUQC8W1RAGbgt6b1yMg==
+axios@^1.4.0:
+  version "1.4.0"
+  resolved "https://registry.yarnpkg.com/axios/-/axios-1.4.0.tgz#38a7bf1224cd308de271146038b551d725f0be1f"
+  integrity sha512-S4XCWMEmzvo64T9GfvQDOXgYRDJ/wsSZc7Jvdgx5u1sd0JwsuPLqb3SYmusag+edF6ziyMensPVqLTSc1PiSEA==
   dependencies:
     follow-redirects "^1.15.0"
     form-data "^4.0.0"
@@ -4309,14 +4325,14 @@ cssesc@^3.0.0:
   resolved "https://registry.yarnpkg.com/cssesc/-/cssesc-3.0.0.tgz#37741919903b868565e1c09ea747445cd18983ee"
   integrity sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==
 
-cssnano-preset-default@^6.0.0:
-  version "6.0.0"
-  resolved "https://registry.yarnpkg.com/cssnano-preset-default/-/cssnano-preset-default-6.0.0.tgz#058726536bdc18711c01b1d328766cbc5691cf71"
-  integrity sha512-BDxlaFzObRDXUiCCBQUNQcI+f1/aX2mgoNtXGjV6PG64POcHoDUoX+LgMWw+Q4609QhxwkcSnS65YFs42RA6qQ==
+cssnano-preset-default@^6.0.1:
+  version "6.0.1"
+  resolved "https://registry.yarnpkg.com/cssnano-preset-default/-/cssnano-preset-default-6.0.1.tgz#2a93247140d214ddb9f46bc6a3562fa9177fe301"
+  integrity sha512-7VzyFZ5zEB1+l1nToKyrRkuaJIx0zi/1npjvZfbBwbtNTzhLtlvYraK/7/uqmX2Wb2aQtd983uuGw79jAjLSuQ==
   dependencies:
     css-declaration-sorter "^6.3.1"
     cssnano-utils "^4.0.0"
-    postcss-calc "^8.2.3"
+    postcss-calc "^9.0.0"
     postcss-colormin "^6.0.0"
     postcss-convert-values "^6.0.0"
     postcss-discard-comments "^6.0.0"
@@ -4324,7 +4340,7 @@ cssnano-preset-default@^6.0.0:
     postcss-discard-empty "^6.0.0"
     postcss-discard-overridden "^6.0.0"
     postcss-merge-longhand "^6.0.0"
-    postcss-merge-rules "^6.0.0"
+    postcss-merge-rules "^6.0.1"
     postcss-minify-font-values "^6.0.0"
     postcss-minify-gradients "^6.0.0"
     postcss-minify-params "^6.0.0"
@@ -4349,12 +4365,12 @@ cssnano-utils@^4.0.0:
   resolved "https://registry.yarnpkg.com/cssnano-utils/-/cssnano-utils-4.0.0.tgz#d1da885ec04003ab19505ff0e62e029708d36b08"
   integrity sha512-Z39TLP+1E0KUcd7LGyF4qMfu8ZufI0rDzhdyAMsa/8UyNUU8wpS0fhdBxbQbv32r64ea00h4878gommRVg2BHw==
 
-cssnano@^6.0.0:
-  version "6.0.0"
-  resolved "https://registry.yarnpkg.com/cssnano/-/cssnano-6.0.0.tgz#53f4cb81101cccba0809fad779f006b5d44925ee"
-  integrity sha512-RGlcbzGhzEBCHuQe3k+Udyj5M00z0pm9S+VurHXFEOXxH+y0sVrJH2sMzoyz2d8N1EScazg+DVvmgyx0lurwwA==
+cssnano@^6.0.1:
+  version "6.0.1"
+  resolved "https://registry.yarnpkg.com/cssnano/-/cssnano-6.0.1.tgz#87c38c4cd47049c735ab756d7e77ac3ca855c008"
+  integrity sha512-fVO1JdJ0LSdIGJq68eIxOqFpIJrZqXUsBt8fkrBcztCQqAjQD51OhZp7tc0ImcbwXD4k7ny84QTV90nZhmqbkg==
   dependencies:
-    cssnano-preset-default "^6.0.0"
+    cssnano-preset-default "^6.0.1"
     lilconfig "^2.1.0"
 
 csso@^5.0.5:
@@ -5035,20 +5051,20 @@ eslint-module-utils@^2.7.4:
   dependencies:
     debug "^3.2.7"
 
-eslint-plugin-formatjs@^4.9.0:
-  version "4.10.0"
-  resolved "https://registry.yarnpkg.com/eslint-plugin-formatjs/-/eslint-plugin-formatjs-4.10.0.tgz#05da23f75b4ce507c90df93ff07be2b6e70ffbc5"
-  integrity sha512-YvNF72NVMkIevgJrX5xTkIj4eBCeiweM2/61ppP2eEni3FP4pDXy9UFsOhtxJVISTBH0UEAqz3xhRjqi1q+qag==
+eslint-plugin-formatjs@^4.10.1:
+  version "4.10.1"
+  resolved "https://registry.yarnpkg.com/eslint-plugin-formatjs/-/eslint-plugin-formatjs-4.10.1.tgz#c67184ac54188dcad84d6541e6b5467248ab6550"
+  integrity sha512-sD3GGdfDQqiaW8TgbUD4lrUR+raIgusPzW+0v+iN36QzkHvpg5L0UZGFQE9GWtgnWfXAndb57UpgB0i/CF2G7w==
   dependencies:
-    "@formatjs/icu-messageformat-parser" "2.3.1"
-    "@formatjs/ts-transformer" "3.13.0"
+    "@formatjs/icu-messageformat-parser" "2.4.0"
+    "@formatjs/ts-transformer" "3.13.1"
     "@types/eslint" "7 || 8"
     "@types/picomatch" "^2.3.0"
-    "@typescript-eslint/typescript-estree" "5.45.0"
+    "@typescript-eslint/typescript-estree" "5.59.0"
     emoji-regex "^10.2.1"
-    magic-string "^0.29.0"
+    magic-string "^0.30.0"
     picomatch "^2.3.1"
-    tslib "2.4.0"
+    tslib "2.5.0"
     typescript "^4.7 || 5"
     unicode-emoji-utils "^1.1.1"
 
@@ -6296,6 +6312,11 @@ ignore@^5.2.0, ignore@^5.2.4:
   resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.2.4.tgz#a291c0c6178ff1b960befe47fcdec301674a6324"
   integrity sha512-MAb38BcSbH0eHNBxn7ql2NH/kX33OkB3lZ1BNdh7ENeRChHTYsTvWrMubiIAMNS2llXEEgZ1MUOBtXChP3kaFQ==
 
+immer@^9.0.21:
+  version "9.0.21"
+  resolved "https://registry.yarnpkg.com/immer/-/immer-9.0.21.tgz#1e025ea31a40f24fb064f1fef23e931496330176"
+  integrity sha512-bc4NBHqOqSfRW7POMkHd51LvClaeMXpm8dx0e8oE2GORbq5aRK7Bxl4FyzVLdGtLmvLKL7BTDBG5ACQm4HWjTA==
+
 immutable@^3.8.2:
   version "3.8.2"
   resolved "https://registry.yarnpkg.com/immutable/-/immutable-3.8.2.tgz#c2439951455bb39913daf281376f1530e104adf3"
@@ -7409,10 +7430,10 @@ jsdom@^20.0.0:
     ws "^8.11.0"
     xml-name-validator "^4.0.0"
 
-jsdom@^21.1.1:
-  version "21.1.1"
-  resolved "https://registry.yarnpkg.com/jsdom/-/jsdom-21.1.1.tgz#ab796361e3f6c01bcfaeda1fea3c06197ac9d8ae"
-  integrity sha512-Jjgdmw48RKcdAIQyUD1UdBh2ecH7VqwaXPN3ehoZN6MqgVbMn+lRm1aAT1AsdJRAJpwfa4IpwgzySn61h2qu3w==
+jsdom@^21.1.2:
+  version "21.1.2"
+  resolved "https://registry.yarnpkg.com/jsdom/-/jsdom-21.1.2.tgz#6433f751b8718248d646af1cdf6662dc8a1ca7f9"
+  integrity sha512-sCpFmK2jv+1sjff4u7fzft+pUh2KSUbUrEHYHyfSIbGTIcmnjyp83qg6qLwdJ/I3LpTXx33ACxeRL7Lsyc6lGQ==
   dependencies:
     abab "^2.0.6"
     acorn "^8.8.2"
@@ -7427,7 +7448,7 @@ jsdom@^21.1.1:
     http-proxy-agent "^5.0.0"
     https-proxy-agent "^5.0.1"
     is-potential-custom-element-name "^1.0.1"
-    nwsapi "^2.2.2"
+    nwsapi "^2.2.4"
     parse5 "^7.1.2"
     rrweb-cssom "^0.6.0"
     saxes "^6.0.0"
@@ -7810,10 +7831,10 @@ magic-string@^0.25.0, magic-string@^0.25.7:
   dependencies:
     sourcemap-codec "^1.4.8"
 
-magic-string@^0.29.0:
-  version "0.29.0"
-  resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.29.0.tgz#f034f79f8c43dba4ae1730ffb5e8c4e084b16cf3"
-  integrity sha512-WcfidHrDjMY+eLjlU+8OvwREqHwpgCeKVBUpQ3OhYYuvfaYCUgcbuBzappNzZvg/v8onU3oQj+BYpkOJe9Iw4Q==
+magic-string@^0.30.0:
+  version "0.30.0"
+  resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.30.0.tgz#fd58a4748c5c4547338a424e90fa5dd17f4de529"
+  integrity sha512-LA+31JYDJLs82r2ScLrlz1GjSgu66ZV518eyWT+S8VhyQn/JL0u9MeBOvQMGYiPk1DBiSN9DDMOcXvigJZaViQ==
   dependencies:
     "@jridgewell/sourcemap-codec" "^1.4.13"
 
@@ -8364,10 +8385,10 @@ nth-check@^2.0.1:
   dependencies:
     boolbase "^1.0.0"
 
-nwsapi@^2.2.2:
-  version "2.2.2"
-  resolved "https://registry.yarnpkg.com/nwsapi/-/nwsapi-2.2.2.tgz#e5418863e7905df67d51ec95938d67bf801f0bb0"
-  integrity sha512-90yv+6538zuvUMnN+zCr8LuV6bPFdq50304114vJYJ8RDyK8D5O9Phpbd6SZWgI7PwzmmfN1upeOJlvybDSgCw==
+nwsapi@^2.2.2, nwsapi@^2.2.4:
+  version "2.2.4"
+  resolved "https://registry.yarnpkg.com/nwsapi/-/nwsapi-2.2.4.tgz#fd59d5e904e8e1f03c25a7d5a15cfa16c714a1e5"
+  integrity sha512-NHj4rzRo0tQdijE9ZqAx6kYDcoRwYwSYzCA8MY3JzfxlrvEU0jhnhJT9BhqhJs7I/dKcrDm6TyulaRqZPIhN5g==
 
 object-assign@^4.0.1, object-assign@^4.1.1:
   version "4.1.1"
@@ -8920,12 +8941,12 @@ posix-character-classes@^0.1.0:
   resolved "https://registry.yarnpkg.com/posix-character-classes/-/posix-character-classes-0.1.1.tgz#01eac0fe3b5af71a2a6c02feabb8c1fef7e00eab"
   integrity sha1-AerA/jta9xoqbAL+q7jB/vfgDqs=
 
-postcss-calc@^8.2.3:
-  version "8.2.4"
-  resolved "https://registry.yarnpkg.com/postcss-calc/-/postcss-calc-8.2.4.tgz#77b9c29bfcbe8a07ff6693dc87050828889739a5"
-  integrity sha512-SmWMSJmB8MRnnULldx0lQIyhSNvuDl9HfrZkaqqE/WHAhToYsAvDq+yAsA/kIyINDszOp3Rh0GFoNuH5Ypsm3Q==
+postcss-calc@^9.0.0:
+  version "9.0.0"
+  resolved "https://registry.yarnpkg.com/postcss-calc/-/postcss-calc-9.0.0.tgz#cd9b2b155e57c823687eb67c9afcbe97c98ecaa4"
+  integrity sha512-B9BNW/SVh4SMJfoCQ6D9h1Wo7Yjqks7UdbiARJ16J5TIsQn5NEqwMF5joSgOYb26oJPUR5Uv3fCQ/4PvmZWeJQ==
   dependencies:
-    postcss-selector-parser "^6.0.9"
+    postcss-selector-parser "^6.0.11"
     postcss-value-parser "^4.2.0"
 
 postcss-colormin@^6.0.0:
@@ -8990,10 +9011,10 @@ postcss-merge-longhand@^6.0.0:
     postcss-value-parser "^4.2.0"
     stylehacks "^6.0.0"
 
-postcss-merge-rules@^6.0.0:
-  version "6.0.0"
-  resolved "https://registry.yarnpkg.com/postcss-merge-rules/-/postcss-merge-rules-6.0.0.tgz#0d95bc73541156b8b4e763bd0de2c3f9d0ecf013"
-  integrity sha512-rCXkklftzEkniyv3f4mRCQzxD6oE4Quyh61uyWTUbCJ26Pv2hoz+fivJSsSBWxDBeScR4fKCfF3HHTcD7Ybqnw==
+postcss-merge-rules@^6.0.1:
+  version "6.0.1"
+  resolved "https://registry.yarnpkg.com/postcss-merge-rules/-/postcss-merge-rules-6.0.1.tgz#39f165746404e646c0f5c510222ccde4824a86aa"
+  integrity sha512-a4tlmJIQo9SCjcfiCcCMg/ZCEe0XTkl/xK0XHBs955GWg9xDX3NwP9pwZ78QUOWB8/0XCjZeJn98Dae0zg6AAw==
   dependencies:
     browserslist "^4.21.4"
     caniuse-api "^3.0.0"
@@ -9160,10 +9181,10 @@ postcss-scss@^4.0.6:
   resolved "https://registry.yarnpkg.com/postcss-scss/-/postcss-scss-4.0.6.tgz#5d62a574b950a6ae12f2aa89b60d63d9e4432bfd"
   integrity sha512-rLDPhJY4z/i4nVFZ27j9GqLxj1pwxE80eAzUNRMXtcpipFYIeowerzBgG3yJhMtObGEXidtIgbUpQ3eLDsf5OQ==
 
-postcss-selector-parser@^6.0.11, postcss-selector-parser@^6.0.2, postcss-selector-parser@^6.0.4, postcss-selector-parser@^6.0.5, postcss-selector-parser@^6.0.9:
-  version "6.0.11"
-  resolved "https://registry.yarnpkg.com/postcss-selector-parser/-/postcss-selector-parser-6.0.11.tgz#2e41dc39b7ad74046e1615185185cd0b17d0c8dc"
-  integrity sha512-zbARubNdogI9j7WY4nQJBiNqQf3sLS3wCP4WfOidu+p28LofJqDH1tcXypGrcmMHhDk2t9wGhCsYe/+szLTy1g==
+postcss-selector-parser@^6.0.11, postcss-selector-parser@^6.0.12, postcss-selector-parser@^6.0.2, postcss-selector-parser@^6.0.4, postcss-selector-parser@^6.0.5:
+  version "6.0.12"
+  resolved "https://registry.yarnpkg.com/postcss-selector-parser/-/postcss-selector-parser-6.0.12.tgz#2efae5ffab3c8bfb2b7fbf0c426e3bca616c4abb"
+  integrity sha512-NdxGCAZdRrwVI1sy59+Wzrh+pMMHxapGnpfenDVlMEXoOcvt4pGE0JLK9YY2F5dLxcFYA/YbVQKhcGU+FtSYQg==
   dependencies:
     cssesc "^3.0.0"
     util-deprecate "^1.0.2"
@@ -9188,7 +9209,7 @@ postcss-value-parser@^4.1.0, postcss-value-parser@^4.2.0:
   resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz#723c09920836ba6d3e5af019f92bc0971c02e514"
   integrity sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==
 
-postcss@^8.2.15, postcss@^8.4.22, postcss@^8.4.23:
+postcss@^8.2.15, postcss@^8.4.23:
   version "8.4.23"
   resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.23.tgz#df0aee9ac7c5e53e1075c24a3613496f9e6552ab"
   integrity sha512-bQ3qMcpF6A/YjR55xtoTr0jGOlnPOKAIMdOWiv0EIT6HVPEaJiJB4NLljSbiHoC2RX7DN5Uvjtpbg1NPdwv1oA==
@@ -9612,10 +9633,10 @@ react-router@^4.3.1:
     prop-types "^15.6.1"
     warning "^4.0.1"
 
-react-select@*, react-select@^5.7.2:
-  version "5.7.2"
-  resolved "https://registry.yarnpkg.com/react-select/-/react-select-5.7.2.tgz#ccd40071b9429277983bf15526e7a5773a060e09"
-  integrity sha512-cTlJkQ8YjV6T/js8wW0owTzht0hHGABh29vjLscY4HfZGkv7hc3FFTmRp9NzY/Ib1uQ36GieAKEjxpHdpCFpcA==
+react-select@*, react-select@^5.7.3:
+  version "5.7.3"
+  resolved "https://registry.yarnpkg.com/react-select/-/react-select-5.7.3.tgz#fa0dc9a23cad6ff3871ad3829f6083a4b54961a2"
+  integrity sha512-z8i3NCuFFWL3w27xq92rBkVI2onT0jzIIPe480HlBjXJ3b5o6Q+Clp4ydyeKrj9DZZ3lrjawwLC5NGl0FSvUDg==
   dependencies:
     "@babel/runtime" "^7.12.0"
     "@emotion/cache" "^11.4.0"
@@ -10949,10 +10970,10 @@ stylelint-scss@^4.6.0:
     postcss-selector-parser "^6.0.11"
     postcss-value-parser "^4.2.0"
 
-stylelint@^15.6.0:
-  version "15.6.0"
-  resolved "https://registry.yarnpkg.com/stylelint/-/stylelint-15.6.0.tgz#1d76176dd8b6307bc4645e428ad18ddd15edbafc"
-  integrity sha512-Cqzpc8tvJm77KaM8qUbhpJ/UYK55Ia0whQXj4b9IId9dlPICO7J8Lyo15SZWiHxKjlvy3p5FQor/3n6i8ignXg==
+stylelint@^15.6.1:
+  version "15.6.1"
+  resolved "https://registry.yarnpkg.com/stylelint/-/stylelint-15.6.1.tgz#e4cd33a3af88587b99a5d1328aedd8c298b6dc81"
+  integrity sha512-d8icFBlVl93Elf3Z5ABQNOCe4nx69is3D/NZhDLAie1eyYnpxfeKe7pCfqzT5W4F8vxHCLSDfV8nKNJzogvV2Q==
   dependencies:
     "@csstools/css-parser-algorithms" "^2.1.1"
     "@csstools/css-tokenizer" "^2.1.1"
@@ -10981,11 +11002,11 @@ stylelint@^15.6.0:
     micromatch "^4.0.5"
     normalize-path "^3.0.0"
     picocolors "^1.0.0"
-    postcss "^8.4.22"
+    postcss "^8.4.23"
     postcss-media-query-parser "^0.2.3"
     postcss-resolve-nested-selector "^0.1.1"
     postcss-safe-parser "^6.0.0"
-    postcss-selector-parser "^6.0.11"
+    postcss-selector-parser "^6.0.12"
     postcss-value-parser "^4.2.0"
     resolve-from "^5.0.0"
     string-width "^4.2.3"
@@ -10995,7 +11016,7 @@ stylelint@^15.6.0:
     svg-tags "^1.0.0"
     table "^6.8.1"
     v8-compile-cache "^2.3.0"
-    write-file-atomic "^5.0.0"
+    write-file-atomic "^5.0.1"
 
 stylis@4.0.13:
   version "4.0.13"
@@ -11316,26 +11337,16 @@ tsconfig-paths@^3.14.1:
     minimist "^1.2.6"
     strip-bom "^3.0.0"
 
-tslib@2.4.0:
-  version "2.4.0"
-  resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.4.0.tgz#7cecaa7f073ce680a05847aa77be941098f36dc3"
-  integrity sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==
-
-tslib@^1.8.1:
-  version "1.14.1"
-  resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00"
-  integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==
-
-tslib@^1.9.0:
-  version "1.13.0"
-  resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.13.0.tgz#c881e13cc7015894ed914862d276436fa9a47043"
-  integrity sha512-i/6DQjL8Xf3be4K/E6Wgpekn5Qasl1usyw++dAA35Ue5orEn65VIxOA+YvNNl9HV3qv70T7CNwjODHZrLwvd1Q==
-
-tslib@^2.1.0, tslib@^2.4.0:
+tslib@2.5.0, tslib@^2.1.0, tslib@^2.4.0:
   version "2.5.0"
   resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.5.0.tgz#42bfed86f5787aeb41d031866c8f402429e0fddf"
   integrity sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==
 
+tslib@^1.8.1, tslib@^1.9.0:
+  version "1.14.1"
+  resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00"
+  integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==
+
 tsutils@^3.21.0:
   version "3.21.0"
   resolved "https://registry.yarnpkg.com/tsutils/-/tsutils-3.21.0.tgz#b48717d394cea6c1e096983eed58e9d61715b623"
@@ -12256,13 +12267,13 @@ write-file-atomic@^4.0.2:
     imurmurhash "^0.1.4"
     signal-exit "^3.0.7"
 
-write-file-atomic@^5.0.0:
-  version "5.0.0"
-  resolved "https://registry.yarnpkg.com/write-file-atomic/-/write-file-atomic-5.0.0.tgz#54303f117e109bf3d540261125c8ea5a7320fab0"
-  integrity sha512-R7NYMnHSlV42K54lwY9lvW6MnSm1HSJqZL3xiSgi9E7//FYaI74r2G0rd+/X6VAMkHEdzxQaU5HUOXWUz5kA/w==
+write-file-atomic@^5.0.1:
+  version "5.0.1"
+  resolved "https://registry.yarnpkg.com/write-file-atomic/-/write-file-atomic-5.0.1.tgz#68df4717c55c6fa4281a7860b4c2ba0a6d2b11e7"
+  integrity sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==
   dependencies:
     imurmurhash "^0.1.4"
-    signal-exit "^3.0.7"
+    signal-exit "^4.0.1"
 
 ws@^6.2.1:
   version "6.2.1"
@@ -12360,10 +12371,10 @@ yargs@^13.3.2:
     y18n "^4.0.0"
     yargs-parser "^13.1.2"
 
-yargs@^17.3.1, yargs@^17.7.1:
-  version "17.7.1"
-  resolved "https://registry.yarnpkg.com/yargs/-/yargs-17.7.1.tgz#34a77645201d1a8fc5213ace787c220eabbd0967"
-  integrity sha512-cwiTb08Xuv5fqF4AovYacTFNxk62th7LKJ6BL9IGUpTJrWoU7/7WdQGTP2SjKf1dUNBGzDd28p/Yfs/GI6JrLw==
+yargs@^17.3.1, yargs@^17.7.2:
+  version "17.7.2"
+  resolved "https://registry.yarnpkg.com/yargs/-/yargs-17.7.2.tgz#991df39aca675a192b816e1e0363f9d75d2aa269"
+  integrity sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==
   dependencies:
     cliui "^8.0.1"
     escalade "^3.1.1"