diff --git a/.devcontainer/compose.yaml b/.devcontainer/compose.yaml
index 4d5ed0f25f..5da1ec3a24 100644
--- a/.devcontainer/compose.yaml
+++ b/.devcontainer/compose.yaml
@@ -21,12 +21,13 @@ services:
       ES_HOST: es
       ES_PORT: '9200'
       LIBRE_TRANSLATE_ENDPOINT: http://libretranslate:5000
+      LOCAL_DOMAIN: ${LOCAL_DOMAIN:-localhost:3000}
     # Overrides default command so things don't shut down after the process ends.
     command: sleep infinity
     ports:
-      - '127.0.0.1:3000:3000'
-      - '127.0.0.1:3035:3035'
-      - '127.0.0.1:4000:4000'
+      - '3000:3000'
+      - '3035:3035'
+      - '4000:4000'
     networks:
       - external_network
       - internal_network
diff --git a/.env.production.sample b/.env.production.sample
index 12ab2b6dcb..4afaf8d756 100644
--- a/.env.production.sample
+++ b/.env.production.sample
@@ -79,6 +79,9 @@ AWS_ACCESS_KEY_ID=
 AWS_SECRET_ACCESS_KEY=
 S3_ALIAS_HOST=files.example.com
 
+# Optional list of hosts that are allowed to serve media for your instance
+# EXTRA_MEDIA_HOSTS=https://data.example1.com,https://data.example2.com
+
 # IP and session retention
 # -----------------------
 # Make sure to modify the scheduling of ip_cleanup_scheduler in config/sidekiq.yml
diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml
index 120b7df202..f589f5f03d 100644
--- a/.rubocop_todo.yml
+++ b/.rubocop_todo.yml
@@ -1,6 +1,6 @@
 # This configuration was generated by
 # `rubocop --auto-gen-config --auto-gen-only-exclude --no-offense-counts --no-auto-gen-timestamp`
-# using RuboCop version 1.73.2.
+# using RuboCop version 1.75.0.
 # The point is for the user to remove these configuration records
 # one by one as the offenses are removed from the code base.
 # Note that changes in the inspected code, or installation of new
@@ -49,7 +49,7 @@ Style/FetchEnvVar:
     - 'lib/tasks/repo.rake'
 
 # This cop supports safe autocorrection (--autocorrect).
-# Configuration parameters: EnforcedStyle, MaxUnannotatedPlaceholdersAllowed, AllowedMethods, AllowedPatterns.
+# Configuration parameters: EnforcedStyle, MaxUnannotatedPlaceholdersAllowed, Mode, AllowedMethods, AllowedPatterns.
 # SupportedStyles: annotated, template, unannotated
 # AllowedMethods: redirect
 Style/FormatStringToken:
@@ -77,7 +77,6 @@ Style/MapToHash:
 # AllowedMethods: respond_to_missing?
 Style/OptionalBooleanParameter:
   Exclude:
-    - 'app/helpers/json_ld_helper.rb'
     - 'app/lib/admin/system_check/message.rb'
     - 'app/lib/request.rb'
     - 'app/lib/webfinger.rb'
diff --git a/Gemfile b/Gemfile
index fba7371b64..b64a1dbe91 100644
--- a/Gemfile
+++ b/Gemfile
@@ -14,6 +14,7 @@ gem 'haml-rails', '~>2.0'
 gem 'pg', '~> 1.5'
 gem 'pghero'
 
+gem 'aws-sdk-core', '< 3.216.0', require: false # TODO: https://github.com/mastodon/mastodon/pull/34173#issuecomment-2733378873
 gem 'aws-sdk-s3', '~> 1.123', require: false
 gem 'blurhash', '~> 0.1'
 gem 'fog-core', '<= 2.6.0'
diff --git a/Gemfile.lock b/Gemfile.lock
index 66a597e99e..1ad5429d4b 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -91,11 +91,11 @@ GEM
     aes_key_wrap (1.1.0)
     android_key_attestation (0.3.0)
     annotaterb (4.14.0)
-    ast (2.4.2)
+    ast (2.4.3)
     attr_required (1.0.2)
-    aws-eventstream (1.3.0)
-    aws-partitions (1.1032.0)
-    aws-sdk-core (3.214.1)
+    aws-eventstream (1.3.2)
+    aws-partitions (1.1066.0)
+    aws-sdk-core (3.215.1)
       aws-eventstream (~> 1, >= 1.3.0)
       aws-partitions (~> 1, >= 1.992.0)
       aws-sigv4 (~> 1.9)
@@ -107,9 +107,9 @@ GEM
       aws-sdk-core (~> 3, >= 3.210.0)
       aws-sdk-kms (~> 1)
       aws-sigv4 (~> 1.5)
-    aws-sigv4 (1.10.1)
+    aws-sigv4 (1.11.0)
       aws-eventstream (~> 1, >= 1.0.2)
-    azure-blob (0.5.4)
+    azure-blob (0.5.7)
       rexml
     base64 (0.2.0)
     bcp47_spec (0.2.1)
@@ -168,9 +168,9 @@ GEM
       bigdecimal
       rexml
     crass (1.0.6)
-    css_parser (1.21.0)
+    css_parser (1.21.1)
       addressable
-    csv (3.3.2)
+    csv (3.3.3)
     database_cleaner-active_record (2.2.0)
       activerecord (>= 5.a)
       database_cleaner-core (~> 2.0.0)
@@ -222,7 +222,8 @@ GEM
     erubi (1.13.1)
     et-orbi (1.2.11)
       tzinfo
-    excon (1.2.3)
+    excon (1.2.5)
+      logger
     fabrication (2.31.0)
     faker (3.5.1)
       i18n (>= 1.8.11, < 2)
@@ -256,7 +257,7 @@ GEM
     fog-json (1.2.0)
       fog-core
       multi_json (~> 1.10)
-    fog-openstack (1.1.4)
+    fog-openstack (1.1.5)
       fog-core (~> 2.1)
       fog-json (>= 1.0)
     formatador (1.1.0)
@@ -265,7 +266,9 @@ GEM
       raabro (~> 1.4)
     globalid (1.2.1)
       activesupport (>= 6.1)
-    google-protobuf (3.25.6)
+    google-protobuf (4.30.1)
+      bigdecimal
+      rake (>= 13)
     googleapis-common-protos-types (1.18.0)
       google-protobuf (>= 3.18, < 5.a)
     haml (6.3.0)
@@ -277,7 +280,7 @@ GEM
       activesupport (>= 5.1)
       haml (>= 4.0.6)
       railties (>= 5.1)
-    haml_lint (0.61.0)
+    haml_lint (0.61.1)
       haml (>= 5.0)
       parallel (~> 1.10)
       rainbow
@@ -302,7 +305,8 @@ GEM
       domain_name (~> 0.5)
     http-form_data (2.3.0)
     http_accept_language (2.1.1)
-    httpclient (2.8.3)
+    httpclient (2.9.0)
+      mutex_m
     httplog (1.7.0)
       rack (>= 2.0)
       rainbow (>= 2.0.0)
@@ -378,7 +382,7 @@ GEM
       mime-types
       terrapin (>= 0.6.0, < 2.0)
     language_server-protocol (3.17.0.4)
-    launchy (3.1.0)
+    launchy (3.1.1)
       addressable (~> 2.8)
       childprocess (~> 5.0)
       logger (~> 1.6)
@@ -391,7 +395,7 @@ GEM
       rexml
     link_header (0.0.8)
     lint_roller (1.1.0)
-    llhttp-ffi (0.5.0)
+    llhttp-ffi (0.5.1)
       ffi-compiler (~> 1.0)
       rake (~> 13.0)
     logger (1.6.6)
@@ -413,13 +417,13 @@ GEM
       redis (>= 3.0.5)
     matrix (0.4.2)
     memory_profiler (1.1.0)
-    mime-types (3.6.0)
+    mime-types (3.6.2)
       logger
       mime-types-data (~> 3.2015)
-    mime-types-data (3.2025.0220)
+    mime-types-data (3.2025.0318)
     mini_mime (1.1.5)
     mini_portile2 (2.8.8)
-    minitest (5.25.4)
+    minitest (5.25.5)
     msgpack (1.8.0)
     multi_json (1.15.0)
     mutex_m (0.3.0)
@@ -436,7 +440,7 @@ GEM
     net-smtp (0.5.1)
       net-protocol
     nio4r (2.7.4)
-    nokogiri (1.18.3)
+    nokogiri (1.18.6)
       mini_portile2 (~> 2.8.2)
       racc (~> 1.4)
     oj (3.16.10)
@@ -576,14 +580,14 @@ GEM
     ox (2.14.22)
       bigdecimal (>= 3.0)
     parallel (1.26.3)
-    parser (3.3.7.1)
+    parser (3.3.7.3)
       ast (~> 2.4.1)
       racc
     parslet (2.0.0)
     pastel (0.8.0)
       tty-color (~> 0.5)
     pg (1.5.9)
-    pghero (3.6.1)
+    pghero (3.6.2)
       activerecord (>= 6.1)
     pp (0.6.2)
       prettyprint
@@ -596,6 +600,7 @@ GEM
       net-smtp
       premailer (~> 1.7, >= 1.7.9)
     prettyprint (0.2.0)
+    prism (1.4.0)
     prometheus_exporter (2.2.0)
       webrick
     propshaft (1.1.0)
@@ -729,7 +734,7 @@ GEM
       rspec-mocks (~> 3.0)
       sidekiq (>= 5, < 9)
     rspec-support (3.13.2)
-    rubocop (1.74.0)
+    rubocop (1.75.1)
       json (~> 2.3)
       language_server-protocol (~> 3.17.0.2)
       lint_roller (~> 1.1.0)
@@ -737,11 +742,12 @@ GEM
       parser (>= 3.3.0.2)
       rainbow (>= 2.2.2, < 4.0)
       regexp_parser (>= 2.9.3, < 3.0)
-      rubocop-ast (>= 1.38.0, < 2.0)
+      rubocop-ast (>= 1.43.0, < 2.0)
       ruby-progressbar (~> 1.7)
       unicode-display_width (>= 2.4.0, < 4.0)
-    rubocop-ast (1.38.1)
-      parser (>= 3.3.1.0)
+    rubocop-ast (1.43.0)
+      parser (>= 3.3.7.2)
+      prism (~> 1.4)
     rubocop-capybara (2.22.1)
       lint_roller (~> 1.1)
       rubocop (~> 1.72, >= 1.72.1)
@@ -785,7 +791,7 @@ GEM
       activerecord (>= 4.0.0)
       railties (>= 4.0.0)
     securerandom (0.4.1)
-    selenium-webdriver (4.29.1)
+    selenium-webdriver (4.30.1)
       base64 (~> 0.2)
       logger (~> 1.4)
       rexml (~> 3.2, >= 3.2.5)
@@ -826,7 +832,7 @@ GEM
     stoplight (4.1.1)
       redlock (~> 1.0)
     stringio (3.1.5)
-    strong_migrations (2.2.0)
+    strong_migrations (2.2.1)
       activerecord (>= 7)
     swd (2.0.3)
       activesupport (>= 3)
@@ -862,7 +868,7 @@ GEM
       unf (~> 0.1.0)
     tzinfo (2.0.6)
       concurrent-ruby (~> 1.0)
-    tzinfo-data (1.2025.1)
+    tzinfo-data (1.2025.2)
       tzinfo (>= 1.0.0)
     unf (0.1.4)
       unf_ext
@@ -917,6 +923,7 @@ DEPENDENCIES
   active_model_serializers (~> 0.10)
   addressable (~> 2.8)
   annotaterb (~> 4.13)
+  aws-sdk-core (< 3.216.0)
   aws-sdk-s3 (~> 1.123)
   better_errors (~> 2.9)
   binding_of_caller (~> 1.0)
@@ -1069,4 +1076,4 @@ RUBY VERSION
    ruby 3.4.1p0
 
 BUNDLED WITH
-   2.6.5
+   2.6.6
diff --git a/app/controllers/api/v1/accounts/identity_proofs_controller.rb b/app/controllers/api/v1/accounts/identity_proofs_controller.rb
index 48f293f47a..02a45e8758 100644
--- a/app/controllers/api/v1/accounts/identity_proofs_controller.rb
+++ b/app/controllers/api/v1/accounts/identity_proofs_controller.rb
@@ -1,6 +1,10 @@
 # frozen_string_literal: true
 
 class Api::V1::Accounts::IdentityProofsController < Api::BaseController
+  include DeprecationConcern
+
+  deprecate_api '2022-03-30'
+
   before_action :require_user!
   before_action :set_account
 
diff --git a/app/controllers/api/v1/accounts_controller.rb b/app/controllers/api/v1/accounts_controller.rb
index 6bef6a3768..46838aeb66 100644
--- a/app/controllers/api/v1/accounts_controller.rb
+++ b/app/controllers/api/v1/accounts_controller.rb
@@ -124,7 +124,7 @@ class Api::V1::AccountsController < Api::BaseController
   end
 
   def account_params
-    params.permit(:username, :email, :password, :agreement, :locale, :reason, :time_zone, :invite_code)
+    params.permit(:username, :email, :password, :agreement, :locale, :reason, :time_zone, :invite_code, :date_of_birth)
   end
 
   def invite
diff --git a/app/controllers/api/v1/filters_controller.rb b/app/controllers/api/v1/filters_controller.rb
index c97e9720ad..f8d91c5f7f 100644
--- a/app/controllers/api/v1/filters_controller.rb
+++ b/app/controllers/api/v1/filters_controller.rb
@@ -1,6 +1,10 @@
 # frozen_string_literal: true
 
 class Api::V1::FiltersController < Api::BaseController
+  include DeprecationConcern
+
+  deprecate_api '2022-11-14'
+
   before_action -> { doorkeeper_authorize! :read, :'read:filters' }, only: [:index, :show]
   before_action -> { doorkeeper_authorize! :write, :'write:filters' }, except: [:index, :show]
   before_action :require_user!
diff --git a/app/controllers/api/v1/instances_controller.rb b/app/controllers/api/v1/instances_controller.rb
index 49da75ed28..e01267c000 100644
--- a/app/controllers/api/v1/instances_controller.rb
+++ b/app/controllers/api/v1/instances_controller.rb
@@ -1,15 +1,9 @@
 # frozen_string_literal: true
 
-class Api::V1::InstancesController < Api::BaseController
-  skip_before_action :require_authenticated_user!, unless: :limited_federation_mode?
-  skip_around_action :set_locale
+class Api::V1::InstancesController < Api::V2::InstancesController
+  include DeprecationConcern
 
-  vary_by ''
-
-  # Override `current_user` to avoid reading session cookies unless in limited federation mode
-  def current_user
-    super if limited_federation_mode?
-  end
+  deprecate_api '2022-11-14'
 
   def show
     cache_even_if_authenticated!
diff --git a/app/controllers/api/v1/suggestions_controller.rb b/app/controllers/api/v1/suggestions_controller.rb
index 9ba1cef63c..918ec45beb 100644
--- a/app/controllers/api/v1/suggestions_controller.rb
+++ b/app/controllers/api/v1/suggestions_controller.rb
@@ -2,6 +2,9 @@
 
 class Api::V1::SuggestionsController < Api::BaseController
   include Authorization
+  include DeprecationConcern
+
+  deprecate_api '2021-05-16'
 
   before_action -> { doorkeeper_authorize! :read, :'read:accounts' }, only: :index
   before_action -> { doorkeeper_authorize! :write, :'write:accounts' }, except: :index
diff --git a/app/controllers/api/v1/trends/tags_controller.rb b/app/controllers/api/v1/trends/tags_controller.rb
index 10a3442344..f84f1c0252 100644
--- a/app/controllers/api/v1/trends/tags_controller.rb
+++ b/app/controllers/api/v1/trends/tags_controller.rb
@@ -1,12 +1,16 @@
 # frozen_string_literal: true
 
 class Api::V1::Trends::TagsController < Api::BaseController
+  include DeprecationConcern
+
   before_action :set_tags
 
   after_action :insert_pagination_headers
 
   DEFAULT_TAGS_LIMIT = 10
 
+  deprecate_api '2022-03-30', only: :index, if: -> { request.path == '/api/v1/trends' }
+
   def index
     cache_if_unauthenticated!
     render json: @tags, each_serializer: REST::TagSerializer, relationships: TagRelationshipsPresenter.new(@tags, current_user&.account_id)
diff --git a/app/controllers/api/v2/instances_controller.rb b/app/controllers/api/v2/instances_controller.rb
index 8346e28830..62adf95260 100644
--- a/app/controllers/api/v2/instances_controller.rb
+++ b/app/controllers/api/v2/instances_controller.rb
@@ -1,6 +1,16 @@
 # frozen_string_literal: true
 
-class Api::V2::InstancesController < Api::V1::InstancesController
+class Api::V2::InstancesController < Api::BaseController
+  skip_before_action :require_authenticated_user!, unless: :limited_federation_mode?
+  skip_around_action :set_locale
+
+  vary_by ''
+
+  # Override `current_user` to avoid reading session cookies unless in limited federation mode
+  def current_user
+    super if limited_federation_mode?
+  end
+
   def show
     cache_even_if_authenticated!
     render_with_cache json: InstancePresenter.new, serializer: REST::InstanceSerializer, root: 'instance'
diff --git a/app/controllers/auth/registrations_controller.rb b/app/controllers/auth/registrations_controller.rb
index 6e34b6b627..0b6f5b3af4 100644
--- a/app/controllers/auth/registrations_controller.rb
+++ b/app/controllers/auth/registrations_controller.rb
@@ -62,7 +62,7 @@ class Auth::RegistrationsController < Devise::RegistrationsController
 
   def configure_sign_up_params
     devise_parameter_sanitizer.permit(:sign_up) do |user_params|
-      user_params.permit({ account_attributes: [:username, :display_name], invite_request_attributes: [:text] }, :email, :password, :password_confirmation, :invite_code, :agreement, :website, :confirm_password)
+      user_params.permit({ account_attributes: [:username, :display_name], invite_request_attributes: [:text] }, :email, :password, :password_confirmation, :invite_code, :agreement, :website, :confirm_password, :date_of_birth)
     end
   end
 
diff --git a/app/controllers/backups_controller.rb b/app/controllers/backups_controller.rb
index 5df1af5f2f..076d19874b 100644
--- a/app/controllers/backups_controller.rb
+++ b/app/controllers/backups_controller.rb
@@ -9,13 +9,15 @@ class BackupsController < ApplicationController
   before_action :authenticate_user!
   before_action :set_backup
 
+  BACKUP_LINK_TIMEOUT = 1.hour.freeze
+
   def download
     case Paperclip::Attachment.default_options[:storage]
     when :s3, :azure
-      redirect_to @backup.dump.expiring_url(10), allow_other_host: true
+      redirect_to @backup.dump.expiring_url(BACKUP_LINK_TIMEOUT.to_i), allow_other_host: true
     when :fog
       if Paperclip::Attachment.default_options.dig(:fog_credentials, :openstack_temp_url_key).present?
-        redirect_to @backup.dump.expiring_url(Time.now.utc + 10), allow_other_host: true
+        redirect_to @backup.dump.expiring_url(BACKUP_LINK_TIMEOUT.from_now), allow_other_host: true
       else
         redirect_to full_asset_url(@backup.dump.url), allow_other_host: true
       end
diff --git a/app/controllers/concerns/deprecation_concern.rb b/app/controllers/concerns/deprecation_concern.rb
new file mode 100644
index 0000000000..ad8de724a1
--- /dev/null
+++ b/app/controllers/concerns/deprecation_concern.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+module DeprecationConcern
+  extend ActiveSupport::Concern
+
+  class_methods do
+    def deprecate_api(date, sunset: nil, **kwargs)
+      deprecation_timestamp = "@#{date.to_datetime.to_i}"
+      sunset = sunset&.to_date&.httpdate
+
+      before_action(**kwargs) do
+        response.headers['Deprecation'] = deprecation_timestamp
+        response.headers['Sunset'] = sunset if sunset
+      end
+    end
+  end
+end
diff --git a/app/helpers/admin/trends/statuses_helper.rb b/app/helpers/admin/trends/statuses_helper.rb
index c7a59660cf..33da1f7216 100644
--- a/app/helpers/admin/trends/statuses_helper.rb
+++ b/app/helpers/admin/trends/statuses_helper.rb
@@ -2,11 +2,18 @@
 
 module Admin::Trends::StatusesHelper
   def one_line_preview(status)
-    text = if status.local?
-             status.text.split("\n").first
-           else
-             Nokogiri::HTML5(status.text).css('html > body > *').first&.text
-           end
+    text = begin
+      if status.local?
+        status.text.split("\n").first
+      else
+        Nokogiri::HTML5(status.text).css('html > body > *').first&.text
+      end
+    rescue ArgumentError
+      # This can happen if one of the Nokogumbo limits is encountered
+      # Unfortunately, it does not use a more precise error class
+      # nor allows more graceful handling
+      ''
+    end
 
     return '' if text.blank?
 
diff --git a/app/inputs/date_of_birth_input.rb b/app/inputs/date_of_birth_input.rb
new file mode 100644
index 0000000000..131234b02e
--- /dev/null
+++ b/app/inputs/date_of_birth_input.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+class DateOfBirthInput < SimpleForm::Inputs::Base
+  OPTIONS = [
+    { autocomplete: 'bday-day', maxlength: 2, pattern: '[0-9]+', placeholder: 'DD' }.freeze,
+    { autocomplete: 'bday-month', maxlength: 2, pattern: '[0-9]+', placeholder: 'MM' }.freeze,
+    { autocomplete: 'bday-year', maxlength: 4, pattern: '[0-9]+', placeholder: 'YYYY' }.freeze,
+  ].freeze
+
+  def input(wrapper_options = nil)
+    merged_input_options = merge_wrapper_options(input_html_options, wrapper_options)
+    merged_input_options[:inputmode] = 'numeric'
+
+    values = (object.public_send(attribute_name) || '').split('.')
+
+    safe_join(Array.new(3) do |index|
+      options = merged_input_options.merge(OPTIONS[index]).merge id: generate_id(index), 'aria-label': I18n.t("simple_form.labels.user.date_of_birth_#{index + 1}i"), value: values[index]
+      @builder.text_field("#{attribute_name}(#{index + 1}i)", options)
+    end)
+  end
+
+  def label_target
+    "#{attribute_name}_1i"
+  end
+
+  private
+
+  def generate_id(index)
+    "#{object_name}_#{attribute_name}_#{index + 1}i"
+  end
+end
diff --git a/app/javascript/mastodon/actions/alerts.ts b/app/javascript/mastodon/actions/alerts.ts
index a521f3ef35..4fd293e252 100644
--- a/app/javascript/mastodon/actions/alerts.ts
+++ b/app/javascript/mastodon/actions/alerts.ts
@@ -1,14 +1,11 @@
 import { defineMessages } from 'react-intl';
-import type { MessageDescriptor } from 'react-intl';
+
+import { createAction } from '@reduxjs/toolkit';
 
 import { AxiosError } from 'axios';
 import type { AxiosResponse } from 'axios';
 
-interface Alert {
-  title: string | MessageDescriptor;
-  message: string | MessageDescriptor;
-  values?: Record<string, string | number | Date>;
-}
+import type { Alert } from 'mastodon/models/alert';
 
 interface ApiErrorResponse {
   error?: string;
@@ -30,24 +27,13 @@ const messages = defineMessages({
   },
 });
 
-export const ALERT_SHOW = 'ALERT_SHOW';
-export const ALERT_DISMISS = 'ALERT_DISMISS';
-export const ALERT_CLEAR = 'ALERT_CLEAR';
-export const ALERT_NOOP = 'ALERT_NOOP';
+export const dismissAlert = createAction<{ key: number }>('alerts/dismiss');
 
-export const dismissAlert = (alert: Alert) => ({
-  type: ALERT_DISMISS,
-  alert,
-});
+export const clearAlerts = createAction('alerts/clear');
 
-export const clearAlert = () => ({
-  type: ALERT_CLEAR,
-});
+export const showAlert = createAction<Omit<Alert, 'key'>>('alerts/show');
 
-export const showAlert = (alert: Alert) => ({
-  type: ALERT_SHOW,
-  alert,
-});
+const ignoreAlert = createAction('alerts/ignore');
 
 export const showAlertForError = (error: unknown, skipNotFound = false) => {
   if (error instanceof AxiosError && error.response) {
@@ -56,7 +42,7 @@ export const showAlertForError = (error: unknown, skipNotFound = false) => {
 
     // Skip these errors as they are reflected in the UI
     if (skipNotFound && (status === 404 || status === 410)) {
-      return { type: ALERT_NOOP };
+      return ignoreAlert();
     }
 
     // Rate limit errors
@@ -76,9 +62,9 @@ export const showAlertForError = (error: unknown, skipNotFound = false) => {
     });
   }
 
-  // An aborted request, e.g. due to reloading the browser window, it not really error
+  // An aborted request, e.g. due to reloading the browser window, is not really an error
   if (error instanceof AxiosError && error.code === AxiosError.ECONNABORTED) {
-    return { type: ALERT_NOOP };
+    return ignoreAlert();
   }
 
   console.error(error);
diff --git a/app/javascript/mastodon/api.ts b/app/javascript/mastodon/api.ts
index f0663ded40..a41b058d2c 100644
--- a/app/javascript/mastodon/api.ts
+++ b/app/javascript/mastodon/api.ts
@@ -1,4 +1,9 @@
-import type { AxiosResponse, Method, RawAxiosRequestHeaders } from 'axios';
+import type {
+  AxiosError,
+  AxiosResponse,
+  Method,
+  RawAxiosRequestHeaders,
+} from 'axios';
 import axios from 'axios';
 import LinkHeader from 'http-link-header';
 
@@ -41,7 +46,7 @@ const authorizationTokenFromInitialState = (): RawAxiosRequestHeaders => {
 
 // eslint-disable-next-line import/no-default-export
 export default function api(withAuthorization = true) {
-  return axios.create({
+  const instance = axios.create({
     transitional: {
       clarifyTimeoutError: true,
     },
@@ -60,6 +65,22 @@ export default function api(withAuthorization = true) {
       },
     ],
   });
+
+  instance.interceptors.response.use(
+    (response: AxiosResponse) => {
+      if (response.headers.deprecation) {
+        console.warn(
+          `Deprecated request: ${response.config.method} ${response.config.url}`,
+        );
+      }
+      return response;
+    },
+    (error: AxiosError) => {
+      return Promise.reject(error);
+    },
+  );
+
+  return instance;
 }
 
 type RequestParamsOrData = Record<string, unknown>;
diff --git a/app/javascript/mastodon/components/alerts_controller.tsx b/app/javascript/mastodon/components/alerts_controller.tsx
new file mode 100644
index 0000000000..26749fa103
--- /dev/null
+++ b/app/javascript/mastodon/components/alerts_controller.tsx
@@ -0,0 +1,105 @@
+import { useState, useEffect } from 'react';
+
+import { useIntl } from 'react-intl';
+import type { IntlShape } from 'react-intl';
+
+import classNames from 'classnames';
+
+import { dismissAlert } from 'mastodon/actions/alerts';
+import type {
+  Alert,
+  TranslatableString,
+  TranslatableValues,
+} from 'mastodon/models/alert';
+import { useAppSelector, useAppDispatch } from 'mastodon/store';
+
+const formatIfNeeded = (
+  intl: IntlShape,
+  message: TranslatableString,
+  values?: TranslatableValues,
+) => {
+  if (typeof message === 'object') {
+    return intl.formatMessage(message, values);
+  }
+
+  return message;
+};
+
+const Alert: React.FC<{
+  alert: Alert;
+  dismissAfter: number;
+}> = ({
+  alert: { key, title, message, values, action, onClick },
+  dismissAfter,
+}) => {
+  const dispatch = useAppDispatch();
+  const intl = useIntl();
+  const [active, setActive] = useState(false);
+
+  useEffect(() => {
+    const setActiveTimeout = setTimeout(() => {
+      setActive(true);
+    }, 1);
+
+    return () => {
+      clearTimeout(setActiveTimeout);
+    };
+  }, []);
+
+  useEffect(() => {
+    const dismissTimeout = setTimeout(() => {
+      setActive(false);
+
+      // Allow CSS transition to finish before removing from the DOM
+      setTimeout(() => {
+        dispatch(dismissAlert({ key }));
+      }, 500);
+    }, dismissAfter);
+
+    return () => {
+      clearTimeout(dismissTimeout);
+    };
+  }, [dispatch, setActive, key, dismissAfter]);
+
+  return (
+    <div
+      className={classNames('notification-bar', {
+        'notification-bar-active': active,
+      })}
+    >
+      <div className='notification-bar-wrapper'>
+        {title && (
+          <span className='notification-bar-title'>
+            {formatIfNeeded(intl, title, values)}
+          </span>
+        )}
+
+        <span className='notification-bar-message'>
+          {formatIfNeeded(intl, message, values)}
+        </span>
+
+        {action && (
+          <button className='notification-bar-action' onClick={onClick}>
+            {formatIfNeeded(intl, action, values)}
+          </button>
+        )}
+      </div>
+    </div>
+  );
+};
+
+export const AlertsController: React.FC = () => {
+  const alerts = useAppSelector((state) => state.alerts);
+
+  if (alerts.length === 0) {
+    return null;
+  }
+
+  return (
+    <div className='notification-list'>
+      {alerts.map((alert, idx) => (
+        <Alert key={alert.key} alert={alert} dismissAfter={5000 + idx * 1000} />
+      ))}
+    </div>
+  );
+};
diff --git a/app/javascript/mastodon/components/edited_timestamp/index.jsx b/app/javascript/mastodon/components/edited_timestamp/index.jsx
index fbf14ec4bd..f8fa043259 100644
--- a/app/javascript/mastodon/components/edited_timestamp/index.jsx
+++ b/app/javascript/mastodon/components/edited_timestamp/index.jsx
@@ -6,6 +6,7 @@ import { FormattedMessage, injectIntl } from 'react-intl';
 import { connect } from 'react-redux';
 
 import { openModal } from 'mastodon/actions/modal';
+import { FormattedDateWrapper } from 'mastodon/components/formatted_date';
 import InlineAccount from 'mastodon/components/inline_account';
 import { RelativeTimestamp } from 'mastodon/components/relative_timestamp';
 
@@ -60,12 +61,12 @@ class EditedTimestamp extends PureComponent {
   };
 
   render () {
-    const { timestamp, intl, statusId } = this.props;
+    const { timestamp, statusId } = this.props;
 
     return (
       <DropdownMenu statusId={statusId} renderItem={this.renderItem} scrollable renderHeader={this.renderHeader} onItemClick={this.handleItemClick}>
         <button className='dropdown-menu__text-button'>
-          <FormattedMessage id='status.edited' defaultMessage='Edited {date}' values={{ date: <span className='animated-number'>{intl.formatDate(timestamp, { month: 'short', day: '2-digit', hour: '2-digit', minute: '2-digit' })}</span> }} />
+          <FormattedMessage id='status.edited' defaultMessage='Edited {date}' values={{ date: <FormattedDateWrapper className='animated-number' value={timestamp} month='short' day='2-digit' hour='2-digit' minute='2-digit' /> }} />
         </button>
       </DropdownMenu>
     );
diff --git a/app/javascript/mastodon/components/formatted_date.tsx b/app/javascript/mastodon/components/formatted_date.tsx
new file mode 100644
index 0000000000..cc927b0873
--- /dev/null
+++ b/app/javascript/mastodon/components/formatted_date.tsx
@@ -0,0 +1,26 @@
+import type { ComponentProps } from 'react';
+
+import { FormattedDate } from 'react-intl';
+
+export const FormattedDateWrapper = (
+  props: ComponentProps<typeof FormattedDate> & { className?: string },
+) => (
+  <FormattedDate {...props}>
+    {(date) => (
+      <time dateTime={tryIsoString(props.value)} className={props.className}>
+        {date}
+      </time>
+    )}
+  </FormattedDate>
+);
+
+const tryIsoString = (date?: string | number | Date): string => {
+  if (!date) {
+    return '';
+  }
+  try {
+    return new Date(date).toISOString();
+  } catch {
+    return date.toString();
+  }
+};
diff --git a/app/javascript/mastodon/components/media_gallery.jsx b/app/javascript/mastodon/components/media_gallery.jsx
index fd8aa59b01..12cf381e5e 100644
--- a/app/javascript/mastodon/components/media_gallery.jsx
+++ b/app/javascript/mastodon/components/media_gallery.jsx
@@ -12,6 +12,7 @@ import { debounce } from 'lodash';
 
 import { AltTextBadge } from 'mastodon/components/alt_text_badge';
 import { Blurhash } from 'mastodon/components/blurhash';
+import { SpoilerButton } from 'mastodon/components/spoiler_button';
 import { formatTime } from 'mastodon/features/video';
 
 import { autoPlayGif, displayMedia, useBlurhash } from '../initial_state';
@@ -38,6 +39,7 @@ class Item extends PureComponent {
 
   state = {
     loaded: false,
+    error: false,
   };
 
   handleMouseEnter = (e) => {
@@ -81,6 +83,10 @@ class Item extends PureComponent {
     this.setState({ loaded: true });
   };
 
+  handleImageError = () => {
+    this.setState({ error: true });
+  };
+
   render () {
     const { attachment, lang, index, size, standalone, displayWidth, visible } = this.props;
 
@@ -164,6 +170,7 @@ class Item extends PureComponent {
             lang={lang}
             style={{ objectPosition: `${x}% ${y}%` }}
             onLoad={this.handleImageLoad}
+            onError={this.handleImageError}
           />
         </a>
       );
@@ -199,7 +206,7 @@ class Item extends PureComponent {
     }
 
     return (
-      <div className={classNames('media-gallery__item', { standalone, 'media-gallery__item--tall': height === 100, 'media-gallery__item--wide': width === 100 })} key={attachment.get('id')}>
+      <div className={classNames('media-gallery__item', { standalone, 'media-gallery__item--error': this.state.error, 'media-gallery__item--tall': height === 100, 'media-gallery__item--wide': width === 100 })} key={attachment.get('id')}>
         <Blurhash
           hash={attachment.get('blurhash')}
           dummy={!useBlurhash}
@@ -236,6 +243,7 @@ class MediaGallery extends PureComponent {
     autoplay: PropTypes.bool,
     onToggleVisibility: PropTypes.func,
     compact: PropTypes.bool,
+    matchedFilters: PropTypes.arrayOf(PropTypes.string),
   };
 
   state = {
@@ -306,11 +314,11 @@ class MediaGallery extends PureComponent {
   }
 
   render () {
-    const { media, lang, sensitive, defaultWidth, autoplay, compact } = this.props;
+    const { media, lang, sensitive, defaultWidth, autoplay, compact, matchedFilters } = this.props;
     const { visible } = this.state;
     const width = this.state.width || defaultWidth;
 
-    let children, spoilerButton;
+    let children;
 
     const style = {};
 
@@ -329,26 +337,6 @@ class MediaGallery extends PureComponent {
       children = media.map((attachment, i) => <Item key={attachment.get('id')} autoplay={autoplay} onClick={this.handleClick} attachment={attachment} index={i} lang={lang} size={size} displayWidth={width} visible={visible || uncached} />);
     }
 
-    if (uncached) {
-      spoilerButton = (
-        <button type='button' disabled className='spoiler-button__overlay'>
-          <span className='spoiler-button__overlay__label'>
-            <FormattedMessage id='status.uncached_media_warning' defaultMessage='Preview not available' />
-            <span className='spoiler-button__overlay__action'><FormattedMessage id='status.media.open' defaultMessage='Click to open' /></span>
-          </span>
-        </button>
-      );
-    } else if (!visible) {
-      spoilerButton = (
-        <button type='button' onClick={this.handleOpen} className='spoiler-button__overlay'>
-          <span className='spoiler-button__overlay__label'>
-            {sensitive ? <FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' /> : <FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' />}
-            <span className='spoiler-button__overlay__action'><FormattedMessage id='status.media.show' defaultMessage='Click to show' /></span>
-          </span>
-        </button>
-      );
-    }
-
     const rowClass = (size === 5 || size === 6 || size === 9 || size === 10 || size === 11 || size === 12) ? 'media-gallery--row3' :
       (size === 7 || size === 8 || size === 13 || size === 14 || size === 15 || size === 16) ? 'media-gallery--row4' :
         'media-gallery--row2';
@@ -366,11 +354,7 @@ class MediaGallery extends PureComponent {
       <div className={classNames(classList)} style={style} ref={this.handleRef}>
         {children}
 
-        {(!visible || uncached) && (
-          <div className={classNames('spoiler-button', { 'spoiler-button--click-thru': uncached })}>
-            {spoilerButton}
-          </div>
-        )}
+        {(!visible || uncached) && <SpoilerButton uncached={uncached} sensitive={sensitive} onClick={this.handleOpen} matchedFilters={matchedFilters} />}
 
         {(visible && !uncached) && (
           <div className='media-gallery__actions'>
diff --git a/app/javascript/mastodon/components/spoiler_button.tsx b/app/javascript/mastodon/components/spoiler_button.tsx
new file mode 100644
index 0000000000..bf84ffd04d
--- /dev/null
+++ b/app/javascript/mastodon/components/spoiler_button.tsx
@@ -0,0 +1,89 @@
+import { FormattedMessage } from 'react-intl';
+
+import classNames from 'classnames';
+
+interface Props {
+  hidden?: boolean;
+  sensitive: boolean;
+  uncached?: boolean;
+  matchedFilters?: string[];
+  onClick: React.MouseEventHandler<HTMLButtonElement>;
+}
+
+export const SpoilerButton: React.FC<Props> = ({
+  hidden = false,
+  sensitive,
+  uncached = false,
+  matchedFilters,
+  onClick,
+}) => {
+  let warning;
+  let action;
+
+  if (uncached) {
+    warning = (
+      <FormattedMessage
+        id='status.uncached_media_warning'
+        defaultMessage='Preview not available'
+      />
+    );
+    action = (
+      <FormattedMessage id='status.media.open' defaultMessage='Click to open' />
+    );
+  } else if (matchedFilters) {
+    warning = (
+      <FormattedMessage
+        id='filter_warning.matches_filter'
+        defaultMessage='Matches filter “<span>{title}</span>”'
+        values={{
+          title: matchedFilters.join(', '),
+          span: (chunks) => <span className='filter-name'>{chunks}</span>,
+        }}
+      />
+    );
+    action = (
+      <FormattedMessage id='status.media.show' defaultMessage='Click to show' />
+    );
+  } else if (sensitive) {
+    warning = (
+      <FormattedMessage
+        id='status.sensitive_warning'
+        defaultMessage='Sensitive content'
+      />
+    );
+    action = (
+      <FormattedMessage id='status.media.show' defaultMessage='Click to show' />
+    );
+  } else {
+    warning = (
+      <FormattedMessage
+        id='status.media_hidden'
+        defaultMessage='Media hidden'
+      />
+    );
+    action = (
+      <FormattedMessage id='status.media.show' defaultMessage='Click to show' />
+    );
+  }
+
+  return (
+    <div
+      className={classNames('spoiler-button', {
+        'spoiler-button--hidden': hidden,
+        'spoiler-button--click-thru': uncached,
+      })}
+    >
+      <button
+        type='button'
+        className='spoiler-button__overlay'
+        onClick={onClick}
+        disabled={uncached}
+      >
+        <span className='spoiler-button__overlay__label'>
+          {warning}
+          <span className='spoiler-button__overlay__action'>{action}</span>
+        </span>
+      </button>
+    </div>
+  );
+};
diff --git a/app/javascript/mastodon/components/status.jsx b/app/javascript/mastodon/components/status.jsx
index 5c95cf46e1..0efea48f87 100644
--- a/app/javascript/mastodon/components/status.jsx
+++ b/app/javascript/mastodon/components/status.jsx
@@ -77,7 +77,7 @@ export const defaultMediaVisibility = (status) => {
     status = status.get('reblog');
   }
 
-  return (displayMedia !== 'hide_all' && !status.get('sensitive') || displayMedia === 'show_all');
+  return !status.get('matched_media_filters') && (displayMedia !== 'hide_all' && !status.get('sensitive') || displayMedia === 'show_all');
 };
 
 const messages = defineMessages({
@@ -496,6 +496,7 @@ class Status extends ImmutablePureComponent {
                 defaultWidth={this.props.cachedMediaWidth}
                 visible={this.state.showMedia}
                 onToggleVisibility={this.handleToggleMediaVisibility}
+                matchedFilters={status.get('matched_media_filters')}
               />
             )}
           </Bundle>
@@ -524,6 +525,7 @@ class Status extends ImmutablePureComponent {
                 blurhash={attachment.get('blurhash')}
                 visible={this.state.showMedia}
                 onToggleVisibility={this.handleToggleMediaVisibility}
+                matchedFilters={status.get('matched_media_filters')}
               />
             )}
           </Bundle>
@@ -548,6 +550,7 @@ class Status extends ImmutablePureComponent {
                 deployPictureInPicture={pictureInPicture.get('available') ? this.handleDeployPictureInPicture : undefined}
                 visible={this.state.showMedia}
                 onToggleVisibility={this.handleToggleMediaVisibility}
+                matchedFilters={status.get('matched_media_filters')}
               />
             )}
           </Bundle>
diff --git a/app/javascript/mastodon/features/account/components/account_note.jsx b/app/javascript/mastodon/features/account/components/account_note.jsx
index e736e7ad64..df7312eafc 100644
--- a/app/javascript/mastodon/features/account/components/account_note.jsx
+++ b/app/javascript/mastodon/features/account/components/account_note.jsx
@@ -4,7 +4,6 @@ import { PureComponent } from 'react';
 import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
 
 import { is } from 'immutable';
-import ImmutablePropTypes from 'react-immutable-proptypes';
 import ImmutablePureComponent from 'react-immutable-pure-component';
 
 import Textarea from 'react-textarea-autosize';
@@ -49,7 +48,7 @@ class InlineAlert extends PureComponent {
 class AccountNote extends ImmutablePureComponent {
 
   static propTypes = {
-    account: ImmutablePropTypes.record.isRequired,
+    accountId: PropTypes.string.isRequired,
     value: PropTypes.string,
     onSave: PropTypes.func.isRequired,
     intl: PropTypes.object.isRequired,
@@ -66,7 +65,7 @@ class AccountNote extends ImmutablePureComponent {
   }
 
   UNSAFE_componentWillReceiveProps (nextProps) {
-    const accountWillChange = !is(this.props.account, nextProps.account);
+    const accountWillChange = !is(this.props.accountId, nextProps.accountId);
     const newState = {};
 
     if (accountWillChange && this._isDirty()) {
@@ -102,10 +101,10 @@ class AccountNote extends ImmutablePureComponent {
     if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) {
       e.preventDefault();
 
-      this._save();
-
       if (this.textarea) {
         this.textarea.blur();
+      } else {
+        this._save();
       }
     } else if (e.keyCode === 27) {
       e.preventDefault();
@@ -141,21 +140,21 @@ class AccountNote extends ImmutablePureComponent {
   }
 
   render () {
-    const { account, intl } = this.props;
+    const { accountId, intl } = this.props;
     const { value, saved } = this.state;
 
-    if (!account) {
+    if (!accountId) {
       return null;
     }
 
     return (
       <div className='account__header__account-note'>
-        <label htmlFor={`account-note-${account.get('id')}`}>
+        <label htmlFor={`account-note-${accountId}`}>
           <FormattedMessage id='account.account_note_header' defaultMessage='Personal note' /> <InlineAlert show={saved} />
         </label>
 
         <Textarea
-          id={`account-note-${account.get('id')}`}
+          id={`account-note-${accountId}`}
           className='account__header__account-note__content'
           disabled={this.props.value === null || value === null}
           placeholder={intl.formatMessage(messages.placeholder)}
diff --git a/app/javascript/mastodon/features/account/containers/account_note_container.js b/app/javascript/mastodon/features/account/containers/account_note_container.js
index 1530242d69..77de964039 100644
--- a/app/javascript/mastodon/features/account/containers/account_note_container.js
+++ b/app/javascript/mastodon/features/account/containers/account_note_container.js
@@ -4,14 +4,14 @@ import { submitAccountNote } from 'mastodon/actions/account_notes';
 
 import AccountNote from '../components/account_note';
 
-const mapStateToProps = (state, { account }) => ({
-  value: account.getIn(['relationship', 'note']),
+const mapStateToProps = (state, { accountId }) => ({
+  value: state.relationships.getIn([accountId, 'note']),
 });
 
-const mapDispatchToProps = (dispatch, { account }) => ({
+const mapDispatchToProps = (dispatch, { accountId }) => ({
 
   onSave (value) {
-    dispatch(submitAccountNote({ accountId: account.get('id'), note: value }));
+    dispatch(submitAccountNote({ accountId: accountId, note: value }));
   },
 
 });
diff --git a/app/javascript/mastodon/features/account_gallery/components/media_item.tsx b/app/javascript/mastodon/features/account_gallery/components/media_item.tsx
index 3de2a29b18..0d251ff99f 100644
--- a/app/javascript/mastodon/features/account_gallery/components/media_item.tsx
+++ b/app/javascript/mastodon/features/account_gallery/components/media_item.tsx
@@ -26,11 +26,16 @@ export const MediaItem: React.FC<{
       displayMedia === 'show_all',
   );
   const [loaded, setLoaded] = useState(false);
+  const [error, setError] = useState(false);
 
   const handleImageLoad = useCallback(() => {
     setLoaded(true);
   }, [setLoaded]);
 
+  const handleImageError = useCallback(() => {
+    setError(true);
+  }, [setError]);
+
   const handleMouseEnter = useCallback(
     (e: React.MouseEvent<HTMLVideoElement>) => {
       if (e.target instanceof HTMLVideoElement) {
@@ -98,6 +103,7 @@ export const MediaItem: React.FC<{
           alt={description}
           lang={lang}
           onLoad={handleImageLoad}
+          onError={handleImageError}
         />
 
         <div className='media-gallery__item__overlay media-gallery__item__overlay--corner'>
@@ -118,6 +124,7 @@ export const MediaItem: React.FC<{
         lang={lang}
         style={{ objectPosition: `${x}% ${y}%` }}
         onLoad={handleImageLoad}
+        onError={handleImageError}
       />
     );
   } else if (['video', 'gifv'].includes(type)) {
@@ -173,7 +180,11 @@ export const MediaItem: React.FC<{
   }
 
   return (
-    <div className='media-gallery__item media-gallery__item--square'>
+    <div
+      className={classNames('media-gallery__item media-gallery__item--square', {
+        'media-gallery__item--error': error,
+      })}
+    >
       <Blurhash
         hash={blurhash}
         className={classNames('media-gallery__preview', {
diff --git a/app/javascript/mastodon/features/account_timeline/components/account_header.tsx b/app/javascript/mastodon/features/account_timeline/components/account_header.tsx
index 723160c349..0d4f20795c 100644
--- a/app/javascript/mastodon/features/account_timeline/components/account_header.tsx
+++ b/app/javascript/mastodon/features/account_timeline/components/account_header.tsx
@@ -44,6 +44,7 @@ import {
   FollowingCounter,
   StatusesCounter,
 } from 'mastodon/components/counters';
+import { FormattedDateWrapper } from 'mastodon/components/formatted_date';
 import { getFeaturedHashtagBar } from 'mastodon/components/hashtag_bar';
 import { Icon } from 'mastodon/components/icon';
 import { IconButton } from 'mastodon/components/icon_button';
@@ -1020,7 +1021,7 @@ export const AccountHeader: React.FC<{
                 onClickCapture={handleLinkClick}
               >
                 {account.id !== me && signedIn && (
-                  <AccountNoteContainer account={account} />
+                  <AccountNoteContainer accountId={accountId} />
                 )}
 
                 {account.note.length > 0 && account.note !== '<p></p>' && (
@@ -1045,11 +1046,12 @@ export const AccountHeader: React.FC<{
                       />
                     </dt>
                     <dd>
-                      {intl.formatDate(account.created_at, {
-                        year: 'numeric',
-                        month: 'short',
-                        day: '2-digit',
-                      })}
+                      <FormattedDateWrapper
+                        value={account.created_at}
+                        year='numeric'
+                        month='short'
+                        day='2-digit'
+                      />
                     </dd>
                   </dl>
 
diff --git a/app/javascript/mastodon/features/audio/index.jsx b/app/javascript/mastodon/features/audio/index.jsx
index 56c0e524ad..dc48756906 100644
--- a/app/javascript/mastodon/features/audio/index.jsx
+++ b/app/javascript/mastodon/features/audio/index.jsx
@@ -1,7 +1,7 @@
 import PropTypes from 'prop-types';
 import { PureComponent } from 'react';
 
-import { defineMessages, FormattedMessage, injectIntl } from 'react-intl';
+import { defineMessages, injectIntl } from 'react-intl';
 
 import classNames from 'classnames';
 
@@ -16,6 +16,7 @@ import VisibilityOffIcon from '@/material-icons/400-24px/visibility_off.svg?reac
 import VolumeOffIcon from '@/material-icons/400-24px/volume_off-fill.svg?react';
 import VolumeUpIcon from '@/material-icons/400-24px/volume_up-fill.svg?react';
 import { Icon }  from 'mastodon/components/icon';
+import { SpoilerButton } from 'mastodon/components/spoiler_button';
 import { formatTime, getPointerPosition, fileNameFromURL } from 'mastodon/features/video';
 
 import { Blurhash } from '../../components/blurhash';
@@ -61,6 +62,7 @@ class Audio extends PureComponent {
     volume: PropTypes.number,
     muted: PropTypes.bool,
     deployPictureInPicture: PropTypes.func,
+    matchedFilters: PropTypes.arrayOf(PropTypes.string),
   };
 
   state = {
@@ -471,19 +473,11 @@ class Audio extends PureComponent {
   };
 
   render () {
-    const { src, intl, alt, lang, editable, autoPlay, sensitive, blurhash } = this.props;
+    const { src, intl, alt, lang, editable, autoPlay, sensitive, blurhash, matchedFilters } = this.props;
     const { paused, volume, currentTime, duration, buffer, dragging, revealed } = this.state;
     const progress = Math.min((currentTime / duration) * 100, 100);
     const muted = this.state.muted || volume === 0;
 
-    let warning;
-
-    if (sensitive) {
-      warning = <FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' />;
-    } else {
-      warning = <FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' />;
-    }
-
     return (
       <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}>
 
@@ -521,14 +515,7 @@ class Audio extends PureComponent {
           lang={lang}
         />
 
-        <div className={classNames('spoiler-button', { 'spoiler-button--hidden': revealed || editable })}>
-          <button type='button' className='spoiler-button__overlay' onClick={this.toggleReveal}>
-            <span className='spoiler-button__overlay__label'>
-              {warning}
-              <span className='spoiler-button__overlay__action'><FormattedMessage id='status.media.show' defaultMessage='Click to show' /></span>
-            </span>
-          </button>
-        </div>
+        <SpoilerButton hidden={revealed || editable} sensitive={sensitive} onClick={this.toggleReveal} matchedFilters={matchedFilters} />
 
         {(revealed || editable) && <img
           src={this.props.poster}
diff --git a/app/javascript/mastodon/features/bookmarked_statuses/index.jsx b/app/javascript/mastodon/features/bookmarked_statuses/index.jsx
index 93be1c6b2e..284342d4ee 100644
--- a/app/javascript/mastodon/features/bookmarked_statuses/index.jsx
+++ b/app/javascript/mastodon/features/bookmarked_statuses/index.jsx
@@ -101,6 +101,7 @@ class Bookmarks extends ImmutablePureComponent {
           onLoadMore={this.handleLoadMore}
           emptyMessage={emptyMessage}
           bindToDocument={!multiColumn}
+          timelineId='bookmarks'
         />
 
         <Helmet>
diff --git a/app/javascript/mastodon/features/compose/components/language_dropdown.tsx b/app/javascript/mastodon/features/compose/components/language_dropdown.tsx
index 21c4359981..d11891308f 100644
--- a/app/javascript/mastodon/features/compose/components/language_dropdown.tsx
+++ b/app/javascript/mastodon/features/compose/components/language_dropdown.tsx
@@ -378,6 +378,7 @@ export const LanguageDropdown: React.FC = () => {
     if (text.length > 20) {
       debouncedGuess(text, setGuess);
     } else {
+      debouncedGuess.cancel();
       setGuess('');
     }
   }, [text, setGuess]);
diff --git a/app/javascript/mastodon/features/favourited_statuses/index.jsx b/app/javascript/mastodon/features/favourited_statuses/index.jsx
index f7d6d14178..9049a20f05 100644
--- a/app/javascript/mastodon/features/favourited_statuses/index.jsx
+++ b/app/javascript/mastodon/features/favourited_statuses/index.jsx
@@ -101,6 +101,7 @@ class Favourites extends ImmutablePureComponent {
           onLoadMore={this.handleLoadMore}
           emptyMessage={emptyMessage}
           bindToDocument={!multiColumn}
+          timelineId='favourites'
         />
 
         <Helmet>
diff --git a/app/javascript/mastodon/features/privacy_policy/index.tsx b/app/javascript/mastodon/features/privacy_policy/index.tsx
index f0309c2712..cd6f9f3b2b 100644
--- a/app/javascript/mastodon/features/privacy_policy/index.tsx
+++ b/app/javascript/mastodon/features/privacy_policy/index.tsx
@@ -1,17 +1,13 @@
 import { useState, useEffect } from 'react';
 
-import {
-  FormattedMessage,
-  FormattedDate,
-  useIntl,
-  defineMessages,
-} from 'react-intl';
+import { FormattedMessage, useIntl, defineMessages } from 'react-intl';
 
 import { Helmet } from 'react-helmet';
 
 import { apiGetPrivacyPolicy } from 'mastodon/api/instance';
 import type { ApiPrivacyPolicyJSON } from 'mastodon/api_types/instance';
 import { Column } from 'mastodon/components/column';
+import { FormattedDateWrapper } from 'mastodon/components/formatted_date';
 import { Skeleton } from 'mastodon/components/skeleton';
 
 const messages = defineMessages({
@@ -58,7 +54,7 @@ const PrivacyPolicy: React.FC<{
                 date: loading ? (
                   <Skeleton width='10ch' />
                 ) : (
-                  <FormattedDate
+                  <FormattedDateWrapper
                     value={response?.updated_at}
                     year='numeric'
                     month='short'
diff --git a/app/javascript/mastodon/features/standalone/compose/index.jsx b/app/javascript/mastodon/features/standalone/compose/index.jsx
index 241d9aadfd..3aff78ffee 100644
--- a/app/javascript/mastodon/features/standalone/compose/index.jsx
+++ b/app/javascript/mastodon/features/standalone/compose/index.jsx
@@ -1,12 +1,12 @@
+import { AlertsController } from 'mastodon/components/alerts_controller';
 import ComposeFormContainer from 'mastodon/features/compose/containers/compose_form_container';
 import LoadingBarContainer from 'mastodon/features/ui/containers/loading_bar_container';
 import ModalContainer from 'mastodon/features/ui/containers/modal_container';
-import NotificationsContainer from 'mastodon/features/ui/containers/notifications_container';
 
 const Compose = () => (
   <>
     <ComposeFormContainer autoFocus withoutNavigation />
-    <NotificationsContainer />
+    <AlertsController />
     <ModalContainer />
     <LoadingBarContainer className='loading-bar' />
   </>
diff --git a/app/javascript/mastodon/features/status/components/detailed_status.tsx b/app/javascript/mastodon/features/status/components/detailed_status.tsx
index c8f082156e..a113d71d99 100644
--- a/app/javascript/mastodon/features/status/components/detailed_status.tsx
+++ b/app/javascript/mastodon/features/status/components/detailed_status.tsx
@@ -6,7 +6,7 @@
 import type { CSSProperties } from 'react';
 import { useState, useRef, useCallback } from 'react';
 
-import { FormattedDate, FormattedMessage } from 'react-intl';
+import { FormattedMessage } from 'react-intl';
 
 import classNames from 'classnames';
 import { Link } from 'react-router-dom';
@@ -15,6 +15,8 @@ import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?re
 import { AnimatedNumber } from 'mastodon/components/animated_number';
 import { ContentWarning } from 'mastodon/components/content_warning';
 import EditedTimestamp from 'mastodon/components/edited_timestamp';
+import { FilterWarning } from 'mastodon/components/filter_warning';
+import { FormattedDateWrapper } from 'mastodon/components/formatted_date';
 import type { StatusLike } from 'mastodon/components/hashtag_bar';
 import { getHashtagBarForStatus } from 'mastodon/components/hashtag_bar';
 import { Icon } from 'mastodon/components/icon';
@@ -80,6 +82,7 @@ export const DetailedStatus: React.FC<{
 }) => {
   const properStatus = status?.get('reblog') ?? status;
   const [height, setHeight] = useState(0);
+  const [showDespiteFilter, setShowDespiteFilter] = useState(false);
   const nodeRef = useRef<HTMLDivElement>();
 
   const handleOpenVideo = useCallback(
@@ -92,6 +95,10 @@ export const DetailedStatus: React.FC<{
     [onOpenVideo, status],
   );
 
+  const handleFilterToggle = useCallback(() => {
+    setShowDespiteFilter(!showDespiteFilter);
+  }, [showDespiteFilter, setShowDespiteFilter]);
+
   const handleExpandedToggle = useCallback(() => {
     if (onToggleHidden) onToggleHidden(status);
   }, [onToggleHidden, status]);
@@ -180,6 +187,7 @@ export const DetailedStatus: React.FC<{
           onOpenMedia={onOpenMedia}
           visible={showMedia}
           onToggleVisibility={onToggleMediaVisibility}
+          matchedFilters={status.get('matched_media_filters')}
         />
       );
     } else if (status.getIn(['media_attachments', 0, 'type']) === 'audio') {
@@ -206,6 +214,7 @@ export const DetailedStatus: React.FC<{
           blurhash={attachment.get('blurhash')}
           height={150}
           onToggleVisibility={onToggleMediaVisibility}
+          matchedFilters={status.get('matched_media_filters')}
         />
       );
     } else if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
@@ -229,6 +238,7 @@ export const DetailedStatus: React.FC<{
           sensitive={status.get('sensitive')}
           visible={showMedia}
           onToggleVisibility={onToggleMediaVisibility}
+          matchedFilters={status.get('matched_media_filters')}
         />
       );
     }
@@ -369,8 +379,12 @@ export const DetailedStatus: React.FC<{
   const { statusContentProps, hashtagBar } = getHashtagBarForStatus(
     status as StatusLike,
   );
+
+  const matchedFilters = status.get('matched_filters');
+
   const expanded =
-    !status.get('hidden') || status.get('spoiler_text').length === 0;
+    (!matchedFilters || showDespiteFilter) &&
+    (!status.get('hidden') || status.get('spoiler_text').length === 0);
 
   const quote = !muted && status.get('quote_id') && (
     <>
@@ -418,17 +432,26 @@ export const DetailedStatus: React.FC<{
           )}
         </Link>
 
-        {status.get('spoiler_text').length > 0 && (
-          <ContentWarning
-            text={
-              status.getIn(['translation', 'spoilerHtml']) ||
-              status.get('spoilerHtml')
-            }
-            expanded={expanded}
-            onClick={handleExpandedToggle}
+        {matchedFilters && (
+          <FilterWarning
+            title={matchedFilters.join(', ')}
+            expanded={showDespiteFilter}
+            onClick={handleFilterToggle}
           />
         )}
 
+        {status.get('spoiler_text').length > 0 &&
+          (!matchedFilters || showDespiteFilter) && (
+            <ContentWarning
+              text={
+                status.getIn(['translation', 'spoilerHtml']) ||
+                status.get('spoilerHtml')
+              }
+              expanded={expanded}
+              onClick={handleExpandedToggle}
+            />
+          )}
+
         {expanded && (
           <>
             <StatusContent
@@ -452,7 +475,7 @@ export const DetailedStatus: React.FC<{
               target='_blank'
               rel='noopener noreferrer'
             >
-              <FormattedDate
+              <FormattedDateWrapper
                 value={new Date(status.get('created_at') as string)}
                 year='numeric'
                 month='short'
diff --git a/app/javascript/mastodon/features/status/index.jsx b/app/javascript/mastodon/features/status/index.jsx
index 08a2a74e88..e9d0c9f8d6 100644
--- a/app/javascript/mastodon/features/status/index.jsx
+++ b/app/javascript/mastodon/features/status/index.jsx
@@ -147,7 +147,7 @@ const makeMapStateToProps = () => {
   });
 
   const mapStateToProps = (state, props) => {
-    const status = getStatus(state, { id: props.params.statusId });
+    const status = getStatus(state, { id: props.params.statusId, contextType: 'detailed' });
 
     let ancestorsIds   = ImmutableList();
     let descendantsIds = ImmutableList();
diff --git a/app/javascript/mastodon/features/ui/components/image_loader.jsx b/app/javascript/mastodon/features/ui/components/image_loader.jsx
deleted file mode 100644
index b1417deda7..0000000000
--- a/app/javascript/mastodon/features/ui/components/image_loader.jsx
+++ /dev/null
@@ -1,175 +0,0 @@
-import PropTypes from 'prop-types';
-import { PureComponent } from 'react';
-
-import classNames from 'classnames';
-
-import { LoadingBar } from 'react-redux-loading-bar';
-
-import ZoomableImage from './zoomable_image';
-
-export default class ImageLoader extends PureComponent {
-
-  static propTypes = {
-    alt: PropTypes.string,
-    lang: PropTypes.string,
-    src: PropTypes.string.isRequired,
-    previewSrc: PropTypes.string,
-    width: PropTypes.number,
-    height: PropTypes.number,
-    onClick: PropTypes.func,
-    zoomedIn: PropTypes.bool,
-  };
-
-  static defaultProps = {
-    alt: '',
-    lang: '',
-    width: null,
-    height: null,
-  };
-
-  state = {
-    loading: true,
-    error: false,
-    width: null,
-  };
-
-  removers = [];
-  canvas = null;
-
-  get canvasContext() {
-    if (!this.canvas) {
-      return null;
-    }
-    this._canvasContext = this._canvasContext || this.canvas.getContext('2d');
-    return this._canvasContext;
-  }
-
-  componentDidMount () {
-    this.loadImage(this.props);
-  }
-
-  UNSAFE_componentWillReceiveProps (nextProps) {
-    if (this.props.src !== nextProps.src) {
-      this.loadImage(nextProps);
-    }
-  }
-
-  componentWillUnmount () {
-    this.removeEventListeners();
-  }
-
-  loadImage (props) {
-    this.removeEventListeners();
-    this.setState({ loading: true, error: false });
-    Promise.all([
-      props.previewSrc && this.loadPreviewCanvas(props),
-      this.hasSize() && this.loadOriginalImage(props),
-    ].filter(Boolean))
-      .then(() => {
-        this.setState({ loading: false, error: false });
-        this.clearPreviewCanvas();
-      })
-      .catch(() => this.setState({ loading: false, error: true }));
-  }
-
-  loadPreviewCanvas = ({ previewSrc, width, height }) => new Promise((resolve, reject) => {
-    const image = new Image();
-    const removeEventListeners = () => {
-      image.removeEventListener('error', handleError);
-      image.removeEventListener('load', handleLoad);
-    };
-    const handleError = () => {
-      removeEventListeners();
-      reject();
-    };
-    const handleLoad = () => {
-      removeEventListeners();
-      this.canvasContext.drawImage(image, 0, 0, width, height);
-      resolve();
-    };
-    image.addEventListener('error', handleError);
-    image.addEventListener('load', handleLoad);
-    image.src = previewSrc;
-    this.removers.push(removeEventListeners);
-  });
-
-  clearPreviewCanvas () {
-    const { width, height } = this.canvas;
-    this.canvasContext.clearRect(0, 0, width, height);
-  }
-
-  loadOriginalImage = ({ src }) => new Promise((resolve, reject) => {
-    const image = new Image();
-    const removeEventListeners = () => {
-      image.removeEventListener('error', handleError);
-      image.removeEventListener('load', handleLoad);
-    };
-    const handleError = () => {
-      removeEventListeners();
-      reject();
-    };
-    const handleLoad = () => {
-      removeEventListeners();
-      resolve();
-    };
-    image.addEventListener('error', handleError);
-    image.addEventListener('load', handleLoad);
-    image.src = src;
-    this.removers.push(removeEventListeners);
-  });
-
-  removeEventListeners () {
-    this.removers.forEach(listeners => listeners());
-    this.removers = [];
-  }
-
-  hasSize () {
-    const { width, height } = this.props;
-    return typeof width === 'number' && typeof height === 'number';
-  }
-
-  setCanvasRef = c => {
-    this.canvas = c;
-    if (c) this.setState({ width: c.offsetWidth });
-  };
-
-  render () {
-    const { alt, lang, src, width, height, onClick, zoomedIn } = this.props;
-    const { loading } = this.state;
-
-    const className = classNames('image-loader', {
-      'image-loader--loading': loading,
-      'image-loader--amorphous': !this.hasSize(),
-    });
-
-    return (
-      <div className={className}>
-        {loading ? (
-          <>
-            <div className='loading-bar__container' style={{ width: this.state.width || width }}>
-              <LoadingBar className='loading-bar' loading={1} />
-            </div>
-
-            <canvas
-              className='image-loader__preview-canvas'
-              ref={this.setCanvasRef}
-              width={width}
-              height={height}
-            />
-          </>
-        ) : (
-          <ZoomableImage
-            alt={alt}
-            lang={lang}
-            src={src}
-            onClick={onClick}
-            width={width}
-            height={height}
-            zoomedIn={zoomedIn}
-          />
-        )}
-      </div>
-    );
-  }
-
-}
diff --git a/app/javascript/mastodon/features/ui/components/image_modal.jsx b/app/javascript/mastodon/features/ui/components/image_modal.jsx
deleted file mode 100644
index f08ce15342..0000000000
--- a/app/javascript/mastodon/features/ui/components/image_modal.jsx
+++ /dev/null
@@ -1,65 +0,0 @@
-import PropTypes from 'prop-types';
-import { PureComponent } from 'react';
-
-import { defineMessages, injectIntl } from 'react-intl';
-
-import classNames from 'classnames';
-
-import CloseIcon from '@/material-icons/400-24px/close.svg?react';
-import { IconButton } from 'mastodon/components/icon_button';
-
-import ImageLoader from './image_loader';
-
-const messages = defineMessages({
-  close: { id: 'lightbox.close', defaultMessage: 'Close' },
-});
-
-class ImageModal extends PureComponent {
-
-  static propTypes = {
-    src: PropTypes.string.isRequired,
-    alt: PropTypes.string.isRequired,
-    onClose: PropTypes.func.isRequired,
-    intl: PropTypes.object.isRequired,
-  };
-
-  state = {
-    navigationHidden: false,
-  };
-
-  toggleNavigation = () => {
-    this.setState(prevState => ({
-      navigationHidden: !prevState.navigationHidden,
-    }));
-  };
-
-  render () {
-    const { intl, src, alt, onClose } = this.props;
-    const { navigationHidden } = this.state;
-
-    const navigationClassName = classNames('media-modal__navigation', {
-      'media-modal__navigation--hidden': navigationHidden,
-    });
-
-    return (
-      <div className='modal-root__modal media-modal'>
-        <div className='media-modal__closer' role='presentation' onClick={onClose} >
-          <ImageLoader
-            src={src}
-            width={400}
-            height={400}
-            alt={alt}
-            onClick={this.toggleNavigation}
-          />
-        </div>
-
-        <div className={navigationClassName}>
-          <IconButton className='media-modal__close' title={intl.formatMessage(messages.close)} icon='times' iconComponent={CloseIcon} onClick={onClose} size={40} />
-        </div>
-      </div>
-    );
-  }
-
-}
-
-export default injectIntl(ImageModal);
diff --git a/app/javascript/mastodon/features/ui/components/image_modal.tsx b/app/javascript/mastodon/features/ui/components/image_modal.tsx
new file mode 100644
index 0000000000..fa94cfcc3c
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/components/image_modal.tsx
@@ -0,0 +1,61 @@
+import { useCallback, useState } from 'react';
+
+import { defineMessages, useIntl } from 'react-intl';
+
+import classNames from 'classnames';
+
+import CloseIcon from '@/material-icons/400-24px/close.svg?react';
+import { IconButton } from 'mastodon/components/icon_button';
+
+import { ZoomableImage } from './zoomable_image';
+
+const messages = defineMessages({
+  close: { id: 'lightbox.close', defaultMessage: 'Close' },
+});
+
+export const ImageModal: React.FC<{
+  src: string;
+  alt: string;
+  onClose: () => void;
+}> = ({ src, alt, onClose }) => {
+  const intl = useIntl();
+  const [navigationHidden, setNavigationHidden] = useState(false);
+
+  const toggleNavigation = useCallback(() => {
+    setNavigationHidden((prevState) => !prevState);
+  }, [setNavigationHidden]);
+
+  const navigationClassName = classNames('media-modal__navigation', {
+    'media-modal__navigation--hidden': navigationHidden,
+  });
+
+  return (
+    <div className='modal-root__modal media-modal'>
+      <div
+        className='media-modal__closer'
+        role='presentation'
+        onClick={onClose}
+      >
+        <ZoomableImage
+          src={src}
+          width={400}
+          height={400}
+          alt={alt}
+          onClick={toggleNavigation}
+        />
+      </div>
+
+      <div className={navigationClassName}>
+        <div className='media-modal__buttons'>
+          <IconButton
+            className='media-modal__close'
+            title={intl.formatMessage(messages.close)}
+            icon='times'
+            iconComponent={CloseIcon}
+            onClick={onClose}
+          />
+        </div>
+      </div>
+    </div>
+  );
+};
diff --git a/app/javascript/mastodon/features/ui/components/media_modal.jsx b/app/javascript/mastodon/features/ui/components/media_modal.jsx
index d69ceba539..9312805b5c 100644
--- a/app/javascript/mastodon/features/ui/components/media_modal.jsx
+++ b/app/javascript/mastodon/features/ui/components/media_modal.jsx
@@ -22,7 +22,7 @@ import Footer from 'mastodon/features/picture_in_picture/components/footer';
 import Video from 'mastodon/features/video';
 import { disableSwiping } from 'mastodon/initial_state';
 
-import ImageLoader from './image_loader';
+import { ZoomableImage } from './zoomable_image';
 
 const messages = defineMessages({
   close: { id: 'lightbox.close', defaultMessage: 'Close' },
@@ -59,6 +59,12 @@ class MediaModal extends ImmutablePureComponent {
     }));
   };
 
+  handleZoomChange = (zoomedIn) => {
+    this.setState({
+      zoomedIn,
+    });
+  };
+
   handleSwipe = (index) => {
     this.setState({
       index: index % this.props.media.size,
@@ -165,23 +171,26 @@ class MediaModal extends ImmutablePureComponent {
     const leftNav  = media.size > 1 && <button tabIndex={0} className='media-modal__nav media-modal__nav--left' onClick={this.handlePrevClick} aria-label={intl.formatMessage(messages.previous)}><Icon id='chevron-left' icon={ChevronLeftIcon} /></button>;
     const rightNav = media.size > 1 && <button tabIndex={0} className='media-modal__nav  media-modal__nav--right' onClick={this.handleNextClick} aria-label={intl.formatMessage(messages.next)}><Icon id='chevron-right' icon={ChevronRightIcon} /></button>;
 
-    const content = media.map((image) => {
+    const content = media.map((image, idx) => {
       const width  = image.getIn(['meta', 'original', 'width']) || null;
       const height = image.getIn(['meta', 'original', 'height']) || null;
       const description = image.getIn(['translation', 'description']) || image.get('description');
 
       if (image.get('type') === 'image') {
         return (
-          <ImageLoader
-            previewSrc={image.get('preview_url')}
+          <ZoomableImage
             src={image.get('url')}
+            blurhash={image.get('blurhash')}
             width={width}
             height={height}
             alt={description}
             lang={lang}
             key={image.get('url')}
             onClick={this.handleToggleNavigation}
-            zoomedIn={zoomedIn}
+            onDoubleClick={this.handleZoomClick}
+            onClose={onClose}
+            onZoomChange={this.handleZoomChange}
+            zoomedIn={zoomedIn && idx === index}
           />
         );
       } else if (image.get('type') === 'video') {
@@ -262,7 +271,7 @@ class MediaModal extends ImmutablePureComponent {
             onChangeIndex={this.handleSwipe}
             onTransitionEnd={this.handleTransitionEnd}
             index={index}
-            disabled={disableSwiping}
+            disabled={disableSwiping || zoomedIn}
           >
             {content}
           </ReactSwipeableViews>
diff --git a/app/javascript/mastodon/features/ui/components/modal_root.jsx b/app/javascript/mastodon/features/ui/components/modal_root.jsx
index 5b2c2fc8d7..4dac46681d 100644
--- a/app/javascript/mastodon/features/ui/components/modal_root.jsx
+++ b/app/javascript/mastodon/features/ui/components/modal_root.jsx
@@ -45,7 +45,7 @@ import {
   ConfirmFollowToListModal,
   ConfirmMissingAltTextModal,
 } from './confirmation_modals';
-import ImageModal from './image_modal';
+import { ImageModal } from './image_modal';
 import MediaModal from './media_modal';
 import { ModalPlaceholder } from './modal_placeholder';
 import VideoModal from './video_modal';
diff --git a/app/javascript/mastodon/features/ui/components/zoomable_image.jsx b/app/javascript/mastodon/features/ui/components/zoomable_image.jsx
deleted file mode 100644
index c4129bf260..0000000000
--- a/app/javascript/mastodon/features/ui/components/zoomable_image.jsx
+++ /dev/null
@@ -1,402 +0,0 @@
-import PropTypes from 'prop-types';
-import { PureComponent } from 'react';
-
-const MIN_SCALE = 1;
-const MAX_SCALE = 4;
-const NAV_BAR_HEIGHT = 66;
-
-const getMidpoint = (p1, p2) => ({
-  x: (p1.clientX + p2.clientX) / 2,
-  y: (p1.clientY + p2.clientY) / 2,
-});
-
-const getDistance = (p1, p2) =>
-  Math.sqrt(Math.pow(p1.clientX - p2.clientX, 2) + Math.pow(p1.clientY - p2.clientY, 2));
-
-const clamp = (min, max, value) => Math.min(max, Math.max(min, value));
-
-// Normalizing mousewheel speed across browsers
-// copy from: https://github.com/facebookarchive/fixed-data-table/blob/master/src/vendor_upstream/dom/normalizeWheel.js
-const normalizeWheel = event => {
-  // Reasonable defaults
-  const PIXEL_STEP = 10;
-  const LINE_HEIGHT = 40;
-  const PAGE_HEIGHT = 800;
-
-  let sX = 0,
-    sY = 0, // spinX, spinY
-    pX = 0,
-    pY = 0; // pixelX, pixelY
-
-  // Legacy
-  if ('detail' in event) {
-    sY = event.detail;
-  }
-  if ('wheelDelta' in event) {
-    sY = -event.wheelDelta / 120;
-  }
-  if ('wheelDeltaY' in event) {
-    sY = -event.wheelDeltaY / 120;
-  }
-  if ('wheelDeltaX' in event) {
-    sX = -event.wheelDeltaX / 120;
-  }
-
-  // side scrolling on FF with DOMMouseScroll
-  if ('axis' in event && event.axis === event.HORIZONTAL_AXIS) {
-    sX = sY;
-    sY = 0;
-  }
-
-  pX = sX * PIXEL_STEP;
-  pY = sY * PIXEL_STEP;
-
-  if ('deltaY' in event) {
-    pY = event.deltaY;
-  }
-  if ('deltaX' in event) {
-    pX = event.deltaX;
-  }
-
-  if ((pX || pY) && event.deltaMode) {
-    if (event.deltaMode === 1) { // delta in LINE units
-      pX *= LINE_HEIGHT;
-      pY *= LINE_HEIGHT;
-    } else { // delta in PAGE units
-      pX *= PAGE_HEIGHT;
-      pY *= PAGE_HEIGHT;
-    }
-  }
-
-  // Fall-back if spin cannot be determined
-  if (pX && !sX) {
-    sX = (pX < 1) ? -1 : 1;
-  }
-  if (pY && !sY) {
-    sY = (pY < 1) ? -1 : 1;
-  }
-
-  return {
-    spinX: sX,
-    spinY: sY,
-    pixelX: pX,
-    pixelY: pY,
-  };
-};
-
-class ZoomableImage extends PureComponent {
-
-  static propTypes = {
-    alt: PropTypes.string,
-    lang: PropTypes.string,
-    src: PropTypes.string.isRequired,
-    width: PropTypes.number,
-    height: PropTypes.number,
-    onClick: PropTypes.func,
-    zoomedIn: PropTypes.bool,
-  };
-
-  static defaultProps = {
-    alt: '',
-    lang: '',
-    width: null,
-    height: null,
-  };
-
-  state = {
-    scale: MIN_SCALE,
-    zoomMatrix: {
-      type: null, // 'width' 'height'
-      fullScreen: null, // bool
-      rate: null, // full screen scale rate
-      clientWidth: null,
-      clientHeight: null,
-      offsetWidth: null,
-      offsetHeight: null,
-      clientHeightFixed: null,
-      scrollTop: null,
-      scrollLeft: null,
-      translateX: null,
-      translateY: null,
-    },
-    dragPosition: { top: 0, left: 0, x: 0, y: 0 },
-    dragged: false,
-    lockScroll: { x: 0, y: 0 },
-    lockTranslate: { x: 0, y: 0 },
-  };
-
-  removers = [];
-  container = null;
-  image = null;
-  lastTouchEndTime = 0;
-  lastDistance = 0;
-
-  componentDidMount () {
-    let handler = this.handleTouchStart;
-    this.container.addEventListener('touchstart', handler);
-    this.removers.push(() => this.container.removeEventListener('touchstart', handler));
-    handler = this.handleTouchMove;
-    // on Chrome 56+, touch event listeners will default to passive
-    // https://www.chromestatus.com/features/5093566007214080
-    this.container.addEventListener('touchmove', handler, { passive: false });
-    this.removers.push(() => this.container.removeEventListener('touchend', handler));
-
-    handler = this.mouseDownHandler;
-    this.container.addEventListener('mousedown', handler);
-    this.removers.push(() => this.container.removeEventListener('mousedown', handler));
-
-    handler = this.mouseWheelHandler;
-    this.container.addEventListener('wheel', handler);
-    this.removers.push(() => this.container.removeEventListener('wheel', handler));
-    // Old Chrome
-    this.container.addEventListener('mousewheel', handler);
-    this.removers.push(() => this.container.removeEventListener('mousewheel', handler));
-    // Old Firefox
-    this.container.addEventListener('DOMMouseScroll', handler);
-    this.removers.push(() => this.container.removeEventListener('DOMMouseScroll', handler));
-
-    this._initZoomMatrix();
-  }
-
-  componentWillUnmount () {
-    this._removeEventListeners();
-  }
-
-  componentDidUpdate (prevProps) {
-    if (prevProps.zoomedIn !== this.props.zoomedIn) {
-      this._toggleZoom();
-    }
-  }
-
-  _removeEventListeners () {
-    this.removers.forEach(listeners => listeners());
-    this.removers = [];
-  }
-
-  mouseWheelHandler = e => {
-    e.preventDefault();
-
-    const event = normalizeWheel(e);
-
-    if (this.state.zoomMatrix.type === 'width') {
-      // full width, scroll vertical
-      this.container.scrollTop = Math.max(this.container.scrollTop + event.pixelY, this.state.lockScroll.y);
-    } else {
-      // full height, scroll horizontal
-      this.container.scrollLeft = Math.max(this.container.scrollLeft + event.pixelY, this.state.lockScroll.x);
-    }
-
-    // lock horizontal scroll
-    this.container.scrollLeft = Math.max(this.container.scrollLeft + event.pixelX, this.state.lockScroll.x);
-  };
-
-  mouseDownHandler = e => {
-    this.setState({ dragPosition: {
-      left: this.container.scrollLeft,
-      top: this.container.scrollTop,
-      // Get the current mouse position
-      x: e.clientX,
-      y: e.clientY,
-    } });
-
-    this.image.addEventListener('mousemove', this.mouseMoveHandler);
-    this.image.addEventListener('mouseup', this.mouseUpHandler);
-  };
-
-  mouseMoveHandler = e => {
-    const dx = e.clientX - this.state.dragPosition.x;
-    const dy = e.clientY - this.state.dragPosition.y;
-
-    this.container.scrollLeft = Math.max(this.state.dragPosition.left - dx, this.state.lockScroll.x);
-    this.container.scrollTop = Math.max(this.state.dragPosition.top - dy, this.state.lockScroll.y);
-
-    this.setState({ dragged: true });
-  };
-
-  mouseUpHandler = () => {
-    this.image.removeEventListener('mousemove', this.mouseMoveHandler);
-    this.image.removeEventListener('mouseup', this.mouseUpHandler);
-  };
-
-  handleTouchStart = e => {
-    if (e.touches.length !== 2) return;
-
-    this.lastDistance = getDistance(...e.touches);
-  };
-
-  handleTouchMove = e => {
-    const { scrollTop, scrollHeight, clientHeight } = this.container;
-    if (e.touches.length === 1 && scrollTop !== scrollHeight - clientHeight) {
-      // prevent propagating event to MediaModal
-      e.stopPropagation();
-      return;
-    }
-    if (e.touches.length !== 2) return;
-
-    e.preventDefault();
-    e.stopPropagation();
-
-    const distance = getDistance(...e.touches);
-    const midpoint = getMidpoint(...e.touches);
-    const _MAX_SCALE = Math.max(MAX_SCALE, this.state.zoomMatrix.rate);
-    const scale = clamp(MIN_SCALE, _MAX_SCALE, this.state.scale * distance / this.lastDistance);
-
-    this._zoom(scale, midpoint);
-
-    this.lastMidpoint = midpoint;
-    this.lastDistance = distance;
-  };
-
-  _zoom(nextScale, midpoint) {
-    const { scale, zoomMatrix } = this.state;
-    const { scrollLeft, scrollTop } = this.container;
-
-    // math memo:
-    // x = (scrollLeft + midpoint.x) / scrollWidth
-    // x' = (nextScrollLeft + midpoint.x) / nextScrollWidth
-    // scrollWidth = clientWidth * scale
-    // scrollWidth' = clientWidth * nextScale
-    // Solve x = x' for nextScrollLeft
-    const nextScrollLeft = (scrollLeft + midpoint.x) * nextScale / scale - midpoint.x;
-    const nextScrollTop = (scrollTop + midpoint.y) * nextScale / scale - midpoint.y;
-
-    this.setState({ scale: nextScale }, () => {
-      this.container.scrollLeft = nextScrollLeft;
-      this.container.scrollTop = nextScrollTop;
-      // reset the translateX/Y constantly
-      if (nextScale < zoomMatrix.rate) {
-        this.setState({
-          lockTranslate: {
-            x: zoomMatrix.fullScreen ? 0 : zoomMatrix.translateX * ((nextScale - MIN_SCALE) / (zoomMatrix.rate - MIN_SCALE)),
-            y: zoomMatrix.fullScreen ? 0 : zoomMatrix.translateY * ((nextScale - MIN_SCALE) / (zoomMatrix.rate - MIN_SCALE)),
-          },
-        });
-      }
-    });
-  }
-
-  handleClick = e => {
-    // don't propagate event to MediaModal
-    e.stopPropagation();
-    const dragged = this.state.dragged;
-    this.setState({ dragged: false });
-    if (dragged) return;
-    const handler = this.props.onClick;
-    if (handler) handler();
-  };
-
-  handleMouseDown = e => {
-    e.preventDefault();
-  };
-
-  _initZoomMatrix = () => {
-    const { width, height } = this.props;
-    const { clientWidth, clientHeight } = this.container;
-    const { offsetWidth, offsetHeight } = this.image;
-    const clientHeightFixed = clientHeight - NAV_BAR_HEIGHT;
-
-    const type = width / height < clientWidth / clientHeightFixed ? 'width' : 'height';
-    const fullScreen = type === 'width' ?  width > clientWidth : height > clientHeightFixed;
-    const rate = type === 'width' ? Math.min(clientWidth, width) / offsetWidth : Math.min(clientHeightFixed, height) / offsetHeight;
-    const scrollTop = type === 'width' ?  (clientHeight - offsetHeight) / 2 - NAV_BAR_HEIGHT : (clientHeightFixed - offsetHeight) / 2;
-    const scrollLeft = (clientWidth - offsetWidth) / 2;
-    const translateX = type === 'width' ? (width - offsetWidth) / (2 * rate) : 0;
-    const translateY = type === 'height' ? (height - offsetHeight) / (2 * rate) : 0;
-
-    this.setState({
-      zoomMatrix: {
-        type: type,
-        fullScreen: fullScreen,
-        rate: rate,
-        clientWidth: clientWidth,
-        clientHeight: clientHeight,
-        offsetWidth: offsetWidth,
-        offsetHeight: offsetHeight,
-        clientHeightFixed: clientHeightFixed,
-        scrollTop: scrollTop,
-        scrollLeft: scrollLeft,
-        translateX: translateX,
-        translateY: translateY,
-      },
-    });
-  };
-
-  _toggleZoom () {
-    const { scale, zoomMatrix } = this.state;
-
-    if ( scale >= zoomMatrix.rate ) {
-      this.setState({
-        scale: MIN_SCALE,
-        lockScroll: {
-          x: 0,
-          y: 0,
-        },
-        lockTranslate: {
-          x: 0,
-          y: 0,
-        },
-      }, () => {
-        this.container.scrollLeft = 0;
-        this.container.scrollTop = 0;
-      });
-    } else {
-      this.setState({
-        scale: zoomMatrix.rate,
-        lockScroll: {
-          x: zoomMatrix.scrollLeft,
-          y: zoomMatrix.scrollTop,
-        },
-        lockTranslate: {
-          x: zoomMatrix.fullScreen ? 0 : zoomMatrix.translateX,
-          y: zoomMatrix.fullScreen ? 0 : zoomMatrix.translateY,
-        },
-      }, () => {
-        this.container.scrollLeft = zoomMatrix.scrollLeft;
-        this.container.scrollTop = zoomMatrix.scrollTop;
-      });
-    }
-  }
-
-  setContainerRef = c => {
-    this.container = c;
-  };
-
-  setImageRef = c => {
-    this.image = c;
-  };
-
-  render () {
-    const { alt, lang, src, width, height } = this.props;
-    const { scale, lockTranslate, dragged } = this.state;
-    const overflow = scale === MIN_SCALE ? 'hidden' : 'scroll';
-    const cursor = scale === MIN_SCALE ? null : (dragged ? 'grabbing' : 'grab');
-
-    return (
-      <div
-        className='zoomable-image'
-        ref={this.setContainerRef}
-        style={{ overflow, cursor, userSelect: 'none' }}
-      >
-        <img
-          role='presentation'
-          ref={this.setImageRef}
-          alt={alt}
-          title={alt}
-          lang={lang}
-          src={src}
-          width={width}
-          height={height}
-          style={{
-            transform: `scale(${scale}) translate(-${lockTranslate.x}px, -${lockTranslate.y}px)`,
-            transformOrigin: '0 0',
-          }}
-          draggable={false}
-          onClick={this.handleClick}
-          onMouseDown={this.handleMouseDown}
-        />
-      </div>
-    );
-  }
-}
-
-export default ZoomableImage;
diff --git a/app/javascript/mastodon/features/ui/components/zoomable_image.tsx b/app/javascript/mastodon/features/ui/components/zoomable_image.tsx
new file mode 100644
index 0000000000..85e29e6aea
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/components/zoomable_image.tsx
@@ -0,0 +1,319 @@
+import { useState, useCallback, useRef, useEffect } from 'react';
+
+import classNames from 'classnames';
+
+import { useSpring, animated, config } from '@react-spring/web';
+import { createUseGesture, dragAction, pinchAction } from '@use-gesture/react';
+
+import { Blurhash } from 'mastodon/components/blurhash';
+import { LoadingIndicator } from 'mastodon/components/loading_indicator';
+
+const MIN_SCALE = 1;
+const MAX_SCALE = 4;
+const DOUBLE_CLICK_THRESHOLD = 250;
+
+interface ZoomMatrix {
+  containerWidth: number;
+  containerHeight: number;
+  imageWidth: number;
+  imageHeight: number;
+  initialScale: number;
+}
+
+const createZoomMatrix = (
+  container: HTMLElement,
+  image: HTMLImageElement,
+  fullWidth: number,
+  fullHeight: number,
+): ZoomMatrix => {
+  const { clientWidth, clientHeight } = container;
+  const { offsetWidth, offsetHeight } = image;
+
+  const type =
+    fullWidth / fullHeight < clientWidth / clientHeight ? 'width' : 'height';
+
+  const initialScale =
+    type === 'width'
+      ? Math.min(clientWidth, fullWidth) / offsetWidth
+      : Math.min(clientHeight, fullHeight) / offsetHeight;
+
+  return {
+    containerWidth: clientWidth,
+    containerHeight: clientHeight,
+    imageWidth: offsetWidth,
+    imageHeight: offsetHeight,
+    initialScale,
+  };
+};
+
+const useGesture = createUseGesture([dragAction, pinchAction]);
+
+const getBounds = (zoomMatrix: ZoomMatrix | null, scale: number) => {
+  if (!zoomMatrix || scale === MIN_SCALE) {
+    return {
+      left: -Infinity,
+      right: Infinity,
+      top: -Infinity,
+      bottom: Infinity,
+    };
+  }
+
+  const { containerWidth, containerHeight, imageWidth, imageHeight } =
+    zoomMatrix;
+
+  const bounds = {
+    left: -Math.max(imageWidth * scale - containerWidth, 0) / 2,
+    right: Math.max(imageWidth * scale - containerWidth, 0) / 2,
+    top: -Math.max(imageHeight * scale - containerHeight, 0) / 2,
+    bottom: Math.max(imageHeight * scale - containerHeight, 0) / 2,
+  };
+
+  return bounds;
+};
+
+interface ZoomableImageProps {
+  alt?: string;
+  lang?: string;
+  src: string;
+  width: number;
+  height: number;
+  onClick?: () => void;
+  onDoubleClick?: () => void;
+  onClose?: () => void;
+  onZoomChange?: (zoomedIn: boolean) => void;
+  zoomedIn?: boolean;
+  blurhash?: string;
+}
+
+export const ZoomableImage: React.FC<ZoomableImageProps> = ({
+  alt = '',
+  lang = '',
+  src,
+  width,
+  height,
+  onClick,
+  onDoubleClick,
+  onClose,
+  onZoomChange,
+  zoomedIn,
+  blurhash,
+}) => {
+  useEffect(() => {
+    const handler = (e: Event) => {
+      e.preventDefault();
+    };
+
+    document.addEventListener('gesturestart', handler);
+    document.addEventListener('gesturechange', handler);
+    document.addEventListener('gestureend', handler);
+
+    return () => {
+      document.removeEventListener('gesturestart', handler);
+      document.removeEventListener('gesturechange', handler);
+      document.removeEventListener('gestureend', handler);
+    };
+  }, []);
+
+  const [dragging, setDragging] = useState(false);
+  const [loaded, setLoaded] = useState(false);
+  const [error, setError] = useState(false);
+
+  const containerRef = useRef<HTMLDivElement>(null);
+  const imageRef = useRef<HTMLImageElement>(null);
+  const doubleClickTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>();
+  const zoomMatrixRef = useRef<ZoomMatrix | null>(null);
+
+  const [style, api] = useSpring(() => ({
+    x: 0,
+    y: 0,
+    scale: 1,
+    onRest: {
+      scale({ value }) {
+        if (!onZoomChange) {
+          return;
+        }
+        if (value === MIN_SCALE) {
+          onZoomChange(false);
+        } else {
+          onZoomChange(true);
+        }
+      },
+    },
+  }));
+
+  useGesture(
+    {
+      onDrag({
+        pinching,
+        cancel,
+        active,
+        last,
+        offset: [x, y],
+        velocity: [, vy],
+        direction: [, dy],
+        tap,
+      }) {
+        if (tap) {
+          if (!doubleClickTimeoutRef.current) {
+            doubleClickTimeoutRef.current = setTimeout(() => {
+              onClick?.();
+              doubleClickTimeoutRef.current = null;
+            }, DOUBLE_CLICK_THRESHOLD);
+          } else {
+            clearTimeout(doubleClickTimeoutRef.current);
+            doubleClickTimeoutRef.current = null;
+            onDoubleClick?.();
+          }
+
+          return;
+        }
+
+        if (!zoomedIn) {
+          // Swipe up/down to dismiss parent
+          if (last) {
+            if ((vy > 0.5 && dy !== 0) || Math.abs(y) > 150) {
+              onClose?.();
+            }
+
+            void api.start({ y: 0, config: config.wobbly });
+            return;
+          } else if (dy !== 0) {
+            void api.start({ y, immediate: true });
+            return;
+          }
+
+          cancel();
+          return;
+        }
+
+        if (pinching) {
+          cancel();
+          return;
+        }
+
+        if (active) {
+          setDragging(true);
+        } else {
+          setDragging(false);
+        }
+
+        void api.start({ x, y });
+      },
+
+      onPinch({ origin: [ox, oy], first, movement: [ms], offset: [s], memo }) {
+        if (!imageRef.current) {
+          return;
+        }
+
+        if (first) {
+          const { width, height, x, y } =
+            imageRef.current.getBoundingClientRect();
+          const tx = ox - (x + width / 2);
+          const ty = oy - (y + height / 2);
+
+          memo = [style.x.get(), style.y.get(), tx, ty];
+        }
+
+        const x = memo[0] - (ms - 1) * memo[2]; // eslint-disable-line @typescript-eslint/no-unsafe-member-access
+        const y = memo[1] - (ms - 1) * memo[3]; // eslint-disable-line @typescript-eslint/no-unsafe-member-access
+
+        void api.start({ scale: s, x, y });
+
+        return memo as [number, number, number, number];
+      },
+    },
+    {
+      target: imageRef,
+      drag: {
+        from: () => [style.x.get(), style.y.get()],
+        filterTaps: true,
+        bounds: () => getBounds(zoomMatrixRef.current, style.scale.get()),
+        rubberband: true,
+      },
+      pinch: {
+        scaleBounds: {
+          min: MIN_SCALE,
+          max: MAX_SCALE,
+        },
+        rubberband: true,
+      },
+    },
+  );
+
+  useEffect(() => {
+    if (!loaded || !containerRef.current || !imageRef.current) {
+      return;
+    }
+
+    zoomMatrixRef.current = createZoomMatrix(
+      containerRef.current,
+      imageRef.current,
+      width,
+      height,
+    );
+
+    if (!zoomedIn) {
+      void api.start({ scale: MIN_SCALE, x: 0, y: 0 });
+    } else if (style.scale.get() === MIN_SCALE) {
+      void api.start({ scale: zoomMatrixRef.current.initialScale, x: 0, y: 0 });
+    }
+  }, [api, style.scale, zoomedIn, width, height, loaded]);
+
+  const handleClick = useCallback((e: React.MouseEvent) => {
+    // This handler exists to cancel the onClick handler on the media modal which would
+    // otherwise close the modal. It cannot be used for actual click handling because
+    // we don't know if the user is about to pan the image or not.
+
+    e.preventDefault();
+    e.stopPropagation();
+  }, []);
+
+  const handleLoad = useCallback(() => {
+    setLoaded(true);
+  }, [setLoaded]);
+
+  const handleError = useCallback(() => {
+    setError(true);
+  }, [setError]);
+
+  return (
+    <div
+      className={classNames('zoomable-image', {
+        'zoomable-image--zoomed-in': zoomedIn,
+        'zoomable-image--error': error,
+        'zoomable-image--dragging': dragging,
+      })}
+      ref={containerRef}
+    >
+      {!loaded && blurhash && (
+        <div
+          className='zoomable-image__preview'
+          style={{
+            aspectRatio: `${width}/${height}`,
+            height: `min(${height}px, 100%)`,
+          }}
+        >
+          <Blurhash hash={blurhash} />
+        </div>
+      )}
+
+      <animated.img
+        style={style}
+        role='presentation'
+        ref={imageRef}
+        alt={alt}
+        title={alt}
+        lang={lang}
+        src={src}
+        width={width}
+        height={height}
+        draggable={false}
+        onLoad={handleLoad}
+        onError={handleError}
+        onClickCapture={handleClick}
+      />
+
+      {!loaded && !error && <LoadingIndicator />}
+    </div>
+  );
+};
diff --git a/app/javascript/mastodon/features/ui/containers/notifications_container.js b/app/javascript/mastodon/features/ui/containers/notifications_container.js
deleted file mode 100644
index b8aa9bc461..0000000000
--- a/app/javascript/mastodon/features/ui/containers/notifications_container.js
+++ /dev/null
@@ -1,20 +0,0 @@
-import { injectIntl } from 'react-intl';
-
-import { connect } from 'react-redux';
-
-import { NotificationStack } from 'react-notification';
-
-import { dismissAlert } from 'mastodon/actions/alerts';
-import { getAlerts } from 'mastodon/selectors';
-
-const mapStateToProps = (state, { intl }) => ({
-  notifications: getAlerts(state, { intl }),
-});
-
-const mapDispatchToProps = (dispatch) => ({
-  onDismiss (alert) {
-    dispatch(dismissAlert(alert));
-  },
-});
-
-export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(NotificationStack));
diff --git a/app/javascript/mastodon/features/ui/index.jsx b/app/javascript/mastodon/features/ui/index.jsx
index d43b6d7b01..8538817b78 100644
--- a/app/javascript/mastodon/features/ui/index.jsx
+++ b/app/javascript/mastodon/features/ui/index.jsx
@@ -15,6 +15,7 @@ import { focusApp, unfocusApp, changeLayout } from 'mastodon/actions/app';
 import { synchronouslySubmitMarkers, submitMarkers, fetchMarkers } from 'mastodon/actions/markers';
 import { fetchNotifications } from 'mastodon/actions/notification_groups';
 import { INTRODUCTION_VERSION } from 'mastodon/actions/onboarding';
+import { AlertsController } from 'mastodon/components/alerts_controller';
 import { HoverCardController } from 'mastodon/components/hover_card_controller';
 import { PictureInPicture } from 'mastodon/features/picture_in_picture';
 import { identityContextPropShape, withIdentity } from 'mastodon/identity_context';
@@ -33,7 +34,6 @@ import UploadArea from './components/upload_area';
 import ColumnsAreaContainer from './containers/columns_area_container';
 import LoadingBarContainer from './containers/loading_bar_container';
 import ModalContainer from './containers/modal_container';
-import NotificationsContainer from './containers/notifications_container';
 import {
   Compose,
   Status,
@@ -656,7 +656,7 @@ class UI extends PureComponent {
           </SwitchingColumnsArea>
 
           {layout !== 'mobile' && <PictureInPicture />}
-          <NotificationsContainer />
+          <AlertsController />
           {!disableHoverCards && <HoverCardController />}
           <LoadingBarContainer className='loading-bar' />
           <ModalContainer />
diff --git a/app/javascript/mastodon/features/video/index.jsx b/app/javascript/mastodon/features/video/index.jsx
index 89a8ba560a..7459b94a92 100644
--- a/app/javascript/mastodon/features/video/index.jsx
+++ b/app/javascript/mastodon/features/video/index.jsx
@@ -1,7 +1,7 @@
 import PropTypes from 'prop-types';
 import { PureComponent } from 'react';
 
-import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import { defineMessages, injectIntl } from 'react-intl';
 
 import classNames from 'classnames';
 
@@ -19,6 +19,7 @@ import VolumeOffIcon from '@/material-icons/400-24px/volume_off-fill.svg?react';
 import VolumeUpIcon from '@/material-icons/400-24px/volume_up-fill.svg?react';
 import { Blurhash } from 'mastodon/components/blurhash';
 import { Icon }  from 'mastodon/components/icon';
+import { SpoilerButton } from 'mastodon/components/spoiler_button';
 import { playerSettings } from 'mastodon/settings';
 
 import { displayMedia, useBlurhash } from '../../initial_state';
@@ -135,6 +136,7 @@ class Video extends PureComponent {
     muted: PropTypes.bool,
     componentIndex: PropTypes.number,
     autoFocus: PropTypes.bool,
+    matchedFilters: PropTypes.arrayOf(PropTypes.string),
   };
 
   static defaultProps = {
@@ -534,7 +536,7 @@ class Video extends PureComponent {
   }
 
   render () {
-    const { preview, src, aspectRatio, onOpenVideo, onCloseVideo, intl, alt, lang, detailed, sensitive, editable, blurhash, autoFocus } = this.props;
+    const { preview, src, aspectRatio, onOpenVideo, onCloseVideo, intl, alt, lang, detailed, sensitive, editable, blurhash, autoFocus, matchedFilters } = this.props;
     const { currentTime, duration, volume, buffer, dragging, paused, fullscreen, hovered, revealed } = this.state;
     const progress = Math.min((currentTime / duration) * 100, 100);
     const muted = this.state.muted || volume === 0;
@@ -549,14 +551,6 @@ class Video extends PureComponent {
       preload = 'none';
     }
 
-    let warning;
-
-    if (sensitive) {
-      warning = <FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' />;
-    } else {
-      warning = <FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' />;
-    }
-
     // The outer wrapper is necessary to avoid reflowing the layout when going into full screen
     return (
       <div style={{ aspectRatio }}>
@@ -599,14 +593,7 @@ class Video extends PureComponent {
             style={{ width: '100%' }}
           />}
 
-          <div className={classNames('spoiler-button', { 'spoiler-button--hidden': revealed || editable })}>
-            <button type='button' className='spoiler-button__overlay' onClick={this.toggleReveal}>
-              <span className='spoiler-button__overlay__label'>
-                {warning}
-                <span className='spoiler-button__overlay__action'><FormattedMessage id='status.media.show' defaultMessage='Click to show' /></span>
-              </span>
-            </button>
-          </div>
+          <SpoilerButton hidden={revealed || editable} sensitive={sensitive} onClick={this.toggleReveal} matchedFilters={matchedFilters} />
 
           <div className={classNames('video-player__controls', { active: paused || hovered })}>
             <div className='video-player__seek' onMouseDown={this.handleMouseDown} ref={this.setSeekRef}>
diff --git a/app/javascript/mastodon/locales/br.json b/app/javascript/mastodon/locales/br.json
index 9829986174..dde569bf5e 100644
--- a/app/javascript/mastodon/locales/br.json
+++ b/app/javascript/mastodon/locales/br.json
@@ -81,9 +81,14 @@
   "alert.rate_limited.title": "Feur bevennet",
   "alert.unexpected.message": "Ur fazi dic'hortozet zo degouezhet.",
   "alert.unexpected.title": "Hopala !",
+  "alt_text_modal.cancel": "Nullañ",
+  "alt_text_modal.change_thumbnail": "Kemmañ ar velvenn",
+  "alt_text_modal.done": "Graet",
   "announcement.announcement": "Kemennad",
   "annual_report.summary.followers.followers": "heulier",
   "annual_report.summary.highlighted_post.possessive": "{name}",
+  "annual_report.summary.most_used_hashtag.none": "Hini ebet",
+  "annual_report.summary.new_posts.new_posts": "toudoù nevez",
   "attachments_list.unprocessed": "(ket meret)",
   "audio.hide": "Kuzhat ar c'hleved",
   "block_modal.show_less": "Diskouez nebeutoc'h",
@@ -109,9 +114,11 @@
   "column.blocks": "Implijer·ezed·ien berzet",
   "column.bookmarks": "Sinedoù",
   "column.community": "Red-amzer lec'hel",
+  "column.create_list": "Krouiñ ul listenn",
   "column.direct": "Menegoù prevez",
   "column.directory": "Mont a-dreuz ar profiloù",
   "column.domain_blocks": "Domani berzet",
+  "column.edit_list": "Kemmañ al listenn",
   "column.favourites": "Muiañ-karet",
   "column.firehose": "Redoù war-eeun",
   "column.follow_requests": "Rekedoù heuliañ",
@@ -162,9 +169,12 @@
   "confirmations.delete.message": "Ha sur oc'h e fell deoc'h dilemel an toud-mañ ?",
   "confirmations.delete_list.confirm": "Dilemel",
   "confirmations.delete_list.message": "Ha sur eo hoc'h eus c'hoant da zilemel ar roll-mañ da vat ?",
+  "confirmations.delete_list.title": "Dilemel al listenn?",
   "confirmations.discard_edit_media.confirm": "Nac'hañ",
   "confirmations.discard_edit_media.message": "Bez ez eus kemmoù n'int ket enrollet e deskrivadur ar media pe ar rakwel, nullañ anezho evelato?",
   "confirmations.edit.confirm": "Kemmañ",
+  "confirmations.edit.message": "Kemmañ bremañ a zilamo ar gemennadenn emaoc'h o skrivañ. Sur e oc'h e fell deoc'h kenderc'hel ganti?",
+  "confirmations.follow_to_list.title": "Heuliañ an implijer·ez?",
   "confirmations.logout.confirm": "Digevreañ",
   "confirmations.logout.message": "Ha sur oc'h e fell deoc'h digevreañ ?",
   "confirmations.mute.confirm": "Kuzhat",
@@ -266,8 +276,10 @@
   "footer.privacy_policy": "Reolennoù prevezded",
   "footer.source_code": "Gwelet ar c'hod mammenn",
   "footer.status": "Statud",
+  "footer.terms_of_service": "Divizoù implijout hollek",
   "generic.saved": "Enrollet",
   "getting_started.heading": "Loc'hañ",
+  "hashtag.admin_moderation": "Digeriñ an etrefas evezhiañ evit #{name}",
   "hashtag.column_header.tag_mode.all": "ha(g) {additional}",
   "hashtag.column_header.tag_mode.any": "pe {additional}",
   "hashtag.column_header.tag_mode.none": "hep {additional}",
@@ -337,8 +349,14 @@
   "limited_account_hint.action": "Diskouez an aelad memes tra",
   "limited_account_hint.title": "Kuzhet eo bet ar profil-mañ gant an evezhierien eus {domain}.",
   "link_preview.author": "Gant {name}",
+  "lists.add_member": "Ouzhpennañ",
+  "lists.add_to_list": "Ouzhpennañ d'al listenn",
+  "lists.create": "Krouiñ",
+  "lists.create_list": "Krouiñ ul listenn",
   "lists.delete": "Dilemel al listenn",
+  "lists.done": "Graet",
   "lists.edit": "Kemmañ al listenn",
+  "lists.list_name": "Anv al listenn",
   "lists.replies_policy.followed": "Pep implijer.ez heuliet",
   "lists.replies_policy.list": "Izili ar roll",
   "lists.replies_policy.none": "Den ebet",
@@ -373,11 +391,17 @@
   "notification.follow": "heuliañ a ra {name} ac'hanoc'h",
   "notification.follow.name_and_others": "{name} <a>{count, plural, one {hag # den all} two {ha # zen all} few {ha # den all} many {ha # den all} other {ha # den all}}</a> zo o heuliañ ac'hanoc'h",
   "notification.follow_request": "Gant {name} eo bet goulennet ho heuliañ",
+  "notification.label.reply": "Respont",
   "notification.moderation-warning.learn_more": "Gouzout hiroc'h",
   "notification.own_poll": "Echu eo ho sontadeg",
   "notification.reblog": "Gant {name} eo bet skignet ho toud",
+  "notification.relationships_severance_event.learn_more": "Gouzout hiroc'h",
   "notification.status": "Emañ {name} o paouez toudañ",
   "notification.update": "Gant {name} ez eus bet kemmet un toud",
+  "notification_requests.accept": "Asantiñ",
+  "notification_requests.dismiss": "Diverkañ",
+  "notification_requests.edit_selection": "Kemmañ",
+  "notification_requests.exit_selection": "Graet",
   "notifications.clear": "Skarzhañ ar c'hemennoù",
   "notifications.clear_confirmation": "Ha sur oc'h e fell deoc'h skarzhañ ho holl kemennoù ?",
   "notifications.column_settings.admin.report": "Disklêriadurioù nevez :",
@@ -410,6 +434,10 @@
   "notifications.permission_denied": "Kemennoù war ar burev n'int ket hegerz rak pedadenn aotren ar merdeer a zo bet nullet araok",
   "notifications.permission_denied_alert": "Kemennoù wa ar burev na c'hellont ket bezañ lezelet, rak aotre ar merdeer a zo bet nac'het a-raok",
   "notifications.permission_required": "Kemennoù war ar burev n'int ket hegerz abalamour d'an aotre rekis n'eo ket bet roet.",
+  "notifications.policy.accept": "Asantiñ",
+  "notifications.policy.accept_hint": "Diskouez er c’hemennoù",
+  "notifications.policy.drop": "Tremen e-bioù",
+  "notifications.policy.filter": "Silañ",
   "notifications.policy.filter_new_accounts_title": "Kontoù nevez",
   "notifications_permission_banner.enable": "Lezel kemennoù war ar burev",
   "notifications_permission_banner.how_to_control": "Evit reseviñ kemennoù pa ne vez ket digoret Mastodon, lezelit kemennoù war ar burev. Gallout a rit kontrollañ peseurt eskemmoù a c'henel kemennoù war ar burev gant ar {icon} nozelenn a-us kentre ma'z int lezelet.",
@@ -515,6 +543,7 @@
   "search_results.accounts": "Profiloù",
   "search_results.all": "Pep tra",
   "search_results.hashtags": "Hashtagoù",
+  "search_results.no_results": "Disoc'h ebet.",
   "search_results.see_all": "Gwelet pep tra",
   "search_results.statuses": "Toudoù",
   "server_banner.active_users": "implijerien·ezed oberiant",
@@ -579,6 +608,7 @@
   "subscribed_languages.target": "Cheñch ar yezhoù koumanantet evit {target}",
   "tabs_bar.home": "Degemer",
   "tabs_bar.notifications": "Kemennoù",
+  "terms_of_service.title": "Divizoù implijout",
   "time_remaining.days": "{number, plural,one {# devezh} other {# a zevezh}} a chom",
   "time_remaining.hours": "{number, plural, one {# eurvezh} other{# eurvezh}} a chom",
   "time_remaining.minutes": "{number, plural, one {# munut} other{# a vunut}} a chom",
diff --git a/app/javascript/mastodon/locales/cs.json b/app/javascript/mastodon/locales/cs.json
index 501283833d..aff66c97f8 100644
--- a/app/javascript/mastodon/locales/cs.json
+++ b/app/javascript/mastodon/locales/cs.json
@@ -110,7 +110,7 @@
   "annual_report.summary.most_used_hashtag.most_used_hashtag": "nejpoužívanější hashtag",
   "annual_report.summary.most_used_hashtag.none": "Žádné",
   "annual_report.summary.new_posts.new_posts": "nové příspěvky",
-  "annual_report.summary.percentile.text": "<topLabel>To vás umisťuje do vrcholu</topLabel><percentage></percentage><bottomLabel>{domain} uživatelů.</bottomLabel>",
+  "annual_report.summary.percentile.text": "<topLabel>To vás umisťuje do horních</topLabel><percentage></percentage><bottomLabel> uživatelů domény {domain}.</bottomLabel>",
   "annual_report.summary.percentile.we_wont_tell_bernie": "To, že jste zdejší smetánka, zůstane mezi námi ;).",
   "annual_report.summary.thanks": "Děkujeme, že jste součástí Mastodonu!",
   "attachments_list.unprocessed": "(nezpracováno)",
@@ -513,9 +513,9 @@
   "loading_indicator.label": "Načítání…",
   "media_gallery.hide": "Skrýt",
   "moved_to_account_banner.text": "Váš účet {disabledAccount} je momentálně deaktivován, protože jste se přesunul/a na {movedToAccount}.",
-  "mute_modal.hide_from_notifications": "Skrýt z notifikací",
+  "mute_modal.hide_from_notifications": "Skrýt z oznámení",
   "mute_modal.hide_options": "Skrýt možnosti",
-  "mute_modal.indefinite": "Dokud je neodkryju",
+  "mute_modal.indefinite": "Dokud je neodeberu ze ztišených",
   "mute_modal.show_options": "Zobrazit možnosti",
   "mute_modal.they_can_mention_and_follow": "Mohou vás zmínit a sledovat, ale neuvidíte je.",
   "mute_modal.they_wont_know": "Nebudou vědět, že byli skryti.",
@@ -524,7 +524,7 @@
   "mute_modal.you_wont_see_posts": "Stále budou moci vidět vaše příspěvky, ale vy jejich neuvidíte.",
   "navigation_bar.about": "O aplikaci",
   "navigation_bar.administration": "Administrace",
-  "navigation_bar.advanced_interface": "Otevřít pokročilé webové rozhraní",
+  "navigation_bar.advanced_interface": "Otevřít v pokročilém webovém rozhraní",
   "navigation_bar.blocks": "Blokovaní uživatelé",
   "navigation_bar.bookmarks": "Záložky",
   "navigation_bar.community_timeline": "Místní časová osa",
@@ -553,13 +553,13 @@
   "notification.admin.report": "Uživatel {name} nahlásil {target}",
   "notification.admin.report_account": "{name} nahlásil {count, plural, one {jeden příspěvek} few {# příspěvky} many {# příspěvků} other {# příspěvků}} od {target} za {category}",
   "notification.admin.report_account_other": "{name} nahlásil {count, plural, one {jeden příspěvek} few {# příspěvky} many {# příspěvků} other {# příspěvků}} od {target}",
-  "notification.admin.report_statuses": "{name} nahlásil {target} za {category}",
-  "notification.admin.report_statuses_other": "{name} nahlásil {target}",
+  "notification.admin.report_statuses": "{name} nahlásili {target} za {category}",
+  "notification.admin.report_statuses_other": "{name} nahlásili {target}",
   "notification.admin.sign_up": "Uživatel {name} se zaregistroval",
   "notification.admin.sign_up.name_and_others": "{name} a {count, plural, one {# další} few {# další} many {# dalších} other {# dalších}} se zaregistrovali",
   "notification.annual_report.message": "Váš #Wrapstodon {year} na Vás čeká! Podívejte se, jak vypadal tento Váš rok na Mastodonu!",
   "notification.annual_report.view": "Zobrazit #Wrapstodon",
-  "notification.favourite": "Uživatel {name} si oblíbil váš příspěvek",
+  "notification.favourite": "{name} si oblíbil*a váš příspěvek",
   "notification.favourite.name_and_others_with_link": "{name} a {count, plural, one {<a># další</a> si oblíbil} few {<a># další</a> si oblíbili} other {<a># dalších</a> si oblíbilo}} Váš příspěvek",
   "notification.favourite_pm": "{name} si oblíbil vaši soukromou zmínku",
   "notification.favourite_pm.name_and_others_with_link": "{name} a {count, plural, one {<a># další</a> si oblíbil} few {<a># další</a> si oblíbili} other {<a># dalších</a> si oblíbilo}} Vaši soukromou zmínku",
@@ -578,7 +578,7 @@
   "notification.moderation_warning.action_delete_statuses": "Některé z vašich příspěvků byly odstraněny.",
   "notification.moderation_warning.action_disable": "Váš účet je zablokován.",
   "notification.moderation_warning.action_mark_statuses_as_sensitive": "Některé z vašich příspěvků byly označeny jako citlivé.",
-  "notification.moderation_warning.action_none": "Váš účet obdržel moderační varování.",
+  "notification.moderation_warning.action_none": "Váš účet obdržel varování od moderátorů.",
   "notification.moderation_warning.action_sensitive": "Vaše příspěvky budou od nynějška označeny jako citlivé.",
   "notification.moderation_warning.action_silence": "Váš účet byl omezen.",
   "notification.moderation_warning.action_suspend": "Váš účet byl pozastaven.",
@@ -610,7 +610,7 @@
   "notification_requests.maximize": "Maximalizovat",
   "notification_requests.minimize_banner": "Minimalizovat banner filtrovaných oznámení",
   "notification_requests.notifications_from": "Oznámení od {name}",
-  "notification_requests.title": "Vyfiltrovaná oznámení",
+  "notification_requests.title": "Filtrovaná oznámení",
   "notification_requests.view": "Zobrazit oznámení",
   "notifications.clear": "Vyčistit oznámení",
   "notifications.clear_confirmation": "Opravdu chcete trvale smazat všechna vaše oznámení?",
@@ -773,7 +773,7 @@
   "report_notification.categories.spam": "Spam",
   "report_notification.categories.spam_sentence": "spam",
   "report_notification.categories.violation": "Porušení pravidla",
-  "report_notification.categories.violation_sentence": "porušení pravidla",
+  "report_notification.categories.violation_sentence": "porušení pravidel",
   "report_notification.open": "Otevřít hlášení",
   "search.no_recent_searches": "Žádná nedávná vyhledávání",
   "search.placeholder": "Hledat",
diff --git a/app/javascript/mastodon/locales/cy.json b/app/javascript/mastodon/locales/cy.json
index a3749a0e42..2aa662960e 100644
--- a/app/javascript/mastodon/locales/cy.json
+++ b/app/javascript/mastodon/locales/cy.json
@@ -16,7 +16,7 @@
   "account.badges.bot": "Awtomataidd",
   "account.badges.group": "Grŵp",
   "account.block": "Blocio @{name}",
-  "account.block_domain": "Blocio parth {domain}",
+  "account.block_domain": "Blocio'r parth {domain}",
   "account.block_short": "Blocio",
   "account.blocked": "Blociwyd",
   "account.cancel_follow_request": "Tynnu cais i ddilyn",
@@ -41,7 +41,7 @@
   "account.go_to_profile": "Mynd i'r proffil",
   "account.hide_reblogs": "Cuddio hybiau gan @{name}",
   "account.in_memoriam": "Er Cof.",
-  "account.joined_short": "Wedi Ymuno",
+  "account.joined_short": "Ymunodd",
   "account.languages": "Newid ieithoedd wedi tanysgrifio iddyn nhw",
   "account.link_verified_on": "Gwiriwyd perchnogaeth y ddolen yma ar {date}",
   "account.locked_info": "Mae'r statws preifatrwydd cyfrif hwn wedi'i osod i fod ar glo. Mae'r perchennog yn adolygu'r sawl sy'n gallu eu dilyn.",
@@ -56,7 +56,7 @@
   "account.no_bio": "Dim disgrifiad wedi'i gynnig.",
   "account.open_original_page": "Agor y dudalen wreiddiol",
   "account.posts": "Postiadau",
-  "account.posts_with_replies": "Postiadau ac atebion",
+  "account.posts_with_replies": "Postiadau ac ymatebion",
   "account.report": "Adrodd @{name}",
   "account.requested": "Aros am gymeradwyaeth. Cliciwch er mwyn canslo cais dilyn",
   "account.requested_follow": "Mae {name} wedi gwneud cais i'ch dilyn",
@@ -85,7 +85,7 @@
   "alert.rate_limited.title": "Cyfradd gyfyngedig",
   "alert.unexpected.message": "Digwyddodd gwall annisgwyl.",
   "alert.unexpected.title": "Wps!",
-  "alt_text_badge.title": "Testun Amgen",
+  "alt_text_badge.title": "Testun amgen",
   "alt_text_modal.add_alt_text": "Ychwanegu testun amgen",
   "alt_text_modal.add_text_from_image": "Ychwanegu testun o'r ddelwedd",
   "alt_text_modal.cancel": "Diddymu",
@@ -110,7 +110,7 @@
   "annual_report.summary.most_used_hashtag.most_used_hashtag": "hashnod a ddefnyddiwyd fwyaf",
   "annual_report.summary.most_used_hashtag.none": "Dim",
   "annual_report.summary.new_posts.new_posts": "postiadau newydd",
-  "annual_report.summary.percentile.text": "<topLabel>Mae hynny'n eich rhoi chi ar y brig</topLabel><percentage></percentage><bottomLabel> o ddefnyddiwr {domain}.</bottomLabel>",
+  "annual_report.summary.percentile.text": "<topLabel>Mae hynny'n eich rhoi chi ymysg y</topLabel><percentage></percentage><bottomLabel>uchaf o ddefnyddwyr {domain}.</bottomLabel>",
   "annual_report.summary.percentile.we_wont_tell_bernie": "Ni fyddwn yn dweud wrth Bernie.",
   "annual_report.summary.thanks": "Diolch am fod yn rhan o Mastodon!",
   "attachments_list.unprocessed": "(heb eu prosesu)",
@@ -142,7 +142,7 @@
   "closed_registrations_modal.description": "Ar hyn o bryd nid yw'n bosib creu cyfrif ar {domain}, ond cadwch mewn cof nad oes raid i chi gael cyfrif yn benodol ar {domain} i ddefnyddio Mastodon.",
   "closed_registrations_modal.find_another_server": "Dod o hyd i weinydd arall",
   "closed_registrations_modal.preamble": "Mae Mastodon wedi'i ddatganoli, felly does dim gwahaniaeth ble rydych chi'n creu eich cyfrif, byddwch chi'n gallu dilyn a rhyngweithio ag unrhyw un ar y gweinydd hwn. Gallwch hyd yn oed ei gynnal un eich hun!",
-  "closed_registrations_modal.title": "Ymgofrestru ar Mastodon",
+  "closed_registrations_modal.title": "Cofrestru ar Mastodon",
   "column.about": "Ynghylch",
   "column.blocks": "Defnyddwyr a flociwyd",
   "column.bookmarks": "Llyfrnodau",
@@ -192,9 +192,9 @@
   "compose_form.poll.switch_to_multiple": "Newid pleidlais i adael mwy nag un dewis",
   "compose_form.poll.switch_to_single": "Newid pleidlais i gyfyngu i un dewis",
   "compose_form.poll.type": "Arddull",
-  "compose_form.publish": "Postiad",
+  "compose_form.publish": "Postio",
   "compose_form.publish_form": "Postiad newydd",
-  "compose_form.reply": "Ateb",
+  "compose_form.reply": "Ymateb",
   "compose_form.save_changes": "Diweddaru",
   "compose_form.spoiler.marked": "Dileu rhybudd cynnwys",
   "compose_form.spoiler.unmarked": "Ychwanegu rhybudd cynnwys",
@@ -226,7 +226,7 @@
   "confirmations.redraft.confirm": "Dileu ac ailddrafftio",
   "confirmations.redraft.message": "Ydych chi wir eisiau'r dileu'r postiad hwn a'i ailddrafftio? Bydd ffefrynnau a hybiau'n cael eu colli, a bydd atebion i'r post gwreiddiol yn mynd yn amddifad.",
   "confirmations.redraft.title": "Dileu & ailddraftio postiad?",
-  "confirmations.reply.confirm": "Ateb",
+  "confirmations.reply.confirm": "Ymateb",
   "confirmations.reply.message": "Bydd ateb nawr yn cymryd lle y neges yr ydych yn cyfansoddi ar hyn o bryd. Ydych chi'n siŵr eich bod am barhau?",
   "confirmations.reply.title": "Trosysgrifo'r postiad?",
   "confirmations.unfollow.confirm": "Dad-ddilyn",
@@ -248,8 +248,8 @@
   "directory.recently_active": "Ar-lein yn ddiweddar",
   "disabled_account_banner.account_settings": "Gosodiadau'r cyfrif",
   "disabled_account_banner.text": "Mae eich cyfrif {disabledAccount} wedi ei analluogi ar hyn o bryd.",
-  "dismissable_banner.community_timeline": "Dyma'r postiadau cyhoeddus diweddaraf gan bobl sydd â chyfrifon ar {domain}.",
-  "dismissable_banner.dismiss": "Cau",
+  "dismissable_banner.community_timeline": "Dyma'r postiadau cyhoeddus diweddaraf gan bobl y caiff eu cyfrifon eu cynnal ar {domain}.",
+  "dismissable_banner.dismiss": "Diystyru",
   "dismissable_banner.explore_links": "Y straeon newyddion hyn yw'r rhai sy'n cael eu rhannu fwyaf ar y ffederasiwn heddiw. Mae straeon newyddion mwy diweddar sy'n cael eu postio gan fwy o amrywiaeth o bobl yn cael eu graddio'n uwch.",
   "dismissable_banner.explore_statuses": "Mae'r postiadau hyn o bob rhan o'r ffedysawd yn cael mwy o sylw heddiw. Mae postiadau mwy diweddar sydd â mwy o hybu a ffefrynnu'n cael eu graddio'n uwch.",
   "dismissable_banner.explore_tags": "Mae'r hashnodau hyn ar gynnydd y ffedysawd heddiw. Mae hashnodau sy'n cael eu defnyddio gan fwy o bobl amrywiol yn cael eu graddio'n uwch.",
@@ -398,10 +398,10 @@
   "hints.profiles.see_more_followers": "Gweld mwy o ddilynwyr ar {domain}",
   "hints.profiles.see_more_follows": "Gweld mwy o 'yn dilyn' ar {domain}",
   "hints.profiles.see_more_posts": "Gweld mwy o bostiadau ar {domain}",
-  "hints.threads.replies_may_be_missing": "Mae'n bosibl y bydd atebion gan weinyddion eraill ar goll.",
-  "hints.threads.see_more": "Gweld mwy o atebion ar {domain}",
+  "hints.threads.replies_may_be_missing": "Mae'n bosibl y bydd ymatebion gan weinyddion eraill ar goll.",
+  "hints.threads.see_more": "Gweld mwy o ymatebion ar {domain}",
   "home.column_settings.show_reblogs": "Dangos hybiau",
-  "home.column_settings.show_replies": "Dangos atebion",
+  "home.column_settings.show_replies": "Dangos ymatebion",
   "home.hide_announcements": "Cuddio cyhoeddiadau",
   "home.pending_critical_update.body": "Diweddarwch eich gweinydd Mastodon cyn gynted â phosibl!",
   "home.pending_critical_update.link": "Gweld diweddariadau",
@@ -423,7 +423,7 @@
   "interaction_modal.action.favourite": "I barhau, mae angen i chi hoffi o'ch cyfrif.",
   "interaction_modal.action.follow": "I barhau, mae angen i chi ddilyn o'ch cyfrif.",
   "interaction_modal.action.reblog": "I barhau, mae angen i chi ail-flogio o'ch cyfrif.",
-  "interaction_modal.action.reply": "I barhau, mae angen i chi ateb o'ch cyfrif.",
+  "interaction_modal.action.reply": "I barhau, mae angen i chi ymateb o'ch cyfrif.",
   "interaction_modal.action.vote": "I barhau, mae angen i chi bleidleisio o'ch cyfrif.",
   "interaction_modal.go": "Mynd",
   "interaction_modal.no_account_yet": "Dim cyfrif eto?",
@@ -462,7 +462,7 @@
   "keyboard_shortcuts.open_media": "Agor cyfryngau",
   "keyboard_shortcuts.pinned": "Agor rhestr postiadau wedi'u pinio",
   "keyboard_shortcuts.profile": "Agor proffil yr awdur",
-  "keyboard_shortcuts.reply": "Ateb i bostiad",
+  "keyboard_shortcuts.reply": "Ymateb i bostiad",
   "keyboard_shortcuts.requests": "Agor rhestr ceisiadau dilyn",
   "keyboard_shortcuts.search": "Ffocysu ar y bar chwilio",
   "keyboard_shortcuts.spoilers": "Dangos/cuddio'r maes CW",
@@ -489,9 +489,9 @@
   "lists.create": "Creu",
   "lists.create_a_list_to_organize": "Creu rhestr newydd i drefnu eich llif Cartref",
   "lists.create_list": "Creu rhestr",
-  "lists.delete": "Dileu rhestr",
+  "lists.delete": "Dileu'r rhestr",
   "lists.done": "Wedi gorffen",
-  "lists.edit": "Golygu rhestr",
+  "lists.edit": "Golygu'r rhestr",
   "lists.exclusive": "Cuddio aelodau yn y Cartref",
   "lists.exclusive_hint": "Os oes rhywun ar y rhestr hon, cuddiwch nhw yn eich llif Cartref i osgoi gweld eu postiadau ddwywaith.",
   "lists.find_users_to_add": "Canfod defnyddwyr i'w hychwanegu",
@@ -508,11 +508,11 @@
   "lists.replies_policy.none": "Neb",
   "lists.save": "Cadw",
   "lists.search": "Chwilio",
-  "lists.show_replies_to": "Cynhwyswch atebion gan aelodau'r rhestr i",
+  "lists.show_replies_to": "Cynnwys ymatebion gan aelodau'r rhestr i",
   "load_pending": "{count, plural, one {# eitem newydd} other {# eitem newydd}}",
   "loading_indicator.label": "Yn llwytho…",
   "media_gallery.hide": "Cuddio",
-  "moved_to_account_banner.text": "Ar hyn y bryd, mae eich cyfrif {disabledAccount} wedi ei analluogi am i chi symud i {movedToAccount}.",
+  "moved_to_account_banner.text": "Mae eich cyfrif {disabledAccount} wedi ei analluogi ar hyn o bryd am i chi symud i {movedToAccount}.",
   "mute_modal.hide_from_notifications": "Cuddio rhag hysbysiadau",
   "mute_modal.hide_options": "Cuddio'r dewis",
   "mute_modal.indefinite": "Nes i mi eu dad-dewi",
@@ -550,7 +550,7 @@
   "navigation_bar.search": "Chwilio",
   "navigation_bar.security": "Diogelwch",
   "not_signed_in_indicator.not_signed_in": "Rhaid i chi fewngofnodi i weld yr adnodd hwn.",
-  "notification.admin.report": "Adroddwyd ar {name} {target}",
+  "notification.admin.report": "Adroddodd {name} {target}",
   "notification.admin.report_account": "Adroddodd {name} {count, plural, one {un postiad} other {# postiad}} gan {target} oherwydd {category}",
   "notification.admin.report_account_other": "Adroddodd {name} {count, plural, one {un postiad} two {# bostiad} few {# postiad} other {# postiad}} gan {target}",
   "notification.admin.report_statuses": "Adroddodd {name} {target} ar gyfer {category}",
@@ -569,8 +569,8 @@
   "notification.follow_request.name_and_others": "Mae {name} a{count, plural, one {# arall} other {# arall}} wedi gofyn i'ch dilyn chi",
   "notification.label.mention": "Crybwyll",
   "notification.label.private_mention": "Crybwyll preifat",
-  "notification.label.private_reply": "Ateb preifat",
-  "notification.label.reply": "Ateb",
+  "notification.label.private_reply": "Ymateb preifat",
+  "notification.label.reply": "Ymateb",
   "notification.mention": "Crybwyll",
   "notification.mentioned_you": "Rydych wedi'ch crybwyll gan {name}",
   "notification.moderation-warning.learn_more": "Dysgu mwy",
@@ -731,7 +731,7 @@
   "report.categories.other": "Arall",
   "report.categories.spam": "Sbam",
   "report.categories.violation": "Mae cynnwys yn torri un neu fwy o reolau'r gweinydd",
-  "report.category.subtitle": "Dewiswch y gyfatebiaeth gorau",
+  "report.category.subtitle": "Dewiswch yr ateb gorau",
   "report.category.title": "Beth sy'n digwydd gyda'r {type} yma?",
   "report.category.title_account": "proffil",
   "report.category.title_status": "post",
@@ -853,7 +853,7 @@
   "status.remove_favourite": "Tynnu o'r ffefrynnau",
   "status.replied_in_thread": "Atebodd mewn edefyn",
   "status.replied_to": "Wedi ateb {name}",
-  "status.reply": "Ateb",
+  "status.reply": "Ymateb",
   "status.replyAll": "Ateb i edefyn",
   "status.report": "Adrodd ar @{name}",
   "status.sensitive_warning": "Cynnwys sensitif",
diff --git a/app/javascript/mastodon/locales/et.json b/app/javascript/mastodon/locales/et.json
index 43983e502f..8a7edc1ccb 100644
--- a/app/javascript/mastodon/locales/et.json
+++ b/app/javascript/mastodon/locales/et.json
@@ -562,6 +562,7 @@
   "notification.favourite": "{name} märkis su postituse lemmikuks",
   "notification.favourite.name_and_others_with_link": "{name} ja <a>{count, plural, one {# veel} other {# teist}}</a> märkis su postituse lemmikuks",
   "notification.favourite_pm": "{name} märkis sinu privaatse mainimise lemmikuks",
+  "notification.favourite_pm.name_and_others_with_link": "{name} ja <a>{count, plural, one {# veel} other {# veel}}</a> märkisid su privaatse mainimise lemmikuks",
   "notification.follow": "{name} alustas su jälgimist",
   "notification.follow.name_and_others": "{name} ja veel {count, plural, one {# kasutaja} other {# kasutajat}} hakkas sind jälgima",
   "notification.follow_request": "{name} soovib sind jälgida",
@@ -696,6 +697,7 @@
   "poll_button.remove_poll": "Eemalda küsitlus",
   "privacy.change": "Muuda postituse nähtavust",
   "privacy.direct.long": "Kõik postituses mainitud",
+  "privacy.direct.short": "Privaatne mainimine",
   "privacy.private.long": "Ainult jälgijad",
   "privacy.private.short": "Jälgijad",
   "privacy.public.long": "Nii kasutajad kui mittekasutajad",
diff --git a/app/javascript/mastodon/locales/fr-CA.json b/app/javascript/mastodon/locales/fr-CA.json
index 55d5480157..d0bfec8229 100644
--- a/app/javascript/mastodon/locales/fr-CA.json
+++ b/app/javascript/mastodon/locales/fr-CA.json
@@ -31,7 +31,7 @@
   "account.featured_tags.last_status_never": "Aucune publication",
   "account.featured_tags.title": "Hashtags inclus de {name}",
   "account.follow": "Suivre",
-  "account.follow_back": "S'abonner en retour",
+  "account.follow_back": "Suivre en retour",
   "account.followers": "abonné·e·s",
   "account.followers.empty": "Personne ne suit ce compte pour l'instant.",
   "account.followers_counter": "{count, plural, one {{counter} abonné·e} other {{counter} abonné·e·s}}",
@@ -119,7 +119,7 @@
   "block_modal.show_less": "Afficher moins",
   "block_modal.show_more": "Afficher plus",
   "block_modal.they_cant_mention": "Il ne peut pas vous mentionner ou vous suivre.",
-  "block_modal.they_cant_see_posts": "Il peut toujours voir vos messages, mais vous ne verrez pas les siens.",
+  "block_modal.they_cant_see_posts": "Il ne peut plus voir vos messages et vous ne verrez plus les siens.",
   "block_modal.they_will_know": "Il peut voir qu'il est bloqué.",
   "block_modal.title": "Bloquer le compte ?",
   "block_modal.you_wont_see_mentions": "Vous ne verrez pas les messages qui le mentionne.",
@@ -872,7 +872,9 @@
   "subscribed_languages.target": "Changer les langues abonnées pour {target}",
   "tabs_bar.home": "Accueil",
   "tabs_bar.notifications": "Notifications",
+  "terms_of_service.effective_as_of": "En vigueur à compter du {date}",
   "terms_of_service.title": "Conditions d'utilisation",
+  "terms_of_service.upcoming_changes_on": "Modifications à venir le {date}",
   "time_remaining.days": "{number, plural, one {# jour restant} other {# jours restants}}",
   "time_remaining.hours": "{number, plural, one {# heure restante} other {# heures restantes}}",
   "time_remaining.minutes": "{number, plural, one {# minute restante} other {# minutes restantes}}",
diff --git a/app/javascript/mastodon/locales/fr.json b/app/javascript/mastodon/locales/fr.json
index 8034c1696e..101aeba211 100644
--- a/app/javascript/mastodon/locales/fr.json
+++ b/app/javascript/mastodon/locales/fr.json
@@ -3,7 +3,7 @@
   "about.contact": "Contact :",
   "about.disclaimer": "Mastodon est un logiciel libre, open-source et une marque déposée de Mastodon gGmbH.",
   "about.domain_blocks.no_reason_available": "Raison non disponible",
-  "about.domain_blocks.preamble": "Mastodon vous permet généralement de visualiser le contenu et d'interagir avec les utilisateur⋅rice⋅s de n'importe quel autre serveur dans le fédiverse. Voici les exceptions qui ont été faites sur ce serveur en particulier.",
+  "about.domain_blocks.preamble": "Mastodon vous permet généralement de visualiser le contenu et d'interagir avec les utilisateur⋅rices de n'importe quel autre serveur dans le fédivers. Voici les exceptions qui ont été faites sur ce serveur-là.",
   "about.domain_blocks.silenced.explanation": "Vous ne verrez généralement pas les profils et le contenu de ce serveur, à moins que vous ne les recherchiez explicitement ou que vous ne choisissiez de les suivre.",
   "about.domain_blocks.silenced.title": "Limité",
   "about.domain_blocks.suspended.explanation": "Aucune donnée de ce serveur ne sera traitée, enregistrée ou échangée, rendant impossible toute interaction ou communication avec les comptes de ce serveur.",
@@ -19,7 +19,7 @@
   "account.block_domain": "Bloquer le domaine {domain}",
   "account.block_short": "Bloquer",
   "account.blocked": "Bloqué·e",
-  "account.cancel_follow_request": "Annuler le suivi",
+  "account.cancel_follow_request": "Annuler l'abonnement",
   "account.copy": "Copier le lien vers le profil",
   "account.direct": "Mention privée @{name}",
   "account.disable_notifications": "Ne plus me notifier quand @{name} publie quelque chose",
@@ -31,18 +31,18 @@
   "account.featured_tags.last_status_never": "Aucun message",
   "account.featured_tags.title": "Les hashtags en vedette de {name}",
   "account.follow": "Suivre",
-  "account.follow_back": "S'abonner en retour",
+  "account.follow_back": "Suivre en retour",
   "account.followers": "Abonné·e·s",
   "account.followers.empty": "Personne ne suit cet·te utilisateur·rice pour l’instant.",
   "account.followers_counter": "{count, plural, one {{counter} abonné·e} other {{counter} abonné·e·s}}",
   "account.following": "Abonnements",
   "account.following_counter": "{count, plural, one {{counter} abonnement} other {{counter} abonnements}}",
   "account.follows.empty": "Cet·te utilisateur·rice ne suit personne pour l’instant.",
-  "account.go_to_profile": "Aller au profil",
+  "account.go_to_profile": "Voir le profil",
   "account.hide_reblogs": "Masquer les partages de @{name}",
   "account.in_memoriam": "En mémoire de.",
   "account.joined_short": "Ici depuis",
-  "account.languages": "Changer les langues abonnées",
+  "account.languages": "Modifier les langues d'abonnements",
   "account.link_verified_on": "La propriété de ce lien a été vérifiée le {date}",
   "account.locked_info": "Ce compte est privé. Son ou sa propriétaire approuve manuellement qui peut le suivre.",
   "account.media": "Médias",
@@ -119,7 +119,7 @@
   "block_modal.show_less": "Afficher moins",
   "block_modal.show_more": "Afficher plus",
   "block_modal.they_cant_mention": "Il ne peut pas vous mentionner ou vous suivre.",
-  "block_modal.they_cant_see_posts": "Il peut toujours voir vos messages, mais vous ne verrez pas les siens.",
+  "block_modal.they_cant_see_posts": "Il ne peut plus voir vos messages et vous ne verrez plus les siens.",
   "block_modal.they_will_know": "Il peut voir qu'il est bloqué.",
   "block_modal.title": "Bloquer le compte ?",
   "block_modal.you_wont_see_mentions": "Vous ne verrez pas les messages qui le mentionne.",
@@ -872,7 +872,9 @@
   "subscribed_languages.target": "Changer les langues abonnées pour {target}",
   "tabs_bar.home": "Accueil",
   "tabs_bar.notifications": "Notifications",
+  "terms_of_service.effective_as_of": "En vigueur à compter du {date}",
   "terms_of_service.title": "Conditions d'utilisation",
+  "terms_of_service.upcoming_changes_on": "Modifications à venir le {date}",
   "time_remaining.days": "{number, plural, one {# jour restant} other {# jours restants}}",
   "time_remaining.hours": "{number, plural, one {# heure restante} other {# heures restantes}}",
   "time_remaining.minutes": "{number, plural, one {# minute restante} other {# minutes restantes}}",
diff --git a/app/javascript/mastodon/locales/it.json b/app/javascript/mastodon/locales/it.json
index 13505d77a9..22a2209fc5 100644
--- a/app/javascript/mastodon/locales/it.json
+++ b/app/javascript/mastodon/locales/it.json
@@ -36,7 +36,7 @@
   "account.followers.empty": "Ancora nessuno segue questo utente.",
   "account.followers_counter": "{count, plural, one {{counter} seguace} other {{counter} seguaci}}",
   "account.following": "Seguiti",
-  "account.following_counter": "{count, plural, one {{counter} segui} other {{counter} segui}}",
+  "account.following_counter": "{count, plural, one {{counter} segui} other {{counter} seguiti}}",
   "account.follows.empty": "Questo utente non segue ancora nessuno.",
   "account.go_to_profile": "Vai al profilo",
   "account.hide_reblogs": "Nascondi condivisioni da @{name}",
diff --git a/app/javascript/mastodon/locales/ka.json b/app/javascript/mastodon/locales/ka.json
index d1587e1c2c..4dec178650 100644
--- a/app/javascript/mastodon/locales/ka.json
+++ b/app/javascript/mastodon/locales/ka.json
@@ -6,20 +6,20 @@
   "account.badges.group": "ჯგუფი",
   "account.block": "დაბლოკე @{name}",
   "account.block_domain": "დაიმალოს ყველაფერი დომენიდან {domain}",
-  "account.blocked": "დაიბლოკა",
+  "account.blocked": "დაბლოკილია",
   "account.cancel_follow_request": "Withdraw follow request",
   "account.domain_blocked": "დომენი დამალულია",
   "account.edit_profile": "პროფილის ცვლილება",
   "account.endorse": "გამორჩევა პროფილზე",
-  "account.featured_tags.last_status_never": "პოსტები არ არის",
+  "account.featured_tags.last_status_never": "პოსტების გარეშე",
   "account.follow": "გაყოლა",
   "account.followers": "მიმდევრები",
   "account.hide_reblogs": "დაიმალოს ბუსტები @{name}-სგან",
   "account.media": "მედია",
   "account.mention": "ასახელეთ @{name}",
   "account.mute": "გააჩუმე @{name}",
-  "account.muted": "გაჩუმებული",
-  "account.posts": "ტუტები",
+  "account.muted": "დადუმებულია",
+  "account.posts": "პოსტები",
   "account.posts_with_replies": "ტუტები და პასუხები",
   "account.report": "დაარეპორტე @{name}",
   "account.requested": "დამტკიცების მოლოდინში. დააწკაპუნეთ რომ უარყოთ დადევნების მოთხონვა",
@@ -42,7 +42,7 @@
   "column.community": "ლოკალური თაიმლაინი",
   "column.domain_blocks": "დამალული დომენები",
   "column.follow_requests": "დადევნების მოთხოვნები",
-  "column.home": "სახლი",
+  "column.home": "საწყისი",
   "column.lists": "სიები",
   "column.mutes": "გაჩუმებული მომხმარებლები",
   "column.notifications": "შეტყობინებები",
@@ -52,9 +52,9 @@
   "column_header.hide_settings": "პარამეტრების დამალვა",
   "column_header.moveLeft_settings": "სვეტის მარცხნივ გადატანა",
   "column_header.moveRight_settings": "სვეტის მარჯვნივ გადატანა",
-  "column_header.pin": "აპინვა",
+  "column_header.pin": "მიმაგრება",
   "column_header.show_settings": "პარამეტრების ჩვენება",
-  "column_header.unpin": "პინის მოხსნა",
+  "column_header.unpin": "მოხსნა",
   "column_subheading.settings": "პარამეტრები",
   "community.column_settings.media_only": "მხოლოდ მედია",
   "compose_form.direct_message_warning_learn_more": "გაიგე მეტი",
@@ -66,21 +66,21 @@
   "compose_form.publish_form": "Publish",
   "compose_form.spoiler.marked": "გაფრთხილების უკან ტექსტი დამალულია",
   "compose_form.spoiler.unmarked": "ტექსტი არაა დამალული",
-  "confirmation_modal.cancel": "უარყოფა",
+  "confirmation_modal.cancel": "გაუქმება",
   "confirmations.block.confirm": "ბლოკი",
-  "confirmations.delete.confirm": "გაუქმება",
+  "confirmations.delete.confirm": "წაშლა",
   "confirmations.delete.message": "დარწმუნებული ხართ, გსურთ გააუქმოთ ეს სტატუსი?",
-  "confirmations.delete_list.confirm": "გაუქმება",
+  "confirmations.delete_list.confirm": "წაშლა",
   "confirmations.delete_list.message": "დარწმუნებული ხართ, გსურთ სამუდამოდ გააუქმოთ ეს სია?",
-  "confirmations.mute.confirm": "გაჩუმება",
+  "confirmations.mute.confirm": "დადუმება",
   "confirmations.redraft.confirm": "გაუქმება და გადანაწილება",
   "confirmations.unfollow.confirm": "ნუღარ მიჰყვები",
   "confirmations.unfollow.message": "დარწმუნებული ხართ, აღარ გსურთ მიჰყვებოდეთ {name}-ს?",
   "embed.instructions": "ეს სტატუსი ჩასვით თქვენს ვებ-საიტზე შემდეგი კოდის კოპირებით.",
   "embed.preview": "ესაა თუ როგორც გამოჩნდება:",
   "emoji_button.activity": "აქტივობა",
-  "emoji_button.custom": "პერსონალიზირებული",
-  "emoji_button.flags": "დროშები",
+  "emoji_button.custom": "მომხმარებლის",
+  "emoji_button.flags": "ალმები",
   "emoji_button.food": "საჭმელი და სასლმელი",
   "emoji_button.label": "ემოჯის ჩასმა",
   "emoji_button.nature": "ბუმება",
@@ -119,7 +119,7 @@
   "keyboard_shortcuts.federated": "to open federated timeline",
   "keyboard_shortcuts.heading": "კლავიატურის სწრაფი ბმულები",
   "keyboard_shortcuts.home": "to open home timeline",
-  "keyboard_shortcuts.hotkey": "ცხელი კლავიში",
+  "keyboard_shortcuts.hotkey": "მალსახმობი ღილაკი",
   "keyboard_shortcuts.legend": "ამ ლეგენდის გამოსაჩენად",
   "keyboard_shortcuts.local": "to open local timeline",
   "keyboard_shortcuts.mention": "ავტორის დასახელებლად",
@@ -180,20 +180,20 @@
   "relative_time.just_now": "ახლა",
   "relative_time.minutes": "{number}წთ",
   "relative_time.seconds": "{number}წმ",
-  "reply_indicator.cancel": "უარყოფა",
+  "reply_indicator.cancel": "გაუქმება",
   "report.forward": "ფორვარდი {target}-ს",
   "report.forward_hint": "ანგარიში სხვა სერვერიდანაა. გავაგზავნოთ რეპორტის ანონიმური ასლიც?",
   "report.placeholder": "დამატებითი კომენტარები",
-  "report.submit": "დასრულება",
+  "report.submit": "გადაცემა",
   "report.target": "არეპორტებთ {target}",
   "report_notification.attached_statuses": "{count, plural, one {# post} other {# posts}} attached",
   "search.placeholder": "ძებნა",
   "search_results.hashtags": "ჰეშტეგები",
-  "search_results.statuses": "ტუტები",
-  "sign_in_banner.sign_in": "Sign in",
+  "search_results.statuses": "პოსტები",
+  "sign_in_banner.sign_in": "შესვლა",
   "status.admin_status": "Open this status in the moderation interface",
   "status.block": "დაბლოკე @{name}",
-  "status.cancel_reblog_private": "ბუსტის მოშორება",
+  "status.cancel_reblog_private": "ბუსტის მოხსნა",
   "status.cannot_reblog": "ეს პოსტი ვერ დაიბუსტება",
   "status.copy": "Copy link to status",
   "status.delete": "წაშლა",
@@ -222,7 +222,7 @@
   "status.title.with_attachments": "{user} posted {attachmentCount, plural, one {an attachment} other {# attachments}}",
   "status.unmute_conversation": "საუბარზე გაჩუმების მოშორება",
   "status.unpin": "პროფილიდან პინის მოშორება",
-  "tabs_bar.home": "სახლი",
+  "tabs_bar.home": "საწყისი",
   "tabs_bar.notifications": "შეტყობინებები",
   "trends.counter_by_accounts": "{count, plural, one {{counter} person} other {{counter} people}} in the past {days, plural, one {day} other {# days}}",
   "ui.beforeunload": "თქვენი დრაფტი გაუქმდება თუ დატოვებთ მასტოდონს.",
diff --git a/app/javascript/mastodon/locales/ko.json b/app/javascript/mastodon/locales/ko.json
index 5b93084c13..e9c5ef2133 100644
--- a/app/javascript/mastodon/locales/ko.json
+++ b/app/javascript/mastodon/locales/ko.json
@@ -874,6 +874,7 @@
   "tabs_bar.notifications": "알림",
   "terms_of_service.effective_as_of": "{date}부터 적용됨",
   "terms_of_service.title": "이용 약관",
+  "terms_of_service.upcoming_changes_on": "{date}에 예정된 변경사항",
   "time_remaining.days": "{number} 일 남음",
   "time_remaining.hours": "{number} 시간 남음",
   "time_remaining.minutes": "{number} 분 남음",
diff --git a/app/javascript/mastodon/locales/lv.json b/app/javascript/mastodon/locales/lv.json
index 08e52077e9..6cf1558198 100644
--- a/app/javascript/mastodon/locales/lv.json
+++ b/app/javascript/mastodon/locales/lv.json
@@ -173,7 +173,7 @@
   "compose.published.open": "Atvērt",
   "compose.saved.body": "Ziņa saglabāta.",
   "compose_form.direct_message_warning_learn_more": "Uzzināt vairāk",
-  "compose_form.encryption_warning": "Mastodon ieraksti nav pilnībā šifrēti. Nedalies ar jebkādu jutīgu informāciju caur Mastodon!",
+  "compose_form.encryption_warning": "Mastodon ieraksti nav pilnībā šifrēti. Nedalies ar jebkādu jūtīgu informāciju caur Mastodon!",
   "compose_form.hashtag_warning": "Šis ieraksts netiks uzrādīts nevienā tēmturī, jo tas nav redzams visiem. Tikai visiem redzamos ierakstus var meklēt pēc tēmtura.",
   "compose_form.lock_disclaimer": "Tavs konts nav {locked}. Ikviens var Tev sekot, lai redzētu tikai sekotājiem paredzētos ierakstus.",
   "compose_form.lock_disclaimer.lock": "slēgts",
@@ -474,9 +474,9 @@
   "notification.moderation_warning": "Ir saņemts satura pārraudzības brīdinājums",
   "notification.moderation_warning.action_delete_statuses": "Daži no Taviem ierakstiem tika noņemti.",
   "notification.moderation_warning.action_disable": "Tavs konts tika atspējots.",
-  "notification.moderation_warning.action_mark_statuses_as_sensitive": "Daži no Taviem ierakstiem tika atzīmēti kā jutīgi.",
+  "notification.moderation_warning.action_mark_statuses_as_sensitive": "Daži no Taviem ierakstiem tika atzīmēti kā jūtīgi.",
   "notification.moderation_warning.action_none": "Konts ir saņēmis satura pārraudzības brīdinājumu.",
-  "notification.moderation_warning.action_sensitive": "Tavi ieraksti turpmāk tiks atzīmēti kā jutīgi.",
+  "notification.moderation_warning.action_sensitive": "Tavi ieraksti turpmāk tiks atzīmēti kā jūtīgi.",
   "notification.moderation_warning.action_silence": "Tavs konts tika ierobežots.",
   "notification.moderation_warning.action_suspend": "Tava konta darbība tika apturēta.",
   "notification.own_poll": "Tava aptauja ir noslēgusies",
@@ -702,7 +702,7 @@
   "status.reply": "Atbildēt",
   "status.replyAll": "Atbildēt uz tematu",
   "status.report": "Ziņot par @{name}",
-  "status.sensitive_warning": "Sensitīvs saturs",
+  "status.sensitive_warning": "Jūtīgs saturs",
   "status.share": "Kopīgot",
   "status.show_less_all": "Rādīt mazāk visiem",
   "status.show_more_all": "Rādīt vairāk visiem",
diff --git a/app/javascript/mastodon/locales/nan.json b/app/javascript/mastodon/locales/nan.json
index 0daba1d8ae..57eef0a874 100644
--- a/app/javascript/mastodon/locales/nan.json
+++ b/app/javascript/mastodon/locales/nan.json
@@ -309,6 +309,15 @@
   "empty_column.followed_tags": "Lí iáu buē收著任何ê hashtag。Nā是lí收著,ē佇tsia顯示。",
   "empty_column.hashtag": "Tsit ê hashtag內底無物件。",
   "empty_column.home": "Lí tshù ê時間線是空ê!跟tuè別lâng來kā充滿。",
+  "empty_column.list": "Tsit張列單內底iáu bô物件。若是列單內底ê成員貼新ê PO文,in ē tī tsia顯示。",
+  "empty_column.mutes": "Lí iáu無消音任何用者。",
+  "empty_column.notification_requests": "清hōo空ah!內底無物件。若是lí收著新ê通知,ē根據lí ê設定,佇tsia出現。",
+  "empty_column.notifications": "Lí iáu無收著任何通知。Nā別lâng kap lí互動,lí ē佇tsia看著。",
+  "empty_column.public": "內底無物件!寫beh公開ê PO文,á是主動跟tuè別ê服侍器ê用者,來加添內容。",
+  "error.unexpected_crash.explanation": "因為原始碼內底有錯誤,á是瀏覽器相容出tshê,tsit頁bē當正確顯示。",
+  "error.unexpected_crash.explanation_addons": "Tsit頁bē當正確顯示,可能是瀏覽器附ê功能,á是自動翻譯工具所致。",
+  "error.unexpected_crash.next_steps": "請試更新tsit頁。若是bē當改善,lí iáu是ē當改使用無kâng ê瀏覽器,á是app,來用Mastodon。",
+  "error.unexpected_crash.next_steps_addons": "請試kā in停止使用,suà落來更新tsit頁。若是bē當改善,lí iáu是ē當改使用無kâng ê瀏覽器,á是app,來用Mastodon。",
   "errors.unexpected_crash.copy_stacktrace": "Khóo-pih stacktrace kàu剪貼pang-á",
   "errors.unexpected_crash.report_issue": "報告問題",
   "explore.suggested_follows": "用者",
@@ -345,6 +354,11 @@
   "follow_suggestions.dismiss": "Mài koh顯示。",
   "follow_suggestions.featured_longer": "{domain} 團隊所揀ê",
   "follow_suggestions.friends_of_friends_longer": "時行佇lí所tuè ê lâng",
+  "follow_suggestions.hints.featured": "Tsit ê個人資料是 {domain} 團隊特別揀ê。",
+  "follow_suggestions.hints.friends_of_friends": "Tsit ê個人資料tī lí跟tuè ê lâng之間真流行。",
+  "follow_suggestions.hints.most_followed": "Tsit ê個人資料是 {domain} 內,有足tsē跟tuè者ê其中tsit ê。",
+  "follow_suggestions.hints.most_interactions": "Tsit ê個人資料tsi̍t-tsām-á佇 {domain} 有得著真tsē關注。",
+  "follow_suggestions.hints.similar_to_recently_followed": "Tsit ê個人資料kap lí最近跟tuè ê口座相siâng。",
   "follow_suggestions.personalized_suggestion": "個人化ê推薦",
   "follow_suggestions.popular_suggestion": "流行ê推薦",
   "follow_suggestions.popular_suggestion_longer": "佇{domain} 足有lâng緣",
@@ -378,6 +392,9 @@
   "hashtag.follow": "跟tuè hashtag",
   "hashtag.unfollow": "取消跟tuè hashtag",
   "hashtags.and_other": "……kap 其他 {count, plural, other {# ê}}",
+  "hints.profiles.followers_may_be_missing": "Tsit ê個人資料ê跟tuè者資訊可能有落勾ê。",
+  "hints.profiles.follows_may_be_missing": "Tsit ê口座所跟tuè ê ê資訊可能有落勾ê。",
+  "hints.profiles.posts_may_be_missing": "Tsit ê口座ê tsi̍t kuá PO文可能有落勾ê。",
   "hints.profiles.see_more_followers": "佇 {domain} 看koh khah tsē跟tuè lí ê",
   "hints.profiles.see_more_follows": "佇 {domain} 看koh khah tsē lí跟tuè ê",
   "hints.profiles.see_more_posts": "佇 {domain} 看koh khah tsē ê PO文",
@@ -390,6 +407,67 @@
   "home.pending_critical_update.link": "看更新內容",
   "home.pending_critical_update.title": "有重要ê安全更新!",
   "home.show_announcements": "顯示公告",
+  "ignore_notifications_modal.disclaimer": "Lí所忽略in ê通知ê用者,Mastodonbē當kā lí通知。忽略通知bē當阻擋訊息ê寄送。",
+  "ignore_notifications_modal.filter_instead": "改做過濾",
+  "ignore_notifications_modal.filter_to_act_users": "Lí猶原ē當接受、拒絕猶是檢舉用者",
+  "ignore_notifications_modal.filter_to_avoid_confusion": "過濾ē當避免可能ê bē分明。",
+  "ignore_notifications_modal.filter_to_review_separately": "Lí ē當個別檢視所過濾ê通知",
+  "ignore_notifications_modal.ignore": "Kā通知忽略",
+  "ignore_notifications_modal.limited_accounts_title": "Kám beh忽略受限制ê口座送來ê通知?",
+  "ignore_notifications_modal.new_accounts_title": "Kám beh忽略新口座送來ê通知?",
+  "ignore_notifications_modal.not_followers_title": "Kám beh忽略無跟tuè lí ê口座送來ê通知?",
+  "ignore_notifications_modal.not_following_title": "Kám beh忽略lí 無跟tuè ê口座送來ê通知?",
+  "ignore_notifications_modal.private_mentions_title": "忽略ka-kī主動送ê私人提起ê通知?",
+  "info_button.label": "幫tsān",
+  "info_button.what_is_alt_text": "<h1>Siánn物是替代文字?</h1> <p>替代文字kā視覺有障礙、網路速度khah慢,á是beh tshuē頂下文ê lâng,提供圖ê敘述。</p> <p>Lí ē當通過寫明白、簡單kap客觀ê替代文字,替逐家改善容易使用性kap幫tsān理解。</p> <ul> <li>掌握重要ê因素</li> <li>替圖寫摘要ê文字</li> <li>用規則ê語句結構</li> <li>避免重複ê資訊</li> <li>專注佇趨勢kap佇複雜視覺(比如圖表á是地圖)內底tshuē關鍵</li> </ul>",
+  "interaction_modal.action.favourite": "Nā beh繼續,lí tio̍h用你ê口座收藏。",
+  "interaction_modal.action.follow": "Nā beh繼續,lí tio̍h用你ê口座跟tuè。",
+  "interaction_modal.action.reblog": "Nā beh繼續,lí tio̍h用你ê口座轉送。",
+  "interaction_modal.action.reply": "Nā beh繼續,lí tio̍h用你ê口座回應。",
+  "interaction_modal.action.vote": "Nā beh繼續,lí tio̍h用你ê口座投票。",
+  "interaction_modal.go": "行",
+  "interaction_modal.no_account_yet": "Tsit-má iáu bô口座?",
+  "interaction_modal.on_another_server": "佇無kâng ê服侍器",
+  "interaction_modal.on_this_server": "Tī tsit ê服侍器",
+  "interaction_modal.title.favourite": "收藏 {name} ê PO文",
+  "interaction_modal.title.follow": "跟tuè {name}",
+  "interaction_modal.title.reblog": "轉送 {name} ê PO文",
+  "interaction_modal.title.reply": "回應 {name} ê PO文",
+  "interaction_modal.title.vote": "參加 {name} ê投票",
+  "interaction_modal.username_prompt": "比如:{example}",
+  "intervals.full.days": "{number, plural, other {# kang}}",
+  "intervals.full.hours": "{number, plural, other {# 點鐘}}",
+  "intervals.full.minutes": "{number, plural, other {# 分鐘}}",
+  "keyboard_shortcuts.back": "Tńg去",
+  "keyboard_shortcuts.blocked": "開封鎖ê用者ê列單",
+  "keyboard_shortcuts.boost": "轉送PO文",
+  "keyboard_shortcuts.column": "揀tsit ê欄",
+  "keyboard_shortcuts.compose": "揀寫文字ê框仔",
+  "keyboard_shortcuts.description": "說明",
+  "keyboard_shortcuts.direct": "phah開私人提起ê欄",
+  "keyboard_shortcuts.down": "佇列單內kā suá khah 下kha",
+  "keyboard_shortcuts.enter": "Phah開PO文",
+  "keyboard_shortcuts.favourite": "收藏PO文",
+  "keyboard_shortcuts.favourites": "Phah開收藏ê列單",
+  "keyboard_shortcuts.federated": "Phah開聯邦ê時間線",
+  "keyboard_shortcuts.heading": "鍵盤ê快速key",
+  "keyboard_shortcuts.home": "Phah開tshù ê時間線",
+  "keyboard_shortcuts.hotkey": "快速key",
+  "keyboard_shortcuts.legend": "顯示tsit篇說明",
+  "keyboard_shortcuts.local": "Phah開本站ê時間線",
+  "keyboard_shortcuts.mention": "提起作者",
+  "keyboard_shortcuts.muted": "Phah開消音ê用者列單",
+  "keyboard_shortcuts.my_profile": "Phah開lí ê個人資料",
+  "keyboard_shortcuts.notifications": "Phah開通知欄",
+  "keyboard_shortcuts.open_media": "Phah開媒體",
+  "keyboard_shortcuts.pinned": "Phah開釘起來ê PO文列單",
+  "keyboard_shortcuts.profile": "Phah開作者ê個人資料",
+  "keyboard_shortcuts.reply": "回應PO文",
+  "keyboard_shortcuts.requests": "Phah開跟tuè請求ê列單",
+  "keyboard_shortcuts.search": "揀tshiau-tshuē條á",
+  "keyboard_shortcuts.spoilers": "顯示/隱藏內容警告",
+  "keyboard_shortcuts.start": "Phah開「開始用」欄",
+  "keyboard_shortcuts.toggle_hidden": "顯示/隱藏內容警告後壁ê PO文",
   "notification.favourite_pm": "{name} kah意lí ê私人提起",
   "notification.favourite_pm.name_and_others_with_link": "{name} kap<a>{count, plural, other {另外 # ê lâng}}</a>kah意lí ê私人提起",
   "search_popout.language_code": "ISO語言代碼",
diff --git a/app/javascript/mastodon/locales/ru.json b/app/javascript/mastodon/locales/ru.json
index 7d4369642c..ed3f741197 100644
--- a/app/javascript/mastodon/locales/ru.json
+++ b/app/javascript/mastodon/locales/ru.json
@@ -872,7 +872,9 @@
   "subscribed_languages.target": "Изменить языки подписки для {target}",
   "tabs_bar.home": "Главная",
   "tabs_bar.notifications": "Уведомления",
+  "terms_of_service.effective_as_of": "Действует с {date}",
   "terms_of_service.title": "Пользовательское соглашение",
+  "terms_of_service.upcoming_changes_on": "Предстоящие изменения {date}",
   "time_remaining.days": "{number, plural, one {остался # день} few {осталось # дня} many {осталось # дней} other {осталось # дней}}",
   "time_remaining.hours": "{number, plural, one {остался # час} few {осталось # часа} many {осталось # часов} other {осталось # часов}}",
   "time_remaining.minutes": "{number, plural, one {осталась # минута} few {осталось # минуты} many {осталось # минут} other {осталось # минут}}",
diff --git a/app/javascript/mastodon/locales/tok.json b/app/javascript/mastodon/locales/tok.json
index 7291588a38..a5612aa5be 100644
--- a/app/javascript/mastodon/locales/tok.json
+++ b/app/javascript/mastodon/locales/tok.json
@@ -65,7 +65,7 @@
   "account.statuses_counter": "{count, plural, other {toki {counter}}}",
   "account.unblock": "o weka ala e jan {name}",
   "account.unblock_domain": "o weka ala e ma {domain}",
-  "account.unblock_short": "o weka ala",
+  "account.unblock_short": "o pini weka",
   "account.unendorse": "lipu jan la o suli ala e ni",
   "account.unfollow": "o kute ala",
   "account.unmute": "o len ala e @{name}",
@@ -114,8 +114,8 @@
   "attachments_list.unprocessed": "(nasin open)",
   "audio.hide": "o len e kalama",
   "block_modal.remote_users_caveat": "mi pana e wile sina tawa ma {domain}. taso, o sona: ma li ken kepeken nasin len ante la pakala li ken lon. toki pi lukin ale la jan pi ma ala li ken lukin.",
-  "block_modal.show_less": "o lili e lukin",
-  "block_modal.show_more": "o suli e lukin",
+  "block_modal.show_less": "o pana e lili",
+  "block_modal.show_more": "o pana e mute",
   "block_modal.they_cant_mention": "ona li ken ala toki tawa sina li ken ala kute e sina.",
   "block_modal.they_cant_see_posts": "ona li ken ala lukin e toki sina. sina ken ala lukin e toki ona.",
   "block_modal.they_will_know": "ona li sona e ni: sina weka e lukin ona.",
@@ -215,6 +215,9 @@
   "confirmations.logout.confirm": "o weka",
   "confirmations.logout.message": "sina wile ala wile weka",
   "confirmations.logout.title": "o weka?",
+  "confirmations.missing_alt_text.confirm": "pana e toki pi sona lukin",
+  "confirmations.missing_alt_text.message": "toki ni la sitelen li lon. taso toki pi sona lukin li lon ala. toki pi sona lukin li pona tan ni: jan ale li ken sona e toki.",
+  "confirmations.missing_alt_text.title": "o pana e toki pi sona lukin",
   "confirmations.mute.confirm": "o len",
   "confirmations.redraft.confirm": "o weka o pali sin e toki",
   "confirmations.redraft.message": "pali sin e toki ni la sina wile ala wile weka e ona? sina ni la suli pi toki ni en wawa pi toki ni li weka. kin la toki lon toki ni li jo e mama ala.",
@@ -235,6 +238,7 @@
   "copy_icon_button.copied": "toki li awen lon ilo sina",
   "copypaste.copied": "sina jo e toki",
   "copypaste.copy_to_clipboard": "o awen lon ilo sina",
+  "directory.federated": "tan lipu ante sona",
   "directory.local": "tan {domain} taso",
   "directory.new_arrivals": "jan pi kama sin",
   "directory.recently_active": "jan lon tenpo poka",
@@ -243,6 +247,7 @@
   "dismissable_banner.community_timeline": "ni li toki pi tenpo poka tawa ale tan jan lon ma lawa pi nimi {domain}.",
   "dismissable_banner.dismiss": "o weka",
   "dismissable_banner.explore_links": "tenpo suno ni la jan pi kulupu ale li toki e ijo sin ni. ijo sin pi jan ante mute li sewi lon lipu ni.",
+  "dismissable_banner.explore_statuses": "jan mute li lukin e toki ni tan ma ilo weka. toki sin en toki pi wawa mute li lon sewi.",
   "domain_block_modal.block": "o weka e ma",
   "domain_block_modal.they_wont_know": "ona li sona ala e ni: sina weka e ona.",
   "domain_block_modal.title": "sina wile weka ala weka e ma?",
@@ -276,6 +281,7 @@
   "emoji_button.symbols": "sitelen",
   "emoji_button.travel": "ma en tawa",
   "empty_column.account_hides_collections": "jan ni li wile len e sona ni",
+  "empty_column.account_suspended": "lipu ni li weka",
   "empty_column.account_timeline": "toki ala li lon!",
   "empty_column.account_unavailable": "ken ala lukin e lipu jan",
   "empty_column.blocks": "jan ala li weka tawa sina.",
@@ -328,6 +334,7 @@
   "hashtag.counter_by_uses": "{count, plural, other {toki {counter}}}",
   "hashtag.follow": "o kute e kulupu lipu",
   "hashtag.unfollow": "o kute ala e kulupu lipu",
+  "home.column_settings.show_reblogs": "lukin e wawa",
   "home.pending_critical_update.link": "o lukin e ijo ilo sin",
   "info_button.label": "sona",
   "interaction_modal.go": "o tawa ma ni",
@@ -376,6 +383,7 @@
   "navigation_bar.about": "sona",
   "navigation_bar.blocks": "jan weka",
   "navigation_bar.compose": "o pali e toki sin",
+  "navigation_bar.domain_blocks": "kulupu pi ma weka",
   "navigation_bar.favourites": "ijo pona",
   "navigation_bar.filters": "nimi len",
   "navigation_bar.lists": "kulupu lipu",
@@ -472,6 +480,8 @@
   "status.pin": "o sewi lon lipu sina",
   "status.pinned": "toki sewi",
   "status.reblog": "o wawa",
+  "status.reblogged_by": "jan {name} li wawa",
+  "status.reblogs.empty": "jan ala li wawa e toki ni. jan li wawa la, nimi ona li sitelen lon ni.",
   "status.share": "o pana tawa ante",
   "status.show_less_all": "o lili e ale",
   "status.show_more_all": "o suli e ale",
diff --git a/app/javascript/mastodon/locales/zh-CN.json b/app/javascript/mastodon/locales/zh-CN.json
index bb7ee3c086..b116008fac 100644
--- a/app/javascript/mastodon/locales/zh-CN.json
+++ b/app/javascript/mastodon/locales/zh-CN.json
@@ -872,7 +872,9 @@
   "subscribed_languages.target": "更改 {target} 的订阅语言",
   "tabs_bar.home": "主页",
   "tabs_bar.notifications": "通知",
+  "terms_of_service.effective_as_of": "自 {date} 起生效",
   "terms_of_service.title": "服务条款",
+  "terms_of_service.upcoming_changes_on": "将于 {date} 进行变更",
   "time_remaining.days": "剩余 {number, plural, other {# 天}}",
   "time_remaining.hours": "剩余 {number, plural, other {# 小时}}",
   "time_remaining.minutes": "剩余 {number, plural, other {# 分钟}}",
diff --git a/app/javascript/mastodon/models/alert.ts b/app/javascript/mastodon/models/alert.ts
new file mode 100644
index 0000000000..bc492eff3c
--- /dev/null
+++ b/app/javascript/mastodon/models/alert.ts
@@ -0,0 +1,14 @@
+import type { MessageDescriptor } from 'react-intl';
+
+export type TranslatableString = string | MessageDescriptor;
+
+export type TranslatableValues = Record<string, string | number | Date>;
+
+export interface Alert {
+  key: number;
+  title?: TranslatableString;
+  message: TranslatableString;
+  action?: TranslatableString;
+  values?: TranslatableValues;
+  onClick?: () => void;
+}
diff --git a/app/javascript/mastodon/reducers/alerts.js b/app/javascript/mastodon/reducers/alerts.js
deleted file mode 100644
index 1ca9b62a02..0000000000
--- a/app/javascript/mastodon/reducers/alerts.js
+++ /dev/null
@@ -1,30 +0,0 @@
-import { List as ImmutableList } from 'immutable';
-
-import {
-  ALERT_SHOW,
-  ALERT_DISMISS,
-  ALERT_CLEAR,
-} from '../actions/alerts';
-
-const initialState = ImmutableList([]);
-
-let id = 0;
-
-const addAlert = (state, alert) =>
-  state.push({
-    key: id++,
-    ...alert,
-  });
-
-export default function alerts(state = initialState, action) {
-  switch(action.type) {
-  case ALERT_SHOW:
-    return addAlert(state, action.alert);
-  case ALERT_DISMISS:
-    return state.filterNot(item => item.key === action.alert.key);
-  case ALERT_CLEAR:
-    return state.clear();
-  default:
-    return state;
-  }
-}
diff --git a/app/javascript/mastodon/reducers/alerts.ts b/app/javascript/mastodon/reducers/alerts.ts
new file mode 100644
index 0000000000..30108744ae
--- /dev/null
+++ b/app/javascript/mastodon/reducers/alerts.ts
@@ -0,0 +1,24 @@
+import { createReducer } from '@reduxjs/toolkit';
+
+import { showAlert, dismissAlert, clearAlerts } from 'mastodon/actions/alerts';
+import type { Alert } from 'mastodon/models/alert';
+
+const initialState: Alert[] = [];
+
+let id = 0;
+
+export const alertsReducer = createReducer(initialState, (builder) => {
+  builder
+    .addCase(showAlert, (state, { payload }) => {
+      state.push({
+        key: id++,
+        ...payload,
+      });
+    })
+    .addCase(dismissAlert, (state, { payload: { key } }) => {
+      return state.filter((item) => item.key !== key);
+    })
+    .addCase(clearAlerts, () => {
+      return [];
+    });
+});
diff --git a/app/javascript/mastodon/reducers/index.ts b/app/javascript/mastodon/reducers/index.ts
index fd83682e12..719ccf86b9 100644
--- a/app/javascript/mastodon/reducers/index.ts
+++ b/app/javascript/mastodon/reducers/index.ts
@@ -5,7 +5,7 @@ import { combineReducers } from 'redux-immutable';
 
 import { accountsReducer } from './accounts';
 import accounts_map from './accounts_map';
-import alerts from './alerts';
+import { alertsReducer } from './alerts';
 import announcements from './announcements';
 import { antennasReducer } from './antennas';
 import { bookmarkCategoriesReducer } from './bookmark_categories';
@@ -49,7 +49,7 @@ const reducers = {
   dropdownMenu: dropdownMenuReducer,
   timelines,
   meta,
-  alerts,
+  alerts: alertsReducer,
   loadingBar: loadingBarReducer,
   modal: modalReducer,
   user_lists,
diff --git a/app/javascript/mastodon/selectors/index.js b/app/javascript/mastodon/selectors/index.js
index 9ae91dac3a..91ff05df4d 100644
--- a/app/javascript/mastodon/selectors/index.js
+++ b/app/javascript/mastodon/selectors/index.js
@@ -17,9 +17,10 @@ export const makeGetStatus = () => {
       (state, { id }) => state.getIn(['accounts', state.getIn(['statuses', id, 'account'])]),
       (state, { id }) => state.getIn(['accounts', state.getIn(['statuses', state.getIn(['statuses', id, 'reblog']), 'account'])]),
       getFilters,
+      (_, { contextType }) => ['detailed', 'bookmarks', 'favourites'].includes(contextType),
     ],
 
-    (statusBase, statusReblog, statusQuote, statusReblogQuote, accountBase, accountReblog, filters) => {
+    (statusBase, statusReblog, statusQuote, statusReblogQuote, accountBase, accountReblog, filters, warnInsteadOfHide) => {
       if (!statusBase || statusBase.get('isLoading')) {
         return null;
       }
@@ -36,6 +37,7 @@ export const makeGetStatus = () => {
       }
 
       let filtered = false;
+      let mediaFiltered = false;
       if ((accountReblog || accountBase).get('id') !== me && filters) {
         let filterResults = statusReblog?.get('filtered') || statusBase.get('filtered') || ImmutableList();
         const quoteFilterResults = statusQuote?.get('filtered');
@@ -46,10 +48,16 @@ export const makeGetStatus = () => {
           }
         }
 
-        if (filterResults.some((result) => filters.getIn([result.get('filter'), 'filter_action']) === 'hide')) {
+        if (!warnInsteadOfHide && filterResults.some((result) => filters.getIn([result.get('filter'), 'filter_action']) === 'hide')) {
           return null;
         }
-        filterResults = filterResults.filter(result => filters.has(result.get('filter')));
+
+        let mediaFilters = filterResults.filter(result => filters.getIn([result.get('filter'), 'filter_action']) === 'blur');
+        if (!mediaFilters.isEmpty()) {
+          mediaFiltered = mediaFilters.map(result => filters.getIn([result.get('filter'), 'title']));
+        }
+
+        filterResults = filterResults.filter(result => filters.has(result.get('filter')) && filters.getIn([result.get('filter'), 'filter_action']) !== 'blur');
         if (!filterResults.isEmpty()) {
           filtered = filterResults.map(result => filters.getIn([result.get('filter'), 'title']));
         }
@@ -60,6 +68,7 @@ export const makeGetStatus = () => {
         map.set('quote', statusQuote);
         map.set('account', accountBase);
         map.set('matched_filters', filtered);
+        map.set('matched_media_filters', mediaFiltered);
       });
     },
   );
@@ -75,28 +84,6 @@ export const makeGetPictureInPicture = () => {
   }));
 };
 
-const ALERT_DEFAULTS = {
-  dismissAfter: 5000,
-  style: false,
-};
-
-const formatIfNeeded = (intl, message, values) => {
-  if (typeof message === 'object') {
-    return intl.formatMessage(message, values);
-  }
-
-  return message;
-};
-
-export const getAlerts = createSelector([state => state.get('alerts'), (_, { intl }) => intl], (alerts, intl) =>
-  alerts.map(item => ({
-    ...ALERT_DEFAULTS,
-    ...item,
-    action: formatIfNeeded(intl, item.action, item.values),
-    title: formatIfNeeded(intl, item.title, item.values),
-    message: formatIfNeeded(intl, item.message, item.values),
-  })).toArray());
-
 export const makeGetNotification = () => createSelector([
   (_, base)             => base,
   (state, _, accountId) => state.getIn(['accounts', accountId]),
diff --git a/app/javascript/mastodon/store/middlewares/errors.ts b/app/javascript/mastodon/store/middlewares/errors.ts
index 3ad3844d5b..b9efe9f2b4 100644
--- a/app/javascript/mastodon/store/middlewares/errors.ts
+++ b/app/javascript/mastodon/store/middlewares/errors.ts
@@ -12,19 +12,21 @@ import type { AsyncThunkRejectValue } from '../typed_functions';
 const defaultFailSuffix = 'FAIL';
 const isFailedAction = new RegExp(`${defaultFailSuffix}$`, 'g');
 
-interface ActionWithMaybeAlertParams extends Action, AsyncThunkRejectValue {}
-
 interface RejectedAction extends Action {
   payload: AsyncThunkRejectValue;
 }
 
+interface ActionWithMaybeAlertParams extends Action, AsyncThunkRejectValue {
+  payload?: AsyncThunkRejectValue;
+}
+
 function isRejectedActionWithPayload(
   action: unknown,
 ): action is RejectedAction {
   return isAsyncThunkAction(action) && isRejectedWithValue(action);
 }
 
-function isActionWithmaybeAlertParams(
+function isActionWithMaybeAlertParams(
   action: unknown,
 ): action is ActionWithMaybeAlertParams {
   return isAction(action);
@@ -40,11 +42,12 @@ export const errorsMiddleware: Middleware<{}, RootState> =
         showAlertForError(action.payload.error, action.payload.skipNotFound),
       );
     } else if (
-      isActionWithmaybeAlertParams(action) &&
-      !action.skipAlert &&
+      isActionWithMaybeAlertParams(action) &&
+      !(action.payload?.skipAlert || action.skipAlert) &&
       action.type.match(isFailedAction)
     ) {
-      dispatch(showAlertForError(action.error, action.skipNotFound));
+      const { error, skipNotFound } = action.payload ?? action;
+      dispatch(showAlertForError(error, skipNotFound));
     }
 
     return next(action);
diff --git a/app/javascript/mastodon/utils/filters.ts b/app/javascript/mastodon/utils/filters.ts
index 5d334fe509..479e1f44ab 100644
--- a/app/javascript/mastodon/utils/filters.ts
+++ b/app/javascript/mastodon/utils/filters.ts
@@ -7,6 +7,11 @@ export const toServerSideType = (columnType: string) => {
     case 'account':
     case 'explore':
       return columnType;
+    case 'detailed':
+      return 'thread';
+    case 'bookmarks':
+    case 'favourites':
+      return 'home';
     default:
       if (columnType.includes('list:') || columnType.includes('antenna:')) {
         return 'home';
diff --git a/app/javascript/styles/application.scss b/app/javascript/styles/application.scss
index 109b69bca5..b328d8ee34 100644
--- a/app/javascript/styles/application.scss
+++ b/app/javascript/styles/application.scss
@@ -1,25 +1,27 @@
-@import 'mastodon/mixins';
-@import 'mastodon/variables';
-@import 'fonts/roboto';
-@import 'fonts/roboto-mono';
+@use 'mastodon/functions';
+@use 'mastodon/mixins';
+@use 'mastodon/variables';
+@use 'mastodon/css_variables';
+@use 'fonts/roboto';
+@use 'fonts/roboto-mono';
 
-@import 'mastodon/reset';
-@import 'mastodon/basics';
-@import 'mastodon/branding';
-@import 'mastodon/containers';
-@import 'mastodon/lists';
-@import 'mastodon/widgets';
-@import 'mastodon/forms';
-@import 'mastodon/accounts';
-@import 'mastodon/components';
-@import 'mastodon/polls';
-@import 'mastodon/modal';
-@import 'mastodon/emoji_picker';
-@import 'mastodon/annual_reports';
-@import 'mastodon/about';
-@import 'mastodon/tables';
-@import 'mastodon/admin';
-@import 'mastodon/dashboard';
-@import 'mastodon/rtl';
-@import 'mastodon/accessibility';
-@import 'mastodon/rich_text';
+@use 'mastodon/reset';
+@use 'mastodon/basics';
+@use 'mastodon/branding';
+@use 'mastodon/containers';
+@use 'mastodon/lists';
+@use 'mastodon/widgets';
+@use 'mastodon/forms';
+@use 'mastodon/accounts';
+@use 'mastodon/components';
+@use 'mastodon/polls';
+@use 'mastodon/modal';
+@use 'mastodon/emoji_picker';
+@use 'mastodon/annual_reports';
+@use 'mastodon/about';
+@use 'mastodon/tables';
+@use 'mastodon/admin';
+@use 'mastodon/dashboard';
+@use 'mastodon/rtl';
+@use 'mastodon/accessibility';
+@use 'mastodon/rich_text';
diff --git a/app/javascript/styles/contrast.scss b/app/javascript/styles/contrast.scss
index 5b43aecbe7..367be051f1 100644
--- a/app/javascript/styles/contrast.scss
+++ b/app/javascript/styles/contrast.scss
@@ -1,3 +1,3 @@
-@import 'contrast/variables';
-@import 'application';
-@import 'contrast/diff';
+@use 'contrast/variables';
+@use 'application';
+@use 'contrast/diff';
diff --git a/app/javascript/styles/contrast/diff.scss b/app/javascript/styles/contrast/diff.scss
index ae607f484a..8aa05dd8ef 100644
--- a/app/javascript/styles/contrast/diff.scss
+++ b/app/javascript/styles/contrast/diff.scss
@@ -1,3 +1,5 @@
+@use '../mastodon/variables' as *;
+
 .status__content a,
 .reply-indicator__content a,
 .edit-indicator__content a,
diff --git a/app/javascript/styles/contrast/variables.scss b/app/javascript/styles/contrast/variables.scss
index 2bee5eca74..d63512ce43 100644
--- a/app/javascript/styles/contrast/variables.scss
+++ b/app/javascript/styles/contrast/variables.scss
@@ -1,3 +1,5 @@
+@use '../mastodon/functions' as *;
+
 // Dependent colors
 $black: #000000;
 
@@ -14,12 +16,13 @@ $ui-primary-color: $classic-primary-color !default;
 $ui-secondary-color: $classic-secondary-color !default;
 $ui-highlight-color: $classic-highlight-color !default;
 
-$darker-text-color: lighten($ui-primary-color, 20%) !default;
-$dark-text-color: lighten($ui-primary-color, 12%) !default;
-$secondary-text-color: lighten($ui-secondary-color, 6%) !default;
-$highlight-text-color: lighten($ui-highlight-color, 10%) !default;
-$action-button-color: lighten($ui-base-color, 50%);
-
-$inverted-text-color: $black !default;
-$lighter-text-color: darken($ui-base-color, 6%) !default;
-$light-text-color: darken($ui-primary-color, 40%) !default;
+@use '../mastodon/variables' with (
+  $darker-text-color: lighten($ui-primary-color, 20%),
+  $dark-text-color: lighten($ui-primary-color, 12%),
+  $secondary-text-color: lighten($ui-secondary-color, 6%),
+  $highlight-text-color: lighten($ui-highlight-color, 10%),
+  $action-button-color: lighten($ui-base-color, 50%),
+  $inverted-text-color: $black,
+  $lighter-text-color: darken($ui-base-color, 6%),
+  $light-text-color: darken($ui-primary-color, 40%)
+);
diff --git a/app/javascript/styles/full-dark.scss b/app/javascript/styles/full-dark.scss
index 105964ba6f..33195d5c6e 100644
--- a/app/javascript/styles/full-dark.scss
+++ b/app/javascript/styles/full-dark.scss
@@ -1,3 +1,4 @@
-@import 'full-dark/variables';
-@import 'application';
-@import 'full-dark/diff';
+@use 'full-dark/variables';
+@use 'full-dark/css_variables';
+@use 'application';
+@use 'full-dark/diff';
diff --git a/app/javascript/styles/full-dark/css_variables.scss b/app/javascript/styles/full-dark/css_variables.scss
new file mode 100644
index 0000000000..56e63dd23b
--- /dev/null
+++ b/app/javascript/styles/full-dark/css_variables.scss
@@ -0,0 +1,4 @@
+@use 'sass:color';
+@use '../mastodon/variables' as *;
+@use 'variables' as *;
+@use '../mastodon/functions' as *;
diff --git a/app/javascript/styles/full-dark/diff.scss b/app/javascript/styles/full-dark/diff.scss
index 9483e7ecb6..727072dc39 100644
--- a/app/javascript/styles/full-dark/diff.scss
+++ b/app/javascript/styles/full-dark/diff.scss
@@ -1,3 +1,7 @@
+@use 'sass:color';
+@use '../mastodon/functions' as *;
+@use '../mastodon/variables' as *;
+
 input[type='text']:not(#cw-spoiler-input),
 input[type='search'],
 input[type='number'],
diff --git a/app/javascript/styles/mailer.scss b/app/javascript/styles/mailer.scss
index 1f3310877a..1e339b4313 100644
--- a/app/javascript/styles/mailer.scss
+++ b/app/javascript/styles/mailer.scss
@@ -1,4 +1,4 @@
-@import 'fonts/inter';
+@use 'fonts/inter';
 
 body {
   accent-color: #6364ff;
diff --git a/app/javascript/styles/mastodon-light.scss b/app/javascript/styles/mastodon-light.scss
index 756a12d868..b530616a3c 100644
--- a/app/javascript/styles/mastodon-light.scss
+++ b/app/javascript/styles/mastodon-light.scss
@@ -1,3 +1,4 @@
-@import 'mastodon-light/variables';
-@import 'application';
-@import 'mastodon-light/diff';
+@use 'mastodon-light/variables';
+@use 'mastodon-light/css_variables';
+@use 'application';
+@use 'mastodon-light/diff';
diff --git a/app/javascript/styles/mastodon-light/css_variables.scss b/app/javascript/styles/mastodon-light/css_variables.scss
new file mode 100644
index 0000000000..d9311da1b9
--- /dev/null
+++ b/app/javascript/styles/mastodon-light/css_variables.scss
@@ -0,0 +1,21 @@
+@use 'sass:color';
+@use '../mastodon/variables' as *;
+@use 'variables' as *;
+@use '../mastodon/functions' as *;
+
+body {
+  --dropdown-border-color: hsl(240deg, 25%, 88%);
+  --dropdown-background-color: #fff;
+  --modal-border-color: hsl(240deg, 25%, 88%);
+  --modal-background-color: var(--background-color-tint);
+  --background-border-color: hsl(240deg, 25%, 88%);
+  --background-color: #fff;
+  --background-color-tint: rgba(255, 255, 255, 80%);
+  --background-filter: blur(10px);
+  --on-surface-color: #{color.adjust($ui-base-color, $alpha: -0.65)};
+  --rich-text-container-color: rgba(255, 216, 231, 100%);
+  --rich-text-text-color: rgba(114, 47, 83, 100%);
+  --rich-text-decorations-color: rgba(255, 175, 212, 100%);
+  --input-placeholder-color: #{color.adjust($dark-text-color, $alpha: -0.5)};
+  --input-background-color: #{darken($ui-base-color, 10%)};
+}
diff --git a/app/javascript/styles/mastodon-light/diff.scss b/app/javascript/styles/mastodon-light/diff.scss
index d23d789e46..8ca860a86d 100644
--- a/app/javascript/styles/mastodon-light/diff.scss
+++ b/app/javascript/styles/mastodon-light/diff.scss
@@ -1,5 +1,8 @@
 // Notes!
 // Sass color functions, "darken" and "lighten" are automatically replaced.
+@use 'sass:color';
+@use '../mastodon/functions' as *;
+@use '../mastodon/variables' as *;
 
 .simple_form .button.button-tertiary {
   color: $highlight-text-color;
@@ -152,8 +155,12 @@
 }
 
 .reactions-bar__item.active {
-  background-color: mix($white, $ui-highlight-color, 80%);
-  border-color: mix(lighten($ui-base-color, 8%), $ui-highlight-color, 80%);
+  background-color: color.mix($white, $ui-highlight-color, 80%);
+  border-color: color.mix(
+    lighten($ui-base-color, 8%),
+    $ui-highlight-color,
+    80%
+  );
 }
 
 .media-modal__overlay .picture-in-picture__footer {
@@ -242,7 +249,7 @@
 
 // Change the default colors used on some parts of the profile pages
 .activity-stream-tabs {
-  background: $account-background-color;
+  background: $white;
   border-bottom-color: lighten($ui-base-color, 8%);
 }
 
@@ -284,7 +291,7 @@
   }
 
   .entry {
-    background: $account-background-color;
+    background: $white;
 
     .detailed-status.light,
     .more.light,
diff --git a/app/javascript/styles/mastodon-light/variables.scss b/app/javascript/styles/mastodon-light/variables.scss
index c47ff2792c..43cd463868 100644
--- a/app/javascript/styles/mastodon-light/variables.scss
+++ b/app/javascript/styles/mastodon-light/variables.scss
@@ -1,87 +1,49 @@
 @use 'sass:color';
 
-// Dependent colors
-$black: #000000;
-$white: #ffffff;
+@use '../mastodon/functions' with (
+  $darken-multiplier: 1,
+  $lighten-multiplier: -1
+);
 
-$classic-base-color: hsl(240deg, 16%, 19%);
-$classic-primary-color: hsl(240deg, 29%, 70%);
-$classic-secondary-color: hsl(255deg, 25%, 88%);
-$classic-highlight-color: hsl(240deg, 100%, 69%);
-
-$blurple-600: hsl(252deg, 59%, 51%); // Iris
-$blurple-500: hsl(240deg, 100%, 69%); // Brand purple
-$blurple-300: hsl(237deg, 92%, 75%); // Faded Blue
+$black: #000000; // Black
+$white: #ffffff; // White
+$blurple-500: #6364ff; // Brand purple
 $grey-600: hsl(240deg, 8%, 33%); // Trout
 $grey-100: hsl(240deg, 51%, 90%); // Topaz
 
 $emoji-reaction-color: #dfe5f5 !default;
 $emoji-reaction-selected-color: #9ac1f2 !default;
 
-// Differences
-$success-green: lighten(hsl(138deg, 32%, 35%), 8%);
+$classic-base-color: hsl(240deg, 16%, 19%);
+$classic-secondary-color: hsl(255deg, 25%, 88%);
+$classic-highlight-color: $blurple-500;
 
-$base-overlay-background: $white !default;
-$valid-value-color: $success-green !default;
+@use '../mastodon/variables' with (
+  $success-green: color.adjust(
+      hsl(138deg, 32%, 35%),
+      $lightness: 8%,
+      $space: hsl
+    ),
+  $base-overlay-background: $white,
 
-$ui-base-color: $classic-secondary-color !default;
-$ui-base-lighter-color: hsl(250deg, 24%, 75%);
-$ui-primary-color: $classic-primary-color !default;
-$ui-secondary-color: $classic-base-color !default;
-$ui-highlight-color: $classic-highlight-color !default;
+  $ui-base-color: $classic-secondary-color,
+  $ui-base-lighter-color: hsl(250deg, 24%, 75%),
+  $ui-secondary-color: $classic-base-color,
 
-$ui-button-secondary-color: $grey-600 !default;
-$ui-button-secondary-border-color: $grey-600 !default;
-$ui-button-secondary-focus-color: $white !default;
+  $ui-button-secondary-color: $grey-600,
+  $ui-button-secondary-border-color: $grey-600,
+  $ui-button-secondary-focus-color: $white,
+  $ui-button-tertiary-color: $blurple-500,
+  $ui-button-tertiary-border-color: $blurple-500,
 
-$ui-button-tertiary-color: $blurple-500 !default;
-$ui-button-tertiary-border-color: $blurple-500 !default;
+  $primary-text-color: $black,
+  $darker-text-color: $classic-base-color,
+  $lighter-text-color: $classic-base-color,
+  $highlight-text-color: $classic-highlight-color,
+  $dark-text-color: hsl(240deg, 16%, 32%),
+  $light-text-color: hsl(240deg, 16%, 32%),
+  $inverted-text-color: $black,
 
-$primary-text-color: $black !default;
-$darker-text-color: $classic-base-color !default;
-$highlight-text-color: $ui-highlight-color !default;
-$dark-text-color: hsl(240deg, 16%, 32%);
-$action-button-color: hsl(240deg, 16%, 45%);
-
-$inverted-text-color: $black !default;
-$lighter-text-color: $classic-base-color !default;
-$light-text-color: hsl(240deg, 16%, 32%);
-
-// Newly added colors
-$account-background-color: $white !default;
-
-// Invert darkened and lightened colors
-@function darken($color, $amount) {
-  @return hsl(
-    hue($color),
-    color.channel($color, 'saturation', $space: hsl),
-    color.channel($color, 'lightness', $space: hsl) + $amount
-  );
-}
-
-@function lighten($color, $amount) {
-  @return hsl(
-    hue($color),
-    color.channel($color, 'saturation', $space: hsl),
-    color.channel($color, 'lightness', $space: hsl) - $amount
-  );
-}
-
-$emojis-requiring-inversion: 'chains';
-
-body {
-  --dropdown-border-color: hsl(240deg, 25%, 88%);
-  --dropdown-background-color: #fff;
-  --modal-border-color: hsl(240deg, 25%, 88%);
-  --modal-background-color: var(--background-color-tint);
-  --background-border-color: hsl(240deg, 25%, 88%);
-  --background-color: #fff;
-  --background-color-tint: rgba(255, 255, 255, 80%);
-  --background-filter: blur(10px);
-  --on-surface-color: #{transparentize($ui-base-color, 0.65)};
-  --rich-text-container-color: rgba(255, 216, 231, 100%);
-  --rich-text-text-color: rgba(114, 47, 83, 100%);
-  --rich-text-decorations-color: rgba(255, 175, 212, 100%);
-  --input-placeholder-color: #{transparentize($dark-text-color, 0.5)};
-  --input-background-color: #{darken($ui-base-color, 10%)};
-}
+  $action-button-color: hsl(240deg, 16%, 45%),
+  $emojis-requiring-inversion: 'chains'
+);
diff --git a/app/javascript/styles/mastodon/_functions.scss b/app/javascript/styles/mastodon/_functions.scss
new file mode 100644
index 0000000000..7190a6233e
--- /dev/null
+++ b/app/javascript/styles/mastodon/_functions.scss
@@ -0,0 +1,21 @@
+@use 'sass:color';
+
+$darken-multiplier: -1 !default;
+$lighten-multiplier: 1 !default;
+
+// Invert darkened and lightened colors
+@function darken($color, $amount) {
+  @return color.adjust(
+    $color,
+    $lightness: $amount * $darken-multiplier,
+    $space: hsl
+  );
+}
+
+@function lighten($color, $amount) {
+  @return color.adjust(
+    $color,
+    $lightness: $amount * $lighten-multiplier,
+    $space: hsl
+  );
+}
diff --git a/app/javascript/styles/mastodon/_mixins.scss b/app/javascript/styles/mastodon/_mixins.scss
index 2599cb0e05..b7d9203e3f 100644
--- a/app/javascript/styles/mastodon/_mixins.scss
+++ b/app/javascript/styles/mastodon/_mixins.scss
@@ -1,3 +1,5 @@
+@use 'variables' as *;
+
 @mixin search-input {
   outline: 0;
   box-sizing: border-box;
diff --git a/app/javascript/styles/mastodon/variables.scss b/app/javascript/styles/mastodon/_variables.scss
similarity index 68%
rename from app/javascript/styles/mastodon/variables.scss
rename to app/javascript/styles/mastodon/_variables.scss
index 6b1057605d..ea2d216441 100644
--- a/app/javascript/styles/mastodon/variables.scss
+++ b/app/javascript/styles/mastodon/_variables.scss
@@ -1,4 +1,5 @@
 @use 'sass:color';
+@use 'functions' as *;
 
 // Commonly used web colors
 $black: #000000; // Black
@@ -101,43 +102,13 @@ $media-modal-media-max-height: 80%;
 
 $no-gap-breakpoint: 1175px;
 $mobile-breakpoint: 630px;
+$no-columns-breakpoint: 600px;
 
 $font-sans-serif: 'mastodon-font-sans-serif' !default;
 $font-display: 'mastodon-font-display' !default;
 $font-monospace: 'mastodon-font-monospace' !default;
 
-:root {
-  --dropdown-border-color: #{lighten($ui-base-color, 4%)};
-  --dropdown-background-color: #{rgba(darken($ui-base-color, 8%), 0.9)};
-  --dropdown-shadow: 0 20px 25px -5px #{rgba($base-shadow-color, 0.25)},
-    0 8px 10px -6px #{rgba($base-shadow-color, 0.25)};
-  --modal-background-color: #{rgba(darken($ui-base-color, 8%), 0.7)};
-  --modal-background-variant-color: #{rgba($ui-base-color, 0.7)};
-  --modal-border-color: #{lighten($ui-base-color, 4%)};
-  --background-border-color: #{lighten($ui-base-color, 4%)};
-  --background-filter: blur(10px) saturate(180%) contrast(75%) brightness(70%);
-  --background-color: #{darken($ui-base-color, 8%)};
-  --background-color-tint: #{rgba(darken($ui-base-color, 8%), 0.9)};
-  --surface-background-color: #{darken($ui-base-color, 4%)};
-  --surface-variant-background-color: #{$ui-base-color};
-  --surface-variant-active-background-color: #{lighten($ui-base-color, 4%)};
-  --on-surface-color: #{transparentize($ui-base-color, 0.5)};
-  --avatar-border-radius: 8px;
-  --content-font-size: 15px;
-  --content-emoji-size: 20px;
-  --content-line-height: 22px;
-  --detail-content-font-size: 19px;
-  --detail-content-emoji-size: 24px;
-  --detail-content-line-height: 24px;
-  --media-outline-color: #{rgba(#fcf8ff, 0.15)};
-  --overlay-icon-shadow: drop-shadow(0 0 8px #{rgba($base-shadow-color, 0.25)});
-  --error-background-color: #{darken($error-red, 16%)};
-  --error-active-background-color: #{darken($error-red, 12%)};
-  --on-error-color: #fff;
-  --rich-text-container-color: rgba(87, 24, 60, 100%);
-  --rich-text-text-color: rgba(255, 175, 212, 100%);
-  --rich-text-decorations-color: rgba(128, 58, 95, 100%);
-  --input-placeholder-color: #{$dark-text-color};
-  --input-background-color: var(--surface-variant-background-color);
-  --on-input-color: #{$secondary-text-color};
-}
+$emojis-requiring-inversion: 'back' 'copyright' 'curly_loop' 'currency_exchange'
+  'end' 'heavy_check_mark' 'heavy_division_sign' 'heavy_dollar_sign'
+  'heavy_minus_sign' 'heavy_multiplication_x' 'heavy_plus_sign' 'on'
+  'registered' 'soon' 'spider' 'telephone_receiver' 'tm' 'top' 'wavy_dash' !default;
diff --git a/app/javascript/styles/mastodon/about.scss b/app/javascript/styles/mastodon/about.scss
index 03e3ccd643..a310e2ffe7 100644
--- a/app/javascript/styles/mastodon/about.scss
+++ b/app/javascript/styles/mastodon/about.scss
@@ -1,3 +1,5 @@
+@use 'variables' as *;
+
 $maximum-width: 1235px;
 $fluid-breakpoint: $maximum-width + 20px;
 
diff --git a/app/javascript/styles/mastodon/accessibility.scss b/app/javascript/styles/mastodon/accessibility.scss
index deaa0afdac..7cd2d4eae3 100644
--- a/app/javascript/styles/mastodon/accessibility.scss
+++ b/app/javascript/styles/mastodon/accessibility.scss
@@ -1,7 +1,4 @@
-$emojis-requiring-inversion: 'back' 'copyright' 'curly_loop' 'currency_exchange'
-  'end' 'heavy_check_mark' 'heavy_division_sign' 'heavy_dollar_sign'
-  'heavy_minus_sign' 'heavy_multiplication_x' 'heavy_plus_sign' 'on'
-  'registered' 'soon' 'spider' 'telephone_receiver' 'tm' 'top' 'wavy_dash' !default;
+@use 'variables' as *;
 
 %emoji-color-inversion {
   filter: invert(1);
diff --git a/app/javascript/styles/mastodon/accounts.scss b/app/javascript/styles/mastodon/accounts.scss
index 89778b9f3b..34d4e840ef 100644
--- a/app/javascript/styles/mastodon/accounts.scss
+++ b/app/javascript/styles/mastodon/accounts.scss
@@ -1,3 +1,6 @@
+@use 'variables' as *;
+@use 'functions' as *;
+
 .card {
   & > a {
     display: block;
diff --git a/app/javascript/styles/mastodon/admin.scss b/app/javascript/styles/mastodon/admin.scss
index 9c566719ef..c3035f946f 100644
--- a/app/javascript/styles/mastodon/admin.scss
+++ b/app/javascript/styles/mastodon/admin.scss
@@ -1,4 +1,6 @@
 @use 'sass:math';
+@use 'functions' as *;
+@use 'variables' as *;
 
 $no-columns-breakpoint: 890px;
 $sidebar-width: 300px;
diff --git a/app/javascript/styles/mastodon/annual_reports.scss b/app/javascript/styles/mastodon/annual_reports.scss
index dff1c76eca..96500a18bb 100644
--- a/app/javascript/styles/mastodon/annual_reports.scss
+++ b/app/javascript/styles/mastodon/annual_reports.scss
@@ -1,3 +1,5 @@
+@use 'variables' as *;
+
 :root {
   --indigo-1: #17063b;
   --indigo-2: #2f0c7a;
diff --git a/app/javascript/styles/mastodon/basics.scss b/app/javascript/styles/mastodon/basics.scss
index b7140fa6d6..69b88c8645 100644
--- a/app/javascript/styles/mastodon/basics.scss
+++ b/app/javascript/styles/mastodon/basics.scss
@@ -1,3 +1,6 @@
+@use 'variables' as *;
+@use 'functions' as *;
+
 @function hex-color($color) {
   @if type-of($color) == 'color' {
     $color: str-slice(ie-hex-str($color), 4);
@@ -92,6 +95,7 @@ body {
 
     &.with-modals--active {
       overflow-y: hidden;
+      overscroll-behavior: none;
     }
   }
 
diff --git a/app/javascript/styles/mastodon/branding.scss b/app/javascript/styles/mastodon/branding.scss
index d1bddc68b0..8e8dd3530b 100644
--- a/app/javascript/styles/mastodon/branding.scss
+++ b/app/javascript/styles/mastodon/branding.scss
@@ -1,3 +1,5 @@
+@use 'variables' as *;
+
 .logo {
   color: $primary-text-color;
 }
diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss
index 8bfb1b518d..e5bc76a46f 100644
--- a/app/javascript/styles/mastodon/components.scss
+++ b/app/javascript/styles/mastodon/components.scss
@@ -1,3 +1,8 @@
+@use 'sass:color';
+@use 'variables' as *;
+@use 'functions' as *;
+@use 'mixins' as *;
+
 .app-body {
   -webkit-overflow-scrolling: touch;
   -ms-overflow-style: -ms-autohiding-scrollbar;
@@ -1911,18 +1916,22 @@ body > [data-popper-placement] {
 .detailed-status__wrapper-direct {
   .detailed-status,
   .detailed-status__action-bar {
-    background: mix($ui-base-color, $ui-highlight-color, 95%);
+    background: color.mix($ui-base-color, $ui-highlight-color, 95%);
   }
 
   &:focus {
     .detailed-status,
     .detailed-status__action-bar {
-      background: mix(lighten($ui-base-color, 4%), $ui-highlight-color, 95%);
+      background: color.mix(
+        lighten($ui-base-color, 4%),
+        $ui-highlight-color,
+        95%
+      );
     }
   }
 
   .detailed-status__action-bar {
-    border-top-color: mix(
+    border-top-color: color.mix(
       lighten($ui-base-color, 8%),
       $ui-highlight-color,
       95%
@@ -2528,49 +2537,6 @@ a.account__display-name {
   }
 }
 
-.image-loader {
-  position: relative;
-  width: 100%;
-  height: 100%;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  flex-direction: column;
-  scrollbar-width: none; /* Firefox */
-  -ms-overflow-style: none; /* IE 10+ */
-
-  * {
-    scrollbar-width: none; /* Firefox */
-    -ms-overflow-style: none; /* IE 10+ */
-  }
-
-  &::-webkit-scrollbar,
-  *::-webkit-scrollbar {
-    width: 0;
-    height: 0;
-    background: transparent; /* Chrome/Safari/Webkit */
-  }
-
-  .image-loader__preview-canvas {
-    max-width: $media-modal-media-max-width;
-    max-height: $media-modal-media-max-height;
-    background: url('../images/void.png') repeat;
-    object-fit: contain;
-  }
-
-  .loading-bar__container {
-    position: relative;
-  }
-
-  .loading-bar {
-    position: absolute;
-  }
-
-  &.image-loader--amorphous .image-loader__preview-canvas {
-    display: none;
-  }
-}
-
 .zoomable-image {
   position: relative;
   width: 100%;
@@ -2578,13 +2544,61 @@ a.account__display-name {
   display: flex;
   align-items: center;
   justify-content: center;
+  scrollbar-width: none;
+  overflow: hidden;
+  user-select: none;
 
   img {
     max-width: $media-modal-media-max-width;
     max-height: $media-modal-media-max-height;
     width: auto;
     height: auto;
-    object-fit: contain;
+    outline: 1px solid var(--media-outline-color);
+    outline-offset: -1px;
+    border-radius: 8px;
+    touch-action: none;
+  }
+
+  &--zoomed-in {
+    z-index: 9999;
+    cursor: grab;
+
+    img {
+      outline: none;
+      border-radius: 0;
+    }
+  }
+
+  &--dragging {
+    cursor: grabbing;
+  }
+
+  &--error img {
+    visibility: hidden;
+  }
+
+  &__preview {
+    max-width: $media-modal-media-max-width;
+    max-height: $media-modal-media-max-height;
+    position: absolute;
+    z-index: 1;
+    outline: 1px solid var(--media-outline-color);
+    outline-offset: -1px;
+    border-radius: 8px;
+    overflow: hidden;
+
+    canvas {
+      position: absolute;
+      width: 100%;
+      height: 100%;
+      object-fit: cover;
+      z-index: -1;
+    }
+  }
+
+  .loading-indicator {
+    z-index: 2;
+    mix-blend-mode: luminosity;
   }
 }
 
@@ -5887,6 +5901,7 @@ a.status-card {
   z-index: 9999;
   pointer-events: none;
   user-select: none;
+  overscroll-behavior: none;
 }
 
 .modal-root__modal {
@@ -6020,7 +6035,7 @@ a.status-card {
   .picture-in-picture__footer {
     border-radius: 0;
     background: transparent;
-    padding: 20px 0;
+    padding: 16px;
 
     .icon-button {
       color: $white;
@@ -7235,6 +7250,10 @@ a.status-card {
       filter: var(--overlay-icon-shadow);
     }
   }
+
+  &--error img {
+    visibility: hidden;
+  }
 }
 
 .media-gallery__item-thumbnail {
@@ -8858,7 +8877,7 @@ noscript {
     &.active {
       transition: all 100ms ease-in;
       transition-property: background-color, color;
-      background-color: mix(
+      background-color: color.mix(
         lighten($ui-base-color, 12%),
         $ui-highlight-color,
         80%
@@ -9759,6 +9778,7 @@ noscript {
   border: 1px solid $highlight-text-color;
   background: rgba($highlight-text-color, 0.15);
   overflow: hidden;
+  flex-shrink: 0;
 
   &__background-image {
     width: 125%;
@@ -10186,6 +10206,9 @@ noscript {
 }
 
 .notification-bar-action {
+  display: inline-block;
+  border: 0;
+  background: transparent;
   text-transform: uppercase;
   margin-inline-start: 10px;
   cursor: pointer;
diff --git a/app/javascript/styles/mastodon/containers.scss b/app/javascript/styles/mastodon/containers.scss
index ac1f862a09..7db9ca409d 100644
--- a/app/javascript/styles/mastodon/containers.scss
+++ b/app/javascript/styles/mastodon/containers.scss
@@ -1,3 +1,5 @@
+@use 'variables' as *;
+
 .container-alt {
   width: 700px;
   margin: 0 auto;
diff --git a/app/javascript/styles/mastodon/css_variables.scss b/app/javascript/styles/mastodon/css_variables.scss
new file mode 100644
index 0000000000..d1a357f730
--- /dev/null
+++ b/app/javascript/styles/mastodon/css_variables.scss
@@ -0,0 +1,39 @@
+@use 'sass:color';
+@use 'functions' as *;
+@use 'variables' as *;
+
+:root {
+  --dropdown-border-color: #{lighten($ui-base-color, 4%)};
+  --dropdown-background-color: #{rgba(darken($ui-base-color, 8%), 0.9)};
+  --dropdown-shadow: 0 20px 25px -5px #{rgba($base-shadow-color, 0.25)},
+    0 8px 10px -6px #{rgba($base-shadow-color, 0.25)};
+  --modal-background-color: #{rgba(darken($ui-base-color, 8%), 0.7)};
+  --modal-background-variant-color: #{rgba($ui-base-color, 0.7)};
+  --modal-border-color: #{lighten($ui-base-color, 4%)};
+  --background-border-color: #{lighten($ui-base-color, 4%)};
+  --background-filter: blur(10px) saturate(180%) contrast(75%) brightness(70%);
+  --background-color: #{darken($ui-base-color, 8%)};
+  --background-color-tint: #{rgba(darken($ui-base-color, 8%), 0.9)};
+  --surface-background-color: #{darken($ui-base-color, 4%)};
+  --surface-variant-background-color: #{$ui-base-color};
+  --surface-variant-active-background-color: #{lighten($ui-base-color, 4%)};
+  --on-surface-color: #{color.adjust($ui-base-color, $alpha: -0.5)};
+  --avatar-border-radius: 8px;
+  --media-outline-color: #{rgba(#fcf8ff, 0.15)};
+  --overlay-icon-shadow: drop-shadow(0 0 8px #{rgba($base-shadow-color, 0.25)});
+  --error-background-color: #{darken($error-red, 16%)};
+  --error-active-background-color: #{darken($error-red, 12%)};
+  --on-error-color: #fff;
+  --rich-text-container-color: rgba(87, 24, 60, 100%);
+  --rich-text-text-color: rgba(255, 175, 212, 100%);
+  --rich-text-decorations-color: rgba(128, 58, 95, 100%);
+  --input-placeholder-color: #{$dark-text-color};
+  --input-background-color: var(--surface-variant-background-color);
+  --on-input-color: #{$secondary-text-color};
+  --content-font-size: 15px;
+  --content-emoji-size: 20px;
+  --content-line-height: 22px;
+  --detail-content-font-size: 19px;
+  --detail-content-emoji-size: 24px;
+  --detail-content-line-height: 24px;
+}
diff --git a/app/javascript/styles/mastodon/dashboard.scss b/app/javascript/styles/mastodon/dashboard.scss
index d049b2456c..c99cdc357a 100644
--- a/app/javascript/styles/mastodon/dashboard.scss
+++ b/app/javascript/styles/mastodon/dashboard.scss
@@ -1,3 +1,6 @@
+@use 'functions' as *;
+@use 'variables' as *;
+
 .dashboard__counters {
   display: flex;
   flex-wrap: wrap;
diff --git a/app/javascript/styles/mastodon/emoji_picker.scss b/app/javascript/styles/mastodon/emoji_picker.scss
index e883bb4ab5..68f4c87ecd 100644
--- a/app/javascript/styles/mastodon/emoji_picker.scss
+++ b/app/javascript/styles/mastodon/emoji_picker.scss
@@ -1,3 +1,6 @@
+@use 'variables' as *;
+@use 'functions' as *;
+
 .emoji-mart {
   font-size: 13px;
   display: inline-block;
diff --git a/app/javascript/styles/mastodon/forms.scss b/app/javascript/styles/mastodon/forms.scss
index e7fdb35cb9..f8eaa43a20 100644
--- a/app/javascript/styles/mastodon/forms.scss
+++ b/app/javascript/styles/mastodon/forms.scss
@@ -1,4 +1,5 @@
-$no-columns-breakpoint: 600px;
+@use 'variables' as *;
+@use 'functions' as *;
 
 code {
   font-family: $font-monospace, monospace;
@@ -365,6 +366,22 @@ code {
     }
   }
 
+  .input.date_of_birth .label_input {
+    display: flex;
+    gap: 8px;
+    align-items: center;
+
+    input {
+      box-sizing: content-box;
+      width: 32px;
+      flex: 0;
+
+      &:last-child {
+        width: 64px;
+      }
+    }
+  }
+
   .input.select.select--languages {
     min-width: 32ch;
   }
diff --git a/app/javascript/styles/mastodon/modal.scss b/app/javascript/styles/mastodon/modal.scss
index 60e7d62245..7d060a2681 100644
--- a/app/javascript/styles/mastodon/modal.scss
+++ b/app/javascript/styles/mastodon/modal.scss
@@ -1,3 +1,6 @@
+@use 'variables' as *;
+@use 'functions' as *;
+
 .modal-layout {
   background: darken($ui-base-color, 4%)
     url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 234.80078 31.757813" width="234.80078" height="31.757812"><path d="M19.599609 0c-1.05 0-2.10039.375-2.90039 1.125L0 16.925781v14.832031h234.80078V17.025391l-16.5-15.900391c-1.6-1.5-4.20078-1.5-5.80078 0l-13.80078 13.099609c-1.6 1.5-4.19883 1.5-5.79883 0L179.09961 1.125c-1.6-1.5-4.19883-1.5-5.79883 0L159.5 14.224609c-1.6 1.5-4.20078 1.5-5.80078 0L139.90039 1.125c-1.6-1.5-4.20078-1.5-5.80078 0l-13.79883 13.099609c-1.6 1.5-4.20078 1.5-5.80078 0L100.69922 1.125c-1.600001-1.5-4.198829-1.5-5.798829 0l-13.59961 13.099609c-1.6 1.5-4.200781 1.5-5.800781 0L61.699219 1.125c-1.6-1.5-4.198828-1.5-5.798828 0L42.099609 14.224609c-1.6 1.5-4.198828 1.5-5.798828 0L22.5 1.125C21.7.375 20.649609 0 19.599609 0z" fill="#{hex-color($ui-base-lighter-color)}33"/></svg>')
diff --git a/app/javascript/styles/mastodon/polls.scss b/app/javascript/styles/mastodon/polls.scss
index ced4c60c44..f49ce3c413 100644
--- a/app/javascript/styles/mastodon/polls.scss
+++ b/app/javascript/styles/mastodon/polls.scss
@@ -1,3 +1,6 @@
+@use 'variables' as *;
+@use 'functions' as *;
+
 .poll {
   margin-top: 16px;
   font-size: 14px;
diff --git a/app/javascript/styles/mastodon/reset.scss b/app/javascript/styles/mastodon/reset.scss
index d1ca4a1837..2dce637a06 100644
--- a/app/javascript/styles/mastodon/reset.scss
+++ b/app/javascript/styles/mastodon/reset.scss
@@ -1,3 +1,5 @@
+@use 'variables' as *;
+
 /* http://meyerweb.com/eric/tools/css/reset/
    v2.0 | 20110126
    License: none (public domain)
diff --git a/app/javascript/styles/mastodon/rtl.scss b/app/javascript/styles/mastodon/rtl.scss
index 0a05ce7c62..6aa94a97bc 100644
--- a/app/javascript/styles/mastodon/rtl.scss
+++ b/app/javascript/styles/mastodon/rtl.scss
@@ -1,3 +1,6 @@
+@use 'functions' as *;
+@use 'variables' as *;
+
 body.rtl {
   direction: rtl;
 
diff --git a/app/javascript/styles/mastodon/tables.scss b/app/javascript/styles/mastodon/tables.scss
index 310d3def07..620518ebf8 100644
--- a/app/javascript/styles/mastodon/tables.scss
+++ b/app/javascript/styles/mastodon/tables.scss
@@ -1,3 +1,6 @@
+@use 'variables' as *;
+@use 'functions' as *;
+
 .table {
   width: 100%;
   max-width: 100%;
diff --git a/app/javascript/styles/mastodon/widgets.scss b/app/javascript/styles/mastodon/widgets.scss
index f467069052..8d09c7d583 100644
--- a/app/javascript/styles/mastodon/widgets.scss
+++ b/app/javascript/styles/mastodon/widgets.scss
@@ -1,3 +1,6 @@
+@use 'variables' as *;
+@use 'functions' as *;
+
 .directory {
   &__tag {
     box-sizing: border-box;
diff --git a/app/lib/account_reach_finder.rb b/app/lib/account_reach_finder.rb
index 19464024a6..f5e229c6a3 100644
--- a/app/lib/account_reach_finder.rb
+++ b/app/lib/account_reach_finder.rb
@@ -37,7 +37,11 @@ class AccountReachFinder
 
   def oldest_status_id
     Mastodon::Snowflake
-      .id_at(STATUS_SINCE.ago, with_random: false)
+      .id_at(oldest_status_date, with_random: false)
+  end
+
+  def oldest_status_date
+    @account.suspended? && @account.suspension_origin_local? ? @account.suspended_at - STATUS_SINCE : STATUS_SINCE.ago
   end
 
   def recent_statuses
diff --git a/app/lib/content_security_policy.rb b/app/lib/content_security_policy.rb
index c764d1856d..fc42e2d48b 100644
--- a/app/lib/content_security_policy.rb
+++ b/app/lib/content_security_policy.rb
@@ -10,7 +10,7 @@ class ContentSecurityPolicy
   end
 
   def media_hosts
-    [assets_host, cdn_host_value, paperclip_root_url].compact
+    [assets_host, cdn_host_value, paperclip_root_url].concat(extra_media_hosts).compact
   end
 
   def sso_host
@@ -31,6 +31,10 @@ class ContentSecurityPolicy
 
   private
 
+  def extra_media_hosts
+    ENV.fetch('EXTRA_MEDIA_HOSTS', '').split(/(?:\s*,\s*|\s+)/)
+  end
+
   def url_from_configured_asset_host
     Rails.configuration.action_controller.asset_host
   end
diff --git a/app/lib/emoji_formatter.rb b/app/lib/emoji_formatter.rb
index c0302767ef..1574d4588d 100644
--- a/app/lib/emoji_formatter.rb
+++ b/app/lib/emoji_formatter.rb
@@ -24,7 +24,15 @@ class EmojiFormatter
   def to_s
     return html if custom_emojis.empty? || html.blank?
 
-    tree = Nokogiri::HTML5.fragment(html)
+    begin
+      tree = Nokogiri::HTML5.fragment(html)
+    rescue ArgumentError
+      # This can happen if one of the Nokogumbo limits is encountered
+      # Unfortunately, it does not use a more precise error class
+      # nor allows more graceful handling
+      return ''
+    end
+
     tree.xpath('./text()|.//text()[not(ancestor[@class="invisible"])]').to_a.each do |node|
       i                     = -1
       inside_shortname      = false
diff --git a/app/lib/plain_text_formatter.rb b/app/lib/plain_text_formatter.rb
index f960ba7acc..e8ff79806f 100644
--- a/app/lib/plain_text_formatter.rb
+++ b/app/lib/plain_text_formatter.rb
@@ -16,7 +16,15 @@ class PlainTextFormatter
     if local?
       text
     else
-      node = Nokogiri::HTML5.fragment(insert_newlines)
+      begin
+        node = Nokogiri::HTML5.fragment(insert_newlines)
+      rescue ArgumentError
+        # This can happen if one of the Nokogumbo limits is encountered
+        # Unfortunately, it does not use a more precise error class
+        # nor allows more graceful handling
+        return ''
+      end
+
       # Elements that are entirely removed with our Sanitize config
       node.xpath('.//iframe|.//math|.//noembed|.//noframes|.//noscript|.//plaintext|.//script|.//style|.//svg|.//xmp').remove
       node.text.chomp
diff --git a/app/lib/request.rb b/app/lib/request.rb
index 8fda86f0e8..ad39f928db 100644
--- a/app/lib/request.rb
+++ b/app/lib/request.rb
@@ -260,7 +260,7 @@ class Request
         outer_e = nil
         port    = args.first
 
-        addresses = [] # rubocop:disable Lint/UselessAssignment -- TODO: https://github.com/rubocop/rubocop/issues/13395
+        addresses = []
         begin
           addresses = [IPAddr.new(host)]
         rescue IPAddr::InvalidAddressError
diff --git a/app/models/account.rb b/app/models/account.rb
index 0612e63fd5..f3f591d006 100644
--- a/app/models/account.rb
+++ b/app/models/account.rb
@@ -160,7 +160,7 @@ class Account < ApplicationRecord
   scope :not_excluded_by_account, ->(account) { where.not(id: account.excluded_from_timeline_account_ids) }
   scope :not_domain_blocked_by_account, ->(account) { where(arel_table[:domain].eq(nil).or(arel_table[:domain].not_in(account.excluded_from_timeline_domains))) }
   scope :dormant, -> { joins(:account_stat).merge(AccountStat.without_recent_activity) }
-  scope :with_username, ->(value) { where arel_table[:username].lower.eq(value.to_s.downcase) }
+  scope :with_username, ->(value) { value.is_a?(Array) ? where(arel_table[:username].lower.in(value.map { |x| x.to_s.downcase })) : where(arel_table[:username].lower.eq(value.to_s.downcase)) }
   scope :with_domain, ->(value) { where arel_table[:domain].lower.eq(value&.to_s&.downcase) }
   scope :without_memorial, -> { where(memorial: false) }
   scope :duplicate_uris, -> { select(:uri, Arel.star.count).group(:uri).having(Arel.star.count.gt(1)) }
diff --git a/app/models/account/field.rb b/app/models/account/field.rb
index bcd89015de..4b3ccea9c4 100644
--- a/app/models/account/field.rb
+++ b/app/models/account/field.rb
@@ -73,7 +73,14 @@ class Account::Field < ActiveModelSerializers::Model
   end
 
   def extract_url_from_html
-    doc = Nokogiri::HTML5.fragment(value)
+    begin
+      doc = Nokogiri::HTML5.fragment(value)
+    rescue ArgumentError
+      # This can happen if one of the Nokogumbo limits is encountered
+      # Unfortunately, it does not use a more precise error class
+      # nor allows more graceful handling
+      return
+    end
 
     return if doc.nil?
     return if doc.children.size != 1
diff --git a/app/models/custom_filter.rb b/app/models/custom_filter.rb
index 9e0784be28..386b49a2df 100644
--- a/app/models/custom_filter.rb
+++ b/app/models/custom_filter.rb
@@ -38,7 +38,7 @@ class CustomFilter < ApplicationRecord
   include Expireable
   include Redisable
 
-  enum :action, { warn: 0, hide: 1 }, suffix: :action
+  enum :action, { warn: 0, hide: 1, blur: 2 }, suffix: :action
 
   belongs_to :account
   has_many :keywords, class_name: 'CustomFilterKeyword', inverse_of: :custom_filter, dependent: :destroy
diff --git a/app/models/form/admin_settings.rb b/app/models/form/admin_settings.rb
index f9c5b80262..31ac04b754 100644
--- a/app/models/form/admin_settings.rb
+++ b/app/models/form/admin_settings.rb
@@ -65,6 +65,7 @@ class Form::AdminSettings
     stop_link_preview_domains
     app_icon
     favicon
+    min_age
   ).freeze
 
   INTEGER_KEYS = %i(
@@ -80,6 +81,7 @@ class Form::AdminSettings
     registrations_end_hour
     registrations_secondary_start_hour
     registrations_secondary_end_hour
+    min_age
   ).freeze
 
   BOOLEAN_KEYS = %i(
@@ -140,6 +142,7 @@ class Form::AdminSettings
   validates :show_domain_blocks, inclusion: { in: %w(disabled users all) }, if: -> { defined?(@show_domain_blocks) }
   validates :show_domain_blocks_rationale, inclusion: { in: %w(disabled users all) }, if: -> { defined?(@show_domain_blocks_rationale) }
   validates :media_cache_retention_period, :content_cache_retention_period, :backups_retention_period, numericality: { only_integer: true }, allow_blank: true, if: -> { defined?(@media_cache_retention_period) || defined?(@content_cache_retention_period) || defined?(@backups_retention_period) }
+  validates :min_age, numericality: { only_integer: true }, allow_blank: true, if: -> { defined?(@min_age) }
   validates :site_short_description, length: { maximum: DESCRIPTION_LIMIT }, if: -> { defined?(@site_short_description) }
   validates :status_page_url, url: true, allow_blank: true
   validate :validate_site_uploads
diff --git a/app/models/media_attachment.rb b/app/models/media_attachment.rb
index 49ff740884..89c74d5a41 100644
--- a/app/models/media_attachment.rb
+++ b/app/models/media_attachment.rb
@@ -116,7 +116,7 @@ class MediaAttachment < ApplicationRecord
   VIDEO_PASSTHROUGH_OPTIONS = {
     video_codecs: ['h264'].freeze,
     audio_codecs: ['aac', nil].freeze,
-    colorspaces: ['yuv420p'].freeze,
+    colorspaces: ['yuv420p', 'yuvj420p'].freeze,
     options: {
       format: 'mp4',
       convert_options: {
@@ -425,8 +425,10 @@ class MediaAttachment < ApplicationRecord
 
     @paths_to_cache_bust = MediaAttachment.attachment_definitions.keys.flat_map do |attachment_name|
       attachment = public_send(attachment_name)
+      next if attachment.blank?
+
       styles = DEFAULT_STYLES | attachment.styles.keys
-      styles.map { |style| attachment.path(style) }
+      styles.map { |style| attachment.url(style) }
     end.compact
   rescue => e
     # We really don't want any error here preventing media deletion
diff --git a/app/models/trends/links.rb b/app/models/trends/links.rb
index 35ccf7744c..c24e069b51 100644
--- a/app/models/trends/links.rb
+++ b/app/models/trends/links.rb
@@ -33,7 +33,8 @@ class Trends::Links < Trends::Base
   def register(status, at_time = Time.now.utc)
     original_status = status.proper
 
-    return unless (original_status.public_visibility? && status.public_visibility?) &&
+    return unless original_status.public_visibility? &&
+                  status.public_visibility? &&
                   !(original_status.account.silenced? || status.account.silenced?) &&
                   !(original_status.spoiler_text? || original_status.sensitive?)
 
diff --git a/app/models/user.rb b/app/models/user.rb
index 70e8ed2e87..7858ab906d 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -5,41 +5,42 @@
 # Table name: users
 #
 #  id                        :bigint(8)        not null, primary key
-#  email                     :string           default(""), not null
-#  created_at                :datetime         not null
-#  updated_at                :datetime         not null
-#  encrypted_password        :string           default(""), not null
-#  reset_password_token      :string
-#  reset_password_sent_at    :datetime
-#  sign_in_count             :integer          default(0), not null
-#  current_sign_in_at        :datetime
-#  last_sign_in_at           :datetime
+#  age_verified_at           :datetime
+#  approved                  :boolean          default(TRUE), not null
+#  chosen_languages          :string           is an Array
+#  confirmation_sent_at      :datetime
 #  confirmation_token        :string
 #  confirmed_at              :datetime
-#  confirmation_sent_at      :datetime
-#  unconfirmed_email         :string
-#  locale                    :string
+#  consumed_timestep         :integer
+#  current_sign_in_at        :datetime
+#  disabled                  :boolean          default(FALSE), not null
+#  email                     :string           default(""), not null
 #  encrypted_otp_secret      :string
 #  encrypted_otp_secret_iv   :string
 #  encrypted_otp_secret_salt :string
-#  consumed_timestep         :integer
-#  otp_required_for_login    :boolean          default(FALSE), not null
+#  encrypted_password        :string           default(""), not null
 #  last_emailed_at           :datetime
+#  last_sign_in_at           :datetime
+#  locale                    :string
 #  otp_backup_codes          :string           is an Array
-#  account_id                :bigint(8)        not null
-#  disabled                  :boolean          default(FALSE), not null
-#  invite_id                 :bigint(8)
-#  chosen_languages          :string           is an Array
-#  created_by_application_id :bigint(8)
-#  approved                  :boolean          default(TRUE), not null
+#  otp_required_for_login    :boolean          default(FALSE), not null
+#  otp_secret                :string
+#  reset_password_sent_at    :datetime
+#  reset_password_token      :string
+#  settings                  :text
+#  sign_in_count             :integer          default(0), not null
 #  sign_in_token             :string
 #  sign_in_token_sent_at     :datetime
-#  webauthn_id               :string
 #  sign_up_ip                :inet
-#  role_id                   :bigint(8)
-#  settings                  :text
 #  time_zone                 :string
-#  otp_secret                :string
+#  unconfirmed_email         :string
+#  created_at                :datetime         not null
+#  updated_at                :datetime         not null
+#  account_id                :bigint(8)        not null
+#  created_by_application_id :bigint(8)
+#  invite_id                 :bigint(8)
+#  role_id                   :bigint(8)
+#  webauthn_id               :string
 #
 
 class User < ApplicationRecord
@@ -116,6 +117,7 @@ class User < ApplicationRecord
   validates_with RegistrationFormTimeValidator, on: :create
   validates :website, absence: true, on: :create
   validates :confirm_password, absence: true, on: :create
+  validates :date_of_birth, presence: true, date_of_birth: true, on: :create, if: -> { Setting.min_age.present? }
   validate :validate_role_elevation
 
   scope :account_not_suspended, -> { joins(:account).merge(Account.without_suspended) }
@@ -134,6 +136,7 @@ class User < ApplicationRecord
 
   before_validation :sanitize_role
   before_create :set_approved
+  before_create :set_age_verified_at
   after_commit :send_pending_devise_notifications
   after_create_commit :trigger_webhooks
 
@@ -145,7 +148,7 @@ class User < ApplicationRecord
 
   delegate :can?, to: :role
 
-  attr_reader :invite_code
+  attr_reader :invite_code, :date_of_birth
   attr_writer :external, :bypass_invite_request_check, :current_account
 
   def self.those_who_can(*any_of_privileges)
@@ -162,6 +165,17 @@ class User < ApplicationRecord
     Rails.env.local?
   end
 
+  def date_of_birth=(hash_or_string)
+    @date_of_birth = begin
+      if hash_or_string.is_a?(Hash)
+        day, month, year = hash_or_string.values_at(1, 2, 3)
+        "#{day}.#{month}.#{year}"
+      else
+        hash_or_string
+      end
+    end
+  end
+
   def role
     if role_id.nil?
       UserRole.everyone
@@ -455,6 +469,10 @@ class User < ApplicationRecord
     end
   end
 
+  def set_age_verified_at
+    self.age_verified_at = Time.now.utc if Setting.min_age.present?
+  end
+
   def grant_approval_on_confirmation?
     # Re-check approval on confirmation if the server has switched to open registrations
     open_registrations? && !sign_up_from_ip_requires_approval? && !sign_up_email_requires_approval?
diff --git a/app/serializers/rest/instance_serializer.rb b/app/serializers/rest/instance_serializer.rb
index 57ceef9bd5..b786baedfa 100644
--- a/app/serializers/rest/instance_serializer.rb
+++ b/app/serializers/rest/instance_serializer.rb
@@ -131,7 +131,9 @@ class REST::InstanceSerializer < ActiveModel::Serializer
       enabled: registrations_enabled?,
       approval_required: Setting.registrations_mode == 'approved' || (Setting.registrations_mode == 'open' && !registrations_in_time?),
       limit_reached: Setting.registrations_mode != 'none' && reach_registrations_limit?,
+      reason_required: Setting.registrations_mode == 'approved' && Setting.require_invite_text,
       message: registrations_enabled? ? nil : registrations_message,
+      min_age: Setting.min_age.presence,
       url: ENV.fetch('SSO_ACCOUNT_SIGN_UP', nil),
     }
   end
diff --git a/app/serializers/rest/terms_of_service_serializer.rb b/app/serializers/rest/terms_of_service_serializer.rb
index 7f48788693..373cb8b56f 100644
--- a/app/serializers/rest/terms_of_service_serializer.rb
+++ b/app/serializers/rest/terms_of_service_serializer.rb
@@ -4,7 +4,7 @@ class REST::TermsOfServiceSerializer < ActiveModel::Serializer
   attributes :effective_date, :effective, :content, :succeeded_by
 
   def effective_date
-    object.effective_date.iso8601
+    (object.effective_date || object.published_at).iso8601
   end
 
   def effective
diff --git a/app/services/activitypub/synchronize_followers_service.rb b/app/services/activitypub/synchronize_followers_service.rb
index b01974dcc6..5b58a025cb 100644
--- a/app/services/activitypub/synchronize_followers_service.rb
+++ b/app/services/activitypub/synchronize_followers_service.rb
@@ -4,32 +4,46 @@ class ActivityPub::SynchronizeFollowersService < BaseService
   include JsonLdHelper
   include Payloadable
 
+  MAX_COLLECTION_PAGES = 10
+
   def call(account, partial_collection_url)
     @account = account
+    @expected_followers_ids = []
 
-    items = collection_items(partial_collection_url)
-    return if items.nil?
-
-    # There could be unresolved accounts (hence the call to .compact) but this
-    # should never happen in practice, since in almost all cases we keep an
-    # Account record, and should we not do that, we should have sent a Delete.
-    # In any case there is not much we can do if that occurs.
-    @expected_followers = items.filter_map { |uri| ActivityPub::TagManager.instance.uri_to_resource(uri, Account) }
+    return unless process_collection!(partial_collection_url)
 
     remove_unexpected_local_followers!
-    handle_unexpected_outgoing_follows!
   end
 
   private
 
+  def process_page!(items)
+    page_expected_followers = extract_local_followers(items)
+    @expected_followers_ids.concat(page_expected_followers.pluck(:id))
+
+    handle_unexpected_outgoing_follows!(page_expected_followers)
+  end
+
+  def extract_local_followers(items)
+    # There could be unresolved accounts (hence the call to .filter_map) but this
+    # should never happen in practice, since in almost all cases we keep an
+    # Account record, and should we not do that, we should have sent a Delete.
+    # In any case there is not much we can do if that occurs.
+
+    # TODO: this will need changes when switching to numeric IDs
+
+    usernames = items.filter_map { |uri| ActivityPub::TagManager.instance.uri_to_local_id(uri, :username)&.downcase }
+    Account.local.with_username(usernames)
+  end
+
   def remove_unexpected_local_followers!
-    @account.followers.local.where.not(id: @expected_followers.map(&:id)).reorder(nil).find_each do |unexpected_follower|
+    @account.followers.local.where.not(id: @expected_followers_ids).reorder(nil).find_each do |unexpected_follower|
       UnfollowService.new.call(unexpected_follower, @account)
     end
   end
 
-  def handle_unexpected_outgoing_follows!
-    @expected_followers.each do |expected_follower|
+  def handle_unexpected_outgoing_follows!(expected_followers)
+    expected_followers.each do |expected_follower|
       next if expected_follower.following?(@account)
 
       if expected_follower.requested?(@account)
@@ -50,18 +64,33 @@ class ActivityPub::SynchronizeFollowersService < BaseService
     Oj.dump(serialize_payload(follow, ActivityPub::UndoFollowSerializer))
   end
 
-  def collection_items(collection_or_uri)
-    collection = fetch_collection(collection_or_uri)
-    return unless collection.is_a?(Hash)
+  # Only returns true if the whole collection has been processed
+  def process_collection!(collection_uri, max_pages: MAX_COLLECTION_PAGES)
+    collection = fetch_collection(collection_uri)
+    return false unless collection.is_a?(Hash)
 
     collection = fetch_collection(collection['first']) if collection['first'].present?
-    return unless collection.is_a?(Hash)
 
+    while collection.is_a?(Hash)
+      process_page!(as_array(collection_page_items(collection)))
+
+      max_pages -= 1
+
+      return true if collection['next'].blank? # We reached the end of the collection
+      return false if max_pages <= 0 # We reached our pages limit
+
+      collection = fetch_collection(collection['next'])
+    end
+
+    false
+  end
+
+  def collection_page_items(collection)
     case collection['type']
     when 'Collection', 'CollectionPage'
-      as_array(collection['items'])
+      collection['items']
     when 'OrderedCollection', 'OrderedCollectionPage'
-      as_array(collection['orderedItems'])
+      collection['orderedItems']
     end
   end
 
diff --git a/app/services/app_sign_up_service.rb b/app/services/app_sign_up_service.rb
index 7665880115..a4399efd65 100644
--- a/app/services/app_sign_up_service.rb
+++ b/app/services/app_sign_up_service.rb
@@ -41,7 +41,7 @@ class AppSignUpService < BaseService
   end
 
   def user_params
-    @params.slice(:email, :password, :agreement, :locale, :time_zone, :invite_code)
+    @params.slice(:email, :password, :agreement, :locale, :time_zone, :invite_code, :date_of_birth)
   end
 
   def account_params
diff --git a/app/services/concerns/payloadable.rb b/app/services/concerns/payloadable.rb
index 4fa534c6d1..42ed994a7f 100644
--- a/app/services/concerns/payloadable.rb
+++ b/app/services/concerns/payloadable.rb
@@ -19,7 +19,7 @@ module Payloadable
     object      = record.respond_to?(:virtual_object) ? record.virtual_object : record
     bearcap     = object.is_a?(String) && record.respond_to?(:type) && ['Create', 'Update'].include?(record.type)
 
-    if ((object.respond_to?(:sign?) && object.sign?) && signer && (always_sign || signing_enabled?)) || bearcap || (signer && always_sign_unsafe)
+    if (object.respond_to?(:sign?) && object.sign? && signer && (always_sign || signing_enabled?)) || bearcap || (signer && always_sign_unsafe)
       ActivityPub::LinkedDataSignature.new(payload).sign!(signer, sign_with: sign_with)
     else
       payload
diff --git a/app/services/suspend_account_service.rb b/app/services/suspend_account_service.rb
index 44210799f9..3934a738f7 100644
--- a/app/services/suspend_account_service.rb
+++ b/app/services/suspend_account_service.rb
@@ -95,7 +95,7 @@ class SuspendAccountService < BaseService
             end
           end
 
-          CacheBusterWorker.perform_async(attachment.path(style)) if Rails.configuration.x.cache_buster_enabled
+          CacheBusterWorker.perform_async(attachment.url(style)) if Rails.configuration.x.cache_buster_enabled
         end
       end
     end
diff --git a/app/services/unsuspend_account_service.rb b/app/services/unsuspend_account_service.rb
index 652dd6a845..7d3bb806a6 100644
--- a/app/services/unsuspend_account_service.rb
+++ b/app/services/unsuspend_account_service.rb
@@ -91,7 +91,7 @@ class UnsuspendAccountService < BaseService
             end
           end
 
-          CacheBusterWorker.perform_async(attachment.path(style)) if Rails.configuration.x.cache_buster_enabled
+          CacheBusterWorker.perform_async(attachment.url(style)) if Rails.configuration.x.cache_buster_enabled
         end
       end
     end
diff --git a/app/validators/date_of_birth_validator.rb b/app/validators/date_of_birth_validator.rb
new file mode 100644
index 0000000000..79119d2c4c
--- /dev/null
+++ b/app/validators/date_of_birth_validator.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+class DateOfBirthValidator < ActiveModel::EachValidator
+  def validate_each(record, attribute, value)
+    record.errors.add(attribute, :below_limit) if value.present? && value.to_date > min_age.ago
+  rescue Date::Error
+    record.errors.add(attribute, :invalid)
+  end
+
+  private
+
+  def min_age
+    Setting.min_age.to_i.years
+  end
+end
diff --git a/app/views/admin/settings/registrations/show.html.haml b/app/views/admin/settings/registrations/show.html.haml
index fe6a71a4ca..4bd384f8b5 100644
--- a/app/views/admin/settings/registrations/show.html.haml
+++ b/app/views/admin/settings/registrations/show.html.haml
@@ -12,6 +12,9 @@
 
   .flash-message= t('admin.settings.registrations.moderation_recommandation')
 
+  .fields-group
+    = f.input :min_age, as: :string, wrapper: :with_block_label, input_html: { inputmode: 'numeric' }
+
   .fields-row
     .fields-row__column.fields-row__column-6.fields-group
       = f.input :registrations_mode,
diff --git a/app/views/admin/terms_of_service/index.html.haml b/app/views/admin/terms_of_service/index.html.haml
index 457ef42670..636851b449 100644
--- a/app/views/admin/terms_of_service/index.html.haml
+++ b/app/views/admin/terms_of_service/index.html.haml
@@ -11,7 +11,7 @@
       .dot-indicator.success
         .dot-indicator__indicator
         %span
-          - if @terms_of_service.effective?
+          - if @terms_of_service.effective? || @terms_of_service.effective_date.nil?
             = t('admin.terms_of_service.live')
           - else
             = t('admin.terms_of_service.going_live_on_html', date: tag.time(l(@terms_of_service.effective_date), class: 'formatted', date: @terms_of_service.effective_date.iso8601))
diff --git a/app/views/auth/registrations/new.html.haml b/app/views/auth/registrations/new.html.haml
index 74dffe83ee..ebbef00ff3 100644
--- a/app/views/auth/registrations/new.html.haml
+++ b/app/views/auth/registrations/new.html.haml
@@ -21,20 +21,19 @@
     = f.simple_fields_for :account do |ff|
       = ff.input :username,
                  append: "@#{site_hostname}",
-                 input_html: { 'aria-label': t('simple_form.labels.defaults.username'), autocomplete: 'off', placeholder: t('simple_form.labels.defaults.username'), pattern: '[a-zA-Z0-9_]+', maxlength: Account::USERNAME_LENGTH_LIMIT },
-                 label: false,
+                 input_html: { autocomplete: 'off', pattern: '[a-zA-Z0-9_]+', maxlength: Account::USERNAME_LENGTH_LIMIT, placeholder: ' ' },
                  required: true,
                  wrapper: :with_label
     = f.input :email,
               hint: false,
-              input_html: { 'aria-label': t('simple_form.labels.defaults.email'), autocomplete: 'username' },
-              placeholder: t('simple_form.labels.defaults.email'),
-              required: true
+              input_html: { autocomplete: 'username', placeholder: ' ' },
+              required: true,
+              wrapper: :with_label
     = f.input :password,
               hint: false,
-              input_html: { 'aria-label': t('simple_form.labels.defaults.password'), autocomplete: 'new-password', minlength: User.password_length.first, maxlength: User.password_length.last },
-              placeholder: t('simple_form.labels.defaults.password'),
-              required: true
+              input_html: { autocomplete: 'new-password', minlength: User.password_length.first, maxlength: User.password_length.last, placeholder: ' ' },
+              required: true,
+              wrapper: :with_label
     = f.input :password_confirmation,
               hint: false,
               input_html: { 'aria-label': t('simple_form.labels.defaults.confirm_password'), autocomplete: 'new-password', maxlength: User.password_length.last },
@@ -53,6 +52,14 @@
               required: false,
               wrapper: :with_label
 
+  - if Setting.min_age.present?
+    .fields-group
+      = f.input :date_of_birth,
+                as: :date_of_birth,
+                hint: t('simple_form.hints.user.date_of_birth', age: Setting.min_age.to_i),
+                required: true,
+                wrapper: :with_block_label
+
   - if approved_registrations? && @invite.blank?
     %p.lead= t('auth.sign_up.manual_review', domain: site_hostname)
 
diff --git a/app/views/filters/_filter_fields.html.haml b/app/views/filters/_filter_fields.html.haml
index 911b10467a..18e9a6580e 100644
--- a/app/views/filters/_filter_fields.html.haml
+++ b/app/views/filters/_filter_fields.html.haml
@@ -26,7 +26,7 @@
 .fields-group
   = f.input :filter_action,
             as: :radio_buttons,
-            collection: %i(warn hide),
+            collection: %i(warn blur hide),
             hint: t('simple_form.hints.filters.action'),
             include_blank: false,
             label_method: ->(action) { filter_action_label(action) },
diff --git a/app/workers/poll_expiration_notify_worker.rb b/app/workers/poll_expiration_notify_worker.rb
index b7a60fab84..fe7647024e 100644
--- a/app/workers/poll_expiration_notify_worker.rb
+++ b/app/workers/poll_expiration_notify_worker.rb
@@ -8,7 +8,7 @@ class PollExpirationNotifyWorker
   def perform(poll_id)
     @poll = Poll.find(poll_id)
 
-    return if does_not_expire?
+    return if missing_expiration?
     requeue! && return if not_due_yet?
 
     notify_remote_voters_and_owner! if @poll.local?
@@ -24,7 +24,7 @@ class PollExpirationNotifyWorker
 
   private
 
-  def does_not_expire?
+  def missing_expiration?
     @poll.expires_at.nil?
   end
 
diff --git a/config/initializers/prometheus_exporter.rb b/config/initializers/prometheus_exporter.rb
index fab095658f..fdfee59dc8 100644
--- a/config/initializers/prometheus_exporter.rb
+++ b/config/initializers/prometheus_exporter.rb
@@ -1,8 +1,10 @@
 # frozen_string_literal: true
 
 if ENV['MASTODON_PROMETHEUS_EXPORTER_ENABLED'] == 'true'
+  require 'prometheus_exporter'
+  require 'prometheus_exporter/middleware'
+
   if ENV['MASTODON_PROMETHEUS_EXPORTER_LOCAL'] == 'true'
-    require 'prometheus_exporter'
     require 'prometheus_exporter/server'
     require 'prometheus_exporter/client'
 
@@ -17,9 +19,11 @@ if ENV['MASTODON_PROMETHEUS_EXPORTER_ENABLED'] == 'true'
 
   if ENV['MASTODON_PROMETHEUS_EXPORTER_WEB_DETAILED_METRICS'] == 'true'
     # Optional, as those metrics might generate extra overhead and be redundant with what OTEL provides
-    require 'prometheus_exporter/middleware'
-
     # Per-action/controller request stats like HTTP status and timings
     Rails.application.middleware.unshift PrometheusExporter::Middleware
+  else
+    # Include stripped down version of PrometheusExporter::Middleware that only collects queue time
+    require 'mastodon/middleware/prometheus_queue_time'
+    Rails.application.middleware.unshift Mastodon::Middleware::PrometheusQueueTime, instrument: false
   end
 end
diff --git a/config/locales/activerecord.bg.yml b/config/locales/activerecord.bg.yml
index 55436e59ad..0b33e953ae 100644
--- a/config/locales/activerecord.bg.yml
+++ b/config/locales/activerecord.bg.yml
@@ -55,6 +55,8 @@ bg:
               too_soon: е твърде скоро и трябва да е по-късно от %{date}
         user:
           attributes:
+            date_of_birth:
+              below_limit: е под възрастовата граница
             email:
               blocked: използва се забранен доставчик на услуга за е-поща
               unreachable: изглежда не съществува
diff --git a/config/locales/activerecord.ca.yml b/config/locales/activerecord.ca.yml
index dd98774c35..f53f7f364a 100644
--- a/config/locales/activerecord.ca.yml
+++ b/config/locales/activerecord.ca.yml
@@ -55,6 +55,8 @@ ca:
               too_soon: és massa aviat, ha de ser després de %{date}
         user:
           attributes:
+            date_of_birth:
+              below_limit: és inferior a l'edat mínima
             email:
               blocked: utilitza un proveïdor de correu-e no autoritzat
               unreachable: sembla que no existeix
diff --git a/config/locales/activerecord.cs.yml b/config/locales/activerecord.cs.yml
index 6b89af6004..38708713d2 100644
--- a/config/locales/activerecord.cs.yml
+++ b/config/locales/activerecord.cs.yml
@@ -55,6 +55,8 @@ cs:
               too_soon: je příliš brzy, musí být později než %{date}
         user:
           attributes:
+            date_of_birth:
+              below_limit: je pod věkovou hranicí
             email:
               blocked: používá zakázanou e-mailovou službu
               unreachable: pravděpodobně neexistuje
diff --git a/config/locales/activerecord.cy.yml b/config/locales/activerecord.cy.yml
index 7f75449ab7..c201016be6 100644
--- a/config/locales/activerecord.cy.yml
+++ b/config/locales/activerecord.cy.yml
@@ -3,7 +3,7 @@ cy:
   activerecord:
     attributes:
       poll:
-        expires_at: Terfyn amser
+        expires_at: Dyddiad cau
         options: Dewisiadau
       user:
         agreement: Cytundeb gwasanaeth
@@ -55,6 +55,8 @@ cy:
               too_soon: yn rhy fuan, rhaid iddo fod yn hwyrach na %{date}
         user:
           attributes:
+            date_of_birth:
+              below_limit: yn iau na'r terfyn oedran
             email:
               blocked: yn defnyddio darparwr e-bost nad yw'n cael ei ganiatáu
               unreachable: nid yw i weld yn bodoli
@@ -63,7 +65,7 @@ cy:
         user_role:
           attributes:
             permissions_as_keys:
-              dangerous: yn cynnwys caniatâd nad ydynt yn ddiogel ar gyfer rôl sail
+              dangerous: yn cynnwys caniatâd nad ydyn nhw'n ddiogel ar gyfer rôl sail
               elevated: yn methu a chynnwys caniatâd nad yw eich rôl cyfredol yn ei gynnwys
               own_role: nid oes modd ei newid gyda'ch rôl cyfredol
             position:
@@ -72,4 +74,4 @@ cy:
         webhook:
           attributes:
             events:
-              invalid_permissions: ni ellir cynnwys digwyddiadau nad oes gennych yr hawl iddynt
+              invalid_permissions: nid oes modd cynnwys digwyddiadau nad oes gennych yr hawl iddyn nhw
diff --git a/config/locales/activerecord.da.yml b/config/locales/activerecord.da.yml
index 8e98b5f8d2..7b49c18ca3 100644
--- a/config/locales/activerecord.da.yml
+++ b/config/locales/activerecord.da.yml
@@ -55,6 +55,8 @@ da:
               too_soon: er for tidligt, skal være efter %{date}
         user:
           attributes:
+            date_of_birth:
+              below_limit: er under alderskravet
             email:
               blocked: bruger en ikke-tilladt e-mailudbyder
               unreachable: ser ikke ud til at eksistere
diff --git a/config/locales/activerecord.de.yml b/config/locales/activerecord.de.yml
index 1a4626b0b2..4ae7aec5dd 100644
--- a/config/locales/activerecord.de.yml
+++ b/config/locales/activerecord.de.yml
@@ -55,6 +55,8 @@ de:
               too_soon: Datum muss später als %{date} sein
         user:
           attributes:
+            date_of_birth:
+              below_limit: liegt unterhalb der Altersgrenze
             email:
               blocked: verwendet einen unerlaubten E-Mail-Anbieter
               unreachable: scheint nicht zu existieren
diff --git a/config/locales/activerecord.en.yml b/config/locales/activerecord.en.yml
index ed389c1323..6940d589ca 100644
--- a/config/locales/activerecord.en.yml
+++ b/config/locales/activerecord.en.yml
@@ -55,6 +55,8 @@ en:
               too_soon: is too soon, must be later than %{date}
         user:
           attributes:
+            date_of_birth:
+              below_limit: is below the age limit
             email:
               blocked: uses a disallowed e-mail provider
               unreachable: does not seem to exist
diff --git a/config/locales/activerecord.es-AR.yml b/config/locales/activerecord.es-AR.yml
index 86080aebb2..62a409a353 100644
--- a/config/locales/activerecord.es-AR.yml
+++ b/config/locales/activerecord.es-AR.yml
@@ -55,6 +55,8 @@ es-AR:
               too_soon: es demasiado pronto, debe ser posterior a %{date}
         user:
           attributes:
+            date_of_birth:
+              below_limit: está por debajo de la edad mínima
             email:
               blocked: usa un proveedor de correo electrónico no permitido
               unreachable: no parece existir
diff --git a/config/locales/activerecord.es-MX.yml b/config/locales/activerecord.es-MX.yml
index 3af9eb598a..c3b0562c32 100644
--- a/config/locales/activerecord.es-MX.yml
+++ b/config/locales/activerecord.es-MX.yml
@@ -55,6 +55,8 @@ es-MX:
               too_soon: es demasiado pronto, debe ser posterior al %{date}
         user:
           attributes:
+            date_of_birth:
+              below_limit: está por debajo de la edad mínima
             email:
               blocked: utiliza un proveedor de correo no autorizado
               unreachable: no parece existir
diff --git a/config/locales/activerecord.es.yml b/config/locales/activerecord.es.yml
index d5facd3477..94f29365e9 100644
--- a/config/locales/activerecord.es.yml
+++ b/config/locales/activerecord.es.yml
@@ -55,6 +55,8 @@ es:
               too_soon: es demasiado pronto, debe ser posterior a %{date}
         user:
           attributes:
+            date_of_birth:
+              below_limit: está por debajo de la edad mínima
             email:
               blocked: utiliza un proveedor de correo no autorizado
               unreachable: no parece existir
diff --git a/config/locales/activerecord.fi.yml b/config/locales/activerecord.fi.yml
index 3bb580a113..c731688a1f 100644
--- a/config/locales/activerecord.fi.yml
+++ b/config/locales/activerecord.fi.yml
@@ -55,6 +55,8 @@ fi:
               too_soon: on liian pian, täytyy olla myöhemmin kuin %{date}
         user:
           attributes:
+            date_of_birth:
+              below_limit: alittaa alaikärajan
             email:
               blocked: käyttää kiellettyä sähköpostipalveluntarjoajaa
               unreachable: ei näytä olevan olemassa
diff --git a/config/locales/activerecord.fo.yml b/config/locales/activerecord.fo.yml
index 9c2715d68b..ce84a1fffd 100644
--- a/config/locales/activerecord.fo.yml
+++ b/config/locales/activerecord.fo.yml
@@ -55,6 +55,8 @@ fo:
               too_soon: tað er ov tíðliga, má vera eftir %{date}
         user:
           attributes:
+            date_of_birth:
+              below_limit: er niðanfyri aldursmarkið
             email:
               blocked: brúkar ein ikki loyvdan teldopostveitara
               unreachable: tykist ikki at vera til
diff --git a/config/locales/activerecord.fr-CA.yml b/config/locales/activerecord.fr-CA.yml
index 97aee2304c..a966bb5a8a 100644
--- a/config/locales/activerecord.fr-CA.yml
+++ b/config/locales/activerecord.fr-CA.yml
@@ -49,8 +49,14 @@ fr-CA:
           attributes:
             reblog:
               taken: de la publication existe déjà
+        terms_of_service:
+          attributes:
+            effective_date:
+              too_soon: est trop tôt, doit être plus tard que %{date}
         user:
           attributes:
+            date_of_birth:
+              below_limit: est en dessous de la limite d'âge
             email:
               blocked: utilise un fournisseur de courriel interdit
               unreachable: ne semble pas exister
diff --git a/config/locales/activerecord.fr.yml b/config/locales/activerecord.fr.yml
index 85dbaf293b..ae3ce7f9cb 100644
--- a/config/locales/activerecord.fr.yml
+++ b/config/locales/activerecord.fr.yml
@@ -49,8 +49,14 @@ fr:
           attributes:
             reblog:
               taken: du message existe déjà
+        terms_of_service:
+          attributes:
+            effective_date:
+              too_soon: est trop tôt, doit être plus tard que %{date}
         user:
           attributes:
+            date_of_birth:
+              below_limit: est en dessous de la limite d'âge
             email:
               blocked: utilise un fournisseur de courriel interdit
               unreachable: ne semble pas exister
diff --git a/config/locales/activerecord.gl.yml b/config/locales/activerecord.gl.yml
index 5a2393ced5..f4e6725565 100644
--- a/config/locales/activerecord.gl.yml
+++ b/config/locales/activerecord.gl.yml
@@ -55,6 +55,8 @@ gl:
               too_soon: é demasiado axiña, debería ser posterior a %{date}
         user:
           attributes:
+            date_of_birth:
+              below_limit: é inferior ao límite de idade
             email:
               blocked: utiliza un provedor de email non autorizado
               unreachable: semella que non existe
diff --git a/config/locales/activerecord.he.yml b/config/locales/activerecord.he.yml
index 4649bc00ad..7dff17493b 100644
--- a/config/locales/activerecord.he.yml
+++ b/config/locales/activerecord.he.yml
@@ -55,6 +55,8 @@ he:
               too_soon: מוקדם מדי, חייב להיות אחרי %{date}
         user:
           attributes:
+            date_of_birth:
+              below_limit: מתחת למגבלת הגיל
             email:
               blocked: עושה שימוש בספק דוא"ל אסור
               unreachable: נראה שלא קיים
diff --git a/config/locales/activerecord.hu.yml b/config/locales/activerecord.hu.yml
index ee5c79d252..cf2f50a9f9 100644
--- a/config/locales/activerecord.hu.yml
+++ b/config/locales/activerecord.hu.yml
@@ -55,6 +55,8 @@ hu:
               too_soon: túl korán van, később kellene lennie, mint %{date}
         user:
           attributes:
+            date_of_birth:
+              below_limit: a korhatár alatt van
             email:
               blocked: egy letiltott email szolgáltatót használ
               unreachable: úgy tűnik, hogy nem létezik
diff --git a/config/locales/activerecord.is.yml b/config/locales/activerecord.is.yml
index 559a49880f..cff90a3476 100644
--- a/config/locales/activerecord.is.yml
+++ b/config/locales/activerecord.is.yml
@@ -55,6 +55,8 @@ is:
               too_soon: er of snemmt, verður að vera síðar en %{date}
         user:
           attributes:
+            date_of_birth:
+              below_limit: er undir aldurstakmörkum
             email:
               blocked: notar óleyfilega tölvupóstþjónustu
               unreachable: virðist ekki vera til
diff --git a/config/locales/activerecord.it.yml b/config/locales/activerecord.it.yml
index 89e038615d..9ff385f26e 100644
--- a/config/locales/activerecord.it.yml
+++ b/config/locales/activerecord.it.yml
@@ -55,6 +55,8 @@ it:
               too_soon: è troppo presto, deve essere successivo alla data %{date}
         user:
           attributes:
+            date_of_birth:
+              below_limit: è inferiore al limite di età
             email:
               blocked: utilizza un provider di posta elettronica non autorizzato
               unreachable: non sembra esistere
diff --git a/config/locales/activerecord.ko.yml b/config/locales/activerecord.ko.yml
index 5b1542496f..3aa991734b 100644
--- a/config/locales/activerecord.ko.yml
+++ b/config/locales/activerecord.ko.yml
@@ -55,6 +55,8 @@ ko:
               too_soon: 너무 이릅니다. %{date} 이후로 지정해야 합니다
         user:
           attributes:
+            date_of_birth:
+              below_limit: 나이 제한보다 아래입니다
             email:
               blocked: 허용되지 않은 이메일 제공자입니다
               unreachable: 존재하지 않는 것 같습니다
diff --git a/config/locales/activerecord.lt.yml b/config/locales/activerecord.lt.yml
index 778cd98271..1eec2782f4 100644
--- a/config/locales/activerecord.lt.yml
+++ b/config/locales/activerecord.lt.yml
@@ -55,6 +55,8 @@ lt:
               too_soon: yra per anksti, turi būti vėliau nei %{date}.
         user:
           attributes:
+            date_of_birth:
+              below_limit: yra žemiau amžiaus ribos.
             email:
               blocked: naudoja neleidžiamą el. laiško paslaugų teikėją.
               unreachable: neatrodo, kad egzistuoja.
diff --git a/config/locales/activerecord.lv.yml b/config/locales/activerecord.lv.yml
index 6d912bd628..c7030221a7 100644
--- a/config/locales/activerecord.lv.yml
+++ b/config/locales/activerecord.lv.yml
@@ -49,8 +49,14 @@ lv:
           attributes:
             reblog:
               taken: ziņai jau pastāv
+        terms_of_service:
+          attributes:
+            effective_date:
+              too_soon: ir pārāk agri, jābūt vēlāk kā %{date}
         user:
           attributes:
+            date_of_birth:
+              below_limit: ir mazāks par minimālo vecuma ierobežojumu
             email:
               blocked: lieto neatļautu e-pasta pakalpojuma sniedzēju
               unreachable: šķietami neeksistē
diff --git a/config/locales/activerecord.nl.yml b/config/locales/activerecord.nl.yml
index 5e5424902e..b05d6680e7 100644
--- a/config/locales/activerecord.nl.yml
+++ b/config/locales/activerecord.nl.yml
@@ -55,6 +55,8 @@ nl:
               too_soon: is te vroeg, moet na %{date} zijn
         user:
           attributes:
+            date_of_birth:
+              below_limit: is onder de leeftijdsgrens
             email:
               blocked: gebruikt een niet toegestane e-mailprovider
               unreachable: schijnt niet te bestaan
diff --git a/config/locales/activerecord.pt-PT.yml b/config/locales/activerecord.pt-PT.yml
index a3bf125e58..397ed492e5 100644
--- a/config/locales/activerecord.pt-PT.yml
+++ b/config/locales/activerecord.pt-PT.yml
@@ -55,6 +55,8 @@ pt-PT:
               too_soon: é muito cedo, deve ser após %{date}
         user:
           attributes:
+            date_of_birth:
+              below_limit: está abaixo da idade mínima
             email:
               blocked: usa um fornecedor de e-mail que não é permitido
               unreachable: não parece existir
diff --git a/config/locales/activerecord.ru.yml b/config/locales/activerecord.ru.yml
index 79d39a5cdc..08e91e459f 100644
--- a/config/locales/activerecord.ru.yml
+++ b/config/locales/activerecord.ru.yml
@@ -3,7 +3,7 @@ ru:
   activerecord:
     attributes:
       poll:
-        expires_at: Крайний срок
+        expires_at: Срок окончания голосования
         options: Варианты
       user:
         agreement: Соглашение с условиями сервиса
@@ -49,8 +49,14 @@ ru:
           attributes:
             reblog:
               taken: пост уже существует
+        terms_of_service:
+          attributes:
+            effective_date:
+              too_soon: должна быть не ранее %{date}
         user:
           attributes:
+            date_of_birth:
+              below_limit: ниже возрастного ограничения
             email:
               blocked: использует запрещённого провайдера эл. почты
               unreachable: не существует
diff --git a/config/locales/activerecord.sl.yml b/config/locales/activerecord.sl.yml
index 8b05d5d2cd..e4c4fe598f 100644
--- a/config/locales/activerecord.sl.yml
+++ b/config/locales/activerecord.sl.yml
@@ -55,6 +55,8 @@ sl:
               too_soon: je prekmalu, naj bo kasneje od %{date}
         user:
           attributes:
+            date_of_birth:
+              below_limit: ne dosega starostne meje
             email:
               blocked: uporablja nedovoljenega ponudnika e-poštnih storitev
               unreachable: kot kaže ne obstaja
diff --git a/config/locales/activerecord.sq.yml b/config/locales/activerecord.sq.yml
index 7fae00035b..2683dd014b 100644
--- a/config/locales/activerecord.sq.yml
+++ b/config/locales/activerecord.sq.yml
@@ -55,6 +55,8 @@ sq:
               too_soon: është shumë herët, duhet të jetë më vonë se %{date}
         user:
           attributes:
+            date_of_birth:
+              below_limit: është nën kufirin e moshave
             email:
               blocked: përdor një shërbim email të palejuar
               unreachable: s’duket se ekziston
diff --git a/config/locales/activerecord.sv.yml b/config/locales/activerecord.sv.yml
index db488200df..74b939fda4 100644
--- a/config/locales/activerecord.sv.yml
+++ b/config/locales/activerecord.sv.yml
@@ -49,8 +49,14 @@ sv:
           attributes:
             reblog:
               taken: av status finns redan
+        terms_of_service:
+          attributes:
+            effective_date:
+              too_soon: är för tidigt, måste vara senare än %{date}
         user:
           attributes:
+            date_of_birth:
+              below_limit: är under åldersgränsen
             email:
               blocked: använder en icke tillåten e-postleverantör
               unreachable: verkar inte existera
diff --git a/config/locales/activerecord.tr.yml b/config/locales/activerecord.tr.yml
index 65d353eda2..db9317afa2 100644
--- a/config/locales/activerecord.tr.yml
+++ b/config/locales/activerecord.tr.yml
@@ -55,6 +55,8 @@ tr:
               too_soon: çok erken, %{date} tarihinden sonra olmalıdır
         user:
           attributes:
+            date_of_birth:
+              below_limit: yaş sınırının altında
             email:
               blocked: izin verilmeyen bir e-posta sağlayıcı kullanıyor
               unreachable: mevcut gözükmüyor
diff --git a/config/locales/activerecord.uk.yml b/config/locales/activerecord.uk.yml
index 1ee426ae45..8b4ba1f671 100644
--- a/config/locales/activerecord.uk.yml
+++ b/config/locales/activerecord.uk.yml
@@ -51,6 +51,8 @@ uk:
               taken: цього допису вже існує
         user:
           attributes:
+            date_of_birth:
+              below_limit: менше вікової межі
             email:
               blocked: використовує не дозволенного постачальника електронної пошти
               unreachable: не існує
diff --git a/config/locales/activerecord.vi.yml b/config/locales/activerecord.vi.yml
index 1483b32cc9..fe810d94e5 100644
--- a/config/locales/activerecord.vi.yml
+++ b/config/locales/activerecord.vi.yml
@@ -55,6 +55,8 @@ vi:
               too_soon: là quá sớm, cần phải sau %{date}
         user:
           attributes:
+            date_of_birth:
+              below_limit: dưới độ tuổi tối thiểu
             email:
               blocked: sử dụng dịch vụ email bị cấm
               unreachable: không tồn tại
diff --git a/config/locales/activerecord.zh-CN.yml b/config/locales/activerecord.zh-CN.yml
index 0513c82a7c..af19014cfd 100644
--- a/config/locales/activerecord.zh-CN.yml
+++ b/config/locales/activerecord.zh-CN.yml
@@ -55,6 +55,8 @@ zh-CN:
               too_soon: 日期太近,必须晚于 %{date}
         user:
           attributes:
+            date_of_birth:
+              below_limit: 低于年龄限制
             email:
               blocked: 使用了被封禁的电子邮件提供商
               unreachable: 似乎不存在
diff --git a/config/locales/activerecord.zh-TW.yml b/config/locales/activerecord.zh-TW.yml
index 2e4841f6c9..f8f630ba3c 100644
--- a/config/locales/activerecord.zh-TW.yml
+++ b/config/locales/activerecord.zh-TW.yml
@@ -55,6 +55,8 @@ zh-TW:
               too_soon: 太快了,必須晚於 %{date}
         user:
           attributes:
+            date_of_birth:
+              below_limit: 低於年齡要求
             email:
               blocked: 使用不被允許的電子郵件提供商
               unreachable: 該電子郵件地址似乎無法使用
diff --git a/config/locales/br.yml b/config/locales/br.yml
index fbe91fcbd7..f12269eba3 100644
--- a/config/locales/br.yml
+++ b/config/locales/br.yml
@@ -47,6 +47,7 @@ br:
       demote: Argilañ
       disable: Skornañ
       disabled: Skornet
+      display_name: Anv diskouezet
       domain: Domani
       edit: Kemmañ
       email: Postel
@@ -66,6 +67,7 @@ br:
       moderation:
         active: Oberiant
         all: Pep tra
+        disabled: Diweredekaet
         pending: War ober
         silenced: Bevennet
         suspended: Astalet
@@ -98,6 +100,7 @@ br:
     action_logs:
       action_types:
         destroy_status: Dilemel ar c'hannad
+        reset_password_user: Adderaouekaat ar ger-tremen
         update_status: Hizivaat ar c'hannad
       actions:
         destroy_status_html: Dilamet eo bet toud %{target} gant %{name}
diff --git a/config/locales/cs.yml b/config/locales/cs.yml
index 5328ec94ca..55d87f995d 100644
--- a/config/locales/cs.yml
+++ b/config/locales/cs.yml
@@ -99,7 +99,7 @@ cs:
         active: Aktivní
         all: Vše
         disabled: Deaktivován
-        pending: Čekající
+        pending: Nevyřízeno
         silenced: Omezeno
         suspended: Pozastavené
         title: Moderování
diff --git a/config/locales/cy.yml b/config/locales/cy.yml
index 3b31a68ed0..9349176842 100644
--- a/config/locales/cy.yml
+++ b/config/locales/cy.yml
@@ -1417,7 +1417,7 @@ cy:
   filters:
     contexts:
       account: Proffilau
-      home: Cartref a rhestrau
+      home: Ffrwd gartref
       notifications: Hysbysiadau
       public: Ffrydiau cyhoeddus
       thread: Sgyrsiau
diff --git a/config/locales/devise.lv.yml b/config/locales/devise.lv.yml
index b5b8f22296..e60f8fa62e 100644
--- a/config/locales/devise.lv.yml
+++ b/config/locales/devise.lv.yml
@@ -2,7 +2,7 @@
 lv:
   devise:
     confirmations:
-      confirmed: Tava e-pasta adrese ir veiksmīgi apstiprināta.
+      confirmed: Tava e-pasta adrese tika sekmīgi apstiprināta.
       send_instructions: Pēc dažām minūtēm saņemsi e-pasta ziņojum ar norādēm, kā apstiprināt savu e-pasta adresi. Lūgums pārbaudīt mēstuļu mapi, ja nesaņēmi šo e-pasta ziņojumu.
       send_paranoid_instructions: Ja Tava e-pasta adrese ir mūsu datubāzē, pēc dažām minūtēm saņemsi e-pasta ziņojumu ar norādēm, kā apstiprināt savu e-pasta adresi. Lūgums pārbaudīt mēstuļu mapi, ja nesaņēmi šo e-pasta ziņojumu.
     failure:
@@ -85,25 +85,25 @@ lv:
         title: Drošības atslēgas iespējotas
     omniauth_callbacks:
       failure: Nevarēja autentificēt tevi no %{kind}, jo "%{reason}".
-      success: Veiksmīgi autentificēts no %{kind} konta.
+      success: Sekmīgi autentificēts no %{kind} konta.
     passwords:
       no_token: Tu nevari piekļūt šai lapai, ja neesi saņēmis paroles atiestatīšanas e-pasta ziņojumu. Ja ienāci no paroles atiestatīšanas e-pasta, lūdzu, pārliecinies, vai izmanto visu norādīto URL.
       send_instructions: Ja Tava e-pasta adrese ir mūsu datubāzē, pēc dažām minūtēm savā e-pasta adresē saņemsi paroles atkopes saiti. Lūgums pārbaudīt mēstuļu mapi, ja nesaņēmi šo e-pasta ziņojumu.
       send_paranoid_instructions: Ja Tava e-pasta adrese ir mūsu datubāzē, pēc dažām minūtēm savā e-pasta adresē saņemsi paroles atkopes saiti. Lūgums pārbaudīt mēstuļu mapi, ja nesaņēmi šo e-pasta ziņojumu.
-      updated: Tava parole tika veiksmīgi nomainīta. Tagad esi pieteicies.
-      updated_not_active: Tava parole ir veiksmīgi nomainīta.
+      updated: Tava parole tika sekmīgi nomainīta. Tagad esi pieteicies.
+      updated_not_active: Tava parole tika sekmīgi nomainīta.
     registrations:
-      destroyed: Visu labu! Tavs konts ir veiksmīgi atcelts. Mēs ceram tevi drīz atkal redzēt.
-      update_needs_confirmation: Tu veiksmīgi atjaunināji savu kontu, taču mums ir jāapliecina Tava jaunā e-pasta adrese. Lūgums pārbaudīt savu e-pastu un sekot apstiprinājuma saitei, lai apstiprinātu savu jauno e-pasta adresi. Lūgums pārbaudīt mēstuļu mapi, ja nesaņēmi šo e-pasta ziņojumu.
-      updated: Tavs konts ir veiksmīgi atjaunināts.
+      destroyed: Visu labu! Tavs konts ir sekmīgi atcelts. Mēs ceram Tevi drīz atkal redzēt.
+      update_needs_confirmation: Tu sekmīgi atjaunināji savu kontu, taču mums ir jāapliecina Tava jaunā e-pasta adrese. Lūgums pārbaudīt savu e-pastu un sekot apstiprinājuma saitei, lai apstiprinātu savu jauno e-pasta adresi. Lūgums pārbaudīt mēstuļu mapi, ja nesaņēmi šo e-pasta ziņojumu.
+      updated: Tavs konts tika sekmīgi atjaunināts.
     sessions:
-      already_signed_out: Veiksmīgi izrakstījies.
-      signed_in: Veiksmīgi pieteicies.
-      signed_out: Veiksmīgi izrakstījies.
+      already_signed_out: Sekmīgi izrakstījies.
+      signed_in: Sekmīgi pierakstījies.
+      signed_out: Sekmīgi izrakstījies.
     unlocks:
       send_instructions: Pēc dažām minūtēm Tu saņemsi e-pasta ziņojumu ar norādēm, kā atslēgt savu kontu. Lūgums pārbaudīt mēstuļu mapi, ja nesaņēmi šo e-pasta ziņojumu.
       send_paranoid_instructions: Ja Tavs konts pastāv, dažu minūšu laikā saņemsi e-pasta ziņojumu ar norādēm, kā to atslēgt. Lūgums pārbaudīt mēstuļu mapi, ja nesaņēmi šo e-pasta ziņojumu.
-      unlocked: Konts tika veiksmīgi atbloķēts. Lūgums pieteikties, lai turpinātu.
+      unlocked: Konts tika sekmīgi atslēgts. Lūgums pieteikties, lai turpinātu.
   errors:
     messages:
       already_confirmed: jau tika apstiprināts, lūgums mēģināt pieteikties
diff --git a/config/locales/doorkeeper.lv.yml b/config/locales/doorkeeper.lv.yml
index af892d79fa..15c1e7692a 100644
--- a/config/locales/doorkeeper.lv.yml
+++ b/config/locales/doorkeeper.lv.yml
@@ -150,13 +150,13 @@ lv:
         title: OAuth nepieciešama autorizācija
     scopes:
       admin:read: lasīt visus datus uz servera
-      admin:read:accounts: lasīt sensitīvu informāciju no visiem kontiem
-      admin:read:canonical_email_blocks: lasīt sensitīvu informāciju par visiem kanoniskajiem e-pasta blokiem
-      admin:read:domain_allows: lasīt visu domēnu sensitīvo informāciju, ko atļauj
-      admin:read:domain_blocks: lasīt sensitīvu informāciju par visiem domēna blokiem
-      admin:read:email_domain_blocks: lasīt sensitīvu informāciju par visiem e-pasta domēna blokiem
-      admin:read:ip_blocks: lasīt sensitīvu informāciju par visiem IP blokiem
-      admin:read:reports: lasīt sensitīvu informāciju no visiem pārskatiem un kontiem, par kuriem ziņots
+      admin:read:accounts: lasīt jūtīgu informāciju no visiem kontiem
+      admin:read:canonical_email_blocks: lasīt jūtīgu informāciju par visiem kanoniskajiem e-pasta blokiem
+      admin:read:domain_allows: lasīt jūtīgu informāciju par visiem atļautajiem domēniem
+      admin:read:domain_blocks: lasīt jūtīgu informāciju par visiem domēna blokiem
+      admin:read:email_domain_blocks: lasīt jūtīgu informāciju par visiem e-pasta domēna blokiem
+      admin:read:ip_blocks: lasīt jūtīgu informāciju par visiem IP blokiem
+      admin:read:reports: lasīt jūtīgu informāciju no visiem pārskatiem un kontiem, par kuriem ziņots
       admin:write: modificēt visus datus uz servera
       admin:write:accounts: veikt satura pārraudzības darbības kontos
       admin:write:canonical_email_blocks: veikt satura pārraudzības darbības kanoniskajos e-pasta blokos
diff --git a/config/locales/fi.yml b/config/locales/fi.yml
index 2ac853c2bc..614b0f1412 100644
--- a/config/locales/fi.yml
+++ b/config/locales/fi.yml
@@ -319,7 +319,7 @@ fi:
         create: Luo tiedote
         title: Uusi tiedote
       preview:
-        explanation_html: 'Sähköposti lähetetään <strong>%{display_count} käyttäjälle</strong>. Seuraava teksti sisällytetään sähköpostiviestiin:'
+        explanation_html: "<strong>%{display_count} käyttäjälle</strong> lähetetään sähköpostia. Sähköpostiviestiin sisällytetään seuraava teksti:"
         title: Esikatsele tiedoteilmoitus
       publish: Julkaise
       published_msg: Tiedotteen julkaisu onnistui!
@@ -1945,8 +1945,8 @@ fi:
     terms_of_service_changed:
       agreement: Jatkamalla palvelun %{domain} käyttöä hyväksyt nämä ehdot. Jos et hyväksy päivitettyjä ehtoja, voit milloin tahansa päättää sopimuksesi palvelun %{domain} kanssa poistamalla tilisi.
       changelog: 'Lyhyesti, mitä tämä päivitys tarkoittaa sinulle:'
-      description: 'Sait tämän sähköpostiviestin, koska teemme muutoksia palvelun %{domain} käyttöehtoihin. Muutokset tulevat voimaan %{date}. Kehotamme sinua tutustumaan päivitettyihin ehtoihin kokonaisuudessaan täällä:'
-      description_html: Sait tämän sähköpostiviestin, koska teemme muutoksia palvelun %{domain} käyttöehtoihin. Muutokset tulevat voimaan <strong>%{date}</strong>. Kehotamme sinua tutustumaan <a href="%{path}" target="_blank">päivitettyihin ehtoihin kokonaisuudessaan täällä</a>.
+      description: 'Sait tämän sähköpostiviestin, koska muutamme palvelun %{domain} käyttöehtoja. Muutokset tulevat voimaan %{date}. Kehotamme tutustumaan päivitettyihin ehtoihin kokonaisuudessaan täällä:'
+      description_html: Sait tämän sähköpostiviestin, koska muutamme palvelun %{domain} käyttöehtoja. Muutokset tulevat voimaan <strong>%{date}</strong>. Kehotamme tutustumaan <a href="%{path}" target="_blank">päivitettyihin ehtoihin kokonaisuudessaan täällä</a>.
       sign_off: Palvelimen %{domain} tiimi
       subject: Käyttöehtojemme päivitykset
       subtitle: Palvelimen %{domain} käyttöehdot muuttuvat
diff --git a/config/locales/fr-CA.yml b/config/locales/fr-CA.yml
index 228b837324..dd751c0678 100644
--- a/config/locales/fr-CA.yml
+++ b/config/locales/fr-CA.yml
@@ -309,6 +309,7 @@ fr-CA:
       title: Journal d’audit
       unavailable_instance: "(nom de domaine indisponible)"
     announcements:
+      back: Retour aux annonces
       destroyed_msg: Annonce supprimée avec succès !
       edit:
         title: Modifier l’annonce
@@ -317,6 +318,9 @@ fr-CA:
       new:
         create: Créer une annonce
         title: Nouvelle annonce
+      preview:
+        explanation_html: 'L''e-mail sera envoyé à <strong>%{display_count} utilisateurs</strong>. Le texte suivant sera inclus :'
+        title: Aperçu de la notification d'annonce
       publish: Publier
       published_msg: Annonce publiée avec succès !
       scheduled_for: Planifiée pour %{time}
@@ -942,6 +946,7 @@ fr-CA:
         chance_to_review_html: "<strong>Les conditions d'utilisation générées ne seront pas publiées automatiquement.</strong> Vous aurez la possibilité de vérifier les résultats. Veuillez remplir les informations nécessaires pour continuer."
         explanation_html: Le modèle de conditions d'utilisation fourni l'est uniquement à titre informatif et ne doit pas être interprété comme un conseil juridique sur quelque sujet que ce soit. Veuillez consulter votre propre conseiller juridique sur votre situation et les questions juridiques spécifiques que vous vous posez.
         title: Configuration des Conditions d'Utilisation
+      going_live_on_html: En direct, à compter du %{date}
       history: Historique
       live: En cours d'utilisation
       no_history: Il n'y a pas encore de modifications enregistrées des conditions d'utilisation.
@@ -1907,6 +1912,10 @@ fr-CA:
     recovery_instructions_html: Si vous perdez l’accès à votre téléphone, vous pouvez utiliser un des codes de récupération ci-dessous pour retrouver l’accès à votre compte. <strong>Conservez les codes de récupération en sécurité</strong>. Par exemple, en les imprimant et en les stockant avec vos autres documents importants.
     webauthn: Clés de sécurité
   user_mailer:
+    announcement_published:
+      description: 'Les administrateurs de %{domain} font une annonce :'
+      subject: Annonce de service
+      title: Annonce de service de %{domain}
     appeal_approved:
       action: Paramètres du compte
       explanation: L'appel de la sanction contre votre compte mise en place le %{strike_date} que vous avez soumis le %{appeal_date} a été approuvé. Votre compte est de nouveau en règle.
@@ -1939,6 +1948,8 @@ fr-CA:
     terms_of_service_changed:
       agreement: En continuant d'utiliser %{domain}, vous acceptez ces conditions. Si vous n'êtes pas d'accord avec les conditions mises à jour, vous pouvez résilier votre accord avec %{domain} à tout moment en supprimant votre compte.
       changelog: 'En un coup d''œil, voici ce que cette mise à jour signifie pour vous :'
+      description: 'Vous recevez cet e-mail parce que nous apportons des modifications à nos conditions de service à %{domain}. Ces modifications entreront en vigueur le %{date}. Nous vous encourageons à consulter l''intégralité des conditions mises à jour ici :'
+      description_html: Vous recevez cet e-mail parce que nous apportons des modifications à nos conditions de service à %{domain}. Ces mises à jour entreront en vigueur le <strong>%{date}</strong>. Nous vous encourageons à consulter l'intégralité des <a href="%{path}" target="_blank">conditions mises à jour ici</a>.
       sign_off: L'équipe %{domain}
       subject: Mises à jour de nos conditions d'utilisation
       subtitle: Les conditions d'utilisation de `%{domain}` changent
diff --git a/config/locales/fr.yml b/config/locales/fr.yml
index 93ba3cbab8..6ad2733f3e 100644
--- a/config/locales/fr.yml
+++ b/config/locales/fr.yml
@@ -309,6 +309,7 @@ fr:
       title: Journal d’audit
       unavailable_instance: "(nom de domaine indisponible)"
     announcements:
+      back: Retour aux annonces
       destroyed_msg: Annonce supprimée avec succès !
       edit:
         title: Modifier l’annonce
@@ -317,6 +318,9 @@ fr:
       new:
         create: Créer une annonce
         title: Nouvelle annonce
+      preview:
+        explanation_html: 'L''e-mail sera envoyé à <strong>%{display_count} utilisateurs</strong>. Le texte suivant sera inclus :'
+        title: Aperçu de la notification d'annonce
       publish: Publier
       published_msg: Annonce publiée avec succès !
       scheduled_for: Planifiée pour %{time}
@@ -942,6 +946,7 @@ fr:
         chance_to_review_html: "<strong>Les conditions d'utilisation générées ne seront pas publiées automatiquement.</strong> Vous aurez la possibilité de vérifier les résultats. Veuillez remplir les informations nécessaires pour continuer."
         explanation_html: Le modèle de conditions d'utilisation fourni l'est uniquement à titre informatif et ne doit pas être interprété comme un conseil juridique sur quelque sujet que ce soit. Veuillez consulter votre propre conseiller juridique sur votre situation et les questions juridiques spécifiques que vous vous posez.
         title: Configuration des Conditions d'Utilisation
+      going_live_on_html: En direct, à compter du %{date}
       history: Historique
       live: En cours d'utilisation
       no_history: Il n'y a pas encore de modifications enregistrées des conditions d'utilisation.
@@ -1907,6 +1912,10 @@ fr:
     recovery_instructions_html: Si vous perdez l’accès à votre téléphone, vous pouvez utiliser un des codes de récupération ci-dessous pour retrouver l’accès à votre compte. <strong>Conservez les codes de récupération en sécurité</strong>. Par exemple, en les imprimant et en les stockant avec vos autres documents importants.
     webauthn: Clés de sécurité
   user_mailer:
+    announcement_published:
+      description: 'Les administrateurs de %{domain} font une annonce :'
+      subject: Annonce de service
+      title: Annonce de service de %{domain}
     appeal_approved:
       action: Paramètres du compte
       explanation: L'appel de la sanction contre votre compte mise en place le %{strike_date} que vous avez soumis le %{appeal_date} a été approuvé. Votre compte est de nouveau en règle.
@@ -1939,6 +1948,8 @@ fr:
     terms_of_service_changed:
       agreement: En continuant d'utiliser %{domain}, vous acceptez ces conditions. Si vous n'êtes pas d'accord avec les conditions mises à jour, vous pouvez résilier votre accord avec %{domain} à tout moment en supprimant votre compte.
       changelog: 'En un coup d''œil, voici ce que cette mise à jour signifie pour vous :'
+      description: 'Vous recevez cet e-mail parce que nous apportons des modifications à nos conditions de service à %{domain}. Ces modifications entreront en vigueur le %{date}. Nous vous encourageons à consulter l''intégralité des conditions mises à jour ici :'
+      description_html: Vous recevez cet e-mail parce que nous apportons des modifications à nos conditions de service à %{domain}. Ces mises à jour entreront en vigueur le <strong>%{date}</strong>. Nous vous encourageons à consulter l'intégralité des <a href="%{path}" target="_blank">conditions mises à jour ici</a>.
       sign_off: L'équipe %{domain}
       subject: Mises à jour de nos conditions d'utilisation
       subtitle: Les conditions d'utilisation de `%{domain}` changent
diff --git a/config/locales/ko.yml b/config/locales/ko.yml
index f96f9a91c0..ca40a2abcd 100644
--- a/config/locales/ko.yml
+++ b/config/locales/ko.yml
@@ -931,6 +931,7 @@ ko:
         chance_to_review_html: "<strong>생성된 이용 약관은 자동으로 게시되지 않을 것입니다.</strong> 결과를 확인할 기회가 있습니다. 진행하려면 필요한 정보들을 입력하세요."
         explanation_html: 제공되는 이용약관 틀은 정보 제공만을 목적으로 하며 법률 조언으로 해석하면 안 됩니다. 귀하의 상황에 맞는 법률 자문을 받아주시기 바랍니다.
         title: 이용 약관 설정
+      going_live_on_html: "%{date} 시행중"
       history: 역사
       live: 활성
       no_history: 기록된 이용약관 변경이 아직 없습니다.
@@ -1903,6 +1904,8 @@ ko:
     terms_of_service_changed:
       agreement: "%{domain}을 계속 사용하는 것으로 약관에 동의하는 것으로 간주합니다. 약관에 동의하지 않는 경우 계정을 삭제함으로써 언제든 동의를 철회할 수 있습니다."
       changelog: '이번 변경사항의 주요 내용입니다:'
+      description: "%{domain}의 이용 약관이 변경되었기 때문에 발송된 이메일입니다. 이 변경사항은 %{date}부터 효력을 발휘합니다. 변경된 전체 약관을 확인하시길 권합니다:"
+      description_html: '%{domain}의 이용 약관이 변경되었기 때문에 발송된 이메일입니다. 이 변경사항은 <strong>%{date}</strong>부터 효력을 발휘합니다. <a href="%{path}" target="_blank">변경된 전체 약관</a>을 확인하시길 권합니다.'
       sign_off: "%{domain} 팀"
       subject: 변경된 이용 약관
       subtitle: "%{domain}의 이용 약관이 변경됩니다"
diff --git a/config/locales/lt.yml b/config/locales/lt.yml
index 8a85daff7d..c5862bcf5d 100644
--- a/config/locales/lt.yml
+++ b/config/locales/lt.yml
@@ -294,6 +294,7 @@ lt:
       title: Audito žurnalas
       unavailable_instance: "(domeno pavadinimas neprieinamas)"
     announcements:
+      back: Atgal į skelbimus
       destroyed_msg: Skelbimas sėkmingai ištrintas.
       edit:
         title: Redaguoti skelbimą
@@ -302,6 +303,9 @@ lt:
       new:
         create: Sukurti skelbimą
         title: Naujas skelbimas
+      preview:
+        explanation_html: 'El. laiškas bus išsiųstas <strong>%{display_count} naudotojams</strong>. Į el. laišką bus įtrauktas toliau nurodytas tekstas:'
+        title: Peržiūrėti skelbimo pranešimą
       publish: Skelbti
       published_msg: Skelbimas sėkmingai paskelbtas.
       scheduled_for: Suplanuota %{time}
@@ -1219,6 +1223,10 @@ lt:
     recovery_instructions_html: Jeigu prarandate prieiga prie telefono, jūs galite naudoti atkūrimo kodus esančius žemiau, kad atgautumėte priega prie savo paskyros.<strong>Laikykite atkūrimo kodus saugiai</strong> Pavyzdžiui, galite norėti juos išspausdinti, ir laikyti kartu su kitais svarbiais dokumentais.
     webauthn: Saugumo raktai
   user_mailer:
+    announcement_published:
+      description: "%{domain} administratoriai daro skelbimą:"
+      subject: Paslaugos skelbimas
+      title: "%{domain} paslaugos skelbimas"
     appeal_approved:
       action: Paskyros nustatymai
       subtitle: Tavo paskyros būklė vėl yra gera.
diff --git a/config/locales/lv.yml b/config/locales/lv.yml
index 66c5821bd1..3dbfb0e2b2 100644
--- a/config/locales/lv.yml
+++ b/config/locales/lv.yml
@@ -144,8 +144,8 @@ lv:
       security_measures:
         only_password: Tikai parole
         password_and_2fa: Parole un 2FA
-      sensitive: Sensitīvs
-      sensitized: Atzīmēts kā sensitīvs
+      sensitive: Uzspiest atzīmēšanu kā jūtīgu
+      sensitized: Atzīmēts kā jūtīgs
       shared_inbox_url: Koplietotās iesūtnes URL
       show:
         created_reports: Sastādītie ziņojumi
@@ -163,7 +163,7 @@ lv:
       unblock_email: Atbloķēt e-pasta adresi
       unblocked_email_msg: Veiksmīgi atbloķēta %{username} e-pasta adrese
       unconfirmed_email: Neapstiprināts e-pasts
-      undo_sensitized: Atcelt sensitivizēšanu
+      undo_sensitized: Atcelt uzspiestu atzīmēšanu kā jūtīgu
       undo_silenced: Atsaukt ierobežojumu
       undo_suspension: Atsaukt apturēšanu
       unsilenced_msg: Veiksmīgi atsaukts %{username} konta ierobežojums
@@ -225,12 +225,12 @@ lv:
         resend_user: Atkārtoti nosūtīt Apstiprinājuma Pastu
         reset_password_user: Atiestatīt Paroli
         resolve_report: Atrisināt Ziņojumu
-        sensitive_account: Piespiedu sensitīvizēt kontu
+        sensitive_account: Uzspiesti atzimēt kontu kā jūtīgu
         silence_account: Ierobežot Kontu
         suspend_account: Apturēt Kontu
         unassigned_report: Atcelt Pārskata Piešķiršanu
         unblock_email_account: Atbloķēt e-pasta adresi
-        unsensitive_account: Atsaukt Konta Piespiedu Sensitivizēšanu
+        unsensitive_account: Atsaukt uzspiestu konta atzīmēšanu kā jūtīgu
         unsilence_account: Atcelt Konta Ierobežošanu
         unsuspend_account: Atcelt konta apturēšanu
         update_announcement: Atjaunināt Paziņojumu
@@ -282,12 +282,12 @@ lv:
         resend_user_html: "%{name} atkārtoti nosūtīja %{target} apstiprinājuma e-pasta ziņojumu"
         reset_password_user_html: "%{name} atiestatīja paroli lietotājam %{target}"
         resolve_report_html: "%{name} atrisināja ziņojumu %{target}"
-        sensitive_account_html: "%{name} atzīmēja %{target} multividi kā sensitīvu"
+        sensitive_account_html: "%{name} atzīmēja %{target} informācijas nesēju kā jūtīgu"
         silence_account_html: "%{name} ierobežoja %{target} kontu"
         suspend_account_html: "%{name} apturēja %{target} kontu"
         unassigned_report_html: "%{name} nepiešķīra ziņojumu %{target}"
         unblock_email_account_html: "%{name} atbloķēja %{target} e-pasta adresi"
-        unsensitive_account_html: "%{name} atmarķēja %{target} multividi kā sensitīvu"
+        unsensitive_account_html: "%{name} atcēla %{target} informācijas nesēja atzīmēšanu kā jūtīgu"
         unsilence_account_html: "%{name} atcēla ierobežojumu %{target} kontam"
         unsuspend_account_html: "%{name} neapturēja %{target} kontu"
         update_announcement_html: "%{name} atjaunināja paziņojumu %{target}"
@@ -610,7 +610,7 @@ lv:
       action_taken_by: Veiktā darbība
       actions:
         delete_description_html: Raksti, par kurām ziņots, tiks dzēsti, un tiks reģistrēts brīdinājums, lai palīdzētu tev izvērst turpmākos pārkāpumus saistībā ar to pašu kontu.
-        mark_as_sensitive_description_html: Multividesu faili ziņojumos, par kuriem ziņots, tiks atzīmēti kā sensitīvi, un tiks reģistrēts brīdinājums, lai palīdzētu tev izvērst turpmākus pārkāpumus saistībā ar to pašu kontu.
+        mark_as_sensitive_description_html: Informācijas nesēji ierakstos, par kuriem ziņots, tiks atzīmēti kā jūtīgi, un tiks iegrāmatots brīdinājums, lai palīdzētu ziņot par turpmākiem tā paša konta pārkāpumiem.
         other_description_html: Skatīt vairāk iespēju kontrolēt konta uzvedību un pielāgot saziņu ar paziņoto kontu.
         resolve_description_html: Pret norādīto kontu netiks veiktas nekādas darbības, netiks reģistrēts brīdinājums, un ziņojums tiks slēgts.
         silence_description_html: Konts būs redzams tikai tiem, kas tam jau seko vai meklē to manuāli, ievērojami ierobežojot tā sasniedzamību. To vienmēr var atgriezt. Tiek aizvērti visi šī konta pārskati.
@@ -621,7 +621,7 @@ lv:
       add_to_report: Pievienot varāk paziņošanai
       are_you_sure: Vai esi pārliecināts?
       assign_to_self: Piešķirt man
-      assigned: Piešķirtais moderators
+      assigned: Piešķirtais satura pārraudzītājs
       by_target_domain: Ziņotā konta domēns
       cancel: Atcelt
       category: Kategorija
@@ -637,7 +637,7 @@ lv:
       forwarded_replies_explanation: Šis ziņojums ir no attāla lietotāja un par attālu saturu. Tas tika pārvirzīts šeit, jo saturs, par kuru tika ziņots, ir atbilde vienam no šī servera lietotājiem.
       forwarded_to: Pārsūtīti %{domain}
       mark_as_resolved: Atzīmēt kā atrisinātu
-      mark_as_sensitive: Atzīmēt kā sensitīvu
+      mark_as_sensitive: Atzīmēt kā jūtīgu
       mark_as_unresolved: Atzīmēt kā neatrisinātu
       no_one_assigned: Neviena
       notes:
@@ -665,12 +665,12 @@ lv:
       summary:
         action_preambles:
           delete_html: 'Jūs gatavojaties <strong>noņemt</strong> dažas no lietotāja <strong>@%{acct}</strong> ziņām. Tas:'
-          mark_as_sensitive_html: 'Jūs gatavojaties <strong>atzīmēt</strong> dažas no lietotāja <strong>@%{acct}</strong> ziņām kā <strong>sensitīvas</strong>. Tas:'
+          mark_as_sensitive_html: 'Tu gatavojies <strong>atzīmēt</strong> dažus no lietotāja <strong>@%{acct}</strong> ierakstiem kā <strong>jūtīgus</strong>. Tas:'
           silence_html: 'Jūs gatavojaties <strong>ierobežot</strong> <strong>@%{acct}</strong> kontu. Tas:'
           suspend_html: 'Jūs gatavojaties <strong>apturēt</strong> <strong>@%{acct}</strong> kontu. Tas:'
         actions:
           delete_html: Noņemt aizskarošās ziņas
-          mark_as_sensitive_html: Atzīmēt aizskarošo ziņu multivides saturu kā sensitīvu
+          mark_as_sensitive_html: Atzīmēt aizskarošo ierakstu informācijas nesējus kā jūtīgus
           silence_html: Ievērojami ierobežo <strong>@%{acct}</strong> sasniedzamību, padarot viņa profilu un saturu redzamu tikai cilvēkiem, kas jau seko tam vai pašrocīgi uzmeklē profilu
           suspend_html: Apturēt <strong>@%{acct}</strong>, padarot viņu profilu un saturu nepieejamu un neiespējamu mijiedarbību ar
         close_report: 'Atzīmēt ziņojumu #%{id} kā atrisinātu'
@@ -854,9 +854,9 @@ lv:
       actions:
         delete_statuses: "%{name} izdzēsa %{target} publikācijas"
         disable: "%{name} iesaldēja %{target} kontu"
-        mark_statuses_as_sensitive: "%{name} atzīmēja %{target} ziņu kā sensitīvu"
+        mark_statuses_as_sensitive: "%{name} atzīmēja %{target} ierakstu kā jūtīgu"
         none: "%{name} nosūtīja brīdinājumu %{target}"
-        sensitive: "%{name} atzīmēja %{target} kontu kā sensitīvu"
+        sensitive: "%{name} atzīmēja %{target} kontu kā jūtīgu"
         silence: "%{name} ierobežoja %{target} kontu"
         suspend: "%{name} apturēja %{target} kontu"
       appeal_approved: Pārsūdzēts
@@ -1052,9 +1052,9 @@ lv:
       actions:
         delete_statuses: lai izdzēstu viņu ierakstus
         disable: lai iesaldētu viņu kontu
-        mark_statuses_as_sensitive: lai atzīmētu viņu ziņas kā sensitīvas
+        mark_statuses_as_sensitive: lai atzīmētu viņu ierakstus kā jūtīgus
         none: brīdinājums
-        sensitive: lai atzīmētu viņu kontu kā sensitīvu
+        sensitive: lai atzīmētu viņu kontu kā jūtīgu
         silence: lai ierobežotu viņu kontu
         suspend: lai apturētu viņu kontu
       body: "%{target} iebilst %{action_taken_by} satura pārraudzības lēmumam no %{date}, kas bija %{type}. Viņi rakstīja:"
@@ -1099,7 +1099,7 @@ lv:
       body: Mastodon ir tulkojuši brīvprātīgie.
       guide_link: https://crowdin.com/project/mastodon
       guide_link_text: Ikviens var piedalīties.
-    sensitive_content: Sensitīvs saturs
+    sensitive_content: Jūtīgs saturs
   application_mailer:
     notification_preferences: Mainīt e-pasta uztādījumus
     salutation: "%{name},"
@@ -1263,9 +1263,9 @@ lv:
       title_actions:
         delete_statuses: Ziņas noņemšana
         disable: Konta iesaldēšana
-        mark_statuses_as_sensitive: Ziņu atzīmēšana kā sensitīvas
+        mark_statuses_as_sensitive: Ierakstu atzīmēšana kā jūtīgus
         none: Brīdinājums
-        sensitive: Konta atzīmēšana kā sensitīvs
+        sensitive: Konta atzīmēšana kā jūtīgu
         silence: Konta ierobežošana
         suspend: Konta apturēšana
       your_appeal_approved: Jūsu apelācija ir apstiprināta
@@ -1807,7 +1807,7 @@ lv:
     min_reblogs: Saglabāt ziņas izceltas vismaz
     min_reblogs_hint: Neizdzēš nevienu no tavām ziņām, kas ir izceltas vismaz tik reižu. Atstāj tukšu, lai dzēstu ziņas neatkarīgi no to izcēlumu skaita
   stream_entries:
-    sensitive_content: Sensitīvs saturs
+    sensitive_content: Jūtīgs saturs
   strikes:
     errors:
       too_late: Brīdinājuma apstrīdēšanas laiks ir nokavēts
@@ -1880,8 +1880,8 @@ lv:
       explanation:
         delete_statuses: Tika konstatēts, ka dažas no tavām ziņām pārkāpj vienu vai vairākas kopienas vadlīnijas, un rezultātā %{instance} moderatori tās noņēma.
         disable: Tu vairs nevari izmantot savu kontu, taču tavs profils un citi dati paliek neskarti. Tu vari pieprasīt savu datu dublējumu, mainīt konta iestatījumus vai dzēst kontu.
-        mark_statuses_as_sensitive: "%{instance} moderatori dažus no Taviem ierakstiem ir atzīmējuši kā jutīgus. Tas nozīmē, ka cilvēkiem būs jāpiesit ierakstos esošajiem informāijas nesējiem, pirms tiek attēlots priekšskatījums. Tu arī pats vari atzīmēt informācijas nesēju kā jutīgu, kad nākotnē tādu ievietosi."
-        sensitive: No šī brīža visi augšupielādētie multivides faili tiks atzīmēti kā sensitīvi un paslēpti aiz klikšķa brīdinājuma.
+        mark_statuses_as_sensitive: "%{instance} satura pārraudzītāji dažus no Taviem ierakstiem ir atzīmējuši kā jūtīgus. Tas nozīmē, ka cilvēkiem būs jāpiesit ierakstos esošajiem informāijas nesējiem, pirms tiek attēlots priekšskatījums. Tu pats vari atzīmēt informācijas nesēju kā jūtīgu, kad nākotnē tādu ievietosi."
+        sensitive: Turpmāk visi augšupielādētās informācijas nesēju datnes tiks atzīmētas kā jūtīgas un paslēptas aiz klikšķināma brīdinājuma.
         silence: Tu joprojām vari izmantot savu kontu, taču tikai tie cilvēki, kuri jau tev seko, redzēs tavas ziņas šajā serverī, un tev var tikt liegtas dažādas atklāšanas funkcijas. Tomēr citi joprojām var tev manuāli sekot.
         suspend: Tu vairs nevari izmantot savu kontu, un tavs profils un citi dati vairs nav pieejami. Tu joprojām vari pieteikties, lai pieprasītu savu datu dublēšanu, līdz dati tiks pilnībā noņemti aptuveni 30 dienu laikā, taču mēs saglabāsim dažus pamata datus, lai neļautu tev izvairīties no apturēšanas.
       reason: 'Iemesls:'
@@ -1889,17 +1889,17 @@ lv:
       subject:
         delete_statuses: Tavas ziņas %{acct} tika noņemtas
         disable: Tavs konts %{acct} tika iesaldēts
-        mark_statuses_as_sensitive: Tavas ziņas vietnē %{acct} ir atzīmētas kā sensitīvas
+        mark_statuses_as_sensitive: Tavi ieraksti %{acct} ir atzīmēti kā jūtīgi
         none: Brīdinājums par %{acct}
-        sensitive: Tavas ziņas vietnē %{acct} turpmāk tiks atzīmētas kā sensitīvas
+        sensitive: Tavi ieraksti %{acct} turpmāk tiks atzīmēti kā jūtīgi
         silence: Tavs konts %{acct} tika ierobežots
         suspend: Tava konta %{acct} darbība ir apturēta
       title:
         delete_statuses: Izdzēstās ziņas
         disable: Konts iesaldēts
-        mark_statuses_as_sensitive: Ziņas ir atzīmēts kā sensitīvas
+        mark_statuses_as_sensitive: Ieraksti atzīmēti kā jūtīgi
         none: Brīdinājums
-        sensitive: Konts ir atzīmēts kā sensitīvs
+        sensitive: Konts ir atzīmēts kā jūtīgs
         silence: Konts ierobežots
         suspend: Konts apturēts
     welcome:
diff --git a/config/locales/ru.yml b/config/locales/ru.yml
index 37b8dd5a09..75c3757934 100644
--- a/config/locales/ru.yml
+++ b/config/locales/ru.yml
@@ -315,6 +315,7 @@ ru:
       title: Журнал аудита
       unavailable_instance: "(доменное имя недоступно)"
     announcements:
+      back: Вернуться к объявлениям
       destroyed_msg: Объявление удалено.
       edit:
         title: Редактировать объявление
@@ -323,6 +324,9 @@ ru:
       new:
         create: Создать объявление
         title: Новое объявление
+      preview:
+        explanation_html: 'Сообщение будет отравлено <strong>%{display_count} пользователям</strong>. В теле письма будет указан следующий текст:'
+        title: Предпросмотр объявления по электронной почте
       publish: Опубликовать
       published_msg: Объявление опубликовано.
       scheduled_for: Запланировано на %{time}
@@ -967,6 +971,7 @@ ru:
         chance_to_review_html: "<strong>Сгенерированное пользовательское соглашение не будет опубликовано автоматически.</strong> У вас будет возможность просмотреть результат. Введите все необходимые сведения, чтобы продолжить."
         explanation_html: Шаблон пользовательского соглашения приводится исключительно в ознакомительных целях, и не может рассматриваться как юридическая консультация по тому или иному вопросу. Обратитесь к своему юрисконсульту насчёт вашей ситуации и имеющихся правовых вопросов.
         title: Создание пользовательского соглашения
+      going_live_on_html: Вступило в силу с %{date}
       history: История
       live: Действует
       no_history: Нет зафиксированных изменений пользовательского соглашения.
@@ -1990,6 +1995,10 @@ ru:
     recovery_instructions_html: 'Пожалуйста, сохраните коды ниже в надёжном месте: они понадобятся, чтобы войти в учётную запись, если вы потеряете доступ к своему смартфону. Вы можете вручную переписать их, распечатать и спрятать среди важных документов или, например, в любимой книжке. <strong>Каждый код действителен только один раз</strong>.'
     webauthn: Ключи безопасности
   user_mailer:
+    announcement_published:
+      description: 'Администраторы %{domain} опубликовали новое объявление:'
+      subject: Сервисное объявление
+      title: Сервисное объявление %{domain}
     appeal_approved:
       action: Настройки аккаунта
       explanation: Апелляция на разблокировку против вашей учетной записи %{strike_date}, которую вы подали на %{appeal_date}, была одобрена. Ваша учетная запись снова на хорошем счету.
@@ -2022,6 +2031,8 @@ ru:
     terms_of_service_changed:
       agreement: Продолжая использовать %{domain}, вы соглашаетесь с этими условиями. Если вы не согласны с новыми условиями, вы в любой момент можете удалить вашу учётную запись на %{domain}.
       changelog: 'Вот что обновление условий будет значит для вас в общих чертах:'
+      description: 'Вы получили это сообщение, потому что мы внесли некоторые изменения в пользовательское соглашение %{domain}. Эти изменения вступят в силу %{date}. Рекомендуем вам ознакомиться с обновлёнными условиями по ссылке:'
+      description_html: Вы получили это сообщение, потому что мы внесли некоторые изменения в пользовательское соглашение %{domain}. Эти изменения вступят в силу <strong>%{date}</strong>. Рекомендуем вам ознакомиться с <a href="%{path}" target="_blank">обновлёнными условиями</a>.
       sign_off: Ваш %{domain}
       subject: Обновления наших условий использования
       subtitle: На %{domain} изменилось пользовательское соглашение
diff --git a/config/locales/simple_form.bg.yml b/config/locales/simple_form.bg.yml
index 7a4fea91b2..b3a5882562 100644
--- a/config/locales/simple_form.bg.yml
+++ b/config/locales/simple_form.bg.yml
@@ -75,6 +75,7 @@ bg:
       filters:
         action: Изберете кое действие да се извърши, прецеждайки съвпаденията на публикацията
         actions:
+          blur: Скриване на мултимедия зад предупреждение, но без скриване на самия текст
           hide: Напълно скриване на филтрираното съдържание, сякаш не съществува
           warn: Скриване на филтрираното съдържание зад предупреждение, споменавайки заглавието на филтъра
       form_admin_settings:
@@ -88,6 +89,7 @@ bg:
         favicon: WEBP, PNG, GIF или JPG. Заменя стандартната сайтоикона на Mastodon с произволна икона.
         mascot: Замества илюстрацията в разширения уеб интерфейс.
         media_cache_retention_period: Мултимедийни файлове от публикации, направени от отдалечени потребители, се сринаха в сървъра ви. Задавайки положителна стойност, мултимедията ще се изтрие след посочения брой дни. Ако се искат мултимедийни данни след изтриването, то ще се изтегли пак, ако още е наличен източникът на съдържание. Поради ограниченията за това колко често картите за предварващ преглед на връзките анкетират сайтове на трети страни, се препоръчва да зададете тази стойност на поне 14 дни или картите за предварващ преглед на връзките няма да се обновяват при поискване преди този момент.
+        min_age: Потребителите ще се питат да потвърдят рождената си дата по време на регириране
         peers_api_enabled: Списък от имена на домейни, с които сървърът се е свързал във федивселената. Тук не се включват данни за това дали федерирате с даден сървър, а само за това дали сървърът ви знае за него. Това се ползва от услуги, събиращи статистика за федерацията в общия смисъл.
         profile_directory: Указателят на профили вписва всички потребители, избрали да бъдат откриваеми.
         require_invite_text: Когато регистрацията изисква ръчно одобрение, то направете текстовото поле за това "Защо желаете да се присъедините?" по-скоро задължително, отколкото по желание
@@ -136,12 +138,15 @@ bg:
         text: Може да се структурира със синтаксиса на Markdown.
       terms_of_service_generator:
         admin_email: Правните бележки включват насрещни известия, постановления на съда, заявки за сваляне и заявки от правоохранителните органи.
+        arbitration_address: Може да е същото като физическия адрес горе или "неприложимо", ако се употребява имейл.
         choice_of_law: Град, регион, територия, щат или държава, чиито вътрешни материални права ще уреждат всички искове.
+        dmca_email: Може да е същият имейл, използван за "Имейл адрес за правни известия" по-горе.
         domain: Неповторимо идентифициране на онлайн услугата, която предоставяте.
         jurisdiction: Впишете държавата, където живее всеки, който плаща сметките. Ако е дружество или друго образувание, то впишете държавата, в която е регистрирано, и градът, регионът, територията или щатът според случая.
         min_age: Не трябва да е под изискваната минимална възраст от закона на юрисдикцията ви.
       user:
         chosen_languages: Само публикации на отметнатите езици ще се показват в публичните часови оси
+        date_of_birth: Трябва да се уверим, че сте поне на %{age}, за да употребявате Mastodon. Няма да съхраняваме това.
         role: Ролята управлява какви позволения има потребителят.
       user_role:
         color: Цветът, използван за ролите в потребителския интерфейс, като RGB в шестнадесетичен формат
@@ -254,6 +259,7 @@ bg:
         name: Хаштаг
       filters:
         actions:
+          blur: Скриване на мултимедията с предупреждение
           hide: Напълно скриване
           warn: Скриване зад предупреждение
       form_admin_settings:
@@ -267,6 +273,7 @@ bg:
         favicon: Сайтоикона
         mascot: Плашило талисман по избор (остаряло)
         media_cache_retention_period: Период на запазване на мултимедийния кеш
+        min_age: Минимално възрастово изискване
         peers_api_enabled: Публикуване на списъка с открити сървъри в API
         profile_directory: Показване на директорията от профили
         registrations_mode: Кой може да се регистрира
@@ -345,6 +352,9 @@ bg:
         jurisdiction: Законова юрисдикция
         min_age: Минимална възраст
       user:
+        date_of_birth_1i: Ден
+        date_of_birth_2i: Месец
+        date_of_birth_3i: Година
         role: Роля
         time_zone: Часова зона
       user_role:
diff --git a/config/locales/simple_form.ca.yml b/config/locales/simple_form.ca.yml
index 2f4c96edab..2f85738c32 100644
--- a/config/locales/simple_form.ca.yml
+++ b/config/locales/simple_form.ca.yml
@@ -75,6 +75,7 @@ ca:
       filters:
         action: Tria quina acció cal executar quan un apunt coincideixi amb el filtre
         actions:
+          blur: Amaga el contingut multimèdia rere un avís, sense amagar el text en si
           hide: Ocultar completament el contingut filtrat, comportant-se com si no existís
           warn: Oculta el contingut filtrat darrere d'un avís mencionant el títol del filtre
       form_admin_settings:
@@ -88,6 +89,7 @@ ca:
         favicon: WEBP, PNG, GIF o JPG. Canvia la icona per defecte de Mastodon a la pestanya del navegador per una de personalitzada.
         mascot: Anul·la la il·lustració en la interfície web avançada.
         media_cache_retention_period: El vostre servidor conserva una còpia dels fitxers multimèdia de les publicacions dels usuaris remots. Si s'indica un valor positiu, s'esborraran passats els dies indicats. Si el fitxer es torna a demanar un cop esborrat, es tornarà a baixar si el contingut origen segueix disponible. Per causa de les restriccions en la freqüència amb què es poden demanar les targetes de previsualització d'altres servidors, es recomana definir aquest valor com a mínim a 14 dies, o les targetes de previsualització no s'actualizaran a demanda abans d'aquest termini.
+        min_age: Es demanarà als usuaris la data de naixement durant la inscripció
         peers_api_enabled: Una llista de noms de domini que aquest servidor ha trobat al fedivers. No inclou cap dada sobre si estàs federat amb un servidor determinat, només si el teu en sap res. La fan servir, en un sentit general, serveis que recol·lecten estadístiques sobre la federació.
         profile_directory: El directori de perfils llista tots els usuaris que tenen activat ser descoberts.
         require_invite_text: Quan el registre requereixi aprovació manual, fes que sigui obligatori en lloc d'opcional d'escriure el text de la sol·licitud d'invitació "Per què vols unir-te?"
@@ -143,6 +145,7 @@ ca:
         min_age: No hauria de ser inferior a l'edat mínima exigida per la llei de la vostra jurisdicció.
       user:
         chosen_languages: Quan estigui marcat, només es mostraran els tuts de les llengües seleccionades en les línies de temps públiques
+        date_of_birth: Ens hem d'assegurar que teniu %{age} anys com a mínim. No desarem aquesta dada.
         role: El rol controla quins permisos té l'usuari.
       user_role:
         color: Color que s'usarà per al rol a tota la interfície d'usuari, com a RGB en format hexadecimal
@@ -255,6 +258,7 @@ ca:
         name: Etiqueta
       filters:
         actions:
+          blur: Amaga el contingut multimèdia amb un avís
           hide: Oculta completament
           warn: Oculta amb un avís
       form_admin_settings:
@@ -268,6 +272,7 @@ ca:
         favicon: Icona de preferits
         mascot: Mascota personalitzada (llegat)
         media_cache_retention_period: Període de retenció del cau multimèdia
+        min_age: Edat mínima requerida
         peers_api_enabled: Publica a l'API la llista de servidors descoberts
         profile_directory: Habilita el directori de perfils
         registrations_mode: Qui es pot registrar
@@ -346,6 +351,9 @@ ca:
         jurisdiction: Jurisdicció
         min_age: Edat mínima
       user:
+        date_of_birth_1i: Dia
+        date_of_birth_2i: Mes
+        date_of_birth_3i: Any
         role: Rol
         time_zone: Zona horària
       user_role:
diff --git a/config/locales/simple_form.cs.yml b/config/locales/simple_form.cs.yml
index f384de36b6..00abf91d3e 100644
--- a/config/locales/simple_form.cs.yml
+++ b/config/locales/simple_form.cs.yml
@@ -3,7 +3,7 @@ cs:
   simple_form:
     hints:
       account:
-        attribution_domains: Jeden na řádek. Chrání před falešným připisování autorství.
+        attribution_domains: Jeden na řádek. Chrání před falešným připisováním autorství.
         discoverable: Vaše veřejné příspěvky a profil mohou být zobrazeny nebo doporučeny v různých oblastech Mastodonu a váš profil může být navrhován ostatním uživatelům.
         display_name: Vaše celé jméno nebo přezdívka.
         fields: Vaše domovská stránka, zájmena, věk, cokoliv chcete.
@@ -75,6 +75,7 @@ cs:
       filters:
         action: Vyberte, jakou akci provést, když příspěvek odpovídá filtru
         actions:
+          blur: Skrýt média za varováním, bez skrytí samotného textu
           hide: Úplně schovat filtrovaný obsah tak, jako by neexistoval
           warn: Schovat filtrovaný obsah za varováním zmiňujicím název filtru
       form_admin_settings:
@@ -88,6 +89,7 @@ cs:
         favicon: WEBP, PNG, GIF nebo JPG. Nahradí výchozí favicon Mastodonu vlastní ikonou.
         mascot: Přepíše ilustraci v pokročilém webovém rozhraní.
         media_cache_retention_period: Mediální soubory z příspěvků vzdálených uživatelů se ukládají do mezipaměti na vašem serveru. Pokud je nastaveno na kladnou hodnotu, budou média po zadaném počtu dní odstraněna. Pokud jsou mediální data vyžádána po jejich odstranění, budou znovu stažena, pokud je zdrojový obsah stále k dispozici. Vzhledem k omezením týkajícím se četnosti dotazů karet náhledů odkazů na weby třetích stran se doporučuje nastavit tuto hodnotu alespoň na 14 dní, jinak nebudou karty náhledů odkazů na vyžádání aktualizovány dříve.
+        min_age: Uživatelé budou požádáni, aby při registraci potvrdili datum svého narození
         peers_api_enabled: Seznam názvů domén se kterými se tento server setkal ve fediversu. Neobsahuje žádná data o tom, zda jste federovali s daným serverem, pouze že o něm váš server ví. Toto je využíváno službami, které sbírají o federování statistiku v obecném smyslu.
         profile_directory: Adresář profilu obsahuje seznam všech uživatelů, kteří se přihlásili, aby mohli být nalezeni.
         require_invite_text: Pokud přihlášení vyžaduje ruční schválení, měl by být textový vstup „Proč se chcete připojit?“ povinný spíše než volitelný
@@ -146,6 +148,7 @@ cs:
         min_age: Neměla by být pod minimálním věkem požadovaným zákony vaší jurisdikce.
       user:
         chosen_languages: Po zaškrtnutí budou ve veřejných časových osách zobrazeny pouze příspěvky ve zvolených jazycích
+        date_of_birth: Musíme se ujistit, že je Vám alespoň %{age}, abyste mohli používat Mastodon. Nebudeme to ukládat.
         role: Role určuje, která oprávnění uživatel má.
       user_role:
         color: Barva, která má být použita pro roli v celém UI, jako RGB v hex formátu
@@ -258,6 +261,7 @@ cs:
         name: Hashtag
       filters:
         actions:
+          blur: Skrýt média za varováním
           hide: Zcela skrýt
           warn: Skrýt s varováním
       form_admin_settings:
@@ -271,6 +275,7 @@ cs:
         favicon: Favicon
         mascot: Vlastní maskot (zastaralé)
         media_cache_retention_period: Doba uchovávání mezipaměti médií
+        min_age: Minimální věková hranice
         peers_api_enabled: Zveřejnit seznam nalezených serverů v API
         profile_directory: Povolit adresář profilů
         registrations_mode: Kdo se může přihlásit
@@ -349,6 +354,9 @@ cs:
         jurisdiction: Právní příslušnost
         min_age: Věková hranice
       user:
+        date_of_birth_1i: Den
+        date_of_birth_2i: Měsíc
+        date_of_birth_3i: Rok
         role: Role
         time_zone: Časové pásmo
       user_role:
diff --git a/config/locales/simple_form.cy.yml b/config/locales/simple_form.cy.yml
index be60431e51..c9c7862a91 100644
--- a/config/locales/simple_form.cy.yml
+++ b/config/locales/simple_form.cy.yml
@@ -41,7 +41,7 @@ cy:
       defaults:
         autofollow: Bydd pobl sy'n cofrestru drwy'r gwahoddiad yn eich dilyn yn awtomatig
         avatar: WEBP, PNG, GIF neu JPG. %{size} ar y mwyaf. Bydd yn cael ei leihau i %{dimensions}px
-        bot: Mae'r cyfrif hwn yn perfformio gweithredoedd awtomatig yn bennaf ac mae'n bosib nad yw'n cael ei fonitro
+        bot: Rhoi gwybod i eraill bod y cyfrif hwn yn perfformio gweithredoedd awtomatig yn bennaf ac mae'n bosib nad yw'n cael ei fonitro
         context: Un neu fwy cyd-destun lle dylai'r hidlydd weithio
         current_password: At ddibenion diogelwch, nodwch gyfrinair y cyfrif cyfredol
         current_username: I gadarnhau, nodwch enw defnyddiwr y cyfrif cyfredol
@@ -75,6 +75,7 @@ cy:
       filters:
         action: Dewiswch pa weithred i'w chyflawni pan fydd postiad yn cyfateb i'r hidlydd
         actions:
+          blur: Cuddio cyfryngau tu ôl i rybudd, heb guddio'r testun ei hun
           hide: Cuddiwch y cynnwys wedi'i hidlo'n llwyr, gan ymddwyn fel pe na bai'n bodoli
           warn: Cuddiwch y cynnwys wedi'i hidlo y tu ôl i rybudd sy'n sôn am deitl yr hidlydd
       form_admin_settings:
@@ -88,6 +89,7 @@ cy:
         favicon: WEBP, PNG, GIF neu JPG. Yn diystyru'r favicon Mastodon rhagosodedig gydag eicon cyfaddas.
         mascot: Yn diystyru'r darlun yn y rhyngwyneb gwe uwch.
         media_cache_retention_period: Mae ffeiliau cyfryngau o bostiadau a wneir gan ddefnyddwyr o bell yn cael eu storio ar eich gweinydd. Pan gaiff ei osod i werth positif, bydd y cyfryngau yn cael eu dileu ar ôl y nifer penodedig o ddyddiau. Os gofynnir am y data cyfryngau ar ôl iddo gael ei ddileu, caiff ei ail-lwytho i lawr, os yw'r cynnwys ffynhonnell yn dal i fod ar gael. Oherwydd cyfyngiadau ar ba mor aml y mae cardiau rhagolwg cyswllt yn pleidleisio i wefannau trydydd parti, argymhellir gosod y gwerth hwn i o leiaf 14 diwrnod, neu ni fydd cardiau rhagolwg cyswllt yn cael eu diweddaru ar alw cyn yr amser hwnnw.
+        min_age: Mae gofyn i ddefnyddwyr gadarnhau eu dyddiad geni wrth gofrestru
         peers_api_enabled: Rhestr o enwau parth y mae'r gweinydd hwn wedi dod ar eu traws yn y ffediws. Nid oes unrhyw ddata wedi'i gynnwys yma ynghylch a ydych chi'n ffedereiddio â gweinydd penodol, dim ond bod eich gweinydd yn gwybod amdano. Defnyddir hwn gan wasanaethau sy'n casglu ystadegau ar ffedereiddio mewn ystyr cyffredinol.
         profile_directory: Mae'r cyfeiriadur proffil yn rhestru'r holl ddefnyddwyr sydd wedi dewis i fod yn ddarganfyddiadwy.
         require_invite_text: Pan fydd angen cymeradwyaeth â llaw ar gyfer cofrestriadau, gwnewch y “Pam ydych chi am ymuno?” mewnbwn testun yn orfodol yn hytrach na dewisol
@@ -146,6 +148,7 @@ cy:
         min_age: Ni ddylai fod yn is na'r isafswm oedran sy'n ofynnol gan gyfreithiau eich awdurdodaeth.
       user:
         chosen_languages: Wedi eu dewis, dim ond tŵtiau yn yr ieithoedd hyn bydd yn cael eu harddangos mewn ffrydiau cyhoeddus
+        date_of_birth: Mae'n rhaid i ni sicrhau eich bod o leiaf %{age} i ddefnyddio Mastodon. Fyddwn ni ddim yn storio hwn.
         role: Mae'r rôl yn rheoli pa ganiatâd sydd gan y defnyddiwr.
       user_role:
         color: Lliw i'w ddefnyddio ar gyfer y rôl drwy'r UI, fel RGB mewn fformat hecs
@@ -258,6 +261,7 @@ cy:
         name: Hashnod
       filters:
         actions:
+          blur: Cuddio cyfryngau gyda rhybudd
           hide: Cuddio'n llwyr
           warn: Cuddio â rhybudd
       form_admin_settings:
@@ -271,6 +275,7 @@ cy:
         favicon: Favicon
         mascot: Mascot cyfaddas (hen)
         media_cache_retention_period: Cyfnod cadw storfa cyfryngau
+        min_age: Gofyniad oed ieuengaf
         peers_api_enabled: Cyhoeddi rhestr o weinyddion a ddarganfuwyd yn yr API
         profile_directory: Galluogi cyfeiriadur proffil
         registrations_mode: Pwy all gofrestru
@@ -336,7 +341,7 @@ cy:
         usable: Caniatáu i bostiadau ddefnyddio'r hashnod hwn yn lleol
       terms_of_service:
         changelog: Beth sydd wedi newid?
-        effective_date: Dyddiad effeithiol
+        effective_date: Dyddiad dod i rym
         text: Telerau Gwasanaeth
       terms_of_service_generator:
         admin_email: Cyfeiriad e-bost ar gyfer hysbysiadau cyfreithiol
@@ -349,6 +354,9 @@ cy:
         jurisdiction: Awdurdodaeth gyfreithiol
         min_age: Isafswm oedran
       user:
+        date_of_birth_1i: Dydd
+        date_of_birth_2i: Mis
+        date_of_birth_3i: Blwyddyn
         role: Rôl
         time_zone: Cylchfa amser
       user_role:
diff --git a/config/locales/simple_form.da.yml b/config/locales/simple_form.da.yml
index 6cc86a6d3e..91582e60c5 100644
--- a/config/locales/simple_form.da.yml
+++ b/config/locales/simple_form.da.yml
@@ -75,6 +75,7 @@ da:
       filters:
         action: Vælg handlingen til eksekvering, når et indlæg matcher filteret
         actions:
+          blur: Skjul medier bag en advarsel, uden at skjule selve teksten
           hide: Skjul filtreret indhold helt (adfærd som om, det ikke fandtes)
           warn: Skjul filtreret indhold bag en advarsel, der nævner filterets titel
       form_admin_settings:
@@ -88,6 +89,7 @@ da:
         favicon: WEBP, PNG, GIF eller JPG. Tilsidesætter standard Mastodon favikonet på mobilenheder med et tilpasset ikon.
         mascot: Tilsidesætter illustrationen i den avancerede webgrænseflade.
         media_cache_retention_period: Mediefiler fra indlæg oprettet af eksterne brugere er cachet på din server. Når sat til positiv værdi, slettes medier efter det angivne antal dage. Anmodes om mediedata efter de er slettet, gendownloades de, hvis kildeindholdet stadig er tilgængeligt. Grundet begrænsninger på, hvor ofte linkforhåndsvisningskort forespørger tredjeparts websteder, anbefales det at sætte denne værdi til mindst 14 dage, ellers opdateres linkforhåndsvisningskort ikke efter behov før det tidspunkt.
+        min_age: Brugere anmodes om at bekræfte deres fødselsdato under tilmelding
         peers_api_enabled: En liste med domænenavne, som denne server har stødt på i fediverset. Ingen data inkluderes her om, hvorvidt der fødereres med en given server, blot at din server kender til det. Dette bruges af tjenester, som indsamler generelle føderationsstatistikker.
         profile_directory: Profilmappen oplister alle brugere, som har valgt at kunne opdages.
         require_invite_text: Når tilmelding kræver manuel godkendelse, så gør “Hvorfor ønsker du at deltage?” tekstinput obligatorisk i stedet for valgfrit
@@ -146,6 +148,7 @@ da:
         min_age: Bør ikke være under den iht. lovgivningen i det aktuelle retsområde krævede minimumsalder.
       user:
         chosen_languages: Når markeret, vil kun indlæg på de valgte sprog fremgå på offentlige tidslinjer
+        date_of_birth: Vi er nødt til at sikre, at man er fyldt %{age} for at bruge Mastodon. Vi gemmer ikke denne information.
         role: Rollen styrer, hvilke tilladelser brugeren er tildelt.
       user_role:
         color: Farven, i RGB hex-format, der skal bruges til rollen i hele UI'en
@@ -258,6 +261,7 @@ da:
         name: Hashtag
       filters:
         actions:
+          blur: Skjul medier med en advarsel
           hide: Skjul helt
           warn: Skjul bag en advarsel
       form_admin_settings:
@@ -271,6 +275,7 @@ da:
         favicon: Favikon
         mascot: Tilpasset maskot (ældre funktion)
         media_cache_retention_period: Media-cache opbevaringsperiode
+        min_age: Minimums alderskrav
         peers_api_enabled: Udgiv liste over fundne server i API'en
         profile_directory: Aktivér profiloversigt
         registrations_mode: Hvem, der kan tilmelde sig
@@ -349,6 +354,9 @@ da:
         jurisdiction: Juridisk jurisdiktion
         min_age: Minimumsalder
       user:
+        date_of_birth_1i: Dag
+        date_of_birth_2i: Måned
+        date_of_birth_3i: År
         role: Rolle
         time_zone: Tidszone
       user_role:
diff --git a/config/locales/simple_form.de.yml b/config/locales/simple_form.de.yml
index 13cfe4e324..016ed4b25a 100644
--- a/config/locales/simple_form.de.yml
+++ b/config/locales/simple_form.de.yml
@@ -75,6 +75,7 @@ de:
       filters:
         action: Gib an, welche Aktion ausgeführt werden soll, wenn ein Beitrag dem Filter entspricht
         actions:
+          blur: Medien mit einer Warnung ausblenden, ohne den Text selbst auszublenden
           hide: Den gefilterten Beitrag vollständig ausblenden, als hätte er nie existiert
           warn: Den gefilterten Beitrag hinter einer Warnung, die den Filtertitel beinhaltet, ausblenden
       form_admin_settings:
@@ -88,6 +89,7 @@ de:
         favicon: WEBP, PNG, GIF oder JPG. Überschreibt das Standard-Mastodon-Favicon mit einem eigenen Symbol.
         mascot: Überschreibt die Abbildung in der erweiterten Weboberfläche.
         media_cache_retention_period: Mediendateien aus Beiträgen von externen Nutzer*innen werden auf deinem Server zwischengespeichert. Wenn ein positiver Wert gesetzt ist, werden die Medien nach der festgelegten Anzahl von Tagen gelöscht. Sollten die Medien nach dem Löschvorgang wieder angefragt werden, werden sie erneut heruntergeladen, sofern der ursprüngliche Inhalt noch vorhanden ist. Es wird empfohlen, diesen Wert auf mindestens 14 Tage festzulegen, da die Häufigkeit der Abfrage von Linkvorschaukarten für Websites von Dritten begrenzt ist und die Linkvorschaukarten sonst nicht vor Ablauf dieser Zeit aktualisiert werden.
+        min_age: Nutzer*innen werden bei der Registrierung aufgefordert, ihr Geburtsdatum zu bestätigen
         peers_api_enabled: Eine Liste von Domains, die diesem Server im Fediverse begegnet sind. Hierbei werden keine Angaben darüber gemacht, ob du mit einem bestimmten Server föderierst, sondern nur, dass dein Server davon weiß. Dies wird von Diensten verwendet, die allgemein Statistiken übers Ferdiverse sammeln.
         profile_directory: Dieses Verzeichnis zeigt alle Profile an, die sich dafür entschieden haben, entdeckt zu werden.
         require_invite_text: Wenn Registrierungen eine manuelle Genehmigung erfordern, dann werden Nutzer einen Grund für ihre Registrierung angeben müssen
@@ -146,6 +148,7 @@ de:
         min_age: Sollte nicht unter dem gesetzlich vorgeschriebenen Mindestalter liegen.
       user:
         chosen_languages: Wenn du hier eine oder mehrere Sprachen auswählst, werden ausschließlich Beiträge in diesen Sprachen in deinen öffentlichen Timelines angezeigt
+        date_of_birth: Wir müssen sicherstellen, dass du mindestens %{age} Jahre alt bist, um Mastodon verwenden zu können. Das Alter wird nicht gespeichert.
         role: Die Rolle bestimmt, welche Berechtigungen das Konto hat.
       user_role:
         color: Farbe, die für diese Rolle in der gesamten Benutzerschnittstelle verwendet wird, als RGB im Hexadezimalsystem
@@ -258,8 +261,9 @@ de:
         name: Hashtag
       filters:
         actions:
+          blur: Medien mit einer Warnung ausblenden
           hide: Vollständig ausblenden
-          warn: Mit einer Inhaltswarnung ausblenden
+          warn: Mit einer Warnung ausblenden
       form_admin_settings:
         activity_api_enabled: Aggregierte Nutzungsdaten über die API veröffentlichen
         app_icon: App-Symbol
@@ -271,6 +275,7 @@ de:
         favicon: Favicon
         mascot: Benutzerdefiniertes Maskottchen (Legacy)
         media_cache_retention_period: Aufbewahrungsfrist für Medien im Cache
+        min_age: Erforderliches Mindestalter
         peers_api_enabled: Die entdeckten Server im Fediverse über die API veröffentlichen
         profile_directory: Profilverzeichnis aktivieren
         registrations_mode: Wer darf ein neues Konto registrieren?
@@ -349,6 +354,9 @@ de:
         jurisdiction: Gerichtsstand
         min_age: Mindestalter
       user:
+        date_of_birth_1i: Tag
+        date_of_birth_2i: Monat
+        date_of_birth_3i: Jahr
         role: Rolle
         time_zone: Zeitzone
       user_role:
diff --git a/config/locales/simple_form.en.yml b/config/locales/simple_form.en.yml
index 805705a0d0..9264216121 100644
--- a/config/locales/simple_form.en.yml
+++ b/config/locales/simple_form.en.yml
@@ -99,6 +99,7 @@ en:
       filters:
         action: Chose which action to perform when a post matches the filter
         actions:
+          blur: Hide media behind a warning, without hiding the text itself
           hide: Completely hide the filtered content, behaving as if it did not exist
           warn: Hide the filtered content behind a warning mentioning the filter's title
       form_admin_settings:
@@ -116,6 +117,7 @@ en:
         favicon: WEBP, PNG, GIF or JPG. Overrides the default Mastodon favicon with a custom icon.
         mascot: Overrides the illustration in the advanced web interface.
         media_cache_retention_period: Media files from posts made by remote users are cached on your server. When set to a positive value, media will be deleted after the specified number of days. If the media data is requested after it is deleted, it will be re-downloaded, if the source content is still available. Due to restrictions on how often link preview cards poll third-party sites, it is recommended to set this value to at least 14 days, or link preview cards will not be updated on demand before that time.
+        min_age: Users will be asked to confirm their date of birth during sign-up
         peers_api_enabled: A list of domain names this server has encountered in the fediverse. No data is included here about whether you federate with a given server, just that your server knows about it. This is used by services that collect statistics on federation in a general sense.
         profile_directory: The profile directory lists all users who have opted-in to be discoverable.
         receive_other_servers_emoji_reaction: It can cause load. It is recommended to enable it only when there are few people.
@@ -180,6 +182,7 @@ en:
         min_age: Should not be below the minimum age required by the laws of your jurisdiction.
       user:
         chosen_languages: When checked, only posts in selected languages will be displayed in public timelines
+        date_of_birth: We have to make sure you're at least %{age} to use Mastodon. We won't store this.
         role: The role controls which permissions the user has.
       user_role:
         color: Color to be used for the role throughout the UI, as RGB in hex format
@@ -376,6 +379,7 @@ en:
         name: Hashtag
       filters:
         actions:
+          blur: Hide media with a warning
           hide: Hide completely
           warn: Hide with a warning
         options:
@@ -400,6 +404,7 @@ en:
         favicon: Favicon
         mascot: Custom mascot (legacy)
         media_cache_retention_period: Media cache retention period
+        min_age: Minimum age requirement
         peers_api_enabled: Publish list of discovered servers in the API
         profile_directory: Enable profile directory
         receive_other_servers_emoji_reaction: Receive emoji reaction between other server users
@@ -491,6 +496,9 @@ en:
         jurisdiction: Legal jurisdiction
         min_age: Minimum age
       user:
+        date_of_birth_1i: Day
+        date_of_birth_2i: Month
+        date_of_birth_3i: Year
         role: Role
         time_zone: Time zone
       user_role:
diff --git a/config/locales/simple_form.eo.yml b/config/locales/simple_form.eo.yml
index 06da13b199..1331ea7333 100644
--- a/config/locales/simple_form.eo.yml
+++ b/config/locales/simple_form.eo.yml
@@ -75,6 +75,7 @@ eo:
       filters:
         action: Elekti ago kiam mesaĝo kongruas la filtrilon
         actions:
+          blur: Kaŝi amaskomunikilaron malantaŭ averto, sen kaŝi la tekston mem
           hide: Tute kaŝigi la filtritajn enhavojn, kvazau ĝi ne ekzistis
           warn: Kaŝi la enhavon filtritan malantaŭ averto mencianta la nomon de la filtro
       form_admin_settings:
@@ -142,6 +143,7 @@ eo:
         jurisdiction: Enlistigu la landon, kie loĝas kiu pagas la fakturojn. Se ĝi estas kompanio aŭ alia ento, listigu la landon, kie ĝi estas enkorpigita, kaj la urbon, regionon, teritorion aŭ ŝtaton laŭeble.
       user:
         chosen_languages: Kun tio markita nur mesaĝoj en elektitaj lingvoj aperos en publikaj tempolinioj
+        date_of_birth: Ni devas certigi, ke vi estas almenaŭ %{age} por uzi Mastodon. Ni ne konservos ĉi tion.
         role: La rolo kontrolas kiujn permesojn la uzanto havas.
       user_role:
         color: Koloro uzita por la rolo sur la UI, kun RGB-formato
@@ -254,6 +256,7 @@ eo:
         name: Kradvorto
       filters:
         actions:
+          blur: Kaŝi amaskomunikilaron kun averto
           hide: Kaŝi komplete
           warn: Kaŝi malantaŭ averto
       form_admin_settings:
@@ -343,7 +346,11 @@ eo:
         dmca_email: Retpoŝtadreso por DMCA/kopirajto-avizoj
         domain: Domajno
         jurisdiction: Laŭleĝa jurisdikcio
+        min_age: Minimuma aĝo
       user:
+        date_of_birth_1i: Tago
+        date_of_birth_2i: Monato
+        date_of_birth_3i: Jaro
         role: Rolo
         time_zone: Horzono
       user_role:
diff --git a/config/locales/simple_form.es-AR.yml b/config/locales/simple_form.es-AR.yml
index 81dbef0723..4333db9fed 100644
--- a/config/locales/simple_form.es-AR.yml
+++ b/config/locales/simple_form.es-AR.yml
@@ -75,6 +75,7 @@ es-AR:
       filters:
         action: Elegir qué acción realizar cuando un mensaje coincide con el filtro
         actions:
+          blur: Ocultar medios detrás de una advertencia, sin ocultar el texto en sí mismo
           hide: Ocultar completamente el contenido filtrado, comportándose como si no existiera
           warn: Ocultar el contenido filtrado detrás de una advertencia mencionando el título del filtro
       form_admin_settings:
@@ -88,6 +89,7 @@ es-AR:
         favicon: WEBP, PNG, GIF o JPG. Reemplaza el favicón predeterminado de Mastodon con uno personalizado.
         mascot: Reemplaza la ilustración en la interface web avanzada.
         media_cache_retention_period: Los archivos de medios de mensajes publicados por usuarios remotos se almacenan en la memoria caché en tu servidor. Cuando se establece un valor positivo, los medios se eliminarán después del número especificado de días. Si los datos multimedia se solicitan después de eliminarse, se volverán a descargar, si es que el contenido fuente todavía está disponible. Debido a restricciones en la frecuencia con la que las tarjetas de previsualización de enlace consultan a sitios web de terceros, se recomienda establecer este valor a, al menos, 14 días, o las tarjetas de previsualización de enlaces no se actualizarán a pedido antes de ese momento.
+        min_age: Se pedirá a los usuarios que confirmen su fecha de nacimiento durante el registro
         peers_api_enabled: Una lista de nombres de dominio que este servidor ha encontrado en el Fediverso. Acá no se incluye ningún dato sobre si federás con un servidor determinado, sólo que tu servidor lo conoce. Esto es usado por los servicios que recopilan estadísticas sobre la federación en un sentido general.
         profile_directory: El directorio de perfiles lista a todos los usuarios que han optado a que su cuenta pueda ser descubierta.
         require_invite_text: Cuando registros aprobación manual, hacé que la solicitud de invitación "¿Por qué querés unirte?" sea obligatoria, en vez de opcional
@@ -146,6 +148,7 @@ es-AR:
         min_age: No debería estar por debajo de la edad mínima requerida por las leyes de su jurisdicción.
       user:
         chosen_languages: Cuando estén marcados, sólo se mostrarán los mensajes en los idiomas seleccionados en las líneas temporales públicas
+        date_of_birth: Tenemos que asegurarnos de que al menos tenés %{age} años de edad para usar Mastodon. No almacenaremos esta información.
         role: El rol controla qué permisos tiene el usuario.
       user_role:
         color: Color que se utilizará para el rol a lo largo de la interface de usuario, como RGB en formato hexadecimal
@@ -258,6 +261,7 @@ es-AR:
         name: Etiqueta
       filters:
         actions:
+          blur: Ocultar medios con una advertencia
           hide: Ocultar completamente
           warn: Ocultar con una advertencia
       form_admin_settings:
@@ -271,6 +275,7 @@ es-AR:
         favicon: Favicón
         mascot: Mascota personalizada (legado)
         media_cache_retention_period: Período de retención de la caché de medios
+        min_age: Edad mínima requerida
         peers_api_enabled: Publicar lista de servidores descubiertos en la API
         profile_directory: Habilitar directorio de perfiles
         registrations_mode: Quién puede registrarse
@@ -349,6 +354,9 @@ es-AR:
         jurisdiction: Jurisdicción legal
         min_age: Edad mínima
       user:
+        date_of_birth_1i: Día
+        date_of_birth_2i: Mes
+        date_of_birth_3i: Año
         role: Rol
         time_zone: Zona horaria
       user_role:
diff --git a/config/locales/simple_form.es-MX.yml b/config/locales/simple_form.es-MX.yml
index 4b47f33c2e..63287a3989 100644
--- a/config/locales/simple_form.es-MX.yml
+++ b/config/locales/simple_form.es-MX.yml
@@ -75,6 +75,7 @@ es-MX:
       filters:
         action: Elegir qué acción realizar cuando una publicación coincide con el filtro
         actions:
+          blur: Ocultar contenido multimedia detrás de una advertencia, sin ocultar el texto en sí
           hide: Ocultar completamente el contenido filtrado, comportándose como si no existiera
           warn: Ocultar el contenido filtrado detrás de una advertencia mencionando el título del filtro
       form_admin_settings:
@@ -88,6 +89,7 @@ es-MX:
         favicon: WEBP, PNG, GIF o JPG. Reemplaza el icono predeterminado de Mastodon con un icono personalizado.
         mascot: Reemplaza la ilustración en la interfaz web avanzada.
         media_cache_retention_period: Los archivos multimedia de las publicaciones realizadas por usuarios remotos se almacenan en caché en su servidor. Si se establece en un valor positivo, los archivos multimedia se eliminarán tras el número de días especificado. Si los datos multimedia se solicitan después de haber sido eliminados, se volverán a descargar, si el contenido de origen sigue estando disponible. Debido a las restricciones sobre la frecuencia con la que las tarjetas de previsualización de enlaces sondean sitios de terceros, se recomienda establecer este valor en al menos 14 días, o las tarjetas de previsualización de enlaces no se actualizarán bajo demanda antes de ese tiempo.
+        min_age: Se pedirá a los usuarios que confirmen su fecha de nacimiento durante el registro
         peers_api_enabled: Una lista de nombres de dominio que este servidor ha encontrado en el fediverso. Aquí no se incluye ningún dato sobre si usted federa con un servidor determinado, sólo que su servidor lo sabe. Esto es utilizado por los servicios que recopilan estadísticas sobre la federación en un sentido general.
         profile_directory: El directorio de perfiles lista a todos los usuarios que han optado por que su cuenta pueda ser descubierta.
         require_invite_text: Cuando los registros requieren aprobación manual, hace obligatoria la entrada de texto "¿Por qué quieres unirte?" en lugar de opcional
@@ -146,6 +148,7 @@ es-MX:
         min_age: No debería estar por debajo de la edad mínima requerida por las leyes de su jurisdicción.
       user:
         chosen_languages: Cuando se marca, solo se mostrarán las publicaciones en los idiomas seleccionados en las líneas de tiempo públicas
+        date_of_birth: Tenemos que asegurarnos de que al menos tienes %{age} años para usar Mastodon. No almacenaremos esta información.
         role: El rol controla qué permisos tiene el usuario.
       user_role:
         color: Color que se usará para el rol en toda la interfaz de usuario, como RGB en formato hexadecimal
@@ -258,6 +261,7 @@ es-MX:
         name: Etiqueta
       filters:
         actions:
+          blur: Ocultar contenido multimedia con una advertencia
           hide: Ocultar completamente
           warn: Ocultar con una advertencia
       form_admin_settings:
@@ -271,6 +275,7 @@ es-MX:
         favicon: Favicon
         mascot: Mascota personalizada (legado)
         media_cache_retention_period: Período de retención de caché multimedia
+        min_age: Edad mínima requerida
         peers_api_enabled: Publicar lista de servidores descubiertos en la API
         profile_directory: Habilitar directorio de perfiles
         registrations_mode: Quién puede registrarse
@@ -349,6 +354,9 @@ es-MX:
         jurisdiction: Jurisdicción legal
         min_age: Edad mínima
       user:
+        date_of_birth_1i: Día
+        date_of_birth_2i: Mes
+        date_of_birth_3i: Año
         role: Rol
         time_zone: Zona horaria
       user_role:
diff --git a/config/locales/simple_form.es.yml b/config/locales/simple_form.es.yml
index a0f115f577..d469c6ec3a 100644
--- a/config/locales/simple_form.es.yml
+++ b/config/locales/simple_form.es.yml
@@ -75,6 +75,7 @@ es:
       filters:
         action: Elegir qué acción realizar cuando una publicación coincide con el filtro
         actions:
+          blur: Ocultar contenido multimedia detrás de una advertencia, sin ocultar el texto en sí
           hide: Ocultar completamente el contenido filtrado, comportándose como si no existiera
           warn: Ocultar el contenido filtrado detrás de una advertencia mencionando el título del filtro
       form_admin_settings:
@@ -88,6 +89,7 @@ es:
         favicon: WEBP, PNG, GIF o JPG. Reemplaza el favicon predeterminado de Mastodon con un icono personalizado.
         mascot: Reemplaza la ilustración en la interfaz web avanzada.
         media_cache_retention_period: Los archivos multimedia de las publicaciones creadas por usuarios remotos se almacenan en caché en tu servidor. Cuando se establece un valor positivo, estos archivos se eliminarán después del número especificado de días. Si los datos multimedia se solicitan después de eliminarse, se volverán a descargar, si el contenido fuente todavía está disponible. Debido a restricciones en la frecuencia con la que las tarjetas de previsualización de enlaces realizan peticiones a sitios de terceros, se recomienda establecer este valor a al menos 14 días, o las tarjetas de previsualización de enlaces no se actualizarán bajo demanda antes de ese momento.
+        min_age: Se pedirá a los usuarios que confirmen su fecha de nacimiento durante el registro
         peers_api_enabled: Una lista de nombres de dominio que este servidor ha encontrado en el Fediverso. Aquí no se incluye ningún dato sobre si federas con un servidor determinado, solo que tu servidor lo conoce. Esto es utilizado por los servicios que recopilan estadísticas sobre la federación en un sentido general.
         profile_directory: El directorio de perfiles lista a todos los usuarios que han optado por que su cuenta pueda ser descubierta.
         require_invite_text: Cuando los registros requieren aprobación manual, hace obligatoria la entrada de texto "¿Por qué quieres unirte?" en lugar de opcional
@@ -146,6 +148,7 @@ es:
         min_age: No debería estar por debajo de la edad mínima requerida por las leyes de su jurisdicción.
       user:
         chosen_languages: Cuando se marca, solo se mostrarán las publicaciones en los idiomas seleccionados en las líneas de tiempo públicas
+        date_of_birth: Tenemos que asegurarnos de que al menos tienes %{age} años para usar Mastodon. No almacenaremos esta información.
         role: El rol controla qué permisos tiene el usuario.
       user_role:
         color: Color que se utilizará para el rol a lo largo de la interfaz de usuario, como RGB en formato hexadecimal
@@ -258,6 +261,7 @@ es:
         name: Etiqueta
       filters:
         actions:
+          blur: Ocultar contenido multimedia con una advertencia
           hide: Ocultar completamente
           warn: Ocultar con una advertencia
       form_admin_settings:
@@ -271,6 +275,7 @@ es:
         favicon: Favicon
         mascot: Mascota personalizada (legado)
         media_cache_retention_period: Período de retención de caché multimedia
+        min_age: Edad mínima requerida
         peers_api_enabled: Publicar lista de servidores descubiertos en la API
         profile_directory: Habilitar directorio de perfiles
         registrations_mode: Quién puede registrarse
@@ -349,6 +354,9 @@ es:
         jurisdiction: Jurisdicción legal
         min_age: Edad mínima
       user:
+        date_of_birth_1i: Día
+        date_of_birth_2i: Mes
+        date_of_birth_3i: Año
         role: Rol
         time_zone: Zona horaria
       user_role:
diff --git a/config/locales/simple_form.fi.yml b/config/locales/simple_form.fi.yml
index 374b61f247..5489780ec7 100644
--- a/config/locales/simple_form.fi.yml
+++ b/config/locales/simple_form.fi.yml
@@ -75,6 +75,7 @@ fi:
       filters:
         action: Valitse, mikä toiminto suoritetaan, kun julkaisu vastaa suodatinta
         actions:
+          blur: Piilota media varoituksen taakse piilottamatta itse tekstiä
           hide: Piilota suodatettu sisältö kokonaan, ikään kuin sitä ei olisi olemassa
           warn: Piilota suodatettu sisältö varoituksen taakse, jossa mainitaan suodattimen nimi
       form_admin_settings:
@@ -88,6 +89,7 @@ fi:
         favicon: WEBP, PNG, GIF tai JPG. Korvaa oletusarvoisen Mastodonin sivustokuvakkeen haluamallasi kuvakkeella.
         mascot: Korvaa kuvituksen edistyneessä selainkäyttöliittymässä.
         media_cache_retention_period: Etäkäyttäjien tekemien julkaisujen mediatiedostot ovat välimuistissa palvelimellasi. Kun kentän arvo on positiivinen, media poistuu, kun määritetty määrä päiviä on kulunut. Jos mediaa pyydetään sen poistamisen jälkeen, se ladataan uudelleen, jos lähdesisältö on vielä saatavilla. Koska linkkien esikatselun kyselyitä kolmansien osapuolien sivustoille on rajoitettu, on suositeltavaa asettaa tämä arvo vähintään 14 päivään, tai linkkien kortteja ei päivitetä pyynnöstä ennen tätä ajankohtaa.
+        min_age: Käyttäjiä pyydetään rekisteröitymisen aikana vahvistamaan syntymäpäivänsä
         peers_api_enabled: Luettelo verkkotunnuksista, jotka tämä palvelin on kohdannut fediversumissa. Se ei kerro, federoitko tietyn palvelimen kanssa, vaan että palvelimesi on ylipäätään tietoinen siitä. Tätä tietoa käytetään palveluissa, jotka keräävät tilastoja federoinnista yleisellä tasolla.
         profile_directory: Profiilihakemisto luetteloi kaikki käyttäjät, jotka ovat valinneet olla löydettävissä.
         require_invite_text: Kun rekisteröityminen vaatii manuaalisen hyväksynnän, tee ”Miksi haluat liittyä?” -tekstikentästä pakollinen vapaaehtoisen sijaan
@@ -132,16 +134,20 @@ fi:
         name: Voit esimerkiksi vaihtaa suur- ja pienaakkosten kesken helppolukuistaaksesi tekstiäsi
       terms_of_service:
         changelog: Voidaan jäsentää Markdown-syntaksilla.
+        effective_date: Sopiva aika on 10–30 päivää siitä, kun olet ilmoittanut ehdoista käyttäjillesi.
         text: Voidaan jäsentää Markdown-syntaksilla.
       terms_of_service_generator:
         admin_email: Oikeudellisiin ilmoituksiin kuuluvat vastailmoitukset, oikeuden määräykset, poistopyynnöt ja lainvalvontaviranomaisten pyynnöt.
         arbitration_address: Voi olla sama kuin edellä mainittu Fyysinen osoite tai ”N/A”, jos käytät sähköpostia.
         arbitration_website: Voi olla verkkolomake tai ”N/A”, jos käytät sähköpostia.
         dmca_address: Yhdysvaltalaisten operaattoreiden on käytettävä DMCA Designated Agent Directory -luetteloon rekisteröityä osoitetta. Postilokeroluettelo on saatavissa suoralla pyynnöllä, joten käytä DMCA Designated Agent Post Office Box Waiver Request -lomaketta lähettääksesi sähköpostia tekijänoikeusvirastolle ja kuvaile, että olet kotona toimiva sisältömoderaattori, joka pelkää kostoa tai rangaistusta toimistaan ja tarvitsee postilokeroa pitääkseen kotiosoitteensa poissa julkisuudesta.
+        dmca_email: Voi olla sama kuin edellä mainittu ”Sähköpostiosoite oikeudellisille ilmoituksille”.
         domain: Tarjoamasi verkkopalvelun yksilöllinen tunniste.
         jurisdiction: Mainitse valtio, jossa laskujen maksaja asuu. Jos kyseessä on yritys tai muu yhteisö, mainitse valtio, johon se on rekisteröity, ja tarvittaessa kaupunki, alue, territorio tai osavaltio.
+        min_age: Ei pidä alittaa lainkäyttöalueesi lakien vaatimaa vähimmäisikää.
       user:
         chosen_languages: Jos valitset kieliä oheisesta luettelosta, vain niidenkieliset julkaisut näkyvät sinulle julkisilla aikajanoilla
+        date_of_birth: Meidän on varmistettava, että olet vähintään %{age} vuotta vanha, jotta voit käyttää Mastodonia. Emme tallenna tätä.
         role: Rooli määrää, millaiset käyttöoikeudet käyttäjällä on.
       user_role:
         color: Väri, jota käytetään roolille kaikkialla käyttöliittymässä, RGB-heksadesimaalimuodossa
@@ -254,6 +260,7 @@ fi:
         name: Aihetunniste
       filters:
         actions:
+          blur: Piilota media varoittaen
           hide: Piilota kokonaan
           warn: Piilota varoittaen
       form_admin_settings:
@@ -267,6 +274,7 @@ fi:
         favicon: Sivustokuvake
         mascot: Mukautettu maskotti (vanhentunut)
         media_cache_retention_period: Mediasisällön välimuistin säilytysaika
+        min_age: Vähimmäisikävaatimus
         peers_api_enabled: Julkaise löydettyjen palvelinten luettelo ohjelmointirajapinnassa
         profile_directory: Ota profiilihakemisto käyttöön
         registrations_mode: Kuka voi rekisteröityä
@@ -344,6 +352,9 @@ fi:
         jurisdiction: Lainkäyttöalue
         min_age: Vähimmäisikä
       user:
+        date_of_birth_1i: Päivä
+        date_of_birth_2i: Kuukausi
+        date_of_birth_3i: Vuosi
         role: Rooli
         time_zone: Aikavyöhyke
       user_role:
diff --git a/config/locales/simple_form.fo.yml b/config/locales/simple_form.fo.yml
index 201de307b0..32d066ed18 100644
--- a/config/locales/simple_form.fo.yml
+++ b/config/locales/simple_form.fo.yml
@@ -75,6 +75,7 @@ fo:
       filters:
         action: Vel, hvat skal henda, tá eitt uppslag svarar til filtrið
         actions:
+          blur: Fjal miðlar aftanfyri eina ávaring, uttan at fjala sjálvan tekstin
           hide: Fjal filtreraða innihaldið fullkomiliga, ber seg at sum at tað ikki fanst
           warn: Fjal filtreraða innihaldið aftan fyri eina ávaring, sum nevnir heitið á filtrinum
       form_admin_settings:
@@ -88,6 +89,7 @@ fo:
         favicon: WEBP, PNG, GIF ella JPG. Býtir vanligu Mastodon fav-ikonina um við eina ser-ikon.
         mascot: Skúgvar til viks myndprýðingina í framkomna vev-markamótinum.
         media_cache_retention_period: Miðlafílur frá postum, sum fjarbrúkarar hava gjørt, verða goymdir á tínum ambætara. Tá hetta er sett til eitt virði størri enn 0, so verða miðlafílurnar strikaðar eftir ásetta talið av døgum. Um miðladátur verða umbidnar eftir at tær eru strikaðar, verða tær tiknar innaftur á ambætaran, um keldutilfarið enn er tøkt. Vegna avmarkingar á hvussu ofta undanvísingarkort til leinki spyrja triðjapartsstøð, so verður mælt til at seta hetta virðið til í minsta lagi 14 dagar. Annars verða umbønir um dagføringar av undanvísingarkortum til leinki ikki gjørdar áðrenn hetta.
+        min_age: Brúkarar verða spurdir um at vátta teirra føðingardag, tá tey skráseta seg
         peers_api_enabled: Ein listi við navnaøkjum, sum hesin ambætarin er komin framat í fediversinum. Ongar dátur eru tiknar við her um tú er sameind/ur við ein givnan ambætara, einans at tín ambætari veit um hann. Hetta verður brúkt av tænastum, sum gera hagtøl um sameining yvirhøvur.
         profile_directory: Vangaskráin listar allar brúkarar, sum hava valt at kunna uppdagast.
         require_invite_text: Tá tilmeldingar krevja serskilda góðkenning, set so "Hví vil tú vera við?" tekstateigin til at vera kravdan heldur enn valfrían
@@ -146,6 +148,7 @@ fo:
         min_age: Eigur ikki at vera undir lægsta aldri, sum lógirnar í tínum rættarøki krevja.
       user:
         chosen_languages: Tá hetta er valt, verða einans postar í valdum málum vístir á almennum tíðarlinjum
+        date_of_birth: Vit mugu tryggja okkum, at tú er í minsta lagi %{age} ár fyri at brúka Mastodon. Vit goyma ikki hesar upplýsingar.
         role: Leikluturin stýrir hvørji rættindi, brúkarin hevur.
       user_role:
         color: Litur, sum leikluturin hevur í øllum brúkaramarkamótinum, sum RGB og upplýst sum sekstandatal
@@ -258,6 +261,7 @@ fo:
         name: Tvíkrossur
       filters:
         actions:
+          blur: Fjal miðlar við eini ávaring
           hide: Fjal fullkomiliga
           warn: Fjal við eini ávaring
       form_admin_settings:
@@ -271,6 +275,7 @@ fo:
         favicon: Favikon
         mascot: Serskildur maskottur (arvur)
         media_cache_retention_period: Tíðarskeið, har miðlagoymslur verða varðveittar
+        min_age: Aldursmark
         peers_api_enabled: Kunnger lista við uppdagaðum ambætarum í API'num
         profile_directory: Ger vangaskrá virkna
         registrations_mode: Hvør kann tilmelda seg
@@ -349,6 +354,9 @@ fo:
         jurisdiction: Løgdømi
         min_age: Lægsti aldur
       user:
+        date_of_birth_1i: Dagur
+        date_of_birth_2i: Mánaði
+        date_of_birth_3i: Ár
         role: Leiklutur
         time_zone: Tíðarsona
       user_role:
diff --git a/config/locales/simple_form.fr-CA.yml b/config/locales/simple_form.fr-CA.yml
index 5e9fe7baa5..905ff30675 100644
--- a/config/locales/simple_form.fr-CA.yml
+++ b/config/locales/simple_form.fr-CA.yml
@@ -88,6 +88,7 @@ fr-CA:
         favicon: WEBP, PNG, GIF ou JPG. Remplace la favicon Mastodon par défaut avec une icône personnalisée.
         mascot: Remplace l'illustration dans l'interface Web avancée.
         media_cache_retention_period: Les fichiers médias des messages publiés par des utilisateurs distants sont mis en cache sur votre serveur. Lorsque cette valeur est positive, les médias sont supprimés au terme du nombre de jours spécifié. Si les données des médias sont demandées après leur suppression, elles seront téléchargées à nouveau, dans la mesure où le contenu source est toujours disponible. En raison des restrictions concernant la fréquence à laquelle les cartes de prévisualisation des liens interrogent des sites tiers, il est recommandé de fixer cette valeur à au moins 14 jours, faute de quoi les cartes de prévisualisation des liens ne seront pas mises à jour à la demande avant cette échéance.
+        min_age: Les utilisateurs seront invités à confirmer leur date de naissance lors de l'inscription
         peers_api_enabled: Une liste de noms de domaine que ce serveur a rencontrés dans le fédiverse. Aucune donnée indiquant si vous vous fédérez ou non avec un serveur particulier n'est incluse ici, seulement l'information que votre serveur connaît un autre serveur. Cette option est utilisée par les services qui collectent des statistiques sur la fédération en général.
         profile_directory: L'annuaire des profils répertorie tous les utilisateurs qui ont opté pour être découverts.
         require_invite_text: Lorsque les inscriptions nécessitent une approbation manuelle, rendre le texte de l’invitation "Pourquoi voulez-vous vous inscrire ?" obligatoire plutôt que facultatif
@@ -132,14 +133,21 @@ fr-CA:
         name: Vous ne pouvez modifier que la casse des lettres, par exemple, pour le rendre plus lisible
       terms_of_service:
         changelog: Peut être structuré avec la syntaxe Markdown.
+        effective_date: Un délai raisonnable peut varier entre 10 et 30 jours à compter de la date à laquelle vous informez vos utilisateurs.
         text: Peut être structuré avec la syntaxe Markdown.
       terms_of_service_generator:
         admin_email: Les avis juridiques comprennent les contre-avis, les ordonnances judiciaires, les demandes de retrait et les demandes des forces de l'ordre.
+        arbitration_address: Il peut s'agir de la même que l'adresse physique ci-dessus, ou « N/A » si vous utilisez une adresse e-mail.
+        arbitration_website: Il peut s'agir d'un formulaire web ou de « N/A » s'il s'agit d'un courrier électronique.
+        choice_of_law: Ville, région, territoire ou État dont le droit matériel interne régit toute réclamation.
         dmca_address: Pour les opérateurs américains, utilisez l'adresse enregistrée dans le répertoire des agents désignés du DMCA Designated Agent Directory. Une boîte postale est disponible sur demande directe. Utilisez le formulaire de demande de dérogation pour l'utilisation d'une boîte postale par un agent désigné du Designated Agent Post Office Box Waiver Request pour envoyer un e-mail au Bureau du droit d'auteur (Copyright Office) et expliquer que vous êtes un modérateur de contenu à domicile qui craint des représailles ou une vengeance pour ses actions et que vous avez besoin d'utiliser une boîte postale afin de masquer votre adresse personnelle au public.
+        dmca_email: Il peut s'agir du même courriel que celui utilisé pour l'« Adresse électronique pour les avis juridiques » ci-dessus.
         domain: Identification unique du service en ligne que vous offrez.
         jurisdiction: Indiquez le pays dans lequel réside la personne qui paie les factures. S'il s'agit d'une entreprise ou d'une autre entité, indiquez le pays dans lequel elle est enregistrée, ainsi que la ville, la région, le territoire ou l'État, le cas échéant.
+        min_age: Ne doit pas être en dessous de l’âge minimum requis par les lois de votre juridiction.
       user:
         chosen_languages: Lorsque coché, seuls les messages dans les langues sélectionnées seront affichés sur les fils publics
+        date_of_birth: Nous devons nous assurer que vous avez au moins %{age} pour utiliser Mastodon. Nous ne conserverons pas ces informations.
         role: Le rôle définit quelles autorisations a l'utilisateur⋅rice.
       user_role:
         color: Couleur à attribuer au rôle dans l'interface, au format hexadécimal RVB
@@ -265,6 +273,7 @@ fr-CA:
         favicon: Favicon
         mascot: Mascotte personnalisée (héritée)
         media_cache_retention_period: Durée de rétention des médias dans le cache
+        min_age: Âge minimum requis
         peers_api_enabled: Publie la liste des serveurs découverts dans l'API
         profile_directory: Activer l’annuaire des profils
         registrations_mode: Qui peut s’inscrire
@@ -330,16 +339,22 @@ fr-CA:
         usable: Autoriser les messages à utiliser ce hashtag localement
       terms_of_service:
         changelog: Nouveautés?
+        effective_date: Date effective
         text: Conditions d'utilisation
       terms_of_service_generator:
         admin_email: Adresse électronique pour les notifications légales
         arbitration_address: Adresse physique pour les notifications d'arbitrage
         arbitration_website: Site Web pour soumettre les notifications d'arbitrage
+        choice_of_law: Choix de la loi
         dmca_address: Adresse physique pour les avis DMCA/copyright
         dmca_email: Adresse e-mail pour les avis DMCA/copyright
         domain: Domaine
         jurisdiction: Juridiction
+        min_age: Âge minimum
       user:
+        date_of_birth_1i: Jour
+        date_of_birth_2i: Mois
+        date_of_birth_3i: Année
         role: Rôle
         time_zone: Fuseau horaire
       user_role:
diff --git a/config/locales/simple_form.fr.yml b/config/locales/simple_form.fr.yml
index 921fa8eda4..3802a7f32f 100644
--- a/config/locales/simple_form.fr.yml
+++ b/config/locales/simple_form.fr.yml
@@ -88,6 +88,7 @@ fr:
         favicon: WEBP, PNG, GIF ou JPG. Remplace la favicon Mastodon par défaut avec une icône personnalisée.
         mascot: Remplace l'illustration dans l'interface Web avancée.
         media_cache_retention_period: Les fichiers médias des messages publiés par des utilisateurs distants sont mis en cache sur votre serveur. Lorsque cette valeur est positive, les médias sont supprimés au terme du nombre de jours spécifié. Si les données des médias sont demandées après leur suppression, elles seront téléchargées à nouveau, dans la mesure où le contenu source est toujours disponible. En raison des restrictions concernant la fréquence à laquelle les cartes de prévisualisation des liens interrogent des sites tiers, il est recommandé de fixer cette valeur à au moins 14 jours, faute de quoi les cartes de prévisualisation des liens ne seront pas mises à jour à la demande avant cette échéance.
+        min_age: Les utilisateurs seront invités à confirmer leur date de naissance lors de l'inscription
         peers_api_enabled: Une liste de noms de domaine que ce serveur a rencontrés dans le fédiverse. Aucune donnée indiquant si vous vous fédérez ou non avec un serveur particulier n'est incluse ici, seulement l'information que votre serveur connaît un autre serveur. Cette option est utilisée par les services qui collectent des statistiques sur la fédération en général.
         profile_directory: L'annuaire des profils répertorie tous les comptes qui choisi d'être découvrables.
         require_invite_text: Lorsque les inscriptions nécessitent une approbation manuelle, rendre le texte de l’invitation "Pourquoi voulez-vous vous inscrire ?" obligatoire plutôt que facultatif
@@ -132,14 +133,21 @@ fr:
         name: Vous ne pouvez modifier que la casse des lettres, par exemple, pour le rendre plus lisible
       terms_of_service:
         changelog: Peut être structuré avec la syntaxe Markdown.
+        effective_date: Un délai raisonnable peut varier entre 10 et 30 jours à compter de la date à laquelle vous informez vos utilisateurs.
         text: Peut être structuré avec la syntaxe Markdown.
       terms_of_service_generator:
         admin_email: Les avis juridiques comprennent les contre-avis, les ordonnances judiciaires, les demandes de retrait et les demandes des forces de l'ordre.
+        arbitration_address: Il peut s'agir de la même que l'adresse physique ci-dessus, ou « N/A » si vous utilisez une adresse e-mail.
+        arbitration_website: Il peut s'agir d'un formulaire web ou de « N/A » s'il s'agit d'un courrier électronique.
+        choice_of_law: Ville, région, territoire ou État dont le droit matériel interne régit toute réclamation.
         dmca_address: Pour les opérateurs américains, utilisez l'adresse enregistrée dans le répertoire des agents désignés du DMCA Designated Agent Directory. Une boîte postale est disponible sur demande directe. Utilisez le formulaire de demande de dérogation pour l'utilisation d'une boîte postale par un agent désigné du Designated Agent Post Office Box Waiver Request pour envoyer un e-mail au Bureau du droit d'auteur (Copyright Office) et expliquer que vous êtes un modérateur de contenu à domicile qui craint des représailles ou une vengeance pour ses actions et que vous avez besoin d'utiliser une boîte postale afin de masquer votre adresse personnelle au public.
+        dmca_email: Il peut s'agir du même courriel que celui utilisé pour l'« Adresse électronique pour les avis juridiques » ci-dessus.
         domain: Identification unique du service en ligne que vous offrez.
         jurisdiction: Indiquez le pays dans lequel réside la personne qui paie les factures. S'il s'agit d'une entreprise ou d'une autre entité, indiquez le pays dans lequel elle est enregistrée, ainsi que la ville, la région, le territoire ou l'État, le cas échéant.
+        min_age: Ne doit pas être en dessous de l’âge minimum requis par les lois de votre juridiction.
       user:
         chosen_languages: Lorsque coché, seuls les messages dans les langues sélectionnées seront affichés sur les fils publics
+        date_of_birth: Nous devons nous assurer que vous avez au moins %{age} pour utiliser Mastodon. Nous ne conserverons pas ces informations.
         role: Le rôle définit quelles autorisations a l'utilisateur⋅rice.
       user_role:
         color: Couleur à attribuer au rôle dans l'interface, au format hexadécimal RVB
@@ -265,6 +273,7 @@ fr:
         favicon: Favicon
         mascot: Mascotte personnalisée (héritée)
         media_cache_retention_period: Durée de rétention des médias dans le cache
+        min_age: Âge minimum requis
         peers_api_enabled: Publie la liste des serveurs découverts dans l'API
         profile_directory: Activer l’annuaire des profils
         registrations_mode: Qui peut s’inscrire
@@ -330,16 +339,22 @@ fr:
         usable: Autoriser les messages à utiliser ce hashtag localement
       terms_of_service:
         changelog: Nouveautés?
+        effective_date: Date effective
         text: Conditions d'utilisation
       terms_of_service_generator:
         admin_email: Adresse électronique pour les notifications légales
         arbitration_address: Adresse physique pour les notifications d'arbitrage
         arbitration_website: Site Web pour soumettre les notifications d'arbitrage
+        choice_of_law: Choix de la loi
         dmca_address: Adresse physique pour les avis DMCA/copyright
         dmca_email: Adresse e-mail pour les avis DMCA/copyright
         domain: Domaine
         jurisdiction: Juridiction
+        min_age: Âge minimum
       user:
+        date_of_birth_1i: Jour
+        date_of_birth_2i: Mois
+        date_of_birth_3i: Année
         role: Rôle
         time_zone: Fuseau horaire
       user_role:
diff --git a/config/locales/simple_form.gl.yml b/config/locales/simple_form.gl.yml
index a23173a6d1..ccf3ebcd4f 100644
--- a/config/locales/simple_form.gl.yml
+++ b/config/locales/simple_form.gl.yml
@@ -88,6 +88,7 @@ gl:
         favicon: WEBP, PNG, GIF ou JPG. Sobrescribe a icona de favoritos de Mastodon por defecto cunha icona personalizada.
         mascot: Sobrescribe a ilustración na interface web avanzada.
         media_cache_retention_period: Os ficheiros multimedia de publicacións de usuarias remotas están almacenados no teu servidor. Ao establecer un valor positivo, o multimedia vaise eliminar despois do número de días establecido. Se o multimedia fose requerido após ser eliminado entón descargaríase outra vez, se aínda está dispoñible na orixe. Debido a restricións sobre a frecuencia en que o servizo de vista previa trae recursos de terceiras partes, é recomendable establecer este valor polo menos en 14 días, ou as tarxetas de vista previa non se actualizarán baixo demanda para casos anteriores a ese prazo.
+        min_age: Váiselle pedir ás usuarias que confirmen a súa data de nacemento cando creen a conta
         peers_api_enabled: Unha lista dos nomes de dominio que este servidor atopou no fediverso. Non se inclúen aquí datos acerca de se estás a federar con eles ou non, só que o teu servidor os recoñeceu. Ten utilidade para servizos que recollen estatísticas acerca da federación nun amplo senso.
         profile_directory: O directorio de perfís inclúe a tódalas usuarias que optaron por ser descubribles.
         require_invite_text: Cando os rexistros requiren aprobación manual, facer que o texto "Por que te queres rexistrar?" do convite sexa obrigatorio en lugar de optativo
@@ -146,6 +147,7 @@ gl:
         min_age: Non debería ser inferior á idade mínima requerida polas leis da túa xurisdición.
       user:
         chosen_languages: Se ten marca, só as publicacións nos idiomas seleccionados serán mostrados en cronoloxías públicas
+        date_of_birth: Temos que ter certeza de que tes %{age} como mínimo para usar Mastodon. Non gardamos este dato.
         role: Os roles establecen os permisos que ten a usuaria.
       user_role:
         color: Cor que se usará para o rol a través da IU, como RGB en formato hex
@@ -271,6 +273,7 @@ gl:
         favicon: Favicon
         mascot: Mascota propia (herdado)
         media_cache_retention_period: Período de retención da caché multimedia
+        min_age: Idade mínima requerida
         peers_api_enabled: Publicar na API unha lista dos servidores descubertos
         profile_directory: Activar o directorio de perfís
         registrations_mode: Quen se pode rexistrar
@@ -349,6 +352,9 @@ gl:
         jurisdiction: Xurisdición legal
         min_age: Idade mínima
       user:
+        date_of_birth_1i: Día
+        date_of_birth_2i: Mes
+        date_of_birth_3i: Ano
         role: Rol
         time_zone: Fuso horario
       user_role:
diff --git a/config/locales/simple_form.he.yml b/config/locales/simple_form.he.yml
index bab104025f..9c01b91638 100644
--- a/config/locales/simple_form.he.yml
+++ b/config/locales/simple_form.he.yml
@@ -75,6 +75,7 @@ he:
       filters:
         action: בחרו איזו פעולה לבצע כאשר הודעה מתאימה למסנן
         actions:
+          blur: החבאת וידאו ותמונות מאחורי אזהרה, ללא החבאת המלל עצמו
           hide: הסתרת התוכן המסונן, כאילו לא היה קיים
           warn: הסתרת התוכן המסונן מאחורי אזהרה עם כותרת המסנן
       form_admin_settings:
@@ -88,6 +89,7 @@ he:
         favicon: WEBP, PNG, GIF או JPG. גובר על "פאבאייקון" ברירת המחדל ומחליף אותו באייקון נבחר בדפדפן.
         mascot: בחירת ציור למנשק הווב המתקדם.
         media_cache_retention_period: קבצי מדיה מהודעות שהגיעו משרתים רחוקים נשמרות על השרת שלך. כאשר יבחר פה מספר חיובי, המדיה תמחק לאחר מספר ימים כמצוין. אם המידע יבוקש שוב לאחר שנמחק, הוא יורד מחדש, אם המידע עדיין זמין בצד הרחוק. עקב מגבלות על תכיפות שליפת כרטיסי קדימון מאתרים מרוחקים, מומלץ לכוון את הערך ל־14 יום לפחות, או שכרטיסי קדימונים לא יעודכנו לפי דרישה לפני חלוף חלון הזמן הזה.
+        min_age: משתמשיםות יתבקשו לאשר את תאריך הלידה בתהליך ההרשמה
         peers_api_enabled: רשימת השרתים ששרת זה פגש בפדיוורס. לא כולל מידע לגבי קשר ישיר עם שרת נתון, אלא רק שידוע לשרת זה על קיומו. מידע זה משמש שירותים האוספים סטטיסטיקות כלליות על הפדרציה.
         profile_directory: ספריית הפרופילים מציגה ברשימה את כל המשתמשים שביקשו להיות ניתנים לגילוי.
         require_invite_text: כאשר הרשמות דורשות אישור ידני, הפיכת טקסט ה"מדוע את/ה רוצה להצטרף" להכרחי במקום אופציונלי
@@ -146,6 +148,7 @@ he:
         min_age: על הערך להיות לפחות בגיל המינימלי הדרוש בחוק באיזור השיפוט שלך.
       user:
         chosen_languages: אם פעיל, רק הודעות בשפות הנבחרות יוצגו לפידים הפומביים
+        date_of_birth: עלינו לוודא שגילך לפחות %{age} כדי להשתמש במסטודון. המידע לא ישמר בשרת שלנו.
         role: התפקיד שולט על אילו הרשאות יש למשתמש.
       user_role:
         color: צבע לתפקיד בממשק המשתמש, כ RGB בפורמט הקסדצימלי
@@ -258,6 +261,7 @@ he:
         name: תגית
       filters:
         actions:
+          blur: הסתרת מדיה עם אזהרה
           hide: הסתרה כוללת
           warn: הסתרה עם אזהרה
       form_admin_settings:
@@ -271,6 +275,7 @@ he:
         favicon: סמל מועדפים (Favicon)
         mascot: סמל השרת (ישן)
         media_cache_retention_period: תקופת שמירת מטמון מדיה
+        min_age: דרישת גיל מינימלי
         peers_api_enabled: פרסם רשימה של שרתים שנתגלו באמצעות ה-API
         profile_directory: הפעלת ספריית פרופילים
         registrations_mode: מי יכולים לפתוח חשבון
@@ -349,6 +354,9 @@ he:
         jurisdiction: איזור השיפוט
         min_age: גיל מינימלי
       user:
+        date_of_birth_1i: יום
+        date_of_birth_2i: חודש
+        date_of_birth_3i: שנה
         role: תפקיד
         time_zone: אזור זמן
       user_role:
diff --git a/config/locales/simple_form.hu.yml b/config/locales/simple_form.hu.yml
index 977ef0a279..adcc3d5789 100644
--- a/config/locales/simple_form.hu.yml
+++ b/config/locales/simple_form.hu.yml
@@ -75,6 +75,7 @@ hu:
       filters:
         action: A végrehajtandó műveletet, ha a bejegyzés megfelel a szűrőnek
         actions:
+          blur: Média elrejtése figyelmeztetéssel, a szöveg elrejtése nélkül
           hide: A szűrt tartalom teljes elrejtése, mintha nem is létezne
           warn: A szűrt tartalom a szűrő címét említő figyelmeztetés mögé rejtése
       form_admin_settings:
@@ -88,6 +89,7 @@ hu:
         favicon: WEBP, PNG, GIF vagy JPG. Az alapértelmezett Mastodon favicont felülírja egy egyéni ikonnal.
         mascot: Felülbírálja a speciális webes felületen található illusztrációt.
         media_cache_retention_period: A távoli felhasználók bejegyzéseinek médiatartalmait a kiszolgálód gyorsítótárazza. Ha pozitív értékre állítják, ezek a médiatartalmak a megadott számú nap után törölve lesznek. Ha a médiát újra lekérik, miután törlődött, újra le fogjuk tölteni, ha az eredeti még elérhető. A hivatkozások előnézeti kártyáinak harmadik fél weboldalai felé történő hivatkozásaira alkalmazott megkötései miatt javasolt, hogy ezt az értéket legalább 14 napra állítsuk, ellenkező esetben a hivatkozások előnézeti kártyái szükség esetén nem fognak tudni frissülni ezen idő előtt.
+        min_age: A felhasználók a regisztráció során arra lesznek kérve, hogy erősítsek meg a születési dátumukat
         peers_api_enabled: Azon domainek listája, melyekkel ez a kiszolgáló találkozott a fediverzumban. Nem csatolunk adatot arról, hogy föderált kapcsolatban vagy-e az adott kiszolgálóval, csak arról, hogy a kiszolgálód tud a másikról. Ezt olyan szolgáltatások használják, melyek általában a föderációról készítenek statisztikákat.
         profile_directory: A profilok jegyzéke minden olyan felhasználót felsorol, akik engedélyezték a felfedezhetőségüket.
         require_invite_text: Ha a regisztrációhoz manuális jóváhagyásra van szükség, akkor a „Miért akarsz csatlakozni?” válasz kitöltése legyen kötelező, és ne opcionális
@@ -146,6 +148,7 @@ hu:
         min_age: Nem lehet a joghatóság által meghatározott minimális kor alatt.
       user:
         chosen_languages: Ha aktív, csak a kiválasztott nyelvű bejegyzések jelennek majd meg a nyilvános idővonalon
+        date_of_birth: Legalább %{age} évesnek kell lenniük, hogy használhassák a Mastodont. Ezt nem tároljuk.
         role: A szerep szabályozza, hogy a felhasználó milyen jogosultságokkal rendelkezik.
       user_role:
         color: A szerephez használandó szín mindenhol a felhasználói felületen, hexa RGB formátumban
@@ -258,6 +261,7 @@ hu:
         name: Hashtag
       filters:
         actions:
+          blur: Média elrejtése figyelmeztetéssel
           hide: Teljes elrejtés
           warn: Elrejtés figyelmeztetéssel
       form_admin_settings:
@@ -271,6 +275,7 @@ hu:
         favicon: Könyvjelzőikon
         mascot: Egyéni kabala (örökölt)
         media_cache_retention_period: Média-gyorsítótár megtartási időszaka
+        min_age: Minimális korhatár
         peers_api_enabled: Felfedezett kiszolgálók listájának közzététele az API-ban
         profile_directory: Profiladatbázis engedélyezése
         registrations_mode: Ki regisztrálhat
@@ -349,6 +354,9 @@ hu:
         jurisdiction: Joghatóság
         min_age: Minimális életkor
       user:
+        date_of_birth_1i: Nap
+        date_of_birth_2i: Hónap
+        date_of_birth_3i: Év
         role: Szerep
         time_zone: Időzóna
       user_role:
diff --git a/config/locales/simple_form.is.yml b/config/locales/simple_form.is.yml
index e190198f89..bc68d14f2a 100644
--- a/config/locales/simple_form.is.yml
+++ b/config/locales/simple_form.is.yml
@@ -75,6 +75,7 @@ is:
       filters:
         action: Veldu hvaða aðgerð á að framkvæma þegar færsla samsvarar síunni
         actions:
+          blur: Fela myndefni á bakvið aðvörun, án þess að fela sjálfann textann
           hide: Fela síað efni algerlega, rétt eins og það sé ekki til staðar
           warn: Fela síað efni á bakvið aðvörun sem tekur fram titil síunnar
       form_admin_settings:
@@ -88,6 +89,7 @@ is:
         favicon: WEBP, PNG, GIF eða JPG. Tekur yfir sjálfgefna Mastodon favicon-táknmynd með sérsniðinni táknmynd.
         mascot: Þetta tekyr yfir myndskreytinguna í ítarlega vefviðmótinu.
         media_cache_retention_period: Myndefnisskrár úr færslum sem gerðar eru af fjartengdum notendum eru geymdar á netþjóninum þínum. Þegar þetta er stillt á jákvætt gildi, verður þessum skrám eytt sjáfkrafa eftir þeim tiltekna fjölda daga. Ef beðið er um myndefnið eftir að því er eytt, mun það verða sótt aftur ef frumgögnin eru ennþá aðgengileg. Vegna takmarkana á hversu oft forskoðunarspjöld tengla eru sótt á utanaðkomandi netþjóna, þá er mælt með því að setja þetta gildi á að minnsta kosti 14 daga, annars gæti mistekist að uppfæra forskoðunarspjöld tengla eftir þörfum fyrir þann tíma.
+        min_age: Notendur verða beðnir um að staðfesta fæðingardag sinn við nýskráningu
         peers_api_enabled: Listi yfir þau lénaheiti sem þessi netþjónn hefur rekist á í skýjasambandinu. Engin gögn eru hér sem gefa til kynna hvort þú sért í sambandi við tiltekinn netþjón, bara að netþjónninn þinn viti um hann. Þetta er notað af þjónustum sem safna tölfræði um skýjasambönd á almennan hátt.
         profile_directory: Notendamappan telur upp alla þá notendur sem hafa valið að vera uppgötvanlegir.
         require_invite_text: Þegar nýskráningar krefjast handvirks samþykkis, þá skal gera textann í “Hvers vegna viltu taka þátt?” að kröfu en ekki valkvæðan
@@ -146,6 +148,7 @@ is:
         min_age: Ætti ekki að vera lægri en sá lágmarksaldur sek kveðið er á um í lögum þíns lögsagnarumdæmis.
       user:
         chosen_languages: Þegar merkt er við þetta, birtast einungis færslur á völdum tungumálum á opinberum tímalínum
+        date_of_birth: Við verðum að ganga úr skugga um að þú hafir náð %{age} aldri til að nota Mastodon. Við munum ekki geyma þessar upplýsingar.
         role: Hlutverk stýrir hvaða heimildir notandinn hefur.
       user_role:
         color: Litur sem notaður er fyrir hlutverkið allsstaðar í viðmótinu, sem RGB-gildi á hex-sniði
@@ -258,6 +261,7 @@ is:
         name: Myllumerki
       filters:
         actions:
+          blur: Fela myndefni með aðvörun
           hide: Fela alveg
           warn: Fela með aðvörun
       form_admin_settings:
@@ -271,6 +275,7 @@ is:
         favicon: Auðkennismynd
         mascot: Sérsniðið gæludýr (eldra)
         media_cache_retention_period: Tímalengd sem myndefni haldið
+        min_age: Kröfur um lágmarksaldur
         peers_api_enabled: Birta lista yfir uppgötvaða netþjóna í API-kerfisviðmótinu
         profile_directory: Virkja notendamöppu
         registrations_mode: Hverjir geta nýskráð sig
@@ -349,6 +354,9 @@ is:
         jurisdiction: Lögsagnarumdæmi
         min_age: Lágmarksaldur
       user:
+        date_of_birth_1i: Dagur
+        date_of_birth_2i: Mánuður
+        date_of_birth_3i: Ár
         role: Hlutverk
         time_zone: Tímabelti
       user_role:
diff --git a/config/locales/simple_form.it.yml b/config/locales/simple_form.it.yml
index 57f497c98f..da203270fa 100644
--- a/config/locales/simple_form.it.yml
+++ b/config/locales/simple_form.it.yml
@@ -75,6 +75,7 @@ it:
       filters:
         action: Scegli quale azione eseguire quando un post corrisponde al filtro
         actions:
+          blur: Nascondi i contenuti multimediali dietro un avviso, senza nascondere il testo stesso
           hide: Nascondi completamente il contenuto filtrato, come se non esistesse
           warn: Nascondi il contenuto filtrato e mostra invece un avviso, citando il titolo del filtro
       form_admin_settings:
@@ -88,6 +89,7 @@ it:
         favicon: WEBP, PNG, GIF o JPG. Sostituisce la favicon predefinita di Mastodon con un'icona personalizzata.
         mascot: Sostituisce l'illustrazione nell'interfaccia web avanzata.
         media_cache_retention_period: I file multimediali da post fatti da utenti remoti sono memorizzati nella cache sul tuo server. Quando impostato a un valore positivo, i media verranno eliminati dopo il numero specificato di giorni. Se i dati multimediali sono richiesti dopo che sono stati eliminati, saranno nuovamente scaricati, se il contenuto sorgente è ancora disponibile. A causa di restrizioni su quanto spesso link anteprima carte sondaggio siti di terze parti, si consiglia di impostare questo valore ad almeno 14 giorni, o le schede di anteprima link non saranno aggiornate su richiesta prima di quel tempo.
+        min_age: Gli utenti saranno invitati a confermare la loro data di nascita durante la registrazione
         peers_api_enabled: Un elenco di nomi di dominio che questo server ha incontrato nel fediverse. Qui non sono inclusi dati sul fatto se si federano con un dato server, solo che il server ne è a conoscenza. Questo viene utilizzato dai servizi che raccolgono statistiche sulla federazione in senso generale.
         profile_directory: La directory del profilo elenca tutti gli utenti che hanno acconsentito ad essere individuabili.
         require_invite_text: 'Quando le iscrizioni richiedono l''approvazione manuale, rendi la domanda: "Perché vuoi unirti?" obbligatoria anziché facoltativa'
@@ -146,6 +148,7 @@ it:
         min_age: Non si dovrebbe avere un'età inferiore a quella minima richiesta, dalle leggi della tua giurisdizione.
       user:
         chosen_languages: Quando una o più lingue sono contrassegnate, nelle timeline pubbliche vengono mostrati solo i toot nelle lingue selezionate
+        date_of_birth: Dobbiamo verificare che tu abbia almeno %{age} anni per usare Mastodon. Non archivieremo questa informazione.
         role: Il ruolo controlla quali permessi ha l'utente.
       user_role:
         color: Colore da usare per il ruolo in tutta l'UI, come RGB in formato esadecimale
@@ -258,6 +261,7 @@ it:
         name: Etichetta
       filters:
         actions:
+          blur: Nascondi i contenuti multimediali con un avviso
           hide: Nascondi completamente
           warn: Nascondi con avviso
       form_admin_settings:
@@ -271,6 +275,7 @@ it:
         favicon: Favicon
         mascot: Personalizza mascotte (legacy)
         media_cache_retention_period: Periodo di conservazione della cache multimediale
+        min_age: Età minima richiesta
         peers_api_enabled: Pubblica l'elenco dei server scoperti nell'API
         profile_directory: Abilita directory del profilo
         registrations_mode: Chi può iscriversi
@@ -349,6 +354,9 @@ it:
         jurisdiction: Giurisdizione legale
         min_age: Età minima
       user:
+        date_of_birth_1i: Giorno
+        date_of_birth_2i: Mese
+        date_of_birth_3i: Anno
         role: Ruolo
         time_zone: Fuso orario
       user_role:
diff --git a/config/locales/simple_form.ko.yml b/config/locales/simple_form.ko.yml
index ef3764bed8..a823604584 100644
--- a/config/locales/simple_form.ko.yml
+++ b/config/locales/simple_form.ko.yml
@@ -75,6 +75,7 @@ ko:
       filters:
         action: 게시물이 필터에 걸러질 때 어떤 동작을 수행할 지 고르세요
         actions:
+          blur: 텍스트는 숨기지 않고 그대로 둔 채 경고 뒤에 미디어를 숨김니다
           hide: 필터에 걸러진 글을 처음부터 없었던 것처럼 완전히 가리기
           warn: 필터 제목을 언급하는 경고 뒤에 걸러진 내용을 숨기기
       form_admin_settings:
@@ -88,6 +89,7 @@ ko:
         favicon: WEBP, PNG, GIF 또는 JPG. 기본 파비콘을 대체합니다.
         mascot: 고급 웹 인터페이스의 그림을 대체합니다.
         media_cache_retention_period: 원격 사용자가 작성한 글의 미디어 파일은 이 서버에 캐시됩니다. 양수로 설정하면 지정된 일수 후에 미디어가 삭제됩니다. 삭제된 후에 미디어 데이터를 요청하면 원본 콘텐츠를 사용할 수 있는 경우 다시 다운로드됩니다. 링크 미리 보기 카드가 타사 사이트를 폴링하는 빈도에 제한이 있으므로 이 값을 최소 14일로 설정하는 것이 좋으며, 그렇지 않으면 그 이전에는 링크 미리 보기 카드가 제때 업데이트되지 않을 것입니다.
+        min_age: 사용자들은 가입할 때 생일을 확인받게 됩니다
         peers_api_enabled: 이 서버가 연합우주에서 만났던 서버들에 대한 도메인 네임의 목록입니다. 해당 서버와 어떤 연합을 했는지에 대한 정보는 전혀 포함되지 않고, 단순히 그 서버를 알고 있는지에 대한 것입니다. 이것은 일반적으로 연합에 대한 통계를 수집할 때 사용됩니다.
         profile_directory: 프로필 책자는 발견되기를 희망하는 모든 사람들의 목록을 나열합니다.
         require_invite_text: 가입이 수동 승인을 필요로 할 때, "왜 가입하려고 하나요?" 항목을 선택사항으로 두는 것보다는 필수로 두는 것이 낫습니다
@@ -153,7 +155,7 @@ ko:
         position: 특정 상황에서 충돌이 발생할 경우 더 높은 역할이 충돌을 해결합니다. 특정 작업은 우선순위가 낮은 역할에 대해서만 수행될 수 있습니다
       webhook:
         events: 전송할 이벤트를 선택하세요
-        template: 원하는 JSON 페이로드를 변수와 함께 작성하거나, 그냥 냅둬서 기본 JSON을 사용할 수 있습니다.
+        template: 원하는 JSON 페이로드를 변수와 함께 작성하거나, 그대로 두어 기본 JSON을 사용할 수 있습니다.
         url: 이벤트가 어디로 전송될 지
     labels:
       account:
@@ -256,6 +258,7 @@ ko:
         name: 해시태그
       filters:
         actions:
+          blur: 경고와 함께 미디어 숨기기
           hide: 완전히 숨기기
           warn: 경고와 함께 숨기기
       form_admin_settings:
@@ -269,6 +272,7 @@ ko:
         favicon: 파비콘
         mascot: 사용자 정의 마스코트 (legacy)
         media_cache_retention_period: 미디어 캐시 유지 기한
+        min_age: 최소 연령 제한
         peers_api_enabled: API에 발견 된 서버들의 목록 발행
         profile_directory: 프로필 책자 활성화
         registrations_mode: 누가 가입할 수 있는지
@@ -347,6 +351,9 @@ ko:
         jurisdiction: 법적 관할권
         min_age: 최소 연령
       user:
+        date_of_birth_1i: 일
+        date_of_birth_2i: 월
+        date_of_birth_3i: 년
         role: 역할
         time_zone: 시간대
       user_role:
diff --git a/config/locales/simple_form.lt.yml b/config/locales/simple_form.lt.yml
index 1db9e27359..d3febceea9 100644
--- a/config/locales/simple_form.lt.yml
+++ b/config/locales/simple_form.lt.yml
@@ -72,6 +72,7 @@ lt:
       filters:
         action: Pasirink, kokį veiksmą atlikti, kai įrašas atitinka filtrą
         actions:
+          blur: Slėpti mediją po įspėjimu, neslepiant paties teksto
           hide: Visiškai paslėpti filtruotą turinį ir elgtis taip, tarsi jo neegzistuotų
           warn: Slėpti filtruojamą turinį po įspėjimu, paminint filtro pavadinimą
       form_admin_settings:
@@ -82,6 +83,7 @@ lt:
         favicon: WEBP, PNG, GIF arba JPG. Pakeičia numatytąją Mastodon svetaines piktogramą pasirinktine piktograma.
         mascot: Pakeičia išplėstinės žiniatinklio sąsajos iliustraciją.
         media_cache_retention_period: Nuotolinių naudotojų įrašytų įrašų medijos failai talpinami tavo serveryje. Nustačius teigiamą reikšmę, medijos bus ištrinamos po nurodyto dienų skaičiaus. Jei medijos duomenų bus paprašyta po to, kai jie bus ištrinti, jie bus atsiųsti iš naujo, jei šaltinio turinys vis dar prieinamas. Dėl apribojimų, susijusių su nuorodų peržiūros kortelių apklausos dažnumu trečiųjų šalių svetainėse, rekomenduojama nustatyti šią reikšmę ne trumpesnę kaip 14 dienų, kitaip nuorodų peržiūros kortelės nebus atnaujinamos pagal pareikalavimą iki to laiko.
+        min_age: Registracijos metu naudotojai bus paprašyti patvirtinti savo gimimo datą.
         peers_api_enabled: Domenų pavadinimų sąrašas, su kuriais šis serveris susidūrė fediverse. Čia nėra duomenų apie tai, ar tu bendrauji su tam tikru serveriu, tik apie tai, kad tavo serveris apie jį žino. Tai naudojama tarnybose, kurios renka federacijos statistiką bendrąja prasme.
         require_invite_text: Kai registraciją reikia patvirtinti rankiniu būdu, teksto įvesties laukelį „Kodėl nori prisijungti?“ padaryk privalomą, o ne pasirenkamą
         site_contact_email: Kaip žmonės gali su tavimi susisiekti teisiniais ar pagalbos užklausimais.
@@ -108,12 +110,17 @@ lt:
         text: Gali būti struktūrizuota su ženklinimo sintakse.
       terms_of_service_generator:
         admin_email: Teisiniai pranešimai įtraukia priešpriešinius pranešimus, teismo įsakymus, pašalinimo prašymus ir teisėsaugos institucijų prašymus.
+        arbitration_address: Gali būti toks pat kaip aukščiau nurodytas fizinis adresas arba „N/A“ (netaikoma), jei naudojamas el. paštas.
+        arbitration_website: Gali būti interneto forma arba „N/A“ (netaikoma), jei naudojamas el. paštas.
         choice_of_law: Miestas, regionas, teritorija ar valstija, kurių vidaus materialinė teisė reglamentuoja visus reikalavimus.
         dmca_address: JAV operatoriams naudokite DMCA paskirtojo agento kataloge užregistruotą adresą. Pašto dėžutės sąrašą galima sudaryti pateikus tiesioginį prašymą, naudokite DMCA paskirtojo agento pašto dėžutės atsisakymo prašymą, kad parašytumėte el. laišką Autorinių teisių tarnybai ir aprašytumėte, kad esate namuose įsikūręs turinio moderatorius, kuris baiminasi keršto ar bausmės už savo veiksmus ir kuriam reikia naudoti pašto dėžutę, kad jo namų adresas nebūtų viešai matomas.
+        dmca_email: Gali būti tas pats aukščiau nurodytas el. pašto adresas, naudojamas „El. pašto adresas, skirtas teisiniams pranešimams“.
         domain: Unikalus jūsų teikiamos internetinės paslaugos identifikavimas.
         jurisdiction: Nurodykite šalį, kurioje gyvena tas, kas apmoka sąskaitas. Jei tai bendrovė ar kita esybė, nurodykite šalį, kurioje jis įregistruotas, ir atitinkamai miestą, regioną, teritoriją ar valstiją.
+        min_age: Neturėtų būti žemiau mažiausio amžiaus, reikalaujamo pagal jūsų jurisdikcijos įstatymus.
       user:
         chosen_languages: Kai pažymėta, viešose laiko skalėse bus rodomi tik įrašai pasirinktomis kalbomis.
+        date_of_birth: Turime įsitikinti, kad esate bent %{age}, kad naudotumėte „Mastodon“. Mes to neišsaugosime.
         role: Vaidmuo valdo, kokius leidimus naudotojas turi.
     labels:
       account:
@@ -177,6 +184,7 @@ lt:
         name: Saitažodis
       filters:
         actions:
+          blur: Slėpti mediją su įspėjimu
           hide: Slėpti visiškai
           warn: Slėpti su įspėjimu
       form_admin_settings:
@@ -187,6 +195,7 @@ lt:
         custom_css: Pasirinktinis CSS
         favicon: Svetainės piktograma
         mascot: Pasirinktinis talismanas (pasenęs)
+        min_age: Mažiausias amžiaus reikalavimas
         registrations_mode: Kas gali užsiregistruoti
         require_invite_text: Reikalauti priežasties prisijungti
         show_domain_blocks_rationale: Rodyti, kodėl domenai buvo užblokuoti
@@ -237,7 +246,11 @@ lt:
         dmca_email: El. pašto adresas, skirtas DMCA / autorinių teisių pranešimams
         domain: Domenas
         jurisdiction: Teisinis teismingumas
+        min_age: Mažiausias amžius
       user:
+        date_of_birth_1i: Diena
+        date_of_birth_2i: Mėnuo
+        date_of_birth_3i: Metai
         role: Vaidmuo
         time_zone: Laiko juosta
       user_role:
diff --git a/config/locales/simple_form.lv.yml b/config/locales/simple_form.lv.yml
index d9fe043952..19e517340d 100644
--- a/config/locales/simple_form.lv.yml
+++ b/config/locales/simple_form.lv.yml
@@ -75,6 +75,7 @@ lv:
       filters:
         action: Izvēlies, kuru darbību veikt, ja ziņa atbilst filtram
         actions:
+          blur: Paslēpt informācijas nesējus aiz brīdinājuma, nepaslēpjot tekstu
           hide: Paslēp filtrēto saturu pilnībā, izturoties tā, it kā tas neeksistētu
           warn: Paslēp filtrēto saturu aiz brīdinājuma, kurā minēts filtra nosaukums
       form_admin_settings:
@@ -88,6 +89,7 @@ lv:
         favicon: WEBP, PNG, GIF vai JPG. Aizstāj noklusējuma Mastodon favikonu ar pielāgotu.
         mascot: Ignorē ilustrāciju uzlabotajā tīmekļa saskarnē.
         media_cache_retention_period: Informācijas nesēju datnes no ierakstiem, kurus ir veikuši attālie lietotāji, tiek kešoti šajā serverī. Kad ir iestatīta apstiprinoša vērtība, informācijas nesēji tiks izdzēsti pēc norādītā dienu skaita. Ja informācijas nesēju dati tiks pieprasīti pēc tam, kad tie tika izdzēsti, tie tiks atkārtoti lejupielādēti, ja avota saturs joprojām būs pieejams. Saišu priekšskatījuma karšu vaicājumu biežuma ierobežojumu dēļ ir ieteicams iestatīt šo vērtību vismaz 14 dienas vai saišu priekšskatījuma kartes netiks atjauninātas pēc pieprasījuma pirms tā laika.
+        min_age: Lietotājiem tiks lūgts apstiprināt viņu dzimšanas datumu reģistrācijas laikā
         peers_api_enabled: Domēna vārdu saraksts, ar kuriem šis serveris ir saskāries fediversā. Šeit nav iekļauti dati par to, vai tu veic federāciju ar noteiktu serveri, tikai tavs serveris par to zina. To izmanto dienesti, kas apkopo statistiku par federāciju vispārīgā nozīmē.
         profile_directory: Profilu direktorijā ir uzskaitīti visi lietotāji, kuri ir izvēlējušies būt atklājami.
         require_invite_text: Ja nepieciešama pašrocīga apstiprināšana, lai pierakstītos, teksta “Kāpēc vēlies pievienoties?” ievade jāpadara par nepieciešamu, nevis izvēles
@@ -140,6 +142,7 @@ lv:
         domain: Sniegtā tiešsaistas pakalpojuma neatkārtojama identifikācija.
       user:
         chosen_languages: Ja ieķeksēts, publiskos laika grafikos tiks parādītas tikai ziņas noteiktajās valodās
+        date_of_birth: Mums jāpārliecinās, ka jums ir vismaz %{age} gadi, lai varētu izmantot Mastodonu. Mēs neuzglabāsim šo informāciju.
         role: Loma nosaka, kādas lietotājam ir atļaujas.
       user_role:
         color: Krāsa, kas jāizmanto lomai visā lietotāja saskarnē, kā RGB hex formātā
@@ -265,6 +268,7 @@ lv:
         favicon: Favikona
         mascot: Pielāgots talismans (mantots)
         media_cache_retention_period: Multivides kešatmiņas saglabāšanas periods
+        min_age: Nepieciešamais minimālais vecums
         peers_api_enabled: Publicēt API atklāto serveru sarakstu
         profile_directory: Iespējot profila direktoriju
         registrations_mode: Kurš drīkst pieteikties
@@ -338,6 +342,9 @@ lv:
         domain: Domēna vārds
         min_age: Mazākais pieļaujamais vecums
       user:
+        date_of_birth_1i: Diena
+        date_of_birth_2i: Mēnesis
+        date_of_birth_3i: Gads
         role: Loma
         time_zone: Laika josla
       user_role:
diff --git a/config/locales/simple_form.nl.yml b/config/locales/simple_form.nl.yml
index b31ae4b5ea..6029698bd7 100644
--- a/config/locales/simple_form.nl.yml
+++ b/config/locales/simple_form.nl.yml
@@ -75,6 +75,7 @@ nl:
       filters:
         action: Kies welke acties uitgevoerd moeten wanneer een bericht overeenkomt met het filter
         actions:
+          blur: Media verbergen achter een waarschuwing, zonder de tekst zelf te verbergen
           hide: Verberg de gefilterde inhoud volledig, alsof het niet bestaat
           warn: Verberg de gefilterde inhoud achter een waarschuwing, met de titel van het filter als waarschuwingstekst
       form_admin_settings:
@@ -88,6 +89,7 @@ nl:
         favicon: WEBP, PNG, GIF of JPG. Vervangt de standaard Mastodon favicon met een aangepast pictogram.
         mascot: Overschrijft de illustratie in de geavanceerde webomgeving.
         media_cache_retention_period: Mediabestanden van berichten van externe gebruikers worden op jouw server in de cache opgeslagen. Indien ingesteld op een positieve waarde, worden media verwijderd na het opgegeven aantal dagen. Als de mediagegevens worden opgevraagd nadat ze zijn verwijderd, worden ze opnieuw gedownload wanneer de originele inhoud nog steeds beschikbaar is. Vanwege beperkingen op hoe vaak linkvoorbeelden sites van derden raadplegen, wordt aanbevolen om deze waarde in te stellen op ten minste 14 dagen. Anders worden linkvoorbeelden niet op aanvraag bijgewerkt.
+        min_age: Gebruikers krijgen tijdens hun inschrijving de vraag om hun geboortedatum te bevestigen
         peers_api_enabled: Een lijst met domeinnamen die deze server heeft aangetroffen in de fediverse. Er zijn hier geen gegevens inbegrepen over de vraag of je verbonden bent met een bepaalde server, alleen dat je server er van weet. Dit wordt gebruikt door diensten die statistieken over de federatie in algemene zin verzamelen.
         profile_directory: De gebruikersgids bevat een lijst van alle gebruikers die ervoor gekozen hebben om ontdekt te kunnen worden.
         require_invite_text: Maak het invullen van "Waarom wil je je hier registreren?" verplicht in plaats van optioneel, wanneer registraties handmatig moeten worden goedgekeurd
@@ -146,6 +148,7 @@ nl:
         min_age: Mag niet lager zijn dan de minimale vereiste leeftijd volgens de wetten van jouw jurisdictie.
       user:
         chosen_languages: Alleen berichten in de aangevinkte talen worden op de openbare tijdlijnen getoond
+        date_of_birth: We moeten ervoor zorgen dat je tenminste %{age} bent om Mastodon te gebruiken. Dit wordt niet opgeslagen.
         role: De rol bepaalt welke rechten de gebruiker heeft.
       user_role:
         color: Kleur die gebruikt wordt voor de rol in de UI, als RGB in hexadecimale formaat
@@ -258,6 +261,7 @@ nl:
         name: Hashtag
       filters:
         actions:
+          blur: Media met een waarschuwing verbergen
           hide: Volledig verbergen
           warn: Met een waarschuwing verbergen
       form_admin_settings:
@@ -271,6 +275,7 @@ nl:
         favicon: Favicon
         mascot: Aangepaste mascotte (legacy)
         media_cache_retention_period: Bewaartermijn mediacache
+        min_age: Vereiste minimumleeftijd
         peers_api_enabled: Lijst van bekende servers via de API publiceren
         profile_directory: Gebruikersgids inschakelen
         registrations_mode: Wie kan zich registreren
@@ -349,6 +354,9 @@ nl:
         jurisdiction: Jurisdictie
         min_age: Minimumleeftijd
       user:
+        date_of_birth_1i: Dag
+        date_of_birth_2i: Maand
+        date_of_birth_3i: Jaar
         role: Rol
         time_zone: Tijdzone
       user_role:
diff --git a/config/locales/simple_form.nn.yml b/config/locales/simple_form.nn.yml
index 1463d8582d..1a33a4b91d 100644
--- a/config/locales/simple_form.nn.yml
+++ b/config/locales/simple_form.nn.yml
@@ -75,6 +75,7 @@ nn:
       filters:
         action: Velg kva som skal gjerast når eit innlegg samsvarar med filteret
         actions:
+          blur: Gøym media bak ei åtvaring utan å gøyme sjølve teksten
           hide: Skjul filtrert innhald fullstendig og lat som om det ikkje finst
           warn: Skjul det filtrerte innhaldet bak ei åtvaring som nemner tittelen på filteret
       form_admin_settings:
@@ -256,6 +257,7 @@ nn:
         name: Emneknagg
       filters:
         actions:
+          blur: Gøym media med ei åtvaring
           hide: Gøym heilt
           warn: Gøym med ei åtvaring
       form_admin_settings:
diff --git a/config/locales/simple_form.pt-PT.yml b/config/locales/simple_form.pt-PT.yml
index ac8fd8689b..48817a60d5 100644
--- a/config/locales/simple_form.pt-PT.yml
+++ b/config/locales/simple_form.pt-PT.yml
@@ -88,6 +88,7 @@ pt-PT:
         favicon: WEBP, PNG, GIF ou JPG. Substitui o ícone de favorito padrão do Mastodon por um ícone personalizado.
         mascot: Sobrepõe-se à ilustração na interface web avançada.
         media_cache_retention_period: Os ficheiros multimédia de publicações feitas por utilizadores remotos são armazenados em cache no seu servidor. Quando definido para um valor positivo, os ficheiros multimédia serão eliminados após o número de dias especificado. Se os ficheiros multimédia forem solicitados depois de terem sido eliminados, serão transferidos novamente, se o conteúdo de origem ainda estiver disponível. Devido a restrições sobre a frequência com que os cartões de pré-visualização de links pesquisam sites de terceiros, recomenda-se que este valor seja definido para, pelo menos, 14 dias, ou os cartões de pré-visualização de links não serão atualizados a pedido antes desse período.
+        min_age: Os utilizadores serão convidados a confirmar a sua data de nascimento durante o processo de inscrição
         peers_api_enabled: Uma lista de nomes de domínio que este servidor encontrou no fediverso. Nenhum dado é incluído aqui sobre se você federa com um determinado servidor, apenas que o seu servidor o conhece. Este serviço é utilizado por serviços que recolhem estatísticas na federação, em termos gerais.
         profile_directory: O diretório de perfis lista todos os utilizadores que optaram por ter a sua conta a ser sugerida a outros.
         require_invite_text: Quando as incrições exigirem aprovação manual, faça o texto "Por que se quer juntar a nós?" da solicitação de convite ser obrigatório, em vez de opcional
@@ -146,6 +147,7 @@ pt-PT:
         min_age: Não deve ter menos do que a idade mínima exigida pela legislação da sua jurisdição.
       user:
         chosen_languages: Quando selecionado, só serão mostradas nas cronologias públicas as publicações nos idiomas escolhidos
+        date_of_birth: Temos de ter a certeza de que tens pelo menos %{age} para usar o Mastodon. Não vamos guardar esta informação.
         role: A função controla as permissões que o utilizador tem.
       user_role:
         color: Cor a ser utilizada para a função em toda a interface de utilizador, como RGB no formato hexadecimal
@@ -271,6 +273,7 @@ pt-PT:
         favicon: Ícone de favoritos
         mascot: Mascote personalizada (legado)
         media_cache_retention_period: Período de retenção de ficheiros multimédia em cache
+        min_age: Idade mínima requerida
         peers_api_enabled: Publicar lista de servidores descobertos na API
         profile_directory: Ativar o diretório de perfis
         registrations_mode: Quem se pode inscrever
@@ -349,6 +352,9 @@ pt-PT:
         jurisdiction: Jurisdição legal
         min_age: Idade mínima
       user:
+        date_of_birth_1i: Dia
+        date_of_birth_2i: Mês
+        date_of_birth_3i: Ano
         role: Função
         time_zone: Fuso horário
       user_role:
diff --git a/config/locales/simple_form.ru.yml b/config/locales/simple_form.ru.yml
index 366335208e..0b8cbe0561 100644
--- a/config/locales/simple_form.ru.yml
+++ b/config/locales/simple_form.ru.yml
@@ -88,6 +88,7 @@ ru:
         favicon: WEBP, PNG, GIF или JPG. Заменяет стандартный фавикон Mastodon на собственный значок.
         mascot: Заменяет иллюстрацию в расширенном веб-интерфейсе.
         media_cache_retention_period: Медиафайлы из сообщений, сделанных удаленными пользователями, кэшируются на вашем сервере. При положительном значении медиафайлы будут удалены через указанное количество дней. Если медиаданные будут запрошены после удаления, они будут загружены повторно, если исходный контент все еще доступен. В связи с ограничениями на частоту опроса карточек предварительного просмотра ссылок на сторонних сайтах рекомендуется устанавливать значение не менее 14 дней, иначе карточки предварительного просмотра ссылок не будут обновляться по запросу до этого времени.
+        min_age: Пользователям при регистрации будет предложено ввести свою дату рождения
         peers_api_enabled: Список доменных имен, с которыми сервер столкнулся в fediverse. Здесь нет данных о том, федерировались ли вы с данным сервером, только что ваш сервер знает об этом. Это используется службами, которые собирают статистику по федерации в общем смысле.
         profile_directory: В каталоге профилей перечислены все пользователи, которые согласились быть доступными для обнаружения.
         require_invite_text: Когда регистрация требует ручного одобрения, сделайте текстовый ввод "Почему вы хотите присоединиться?" обязательным, а не опциональным
@@ -132,15 +133,21 @@ ru:
         name: Вы можете изменить только регистр букв чтобы, например, сделать тег более читаемым
       terms_of_service:
         changelog: Можно использовать синтаксис языка разметки Markdown.
+        effective_date: Разумные временные рамки могут варьироваться в диапазоне от 10 до 30 дней после уведомления пользователей.
         text: Можно использовать синтаксис языка разметки Markdown.
       terms_of_service_generator:
         admin_email: Юридические уведомления включают в себя встречные уведомления, постановления суда, запросы на удаление и запросы правоохранительных органов.
-        choice_of_law: Город, регион, территория или государственное материальное право, которое регулирует любые претензии и все их требования.
+        arbitration_address: Может совпадать с почтовым адресом, указанным выше, либо «N/A» в случае электронной почты.
+        arbitration_website: Веб-форма или «N/A» в случае электронной почты.
+        choice_of_law: Город, регион, территория или государство, внутреннее материальное право которого регулирует любые претензии.
         dmca_address: Находящиеся в США операторы должны использовать адрес, зарегистрированный в DMCA Designated Agent Directory. Использовать абонентский ящик возможно при обращении в соответствующей просьбой, для чего нужно с помощью DMCA Designated Agent Post Office Box Waiver Request написать сообщение в Copyright Office и объяснить, что вы занимаетесь модерацией контента из дома и опасаетесь мести за свои действия, поэтому должны использовать абонентский ящик, чтобы убрать ваш домашний адрес из общего доступа.
+        dmca_email: Может совпадать с адресом электронной почты для юридических уведомлений, указанным выше.
         domain: Имя, позволяющее уникально идентифицировать ваш онлайн-ресурс.
         jurisdiction: Впишите страну, где находится лицо, оплачивающее счета. Если это компания либо организация, впишите страну инкорпорации, включая город, регион, территорию или штат, если это необходимо.
+        min_age: Не меньше минимального возраста, требуемого по закону в вашей юрисдикции.
       user:
         chosen_languages: Если выбрано, то в публичных лентах будут показаны только посты на выбранных языках.
+        date_of_birth: Нужно убедиться, что вам не меньше %{age} лет. Мы не храним введённые здесь данные.
         role: Роль определяет, какими правами обладает пользователь.
       user_role:
         color: Цвет, который будет использоваться для роли в интерфейсе (UI), как RGB в формате HEX
@@ -266,6 +273,7 @@ ru:
         favicon: Favicon
         mascot: Пользовательский маскот (устаревшее)
         media_cache_retention_period: Период хранения кэша медиафайлов
+        min_age: Требование минимального возраста
         peers_api_enabled: Публикация списка обнаруженных узлов в API
         profile_directory: Включить каталог профилей
         registrations_mode: Кто может зарегистрироваться
@@ -331,17 +339,22 @@ ru:
         usable: Позволить этот хэштег в локальных сообщениях
       terms_of_service:
         changelog: Что изменилось?
+        effective_date: Дата вступления в силу
         text: Пользовательское соглашение
       terms_of_service_generator:
         admin_email: Адрес электронной почты для юридических уведомлений
         arbitration_address: Почтовый адрес для уведомлений об арбитраже
         arbitration_website: Вебсайт для подачи уведомления об арбитраже
-        choice_of_law: Выбор закана.
+        choice_of_law: Юрисдикция
         dmca_address: Почтовый адрес для обращений правообладателей
         dmca_email: Адрес электронной почты для обращений правообладателей
         domain: Доменное имя
         jurisdiction: Юрисдикция
+        min_age: Минимальный возраст
       user:
+        date_of_birth_1i: День
+        date_of_birth_2i: Месяц
+        date_of_birth_3i: Год
         role: Роль
         time_zone: Часовой пояс
       user_role:
diff --git a/config/locales/simple_form.sl.yml b/config/locales/simple_form.sl.yml
index 4562b7004b..5d55844aa9 100644
--- a/config/locales/simple_form.sl.yml
+++ b/config/locales/simple_form.sl.yml
@@ -88,6 +88,7 @@ sl:
         favicon: WEBP, PNG, GIF ali JPG. Zamenja privzeto ikono spletne strani Mastodon z ikono po meri.
         mascot: Preglasi ilustracijo v naprednem spletnem vmesniku.
         media_cache_retention_period: Predstavnostne datoteke iz objav uporabnikov na ostalih strežnikih se začasno hranijo na tem strežniku. Ko je nastavljeno na pozitivno vrednost, bodo predstavnostne datoteke izbrisane po nastavljenem številu dni. Če bo predstavnostna datoteka zahtevana po izbrisu, bo ponovno prenešena, če bo vir še vedno na voljo. Zaradi omejitev pogostosti prejemanja predogledov povezav z drugih strani je priporočljivo to vrednost nastaviti na vsaj 14 dni. V nasprotnem predogledi povezav pred tem časom ne bodo osveženi na zahtevo.
+        min_age: Med registracijo bodo morali uporabniki potrditi svoj datum rojstva
         peers_api_enabled: Seznam imen domen, na katere je ta strežnik naletel v fediverzumu. Sem niso vključeni podatki o tem, ali ste v federaciji z danim strežnikom, zgolj to, ali vaš strežnik ve zanj. To uporabljajo storitve, ki zbirajo statistične podatke o federaciji v splošnem smislu.
         profile_directory: Imenik profilov izpiše vse uporabnike, ki so dovolili, da so v njem navedeni.
         require_invite_text: Če registracije zahtevajo ročno potrditev, nastavite vnos besedila pod »Zakaj se želite pridružiti?« za obveznega.
@@ -145,6 +146,7 @@ sl:
         min_age: Ne smete biti mlajši od starostne omejitve, ki jo postavljajo zakoni vašega pravosodnega sistema.
       user:
         chosen_languages: Ko je označeno, bodo v javnih časovnicah prikazane samo objave v izbranih jezikih
+        date_of_birth: Prepričati se moramo, da so uporabniki Mastodona stari vsaj %{age} let. Tega podatka ne bomo shranili.
         role: Vloga določa, katera dovoljenja ima uporabnik.
       user_role:
         color: Barva, uporabljena za vlogo po celem up. vmesniku, podana v šestnajstiškem zapisu RGB
@@ -270,6 +272,7 @@ sl:
         favicon: Ikona spletne strani
         mascot: Maskota po meri (opuščeno)
         media_cache_retention_period: Obdobje hrambe predpomnilnika predstavnosti
+        min_age: Spodnja starostna meja
         peers_api_enabled: Objavi seznam odkritih strežnikov v API-ju
         profile_directory: Omogoči imenik profilov
         registrations_mode: Kdo se lahko registrira
@@ -341,12 +344,16 @@ sl:
         admin_email: E-poštni naslov za pravna obvestila
         arbitration_address: Fizični naslov za arbitražna obvestila
         arbitration_website: Spletišče za vložitev arbitražnih obvestil
+        choice_of_law: Izbira prava
         dmca_address: Fizični naslov za obvestila DMCA ali o avtorskih pravicah
         dmca_email: E-poštni naslov za obvestila DMCA ali o avtorskih pravicah
         domain: Domena
         jurisdiction: Pravna pristojnost
         min_age: Najmanjša starost
       user:
+        date_of_birth_1i: Dan
+        date_of_birth_2i: Mesec
+        date_of_birth_3i: Leto
         role: Vloga
         time_zone: Časovni pas
       user_role:
diff --git a/config/locales/simple_form.sq.yml b/config/locales/simple_form.sq.yml
index 47c0e18c5c..9f5ada184c 100644
--- a/config/locales/simple_form.sq.yml
+++ b/config/locales/simple_form.sq.yml
@@ -75,6 +75,7 @@ sq:
       filters:
         action: Zgjidhni cili veprim të kryhet, kur një postim ka përputhje me një filtër
         actions:
+          blur: Fshihe median pas një sinjalizimi, pa fshehur vetë tekstin
           hide: Fshihe plotësisht lëndën e filtruar, duke u sjellë sikur të mos ekzistonte
           warn: Fshihe lëndën e filtruar pas një sinjalizimi që përmend titullin e filtrit
       form_admin_settings:
@@ -88,6 +89,7 @@ sq:
         favicon: WEBP, PNG, GIF, ose JPG. Anashkalon favikonën parazgjedhje Mastodon me një ikonë vetjake.
         mascot: Anashkalon ilustrimin te ndërfaqja web e thelluar.
         media_cache_retention_period: Kartela media nga postime të bëra nga përdorues të largët ruhen në një fshehtinë në shërbyesin tuaj. Kur i jepet një vlerë pozitive, media do të fshihet pas numrit të dhënë të ditëve. Nëse të dhënat e medias duhen pas fshirjes, do të rishkarkohen, nëse lënda burim mund të kihet ende. Për shkak kufizimesh mbi sa shpesh skeda paraparjesh lidhjesh ndërveprojnë me sajte palësh të treta, rekomandohet të vihet kjo vlerë të paktën 14 ditë, ose skedat e paraparjes së lidhje s’do të përditësohen duke e kërkuar para asaj kohe.
+        min_age: Përdoruesve do t’ju kërkohet gjatë regjistrimit të ripohojnë datën e lindjes
         peers_api_enabled: Një listë emrash përkatësish që ky shërbyes ka hasur në fedivers. Këtu s’jepen të dhëna nëse jeni i federuar me shërbyesin e dhënë, thjesht tregohet se shërbyesi juaj e njeh. Kjo përdoret nga shërbime që mbledhin statistika mbi federimin në kuptimin e përgjithshëm.
         profile_directory: Drejtoria e profileve paraqet krejt përdoruesit që kanë zgjedhur të jenë të zbulueshëm.
         require_invite_text: Kur regjistrimet lypin miratim dorazi, bëje tekstin “Përse doni të bëheni pjesë?” të detyrueshëm, në vend se opsional
@@ -145,6 +147,7 @@ sq:
         min_age: S’duhet të jetë nën moshën minimum të domosdoshme nga ligjet në juridiksionin tuaj.
       user:
         chosen_languages: Në iu vëntë shenjë, te rrjedha kohore publike do të shfaqen vetëm mesazhe në gjuhët e përzgjedhura
+        date_of_birth: Na duhet të sigurohemi se jeni të paktën %{age}, që të përdorni Mastodon-in. Këtë s’e depozitojmë.
         role: Roli kontrollon cilat leje ka përdoruesi.
       user_role:
         color: Ngjyrë për t’u përdorur për rolin nëpër UI, si RGB në format gjashtëmbëdhjetësh
@@ -257,6 +260,7 @@ sq:
         name: Hashtag
       filters:
         actions:
+          blur: Fshihe median me një sinjalizim
           hide: Fshihe plotësisht
           warn: Fshihe me një sinjalizim
       form_admin_settings:
@@ -270,6 +274,7 @@ sq:
         favicon: Favikonë
         mascot: Simbol vetjak (e dikurshme)
         media_cache_retention_period: Periudhë mbajtjeje lënde media
+        min_age: Domosdosmëri moshe minimum
         peers_api_enabled: Publiko te API listë shërbyesish të zbuluar
         profile_directory: Aktivizo drejtori profilesh
         registrations_mode: Kush mund të regjistrohet
@@ -348,6 +353,9 @@ sq:
         jurisdiction: Juridiksion ligjor
         min_age: Mosha minimale
       user:
+        date_of_birth_1i: Ditë
+        date_of_birth_2i: Muaj
+        date_of_birth_3i: Vit
         role: Rol
         time_zone: Zonë kohore
       user_role:
diff --git a/config/locales/simple_form.sv.yml b/config/locales/simple_form.sv.yml
index e5503a5d12..41ac513f39 100644
--- a/config/locales/simple_form.sv.yml
+++ b/config/locales/simple_form.sv.yml
@@ -331,7 +331,11 @@ sv:
         dmca_address: Fysisk adress för meddelanden om DMCA/upphovsrätt
         dmca_email: Fysisk adress för meddelanden om DMCA/upphovsrätt
         domain: Domän
+        min_age: Minimiålder
       user:
+        date_of_birth_1i: Dag
+        date_of_birth_2i: Månad
+        date_of_birth_3i: År
         role: Roll
         time_zone: Tidszon
       user_role:
diff --git a/config/locales/simple_form.tr.yml b/config/locales/simple_form.tr.yml
index 9667b2b658..22300ccec3 100644
--- a/config/locales/simple_form.tr.yml
+++ b/config/locales/simple_form.tr.yml
@@ -75,6 +75,7 @@ tr:
       filters:
         action: Bir gönderi filtreyle eşleştiğinde hangi eylemin yapılacağını seçin
         actions:
+          blur: Medyayı, metnin kendisini gizlemeden bir uyarı arkasında gizle
           hide: Filtrelenmiş içeriği tamamen gizle, sanki varolmamış gibi
           warn: Süzgeçlenmiş içeriği, süzgecinin başlığından söz eden bir uyarının arkasında gizle
       form_admin_settings:
@@ -88,6 +89,7 @@ tr:
         favicon: WEBP, PNG, GIF veya JPG. Varsayılan Mastodon simgesini isteğe bağlı bir simgeyle değiştirir.
         mascot: Gelişmiş web arayüzündeki illüstrasyonu geçersiz kılar.
         media_cache_retention_period: Uzak kullanıcıların gönderilerindeki ortam dosyaları sunucunuzda önbelleklenir. Pozitif bir değer verildiğinde, ortam dosyaları belirlenen gün sonunda silinecektir. Eğer ortam dosyaları silindikten sonra istenirse, kaynak içerik hala mevcutsa, tekrar indirilecektir. Bağlantı önizleme kartlarının üçüncü parti siteleri yoklamasına ilişkin kısıtlamalar nedeniyle, bu değeri en azından 14 gün olarak ayarlamanız önerilir, yoksa bağlantı önizleme kartları bu süreden önce isteğe bağlı olarak güncellenmeyecektir.
+        min_age: Kullanıcılardan kayıt olurken doğum tarihlerini doğrulamaları istenecektir
         peers_api_enabled: Bu sunucunun fediverse'te karşılaştığı alan adlarının bir listesi. İlgili sunucuyla birleştirme mi yapıyorsunuz yoksa sunucunuz sadece onu biliyor mu hakkında bir bilgi burada yok. Bu blgi genel olarak federasyın hakkında istatistik toplamak isteyen hizmetler tarafından kullanılıyor.
         profile_directory: Profil dizini keşfedilebilir olmayı kabul eden tüm kullanıcıları listeler.
         require_invite_text: Kayıt olmak elle doğrulama gerektiriyorsa, "Neden katılmak istiyorsunuz?" metin girdisini isteğe bağlı yerine zorunlu yapın
@@ -146,6 +148,7 @@ tr:
         min_age: Tabi olduğunuz yasaların gerektirdiği yaştan düşük olmamalıdır.
       user:
         chosen_languages: İşaretlendiğinde, yalnızca seçilen dillerdeki gönderiler genel zaman çizelgelerinde görüntülenir
+        date_of_birth: Mastodon kullanmak için en az %{age} yaşında olduğunuzdan emin olmalıyız. Bu bilgiyi saklamıyoruz.
         role: Rol, kullanıcıların sahip olduğu izinleri denetler.
       user_role:
         color: Arayüz boyunca rol için kullanılacak olan renk, hex biçiminde RGB
@@ -258,6 +261,7 @@ tr:
         name: Etiket
       filters:
         actions:
+          blur: Medyayı bir uyarıyla gizle
           hide: Tamamen gizle
           warn: Uyarıyla gizle
       form_admin_settings:
@@ -271,6 +275,7 @@ tr:
         favicon: Yer imi simgesi
         mascot: Özel maskot (eski)
         media_cache_retention_period: Medya önbelleği saklama süresi
+        min_age: Azami yaş gereksinimi
         peers_api_enabled: API'de keşfedilen sunucuların listesini yayınla
         profile_directory: Profil dizinini etkinleştir
         registrations_mode: Kim kaydolabilir
@@ -349,6 +354,9 @@ tr:
         jurisdiction: Yasal yetki alanı
         min_age: Minimum yaş
       user:
+        date_of_birth_1i: Gün
+        date_of_birth_2i: Ay
+        date_of_birth_3i: Yıl
         role: Rol
         time_zone: Zaman dilimi
       user_role:
diff --git a/config/locales/simple_form.uk.yml b/config/locales/simple_form.uk.yml
index 7d77aa813f..b43aaeb234 100644
--- a/config/locales/simple_form.uk.yml
+++ b/config/locales/simple_form.uk.yml
@@ -75,6 +75,7 @@ uk:
       filters:
         action: Виберіть дію для виконання коли допис збігається з фільтром
         actions:
+          blur: Приховати медіа за попередженням, не приховуючи сам текст
           hide: Повністю сховати фільтрований вміст, ніби його не існує
           warn: Сховати відфільтрований вміст за попередженням, у якому вказано заголовок фільтра
       form_admin_settings:
@@ -88,6 +89,7 @@ uk:
         favicon: WEBP, PNG, GIF або JPG. Замінює стандартну піктограму Mastodon на власну.
         mascot: Змінює ілюстрацію в розширеному вебінтерфейсі.
         media_cache_retention_period: Медіафайли з дописів віддалених користувачів кешуються на вашому сервері. Якщо встановлено додатне значення, медіа буде видалено через вказану кількість днів. Якщо медіадані будуть запитані після видалення, вони будуть завантажені повторно, якщо вихідний вміст все ще доступний. Через обмеження на частоту опитування карток попереднього перегляду посилань на сторонніх сайтах, рекомендується встановити це значення не менше 14 днів, інакше картки попереднього перегляду посилань не будуть оновлюватися на вимогу раніше цього часу.
+        min_age: Користувачам буде запропоновано підтвердити дату народження під час реєстрації
         peers_api_enabled: Список доменів імен цього сервера з'явився у федівсесвіті. Сюди не входять дані чи ви пов'язані федерацією з цим сервером, а лише відомості, що вашому серверу відомо про нього. Його використовують служби, які збирають загальну статистику про федерації.
         profile_directory: У каталозі профілів перераховані всі користувачі, які погодились бути видимими.
         require_invite_text: Якщо реєстрація вимагає власноручного затвердження, зробіть текстове поле «Чому ви хочете приєднатися?» обов'язковим, а не додатковим
@@ -145,6 +147,7 @@ uk:
         min_age: Не повинно бути нижче мінімального віку, необхідного законодавством вашої юрисдикції.
       user:
         chosen_languages: У глобальних стрічках будуть показані дописи тільки вибраними мовами
+        date_of_birth: Ми повинні переконатися, що вам принаймні %{age}, щоб використовувати Mastodon. Ми не будемо зберігати це.
         role: Роль визначає, які права має користувач.
       user_role:
         color: Колір, який буде використовуватися для ролі у всьому інтерфейсі, як RGB у форматі hex
@@ -256,6 +259,7 @@ uk:
         name: Хештеґ
       filters:
         actions:
+          blur: Приховати медіа з попередженням
           hide: Сховати повністю
           warn: Сховати за попередженням
       form_admin_settings:
@@ -269,6 +273,7 @@ uk:
         favicon: Піктограма сайту
         mascot: Користувацький символ (застарілий)
         media_cache_retention_period: Період збереження кешу медіа
+        min_age: Мінімальна вимога по віку
         peers_api_enabled: Опублікувати список знайдених серверів у API
         profile_directory: Увімкнути каталог профілів
         registrations_mode: Хто може зареєструватися
@@ -346,6 +351,9 @@ uk:
         jurisdiction: Правова юрисдикція
         min_age: Мінімальний вік
       user:
+        date_of_birth_1i: День
+        date_of_birth_2i: Місяць
+        date_of_birth_3i: Рік
         role: Роль
         time_zone: Часовий пояс
       user_role:
diff --git a/config/locales/simple_form.vi.yml b/config/locales/simple_form.vi.yml
index 4d182c56c2..8b78787f86 100644
--- a/config/locales/simple_form.vi.yml
+++ b/config/locales/simple_form.vi.yml
@@ -75,6 +75,7 @@ vi:
       filters:
         action: Chọn hành động sẽ thực hiện khi một tút khớp với bộ lọc
         actions:
+          blur: Ẩn sau một cảnh báo, mà không ảnh hưởng nội dung
           hide: Ẩn hoàn toàn, như thể nó không tồn tại
           warn: Hiện cảnh báo và bộ lọc
       form_admin_settings:
@@ -88,6 +89,7 @@ vi:
         favicon: WEBP, PNG, GIF hoặc JPG. Dùng favicon Maston tùy chỉnh.
         mascot: Ghi đè hình minh họa trong giao diện web nâng cao.
         media_cache_retention_period: Các tệp phương tiện từ các tút do người dùng máy chủ khác thực hiện sẽ được lưu vào bộ đệm trên máy chủ của bạn. Khi được đặt thành giá trị dương, phương tiện sẽ bị xóa sau số ngày được chỉ định. Nếu dữ liệu phương tiện được yêu cầu sau khi bị xóa, dữ liệu đó sẽ được tải xuống lại nếu nội dung nguồn vẫn còn. Do những hạn chế về tần suất thẻ xem trước liên kết thăm dò ý kiến ​​các trang web của bên thứ ba, bạn nên đặt giá trị này thành ít nhất 14 ngày, nếu không thẻ xem trước liên kết sẽ không được cập nhật theo yêu cầu trước thời gian đó.
+        min_age: Thành viên sẽ được yêu cầu xác nhận ngày sinh của họ trong quá trình đăng ký
         peers_api_enabled: Danh sách các máy chủ khác mà máy chủ này đã liên hợp. Không có dữ liệu nào được đưa vào đây về việc bạn có liên kết với một máy chủ nhất định hay không, chỉ là máy chủ của bạn biết về nó. Điều này được sử dụng bởi các dịch vụ thu thập số liệu thống kê về liên kết theo nghĩa chung.
         profile_directory: Liệt kê tất cả người đã chọn tham gia để có thể khám phá.
         require_invite_text: Khi đăng ký yêu cầu phê duyệt thủ công, hãy đặt câu hỏi "Tại sao bạn muốn tham gia?" nhập văn bản bắt buộc thay vì tùy chọn
@@ -146,6 +148,7 @@ vi:
         min_age: Không được dưới độ tuổi tối thiểu theo quy định của luật pháp tại khu vực của bạn.
       user:
         chosen_languages: Chỉ hiển thị những tút viết bằng các ngôn ngữ sau
+        date_of_birth: Chúng tôi phải đảm bảo rằng bạn ít nhất %{age} tuổi để sử dụng Mastodon. Chúng tôi không lưu trữ thông tin này.
         role: Vai trò kiểm soát những quyền mà người dùng có.
       user_role:
         color: Màu được sử dụng cho vai trò trong toàn bộ giao diện người dùng, dưới dạng RGB ở định dạng hex
@@ -258,6 +261,7 @@ vi:
         name: Hashtag
       filters:
         actions:
+          blur: Ẩn kèm theo cảnh báo
           hide: Ẩn toàn bộ
           warn: Ẩn kèm theo cảnh báo
       form_admin_settings:
@@ -271,6 +275,7 @@ vi:
         favicon: Favicon
         mascot: Tùy chỉnh linh vật (kế thừa)
         media_cache_retention_period: Thời hạn lưu trữ cache media
+        min_age: Độ tuổi tối thiểu
         peers_api_enabled: Công khai danh sách các máy chủ được phát hiện trong API
         profile_directory: Cho phép hiện danh sách thành viên
         registrations_mode: Ai có thể đăng ký
@@ -349,6 +354,9 @@ vi:
         jurisdiction: Quyền tài phán pháp lý
         min_age: Độ tuổi tối thiểu
       user:
+        date_of_birth_1i: Ngày
+        date_of_birth_2i: Tháng
+        date_of_birth_3i: Năm
         role: Vai trò
         time_zone: Múi giờ
       user_role:
diff --git a/config/locales/simple_form.zh-CN.yml b/config/locales/simple_form.zh-CN.yml
index 599c3de084..5fd28497af 100644
--- a/config/locales/simple_form.zh-CN.yml
+++ b/config/locales/simple_form.zh-CN.yml
@@ -88,6 +88,7 @@ zh-CN:
         favicon: WEBP、PNG、GIF 或 JPG。使用自定义图标覆盖 Mastodon 的默认图标。
         mascot: 覆盖高级网页界面中的绘图形象。
         media_cache_retention_period: 来自外站用户嘟文的媒体文件将被缓存到你的实例上。当该值被设为正值时,缓存的媒体文件将在指定天数后被清除。如果媒体文件在被清除后重新被请求,且源站内容仍然可用,它将被重新下载。由于链接预览卡拉取第三方站点的频率受到限制,建议将此值设置为至少 14 天,如果小于该值,链接预览卡将不会按需更新。
+        min_age: 用户注册时必须确认出生日期
         peers_api_enabled: 本站在联邦宇宙中遇到的站点列表。 此处不包含关于您是否与给定站点联合的数据,只是您的实例知道它。 这由收集一般意义上的联合统计信息的服务使用。
         profile_directory: 个人资料目录会列出所有选择可被发现的用户。
         require_invite_text: 当注册需要手动批准时,将“你为什么想要加入?”设为必填项
@@ -132,16 +133,21 @@ zh-CN:
         name: 你只能改变字母的大小写,让它更易读
       terms_of_service:
         changelog: 可以使用 Markdown 语法。
+        effective_date: 合理的时间范围可以是从您通知用户之日起 10 到 30 天。
         text: 可以使用 Markdown 语法。
       terms_of_service_generator:
         admin_email: 法务通知包括反通知、法院命令、内容下架要求与执法机关的要求。
+        arbitration_address: 可以与上面的实际地址相同,如果使用电子邮件则为“N/A”。
+        arbitration_website: 可以是网页表单,如果使用电子邮件则为“N/A”。
         choice_of_law: 适用内部实质法律以管辖任何及所有索赔的城市、地区、领土或州。
         dmca_address: 如果你是位于美国的运营者,请使用在 DMCA 指定代表名录中注册的地址。如果你需要使用邮政信箱,可以直接申请。请使用 DMCA 指定代表邮政信箱豁免申请表,通过电子邮件联系版权办公室,并声明你是居家内容审核员,因担心审核操作会招致报复或打击报复,需要使用邮政信箱以避免公开家庭住址。
+        dmca_email: 可以与上面“法律声明的电子邮件地址”使用相同的电子邮件地址。
         domain: 你所提供的在线服务的唯一标识。
         jurisdiction: 请列出支付运营费用者所在的国家/地区。如果为公司或其他实体,请列出其注册的国家/地区以及相应的城市、地区、领地或州。
         min_age: 不应低于您所在地法律管辖权要求的最低年龄。
       user:
         chosen_languages: 仅选中语言的嘟文会出现在公共时间线上(全不选则显示所有语言的嘟文)
+        date_of_birth: 我们必须确认%{age}岁以上的用户才能使用Mastodon。我们不会存储该信息。
         role: 角色用于控制用户拥有的权限。
       user_role:
         color: 在界面各处用于标记该角色的颜色,以十六进制 RGB 格式表示
@@ -254,6 +260,7 @@ zh-CN:
         name: 话题
       filters:
         actions:
+          blur: 隐藏媒体并显示警告
           hide: 完全隐藏
           warn: 隐藏时显示警告
       form_admin_settings:
@@ -267,6 +274,7 @@ zh-CN:
         favicon: Favicon
         mascot: 自定义吉祥物(旧)
         media_cache_retention_period: 媒体缓存保留期
+        min_age: 最低年龄要求
         peers_api_enabled: 在API中公开的已知实例的服务器的列表
         profile_directory: 启用用户目录
         registrations_mode: 谁可以注册
@@ -345,6 +353,9 @@ zh-CN:
         jurisdiction: 法律管辖区
         min_age: 最低年龄
       user:
+        date_of_birth_1i: 日
+        date_of_birth_2i: 月
+        date_of_birth_3i: 年
         role: 角色
         time_zone: 时区
       user_role:
diff --git a/config/locales/simple_form.zh-TW.yml b/config/locales/simple_form.zh-TW.yml
index 47adb05010..fc86c77b74 100644
--- a/config/locales/simple_form.zh-TW.yml
+++ b/config/locales/simple_form.zh-TW.yml
@@ -75,6 +75,7 @@ zh-TW:
       filters:
         action: 請選擇當嘟文符合該過濾器時將被執行之動作
         actions:
+          blur: 將多媒體隱藏於警告之後,而不隱藏文字內容
           hide: 完全隱藏過濾內容,當作它似乎不曾存在過
           warn: 隱藏過濾內容於過濾器標題之警告後
       form_admin_settings:
@@ -88,6 +89,7 @@ zh-TW:
         favicon: WEBP、PNG、GIF、或 JPG。使用自訂圖示替代預設 Mastodon favicon 圖示。
         mascot: 覆寫進階網頁介面中的圖例。
         media_cache_retention_period: 來自遠端伺服器嘟文中之多媒體內容將快取於您的伺服器。當設定為正值時,這些多媒體內容將於指定之天數後自您的儲存空間中自動刪除。若多媒體資料於刪除後被請求,且原始內容仍可存取,它們將被重新下載。由於連結預覽中第三方網站查詢頻率限制,建議將其設定為至少 14 日,否則於此之前連結預覽將不被即時更新。
+        min_age: 使用者將於註冊時被要求確認他們的生日
         peers_api_enabled: 浩瀚聯邦宇宙中與此伺服器曾經擦肩而過的網域列表。不包含關於您是否與此伺服器是否有與之串連,僅僅表示您的伺服器已知此網域。這是供收集聯邦宇宙中一般性統計資料服務使用。
         profile_directory: 個人檔案目錄將會列出那些有選擇被發現的使用者。
         require_invite_text: 如果已設定為手動審核註冊,請將「為什麼想要加入呢?」設定為必填項目。
@@ -146,6 +148,7 @@ zh-TW:
         min_age: 不應低於您所屬法律管轄區要求之最低年齡。
       user:
         chosen_languages: 當選取時,只有選取語言之嘟文會於公開時間軸中顯示
+        date_of_birth: 我們必須確認您至少年滿 %{age} 以使用 Mastodon。我們不會儲存此資料。
         role: 角色控制使用者有哪些權限。
       user_role:
         color: 於整個使用者介面中用於角色的顏色,十六進位格式的 RGB
@@ -258,6 +261,7 @@ zh-TW:
         name: "「#」主題標籤"
       filters:
         actions:
+          blur: 將多媒體隱藏於警告之後
           hide: 完全隱藏
           warn: 隱藏於警告之後
       form_admin_settings:
@@ -271,6 +275,7 @@ zh-TW:
         favicon: 網站圖示 (Favicon)
         mascot: 自訂吉祥物 (legacy)
         media_cache_retention_period: 多媒體快取資料保留期間
+        min_age: 最低年齡要求
         peers_api_enabled: 於 API 中公開已知伺服器的列表
         profile_directory: 啟用個人檔案目錄
         registrations_mode: 誰能註冊
@@ -349,6 +354,9 @@ zh-TW:
         jurisdiction: 司法管轄區
         min_age: 最低年齡
       user:
+        date_of_birth_1i: 日
+        date_of_birth_2i: 月
+        date_of_birth_3i: 年
         role: 角色
         time_zone: 時區
       user_role:
diff --git a/config/locales/th.yml b/config/locales/th.yml
index 2f8d03eec4..9b7ae7897d 100644
--- a/config/locales/th.yml
+++ b/config/locales/th.yml
@@ -1089,7 +1089,7 @@ th:
     salutation: "%{name},"
     settings: 'เปลี่ยนการกำหนดลักษณะอีเมล: %{link}'
     unsubscribe: เลิกบอกรับ
-    view: 'มุมมอง:'
+    view: 'ดู:'
     view_profile: ดูโปรไฟล์
     view_status: ดูโพสต์
   applications:
diff --git a/config/locales/tok.yml b/config/locales/tok.yml
index 2cb8429ac4..48b5406147 100644
--- a/config/locales/tok.yml
+++ b/config/locales/tok.yml
@@ -27,9 +27,14 @@ tok:
     accounts:
       approve: o wile
       are_you_sure: ni li pona ala pona?
+      avatar: sitelen
       delete: o ala e sona
       deleted: jan li ala e ni
       demote: o lili e ken
+      edit: ante toki
+      invite_request_text: nasin kama
+      ip: nanpa IP
+      joined: tenpo kama
       search: o alasa
     tags:
       search: o alasa
diff --git a/config/locales/zh-CN.yml b/config/locales/zh-CN.yml
index 12b3aa44d4..5542975143 100644
--- a/config/locales/zh-CN.yml
+++ b/config/locales/zh-CN.yml
@@ -315,6 +315,9 @@ zh-CN:
       new:
         create: 创建公告
         title: 新公告
+      preview:
+        explanation_html: 此电子邮件将发送给 <strong>%{display_count} 用户</strong>。电子邮件将包含以下文本:
+        title: 预览公告通知
       publish: 发布
       published_msg: 公告已发布!
       scheduled_for: 定时在 %{time}
@@ -1862,6 +1865,10 @@ zh-CN:
     recovery_instructions_html: 如果你的手机无法使用,你可以使用下列任意一个恢复代码来重新获得对账号的访问权。<strong>请妥善保管好你的恢复代码</strong>(例如,你可以将它们打印出来,然后和其他重要的文件放在一起)。
     webauthn: 安全密钥
   user_mailer:
+    announcement_published:
+      description: "%{domain}管理员发布了一则公告:"
+      subject: 服务公告
+      title: "%{domain}服务公告"
     appeal_approved:
       action: 账号设置
       explanation: 你于 %{appeal_date} 对 %{strike_date} 在你账号上做出的处罚提出的申诉已被批准,你的账号已回到正常状态。
diff --git a/config/webpack/rules/babel.js b/config/webpack/rules/babel.js
index f1b53c3606..76e41f3df0 100644
--- a/config/webpack/rules/babel.js
+++ b/config/webpack/rules/babel.js
@@ -4,7 +4,7 @@ const { env, settings } = require('../configuration');
 
 // Those modules contain modern ES code that need to be transpiled for Webpack to process it
 const nodeModulesToProcess = [
-  '@reduxjs', 'fuzzysort', 'toygrad'
+  '@reduxjs', 'fuzzysort', 'toygrad', '@react-spring'
 ];
 
 module.exports = {
diff --git a/db/migrate/20250313123400_add_age_verified_at_to_users.rb b/db/migrate/20250313123400_add_age_verified_at_to_users.rb
new file mode 100644
index 0000000000..c6cd6120ef
--- /dev/null
+++ b/db/migrate/20250313123400_add_age_verified_at_to_users.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+class AddAgeVerifiedAtToUsers < ActiveRecord::Migration[8.0]
+  def change
+    add_column :users, :age_verified_at, :datetime
+  end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 3bdd09f15d..eb488a7342 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[8.0].define(version: 2025_03_05_074104) do
+ActiveRecord::Schema[8.0].define(version: 2025_03_13_123400) do
   # These are extensions that must be enabled in order to support this database
   enable_extension "pg_catalog.plpgsql"
 
@@ -1559,6 +1559,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_03_05_074104) do
     t.text "settings"
     t.string "time_zone"
     t.string "otp_secret"
+    t.datetime "age_verified_at"
     t.index ["account_id"], name: "index_users_on_account_id"
     t.index ["confirmation_token"], name: "index_users_on_confirmation_token", unique: true
     t.index ["created_by_application_id"], name: "index_users_on_created_by_application_id", where: "(created_by_application_id IS NOT NULL)"
diff --git a/lib/mastodon/middleware/prometheus_queue_time.rb b/lib/mastodon/middleware/prometheus_queue_time.rb
new file mode 100644
index 0000000000..bb8add51ec
--- /dev/null
+++ b/lib/mastodon/middleware/prometheus_queue_time.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+module Mastodon
+  module Middleware
+    class PrometheusQueueTime < ::PrometheusExporter::Middleware
+      # Overwrite to only collect the queue time metric
+      def call(env)
+        queue_time = measure_queue_time(env)
+
+        result = @app.call(env)
+
+        result
+      ensure
+        obj = {
+          type: 'web',
+          queue_time: queue_time,
+          default_labels: {},
+        }
+
+        @client.send_json(obj)
+      end
+    end
+  end
+end
diff --git a/lib/mastodon/version.rb b/lib/mastodon/version.rb
index 86db57d35f..acf7a4e79a 100644
--- a/lib/mastodon/version.rb
+++ b/lib/mastodon/version.rb
@@ -96,7 +96,7 @@ module Mastodon
 
     def api_versions
       {
-        mastodon: 4,
+        mastodon: 5,
         kmyblue: KMYBLUE_API_VERSION,
       }
     end
diff --git a/package.json b/package.json
index 4bafcedebf..5a84692ad9 100644
--- a/package.json
+++ b/package.json
@@ -56,8 +56,10 @@
     "@github/webauthn-json": "^2.1.1",
     "@hello-pangea/dnd": "^17.0.0",
     "@rails/ujs": "7.1.501",
+    "@react-spring/web": "^9.7.5",
     "@reduxjs/toolkit": "^2.0.1",
     "@svgr/webpack": "^5.5.0",
+    "@use-gesture/react": "^10.3.1",
     "arrow-key-navigation": "^1.2.0",
     "async-mutex": "^0.5.0",
     "axios": "^1.4.0",
@@ -108,7 +110,6 @@
     "react-immutable-pure-component": "^2.2.2",
     "react-intl": "^7.0.0",
     "react-motion": "^0.5.2",
-    "react-notification": "^6.8.5",
     "react-overlays": "^5.2.1",
     "react-redux": "^9.0.4",
     "react-redux-loading-bar": "^5.0.8",
diff --git a/spec/controllers/activitypub/inboxes_controller_spec.rb b/spec/controllers/activitypub/inboxes_controller_spec.rb
deleted file mode 100644
index feca543cb7..0000000000
--- a/spec/controllers/activitypub/inboxes_controller_spec.rb
+++ /dev/null
@@ -1,112 +0,0 @@
-# frozen_string_literal: true
-
-require 'rails_helper'
-
-RSpec.describe ActivityPub::InboxesController do
-  let(:remote_account) { nil }
-
-  before do
-    allow(controller).to receive(:signed_request_actor).and_return(remote_account)
-  end
-
-  describe 'POST #create' do
-    context 'with signature' do
-      let(:remote_account) { Fabricate(:account, domain: 'example.com', protocol: :activitypub) }
-
-      before do
-        post :create, body: '{}'
-      end
-
-      it 'returns http accepted' do
-        expect(response).to have_http_status(202)
-      end
-
-      context 'with a specific account' do
-        subject(:response) { post :create, params: { account_username: account.username }, body: '{}' }
-
-        let(:account) { Fabricate(:account) }
-
-        context 'when account is permanently suspended' do
-          before do
-            account.suspend!
-            account.deletion_request.destroy
-          end
-
-          it 'returns http gone' do
-            expect(response).to have_http_status(410)
-          end
-        end
-
-        context 'when account is temporarily suspended' do
-          before do
-            account.suspend!
-          end
-
-          it 'returns http accepted' do
-            expect(response).to have_http_status(202)
-          end
-        end
-      end
-    end
-
-    context 'with Collection-Synchronization header' do
-      let(:remote_account)             { Fabricate(:account, followers_url: 'https://example.com/followers', domain: 'example.com', uri: 'https://example.com/actor', protocol: :activitypub) }
-      let(:synchronization_collection) { remote_account.followers_url }
-      let(:synchronization_url)        { 'https://example.com/followers-for-domain' }
-      let(:synchronization_hash)       { 'somehash' }
-      let(:synchronization_header)     { "collectionId=\"#{synchronization_collection}\", digest=\"#{synchronization_hash}\", url=\"#{synchronization_url}\"" }
-
-      before do
-        allow(ActivityPub::FollowersSynchronizationWorker).to receive(:perform_async).and_return(nil)
-        allow(remote_account).to receive(:local_followers_hash).and_return('somehash')
-
-        request.headers['Collection-Synchronization'] = synchronization_header
-        post :create, body: '{}'
-      end
-
-      context 'with mismatching target collection' do
-        let(:synchronization_collection) { 'https://example.com/followers2' }
-
-        it 'does not start a synchronization job' do
-          expect(ActivityPub::FollowersSynchronizationWorker).to_not have_received(:perform_async)
-        end
-      end
-
-      context 'with mismatching domain in partial collection attribute' do
-        let(:synchronization_url) { 'https://example.org/followers' }
-
-        it 'does not start a synchronization job' do
-          expect(ActivityPub::FollowersSynchronizationWorker).to_not have_received(:perform_async)
-        end
-      end
-
-      context 'with matching digest' do
-        it 'does not start a synchronization job' do
-          expect(ActivityPub::FollowersSynchronizationWorker).to_not have_received(:perform_async)
-        end
-      end
-
-      context 'with mismatching digest' do
-        let(:synchronization_hash) { 'wronghash' }
-
-        it 'starts a synchronization job' do
-          expect(ActivityPub::FollowersSynchronizationWorker).to have_received(:perform_async)
-        end
-      end
-
-      it 'returns http accepted' do
-        expect(response).to have_http_status(202)
-      end
-    end
-
-    context 'without signature' do
-      before do
-        post :create, body: '{}'
-      end
-
-      it 'returns http not authorized' do
-        expect(response).to have_http_status(401)
-      end
-    end
-  end
-end
diff --git a/spec/controllers/admin/account_actions_controller_spec.rb b/spec/controllers/admin/account_actions_controller_spec.rb
deleted file mode 100644
index fabe5cef4d..0000000000
--- a/spec/controllers/admin/account_actions_controller_spec.rb
+++ /dev/null
@@ -1,35 +0,0 @@
-# frozen_string_literal: true
-
-require 'rails_helper'
-
-RSpec.describe Admin::AccountActionsController do
-  render_views
-
-  let(:user) { Fabricate(:admin_user) }
-
-  before do
-    sign_in user, scope: :user
-  end
-
-  describe 'GET #new' do
-    let(:account) { Fabricate(:account) }
-
-    it 'returns http success' do
-      get :new, params: { account_id: account.id }
-
-      expect(response).to have_http_status(:success)
-    end
-  end
-
-  describe 'POST #create' do
-    let(:account) { Fabricate(:account) }
-
-    it 'records the account action' do
-      expect do
-        post :create, params: { account_id: account.id, admin_account_action: { type: 'silence' } }
-      end.to change { account.strikes.count }.by(1)
-
-      expect(response).to redirect_to(admin_account_path(account.id))
-    end
-  end
-end
diff --git a/spec/controllers/admin/change_emails_controller_spec.rb b/spec/controllers/admin/change_emails_controller_spec.rb
deleted file mode 100644
index 899106e54e..0000000000
--- a/spec/controllers/admin/change_emails_controller_spec.rb
+++ /dev/null
@@ -1,48 +0,0 @@
-# frozen_string_literal: true
-
-require 'rails_helper'
-
-RSpec.describe Admin::ChangeEmailsController do
-  render_views
-
-  let(:admin) { Fabricate(:admin_user) }
-
-  before do
-    sign_in admin
-  end
-
-  describe 'GET #show' do
-    it 'returns http success' do
-      user = Fabricate(:user)
-
-      get :show, params: { account_id: user.account.id }
-
-      expect(response).to have_http_status(200)
-    end
-  end
-
-  describe 'GET #update' do
-    before do
-      allow(UserMailer).to receive(:confirmation_instructions)
-        .and_return(instance_double(ActionMailer::MessageDelivery, deliver_later: nil))
-    end
-
-    it 'returns http success' do
-      user = Fabricate(:user)
-
-      previous_email = user.email
-
-      post :update, params: { account_id: user.account.id, user: { unconfirmed_email: 'test@example.com' } }
-
-      user.reload
-
-      expect(user.email).to eq previous_email
-      expect(user.unconfirmed_email).to eq 'test@example.com'
-      expect(user.confirmation_token).to_not be_nil
-
-      expect(UserMailer).to have_received(:confirmation_instructions).with(user, user.confirmation_token, { to: 'test@example.com' })
-
-      expect(response).to redirect_to(admin_account_path(user.account.id))
-    end
-  end
-end
diff --git a/spec/controllers/admin/users/two_factor_authentications_controller_spec.rb b/spec/controllers/admin/users/two_factor_authentications_controller_spec.rb
deleted file mode 100644
index 39af2ca914..0000000000
--- a/spec/controllers/admin/users/two_factor_authentications_controller_spec.rb
+++ /dev/null
@@ -1,54 +0,0 @@
-# frozen_string_literal: true
-
-require 'rails_helper'
-require 'webauthn/fake_client'
-
-RSpec.describe Admin::Users::TwoFactorAuthenticationsController do
-  render_views
-
-  let(:user) { Fabricate(:user) }
-
-  before do
-    sign_in Fabricate(:admin_user), scope: :user
-  end
-
-  describe 'DELETE #destroy' do
-    context 'when user has OTP enabled' do
-      before do
-        user.update(otp_required_for_login: true)
-      end
-
-      it 'redirects to admin account page' do
-        delete :destroy, params: { user_id: user.id }
-
-        user.reload
-        expect(user.otp_enabled?).to be false
-        expect(response).to redirect_to(admin_account_path(user.account_id))
-      end
-    end
-
-    context 'when user has OTP and WebAuthn enabled' do
-      let(:fake_client) { WebAuthn::FakeClient.new('http://test.host') }
-
-      before do
-        user.update(otp_required_for_login: true, webauthn_id: WebAuthn.generate_user_id)
-
-        public_key_credential = WebAuthn::Credential.from_create(fake_client.create)
-        Fabricate(:webauthn_credential,
-                  user_id: user.id,
-                  external_id: public_key_credential.id,
-                  public_key: public_key_credential.public_key,
-                  nickname: 'Security Key')
-      end
-
-      it 'redirects to admin account page' do
-        delete :destroy, params: { user_id: user.id }
-
-        user.reload
-        expect(user.otp_enabled?).to be false
-        expect(user.webauthn_enabled?).to be false
-        expect(response).to redirect_to(admin_account_path(user.account_id))
-      end
-    end
-  end
-end
diff --git a/spec/controllers/auth/registrations_controller_spec.rb b/spec/controllers/auth/registrations_controller_spec.rb
index a16e933cf3..e7a8dd6d7f 100644
--- a/spec/controllers/auth/registrations_controller_spec.rb
+++ b/spec/controllers/auth/registrations_controller_spec.rb
@@ -342,6 +342,42 @@ RSpec.describe Auth::RegistrationsController do
       end
     end
 
+    context 'when age verification is enabled' do
+      subject { post :create, params: { user: { account_attributes: { username: 'test' }, email: 'test@example.com', password: '12345678', password_confirmation: '12345678', agreement: 'true' }.merge(date_of_birth) } }
+
+      before do
+        Setting.min_age = 16
+      end
+
+      let(:date_of_birth) { {} }
+
+      context 'when date of birth is below age limit' do
+        let(:date_of_birth) { 13.years.ago.then { |date| { 'date_of_birth(1i)': date.day.to_s, 'date_of_birth(2i)': date.month.to_s, 'date_of_birth(3i)': date.year.to_s } } }
+
+        it 'does not create user' do
+          subject
+          user = User.find_by(email: 'test@example.com')
+          expect(user).to be_nil
+        end
+      end
+
+      context 'when date of birth is above age limit' do
+        let(:date_of_birth) { 17.years.ago.then { |date| { 'date_of_birth(1i)': date.day.to_s, 'date_of_birth(2i)': date.month.to_s, 'date_of_birth(3i)': date.year.to_s } } }
+
+        it 'redirects to setup and creates user' do
+          subject
+
+          expect(response).to redirect_to auth_setup_path
+
+          expect(User.find_by(email: 'test@example.com'))
+            .to be_present
+            .and have_attributes(
+              age_verified_at: not_eq(nil)
+            )
+        end
+      end
+    end
+
     context 'when max user count is set' do
       subject do
         post :create, params: { user: { account_attributes: { username: 'test' }, email: 'test@example.com', password: '12345678', password_confirmation: '12345678', agreement: 'true' } }
diff --git a/spec/controllers/disputes/strikes_controller_spec.rb b/spec/controllers/disputes/strikes_controller_spec.rb
deleted file mode 100644
index f6d28fc09a..0000000000
--- a/spec/controllers/disputes/strikes_controller_spec.rb
+++ /dev/null
@@ -1,32 +0,0 @@
-# frozen_string_literal: true
-
-require 'rails_helper'
-
-RSpec.describe Disputes::StrikesController do
-  render_views
-
-  before { sign_in current_user, scope: :user }
-
-  describe '#show' do
-    let(:current_user) { Fabricate(:user) }
-    let(:strike) { Fabricate(:account_warning, target_account: current_user.account) }
-
-    before do
-      get :show, params: { id: strike.id }
-    end
-
-    context 'when meant for the user' do
-      it 'returns http success' do
-        expect(response).to have_http_status(:success)
-      end
-    end
-
-    context 'when meant for a different user' do
-      let(:strike) { Fabricate(:account_warning) }
-
-      it 'returns http forbidden' do
-        expect(response).to have_http_status(403)
-      end
-    end
-  end
-end
diff --git a/spec/controllers/filters/statuses_controller_spec.rb b/spec/controllers/filters/statuses_controller_spec.rb
deleted file mode 100644
index 7bad403571..0000000000
--- a/spec/controllers/filters/statuses_controller_spec.rb
+++ /dev/null
@@ -1,45 +0,0 @@
-# frozen_string_literal: true
-
-require 'rails_helper'
-
-RSpec.describe Filters::StatusesController do
-  render_views
-
-  describe 'GET #index' do
-    let(:filter) { Fabricate(:custom_filter) }
-
-    context 'with signed out user' do
-      it 'redirects' do
-        get :index, params: { filter_id: filter }
-
-        expect(response).to be_redirect
-      end
-    end
-
-    context 'with a signed in user' do
-      context 'with the filter user signed in' do
-        before do
-          sign_in(filter.account.user)
-          get :index, params: { filter_id: filter }
-        end
-
-        it 'returns http success and private cache control headers' do
-          expect(response).to have_http_status(200)
-
-          expect(response.headers['Cache-Control']).to include('private, no-store')
-        end
-      end
-
-      context 'with another user signed in' do
-        before do
-          sign_in(Fabricate(:user))
-          get :index, params: { filter_id: filter }
-        end
-
-        it 'returns http not found' do
-          expect(response).to have_http_status(404)
-        end
-      end
-    end
-  end
-end
diff --git a/spec/controllers/oauth/tokens_controller_spec.rb b/spec/controllers/oauth/tokens_controller_spec.rb
deleted file mode 100644
index a2eed797e0..0000000000
--- a/spec/controllers/oauth/tokens_controller_spec.rb
+++ /dev/null
@@ -1,23 +0,0 @@
-# frozen_string_literal: true
-
-require 'rails_helper'
-
-RSpec.describe Oauth::TokensController do
-  describe 'POST #revoke' do
-    let!(:user) { Fabricate(:user) }
-    let!(:application) { Fabricate(:application, confidential: false) }
-    let!(:access_token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, application: application) }
-    let!(:web_push_subscription) { Fabricate(:web_push_subscription, user: user, access_token: access_token) }
-
-    it 'revokes the token and removes subscriptions' do
-      post :revoke, params: { client_id: application.uid, token: access_token.token }
-
-      expect(access_token.reload.revoked_at)
-        .to_not be_nil
-      expect(Web::PushSubscription.where(access_token: access_token).count)
-        .to eq(0)
-      expect { web_push_subscription.reload }
-        .to raise_error(ActiveRecord::RecordNotFound)
-    end
-  end
-end
diff --git a/spec/controllers/settings/deletes_controller_spec.rb b/spec/controllers/settings/deletes_controller_spec.rb
deleted file mode 100644
index 98104b8454..0000000000
--- a/spec/controllers/settings/deletes_controller_spec.rb
+++ /dev/null
@@ -1,87 +0,0 @@
-# frozen_string_literal: true
-
-require 'rails_helper'
-
-RSpec.describe Settings::DeletesController do
-  render_views
-
-  describe 'GET #show' do
-    context 'when signed in' do
-      let(:user) { Fabricate(:user) }
-
-      before do
-        sign_in user, scope: :user
-        get :show
-      end
-
-      it 'renders confirmation page with private cache control headers', :aggregate_failures do
-        expect(response).to have_http_status(200)
-        expect(response.headers['Cache-Control']).to include('private, no-store')
-      end
-
-      context 'when suspended' do
-        let(:user) { Fabricate(:user, account_attributes: { suspended_at: Time.now.utc }) }
-
-        it 'returns http forbidden with private cache control headers', :aggregate_failures do
-          expect(response).to have_http_status(403)
-          expect(response.headers['Cache-Control']).to include('private, no-store')
-        end
-      end
-    end
-
-    context 'when not signed in' do
-      it 'redirects' do
-        get :show
-        expect(response).to redirect_to '/auth/sign_in'
-      end
-    end
-  end
-
-  describe 'DELETE #destroy' do
-    context 'when signed in' do
-      let(:user) { Fabricate(:user, password: 'petsmoldoggos') }
-
-      before do
-        sign_in user, scope: :user
-      end
-
-      context 'with correct password' do
-        before do
-          delete :destroy, params: { form_delete_confirmation: { password: 'petsmoldoggos' } }
-        end
-
-        it 'removes user record and redirects', :aggregate_failures, :inline_jobs do
-          expect(response).to redirect_to '/auth/sign_in'
-          expect(User.find_by(id: user.id)).to be_nil
-          expect(user.account.reload).to be_suspended
-          expect(CanonicalEmailBlock.block?(user.email)).to be false
-        end
-
-        context 'when suspended' do
-          let(:user) { Fabricate(:user, account_attributes: { suspended_at: Time.now.utc }) }
-
-          it 'returns http forbidden' do
-            expect(response).to have_http_status(403)
-          end
-        end
-      end
-
-      context 'with incorrect password' do
-        before do
-          delete :destroy, params: { form_delete_confirmation: { password: 'blaze420' } }
-        end
-
-        it 'redirects back to confirmation page' do
-          expect(response).to redirect_to settings_delete_path
-        end
-      end
-    end
-
-    context 'when not signed in' do
-      it 'redirects' do
-        delete :destroy
-        expect(response).to redirect_to '/auth/sign_in'
-      end
-    end
-  end
-end
diff --git a/spec/lib/mastodon/middleware/prometheus_queue_time_spec.rb b/spec/lib/mastodon/middleware/prometheus_queue_time_spec.rb
new file mode 100644
index 0000000000..eaab93772d
--- /dev/null
+++ b/spec/lib/mastodon/middleware/prometheus_queue_time_spec.rb
@@ -0,0 +1,32 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+require 'prometheus_exporter'
+require 'prometheus_exporter/middleware'
+require 'mastodon/middleware/prometheus_queue_time'
+
+RSpec.describe Mastodon::Middleware::PrometheusQueueTime do
+  subject { described_class.new(app, client:) }
+
+  let(:app) do
+    proc { |_env| [200, {}, 'OK'] }
+  end
+  let(:client) do
+    instance_double(PrometheusExporter::Client, send_json: true)
+  end
+
+  describe '#call' do
+    let(:env) do
+      {
+        'HTTP_X_REQUEST_START' => "t=#{(Time.now.to_f * 1000).to_i}",
+      }
+    end
+
+    it 'reports a queue time to the client' do
+      subject.call(env)
+
+      expect(client).to have_received(:send_json)
+        .with(hash_including(queue_time: instance_of(Float)))
+    end
+  end
+end
diff --git a/spec/models/media_attachment_spec.rb b/spec/models/media_attachment_spec.rb
index 5f91ae0967..43e9ed087b 100644
--- a/spec/models/media_attachment_spec.rb
+++ b/spec/models/media_attachment_spec.rb
@@ -295,12 +295,21 @@ RSpec.describe MediaAttachment, :attachment_processing do
     end
 
     it 'queues CacheBusterWorker jobs' do
-      original_path = media.file.path(:original)
-      small_path = media.file.path(:small)
+      original_url = media.file.url(:original)
+      small_url = media.file.url(:small)
 
       expect { media.destroy }
-        .to enqueue_sidekiq_job(CacheBusterWorker).with(original_path)
-        .and enqueue_sidekiq_job(CacheBusterWorker).with(small_path)
+        .to enqueue_sidekiq_job(CacheBusterWorker).with(original_url)
+        .and enqueue_sidekiq_job(CacheBusterWorker).with(small_url)
+    end
+
+    context 'with a missing remote attachment' do
+      let(:media) { Fabricate(:media_attachment, remote_url: 'https://example.com/foo.png', file: nil) }
+
+      it 'does not queue CacheBusterWorker jobs' do
+        expect { media.destroy }
+          .to_not enqueue_sidekiq_job(CacheBusterWorker)
+      end
     end
   end
 
diff --git a/spec/controllers/activitypub/collections_controller_spec.rb b/spec/requests/activitypub/collections_spec.rb
similarity index 59%
rename from spec/controllers/activitypub/collections_controller_spec.rb
rename to spec/requests/activitypub/collections_spec.rb
index 408e0dd2f6..d2761f98ea 100644
--- a/spec/controllers/activitypub/collections_controller_spec.rb
+++ b/spec/requests/activitypub/collections_spec.rb
@@ -2,22 +2,19 @@
 
 require 'rails_helper'
 
-RSpec.describe ActivityPub::CollectionsController do
+RSpec.describe 'ActivityPub Collections' do
   let!(:account) { Fabricate(:account) }
   let!(:private_pinned) { Fabricate(:status, account: account, text: 'secret private stuff', visibility: :private) }
   let(:remote_account) { nil }
 
   before do
-    allow(controller).to receive(:signed_request_actor).and_return(remote_account)
-
-    Fabricate(:status_pin, account: account)
-    Fabricate(:status_pin, account: account)
+    Fabricate.times(2, :status_pin, account: account)
     Fabricate(:status_pin, account: account, status: private_pinned)
     Fabricate(:status, account: account, visibility: :private)
   end
 
   describe 'GET #show' do
-    subject(:response) { get :show, params: { id: id, account_username: account.username } }
+    subject { get account_collection_path(id: id, account_username: account.username), headers: nil, sign_with: remote_account }
 
     context 'when id is "featured"' do
       let(:id) { 'featured' }
@@ -26,10 +23,13 @@ RSpec.describe ActivityPub::CollectionsController do
         let(:remote_account) { nil }
 
         it 'returns http success and correct media type and correct items' do
+          subject
+
           expect(response)
             .to have_http_status(200)
             .and have_cacheable_headers
-          expect(response.media_type).to eq 'application/activity+json'
+          expect(response.media_type)
+            .to eq 'application/activity+json'
 
           expect(response.parsed_body[:orderedItems])
             .to be_an(Array)
@@ -45,17 +45,21 @@ RSpec.describe ActivityPub::CollectionsController do
           end
 
           it 'returns http gone' do
-            expect(response).to have_http_status(410)
+            subject
+
+            expect(response)
+              .to have_http_status(410)
           end
         end
 
         context 'when account is temporarily suspended' do
-          before do
-            account.suspend!
-          end
+          before { account.suspend! }
 
           it 'returns http forbidden' do
-            expect(response).to have_http_status(403)
+            subject
+
+            expect(response)
+              .to have_http_status(403)
           end
         end
       end
@@ -65,11 +69,14 @@ RSpec.describe ActivityPub::CollectionsController do
 
         context 'when getting a featured resource' do
           it 'returns http success and correct media type and expected items' do
+            subject
+
             expect(response)
               .to have_http_status(200)
               .and have_cacheable_headers
 
-            expect(response.media_type).to eq 'application/activity+json'
+            expect(response.media_type)
+              .to eq 'application/activity+json'
 
             expect(response.parsed_body[:orderedItems])
               .to be_an(Array)
@@ -80,39 +87,45 @@ RSpec.describe ActivityPub::CollectionsController do
         end
 
         context 'with authorized fetch mode' do
-          before do
-            allow(controller).to receive(:authorized_fetch_mode?).and_return(true)
-          end
+          before { Setting.authorized_fetch = true }
 
           context 'when signed request account is blocked' do
-            before do
-              account.block!(remote_account)
-            end
+            before { account.block!(remote_account) }
 
             it 'returns http success and correct media type and cache headers and empty items' do
-              expect(response).to have_http_status(200)
-              expect(response.media_type).to eq 'application/activity+json'
-              expect(response.headers['Cache-Control']).to include 'private'
+              subject
 
-              expect(response.parsed_body[:orderedItems])
-                .to be_an(Array)
-                .and be_empty
+              expect(response)
+                .to have_http_status(200)
+              expect(response.media_type)
+                .to eq('application/activity+json')
+              expect(response.headers['Cache-Control'])
+                .to include('private')
+
+              expect(response.parsed_body)
+                .to include(
+                  orderedItems: be_an(Array).and(be_empty)
+                )
             end
           end
 
           context 'when signed request account is domain blocked' do
-            before do
-              account.block_domain!(remote_account.domain)
-            end
+            before { account.block_domain!(remote_account.domain) }
 
             it 'returns http success and correct media type and cache headers and empty items' do
-              expect(response).to have_http_status(200)
-              expect(response.media_type).to eq 'application/activity+json'
-              expect(response.headers['Cache-Control']).to include 'private'
+              subject
 
-              expect(response.parsed_body[:orderedItems])
-                .to be_an(Array)
-                .and be_empty
+              expect(response)
+                .to have_http_status(200)
+              expect(response.media_type)
+                .to eq('application/activity+json')
+              expect(response.headers['Cache-Control'])
+                .to include('private')
+
+              expect(response.parsed_body)
+                .to include(
+                  orderedItems: be_an(Array).and(be_empty)
+                )
             end
           end
         end
@@ -123,7 +136,10 @@ RSpec.describe ActivityPub::CollectionsController do
       let(:id) { 'hoge' }
 
       it 'returns http not found' do
-        expect(response).to have_http_status(404)
+        subject
+
+        expect(response)
+          .to have_http_status(404)
       end
     end
   end
diff --git a/spec/controllers/activitypub/followers_synchronizations_controller_spec.rb b/spec/requests/activitypub/followers_synchronizations_spec.rb
similarity index 68%
rename from spec/controllers/activitypub/followers_synchronizations_controller_spec.rb
rename to spec/requests/activitypub/followers_synchronizations_spec.rb
index cbd982f18f..97b8a7908e 100644
--- a/spec/controllers/activitypub/followers_synchronizations_controller_spec.rb
+++ b/spec/requests/activitypub/followers_synchronizations_spec.rb
@@ -2,7 +2,7 @@
 
 require 'rails_helper'
 
-RSpec.describe ActivityPub::FollowersSynchronizationsController do
+RSpec.describe 'ActivityPub Follower Synchronizations' do
   let!(:account) { Fabricate(:account) }
   let!(:follower_example_com_user_a) { Fabricate(:account, domain: 'example.com', uri: 'https://example.com/users/a') }
   let!(:follower_example_com_user_b) { Fabricate(:account, domain: 'example.com', uri: 'https://example.com/users/b') }
@@ -14,32 +14,34 @@ RSpec.describe ActivityPub::FollowersSynchronizationsController do
     follower_example_com_user_b.follow!(account)
     follower_foo_com_user_a.follow!(account)
     follower_example_com_instance_actor.follow!(account)
-
-    allow(controller).to receive(:signed_request_actor).and_return(remote_account)
   end
 
   describe 'GET #show' do
     context 'without signature' do
-      let(:remote_account) { nil }
-
-      before do
-        get :show, params: { account_username: account.username }
-      end
+      subject { get account_followers_synchronization_path(account_username: account.username) }
 
       it 'returns http not authorized' do
-        expect(response).to have_http_status(401)
+        subject
+
+        expect(response)
+          .to have_http_status(401)
       end
     end
 
     context 'with signature from example.com' do
-      subject(:response) { get :show, params: { account_username: account.username } }
+      subject { get account_followers_synchronization_path(account_username: account.username), headers: nil, sign_with: remote_account }
 
       let(:remote_account) { Fabricate(:account, domain: 'example.com', uri: 'https://example.com/instance') }
 
       it 'returns http success and cache control and activity json types and correct items' do
-        expect(response).to have_http_status(200)
-        expect(response.headers['Cache-Control']).to eq 'max-age=0, private'
-        expect(response.media_type).to eq 'application/activity+json'
+        subject
+
+        expect(response)
+          .to have_http_status(200)
+        expect(response.headers['Cache-Control'])
+          .to eq 'max-age=0, private'
+        expect(response.media_type)
+          .to eq 'application/activity+json'
 
         expect(response.parsed_body[:orderedItems])
           .to be_an(Array)
@@ -57,17 +59,21 @@ RSpec.describe ActivityPub::FollowersSynchronizationsController do
         end
 
         it 'returns http gone' do
-          expect(response).to have_http_status(410)
+          subject
+
+          expect(response)
+            .to have_http_status(410)
         end
       end
 
       context 'when account is temporarily suspended' do
-        before do
-          account.suspend!
-        end
+        before { account.suspend! }
 
         it 'returns http forbidden' do
-          expect(response).to have_http_status(403)
+          subject
+
+          expect(response)
+            .to have_http_status(403)
         end
       end
     end
diff --git a/spec/requests/activitypub/inboxes_spec.rb b/spec/requests/activitypub/inboxes_spec.rb
new file mode 100644
index 0000000000..b21881b10f
--- /dev/null
+++ b/spec/requests/activitypub/inboxes_spec.rb
@@ -0,0 +1,148 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe 'ActivityPub Inboxes' do
+  let(:remote_account) { nil }
+
+  describe 'POST #create' do
+    context 'with signature' do
+      let(:remote_account) { Fabricate(:account, domain: 'example.com', protocol: :activitypub) }
+
+      context 'without a named account' do
+        subject { post inbox_path, params: {}.to_json, sign_with: remote_account }
+
+        it 'returns http accepted' do
+          subject
+
+          expect(response)
+            .to have_http_status(202)
+        end
+      end
+
+      context 'with a specific account' do
+        subject { post account_inbox_path(account_username: account.username), params: {}.to_json, sign_with: remote_account }
+
+        let(:account) { Fabricate(:account) }
+
+        context 'when account is permanently suspended' do
+          before do
+            account.suspend!
+            account.deletion_request.destroy
+          end
+
+          it 'returns http gone' do
+            subject
+
+            expect(response)
+              .to have_http_status(410)
+          end
+        end
+
+        context 'when account is temporarily suspended' do
+          before { account.suspend! }
+
+          it 'returns http accepted' do
+            subject
+
+            expect(response)
+              .to have_http_status(202)
+          end
+        end
+      end
+    end
+
+    context 'with Collection-Synchronization header' do
+      subject { post inbox_path, params: {}.to_json, headers: { 'Collection-Synchronization' => synchronization_header }, sign_with: remote_account }
+
+      let(:remote_account) { Fabricate(:account, followers_url: 'https://example.com/followers', domain: 'example.com', uri: 'https://example.com/actor', protocol: :activitypub) }
+      let(:synchronization_collection) { remote_account.followers_url }
+      let(:synchronization_url) { 'https://example.com/followers-for-domain' }
+      let(:synchronization_hash) { 'somehash' }
+      let(:synchronization_header) { "collectionId=\"#{synchronization_collection}\", digest=\"#{synchronization_hash}\", url=\"#{synchronization_url}\"" }
+
+      before do
+        stub_follow_sync_worker
+        stub_followers_hash
+      end
+
+      context 'with mismatching target collection' do
+        let(:synchronization_collection) { 'https://example.com/followers2' }
+
+        it 'does not start a synchronization job' do
+          subject
+
+          expect(response)
+            .to have_http_status(202)
+          expect(ActivityPub::FollowersSynchronizationWorker)
+            .to_not have_received(:perform_async)
+        end
+      end
+
+      context 'with mismatching domain in partial collection attribute' do
+        let(:synchronization_url) { 'https://example.org/followers' }
+
+        it 'does not start a synchronization job' do
+          subject
+
+          expect(response)
+            .to have_http_status(202)
+          expect(ActivityPub::FollowersSynchronizationWorker)
+            .to_not have_received(:perform_async)
+        end
+      end
+
+      context 'with matching digest' do
+        it 'does not start a synchronization job' do
+          subject
+
+          expect(response)
+            .to have_http_status(202)
+          expect(ActivityPub::FollowersSynchronizationWorker)
+            .to_not have_received(:perform_async)
+        end
+      end
+
+      context 'with mismatching digest' do
+        let(:synchronization_hash) { 'wronghash' }
+
+        it 'starts a synchronization job' do
+          subject
+
+          expect(response)
+            .to have_http_status(202)
+          expect(ActivityPub::FollowersSynchronizationWorker)
+            .to have_received(:perform_async)
+        end
+      end
+
+      it 'returns http accepted' do
+        subject
+
+        expect(response)
+          .to have_http_status(202)
+      end
+
+      def stub_follow_sync_worker
+        allow(ActivityPub::FollowersSynchronizationWorker)
+          .to receive(:perform_async)
+          .and_return(nil)
+      end
+
+      def stub_followers_hash
+        Rails.cache.write("followers_hash:#{remote_account.id}:local", 'somehash') # Populate value to match request
+      end
+    end
+
+    context 'without signature' do
+      subject { post inbox_path, params: {}.to_json }
+
+      it 'returns http not authorized' do
+        subject
+
+        expect(response)
+          .to have_http_status(401)
+      end
+    end
+  end
+end
diff --git a/spec/controllers/activitypub/outboxes_controller_spec.rb b/spec/requests/activitypub/outboxes_spec.rb
similarity index 63%
rename from spec/controllers/activitypub/outboxes_controller_spec.rb
rename to spec/requests/activitypub/outboxes_spec.rb
index ca986dcabb..22b2f97c07 100644
--- a/spec/controllers/activitypub/outboxes_controller_spec.rb
+++ b/spec/requests/activitypub/outboxes_spec.rb
@@ -2,7 +2,7 @@
 
 require 'rails_helper'
 
-RSpec.describe ActivityPub::OutboxesController do
+RSpec.describe 'ActivityPub Outboxes' do
   let!(:account) { Fabricate(:account) }
 
   before do
@@ -11,13 +11,11 @@ RSpec.describe ActivityPub::OutboxesController do
     Fabricate(:status, account: account, visibility: :private)
     Fabricate(:status, account: account, visibility: :direct)
     Fabricate(:status, account: account, visibility: :limited)
-
-    allow(controller).to receive(:signed_request_actor).and_return(remote_account)
   end
 
   describe 'GET #show' do
     context 'without signature' do
-      subject(:response) { get :show, params: { account_username: account.username, page: page } }
+      subject { get account_outbox_path(account_username: account.username, page: page) }
 
       let(:remote_account) { nil }
 
@@ -25,13 +23,18 @@ RSpec.describe ActivityPub::OutboxesController do
         let(:page) { nil }
 
         it 'returns http success and correct media type and headers and items count' do
+          subject
+
           expect(response)
             .to have_http_status(200)
             .and have_cacheable_headers
 
-          expect(response.media_type).to eq 'application/activity+json'
-          expect(response.headers['Vary']).to be_nil
-          expect(response.parsed_body[:totalItems]).to eq 4
+          expect(response.media_type)
+            .to eq 'application/activity+json'
+          expect(response.headers['Vary'])
+            .to be_nil
+          expect(response.parsed_body[:totalItems])
+            .to eq 4
         end
 
         context 'when account is permanently suspended' do
@@ -41,17 +44,21 @@ RSpec.describe ActivityPub::OutboxesController do
           end
 
           it 'returns http gone' do
-            expect(response).to have_http_status(410)
+            subject
+
+            expect(response)
+              .to have_http_status(410)
           end
         end
 
         context 'when account is temporarily suspended' do
-          before do
-            account.suspend!
-          end
+          before { account.suspend! }
 
           it 'returns http forbidden' do
-            expect(response).to have_http_status(403)
+            subject
+
+            expect(response)
+              .to have_http_status(403)
           end
         end
       end
@@ -60,12 +67,16 @@ RSpec.describe ActivityPub::OutboxesController do
         let(:page) { 'true' }
 
         it 'returns http success and correct media type and vary header and items' do
+          subject
+
           expect(response)
             .to have_http_status(200)
             .and have_cacheable_headers
 
-          expect(response.media_type).to eq 'application/activity+json'
-          expect(response.headers['Vary']).to include 'Signature'
+          expect(response.media_type)
+            .to eq 'application/activity+json'
+          expect(response.headers['Vary'])
+            .to include 'Signature'
 
           expect(response.parsed_body)
             .to include(
@@ -82,35 +93,42 @@ RSpec.describe ActivityPub::OutboxesController do
           end
 
           it 'returns http gone' do
-            expect(response).to have_http_status(410)
+            subject
+
+            expect(response)
+              .to have_http_status(410)
           end
         end
 
         context 'when account is temporarily suspended' do
-          before do
-            account.suspend!
-          end
+          before { account.suspend! }
 
           it 'returns http forbidden' do
-            expect(response).to have_http_status(403)
+            subject
+
+            expect(response)
+              .to have_http_status(403)
           end
         end
       end
     end
 
     context 'with signature' do
+      subject { get account_outbox_path(account_username: account.username, page: page), headers: nil, sign_with: remote_account }
+
       let(:remote_account) { Fabricate(:account, domain: 'example.com') }
       let(:page) { 'true' }
 
       context 'when signed request account does not follow account' do
-        before do
-          get :show, params: { account_username: account.username, page: page }
-        end
-
         it 'returns http success and correct media type and headers and items' do
-          expect(response).to have_http_status(200)
-          expect(response.media_type).to eq 'application/activity+json'
-          expect(response.headers['Cache-Control']).to eq 'max-age=60, private'
+          subject
+
+          expect(response)
+            .to have_http_status(200)
+          expect(response.media_type)
+            .to eq 'application/activity+json'
+          expect(response.headers['Cache-Control'])
+            .to eq 'private, no-store'
 
           expect(response.parsed_body)
             .to include(
@@ -122,15 +140,17 @@ RSpec.describe ActivityPub::OutboxesController do
       end
 
       context 'when signed request account follows account' do
-        before do
-          remote_account.follow!(account)
-          get :show, params: { account_username: account.username, page: page }
-        end
+        before { remote_account.follow!(account) }
 
         it 'returns http success and correct media type and headers and items' do
-          expect(response).to have_http_status(200)
-          expect(response.media_type).to eq 'application/activity+json'
-          expect(response.headers['Cache-Control']).to eq 'max-age=60, private'
+          subject
+
+          expect(response)
+            .to have_http_status(200)
+          expect(response.media_type)
+            .to eq 'application/activity+json'
+          expect(response.headers['Cache-Control'])
+            .to eq 'private, no-store'
 
           expect(response.parsed_body)
             .to include(
@@ -142,15 +162,17 @@ RSpec.describe ActivityPub::OutboxesController do
       end
 
       context 'when signed request account is blocked' do
-        before do
-          account.block!(remote_account)
-          get :show, params: { account_username: account.username, page: page }
-        end
+        before { account.block!(remote_account) }
 
         it 'returns http success and correct media type and headers and items' do
-          expect(response).to have_http_status(200)
-          expect(response.media_type).to eq 'application/activity+json'
-          expect(response.headers['Cache-Control']).to eq 'max-age=60, private'
+          subject
+
+          expect(response)
+            .to have_http_status(200)
+          expect(response.media_type)
+            .to eq 'application/activity+json'
+          expect(response.headers['Cache-Control'])
+            .to eq 'private, no-store'
 
           expect(response.parsed_body)
             .to include(
@@ -160,15 +182,17 @@ RSpec.describe ActivityPub::OutboxesController do
       end
 
       context 'when signed request account is domain blocked' do
-        before do
-          account.block_domain!(remote_account.domain)
-          get :show, params: { account_username: account.username, page: page }
-        end
+        before { account.block_domain!(remote_account.domain) }
 
         it 'returns http success and correct media type and headers and items' do
-          expect(response).to have_http_status(200)
-          expect(response.media_type).to eq 'application/activity+json'
-          expect(response.headers['Cache-Control']).to eq 'max-age=60, private'
+          subject
+
+          expect(response)
+            .to have_http_status(200)
+          expect(response.media_type)
+            .to eq 'application/activity+json'
+          expect(response.headers['Cache-Control'])
+            .to eq 'private, no-store'
 
           expect(response.parsed_body)
             .to include(
diff --git a/spec/controllers/activitypub/replies_controller_spec.rb b/spec/requests/activitypub/replies_spec.rb
similarity index 78%
rename from spec/controllers/activitypub/replies_controller_spec.rb
rename to spec/requests/activitypub/replies_spec.rb
index d7c2c2d3b0..313cab2a44 100644
--- a/spec/controllers/activitypub/replies_controller_spec.rb
+++ b/spec/requests/activitypub/replies_spec.rb
@@ -2,9 +2,9 @@
 
 require 'rails_helper'
 
-RSpec.describe ActivityPub::RepliesController do
+RSpec.describe 'ActivityPub Replies' do
   let(:status) { Fabricate(:status, visibility: parent_visibility) }
-  let(:remote_account)  { Fabricate(:account, domain: 'foobar.com') }
+  let(:remote_account) { Fabricate(:account, domain: 'foobar.com') }
   let(:remote_reply_id) { 'https://foobar.com/statuses/1234' }
   let(:remote_querier) { nil }
 
@@ -13,7 +13,10 @@ RSpec.describe ActivityPub::RepliesController do
       let(:parent_visibility) { :private }
 
       it 'returns http not found' do
-        expect(response).to have_http_status(404)
+        subject
+
+        expect(response)
+          .to have_http_status(404)
       end
     end
 
@@ -21,7 +24,10 @@ RSpec.describe ActivityPub::RepliesController do
       let(:parent_visibility) { :direct }
 
       it 'returns http not found' do
-        expect(response).to have_http_status(404)
+        subject
+
+        expect(response)
+          .to have_http_status(404)
       end
     end
   end
@@ -31,7 +37,10 @@ RSpec.describe ActivityPub::RepliesController do
       let(:parent_visibility) { :public }
 
       it 'returns http not found' do
-        expect(response).to have_http_status(404)
+        subject
+
+        expect(response)
+          .to have_http_status(404)
       end
     end
 
@@ -48,19 +57,23 @@ RSpec.describe ActivityPub::RepliesController do
       end
 
       it 'returns http gone' do
-        expect(response).to have_http_status(410)
+        subject
+
+        expect(response)
+          .to have_http_status(410)
       end
     end
 
     context 'when account is temporarily suspended' do
       let(:parent_visibility) { :public }
 
-      before do
-        status.account.suspend!
-      end
+      before { status.account.suspend! }
 
       it 'returns http forbidden' do
-        expect(response).to have_http_status(403)
+        subject
+
+        expect(response)
+          .to have_http_status(403)
       end
     end
 
@@ -68,15 +81,20 @@ RSpec.describe ActivityPub::RepliesController do
       let(:parent_visibility) { :public }
 
       it 'returns http success and correct media type' do
+        subject
+
         expect(response)
           .to have_http_status(200)
           .and have_cacheable_headers
 
-        expect(response.media_type).to eq 'application/activity+json'
+        expect(response.media_type)
+          .to eq 'application/activity+json'
       end
 
-      context 'without only_other_accounts' do
+      context 'without `only_other_accounts` param' do
         it "returns items with thread author's replies" do
+          subject
+
           expect(response.parsed_body)
             .to include(
               first: be_a(Hash).and(
@@ -91,6 +109,8 @@ RSpec.describe ActivityPub::RepliesController do
 
         context 'when there are few self-replies' do
           it 'points next to replies from other people' do
+            subject
+
             expect(response.parsed_body)
               .to include(
                 first: be_a(Hash).and(
@@ -108,6 +128,8 @@ RSpec.describe ActivityPub::RepliesController do
           end
 
           it 'points next to other self-replies' do
+            subject
+
             expect(response.parsed_body)
               .to include(
                 first: be_a(Hash).and(
@@ -120,31 +142,33 @@ RSpec.describe ActivityPub::RepliesController do
         end
       end
 
-      context 'with only_other_accounts' do
+      context 'with `only_other_accounts` param' do
         let(:only_other_accounts) { 'true' }
 
-        it 'returns items with other public or unlisted replies' do
+        it 'returns items with other public or unlisted replies and correctly inlines replies and uses IDs' do
+          subject
+
           expect(response.parsed_body)
             .to include(
               first: be_a(Hash).and(
                 include(items: be_an(Array).and(have_attributes(size: 3)))
               )
             )
-        end
 
-        it 'only inlines items that are local and public or unlisted replies' do
+          # Only inline replies that are local and public, or unlisted
           expect(inlined_replies)
             .to all(satisfy { |item| targets_public_collection?(item) })
             .and all(satisfy { |item| ActivityPub::TagManager.instance.local_uri?(item[:id]) })
-        end
 
-        it 'uses ids for remote toots' do
+          # Use ids for remote replies
           expect(remote_replies)
             .to all(satisfy { |item| item.is_a?(String) && !ActivityPub::TagManager.instance.local_uri?(item) })
         end
 
         context 'when there are few replies' do
           it 'does not have a next page' do
+            subject
+
             expect(response.parsed_body)
               .to include(
                 first: be_a(Hash).and(not_include(next: be_present))
@@ -158,6 +182,8 @@ RSpec.describe ActivityPub::RepliesController do
           end
 
           it 'points next to other replies' do
+            subject
+
             expect(response.parsed_body)
               .to include(
                 first: be_a(Hash).and(
@@ -176,10 +202,8 @@ RSpec.describe ActivityPub::RepliesController do
 
   before do
     stub_const 'ActivityPub::RepliesController::DESCENDANTS_LIMIT', 5
-    allow(controller).to receive(:signed_request_actor).and_return(remote_querier)
 
-    Fabricate(:status, thread: status, visibility: :public)
-    Fabricate(:status, thread: status, visibility: :public)
+    Fabricate.times(2, :status, thread: status, visibility: :public)
     Fabricate(:status, thread: status, visibility: :private)
     Fabricate(:status, account: status.account, thread: status, visibility: :public)
     Fabricate(:status, account: status.account, thread: status, visibility: :private)
@@ -188,31 +212,29 @@ RSpec.describe ActivityPub::RepliesController do
   end
 
   describe 'GET #index' do
-    subject(:response) { get :index, params: { account_username: status.account.username, status_id: status.id, only_other_accounts: only_other_accounts } }
-
     let(:only_other_accounts) { nil }
 
     context 'with no signature' do
+      subject { get account_status_replies_path(account_username: status.account.username, status_id: status.id, only_other_accounts: only_other_accounts) }
+
       it_behaves_like 'allowed access'
     end
 
     context 'with signature' do
+      subject { get account_status_replies_path(account_username: status.account.username, status_id: status.id, only_other_accounts: only_other_accounts), headers: nil, sign_with: remote_querier }
+
       let(:remote_querier) { Fabricate(:account, domain: 'example.com') }
 
       it_behaves_like 'allowed access'
 
       context 'when signed request account is blocked' do
-        before do
-          status.account.block!(remote_querier)
-        end
+        before { status.account.block!(remote_querier) }
 
         it_behaves_like 'disallowed access'
       end
 
       context 'when signed request account is domain blocked' do
-        before do
-          status.account.block_domain!(remote_querier.domain)
-        end
+        before { status.account.block_domain!(remote_querier.domain) }
 
         it_behaves_like 'disallowed access'
       end
diff --git a/spec/requests/api/v1/accounts_spec.rb b/spec/requests/api/v1/accounts_spec.rb
index d423a08f12..329bb5f1e4 100644
--- a/spec/requests/api/v1/accounts_spec.rb
+++ b/spec/requests/api/v1/accounts_spec.rb
@@ -74,12 +74,45 @@ RSpec.describe '/api/v1/accounts' do
 
   describe 'POST /api/v1/accounts' do
     subject do
-      post '/api/v1/accounts', headers: headers, params: { username: 'test', password: '12345678', email: 'hello@world.tld', agreement: agreement }
+      post '/api/v1/accounts', headers: headers, params: { username: 'test', password: '12345678', email: 'hello@world.tld', agreement: agreement, date_of_birth: date_of_birth }
     end
 
     let(:client_app) { Fabricate(:application) }
     let(:token) { Doorkeeper::AccessToken.find_or_create_for(application: client_app, resource_owner: nil, scopes: 'read write', use_refresh_token: false) }
     let(:agreement) { nil }
+    let(:date_of_birth) { nil }
+
+    context 'when age verification is enabled' do
+      before do
+        Setting.min_age = 16
+      end
+
+      let(:agreement) { 'true' }
+
+      context 'when date of birth is below age limit' do
+        let(:date_of_birth) { 13.years.ago.strftime('%d.%m.%Y') }
+
+        it 'returns http unprocessable entity' do
+          subject
+
+          expect(response).to have_http_status(422)
+          expect(response.content_type)
+            .to start_with('application/json')
+        end
+      end
+
+      context 'when date of birth is over age limit' do
+        let(:date_of_birth) { 17.years.ago.strftime('%d.%m.%Y') }
+
+        it 'creates a user', :aggregate_failures do
+          subject
+
+          expect(response).to have_http_status(200)
+          expect(response.content_type)
+            .to start_with('application/json')
+        end
+      end
+    end
 
     context 'when given truthy agreement' do
       let(:agreement) { 'true' }
diff --git a/spec/requests/api/v1/trends/tags_spec.rb b/spec/requests/api/v1/trends/tags_spec.rb
index 14ab73fc96..097393e58d 100644
--- a/spec/requests/api/v1/trends/tags_spec.rb
+++ b/spec/requests/api/v1/trends/tags_spec.rb
@@ -15,6 +15,8 @@ RSpec.describe 'API V1 Trends Tags' do
           .and not_have_http_link_header
         expect(response.content_type)
           .to start_with('application/json')
+        expect(response.headers['Deprecation'])
+          .to be_nil
       end
     end
 
@@ -31,6 +33,8 @@ RSpec.describe 'API V1 Trends Tags' do
           .and have_http_link_header(api_v1_trends_tags_url(offset: 2)).for(rel: 'next')
         expect(response.content_type)
           .to start_with('application/json')
+        expect(response.headers['Deprecation'])
+          .to be_nil
       end
 
       def prepare_trends
diff --git a/spec/requests/api/v1/trends_spec.rb b/spec/requests/api/v1/trends_spec.rb
new file mode 100644
index 0000000000..5bfabdca1c
--- /dev/null
+++ b/spec/requests/api/v1/trends_spec.rb
@@ -0,0 +1,48 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe 'deprecated API V1 Trends Tags' do
+  describe 'GET /api/v1/trends' do
+    context 'when trends are disabled' do
+      before { Setting.trends = false }
+
+      it 'returns http success' do
+        get '/api/v1/trends'
+
+        expect(response)
+          .to have_http_status(200)
+          .and not_have_http_link_header
+        expect(response.content_type)
+          .to start_with('application/json')
+        expect(response.headers['Deprecation'])
+          .to start_with '@'
+      end
+    end
+
+    context 'when trends are enabled' do
+      before { Setting.trends = true }
+
+      it 'returns http success' do
+        prepare_trends
+        stub_const('Api::V1::Trends::TagsController::DEFAULT_TAGS_LIMIT', 2)
+        get '/api/v1/trends'
+
+        expect(response)
+          .to have_http_status(200)
+          .and have_http_link_header(api_v1_trends_tags_url(offset: 2)).for(rel: 'next')
+        expect(response.content_type)
+          .to start_with('application/json')
+        expect(response.headers['Deprecation'])
+          .to start_with '@'
+      end
+
+      def prepare_trends
+        Fabricate.times(3, :tag, trendable: true).each do |tag|
+          2.times { |i| Trends.tags.add(tag, i) }
+        end
+        Trends::Tags.new(threshold: 1).refresh
+      end
+    end
+  end
+end
diff --git a/spec/requests/api/v2/notifications_spec.rb b/spec/requests/api/v2/notifications_spec.rb
index a7608e1419..69feb6cb6e 100644
--- a/spec/requests/api/v2/notifications_spec.rb
+++ b/spec/requests/api/v2/notifications_spec.rb
@@ -158,19 +158,18 @@ RSpec.describe 'Notifications' do
           expect(response).to have_http_status(200)
           expect(response.content_type)
             .to start_with('application/json')
-          expect(response.parsed_body[:notification_groups]).to contain_exactly(
-            a_hash_including(
-              type: 'favourite',
-              sample_account_ids: have_attributes(size: 5),
-              page_min_id: notification_ids.first.to_s,
-              page_max_id: notification_ids.last.to_s
-            )
-          )
+          expect(response.parsed_body[:notification_groups].size)
+            .to eq(1)
+          expect(response.parsed_body.dig(:notification_groups, 0))
+            .to include(type: 'favourite')
+            .and(include(sample_account_ids: have_attributes(size: 5)))
+            .and(include(page_max_id: notification_ids.last.to_s))
+            .and(include(page_min_id: notification_ids.first.to_s))
         end
       end
 
       context 'with min_id param' do
-        let(:params) { { min_id: user.account.notifications.reload.first.id - 1 } }
+        let(:params) { { min_id: user.account.notifications.order(id: :asc).first.id - 1 } }
 
         it 'returns a notification group covering all notifications' do
           subject
@@ -180,14 +179,13 @@ RSpec.describe 'Notifications' do
           expect(response).to have_http_status(200)
           expect(response.content_type)
             .to start_with('application/json')
-          expect(response.parsed_body[:notification_groups]).to contain_exactly(
-            a_hash_including(
-              type: 'favourite',
-              sample_account_ids: have_attributes(size: 5),
-              page_min_id: notification_ids.first.to_s,
-              page_max_id: notification_ids.last.to_s
-            )
-          )
+          expect(response.parsed_body[:notification_groups].size)
+            .to eq(1)
+          expect(response.parsed_body.dig(:notification_groups, 0))
+            .to include(type: 'favourite')
+            .and(include(sample_account_ids: have_attributes(size: 5)))
+            .and(include(page_max_id: notification_ids.last.to_s))
+            .and(include(page_min_id: notification_ids.first.to_s))
         end
       end
     end
diff --git a/spec/requests/disputes/strikes_spec.rb b/spec/requests/disputes/strikes_spec.rb
new file mode 100644
index 0000000000..48685893c2
--- /dev/null
+++ b/spec/requests/disputes/strikes_spec.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe 'Disputes Strikes' do
+  before { sign_in current_user }
+
+  describe 'GET /disputes/strikes/:id' do
+    let(:current_user) { Fabricate(:user) }
+
+    context 'when meant for a different user' do
+      let(:strike) { Fabricate(:account_warning) }
+
+      it 'returns http forbidden' do
+        get disputes_strike_path(strike)
+
+        expect(response)
+          .to have_http_status(403)
+      end
+    end
+  end
+end
diff --git a/spec/requests/filters/statuses_spec.rb b/spec/requests/filters/statuses_spec.rb
index aa1d049da7..b462b56223 100644
--- a/spec/requests/filters/statuses_spec.rb
+++ b/spec/requests/filters/statuses_spec.rb
@@ -16,4 +16,30 @@ RSpec.describe 'Filters Statuses' do
         .to redirect_to(edit_filter_path(filter))
     end
   end
+
+  describe 'GET /filters/:filter_id/statuses' do
+    let(:filter) { Fabricate(:custom_filter) }
+
+    context 'with signed out user' do
+      it 'redirects' do
+        get filter_statuses_path(filter)
+
+        expect(response)
+          .to be_redirect
+      end
+    end
+
+    context 'with a signed in user' do
+      context 'with another user signed in' do
+        before { sign_in(Fabricate(:user)) }
+
+        it 'returns http not found' do
+          get filter_statuses_path(filter)
+
+          expect(response)
+            .to have_http_status(404)
+        end
+      end
+    end
+  end
 end
diff --git a/spec/controllers/intents_controller_spec.rb b/spec/requests/intents_spec.rb
similarity index 90%
rename from spec/controllers/intents_controller_spec.rb
rename to spec/requests/intents_spec.rb
index 668d833ea7..b62f570d7a 100644
--- a/spec/controllers/intents_controller_spec.rb
+++ b/spec/requests/intents_spec.rb
@@ -2,15 +2,15 @@
 
 require 'rails_helper'
 
-RSpec.describe IntentsController do
-  render_views
-
+RSpec.describe 'Intents' do
   let(:user) { Fabricate(:user) }
 
   before { sign_in user, scope: :user }
 
-  describe 'GET #show' do
-    subject { get :show, params: { uri: uri } }
+  describe 'GET /intent' do
+    subject { response }
+
+    before { get intent_path(uri: uri) }
 
     context 'when schema is web+mastodon' do
       context 'when host is follow' do
diff --git a/spec/requests/oauth/token_spec.rb b/spec/requests/oauth/token_spec.rb
index 18d232e5ab..74f301c577 100644
--- a/spec/requests/oauth/token_spec.rb
+++ b/spec/requests/oauth/token_spec.rb
@@ -2,7 +2,7 @@
 
 require 'rails_helper'
 
-RSpec.describe 'Obtaining OAuth Tokens' do
+RSpec.describe 'Managing OAuth Tokens' do
   describe 'POST /oauth/token' do
     subject do
       post '/oauth/token', params: params
@@ -104,4 +104,23 @@ RSpec.describe 'Obtaining OAuth Tokens' do
       end
     end
   end
+
+  describe 'POST /oauth/revoke' do
+    subject { post '/oauth/revoke', params: { client_id: application.uid, token: access_token.token } }
+
+    let!(:user) { Fabricate(:user) }
+    let!(:application) { Fabricate(:application, confidential: false) }
+    let!(:access_token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, application: application) }
+    let!(:web_push_subscription) { Fabricate(:web_push_subscription, user: user, access_token: access_token) }
+
+    it 'revokes the token and removes subscriptions' do
+      expect { subject }
+        .to change { access_token.reload.revoked_at }.from(nil).to(be_present)
+
+      expect(Web::PushSubscription.where(access_token: access_token).count)
+        .to eq(0)
+      expect { web_push_subscription.reload }
+        .to raise_error(ActiveRecord::RecordNotFound)
+    end
+  end
 end
diff --git a/spec/requests/settings/deletes_spec.rb b/spec/requests/settings/deletes_spec.rb
index 4563f639d5..c277181999 100644
--- a/spec/requests/settings/deletes_spec.rb
+++ b/spec/requests/settings/deletes_spec.rb
@@ -4,13 +4,65 @@ require 'rails_helper'
 
 RSpec.describe 'Settings Deletes' do
   describe 'DELETE /settings/delete' do
-    before { sign_in Fabricate(:user) }
+    context 'when signed in' do
+      before { sign_in(user) }
 
-    it 'gracefully handles invalid nested params' do
-      delete settings_delete_path(form_delete_confirmation: 'invalid')
+      let(:user) { Fabricate(:user) }
 
-      expect(response)
-        .to have_http_status(400)
+      it 'gracefully handles invalid nested params' do
+        delete settings_delete_path(form_delete_confirmation: 'invalid')
+
+        expect(response)
+          .to have_http_status(400)
+      end
+
+      context 'when suspended' do
+        let(:user) { Fabricate(:user, account_attributes: { suspended_at: Time.now.utc }) }
+
+        it 'returns http forbidden' do
+          delete settings_delete_path
+
+          expect(response)
+            .to have_http_status(403)
+        end
+      end
+    end
+
+    context 'when not signed in' do
+      it 'redirects to sign in' do
+        delete settings_delete_path
+
+        expect(response)
+          .to redirect_to(new_user_session_path)
+      end
+    end
+  end
+
+  describe 'GET /settings/delete' do
+    context 'when signed in' do
+      before { sign_in(user) }
+
+      context 'when suspended' do
+        let(:user) { Fabricate(:user, account_attributes: { suspended_at: Time.now.utc }) }
+
+        it 'returns http forbidden with private cache control headers' do
+          get settings_delete_path
+
+          expect(response)
+            .to have_http_status(403)
+          expect(response.headers['Cache-Control'])
+            .to include('private, no-store')
+        end
+      end
+    end
+
+    context 'when not signed in' do
+      it 'redirects to sign in' do
+        get settings_delete_path
+
+        expect(response)
+          .to redirect_to(new_user_session_path)
+      end
     end
   end
 end
diff --git a/spec/services/activitypub/synchronize_followers_service_spec.rb b/spec/services/activitypub/synchronize_followers_service_spec.rb
index 974368b7d7..70f27627e1 100644
--- a/spec/services/activitypub/synchronize_followers_service_spec.rb
+++ b/spec/services/activitypub/synchronize_followers_service_spec.rb
@@ -10,7 +10,7 @@ RSpec.describe ActivityPub::SynchronizeFollowersService do
   let(:bob)            { Fabricate(:account, username: 'bob') }
   let(:eve)            { Fabricate(:account, username: 'eve') }
   let(:mallory)        { Fabricate(:account, username: 'mallory') }
-  let(:collection_uri) { 'http://example.com/partial-followers' }
+  let(:collection_uri) { 'https://example.com/partial-followers' }
 
   let(:items) do
     [alice, eve, mallory].map do |account|
@@ -27,14 +27,14 @@ RSpec.describe ActivityPub::SynchronizeFollowersService do
     }.with_indifferent_access
   end
 
+  before do
+    alice.follow!(actor)
+    bob.follow!(actor)
+    mallory.request_follow!(actor)
+  end
+
   shared_examples 'synchronizes followers' do
     before do
-      alice.follow!(actor)
-      bob.follow!(actor)
-      mallory.request_follow!(actor)
-
-      allow(ActivityPub::DeliveryWorker).to receive(:perform_async)
-
       subject.call(actor, collection_uri)
     end
 
@@ -46,7 +46,7 @@ RSpec.describe ActivityPub::SynchronizeFollowersService do
       expect(mallory)
         .to be_following(actor) # Convert follow request to follow when accepted
       expect(ActivityPub::DeliveryWorker)
-        .to have_received(:perform_async).with(anything, eve.id, actor.inbox_url) # Send Undo Follow to actor
+        .to have_enqueued_sidekiq_job(anything, eve.id, actor.inbox_url) # Send Undo Follow to actor
     end
   end
 
@@ -76,7 +76,7 @@ RSpec.describe ActivityPub::SynchronizeFollowersService do
       it_behaves_like 'synchronizes followers'
     end
 
-    context 'when the endpoint is a paginated Collection of actor URIs' do
+    context 'when the endpoint is a single-page paginated Collection of actor URIs' do
       let(:payload) do
         {
           '@context': 'https://www.w3.org/ns/activitystreams',
@@ -96,5 +96,106 @@ RSpec.describe ActivityPub::SynchronizeFollowersService do
 
       it_behaves_like 'synchronizes followers'
     end
+
+    context 'when the endpoint is a paginated Collection of actor URIs split across multiple pages' do
+      before do
+        stub_request(:get, 'https://example.com/partial-followers')
+          .to_return(status: 200, headers: { 'Content-Type': 'application/activity+json' }, body: Oj.dump({
+            '@context': 'https://www.w3.org/ns/activitystreams',
+            type: 'Collection',
+            id: 'https://example.com/partial-followers',
+            first: 'https://example.com/partial-followers/1',
+          }))
+
+        stub_request(:get, 'https://example.com/partial-followers/1')
+          .to_return(status: 200, headers: { 'Content-Type': 'application/activity+json' }, body: Oj.dump({
+            '@context': 'https://www.w3.org/ns/activitystreams',
+            type: 'CollectionPage',
+            id: 'https://example.com/partial-followers/1',
+            partOf: 'https://example.com/partial-followers',
+            next: 'https://example.com/partial-followers/2',
+            items: [alice, eve].map { |account| ActivityPub::TagManager.instance.uri_for(account) },
+          }))
+
+        stub_request(:get, 'https://example.com/partial-followers/2')
+          .to_return(status: 200, headers: { 'Content-Type': 'application/activity+json' }, body: Oj.dump({
+            '@context': 'https://www.w3.org/ns/activitystreams',
+            type: 'CollectionPage',
+            id: 'https://example.com/partial-followers/2',
+            partOf: 'https://example.com/partial-followers',
+            items: ActivityPub::TagManager.instance.uri_for(mallory),
+          }))
+      end
+
+      it_behaves_like 'synchronizes followers'
+    end
+
+    context 'when the endpoint is a paginated Collection of actor URIs split across, but one page errors out' do
+      before do
+        stub_request(:get, 'https://example.com/partial-followers')
+          .to_return(status: 200, headers: { 'Content-Type': 'application/activity+json' }, body: Oj.dump({
+            '@context': 'https://www.w3.org/ns/activitystreams',
+            type: 'Collection',
+            id: 'https://example.com/partial-followers',
+            first: 'https://example.com/partial-followers/1',
+          }))
+
+        stub_request(:get, 'https://example.com/partial-followers/1')
+          .to_return(status: 200, headers: { 'Content-Type': 'application/activity+json' }, body: Oj.dump({
+            '@context': 'https://www.w3.org/ns/activitystreams',
+            type: 'CollectionPage',
+            id: 'https://example.com/partial-followers/1',
+            partOf: 'https://example.com/partial-followers',
+            next: 'https://example.com/partial-followers/2',
+            items: [mallory].map { |account| ActivityPub::TagManager.instance.uri_for(account) },
+          }))
+
+        stub_request(:get, 'https://example.com/partial-followers/2')
+          .to_return(status: 404)
+      end
+
+      it 'confirms pending follow request but does not remove extra followers' do
+        previous_follower_ids = actor.followers.pluck(:id)
+
+        subject.call(actor, collection_uri)
+
+        expect(previous_follower_ids - actor.followers.reload.pluck(:id))
+          .to be_empty
+        expect(mallory)
+          .to be_following(actor)
+      end
+    end
+
+    context 'when the endpoint is a paginated Collection of actor URIs with more pages than we allow' do
+      let(:payload) do
+        {
+          '@context': 'https://www.w3.org/ns/activitystreams',
+          type: 'Collection',
+          id: collection_uri,
+          first: {
+            type: 'CollectionPage',
+            partOf: collection_uri,
+            items: items,
+            next: "#{collection_uri}/page2",
+          },
+        }.with_indifferent_access
+      end
+
+      before do
+        stub_const('ActivityPub::SynchronizeFollowersService::MAX_COLLECTION_PAGES', 1)
+        stub_request(:get, collection_uri).to_return(status: 200, body: Oj.dump(payload), headers: { 'Content-Type': 'application/activity+json' })
+      end
+
+      it 'confirms pending follow request but does not remove extra followers' do
+        previous_follower_ids = actor.followers.pluck(:id)
+
+        subject.call(actor, collection_uri)
+
+        expect(previous_follower_ids - actor.followers.reload.pluck(:id))
+          .to be_empty
+        expect(mallory)
+          .to be_following(actor)
+      end
+    end
   end
 end
diff --git a/spec/services/suspend_account_service_spec.rb b/spec/services/suspend_account_service_spec.rb
index 4a2f494e0c..c15c23ca30 100644
--- a/spec/services/suspend_account_service_spec.rb
+++ b/spec/services/suspend_account_service_spec.rb
@@ -2,7 +2,7 @@
 
 require 'rails_helper'
 
-RSpec.describe SuspendAccountService, :inline_jobs do
+RSpec.describe SuspendAccountService do
   shared_examples 'common behavior' do
     subject { described_class.new.call(account) }
 
@@ -11,6 +11,7 @@ RSpec.describe SuspendAccountService, :inline_jobs do
 
     before do
       allow(FeedManager.instance).to receive_messages(unmerge_from_home: nil, unmerge_from_list: nil)
+      allow(Rails.configuration.x).to receive(:cache_buster_enabled).and_return(true)
 
       local_follower.follow!(account)
       list.accounts << account
@@ -23,6 +24,7 @@ RSpec.describe SuspendAccountService, :inline_jobs do
     it 'unmerges from feeds of local followers and changes file mode and preserves suspended flag' do
       expect { subject }
         .to change_file_mode
+        .and enqueue_sidekiq_job(CacheBusterWorker).with(account.media_attachments.first.file.url(:original))
         .and not_change_suspended_flag
       expect(FeedManager.instance).to have_received(:unmerge_from_home).with(account, local_follower)
       expect(FeedManager.instance).to have_received(:unmerge_from_list).with(account, list)
@@ -38,17 +40,12 @@ RSpec.describe SuspendAccountService, :inline_jobs do
   end
 
   describe 'suspending a local account' do
-    def match_update_actor_request(req, account)
-      json = JSON.parse(req.body)
+    def match_update_actor_request(json, account)
+      json = JSON.parse(json)
       actor_id = ActivityPub::TagManager.instance.uri_for(account)
       json['type'] == 'Update' && json['actor'] == actor_id && json['object']['id'] == actor_id && json['object']['suspended']
     end
 
-    before do
-      stub_request(:post, 'https://alice.com/inbox').to_return(status: 201)
-      stub_request(:post, 'https://bob.com/inbox').to_return(status: 201)
-    end
-
     include_examples 'common behavior' do
       let!(:account)         { Fabricate(:account) }
       let!(:remote_follower) { Fabricate(:account, uri: 'https://alice.com', inbox_url: 'https://alice.com/inbox', protocol: :activitypub, domain: 'alice.com') }
@@ -61,22 +58,20 @@ RSpec.describe SuspendAccountService, :inline_jobs do
 
       it 'sends an Update actor activity to followers and reporters' do
         subject
-        expect(a_request(:post, remote_follower.inbox_url).with { |req| match_update_actor_request(req, account) }).to have_been_made.once
-        expect(a_request(:post, remote_reporter.inbox_url).with { |req| match_update_actor_request(req, account) }).to have_been_made.once
+
+        expect(ActivityPub::DeliveryWorker)
+          .to have_enqueued_sidekiq_job(satisfying { |json| match_update_actor_request(json, account) }, account.id, remote_follower.inbox_url).once
+          .and have_enqueued_sidekiq_job(satisfying { |json| match_update_actor_request(json, account) }, account.id, remote_reporter.inbox_url).once
       end
     end
   end
 
   describe 'suspending a remote account' do
-    def match_reject_follow_request(req, account, followee)
-      json = JSON.parse(req.body)
+    def match_reject_follow_request(json, account, followee)
+      json = JSON.parse(json)
       json['type'] == 'Reject' && json['actor'] == ActivityPub::TagManager.instance.uri_for(followee) && json['object']['actor'] == account.uri
     end
 
-    before do
-      stub_request(:post, 'https://bob.com/inbox').to_return(status: 201)
-    end
-
     include_examples 'common behavior' do
       let!(:account)        { Fabricate(:account, domain: 'bob.com', uri: 'https://bob.com', inbox_url: 'https://bob.com/inbox', protocol: :activitypub) }
       let!(:local_followee) { Fabricate(:account) }
@@ -88,7 +83,8 @@ RSpec.describe SuspendAccountService, :inline_jobs do
       it 'sends a Reject Follow activity', :aggregate_failures do
         subject
 
-        expect(a_request(:post, account.inbox_url).with { |req| match_reject_follow_request(req, account, local_followee) }).to have_been_made.once
+        expect(ActivityPub::DeliveryWorker)
+          .to have_enqueued_sidekiq_job(satisfying { |json| match_reject_follow_request(json, account, local_followee) }, local_followee.id, account.inbox_url).once
       end
     end
   end
diff --git a/spec/support/signed_request_helpers.rb b/spec/support/signed_request_helpers.rb
index 8a52179cae..a4423af748 100644
--- a/spec/support/signed_request_helpers.rb
+++ b/spec/support/signed_request_helpers.rb
@@ -18,4 +18,24 @@ module SignedRequestHelpers
 
     super(path, headers: headers, **args)
   end
+
+  def post(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'] = Rails.configuration.x.local_domain
+    headers['Digest'] = "SHA-256=#{Digest::SHA256.base64digest(args[:params].to_s)}"
+
+    signed_headers = headers.merge('(request-target)' => "post #{path}").slice('(request-target)', 'Host', 'Date', 'Digest')
+
+    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
diff --git a/spec/system/account_notes_spec.rb b/spec/system/account_notes_spec.rb
new file mode 100644
index 0000000000..4677068f6a
--- /dev/null
+++ b/spec/system/account_notes_spec.rb
@@ -0,0 +1,53 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe 'Account notes', :inline_jobs, :js, :streaming do
+  include ProfileStories
+
+  let(:email)               { 'test@example.com' }
+  let(:password)            { 'password' }
+  let(:confirmed_at)        { Time.zone.now }
+  let(:finished_onboarding) { true }
+
+  let!(:other_account) { Fabricate(:account) }
+
+  before { as_a_logged_in_user }
+
+  it 'can be written and viewed' do
+    visit_profile(other_account)
+
+    note_text = 'This is a personal note'
+    fill_in 'Click to add note', with: note_text
+
+    # This is a bit awkward since there is no button to save the change
+    # The easiest way is to send ctrl+enter ourselves
+    find_field(class: 'account__header__account-note__content').send_keys [:control, :enter]
+
+    within('.account__header__account-note .inline-alert') do
+      expect(page)
+        .to have_content('SAVED')
+    end
+
+    expect(page)
+      .to have_css('.account__header__account-note__content', text: note_text)
+
+    # Navigate back and forth and ensure the comment is still here
+    visit root_url
+    visit_profile(other_account)
+
+    expect(AccountNote.find_by(account: bob.account, target_account: other_account).comment)
+      .to eq note_text
+
+    expect(page)
+      .to have_css('.account__header__account-note__content', text: note_text)
+  end
+
+  def visit_profile(account)
+    visit short_account_path(account)
+
+    expect(page)
+      .to have_css('div.app-holder')
+      .and have_css('form.compose-form')
+  end
+end
diff --git a/spec/system/admin/account_actions_spec.rb b/spec/system/admin/account_actions_spec.rb
new file mode 100644
index 0000000000..787b988a0d
--- /dev/null
+++ b/spec/system/admin/account_actions_spec.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe 'Admin Account Actions' do
+  let(:user) { Fabricate(:admin_user) }
+
+  before { sign_in user }
+
+  describe 'Creating a new account action on an account' do
+    let(:account) { Fabricate(:account) }
+
+    it 'creates the action and redirects to the account page' do
+      visit new_admin_account_action_path(account_id: account.id)
+      expect(page)
+        .to have_title(I18n.t('admin.account_actions.title', acct: account.pretty_acct))
+
+      choose(option: 'silence')
+      expect { submit_form }
+        .to change { account.strikes.count }.by(1)
+      expect(page)
+        .to have_title(account.pretty_acct)
+    end
+
+    def submit_form
+      click_on I18n.t('admin.account_actions.action')
+    end
+  end
+end
diff --git a/spec/controllers/admin/action_logs_controller_spec.rb b/spec/system/admin/action_logs_spec.rb
similarity index 65%
rename from spec/controllers/admin/action_logs_controller_spec.rb
rename to spec/system/admin/action_logs_spec.rb
index d3108e8055..b6a6996f91 100644
--- a/spec/controllers/admin/action_logs_controller_spec.rb
+++ b/spec/system/admin/action_logs_spec.rb
@@ -2,29 +2,33 @@
 
 require 'rails_helper'
 
-RSpec.describe Admin::ActionLogsController do
-  render_views
-
+RSpec.describe 'Admin Action Logs' do
   # Action logs typically cause issues when their targets are not in the database
   let!(:account) { Fabricate(:account) }
 
   before do
-    orphaned_log_types.map do |type|
-      Fabricate(:action_log, account: account, action: 'destroy', target_type: type, target_id: 1312)
-    end
+    populate_action_logs
+    sign_in Fabricate(:admin_user)
   end
 
-  describe 'GET #index' do
-    it 'returns 200' do
-      sign_in Fabricate(:admin_user)
-      get :index, params: { page: 1 }
+  describe 'Viewing action logs' do
+    it 'shows page with action logs listed' do
+      visit admin_action_logs_path
 
-      expect(response).to have_http_status(200)
+      expect(page)
+        .to have_title(I18n.t('admin.action_logs.title'))
+        .and have_css('.log-entry')
     end
   end
 
   private
 
+  def populate_action_logs
+    orphaned_log_types.map do |type|
+      Fabricate(:action_log, account: account, action: 'destroy', target_type: type, target_id: 1312)
+    end
+  end
+
   def orphaned_log_types
     %w(
       Account
diff --git a/spec/system/admin/change_emails_spec.rb b/spec/system/admin/change_emails_spec.rb
new file mode 100644
index 0000000000..6592ddff7c
--- /dev/null
+++ b/spec/system/admin/change_emails_spec.rb
@@ -0,0 +1,35 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe 'Admin Change Emails' do
+  let(:admin) { Fabricate(:admin_user) }
+
+  before { sign_in admin }
+
+  describe 'Changing the email address for a user', :inline_jobs do
+    let(:user) { Fabricate :user }
+
+    it 'updates user details and sends email' do
+      visit admin_account_change_email_path(user.account.id)
+      expect(page)
+        .to have_title(I18n.t('admin.accounts.change_email.title', username: user.account.username))
+
+      fill_in 'user_unconfirmed_email', with: 'test@host.example'
+      emails = capture_emails { process_change_email }
+      expect(emails.first)
+        .to be_present
+        .and(deliver_to('test@host.example'))
+        .and(have_subject(/Confirm email/))
+      expect(page)
+        .to have_title(user.account.pretty_acct)
+    end
+
+    def process_change_email
+      expect { click_on I18n.t('admin.accounts.change_email.submit') }
+        .to not_change { user.reload.email }
+        .and(change { user.reload.unconfirmed_email }.to('test@host.example'))
+        .and(change { user.reload.confirmation_token }.from(nil).to(be_present))
+    end
+  end
+end
diff --git a/spec/controllers/admin/dashboard_controller_spec.rb b/spec/system/admin/dashboard_spec.rb
similarity index 50%
rename from spec/controllers/admin/dashboard_controller_spec.rb
rename to spec/system/admin/dashboard_spec.rb
index 5a1ea848cc..06d31cde44 100644
--- a/spec/controllers/admin/dashboard_controller_spec.rb
+++ b/spec/system/admin/dashboard_spec.rb
@@ -2,10 +2,8 @@
 
 require 'rails_helper'
 
-RSpec.describe Admin::DashboardController do
-  render_views
-
-  describe 'GET #index' do
+RSpec.describe 'Admin Dashboard' do
+  describe 'Viewing the dashboard page' do
     let(:user) { Fabricate(:owner_user) }
 
     before do
@@ -14,14 +12,12 @@ RSpec.describe Admin::DashboardController do
       sign_in(user)
     end
 
-    it 'returns http success and body with system check messages' do
-      get :index
+    it 'returns page with system check messages' do
+      visit admin_dashboard_path
 
-      expect(response)
-        .to have_http_status(200)
-        .and have_attributes(
-          body: include(I18n.t('admin.system_checks.software_version_patch_check.message_html'))
-        )
+      expect(page)
+        .to have_title(I18n.t('admin.dashboard.title'))
+        .and have_content(I18n.t('admin.system_checks.software_version_patch_check.message_html'))
     end
 
     private
diff --git a/spec/system/admin/users/two_factor_authentications_spec.rb b/spec/system/admin/users/two_factor_authentications_spec.rb
new file mode 100644
index 0000000000..e09bc437b4
--- /dev/null
+++ b/spec/system/admin/users/two_factor_authentications_spec.rb
@@ -0,0 +1,58 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+require 'webauthn/fake_client'
+
+RSpec.describe 'Admin Users TwoFactorAuthentications' do
+  let(:user) { Fabricate(:user) }
+
+  before { sign_in Fabricate(:admin_user) }
+
+  describe 'Disabling 2FA for users' do
+    before { stub_webauthn_credential }
+
+    let(:fake_client) { WebAuthn::FakeClient.new('http://test.host') }
+
+    context 'when user has OTP enabled' do
+      before { user.update(otp_required_for_login: true) }
+
+      it 'disables OTP and redirects to admin account page' do
+        visit admin_account_path(user.account.id)
+
+        expect { disable_two_factor }
+          .to change { user.reload.otp_enabled? }.to(false)
+        expect(page)
+          .to have_title(user.account.pretty_acct)
+      end
+    end
+
+    context 'when user has OTP and WebAuthn enabled' do
+      before { user.update(otp_required_for_login: true, webauthn_id: WebAuthn.generate_user_id) }
+
+      it 'disables OTP and webauthn and redirects to admin account page' do
+        visit admin_account_path(user.account.id)
+
+        expect { disable_two_factor }
+          .to change { user.reload.otp_enabled? }.to(false)
+          .and(change { user.reload.webauthn_enabled? }.to(false))
+        expect(page)
+          .to have_title(user.account.pretty_acct)
+      end
+    end
+
+    def disable_two_factor
+      click_on I18n.t('admin.accounts.disable_two_factor_authentication')
+    end
+
+    def stub_webauthn_credential
+      public_key_credential = WebAuthn::Credential.from_create(fake_client.create)
+      Fabricate(
+        :webauthn_credential,
+        external_id: public_key_credential.id,
+        nickname: 'Security Key',
+        public_key: public_key_credential.public_key,
+        user_id: user.id
+      )
+    end
+  end
+end
diff --git a/spec/system/disputes/strikes_spec.rb b/spec/system/disputes/strikes_spec.rb
new file mode 100644
index 0000000000..d2b6b08c46
--- /dev/null
+++ b/spec/system/disputes/strikes_spec.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe 'Disputes Strikes' do
+  before { sign_in(current_user) }
+
+  describe 'viewing strike disputes' do
+    let(:current_user) { Fabricate(:user) }
+    let!(:strike) { Fabricate(:account_warning, target_account: current_user.account) }
+
+    it 'shows a list of strikes and details for each' do
+      visit disputes_strikes_path
+      expect(page)
+        .to have_title(I18n.t('settings.strikes'))
+
+      find('.strike-entry').click
+      expect(page)
+        .to have_title(strike_page_title)
+        .and have_content(strike.text)
+    end
+
+    def strike_page_title
+      I18n.t('disputes.strikes.title', action: I18n.t(strike.action, scope: 'disputes.strikes.title_actions'), date: I18n.l(strike.created_at.to_date))
+    end
+  end
+end
diff --git a/spec/system/filters/statuses_spec.rb b/spec/system/filters/statuses_spec.rb
new file mode 100644
index 0000000000..b353bd8674
--- /dev/null
+++ b/spec/system/filters/statuses_spec.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe 'Filters Statuses' do
+  describe 'Viewing statuses under a filter' do
+    let(:filter) { Fabricate(:custom_filter, title: 'good filter') }
+
+    context 'with the filter user signed in' do
+      before { sign_in(filter.account.user) }
+
+      it 'returns a page with the status filters' do
+        visit filter_statuses_path(filter)
+
+        expect(page)
+          .to have_private_cache_control
+          .and have_title(/good filter/)
+      end
+    end
+  end
+end
diff --git a/spec/system/settings/deletes_spec.rb b/spec/system/settings/deletes_spec.rb
new file mode 100644
index 0000000000..91f7104252
--- /dev/null
+++ b/spec/system/settings/deletes_spec.rb
@@ -0,0 +1,38 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe 'Settings Deletes' do
+  describe 'Deleting user from settings area' do
+    let(:user) { Fabricate(:user) }
+
+    before { sign_in(user) }
+
+    it 'requires password and deletes user record', :inline_jobs do
+      visit settings_delete_path
+      expect(page)
+        .to have_title(I18n.t('settings.delete'))
+        .and have_private_cache_control
+
+      # Wrong confirmation value
+      fill_in 'form_delete_confirmation_password', with: 'wrongvalue'
+      click_on I18n.t('deletes.proceed')
+      expect(page)
+        .to have_content(I18n.t('deletes.challenge_not_passed'))
+
+      # Correct confirmation value
+      fill_in 'form_delete_confirmation_password', with: user.password
+      click_on I18n.t('deletes.proceed')
+      expect(page)
+        .to have_content(I18n.t('deletes.success_msg'))
+      expect(page)
+        .to have_title(I18n.t('auth.login'))
+      expect(User.find_by(id: user.id))
+        .to be_nil
+      expect(user.account.reload)
+        .to be_suspended
+      expect(CanonicalEmailBlock.block?(user.email))
+        .to be(false)
+    end
+  end
+end
diff --git a/spec/validators/date_of_birth_validator_spec.rb b/spec/validators/date_of_birth_validator_spec.rb
new file mode 100644
index 0000000000..33e69e811b
--- /dev/null
+++ b/spec/validators/date_of_birth_validator_spec.rb
@@ -0,0 +1,51 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe DateOfBirthValidator do
+  let(:record_class) do
+    Class.new do
+      include ActiveModel::Validations
+
+      attr_accessor :date_of_birth
+
+      validates :date_of_birth, date_of_birth: true
+    end
+  end
+
+  let(:record) { record_class.new }
+
+  before do
+    Setting.min_age = 16
+  end
+
+  describe '#validate_each' do
+    context 'with an invalid date' do
+      it 'adds errors' do
+        record.date_of_birth = '76.830.10'
+
+        expect(record).to_not be_valid
+        expect(record.errors.first.attribute).to eq(:date_of_birth)
+        expect(record.errors.first.type).to eq(:invalid)
+      end
+    end
+
+    context 'with a date below age limit' do
+      it 'adds errors' do
+        record.date_of_birth = 13.years.ago.strftime('%d.%m.%Y')
+
+        expect(record).to_not be_valid
+        expect(record.errors.first.attribute).to eq(:date_of_birth)
+        expect(record.errors.first.type).to eq(:below_limit)
+      end
+    end
+
+    context 'with a date above age limit' do
+      it 'does not add errors' do
+        record.date_of_birth = 16.years.ago.strftime('%d.%m.%Y')
+
+        expect(record).to be_valid
+      end
+    end
+  end
+end
diff --git a/streaming/database.js b/streaming/database.js
index 60a3b34ef0..553c9149cc 100644
--- a/streaming/database.js
+++ b/streaming/database.js
@@ -49,7 +49,7 @@ export function configFromEnv(env, environment) {
     if (typeof parsedUrl.password === 'string') baseConfig.password = parsedUrl.password;
     if (typeof parsedUrl.host === 'string') baseConfig.host = parsedUrl.host;
     if (typeof parsedUrl.user === 'string') baseConfig.user = parsedUrl.user;
-    if (typeof parsedUrl.port === 'string') {
+    if (typeof parsedUrl.port === 'string' && parsedUrl.port) {
       const parsedPort = parseInt(parsedUrl.port, 10);
       if (isNaN(parsedPort)) {
         throw new Error('Invalid port specified in DATABASE_URL environment variable');
diff --git a/yarn.lock b/yarn.lock
index 4e3bbb98e9..7bb1dd27c4 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -109,6 +109,19 @@ __metadata:
   languageName: node
   linkType: hard
 
+"@babel/generator@npm:^7.27.0":
+  version: 7.27.0
+  resolution: "@babel/generator@npm:7.27.0"
+  dependencies:
+    "@babel/parser": "npm:^7.27.0"
+    "@babel/types": "npm:^7.27.0"
+    "@jridgewell/gen-mapping": "npm:^0.3.5"
+    "@jridgewell/trace-mapping": "npm:^0.3.25"
+    jsesc: "npm:^3.0.2"
+  checksum: 10c0/7cb10693d2b365c278f109a745dc08856cae139d262748b77b70ce1d97da84627f79648cab6940d847392c0e5d180441669ed958b3aee98d9c7d274b37c553bd
+  languageName: node
+  linkType: hard
+
 "@babel/helper-annotate-as-pure@npm:^7.25.9":
   version: 7.25.9
   resolution: "@babel/helper-annotate-as-pure@npm:7.25.9"
@@ -142,19 +155,19 @@ __metadata:
   linkType: hard
 
 "@babel/helper-create-class-features-plugin@npm:^7.25.9":
-  version: 7.25.9
-  resolution: "@babel/helper-create-class-features-plugin@npm:7.25.9"
+  version: 7.27.0
+  resolution: "@babel/helper-create-class-features-plugin@npm:7.27.0"
   dependencies:
     "@babel/helper-annotate-as-pure": "npm:^7.25.9"
     "@babel/helper-member-expression-to-functions": "npm:^7.25.9"
     "@babel/helper-optimise-call-expression": "npm:^7.25.9"
-    "@babel/helper-replace-supers": "npm:^7.25.9"
+    "@babel/helper-replace-supers": "npm:^7.26.5"
     "@babel/helper-skip-transparent-expression-wrappers": "npm:^7.25.9"
-    "@babel/traverse": "npm:^7.25.9"
+    "@babel/traverse": "npm:^7.27.0"
     semver: "npm:^6.3.1"
   peerDependencies:
     "@babel/core": ^7.0.0
-  checksum: 10c0/b2bdd39f38056a76b9ba00ec5b209dd84f5c5ebd998d0f4033cf0e73d5f2c357fbb49d1ce52db77a2709fb29ee22321f84a5734dc9914849bdfee9ad12ce8caf
+  checksum: 10c0/c4945903136d934050e070f69a4d72ec425f1f70634e0ddf14ad36695f935125a6df559f8d5b94cc1ed49abd4ce9c5be8ef3ba033fa8d09c5dd78d1a9b97d8cc
   languageName: node
   linkType: hard
 
@@ -248,16 +261,16 @@ __metadata:
   languageName: node
   linkType: hard
 
-"@babel/helper-replace-supers@npm:^7.25.9":
-  version: 7.25.9
-  resolution: "@babel/helper-replace-supers@npm:7.25.9"
+"@babel/helper-replace-supers@npm:^7.25.9, @babel/helper-replace-supers@npm:^7.26.5":
+  version: 7.26.5
+  resolution: "@babel/helper-replace-supers@npm:7.26.5"
   dependencies:
     "@babel/helper-member-expression-to-functions": "npm:^7.25.9"
     "@babel/helper-optimise-call-expression": "npm:^7.25.9"
-    "@babel/traverse": "npm:^7.25.9"
+    "@babel/traverse": "npm:^7.26.5"
   peerDependencies:
     "@babel/core": ^7.0.0
-  checksum: 10c0/0b40d7d2925bd3ba4223b3519e2e4d2456d471ad69aa458f1c1d1783c80b522c61f8237d3a52afc9e47c7174129bbba650df06393a6787d5722f2ec7f223c3f4
+  checksum: 10c0/b19b1245caf835207aaaaac3a494f03a16069ae55e76a2e1350b5acd560e6a820026997a8160e8ebab82ae873e8208759aa008eb8422a67a775df41f0a4633d4
   languageName: node
   linkType: hard
 
@@ -324,6 +337,17 @@ __metadata:
   languageName: node
   linkType: hard
 
+"@babel/parser@npm:^7.27.0":
+  version: 7.27.0
+  resolution: "@babel/parser@npm:7.27.0"
+  dependencies:
+    "@babel/types": "npm:^7.27.0"
+  bin:
+    parser: ./bin/babel-parser.js
+  checksum: 10c0/ba2ed3f41735826546a3ef2a7634a8d10351df221891906e59b29b0a0cd748f9b0e7a6f07576858a9de8e77785aad925c8389ddef146de04ea2842047c9d2859
+  languageName: node
+  linkType: hard
+
 "@babel/plugin-bugfix-firefox-class-in-computed-class-key@npm:^7.25.9":
   version: 7.25.9
   resolution: "@babel/plugin-bugfix-firefox-class-in-computed-class-key@npm:7.25.9"
@@ -1422,6 +1446,17 @@ __metadata:
   languageName: node
   linkType: hard
 
+"@babel/template@npm:^7.27.0":
+  version: 7.27.0
+  resolution: "@babel/template@npm:7.27.0"
+  dependencies:
+    "@babel/code-frame": "npm:^7.26.2"
+    "@babel/parser": "npm:^7.27.0"
+    "@babel/types": "npm:^7.27.0"
+  checksum: 10c0/13af543756127edb5f62bf121f9b093c09a2b6fe108373887ccffc701465cfbcb17e07cf48aa7f440415b263f6ec006e9415c79dfc2e8e6010b069435f81f340
+  languageName: node
+  linkType: hard
+
 "@babel/traverse@npm:^7.25.0, @babel/traverse@npm:^7.25.9, @babel/traverse@npm:^7.26.8, @babel/traverse@npm:^7.26.9":
   version: 7.26.9
   resolution: "@babel/traverse@npm:7.26.9"
@@ -1437,6 +1472,21 @@ __metadata:
   languageName: node
   linkType: hard
 
+"@babel/traverse@npm:^7.26.5, @babel/traverse@npm:^7.27.0":
+  version: 7.27.0
+  resolution: "@babel/traverse@npm:7.27.0"
+  dependencies:
+    "@babel/code-frame": "npm:^7.26.2"
+    "@babel/generator": "npm:^7.27.0"
+    "@babel/parser": "npm:^7.27.0"
+    "@babel/template": "npm:^7.27.0"
+    "@babel/types": "npm:^7.27.0"
+    debug: "npm:^4.3.1"
+    globals: "npm:^11.1.0"
+  checksum: 10c0/c7af29781960dacaae51762e8bc6c4b13d6ab4b17312990fbca9fc38e19c4ad7fecaae24b1cf52fb844e8e6cdc76c70ad597f90e496bcb3cc0a1d66b41a0aa5b
+  languageName: node
+  linkType: hard
+
 "@babel/types@npm:^7.0.0, @babel/types@npm:^7.0.0-beta.49, @babel/types@npm:^7.12.6, @babel/types@npm:^7.20.7, @babel/types@npm:^7.25.0, @babel/types@npm:^7.25.9, @babel/types@npm:^7.26.9, @babel/types@npm:^7.3.3, @babel/types@npm:^7.4.4":
   version: 7.26.9
   resolution: "@babel/types@npm:7.26.9"
@@ -1447,6 +1497,16 @@ __metadata:
   languageName: node
   linkType: hard
 
+"@babel/types@npm:^7.27.0":
+  version: 7.27.0
+  resolution: "@babel/types@npm:7.27.0"
+  dependencies:
+    "@babel/helper-string-parser": "npm:^7.25.9"
+    "@babel/helper-validator-identifier": "npm:^7.25.9"
+  checksum: 10c0/6f1592eabe243c89a608717b07b72969be9d9d2fce1dee21426238757ea1fa60fdfc09b29de9e48d8104311afc6e6fb1702565a9cc1e09bc1e76f2b2ddb0f6e1
+  languageName: node
+  linkType: hard
+
 "@bcoe/v8-coverage@npm:^0.2.3":
   version: 0.2.3
   resolution: "@bcoe/v8-coverage@npm:0.2.3"
@@ -2767,6 +2827,7 @@ __metadata:
     "@github/webauthn-json": "npm:^2.1.1"
     "@hello-pangea/dnd": "npm:^17.0.0"
     "@rails/ujs": "npm:7.1.501"
+    "@react-spring/web": "npm:^9.7.5"
     "@reduxjs/toolkit": "npm:^2.0.1"
     "@svgr/webpack": "npm:^5.5.0"
     "@testing-library/dom": "npm:^10.2.0"
@@ -2803,6 +2864,7 @@ __metadata:
     "@types/webpack-env": "npm:^1.18.4"
     "@typescript-eslint/eslint-plugin": "npm:^8.0.0"
     "@typescript-eslint/parser": "npm:^8.0.0"
+    "@use-gesture/react": "npm:^10.3.1"
     arrow-key-navigation: "npm:^1.2.0"
     async-mutex: "npm:^0.5.0"
     axios: "npm:^1.4.0"
@@ -2869,7 +2931,6 @@ __metadata:
     react-immutable-pure-component: "npm:^2.2.2"
     react-intl: "npm:^7.0.0"
     react-motion: "npm:^0.5.2"
-    react-notification: "npm:^6.8.5"
     react-overlays: "npm:^5.2.1"
     react-redux: "npm:^9.0.4"
     react-redux-loading-bar: "npm:^5.0.8"
@@ -3218,6 +3279,72 @@ __metadata:
   languageName: node
   linkType: hard
 
+"@react-spring/animated@npm:~9.7.5":
+  version: 9.7.5
+  resolution: "@react-spring/animated@npm:9.7.5"
+  dependencies:
+    "@react-spring/shared": "npm:~9.7.5"
+    "@react-spring/types": "npm:~9.7.5"
+  peerDependencies:
+    react: ^16.8.0 || ^17.0.0 || ^18.0.0
+  checksum: 10c0/f8c2473c60f39a878c7dd0fdfcfcdbc720521e1506aa3f63c9de64780694a0a73d5ccc535a5ccec3520ddb70a71cf43b038b32c18e99531522da5388c510ecd7
+  languageName: node
+  linkType: hard
+
+"@react-spring/core@npm:~9.7.5":
+  version: 9.7.5
+  resolution: "@react-spring/core@npm:9.7.5"
+  dependencies:
+    "@react-spring/animated": "npm:~9.7.5"
+    "@react-spring/shared": "npm:~9.7.5"
+    "@react-spring/types": "npm:~9.7.5"
+  peerDependencies:
+    react: ^16.8.0 || ^17.0.0 || ^18.0.0
+  checksum: 10c0/5bfd83dfe248cd91889f215f015d908c7714ef445740fd5afa054b27ebc7d5a456abf6c309e2459d9b5b436e78d6fda16b62b9601f96352e9130552c02270830
+  languageName: node
+  linkType: hard
+
+"@react-spring/rafz@npm:~9.7.5":
+  version: 9.7.5
+  resolution: "@react-spring/rafz@npm:9.7.5"
+  checksum: 10c0/8bdad180feaa9a0e870a513043a5e98a4e9b7292a9f887575b7e6fadab2677825bc894b7ff16c38511b35bfe6cc1072df5851c5fee64448d67551559578ca759
+  languageName: node
+  linkType: hard
+
+"@react-spring/shared@npm:~9.7.5":
+  version: 9.7.5
+  resolution: "@react-spring/shared@npm:9.7.5"
+  dependencies:
+    "@react-spring/rafz": "npm:~9.7.5"
+    "@react-spring/types": "npm:~9.7.5"
+  peerDependencies:
+    react: ^16.8.0 || ^17.0.0 || ^18.0.0
+  checksum: 10c0/0207eacccdedd918a2fc55e78356ce937f445ce27ad9abd5d3accba8f9701a39349b55115641dc2b39bb9d3a155b058c185b411d292dc8cc5686bfa56f73b94f
+  languageName: node
+  linkType: hard
+
+"@react-spring/types@npm:~9.7.5":
+  version: 9.7.5
+  resolution: "@react-spring/types@npm:9.7.5"
+  checksum: 10c0/85c05121853cacb64f7cf63a4855e9044635e1231f70371cd7b8c78bc10be6f4dd7c68f592f92a2607e8bb68051540989b4677a2ccb525dba937f5cd95dc8bc1
+  languageName: node
+  linkType: hard
+
+"@react-spring/web@npm:^9.7.5":
+  version: 9.7.5
+  resolution: "@react-spring/web@npm:9.7.5"
+  dependencies:
+    "@react-spring/animated": "npm:~9.7.5"
+    "@react-spring/core": "npm:~9.7.5"
+    "@react-spring/shared": "npm:~9.7.5"
+    "@react-spring/types": "npm:~9.7.5"
+  peerDependencies:
+    react: ^16.8.0 || ^17.0.0 || ^18.0.0
+    react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0
+  checksum: 10c0/bcd1e052e1b16341a12a19bf4515f153ca09d1fa86ff7752a5d02d7c4db58e8baf80e6283e64411f1e388c65340dce2254b013083426806b5dbae38bd151e53e
+  languageName: node
+  linkType: hard
+
 "@reduxjs/toolkit@npm:^2.0.1":
   version: 2.6.1
   resolution: "@reduxjs/toolkit@npm:2.6.1"
@@ -4443,6 +4570,24 @@ __metadata:
   languageName: node
   linkType: hard
 
+"@use-gesture/core@npm:10.3.1":
+  version: 10.3.1
+  resolution: "@use-gesture/core@npm:10.3.1"
+  checksum: 10c0/2e3b5c0f7fe26cdb47be3a9c2a58a6a9edafc5b2895b07d2898eda9ab5a2b29fb0098b15597baa0856907b593075cd44cc69bba4785c9cfb7b6fabaa3b52cd3e
+  languageName: node
+  linkType: hard
+
+"@use-gesture/react@npm:^10.3.1":
+  version: 10.3.1
+  resolution: "@use-gesture/react@npm:10.3.1"
+  dependencies:
+    "@use-gesture/core": "npm:10.3.1"
+  peerDependencies:
+    react: ">= 16.8.0"
+  checksum: 10c0/978da66e4e7c424866ad52eba8fdf0ce93a4c8fc44f8837c7043e68c6a6107cd67e817fffb27f7db2ae871ef2f6addb0c8ddf1586f24c67b7e6aef1646c668cf
+  languageName: node
+  linkType: hard
+
 "@webassemblyjs/ast@npm:1.9.0":
   version: 1.9.0
   resolution: "@webassemblyjs/ast@npm:1.9.0"
@@ -14769,17 +14914,6 @@ __metadata:
   languageName: node
   linkType: hard
 
-"react-notification@npm:^6.8.5":
-  version: 6.8.5
-  resolution: "react-notification@npm:6.8.5"
-  dependencies:
-    prop-types: "npm:^15.6.2"
-  peerDependencies:
-    react: ^0.14.0 || ^15.0.0 || ^16.0.0
-  checksum: 10c0/14ffb71a5b18301830699b814d1de2421f4f43f31df5b95efd95cd47548a0d7597ec58abc16a12191958cad398495eba9274193af3294863e2864d32ea79f2c6
-  languageName: node
-  linkType: hard
-
 "react-overlays@npm:^5.2.1":
   version: 5.2.1
   resolution: "react-overlays@npm:5.2.1"