about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--.circleci/config.yml12
-rw-r--r--.devcontainer/Dockerfile2
-rw-r--r--.devcontainer/devcontainer.json4
-rw-r--r--.devcontainer/docker-compose.yml4
-rwxr-xr-x.devcontainer/post-create.sh21
-rw-r--r--.env.production.sample2
-rw-r--r--.github/workflows/build-image.yml1
-rw-r--r--.github/workflows/codeql.yml63
-rw-r--r--.nvmrc2
-rw-r--r--.rubocop.yml200
-rw-r--r--Aptfile22
-rw-r--r--Dockerfile7
-rw-r--r--Gemfile11
-rw-r--r--Gemfile.lock33
-rw-r--r--app/controllers/admin/domain_blocks_controller.rb8
-rw-r--r--app/controllers/admin/relays_controller.rb6
-rw-r--r--app/controllers/api/base_controller.rb20
-rw-r--r--app/controllers/api/v1/admin/domain_blocks_controller.rb6
-rw-r--r--app/controllers/api/v1/notifications_controller.rb2
-rw-r--r--app/controllers/auth/passwords_controller.rb2
-rw-r--r--app/controllers/auth/registrations_controller.rb4
-rw-r--r--app/controllers/concerns/rate_limit_headers.rb2
-rw-r--r--app/controllers/concerns/signature_verification.rb4
-rw-r--r--app/controllers/follower_accounts_controller.rb2
-rw-r--r--app/controllers/following_accounts_controller.rb2
-rw-r--r--app/controllers/media_controller.rb4
-rw-r--r--app/controllers/statuses_controller.rb4
-rw-r--r--app/controllers/tags_controller.rb2
-rw-r--r--app/helpers/formatting_helper.rb25
-rw-r--r--app/helpers/languages_helper.rb3
-rw-r--r--app/helpers/statuses_helper.rb2
-rw-r--r--app/javascript/flavours/glitch/components/status_action_bar.js10
-rw-r--r--app/javascript/flavours/glitch/containers/mastodon.js5
-rw-r--r--app/javascript/flavours/glitch/features/account/components/follow_request_note.js37
-rw-r--r--app/javascript/flavours/glitch/features/account/components/header.js3
-rw-r--r--app/javascript/flavours/glitch/features/account/containers/follow_request_note_container.js15
-rw-r--r--app/javascript/flavours/glitch/features/account_gallery/components/media_item.js1
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/poll_form.js1
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/search.js39
-rw-r--r--app/javascript/flavours/glitch/features/explore/index.js32
-rw-r--r--app/javascript/flavours/glitch/features/hashtag_timeline/index.js2
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/columns_area.js2
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/focal_point_modal.js6
-rw-r--r--app/javascript/flavours/glitch/packs/public.js38
-rw-r--r--app/javascript/flavours/glitch/reducers/compose.js2
-rw-r--r--app/javascript/flavours/glitch/reducers/relationships.js11
-rw-r--r--app/javascript/flavours/glitch/styles/components/accounts.scss36
-rw-r--r--app/javascript/flavours/glitch/styles/components/index.scss24
-rw-r--r--app/javascript/flavours/glitch/styles/components/search.scss1
-rw-r--r--app/javascript/flavours/glitch/styles/components/single_column.scss5
-rw-r--r--app/javascript/flavours/glitch/styles/components/status.scss2
-rw-r--r--app/javascript/flavours/glitch/styles/modal.scss2
-rw-r--r--app/javascript/images/logo-symbol-icon.svg2
-rw-r--r--app/javascript/images/logo-symbol-wordmark.svg2
-rw-r--r--app/javascript/mastodon/actions/announcements.js4
-rw-r--r--app/javascript/mastodon/components/status_action_bar.js11
-rw-r--r--app/javascript/mastodon/containers/mastodon.js4
-rw-r--r--app/javascript/mastodon/features/account/components/follow_request_note.js37
-rw-r--r--app/javascript/mastodon/features/account/components/header.js3
-rw-r--r--app/javascript/mastodon/features/account/containers/follow_request_note_container.js15
-rw-r--r--app/javascript/mastodon/features/account_gallery/components/media_item.js1
-rw-r--r--app/javascript/mastodon/features/compose/components/compose_form.js13
-rw-r--r--app/javascript/mastodon/features/compose/components/poll_form.js1
-rw-r--r--app/javascript/mastodon/features/compose/components/search.js29
-rw-r--r--app/javascript/mastodon/features/compose/containers/compose_form_container.js1
-rw-r--r--app/javascript/mastodon/features/compose/index.js3
-rw-r--r--app/javascript/mastodon/features/explore/index.js32
-rw-r--r--app/javascript/mastodon/features/hashtag_timeline/index.js2
-rw-r--r--app/javascript/mastodon/features/ui/components/columns_area.js2
-rw-r--r--app/javascript/mastodon/features/ui/components/focal_point_modal.js6
-rw-r--r--app/javascript/mastodon/locales/defaultMessages.json22
-rw-r--r--app/javascript/mastodon/locales/en.json11
-rw-r--r--app/javascript/mastodon/reducers/compose.js2
-rw-r--r--app/javascript/mastodon/reducers/relationships.js11
-rw-r--r--app/javascript/packs/public.js38
-rw-r--r--app/javascript/styles/mastodon/admin.scss2
-rw-r--r--app/javascript/styles/mastodon/components.scss42
-rw-r--r--app/javascript/styles/mastodon/modal.scss2
-rw-r--r--app/javascript/styles/mastodon/widgets.scss2
-rw-r--r--app/lib/admin/system_check/elasticsearch_check.rb1
-rw-r--r--app/lib/request.rb3
-rw-r--r--app/lib/status_reach_finder.rb2
-rw-r--r--app/lib/translation_service/libre_translate.rb2
-rw-r--r--app/models/account.rb12
-rw-r--r--app/models/account_filter.rb2
-rw-r--r--app/models/concerns/account_interactions.rb4
-rw-r--r--app/models/media_attachment.rb2
-rw-r--r--app/models/user.rb16
-rw-r--r--app/presenters/account_relationships_presenter.rb6
-rw-r--r--app/serializers/initial_state_serializer.rb2
-rw-r--r--app/serializers/rest/relationship_serializer.rb8
-rw-r--r--app/services/activitypub/process_status_update_service.rb42
-rw-r--r--app/services/post_status_service.rb12
-rw-r--r--app/services/tag_search_service.rb18
-rw-r--r--app/views/admin/accounts/index.html.haml2
-rw-r--r--app/views/admin/accounts/show.html.haml6
-rw-r--r--app/views/admin/report_notes/_report_note.html.haml7
-rw-r--r--app/views/admin/reports/show.html.haml7
-rw-r--r--app/views/auth/sessions/new.html.haml2
-rw-r--r--app/views/disputes/strikes/show.html.haml7
-rw-r--r--app/views/settings/featured_tags/index.html.haml2
-rw-r--r--app/workers/scheduler/suspended_user_cleanup_scheduler.rb2
-rw-r--r--config/application.rb1
-rw-r--r--config/initializers/devise.rb2
-rw-r--r--config/locales/doorkeeper.en.yml10
-rw-r--r--config/locales/en.yml8
-rw-r--r--config/sidekiq.yml2
-rw-r--r--config/webpack/production.js6
-rw-r--r--lib/mastodon/accounts_cli.rb41
-rw-r--r--lib/mastodon/media_cli.rb78
-rw-r--r--lib/tasks/mastodon.rake2
-rw-r--r--package.json2
-rw-r--r--public/embed.js22
-rw-r--r--spec/controllers/admin/domain_blocks_controller_spec.rb47
-rw-r--r--spec/controllers/api/v1/admin/domain_blocks_controller_spec.rb47
-rw-r--r--spec/controllers/auth/passwords_controller_spec.rb61
-rw-r--r--spec/controllers/well_known/nodeinfo_controller_spec.rb2
-rw-r--r--spec/helpers/application_helper_spec.rb2
-rw-r--r--spec/helpers/formatting_helper_spec.rb24
-rw-r--r--spec/models/account_spec.rb8
-rw-r--r--spec/presenters/account_relationships_presenter_spec.rb9
-rw-r--r--spec/support/matchers/json/match_json_schema.rb6
-rw-r--r--spec/support/schema/nodeinfo_2.0.json170
123 files changed, 1442 insertions, 331 deletions
diff --git a/.circleci/config.yml b/.circleci/config.yml
index bddfd2d27..a373d685e 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -1,8 +1,8 @@
 version: 2.1
 
 orbs:
-  ruby: circleci/ruby@1.4.1
-  node: circleci/node@5.0.1
+  ruby: circleci/ruby@2.0.0
+  node: circleci/node@5.0.3
 
 executors:
   default:
@@ -19,11 +19,11 @@ executors:
           DB_USER: root
           DISABLE_SIMPLECOV: true
           RAILS_ENV: test
-      - image: cimg/postgres:14.0
+      - image: cimg/postgres:14.5
         environment:
           POSTGRES_USER: root
           POSTGRES_HOST_AUTH_METHOD: trust
-      - image: cimg/redis:6.2
+      - image: cimg/redis:7.0
 
 commands:
   install-system-dependencies:
@@ -45,7 +45,7 @@ commands:
             bundle config without 'development production'
           name: Set bundler settings
       - ruby/install-deps:
-          bundler-version: '2.3.8'
+          bundler-version: '2.3.26'
           key: ruby<< parameters.ruby-version >>-gems-v1
   wait-db:
     steps:
@@ -221,5 +221,5 @@ workflows:
           pkg-manager: yarn
           requires:
             - build
-          version: lts
+          version: '16.18'
           yarn-run: test:jest
diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile
index ac495e1c9..425b86a6b 100644
--- a/.devcontainer/Dockerfile
+++ b/.devcontainer/Dockerfile
@@ -9,7 +9,7 @@ FROM mcr.microsoft.com/vscode/devcontainers/ruby:${VARIANT}
 # The value is a comma-separated list of allowed domains
 ENV RAILS_DEVELOPMENT_HOSTS=".githubpreview.dev"
 
-# [Choice] Node.js version: lts/*, 16, 14, 12, 10
+# [Choice] Node.js version: lts/*, 18, 16, 14
 ARG NODE_VERSION="lts/*"
 RUN su vscode -c "source /usr/local/share/nvm/nvm.sh && nvm install ${NODE_VERSION} 2>&1"
 
diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json
index 47497794f..01941a9d3 100644
--- a/.devcontainer/devcontainer.json
+++ b/.devcontainer/devcontainer.json
@@ -2,7 +2,7 @@
   "name": "Mastodon",
   "dockerComposeFile": "docker-compose.yml",
   "service": "app",
-  "workspaceFolder": "/workspaces/mastodon",
+  "workspaceFolder": "/mastodon",
 
   // Set *default* container specific settings.json values on container create.
   "settings": {},
@@ -20,7 +20,7 @@
   "forwardPorts": [3000, 4000],
 
   // Use 'postCreateCommand' to run commands after the container is created.
-  "postCreateCommand": "bundle install --path vendor/bundle && yarn install && git checkout -- Gemfile.lock && ./bin/rails db:setup",
+  "postCreateCommand": ".devcontainer/post-create.sh",
 
   // Comment out to connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root.
   "remoteUser": "vscode"
diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml
index 46f42c454..95f401379 100644
--- a/.devcontainer/docker-compose.yml
+++ b/.devcontainer/docker-compose.yml
@@ -11,9 +11,9 @@ services:
         # Use -bullseye variants on local arm64/Apple Silicon.
         VARIANT: '3.0-bullseye'
         # Optional Node.js version to install
-        NODE_VERSION: '14'
+        NODE_VERSION: '16'
     volumes:
-      - ..:/workspaces/mastodon:cached
+      - ..:/mastodon:cached
     environment:
       RAILS_ENV: development
       NODE_ENV: development
diff --git a/.devcontainer/post-create.sh b/.devcontainer/post-create.sh
new file mode 100755
index 000000000..02f488f12
--- /dev/null
+++ b/.devcontainer/post-create.sh
@@ -0,0 +1,21 @@
+#!/bin/bash
+
+set -e # Fail the whole script on first error
+
+# Fetch Ruby gem dependencies
+bundle install --path vendor/bundle --with='development test'
+
+# Fetch Javascript dependencies
+yarn install
+
+# Make Gemfile.lock pristine again
+git checkout -- Gemfile.lock
+
+# [re]create, migrate, and seed the test database
+RAILS_ENV=test ./bin/rails db:setup
+
+# Precompile assets for development
+RAILS_ENV=development ./bin/rails assets:precompile
+
+# Precompile assets for test
+RAILS_ENV=test NODE_ENV=tests ./bin/rails assets:precompile
diff --git a/.env.production.sample b/.env.production.sample
index da4c7fe4c..7bcce0f7e 100644
--- a/.env.production.sample
+++ b/.env.production.sample
@@ -103,7 +103,7 @@ VAPID_PUBLIC_KEY=
 
 # Sending mail
 # ------------
-SMTP_SERVER=smtp.mailgun.org
+SMTP_SERVER=
 SMTP_PORT=587
 SMTP_LOGIN=
 SMTP_PASSWORD=
diff --git a/.github/workflows/build-image.yml b/.github/workflows/build-image.yml
index 3a880fabf..bf50afe8c 100644
--- a/.github/workflows/build-image.yml
+++ b/.github/workflows/build-image.yml
@@ -17,6 +17,7 @@ jobs:
     runs-on: ubuntu-latest
     steps:
       - uses: actions/checkout@v3
+      - uses: hadolint/hadolint-action@v3.0.0
       - uses: docker/setup-qemu-action@v2
       - uses: docker/setup-buildx-action@v2
       - uses: docker/login-action@v2
diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml
new file mode 100644
index 000000000..88ac2fb08
--- /dev/null
+++ b/.github/workflows/codeql.yml
@@ -0,0 +1,63 @@
+name: "CodeQL"
+
+on:
+  push:
+    branches: [ "main" ]
+  pull_request:
+    # The branches below must be a subset of the branches above
+    branches: [ "main" ]
+  schedule:
+    - cron: '22 6 * * 1'
+
+jobs:
+  analyze:
+    name: Analyze
+    runs-on: ubuntu-latest
+    permissions:
+      actions: read
+      contents: read
+      security-events: write
+
+    strategy:
+      fail-fast: false
+      matrix:
+        language: [ 'javascript', 'ruby' ]
+        # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ]
+        # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support
+
+    steps:
+    - name: Checkout repository
+      uses: actions/checkout@v3
+
+    # Initializes the CodeQL tools for scanning.
+    - name: Initialize CodeQL
+      uses: github/codeql-action/init@v2
+      with:
+        languages: ${{ matrix.language }}
+        # If you wish to specify custom queries, you can do so here or in a config file.
+        # By default, queries listed here will override any specified in a config file.
+        # Prefix the list here with "+" to use these queries and those in the config file.
+
+        # Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
+        # queries: security-extended,security-and-quality
+
+
+    # Autobuild attempts to build any compiled languages  (C/C++, C#, Go, or Java).
+    # If this step fails, then you should remove it and run the build manually (see below)
+    - name: Autobuild
+      uses: github/codeql-action/autobuild@v2
+
+    # ℹ️ Command-line programs to run using the OS shell.
+    # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
+
+    #   If the Autobuild fails above, remove it and uncomment the following three lines.
+    #   modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance.
+
+    # - run: |
+    #   echo "Run, Build Application using script"
+    #   ./location_of_script_within_repo/buildscript.sh
+
+    - name: Perform CodeQL Analysis
+      uses: github/codeql-action/analyze@v2
+      with:
+        category: "/language:${{matrix.language}}"
diff --git a/.nvmrc b/.nvmrc
index 8351c1939..b6a7d89c6 100644
--- a/.nvmrc
+++ b/.nvmrc
@@ -1 +1 @@
-14
+16
diff --git a/.rubocop.yml b/.rubocop.yml
index aec11b030..67284fe34 100644
--- a/.rubocop.yml
+++ b/.rubocop.yml
@@ -1,12 +1,18 @@
 require:
   - rubocop-rails
+  - rubocop-rspec
+  - rubocop-performance
 
 AllCops:
   TargetRubyVersion: 2.7
-  NewCops: disable
+  DisplayCopNames: true
+  DisplayStyleGuide: true
+  ExtraDetails: true
+  UseCache: true
+  CacheRootDirectory: tmp
+  NewCops: enable
   Exclude:
-    - 'spec/**/*'
-    - 'db/**/*'
+    - db/schema.rb
     - 'app/views/**/*'
     - 'config/**/*'
     - 'bin/*'
@@ -67,15 +73,57 @@ Lint/UselessAccessModifier:
     - class_methods
 
 Metrics/AbcSize:
-  Max: 115
+  Max: 34 # RuboCop default 17
   Exclude:
-    - 'lib/mastodon/*_cli.rb'
+    - 'lib/**/*cli*.rb'
+    - db/*migrate/**/*
+    - lib/paperclip/color_extractor.rb
+    - app/workers/scheduler/follow_recommendations_scheduler.rb
+    - app/services/activitypub/fetch*_service.rb
+    - lib/paperclip/**/*
+  CountRepeatedAttributes: false
+  AllowedMethods:
+    - update_media_attachments!
+    - account_link_to
+    - attempt_oembed
+    - build_crutches
+    - calculate_scores
+    - cc
+    - dump_actor!
+    - filter_from_home?
+    - hydrate
+    - import_bookmarks!
+    - import_relationships!
+    - initialize
+    - link_to_mention
+    - log_target
+    - matches_time_window?
+    - parse_metadata
+    - perform_statuses_search!
+    - privatize_media_attachments!
+    - process_update
+    - publish_media_attachments!
+    - remotable_attachment
+    - render_initial_state
+    - render_with_cache
+    - searchable_by
+    - self.cached_filters_for
+    - set_fetchable_attributes!
+    - signed_request_actor
+    - statuses_to_delete
+    - update_poll!
 
 Metrics/BlockLength:
   Max: 55
   Exclude:
-    - 'lib/tasks/**/*'
     - 'lib/mastodon/*_cli.rb'
+  CountComments: false
+  CountAsOne: [array, heredoc]
+  AllowedMethods:
+    - task
+    - namespace
+    - class_methods
+    - included
 
 Metrics/BlockNesting:
   Max: 3
@@ -85,34 +133,144 @@ Metrics/BlockNesting:
 Metrics/ClassLength:
   CountComments: false
   Max: 500
+  CountAsOne: [array, heredoc]
   Exclude:
     - 'lib/mastodon/*_cli.rb'
 
 Metrics/CyclomaticComplexity:
-  Max: 25
+  Max: 12
   Exclude:
-    - 'lib/mastodon/*_cli.rb'
+    - lib/mastodon/*cli*.rb
+    - db/*migrate/**/*
+  AllowedMethods:
+    - attempt_oembed
+    - blocked?
+    - build_crutches
+    - calculate_scores
+    - cc
+    - discover_endpoint!
+    - filter_from_home?
+    - hydrate
+    - klass
+    - link_to_mention
+    - log_target
+    - matches_time_window?
+    - patch_for_forwarding!
+    - preprocess_attributes!
+    - process_update
+    - remotable_attachment
+    - scan_text!
+    - self.cached_filters_for
+    - set_fetchable_attributes!
+    - setup_redis_env_url
+    - update_media_attachments!
 
 Layout/LineLength:
+  Max: 140 # RuboCop default 120
+  AllowHeredoc: true
   AllowURI: true
-  Enabled: false
+  IgnoreCopDirectives: true
+  AllowedPatterns:
+    # Allow comments to be long lines
+    - !ruby/regexp / \# .*$/
+    - !ruby/regexp /^\# .*$/
+  Exclude:
+    - lib/**/*cli*.rb
+    - db/*migrate/**/*
+    - db/seeds/**/*
 
 Metrics/MethodLength:
   CountComments: false
-  Max: 65
+  CountAsOne: [array, heredoc]
+  Max: 25 # RuboCop default 10
   Exclude:
     - 'lib/mastodon/*_cli.rb'
+  AllowedMethods:
+    - account_link_to
+    - attempt_oembed
+    - body_with_limit
+    - build_crutches
+    - cached_filters_for
+    - calculate_scores
+    - check_webfinger!
+    - clean_feeds!
+    - collection_items
+    - collection_presenter
+    - copy_account_notes!
+    - deduplicate_accounts!
+    - deduplicate_conversations!
+    - deduplicate_local_accounts!
+    - deduplicate_statuses!
+    - deduplicate_tags!
+    - deduplicate_users!
+    - discover_endpoint!
+    - extract_extra_uris_with_indices
+    - extract_hashtags_with_indices
+    - extract_mentions_or_lists_with_indices
+    - filter_from_home?
+    - from_elasticsearch
+    - handle_explicit_update!
+    - handle_mark_as_sensitive!
+    - hsl_to_rgb
+    - import_bookmarks!
+    - import_domain_blocks!
+    - import_relationships!
+    - ldap_options
+    - matches_time_window?
+    - outbox_presenter
+    - pam_get_user
+    - parallelize_with_progress
+    - parse_and_transform
+    - patch_for_forwarding!
+    - populate_home
+    - post_process_style
+    - preload_cache_collection_target_statuses
+    - privatize_media_attachments!
+    - provides_callback_for
+    - publish_media_attachments!
+    - relevant_account_timestamp
+    - remotable_attachment
+    - rgb_to_hsl
+    - rss_status_content_format
+    - set_fetchable_attributes!
+    - setup_redis_env_url
+    - signed_request_actor
+    - to_preview_card_attributes
+    - upgrade_storage_filesystem
+    - upgrade_storage_s3
+    - user_settings_params
+    - hydrate
+    - cc
+    - self_destruct
 
 Metrics/ModuleLength:
   CountComments: false
   Max: 200
+  CountAsOne: [array, heredoc]
 
 Metrics/ParameterLists:
-  Max: 5
-  CountKeywordArgs: true
+  Max: 5 # RuboCop default 5
+  CountKeywordArgs: true  # RuboCop default true
+  MaxOptionalParameters: 3 # RuboCop default 3
+  Exclude:
+    - app/models/concerns/account_interactions.rb
+    - app/services/activitypub/fetch_remote_account_service.rb
+    - app/services/activitypub/fetch_remote_actor_service.rb
 
 Metrics/PerceivedComplexity:
-  Max: 25
+  Max: 16 # RuboCop default 8
+  AllowedMethods:
+    - attempt_oembed
+    - build_crutches
+    - calculate_scores
+    - deduplicate_users!
+    - discover_endpoint!
+    - filter_from_home?
+    - hydrate
+    - patch_for_forwarding!
+    - process_update
+    - remove_orphans
+    - update_media_attachments!
 
 Naming/MemoizedInstanceVariableName:
   Enabled: false
@@ -267,9 +425,6 @@ Style/PercentLiteralDelimiters:
 Style/PerlBackrefs:
   AutoCorrect: false
 
-Style/RedundantAssignment:
-  Enabled: false
-
 Style/RedundantFetchBlock:
   Enabled: true
 
@@ -292,7 +447,7 @@ Style/RegexpLiteral:
   Enabled: false
 
 Style/RescueStandardError:
-  Enabled: false
+  Enabled: true
 
 Style/SignalException:
   Enabled: false
@@ -311,3 +466,14 @@ Style/TrailingCommaInHashLiteral:
 
 Style/UnpackFirst:
   Enabled: false
+
+RSpec/ScatteredSetup:
+  Enabled: false
+RSpec/ImplicitExpect:
+  Enabled: false
+RSpec/NamedSubject:
+  Enabled: false
+RSpec/DescribeClass:
+  Enabled: false
+RSpec/LetSetup:
+  Enabled: false
diff --git a/Aptfile b/Aptfile
index a52eef4e1..8f5bb72a2 100644
--- a/Aptfile
+++ b/Aptfile
@@ -1,26 +1,4 @@
 ffmpeg
-libicu[0-9][0-9]
-libicu-dev
-libidn12
-libidn-dev
 libpq-dev
 libxdamage1
 libxfixes3
-zlib1g-dev
-libcairo2
-libcroco3
-libdatrie1
-libgdk-pixbuf2.0-0
-libgraphite2-3
-libharfbuzz0b
-libpango-1.0-0
-libpangocairo-1.0-0
-libpangoft2-1.0-0
-libpixman-1-0
-librsvg2-2
-libthai-data
-libthai0
-libvpx[5-9]
-libxcb-render0
-libxcb-shm0
-libxrender1
diff --git a/Dockerfile b/Dockerfile
index 1a97965ac..ce7f4d718 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -15,7 +15,8 @@ SHELL ["/bin/bash", "-o", "pipefail", "-c"]
 WORKDIR /opt/mastodon
 COPY Gemfile* package.json yarn.lock /opt/mastodon/
 
-RUN apt update && \
+# hadolint ignore=DL3008
+RUN apt-get update && \
     apt-get install -y --no-install-recommends build-essential \
         ca-certificates \
         git \
@@ -50,10 +51,12 @@ SHELL ["/bin/bash", "-o", "pipefail", "-c"]
 ENV DEBIAN_FRONTEND="noninteractive" \
     PATH="${PATH}:/opt/ruby/bin:/opt/mastodon/bin"
 
+# Ignoreing these here since we don't want to pin any versions and the Debian image removes apt-get content after use
+# hadolint ignore=DL3008,DL3009
 RUN apt-get update && \
     echo "Etc/UTC" > /etc/localtime && \
     groupadd -g "${GID}" mastodon && \
-    useradd -u "$UID" -g "${GID}" -m -d /opt/mastodon mastodon && \
+    useradd -l -u "$UID" -g "${GID}" -m -d /opt/mastodon mastodon && \
     apt-get -y --no-install-recommends install whois \
         wget \
         procps \
diff --git a/Gemfile b/Gemfile
index 42d30589f..099e84fc0 100644
--- a/Gemfile
+++ b/Gemfile
@@ -107,6 +107,10 @@ group :development, :test do
   gem 'pry-byebug', '~> 3.10'
   gem 'pry-rails', '~> 0.3'
   gem 'rspec-rails', '~> 5.1'
+  gem 'rubocop-performance', require: false
+  gem 'rubocop-rails', require: false
+  gem 'rubocop-rspec', require: false
+  gem 'rubocop', require: false
 end
 
 group :production, :test do
@@ -117,13 +121,14 @@ group :test do
   gem 'capybara', '~> 3.38'
   gem 'climate_control', '~> 0.2'
   gem 'faker', '~> 3.0'
+  gem 'json-schema', '~> 3.0'
   gem 'microformats', '~> 4.4'
+  gem 'rack-test', '~> 2.0'  
   gem 'rails-controller-testing', '~> 1.0'
+  gem 'rspec_junit_formatter', '~> 0.6'
   gem 'rspec-sidekiq', '~> 3.1'
   gem 'simplecov', '~> 0.21', require: false
   gem 'webmock', '~> 3.18'
-  gem 'rspec_junit_formatter', '~> 0.6'
-  gem 'rack-test', '~> 2.0'
 end
 
 group :development do
@@ -135,8 +140,6 @@ group :development do
   gem 'letter_opener', '~> 1.8'
   gem 'letter_opener_web', '~> 2.0'
   gem 'memory_profiler'
-  gem 'rubocop', '~> 1.30', require: false
-  gem 'rubocop-rails', '~> 2.15', require: false
   gem 'brakeman', '~> 5.4', require: false
   gem 'bundler-audit', '~> 0.9', require: false
 
diff --git a/Gemfile.lock b/Gemfile.lock
index 037b3b1ee..15d0e6001 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -346,6 +346,8 @@ GEM
     json-ld-preloaded (3.2.2)
       json-ld (~> 3.2)
       rdf (~> 3.2)
+    json-schema (3.0.0)
+      addressable (>= 2.8)
     jsonapi-renderer (0.2.2)
     jwt (2.4.1)
     kaminari (1.2.2)
@@ -587,21 +589,27 @@ GEM
     rspec-support (3.11.1)
     rspec_junit_formatter (0.6.0)
       rspec-core (>= 2, < 4, != 2.12.0)
-    rubocop (1.30.1)
+    rubocop (1.39.0)
+      json (~> 2.3)
       parallel (~> 1.10)
-      parser (>= 3.1.0.0)
+      parser (>= 3.1.2.1)
       rainbow (>= 2.2.2, < 4.0)
       regexp_parser (>= 1.8, < 3.0)
       rexml (>= 3.2.5, < 4.0)
-      rubocop-ast (>= 1.18.0, < 2.0)
+      rubocop-ast (>= 1.23.0, < 2.0)
       ruby-progressbar (~> 1.7)
       unicode-display_width (>= 1.4.0, < 3.0)
-    rubocop-ast (1.18.0)
+    rubocop-ast (1.23.0)
       parser (>= 3.1.1.0)
-    rubocop-rails (2.15.0)
+    rubocop-performance (1.15.1)
+      rubocop (>= 1.7.0, < 2.0)
+      rubocop-ast (>= 0.4.0)
+    rubocop-rails (2.17.2)
       activesupport (>= 4.2.0)
       rack (>= 1.1)
-      rubocop (>= 1.7.0, < 2.0)
+      rubocop (>= 1.33.0, < 2.0)
+    rubocop-rspec (2.15.0)
+      rubocop (~> 1.33)
     ruby-progressbar (1.11.0)
     ruby-saml (1.13.0)
       nokogiri (>= 1.10.5)
@@ -794,6 +802,7 @@ DEPENDENCIES
   idn-ruby
   json-ld
   json-ld-preloaded (~> 3.2)
+  json-schema (~> 3.0)
   kaminari (~> 1.2)
   kt-paperclip (~> 7.1)
   letter_opener (~> 1.8)
@@ -843,8 +852,10 @@ DEPENDENCIES
   rspec-rails (~> 5.1)
   rspec-sidekiq (~> 3.1)
   rspec_junit_formatter (~> 0.6)
-  rubocop (~> 1.30)
-  rubocop-rails (~> 2.15)
+  rubocop
+  rubocop-performance
+  rubocop-rails
+  rubocop-rspec
   ruby-progressbar (~> 1.11)
   sanitize (~> 6.0)
   scenic (~> 1.6)
@@ -869,3 +880,9 @@ DEPENDENCIES
   webpacker (~> 5.4)
   webpush!
   xorcist (~> 1.1)
+
+RUBY VERSION
+   ruby 3.0.4p208
+
+BUNDLED WITH
+   2.2.33
diff --git a/app/controllers/admin/domain_blocks_controller.rb b/app/controllers/admin/domain_blocks_controller.rb
index e79f7a43e..74764640b 100644
--- a/app/controllers/admin/domain_blocks_controller.rb
+++ b/app/controllers/admin/domain_blocks_controller.rb
@@ -55,12 +55,8 @@ module Admin
     def update
       authorize :domain_block, :update?
 
-      @domain_block.update(update_params)
-
-      severity_changed = @domain_block.severity_changed?
-
-      if @domain_block.save
-        DomainBlockWorker.perform_async(@domain_block.id, severity_changed)
+      if @domain_block.update(update_params)
+        DomainBlockWorker.perform_async(@domain_block.id, @domain_block.severity_previously_changed?)
         log_action :update, @domain_block
         redirect_to admin_instances_path(limited: '1'), notice: I18n.t('admin.domain_blocks.created_msg')
       else
diff --git a/app/controllers/admin/relays_controller.rb b/app/controllers/admin/relays_controller.rb
index 6fbb6e063..c1297c8b9 100644
--- a/app/controllers/admin/relays_controller.rb
+++ b/app/controllers/admin/relays_controller.rb
@@ -3,7 +3,7 @@
 module Admin
   class RelaysController < BaseController
     before_action :set_relay, except: [:index, :new, :create]
-    before_action :require_signatures_enabled!, only: [:new, :create, :enable]
+    before_action :warn_signatures_not_enabled!, only: [:new, :create, :enable]
 
     def index
       authorize :relay, :update?
@@ -56,8 +56,8 @@ module Admin
       params.require(:relay).permit(:inbox_url)
     end
 
-    def require_signatures_enabled!
-      redirect_to admin_relays_path, alert: I18n.t('admin.relays.signatures_not_enabled') if authorized_fetch_mode?
+    def warn_signatures_not_enabled!
+      flash.now[:error] = I18n.t('admin.relays.signatures_not_enabled') if authorized_fetch_mode?
     end
   end
 end
diff --git a/app/controllers/api/base_controller.rb b/app/controllers/api/base_controller.rb
index defef0656..41f3ce2ee 100644
--- a/app/controllers/api/base_controller.rb
+++ b/app/controllers/api/base_controller.rb
@@ -16,6 +16,26 @@ class Api::BaseController < ApplicationController
 
   protect_from_forgery with: :null_session
 
+  content_security_policy do |p|
+    # Set every directive that does not have a fallback
+    p.default_src :none
+    p.frame_ancestors :none
+    p.form_action :none
+
+    # Disable every directive with a fallback to cut on response size
+    p.base_uri false
+    p.font_src false
+    p.img_src false
+    p.style_src false
+    p.media_src false
+    p.frame_src false
+    p.manifest_src false
+    p.connect_src false
+    p.script_src false
+    p.child_src false
+    p.worker_src false
+  end
+
   rescue_from ActiveRecord::RecordInvalid, Mastodon::ValidationError do |e|
     render json: { error: e.to_s }, status: 422
   end
diff --git a/app/controllers/api/v1/admin/domain_blocks_controller.rb b/app/controllers/api/v1/admin/domain_blocks_controller.rb
index df5b1b3fc..8b77e9717 100644
--- a/app/controllers/api/v1/admin/domain_blocks_controller.rb
+++ b/app/controllers/api/v1/admin/domain_blocks_controller.rb
@@ -40,10 +40,8 @@ class Api::V1::Admin::DomainBlocksController < Api::BaseController
 
   def update
     authorize @domain_block, :update?
-    @domain_block.update(domain_block_params)
-    severity_changed = @domain_block.severity_changed?
-    @domain_block.save!
-    DomainBlockWorker.perform_async(@domain_block.id, severity_changed)
+    @domain_block.update!(domain_block_params)
+    DomainBlockWorker.perform_async(@domain_block.id, @domain_block.severity_previously_changed?)
     log_action :update, @domain_block
     render json: @domain_block, serializer: REST::Admin::DomainBlockSerializer
   end
diff --git a/app/controllers/api/v1/notifications_controller.rb b/app/controllers/api/v1/notifications_controller.rb
index ac49167cb..a6ed359c9 100644
--- a/app/controllers/api/v1/notifications_controller.rb
+++ b/app/controllers/api/v1/notifications_controller.rb
@@ -40,7 +40,7 @@ class Api::V1::NotificationsController < Api::BaseController
   private
 
   def load_notifications
-    notifications = browserable_account_notifications.includes(from_account: :account_stat).to_a_paginated_by_id(
+    notifications = browserable_account_notifications.includes(from_account: [:account_stat, :user]).to_a_paginated_by_id(
       limit_param(DEFAULT_NOTIFICATIONS_LIMIT),
       params_slice(:max_id, :since_id, :min_id)
     )
diff --git a/app/controllers/auth/passwords_controller.rb b/app/controllers/auth/passwords_controller.rb
index 609220eb1..576c3e7bc 100644
--- a/app/controllers/auth/passwords_controller.rb
+++ b/app/controllers/auth/passwords_controller.rb
@@ -11,6 +11,8 @@ class Auth::PasswordsController < Devise::PasswordsController
     super do |resource|
       if resource.errors.empty?
         resource.session_activations.destroy_all
+
+        resource.revoke_access!
       end
     end
   end
diff --git a/app/controllers/auth/registrations_controller.rb b/app/controllers/auth/registrations_controller.rb
index d363efeee..40c38bc6d 100644
--- a/app/controllers/auth/registrations_controller.rb
+++ b/app/controllers/auth/registrations_controller.rb
@@ -57,8 +57,8 @@ class Auth::RegistrationsController < Devise::RegistrationsController
   end
 
   def configure_sign_up_params
-    devise_parameter_sanitizer.permit(:sign_up) do |u|
-      u.permit({ account_attributes: [:username, :display_name], invite_request_attributes: [:text] }, :email, :password, :password_confirmation, :invite_code, :agreement, :website, :confirm_password)
+    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)
     end
   end
 
diff --git a/app/controllers/concerns/rate_limit_headers.rb b/app/controllers/concerns/rate_limit_headers.rb
index 86fe58a71..b8696df73 100644
--- a/app/controllers/concerns/rate_limit_headers.rb
+++ b/app/controllers/concerns/rate_limit_headers.rb
@@ -58,7 +58,7 @@ module RateLimitHeaders
   end
 
   def api_throttle_data
-    most_limited_type, = request.env['rack.attack.throttle_data'].min_by { |_, v| v[:limit] - v[:count] }
+    most_limited_type, = request.env['rack.attack.throttle_data'].min_by { |_key, value| value[:limit] - value[:count] }
     request.env['rack.attack.throttle_data'][most_limited_type]
   end
 
diff --git a/app/controllers/concerns/signature_verification.rb b/app/controllers/concerns/signature_verification.rb
index 2394574b3..4502da698 100644
--- a/app/controllers/concerns/signature_verification.rb
+++ b/app/controllers/concerns/signature_verification.rb
@@ -28,8 +28,8 @@ module SignatureVerification
   end
 
   class SignatureParamsTransformer < Parslet::Transform
-    rule(params: subtree(:p)) do
-      (p.is_a?(Array) ? p : [p]).each_with_object({}) { |(key, val), h| h[key] = val }
+    rule(params: subtree(:param)) do
+      (param.is_a?(Array) ? param : [param]).each_with_object({}) { |(key, value), hash| hash[key] = value }
     end
 
     rule(param: { key: simple(:key), value: simple(:val) }) do
diff --git a/app/controllers/follower_accounts_controller.rb b/app/controllers/follower_accounts_controller.rb
index 35ce31f80..1f5ed30de 100644
--- a/app/controllers/follower_accounts_controller.rb
+++ b/app/controllers/follower_accounts_controller.rb
@@ -63,7 +63,7 @@ class FollowerAccountsController < ApplicationController
     if page_requested?
       ActivityPub::CollectionPresenter.new(
         id: account_followers_url(@account, page: params.fetch(:page, 1)),
-        items: follows.map { |f| ActivityPub::TagManager.instance.uri_for(f.account) },
+        items: follows.map { |follow| ActivityPub::TagManager.instance.uri_for(follow.account) },
         part_of: account_followers_url(@account),
         next: next_page_url,
         prev: prev_page_url,
diff --git a/app/controllers/following_accounts_controller.rb b/app/controllers/following_accounts_controller.rb
index f84dca1e5..febd13c97 100644
--- a/app/controllers/following_accounts_controller.rb
+++ b/app/controllers/following_accounts_controller.rb
@@ -66,7 +66,7 @@ class FollowingAccountsController < ApplicationController
         id: account_following_index_url(@account, page: params.fetch(:page, 1)),
         type: :ordered,
         size: @account.following_count,
-        items: follows.map { |f| ActivityPub::TagManager.instance.uri_for(f.target_account) },
+        items: follows.map { |follow| ActivityPub::TagManager.instance.uri_for(follow.target_account) },
         part_of: account_following_index_url(@account),
         next: next_page_url,
         prev: prev_page_url
diff --git a/app/controllers/media_controller.rb b/app/controllers/media_controller.rb
index d2de432ba..f9160d8c4 100644
--- a/app/controllers/media_controller.rb
+++ b/app/controllers/media_controller.rb
@@ -13,8 +13,8 @@ class MediaController < ApplicationController
   before_action :allow_iframing, only: :player
   before_action :set_pack, only: :player
 
-  content_security_policy only: :player do |p|
-    p.frame_ancestors(false)
+  content_security_policy only: :player do |policy|
+    policy.frame_ancestors(false)
   end
 
   def show
diff --git a/app/controllers/statuses_controller.rb b/app/controllers/statuses_controller.rb
index 1a835c726..e5221df3a 100644
--- a/app/controllers/statuses_controller.rb
+++ b/app/controllers/statuses_controller.rb
@@ -17,8 +17,8 @@ class StatusesController < ApplicationController
   skip_around_action :set_locale, if: -> { request.format == :json }
   skip_before_action :require_functional!, only: [:show, :embed], unless: :whitelist_mode?
 
-  content_security_policy only: :embed do |p|
-    p.frame_ancestors(false)
+  content_security_policy only: :embed do |policy|
+    policy.frame_ancestors(false)
   end
 
   def show
diff --git a/app/controllers/tags_controller.rb b/app/controllers/tags_controller.rb
index f0a099350..65017acba 100644
--- a/app/controllers/tags_controller.rb
+++ b/app/controllers/tags_controller.rb
@@ -65,7 +65,7 @@ class TagsController < ApplicationController
       id: tag_url(@tag),
       type: :ordered,
       size: @tag.statuses.count,
-      items: @statuses.map { |s| ActivityPub::TagManager.instance.uri_for(s) }
+      items: @statuses.map { |status| ActivityPub::TagManager.instance.uri_for(status) }
     )
   end
 end
diff --git a/app/helpers/formatting_helper.rb b/app/helpers/formatting_helper.rb
index 448177bec..05c003037 100644
--- a/app/helpers/formatting_helper.rb
+++ b/app/helpers/formatting_helper.rb
@@ -23,19 +23,28 @@ module FormattingHelper
 
     before_html = begin
       if status.spoiler_text?
-        "<p><strong>#{I18n.t('rss.content_warning', locale: available_locale_or_nil(status.language) || I18n.default_locale)}</strong> #{h(status.spoiler_text)}</p><hr />"
-      else
-        ''
+        tag.p do
+          tag.strong do
+            I18n.t('rss.content_warning', locale: available_locale_or_nil(status.language) || I18n.default_locale)
+          end
+
+          status.spoiler_text
+        end + tag.hr
       end
-    end.html_safe # rubocop:disable Rails/OutputSafety
+    end
 
     after_html = begin
       if status.preloadable_poll
-        "<p>#{status.preloadable_poll.options.map { |o| "<input type=#{status.preloadable_poll.multiple? ? 'checkbox' : 'radio'} disabled /> #{h(o)}" }.join('<br />')}</p>"
-      else
-        ''
+        tag.p do
+          safe_join(
+            status.preloadable_poll.options.map do |o|
+              tag.send(status.preloadable_poll.multiple? ? 'checkbox' : 'radio', o, disabled: true)
+            end,
+            tag.br
+          )
+        end
       end
-    end.html_safe # rubocop:disable Rails/OutputSafety
+    end
 
     prerender_custom_emojis(
       safe_join([before_html, html, after_html]),
diff --git a/app/helpers/languages_helper.rb b/app/helpers/languages_helper.rb
index fff073ced..bb87dd596 100644
--- a/app/helpers/languages_helper.rb
+++ b/app/helpers/languages_helper.rb
@@ -190,12 +190,15 @@ module LanguagesHelper
   ISO_639_3 = {
     ast: ['Asturian', 'Asturianu'].freeze,
     ckb: ['Sorani (Kurdish)', 'سۆرانی'].freeze,
+    cnr: ['Montenegrin', 'crnogorski'].freeze,
     jbo: ['Lojban', 'la .lojban.'].freeze,
     kab: ['Kabyle', 'Taqbaylit'].freeze,
     kmr: ['Kurmanji (Kurdish)', 'Kurmancî'].freeze,
     ldn: ['Láadan', 'Láadan'].freeze,
     lfn: ['Lingua Franca Nova', 'lingua franca nova'].freeze,
     sco: ['Scots', 'Scots'].freeze,
+    sma: ['Southern Sami', 'Åarjelsaemien Gïele'].freeze,
+    smj: ['Lule Sami', 'Julevsámegiella'].freeze,
     tok: ['Toki Pona', 'toki pona'].freeze,
     zba: ['Balaibalan', 'باليبلن'].freeze,
     zgh: ['Standard Moroccan Tamazight', 'ⵜⴰⵎⴰⵣⵉⵖⵜ'].freeze,
diff --git a/app/helpers/statuses_helper.rb b/app/helpers/statuses_helper.rb
index 488eabeec..d1e3fddaf 100644
--- a/app/helpers/statuses_helper.rb
+++ b/app/helpers/statuses_helper.rb
@@ -21,7 +21,7 @@ module StatusesHelper
   def media_summary(status)
     attachments = { image: 0, video: 0, audio: 0 }
 
-    status.media_attachments.each do |media|
+    status.ordered_media_attachments.each do |media|
       if media.video?
         attachments[:video] += 1
       elsif media.audio?
diff --git a/app/javascript/flavours/glitch/components/status_action_bar.js b/app/javascript/flavours/glitch/components/status_action_bar.js
index 977c98ccb..7949b2db3 100644
--- a/app/javascript/flavours/glitch/components/status_action_bar.js
+++ b/app/javascript/flavours/glitch/components/status_action_bar.js
@@ -212,11 +212,13 @@ class StatusActionBar extends ImmutablePureComponent {
 
     menu.push({ text: intl.formatMessage(messages.open), action: this.handleOpen });
 
+    if (publicStatus && isRemote) {
+      menu.push({ text: intl.formatMessage(messages.openOriginalPage), href: status.get('url') });
+    }
+
+    menu.push({ text: intl.formatMessage(messages.copy), action: this.handleCopy });
+
     if (publicStatus) {
-      if (isRemote) {
-        menu.push({ text: intl.formatMessage(messages.openOriginalPage), href: status.get('url') });
-      }
-      menu.push({ text: intl.formatMessage(messages.copy), action: this.handleCopy });
       menu.push({ text: intl.formatMessage(messages.embed), action: this.handleEmbed });
     }
 
diff --git a/app/javascript/flavours/glitch/containers/mastodon.js b/app/javascript/flavours/glitch/containers/mastodon.js
index 6c92376d8..dd7623a81 100644
--- a/app/javascript/flavours/glitch/containers/mastodon.js
+++ b/app/javascript/flavours/glitch/containers/mastodon.js
@@ -27,8 +27,9 @@ store.dispatch(hydrateAction);
 // check for deprecated local settings
 store.dispatch(checkDeprecatedLocalSettings());
 
-// load custom emojis
-store.dispatch(fetchCustomEmojis());
+if (initialState.meta.me) {
+  store.dispatch(fetchCustomEmojis());
+}
 
 const createIdentityContext = state => ({
   signedIn: !!state.meta.me,
diff --git a/app/javascript/flavours/glitch/features/account/components/follow_request_note.js b/app/javascript/flavours/glitch/features/account/components/follow_request_note.js
new file mode 100644
index 000000000..73c1737a6
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/account/components/follow_request_note.js
@@ -0,0 +1,37 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { FormattedMessage } from 'react-intl';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import Icon from 'flavours/glitch/components/icon';
+
+export default class FollowRequestNote extends ImmutablePureComponent {
+
+  static propTypes = {
+    account: ImmutablePropTypes.map.isRequired,
+  };
+
+  render () {
+    const { account, onAuthorize, onReject } = this.props;
+
+    return (
+      <div className='follow-request-banner'>
+        <div className='follow-request-banner__message'>
+          <FormattedMessage id='account.requested_follow' defaultMessage='{name} has requested to follow you' values={{ name: <bdi><strong dangerouslySetInnerHTML={{ __html: account.get('display_name_html') }} /></bdi> }} />
+        </div>
+
+        <div className='follow-request-banner__action'>
+          <button type='button' className='button button-tertiary button--confirmation' onClick={onAuthorize}>
+            <Icon id='check' fixedWidth />
+            <FormattedMessage id='follow_request.authorize' defaultMessage='Authorize' />
+          </button>
+
+          <button type='button' className='button button-tertiary button--destructive' onClick={onReject}>
+            <Icon id='times' fixedWidth />
+            <FormattedMessage id='follow_request.reject' defaultMessage='Reject' />
+          </button>
+        </div>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/account/components/header.js b/app/javascript/flavours/glitch/features/account/components/header.js
index d261c6ea5..b0fb527c7 100644
--- a/app/javascript/flavours/glitch/features/account/components/header.js
+++ b/app/javascript/flavours/glitch/features/account/components/header.js
@@ -13,6 +13,7 @@ import Button from 'flavours/glitch/components/button';
 import { NavLink } from 'react-router-dom';
 import DropdownMenuContainer from 'flavours/glitch/containers/dropdown_menu_container';
 import AccountNoteContainer from '../containers/account_note_container';
+import FollowRequestNoteContainer from '../containers/follow_request_note_container';
 import { PERMISSION_MANAGE_USERS } from 'flavours/glitch/permissions';
 import { Helmet } from 'react-helmet';
 
@@ -314,6 +315,8 @@ class Header extends ImmutablePureComponent {
 
     return (
       <div className={classNames('account__header', { inactive: !!account.get('moved') })} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}>
+        {!(suspended || hidden || account.get('moved')) && account.getIn(['relationship', 'requested_by']) && <FollowRequestNoteContainer account={account} />}
+
         <div className='account__header__image'>
           <div className='account__header__info'>
             {info}
diff --git a/app/javascript/flavours/glitch/features/account/containers/follow_request_note_container.js b/app/javascript/flavours/glitch/features/account/containers/follow_request_note_container.js
new file mode 100644
index 000000000..c6a3afb7e
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/account/containers/follow_request_note_container.js
@@ -0,0 +1,15 @@
+import { connect } from 'react-redux';
+import FollowRequestNote from '../components/follow_request_note';
+import { authorizeFollowRequest, rejectFollowRequest } from 'flavours/glitch/actions/accounts';
+
+const mapDispatchToProps = (dispatch, { account }) => ({
+  onAuthorize () {
+    dispatch(authorizeFollowRequest(account.get('id')));
+  },
+
+  onReject () {
+    dispatch(rejectFollowRequest(account.get('id')));
+  },
+});
+
+export default connect(null, mapDispatchToProps)(FollowRequestNote);
diff --git a/app/javascript/flavours/glitch/features/account_gallery/components/media_item.js b/app/javascript/flavours/glitch/features/account_gallery/components/media_item.js
index f7a7fd467..f20ee685e 100644
--- a/app/javascript/flavours/glitch/features/account_gallery/components/media_item.js
+++ b/app/javascript/flavours/glitch/features/account_gallery/components/media_item.js
@@ -104,6 +104,7 @@ export default class MediaItem extends ImmutablePureComponent {
           <video
             className='media-gallery__item-gifv-thumbnail'
             aria-label={attachment.get('description')}
+            title={attachment.get('description')}
             role='application'
             src={attachment.get('url')}
             onMouseEnter={this.handleMouseEnter}
diff --git a/app/javascript/flavours/glitch/features/compose/components/poll_form.js b/app/javascript/flavours/glitch/features/compose/components/poll_form.js
index d5edccff3..afb5da097 100644
--- a/app/javascript/flavours/glitch/features/compose/components/poll_form.js
+++ b/app/javascript/flavours/glitch/features/compose/components/poll_form.js
@@ -153,6 +153,7 @@ class PollForm extends ImmutablePureComponent {
             <option value={1800}>{intl.formatMessage(messages.minutes, { number: 30 })}</option>
             <option value={3600}>{intl.formatMessage(messages.hours, { number: 1 })}</option>
             <option value={21600}>{intl.formatMessage(messages.hours, { number: 6 })}</option>
+            <option value={43200}>{intl.formatMessage(messages.hours, { number: 12 })}</option>
             <option value={86400}>{intl.formatMessage(messages.days, { number: 1 })}</option>
             <option value={259200}>{intl.formatMessage(messages.days, { number: 3 })}</option>
             <option value={604800}>{intl.formatMessage(messages.days, { number: 7 })}</option>
diff --git a/app/javascript/flavours/glitch/features/compose/components/search.js b/app/javascript/flavours/glitch/features/compose/components/search.js
index 326fe5b70..9f90a767d 100644
--- a/app/javascript/flavours/glitch/features/compose/components/search.js
+++ b/app/javascript/flavours/glitch/features/compose/components/search.js
@@ -144,33 +144,24 @@ class Search extends React.PureComponent {
 
     return (
       <div className='search'>
-        <label>
-          <span style={{ display: 'none' }}>{intl.formatMessage(messages.placeholder)}</span>
-          <input
-            ref={this.setRef}
-            className='search__input'
-            type='text'
-            placeholder={intl.formatMessage(signedIn ? messages.placeholderSignedIn : messages.placeholder)}
-            value={value || ''}
-            onChange={this.handleChange}
-            onKeyUp={this.handleKeyUp}
-            onFocus={this.handleFocus}
-            onBlur={this.handleBlur}
-          />
-        </label>
-
-        <div
-          aria-label={intl.formatMessage(messages.placeholder)}
-          className='search__icon'
-          onClick={this.handleClear}
-          role='button'
-          tabIndex='0'
-        >
+        <input
+          ref={this.setRef}
+          className='search__input'
+          type='text'
+          placeholder={intl.formatMessage(signedIn ? messages.placeholderSignedIn : messages.placeholder)}
+          aria-label={intl.formatMessage(signedIn ? messages.placeholderSignedIn : messages.placeholder)}
+          value={value || ''}
+          onChange={this.handleChange}
+          onKeyUp={this.handleKeyUp}
+          onFocus={this.handleFocus}
+          onBlur={this.handleBlur}
+        />
+
+        <div role='button' tabIndex='0' className='search__icon' onClick={this.handleClear}>
           <Icon id='search' className={hasValue ? '' : 'active'} />
           <Icon id='times-circle' className={hasValue ? 'active' : ''} />
         </div>
-
-        <Overlay show={expanded && !hasValue} placement='bottom' target={this}>
+        <Overlay show={expanded && !hasValue} placement='bottom' target={this} container={this}>
           <SearchPopout />
         </Overlay>
       </div>
diff --git a/app/javascript/flavours/glitch/features/explore/index.js b/app/javascript/flavours/glitch/features/explore/index.js
index ba435d7e3..da0dc7f7c 100644
--- a/app/javascript/flavours/glitch/features/explore/index.js
+++ b/app/javascript/flavours/glitch/features/explore/index.js
@@ -24,16 +24,6 @@ const mapStateToProps = state => ({
   isSearching: state.getIn(['search', 'submitted']) || !showTrends,
 });
 
-// Fix strange bug on Safari where <span> (rendered by FormattedMessage) disappears
-// after clicking around Explore top bar (issue #20885).
-// Removing width=100% from <a> also fixes it, as well as replacing <span> with <div>
-// We're choosing to wrap span with div to keep the changes local only to this tool bar.
-const WrapFormattedMessage = ({ children, ...props }) => <div><FormattedMessage {...props}>{children}</FormattedMessage></div>;
-WrapFormattedMessage.propTypes = {
-  children: PropTypes.any,
-};
-
-
 export default @connect(mapStateToProps)
 @injectIntl
 class Explore extends React.PureComponent {
@@ -78,12 +68,22 @@ class Explore extends React.PureComponent {
           {isSearching ? (
             <SearchResults />
           ) : (
-            <React.Fragment>
+            <>
               <div className='account__section-headline'>
-                <NavLink exact to='/explore'><WrapFormattedMessage id='explore.trending_statuses' defaultMessage='Posts' /></NavLink>
-                <NavLink exact to='/explore/tags'><WrapFormattedMessage id='explore.trending_tags' defaultMessage='Hashtags' /></NavLink>
-                <NavLink exact to='/explore/links'><WrapFormattedMessage id='explore.trending_links' defaultMessage='News' /></NavLink>
-                {signedIn && <NavLink exact to='/explore/suggestions'><WrapFormattedMessage id='explore.suggested_follows' defaultMessage='For you' /></NavLink>}
+                <NavLink exact to='/explore'>
+                  <FormattedMessage tagName='div' id='explore.trending_statuses' defaultMessage='Posts' />
+                </NavLink>
+                <NavLink exact to='/explore/tags'>
+                  <FormattedMessage tagName='div' id='explore.trending_tags' defaultMessage='Hashtags' />
+                </NavLink>
+                <NavLink exact to='/explore/links'>
+                  <FormattedMessage tagName='div' id='explore.trending_links' defaultMessage='News' />
+                </NavLink>
+                {signedIn && (
+                  <NavLink exact to='/explore/suggestions'>
+                    <FormattedMessage tagName='div' id='explore.suggested_follows' defaultMessage='For you' />
+                  </NavLink>
+                )}
               </div>
 
               <Switch>
@@ -97,7 +97,7 @@ class Explore extends React.PureComponent {
                 <title>{intl.formatMessage(messages.title)}</title>
                 <meta name='robots' content={isSearching ? 'noindex' : 'all'} />
               </Helmet>
-            </React.Fragment>
+            </>
           )}
         </div>
       </Column>
diff --git a/app/javascript/flavours/glitch/features/hashtag_timeline/index.js b/app/javascript/flavours/glitch/features/hashtag_timeline/index.js
index 4428fdecf..219dc0ec6 100644
--- a/app/javascript/flavours/glitch/features/hashtag_timeline/index.js
+++ b/app/javascript/flavours/glitch/features/hashtag_timeline/index.js
@@ -194,7 +194,7 @@ class HashtagTimeline extends React.PureComponent {
       const following = tag.get('following');
 
       followButton = (
-        <button className={classNames('column-header__button')} onClick={this.handleFollow} disabled={!signedIn} title={intl.formatMessage(following ? messages.unfollowHashtag : messages.followHashtag)} aria-label={intl.formatMessage(following ? messages.unfollowHashtag : messages.followHashtag)}>
+        <button className={classNames('column-header__button')} onClick={this.handleFollow} disabled={!signedIn} active={following} title={intl.formatMessage(following ? messages.unfollowHashtag : messages.followHashtag)} aria-label={intl.formatMessage(following ? messages.unfollowHashtag : messages.followHashtag)}>
           <Icon id={following ? 'user-times' : 'user-plus'} fixedWidth className='column-header__icon' />
         </button>
       );
diff --git a/app/javascript/flavours/glitch/features/ui/components/columns_area.js b/app/javascript/flavours/glitch/features/ui/components/columns_area.js
index bf3e79c24..993a50796 100644
--- a/app/javascript/flavours/glitch/features/ui/components/columns_area.js
+++ b/app/javascript/flavours/glitch/features/ui/components/columns_area.js
@@ -99,7 +99,7 @@ export default class ColumnsArea extends ImmutablePureComponent {
       if (this.mediaQuery.removeEventListener) {
         this.mediaQuery.removeEventListener('change', this.handleLayoutChange);
       } else {
-        this.mediaQuery.removeListener(this.handleLayouteChange);
+        this.mediaQuery.removeListener(this.handleLayoutChange);
       }
     }
   }
diff --git a/app/javascript/flavours/glitch/features/ui/components/focal_point_modal.js b/app/javascript/flavours/glitch/features/ui/components/focal_point_modal.js
index de330b3a1..0dd07fb76 100644
--- a/app/javascript/flavours/glitch/features/ui/components/focal_point_modal.js
+++ b/app/javascript/flavours/glitch/features/ui/components/focal_point_modal.js
@@ -291,11 +291,11 @@ class FocalPointModal extends ImmutablePureComponent {
     let descriptionLabel = null;
 
     if (media.get('type') === 'audio') {
-      descriptionLabel = <FormattedMessage id='upload_form.audio_description' defaultMessage='Describe for people with hearing loss' />;
+      descriptionLabel = <FormattedMessage id='upload_form.audio_description' defaultMessage='Describe for people who are hard of hearing' />;
     } else if (media.get('type') === 'video') {
-      descriptionLabel = <FormattedMessage id='upload_form.video_description' defaultMessage='Describe for people with hearing loss or visual impairment' />;
+      descriptionLabel = <FormattedMessage id='upload_form.video_description' defaultMessage='Describe for people who are deaf, hard of hearing, blind or have low vision' />;
     } else {
-      descriptionLabel = <FormattedMessage id='upload_form.description' defaultMessage='Describe for the visually impaired' />;
+      descriptionLabel = <FormattedMessage id='upload_form.description' defaultMessage='Describe for people who are blind or have low vision' />;
     }
 
     let ocrMessage = '';
diff --git a/app/javascript/flavours/glitch/packs/public.js b/app/javascript/flavours/glitch/packs/public.js
index 843fd5163..b256fdbd5 100644
--- a/app/javascript/flavours/glitch/packs/public.js
+++ b/app/javascript/flavours/glitch/packs/public.js
@@ -42,6 +42,18 @@ function main() {
       minute: 'numeric',
     });
 
+    const dateFormat = new Intl.DateTimeFormat(locale, {
+      year: 'numeric',
+      month: 'short',
+      day: 'numeric',
+      timeFormat: false,
+    });
+
+    const timeFormat = new Intl.DateTimeFormat(locale, {
+      timeStyle: 'short',
+      hour12: false,
+    });
+
     [].forEach.call(document.querySelectorAll('.emojify'), (content) => {
       content.innerHTML = emojify(content.innerHTML);
     });
@@ -54,6 +66,32 @@ function main() {
       content.textContent = formattedDate;
     });
 
+    const isToday = date => {
+      const today = new Date();
+
+      return date.getDate() === today.getDate() &&
+        date.getMonth() === today.getMonth() &&
+        date.getFullYear() === today.getFullYear();
+    };
+    const todayFormat = new IntlMessageFormat(messages['relative_format.today'] || 'Today at {time}', locale);
+
+    [].forEach.call(document.querySelectorAll('time.relative-formatted'), (content) => {
+      const datetime = new Date(content.getAttribute('datetime'));
+
+      let formattedContent;
+
+      if (isToday(datetime)) {
+        const formattedTime = timeFormat.format(datetime);
+
+        formattedContent = todayFormat.format({ time: formattedTime });
+      } else {
+        formattedContent = dateFormat.format(datetime);
+      }
+
+      content.title = formattedContent;
+      content.textContent = formattedContent;
+    });
+
     [].forEach.call(document.querySelectorAll('time.time-ago'), (content) => {
       const datetime = new Date(content.getAttribute('datetime'));
       const now      = new Date();
diff --git a/app/javascript/flavours/glitch/reducers/compose.js b/app/javascript/flavours/glitch/reducers/compose.js
index ddc3fa41e..814b6a1a7 100644
--- a/app/javascript/flavours/glitch/reducers/compose.js
+++ b/app/javascript/flavours/glitch/reducers/compose.js
@@ -538,6 +538,8 @@ export default function compose(state = initialState, action) {
   case TIMELINE_DELETE:
     if (action.id === state.get('in_reply_to')) {
       return state.set('in_reply_to', null);
+    } else if (action.id === state.get('id')) {
+      return state.set('id', null);
     } else {
       return state;
     }
diff --git a/app/javascript/flavours/glitch/reducers/relationships.js b/app/javascript/flavours/glitch/reducers/relationships.js
index 49dd77ef5..e4b9acea2 100644
--- a/app/javascript/flavours/glitch/reducers/relationships.js
+++ b/app/javascript/flavours/glitch/reducers/relationships.js
@@ -1,4 +1,7 @@
 import {
+  NOTIFICATIONS_UPDATE,
+} from '../actions/notifications';
+import {
   ACCOUNT_FOLLOW_SUCCESS,
   ACCOUNT_FOLLOW_REQUEST,
   ACCOUNT_FOLLOW_FAIL,
@@ -12,6 +15,8 @@ import {
   ACCOUNT_PIN_SUCCESS,
   ACCOUNT_UNPIN_SUCCESS,
   RELATIONSHIPS_FETCH_SUCCESS,
+  FOLLOW_REQUEST_AUTHORIZE_SUCCESS,
+  FOLLOW_REQUEST_REJECT_SUCCESS,
 } from 'flavours/glitch/actions/accounts';
 import {
   DOMAIN_BLOCK_SUCCESS,
@@ -44,6 +49,12 @@ const initialState = ImmutableMap();
 
 export default function relationships(state = initialState, action) {
   switch(action.type) {
+  case FOLLOW_REQUEST_AUTHORIZE_SUCCESS:
+    return state.setIn([action.id, 'followed_by'], true).setIn([action.id, 'requested_by'], false);
+  case FOLLOW_REQUEST_REJECT_SUCCESS:
+    return state.setIn([action.id, 'followed_by'], false).setIn([action.id, 'requested_by'], false);
+  case NOTIFICATIONS_UPDATE:
+    return action.notification.type === 'follow_request' ? state.setIn([action.notification.account.id, 'requested_by'], true) : state;
   case ACCOUNT_FOLLOW_REQUEST:
     return state.getIn([action.id, 'following']) ? state : state.setIn([action.id, action.locked ? 'requested' : 'following'], true);
   case ACCOUNT_FOLLOW_FAIL:
diff --git a/app/javascript/flavours/glitch/styles/components/accounts.scss b/app/javascript/flavours/glitch/styles/components/accounts.scss
index ed1d3d660..5b3e1db1b 100644
--- a/app/javascript/flavours/glitch/styles/components/accounts.scss
+++ b/app/javascript/flavours/glitch/styles/components/accounts.scss
@@ -523,7 +523,6 @@
       display: block;
       flex: 0 0 auto;
       width: 94px;
-      margin-left: -2px;
 
       .account__avatar {
         background: darken($ui-base-color, 8%);
@@ -540,6 +539,7 @@
     margin-top: -55px;
     gap: 8px;
     overflow: hidden;
+    margin-left: -2px; // aligns the pfp with content below
 
     &__buttons {
       display: flex;
@@ -756,3 +756,37 @@
     }
   }
 }
+
+.moved-account-banner,
+.follow-request-banner {
+  padding: 20px;
+  background: lighten($ui-base-color, 4%);
+  display: flex;
+  align-items: center;
+  flex-direction: column;
+  &__message {
+    color: $darker-text-color;
+    padding: 8px 0;
+    padding-top: 0;
+    padding-bottom: 4px;
+    font-size: 14px;
+    font-weight: 500;
+    text-align: center;
+    margin-bottom: 16px;
+  }
+  &__action {
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    gap: 15px;
+    width: 100%;
+  }
+
+  .detailed-status__display-name {
+    margin-bottom: 0;
+  }
+}
+
+.follow-request-banner .button {
+  width: 100%;
+}
diff --git a/app/javascript/flavours/glitch/styles/components/index.scss b/app/javascript/flavours/glitch/styles/components/index.scss
index 84aca2ebc..b7a54cd2b 100644
--- a/app/javascript/flavours/glitch/styles/components/index.scss
+++ b/app/javascript/flavours/glitch/styles/components/index.scss
@@ -141,6 +141,30 @@
     &:disabled {
       opacity: 0.5;
     }
+
+    &.button--confirmation {
+      color: $valid-value-color;
+      border-color: $valid-value-color;
+
+      &:active,
+      &:focus,
+      &:hover {
+        background: $valid-value-color;
+        color: $primary-text-color;
+      }
+    }
+
+    &.button--destructive {
+      color: $error-value-color;
+      border-color: $error-value-color;
+
+      &:active,
+      &:focus,
+      &:hover {
+        background: $error-value-color;
+        color: $primary-text-color;
+      }
+    }
   }
 
   &.button--block {
diff --git a/app/javascript/flavours/glitch/styles/components/search.scss b/app/javascript/flavours/glitch/styles/components/search.scss
index 70af0f651..b8078bdb6 100644
--- a/app/javascript/flavours/glitch/styles/components/search.scss
+++ b/app/javascript/flavours/glitch/styles/components/search.scss
@@ -1,4 +1,5 @@
 .search {
+  margin-bottom: 10px;
   position: relative;
 }
 
diff --git a/app/javascript/flavours/glitch/styles/components/single_column.scss b/app/javascript/flavours/glitch/styles/components/single_column.scss
index 45d57aedd..74e5d0884 100644
--- a/app/javascript/flavours/glitch/styles/components/single_column.scss
+++ b/app/javascript/flavours/glitch/styles/components/single_column.scss
@@ -227,8 +227,7 @@
     height: calc(100% - 10px) !important;
   }
 
-  .getting-started__wrapper,
-  .search {
+  .getting-started__wrapper {
     margin-bottom: 10px;
   }
 
@@ -281,7 +280,7 @@
     }
   }
 
-  .ui__header {
+  .layout-single-column .ui__header {
     display: flex;
     background: $ui-base-color;
     border-bottom: 1px solid lighten($ui-base-color, 8%);
diff --git a/app/javascript/flavours/glitch/styles/components/status.scss b/app/javascript/flavours/glitch/styles/components/status.scss
index 7cc6f5386..39184ba7a 100644
--- a/app/javascript/flavours/glitch/styles/components/status.scss
+++ b/app/javascript/flavours/glitch/styles/components/status.scss
@@ -1067,7 +1067,7 @@ a.status-card.compact:hover {
       pointer-events: 0;
       width: 100%;
       height: 100%;
-      border-left: 2px solid $highlight-text-color;
+      border-left: 4px solid $highlight-text-color;
       pointer-events: none;
     }
   }
diff --git a/app/javascript/flavours/glitch/styles/modal.scss b/app/javascript/flavours/glitch/styles/modal.scss
index 6c6de4206..a333926dd 100644
--- a/app/javascript/flavours/glitch/styles/modal.scss
+++ b/app/javascript/flavours/glitch/styles/modal.scss
@@ -1,5 +1,5 @@
 .modal-layout {
-  background: $ui-base-color 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)}"/></svg>') repeat-x bottom fixed;
+  background: $ui-base-color 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>') repeat-x bottom fixed;
   display: flex;
   flex-direction: column;
   height: 100vh;
diff --git a/app/javascript/images/logo-symbol-icon.svg b/app/javascript/images/logo-symbol-icon.svg
index 56cf03921..c4c14f098 100644
--- a/app/javascript/images/logo-symbol-icon.svg
+++ b/app/javascript/images/logo-symbol-icon.svg
@@ -1,2 +1,2 @@
-<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="79" height="79" viewBox="0 0 79 75"><symbol id="logo-symbol-icon"><path d="M74.7135 16.6043C73.6199 8.54587 66.5351 2.19527 58.1366 0.964691C56.7196 0.756754 51.351 0 38.9148 0H38.822C26.3824 0 23.7135 0.756754 22.2966 0.964691C14.1319 2.16118 6.67571 7.86752 4.86669 16.0214C3.99657 20.0369 3.90371 24.4888 4.06535 28.5726C4.29578 34.4289 4.34049 40.275 4.877 46.1075C5.24791 49.9817 5.89495 53.8251 6.81328 57.6088C8.53288 64.5968 15.4938 70.4122 22.3138 72.7848C29.6155 75.259 37.468 75.6697 44.9919 73.971C45.8196 73.7801 46.6381 73.5586 47.4475 73.3063C49.2737 72.7302 51.4164 72.086 52.9915 70.9542C53.0131 70.9384 53.0308 70.9178 53.0433 70.8942C53.0558 70.8706 53.0628 70.8445 53.0637 70.8179V65.1661C53.0634 65.1412 53.0574 65.1167 53.0462 65.0944C53.035 65.0721 53.0189 65.0525 52.9992 65.0371C52.9794 65.0218 52.9564 65.011 52.9318 65.0056C52.9073 65.0002 52.8819 65.0003 52.8574 65.0059C48.0369 66.1472 43.0971 66.7193 38.141 66.7103C29.6118 66.7103 27.3178 62.6981 26.6609 61.0278C26.1329 59.5842 25.7976 58.0784 25.6636 56.5486C25.6622 56.5229 25.667 56.4973 25.6775 56.4738C25.688 56.4502 25.7039 56.4295 25.724 56.4132C25.7441 56.397 25.7678 56.3856 25.7931 56.3801C25.8185 56.3746 25.8448 56.3751 25.8699 56.3816C30.6101 57.5151 35.4693 58.0873 40.3455 58.086C41.5183 58.086 42.6876 58.086 43.8604 58.0553C48.7647 57.919 53.9339 57.6701 58.7591 56.7361C58.8794 56.7123 58.9998 56.6918 59.103 56.6611C66.7139 55.2124 73.9569 50.665 74.6929 39.1501C74.7204 38.6967 74.7892 34.4016 74.7892 33.9312C74.7926 32.3325 75.3085 22.5901 74.7135 16.6043ZM62.9996 45.3371H54.9966V25.9069C54.9966 21.8163 53.277 19.7302 49.7793 19.7302C45.9343 19.7302 44.0083 22.1981 44.0083 27.0727V37.7082H36.0534V27.0727C36.0534 22.1981 34.124 19.7302 30.279 19.7302C26.8019 19.7302 25.0651 21.8163 25.0617 25.9069V45.3371H17.0656V25.3172C17.0656 21.2266 18.1191 17.9769 20.2262 15.568C22.3998 13.1648 25.2509 11.9308 28.7898 11.9308C32.8859 11.9308 35.9812 13.492 38.0447 16.6111L40.036 19.9245L42.0308 16.6111C44.0943 13.492 47.1896 11.9308 51.2788 11.9308C54.8143 11.9308 57.6654 13.1648 59.8459 15.568C61.9529 17.9746 63.0065 21.2243 63.0065 25.3172L62.9996 45.3371Z" fill="currentColor"/></symbol><use xlink:href="#logo-symbol-icon" style="color:#fff" /></svg>
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="79" height="79" viewBox="0 0 79 75"><symbol id="logo-symbol-icon"><path d="M74.7135 16.6043C73.6199 8.54587 66.5351 2.19527 58.1366 0.964691C56.7196 0.756754 51.351 0 38.9148 0H38.822C26.3824 0 23.7135 0.756754 22.2966 0.964691C14.1319 2.16118 6.67571 7.86752 4.86669 16.0214C3.99657 20.0369 3.90371 24.4888 4.06535 28.5726C4.29578 34.4289 4.34049 40.275 4.877 46.1075C5.24791 49.9817 5.89495 53.8251 6.81328 57.6088C8.53288 64.5968 15.4938 70.4122 22.3138 72.7848C29.6155 75.259 37.468 75.6697 44.9919 73.971C45.8196 73.7801 46.6381 73.5586 47.4475 73.3063C49.2737 72.7302 51.4164 72.086 52.9915 70.9542C53.0131 70.9384 53.0308 70.9178 53.0433 70.8942C53.0558 70.8706 53.0628 70.8445 53.0637 70.8179V65.1661C53.0634 65.1412 53.0574 65.1167 53.0462 65.0944C53.035 65.0721 53.0189 65.0525 52.9992 65.0371C52.9794 65.0218 52.9564 65.011 52.9318 65.0056C52.9073 65.0002 52.8819 65.0003 52.8574 65.0059C48.0369 66.1472 43.0971 66.7193 38.141 66.7103C29.6118 66.7103 27.3178 62.6981 26.6609 61.0278C26.1329 59.5842 25.7976 58.0784 25.6636 56.5486C25.6622 56.5229 25.667 56.4973 25.6775 56.4738C25.688 56.4502 25.7039 56.4295 25.724 56.4132C25.7441 56.397 25.7678 56.3856 25.7931 56.3801C25.8185 56.3746 25.8448 56.3751 25.8699 56.3816C30.6101 57.5151 35.4693 58.0873 40.3455 58.086C41.5183 58.086 42.6876 58.086 43.8604 58.0553C48.7647 57.919 53.9339 57.6701 58.7591 56.7361C58.8794 56.7123 58.9998 56.6918 59.103 56.6611C66.7139 55.2124 73.9569 50.665 74.6929 39.1501C74.7204 38.6967 74.7892 34.4016 74.7892 33.9312C74.7926 32.3325 75.3085 22.5901 74.7135 16.6043ZM62.9996 45.3371H54.9966V25.9069C54.9966 21.8163 53.277 19.7302 49.7793 19.7302C45.9343 19.7302 44.0083 22.1981 44.0083 27.0727V37.7082H36.0534V27.0727C36.0534 22.1981 34.124 19.7302 30.279 19.7302C26.8019 19.7302 25.0651 21.8163 25.0617 25.9069V45.3371H17.0656V25.3172C17.0656 21.2266 18.1191 17.9769 20.2262 15.568C22.3998 13.1648 25.2509 11.9308 28.7898 11.9308C32.8859 11.9308 35.9812 13.492 38.0447 16.6111L40.036 19.9245L42.0308 16.6111C44.0943 13.492 47.1896 11.9308 51.2788 11.9308C54.8143 11.9308 57.6654 13.1648 59.8459 15.568C61.9529 17.9746 63.0065 21.2243 63.0065 25.3172L62.9996 45.3371Z" fill="currentColor"/></symbol><use xlink:href="#logo-symbol-icon"/></svg>
 
diff --git a/app/javascript/images/logo-symbol-wordmark.svg b/app/javascript/images/logo-symbol-wordmark.svg
index 7e7f7b087..ee0b636d9 100644
--- a/app/javascript/images/logo-symbol-wordmark.svg
+++ b/app/javascript/images/logo-symbol-wordmark.svg
@@ -7,5 +7,5 @@
 <stop stop-color="#6364FF"/>
 <stop offset="1" stop-color="#563ACC"/>
 </linearGradient>
-</defs></symbol><use xlink:href="#logo-symbol-wordmark" style="color:#fff"/>
+</defs></symbol><use xlink:href="#logo-symbol-wordmark"/>
 </svg>
diff --git a/app/javascript/mastodon/actions/announcements.js b/app/javascript/mastodon/actions/announcements.js
index 1bdea909f..586dcfd33 100644
--- a/app/javascript/mastodon/actions/announcements.js
+++ b/app/javascript/mastodon/actions/announcements.js
@@ -102,7 +102,7 @@ export const addReaction = (announcementId, name) => (dispatch, getState) => {
     dispatch(addReactionRequest(announcementId, name, alreadyAdded));
   }
 
-  api(getState).put(`/api/v1/announcements/${announcementId}/reactions/${name}`).then(() => {
+  api(getState).put(`/api/v1/announcements/${announcementId}/reactions/${encodeURIComponent(name)}`).then(() => {
     dispatch(addReactionSuccess(announcementId, name, alreadyAdded));
   }).catch(err => {
     if (!alreadyAdded) {
@@ -136,7 +136,7 @@ export const addReactionFail = (announcementId, name, error) => ({
 export const removeReaction = (announcementId, name) => (dispatch, getState) => {
   dispatch(removeReactionRequest(announcementId, name));
 
-  api(getState).delete(`/api/v1/announcements/${announcementId}/reactions/${name}`).then(() => {
+  api(getState).delete(`/api/v1/announcements/${announcementId}/reactions/${encodeURIComponent(name)}`).then(() => {
     dispatch(removeReactionSuccess(announcementId, name));
   }).catch(err => {
     dispatch(removeReactionFail(announcementId, name, err));
diff --git a/app/javascript/mastodon/components/status_action_bar.js b/app/javascript/mastodon/components/status_action_bar.js
index 2a1fedb93..40c86afdf 100644
--- a/app/javascript/mastodon/components/status_action_bar.js
+++ b/app/javascript/mastodon/components/status_action_bar.js
@@ -246,12 +246,13 @@ class StatusActionBar extends ImmutablePureComponent {
 
     menu.push({ text: intl.formatMessage(messages.open), action: this.handleOpen });
 
-    if (publicStatus) {
-      if (isRemote) {
-        menu.push({ text: intl.formatMessage(messages.openOriginalPage), href: status.get('url') });
-      }
+    if (publicStatus && isRemote) {
+      menu.push({ text: intl.formatMessage(messages.openOriginalPage), href: status.get('url') });
+    }
 
-      menu.push({ text: intl.formatMessage(messages.copy), action: this.handleCopy });
+    menu.push({ text: intl.formatMessage(messages.copy), action: this.handleCopy });
+
+    if (publicStatus) {
       menu.push({ text: intl.formatMessage(messages.embed), action: this.handleEmbed });
     }
 
diff --git a/app/javascript/mastodon/containers/mastodon.js b/app/javascript/mastodon/containers/mastodon.js
index 724719f74..002b71e93 100644
--- a/app/javascript/mastodon/containers/mastodon.js
+++ b/app/javascript/mastodon/containers/mastodon.js
@@ -23,7 +23,9 @@ export const store = configureStore();
 const hydrateAction = hydrateStore(initialState);
 
 store.dispatch(hydrateAction);
-store.dispatch(fetchCustomEmojis());
+if (initialState.meta.me) {
+  store.dispatch(fetchCustomEmojis());
+}
 
 const createIdentityContext = state => ({
   signedIn: !!state.meta.me,
diff --git a/app/javascript/mastodon/features/account/components/follow_request_note.js b/app/javascript/mastodon/features/account/components/follow_request_note.js
new file mode 100644
index 000000000..300ae4266
--- /dev/null
+++ b/app/javascript/mastodon/features/account/components/follow_request_note.js
@@ -0,0 +1,37 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { FormattedMessage } from 'react-intl';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import Icon from 'mastodon/components/icon';
+
+export default class FollowRequestNote extends ImmutablePureComponent {
+
+  static propTypes = {
+    account: ImmutablePropTypes.map.isRequired,
+  };
+
+  render () {
+    const { account, onAuthorize, onReject } = this.props;
+
+    return (
+      <div className='follow-request-banner'>
+        <div className='follow-request-banner__message'>
+          <FormattedMessage id='account.requested_follow' defaultMessage='{name} has requested to follow you' values={{ name: <bdi><strong dangerouslySetInnerHTML={{ __html: account.get('display_name_html') }} /></bdi> }} />
+        </div>
+
+        <div className='follow-request-banner__action'>
+          <button type='button' className='button button-tertiary button--confirmation' onClick={onAuthorize}>
+            <Icon id='check' fixedWidth />
+            <FormattedMessage id='follow_request.authorize' defaultMessage='Authorize' />
+          </button>
+
+          <button type='button' className='button button-tertiary button--destructive' onClick={onReject}>
+            <Icon id='times' fixedWidth />
+            <FormattedMessage id='follow_request.reject' defaultMessage='Reject' />
+          </button>
+        </div>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/mastodon/features/account/components/header.js b/app/javascript/mastodon/features/account/components/header.js
index f117412be..dddbf4dd4 100644
--- a/app/javascript/mastodon/features/account/components/header.js
+++ b/app/javascript/mastodon/features/account/components/header.js
@@ -14,6 +14,7 @@ import ShortNumber from 'mastodon/components/short_number';
 import { NavLink } from 'react-router-dom';
 import DropdownMenuContainer from 'mastodon/containers/dropdown_menu_container';
 import AccountNoteContainer from '../containers/account_note_container';
+import FollowRequestNoteContainer from '../containers/follow_request_note_container';
 import { PERMISSION_MANAGE_USERS } from 'mastodon/permissions';
 import { Helmet } from 'react-helmet';
 
@@ -311,6 +312,8 @@ class Header extends ImmutablePureComponent {
 
     return (
       <div className={classNames('account__header', { inactive: !!account.get('moved') })} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}>
+        {!(suspended || hidden || account.get('moved')) && account.getIn(['relationship', 'requested_by']) && <FollowRequestNoteContainer account={account} />}
+
         <div className='account__header__image'>
           <div className='account__header__info'>
             {!suspended && info}
diff --git a/app/javascript/mastodon/features/account/containers/follow_request_note_container.js b/app/javascript/mastodon/features/account/containers/follow_request_note_container.js
new file mode 100644
index 000000000..c33c3de59
--- /dev/null
+++ b/app/javascript/mastodon/features/account/containers/follow_request_note_container.js
@@ -0,0 +1,15 @@
+import { connect } from 'react-redux';
+import FollowRequestNote from '../components/follow_request_note';
+import { authorizeFollowRequest, rejectFollowRequest } from 'mastodon/actions/accounts';
+
+const mapDispatchToProps = (dispatch, { account }) => ({
+  onAuthorize () {
+    dispatch(authorizeFollowRequest(account.get('id')));
+  },
+
+  onReject () {
+    dispatch(rejectFollowRequest(account.get('id')));
+  },
+});
+
+export default connect(null, mapDispatchToProps)(FollowRequestNote);
diff --git a/app/javascript/mastodon/features/account_gallery/components/media_item.js b/app/javascript/mastodon/features/account_gallery/components/media_item.js
index f16fe07f1..13fd7fe03 100644
--- a/app/javascript/mastodon/features/account_gallery/components/media_item.js
+++ b/app/javascript/mastodon/features/account_gallery/components/media_item.js
@@ -104,6 +104,7 @@ export default class MediaItem extends ImmutablePureComponent {
           <video
             className='media-gallery__item-gifv-thumbnail'
             aria-label={attachment.get('description')}
+            title={attachment.get('description')}
             role='application'
             src={attachment.get('url')}
             onMouseEnter={this.handleMouseEnter}
diff --git a/app/javascript/mastodon/features/compose/components/compose_form.js b/app/javascript/mastodon/features/compose/components/compose_form.js
index 55ffecb49..abd3ba2f7 100644
--- a/app/javascript/mastodon/features/compose/components/compose_form.js
+++ b/app/javascript/mastodon/features/compose/components/compose_form.js
@@ -16,7 +16,6 @@ import PollFormContainer from '../containers/poll_form_container';
 import UploadFormContainer from '../containers/upload_form_container';
 import WarningContainer from '../containers/warning_container';
 import LanguageDropdown from '../containers/language_dropdown_container';
-import { isMobile } from '../../../is_mobile';
 import ImmutablePureComponent from 'react-immutable-pure-component';
 import { length } from 'stringz';
 import { countableText } from '../util/counter';
@@ -62,14 +61,14 @@ class ComposeForm extends ImmutablePureComponent {
     onChangeSpoilerText: PropTypes.func.isRequired,
     onPaste: PropTypes.func.isRequired,
     onPickEmoji: PropTypes.func.isRequired,
-    showSearch: PropTypes.bool,
+    autoFocus: PropTypes.bool,
     anyMedia: PropTypes.bool,
     isInReply: PropTypes.bool,
     singleColumn: PropTypes.bool,
   };
 
   static defaultProps = {
-    showSearch: false,
+    autoFocus: false,
   };
 
   handleChange = (e) => {
@@ -155,7 +154,7 @@ class ComposeForm extends ImmutablePureComponent {
     //     - Replying to zero or one users, places the cursor at the end of the textbox.
     //     - Replying to more than one user, selects any usernames past the first;
     //       this provides a convenient shortcut to drop everyone else from the conversation.
-    if (this.props.focusDate !== prevProps.focusDate) {
+    if (this.props.focusDate && this.props.focusDate !== prevProps.focusDate) {
       let selectionEnd, selectionStart;
 
       if (this.props.preselectDate !== prevProps.preselectDate && this.props.isInReply) {
@@ -181,7 +180,7 @@ class ComposeForm extends ImmutablePureComponent {
     } else if (this.props.spoiler !== prevProps.spoiler) {
       if (this.props.spoiler) {
         this.spoilerText.input.focus();
-      } else {
+      } else if (prevProps.spoiler) {
         this.autosuggestTextarea.textarea.focus();
       }
     }
@@ -208,7 +207,7 @@ class ComposeForm extends ImmutablePureComponent {
   }
 
   render () {
-    const { intl, onPaste, showSearch } = this.props;
+    const { intl, onPaste, autoFocus } = this.props;
     const disabled = this.props.isSubmitting;
 
     let publishText = '';
@@ -258,7 +257,7 @@ class ComposeForm extends ImmutablePureComponent {
           onSuggestionsClearRequested={this.onSuggestionsClearRequested}
           onSuggestionSelected={this.onSuggestionSelected}
           onPaste={onPaste}
-          autoFocus={!showSearch && !isMobile(window.innerWidth)}
+          autoFocus={autoFocus}
         >
           <EmojiPickerDropdown onPickEmoji={this.handleEmojiPick} />
 
diff --git a/app/javascript/mastodon/features/compose/components/poll_form.js b/app/javascript/mastodon/features/compose/components/poll_form.js
index 47ba2fdc3..3aa527161 100644
--- a/app/javascript/mastodon/features/compose/components/poll_form.js
+++ b/app/javascript/mastodon/features/compose/components/poll_form.js
@@ -165,6 +165,7 @@ class PollForm extends ImmutablePureComponent {
             <option value={1800}>{intl.formatMessage(messages.minutes, { number: 30 })}</option>
             <option value={3600}>{intl.formatMessage(messages.hours, { number: 1 })}</option>
             <option value={21600}>{intl.formatMessage(messages.hours, { number: 6 })}</option>
+            <option value={43200}>{intl.formatMessage(messages.hours, { number: 12 })}</option>
             <option value={86400}>{intl.formatMessage(messages.days, { number: 1 })}</option>
             <option value={259200}>{intl.formatMessage(messages.days, { number: 3 })}</option>
             <option value={604800}>{intl.formatMessage(messages.days, { number: 7 })}</option>
diff --git a/app/javascript/mastodon/features/compose/components/search.js b/app/javascript/mastodon/features/compose/components/search.js
index ebb23d92f..8254fb607 100644
--- a/app/javascript/mastodon/features/compose/components/search.js
+++ b/app/javascript/mastodon/features/compose/components/search.js
@@ -123,27 +123,24 @@ class Search extends React.PureComponent {
 
     return (
       <div className='search'>
-        <label>
-          <span style={{ display: 'none' }}>{intl.formatMessage(messages.placeholder)}</span>
-          <input
-            ref={this.setRef}
-            className='search__input'
-            type='text'
-            placeholder={intl.formatMessage(signedIn ? messages.placeholderSignedIn : messages.placeholder)}
-            value={value}
-            onChange={this.handleChange}
-            onKeyUp={this.handleKeyUp}
-            onFocus={this.handleFocus}
-            onBlur={this.handleBlur}
-          />
-        </label>
+        <input
+          ref={this.setRef}
+          className='search__input'
+          type='text'
+          placeholder={intl.formatMessage(signedIn ? messages.placeholderSignedIn : messages.placeholder)}
+          aria-label={intl.formatMessage(signedIn ? messages.placeholderSignedIn : messages.placeholder)}
+          value={value}
+          onChange={this.handleChange}
+          onKeyUp={this.handleKeyUp}
+          onFocus={this.handleFocus}
+          onBlur={this.handleBlur}
+        />
 
         <div role='button' tabIndex='0' className='search__icon' onClick={this.handleClear}>
           <Icon id='search' className={hasValue ? '' : 'active'} />
           <Icon id='times-circle' className={hasValue ? 'active' : ''} aria-label={intl.formatMessage(messages.placeholder)} />
         </div>
-
-        <Overlay show={expanded && !hasValue} placement='bottom' target={this}>
+        <Overlay show={expanded && !hasValue} placement='bottom' target={this} container={this}>
           <SearchPopout />
         </Overlay>
       </div>
diff --git a/app/javascript/mastodon/features/compose/containers/compose_form_container.js b/app/javascript/mastodon/features/compose/containers/compose_form_container.js
index 1be7633cc..14cf9230b 100644
--- a/app/javascript/mastodon/features/compose/containers/compose_form_container.js
+++ b/app/javascript/mastodon/features/compose/containers/compose_form_container.js
@@ -24,7 +24,6 @@ const mapStateToProps = state => ({
   isEditing: state.getIn(['compose', 'id']) !== null,
   isChangingUpload: state.getIn(['compose', 'is_changing_upload']),
   isUploading: state.getIn(['compose', 'is_uploading']),
-  showSearch: state.getIn(['search', 'submitted']) && !state.getIn(['search', 'hidden']),
   anyMedia: state.getIn(['compose', 'media_attachments']).size > 0,
   isInReply: state.getIn(['compose', 'in_reply_to']) !== null,
 });
diff --git a/app/javascript/mastodon/features/compose/index.js b/app/javascript/mastodon/features/compose/index.js
index f744fc611..aead7776a 100644
--- a/app/javascript/mastodon/features/compose/index.js
+++ b/app/javascript/mastodon/features/compose/index.js
@@ -18,6 +18,7 @@ import Icon from 'mastodon/components/icon';
 import { logOut } from 'mastodon/utils/log_out';
 import Column from 'mastodon/components/column';
 import { Helmet } from 'react-helmet';
+import { isMobile } from '../../is_mobile';
 
 const messages = defineMessages({
   start: { id: 'getting_started.heading', defaultMessage: 'Getting started' },
@@ -115,7 +116,7 @@ class Compose extends React.PureComponent {
             <div className='drawer__inner' onFocus={this.onFocus}>
               <NavigationContainer onClose={this.onBlur} />
 
-              <ComposeFormContainer />
+              <ComposeFormContainer autoFocus={!isMobile(window.innerWidth)} />
 
               <div className='drawer__inner__mastodon'>
                 <img alt='' draggable='false' src={mascot || elephantUIPlane} />
diff --git a/app/javascript/mastodon/features/explore/index.js b/app/javascript/mastodon/features/explore/index.js
index 286170c9f..1ae249f45 100644
--- a/app/javascript/mastodon/features/explore/index.js
+++ b/app/javascript/mastodon/features/explore/index.js
@@ -24,16 +24,6 @@ const mapStateToProps = state => ({
   isSearching: state.getIn(['search', 'submitted']) || !showTrends,
 });
 
-// Fix strange bug on Safari where <span> (rendered by FormattedMessage) disappears
-// after clicking around Explore top bar (issue #20885).
-// Removing width=100% from <a> also fixes it, as well as replacing <span> with <div>
-// We're choosing to wrap span with div to keep the changes local only to this tool bar.
-const WrapFormattedMessage = ({ children, ...props }) => <div><FormattedMessage {...props}>{children}</FormattedMessage></div>;
-WrapFormattedMessage.propTypes = {
-  children: PropTypes.any,
-};
-
-
 export default @connect(mapStateToProps)
 @injectIntl
 class Explore extends React.PureComponent {
@@ -78,12 +68,22 @@ class Explore extends React.PureComponent {
           {isSearching ? (
             <SearchResults />
           ) : (
-            <React.Fragment>
+            <>
               <div className='account__section-headline'>
-                <NavLink exact to='/explore'><WrapFormattedMessage id='explore.trending_statuses' defaultMessage='Posts' /></NavLink>
-                <NavLink exact to='/explore/tags'><WrapFormattedMessage id='explore.trending_tags' defaultMessage='Hashtags' /></NavLink>
-                <NavLink exact to='/explore/links'><WrapFormattedMessage id='explore.trending_links' defaultMessage='News' /></NavLink>
-                {signedIn && <NavLink exact to='/explore/suggestions'><WrapFormattedMessage id='explore.suggested_follows' defaultMessage='For you' /></NavLink>}
+                <NavLink exact to='/explore'>
+                  <FormattedMessage tagName='div' id='explore.trending_statuses' defaultMessage='Posts' />
+                </NavLink>
+                <NavLink exact to='/explore/tags'>
+                  <FormattedMessage tagName='div' id='explore.trending_tags' defaultMessage='Hashtags' />
+                </NavLink>
+                <NavLink exact to='/explore/links'>
+                  <FormattedMessage tagName='div' id='explore.trending_links' defaultMessage='News' />
+                </NavLink>
+                {signedIn && (
+                  <NavLink exact to='/explore/suggestions'>
+                    <FormattedMessage tagName='div' id='explore.suggested_follows' defaultMessage='For you' />
+                  </NavLink>
+                )}
               </div>
 
               <Switch>
@@ -97,7 +97,7 @@ class Explore extends React.PureComponent {
                 <title>{intl.formatMessage(messages.title)}</title>
                 <meta name='robots' content={isSearching ? 'noindex' : 'all'} />
               </Helmet>
-            </React.Fragment>
+            </>
           )}
         </div>
       </Column>
diff --git a/app/javascript/mastodon/features/hashtag_timeline/index.js b/app/javascript/mastodon/features/hashtag_timeline/index.js
index b635c3529..733f54ff3 100644
--- a/app/javascript/mastodon/features/hashtag_timeline/index.js
+++ b/app/javascript/mastodon/features/hashtag_timeline/index.js
@@ -194,7 +194,7 @@ class HashtagTimeline extends React.PureComponent {
       const following = tag.get('following');
 
       followButton = (
-        <button className={classNames('column-header__button')} onClick={this.handleFollow} disabled={!signedIn} title={intl.formatMessage(following ? messages.unfollowHashtag : messages.followHashtag)} aria-label={intl.formatMessage(following ? messages.unfollowHashtag : messages.followHashtag)}>
+        <button className={classNames('column-header__button')} onClick={this.handleFollow} disabled={!signedIn} active={following} title={intl.formatMessage(following ? messages.unfollowHashtag : messages.followHashtag)} aria-label={intl.formatMessage(following ? messages.unfollowHashtag : messages.followHashtag)}>
           <Icon id={following ? 'user-times' : 'user-plus'} fixedWidth className='column-header__icon' />
         </button>
       );
diff --git a/app/javascript/mastodon/features/ui/components/columns_area.js b/app/javascript/mastodon/features/ui/components/columns_area.js
index f4824f045..e7def800e 100644
--- a/app/javascript/mastodon/features/ui/components/columns_area.js
+++ b/app/javascript/mastodon/features/ui/components/columns_area.js
@@ -97,7 +97,7 @@ export default class ColumnsArea extends ImmutablePureComponent {
       if (this.mediaQuery.removeEventListener) {
         this.mediaQuery.removeEventListener('change', this.handleLayoutChange);
       } else {
-        this.mediaQuery.removeListener(this.handleLayouteChange);
+        this.mediaQuery.removeListener(this.handleLayoutChange);
       }
     }
   }
diff --git a/app/javascript/mastodon/features/ui/components/focal_point_modal.js b/app/javascript/mastodon/features/ui/components/focal_point_modal.js
index ba8aa8f03..479f4abd2 100644
--- a/app/javascript/mastodon/features/ui/components/focal_point_modal.js
+++ b/app/javascript/mastodon/features/ui/components/focal_point_modal.js
@@ -291,11 +291,11 @@ class FocalPointModal extends ImmutablePureComponent {
     let descriptionLabel = null;
 
     if (media.get('type') === 'audio') {
-      descriptionLabel = <FormattedMessage id='upload_form.audio_description' defaultMessage='Describe for people with hearing loss' />;
+      descriptionLabel = <FormattedMessage id='upload_form.audio_description' defaultMessage='Describe for people who are hard of hearing' />;
     } else if (media.get('type') === 'video') {
-      descriptionLabel = <FormattedMessage id='upload_form.video_description' defaultMessage='Describe for people with hearing loss or visual impairment' />;
+      descriptionLabel = <FormattedMessage id='upload_form.video_description' defaultMessage='Describe for people who are deaf, hard of hearing, blind or have low vision' />;
     } else {
-      descriptionLabel = <FormattedMessage id='upload_form.description' defaultMessage='Describe for the visually impaired' />;
+      descriptionLabel = <FormattedMessage id='upload_form.description' defaultMessage='Describe for people who are blind or have low vision' />;
     }
 
     let ocrMessage = '';
diff --git a/app/javascript/mastodon/locales/defaultMessages.json b/app/javascript/mastodon/locales/defaultMessages.json
index 445ab3894..4312e7b84 100644
--- a/app/javascript/mastodon/locales/defaultMessages.json
+++ b/app/javascript/mastodon/locales/defaultMessages.json
@@ -2014,6 +2014,22 @@
       {
         "defaultMessage": "Search results",
         "id": "explore.search_results"
+      },
+      {
+        "defaultMessage": "Posts",
+        "id": "explore.trending_statuses"
+      },
+      {
+        "defaultMessage": "Hashtags",
+        "id": "explore.trending_tags"
+      },
+      {
+        "defaultMessage": "News",
+        "id": "explore.trending_links"
+      },
+      {
+        "defaultMessage": "For you",
+        "id": "explore.suggested_follows"
       }
     ],
     "path": "app/javascript/mastodon/features/explore/index.json"
@@ -3918,15 +3934,15 @@
         "id": "confirmations.discard_edit_media.confirm"
       },
       {
-        "defaultMessage": "Describe for people with hearing loss",
+        "defaultMessage": "Describe for people who are deaf or hard of hearing",
         "id": "upload_form.audio_description"
       },
       {
-        "defaultMessage": "Describe for people with hearing loss or visual impairment",
+        "defaultMessage": "Describe for people who are deaf, hard of hearing, blind or have low vision",
         "id": "upload_form.video_description"
       },
       {
-        "defaultMessage": "Describe for the visually impaired",
+        "defaultMessage": "Describe for people who are blind or have low vision",
         "id": "upload_form.description"
       },
       {
diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json
index 4230e0dac..cb190b496 100644
--- a/app/javascript/mastodon/locales/en.json
+++ b/app/javascript/mastodon/locales/en.json
@@ -239,7 +239,11 @@
   "errors.unexpected_crash.copy_stacktrace": "Copy stacktrace to clipboard",
   "errors.unexpected_crash.report_issue": "Report issue",
   "explore.search_results": "Search results",
+  "explore.suggested_follows": "For you",
   "explore.title": "Explore",
+  "explore.trending_links": "News",
+  "explore.trending_statuses": "Posts",
+  "explore.trending_tags": "Hashtags",
   "filter_modal.added.context_mismatch_explanation": "This filter category does not apply to the context in which you have accessed this post. If you want the post to be filtered in this context too, you will have to edit the filter.",
   "filter_modal.added.context_mismatch_title": "Context mismatch!",
   "filter_modal.added.expired_explanation": "This filter category has expired, you will need to change the expiration date for it to apply.",
@@ -462,6 +466,7 @@
   "refresh": "Refresh",
   "regeneration_indicator.label": "Loading…",
   "regeneration_indicator.sublabel": "Your home feed is being prepared!",
+  "relative_format.today": "Today at {time}",
   "relative_time.days": "{number}d",
   "relative_time.full.days": "{number, plural, one {# day} other {# days}} ago",
   "relative_time.full.hours": "{number, plural, one {# hour} other {# hours}} ago",
@@ -622,13 +627,13 @@
   "upload_button.label": "Add images, a video or an audio file",
   "upload_error.limit": "File upload limit exceeded.",
   "upload_error.poll": "File upload not allowed with polls.",
-  "upload_form.audio_description": "Describe for people with hearing loss",
-  "upload_form.description": "Describe for the visually impaired",
+  "upload_form.audio_description": "Describe for people who are deaf or hard of hearing",
+  "upload_form.description": "Describe for people who are blind or have low vision",
   "upload_form.description_missing": "No description added",
   "upload_form.edit": "Edit",
   "upload_form.thumbnail": "Change thumbnail",
   "upload_form.undo": "Delete",
-  "upload_form.video_description": "Describe for people with hearing loss or visual impairment",
+  "upload_form.video_description": "Describe for people who are deaf, hard of hearing, blind or have low vision",
   "upload_modal.analyzing_picture": "Analyzing picture…",
   "upload_modal.apply": "Apply",
   "upload_modal.applying": "Applying…",
diff --git a/app/javascript/mastodon/reducers/compose.js b/app/javascript/mastodon/reducers/compose.js
index 9496b56f8..60b0cfb57 100644
--- a/app/javascript/mastodon/reducers/compose.js
+++ b/app/javascript/mastodon/reducers/compose.js
@@ -431,6 +431,8 @@ export default function compose(state = initialState, action) {
   case TIMELINE_DELETE:
     if (action.id === state.get('in_reply_to')) {
       return state.set('in_reply_to', null);
+    } else if (action.id === state.get('id')) {
+      return state.set('id', null);
     } else {
       return state;
     }
diff --git a/app/javascript/mastodon/reducers/relationships.js b/app/javascript/mastodon/reducers/relationships.js
index 53949258a..850ece351 100644
--- a/app/javascript/mastodon/reducers/relationships.js
+++ b/app/javascript/mastodon/reducers/relationships.js
@@ -1,4 +1,7 @@
 import {
+  NOTIFICATIONS_UPDATE,
+} from '../actions/notifications';
+import {
   ACCOUNT_FOLLOW_SUCCESS,
   ACCOUNT_FOLLOW_REQUEST,
   ACCOUNT_FOLLOW_FAIL,
@@ -12,6 +15,8 @@ import {
   ACCOUNT_PIN_SUCCESS,
   ACCOUNT_UNPIN_SUCCESS,
   RELATIONSHIPS_FETCH_SUCCESS,
+  FOLLOW_REQUEST_AUTHORIZE_SUCCESS,
+  FOLLOW_REQUEST_REJECT_SUCCESS,
 } from '../actions/accounts';
 import {
   DOMAIN_BLOCK_SUCCESS,
@@ -44,6 +49,12 @@ const initialState = ImmutableMap();
 
 export default function relationships(state = initialState, action) {
   switch(action.type) {
+  case FOLLOW_REQUEST_AUTHORIZE_SUCCESS:
+    return state.setIn([action.id, 'followed_by'], true).setIn([action.id, 'requested_by'], false);
+  case FOLLOW_REQUEST_REJECT_SUCCESS:
+    return state.setIn([action.id, 'followed_by'], false).setIn([action.id, 'requested_by'], false);
+  case NOTIFICATIONS_UPDATE:
+    return action.notification.type === 'follow_request' ? state.setIn([action.notification.account.id, 'requested_by'], true) : state;
   case ACCOUNT_FOLLOW_REQUEST:
     return state.getIn([action.id, 'following']) ? state : state.setIn([action.id, action.locked ? 'requested' : 'following'], true);
   case ACCOUNT_FOLLOW_FAIL:
diff --git a/app/javascript/packs/public.js b/app/javascript/packs/public.js
index ab7c4a3f3..8017734d5 100644
--- a/app/javascript/packs/public.js
+++ b/app/javascript/packs/public.js
@@ -46,6 +46,18 @@ function main() {
       minute: 'numeric',
     });
 
+    const dateFormat = new Intl.DateTimeFormat(locale, {
+      year: 'numeric',
+      month: 'short',
+      day: 'numeric',
+      timeFormat: false,
+    });
+
+    const timeFormat = new Intl.DateTimeFormat(locale, {
+      timeStyle: 'short',
+      hour12: false,
+    });
+
     [].forEach.call(document.querySelectorAll('.emojify'), (content) => {
       content.innerHTML = emojify(content.innerHTML);
     });
@@ -58,6 +70,32 @@ function main() {
       content.textContent = formattedDate;
     });
 
+    const isToday = date => {
+      const today = new Date();
+
+      return date.getDate() === today.getDate() &&
+        date.getMonth() === today.getMonth() &&
+        date.getFullYear() === today.getFullYear();
+    };
+    const todayFormat = new IntlMessageFormat(messages['relative_format.today'] || 'Today at {time}', locale);
+
+    [].forEach.call(document.querySelectorAll('time.relative-formatted'), (content) => {
+      const datetime = new Date(content.getAttribute('datetime'));
+
+      let formattedContent;
+
+      if (isToday(datetime)) {
+        const formattedTime = timeFormat.format(datetime);
+
+        formattedContent = todayFormat.format({ time: formattedTime });
+      } else {
+        formattedContent = dateFormat.format(datetime);
+      }
+
+      content.title = formattedContent;
+      content.textContent = formattedContent;
+    });
+
     [].forEach.call(document.querySelectorAll('time.time-ago'), (content) => {
       const datetime = new Date(content.getAttribute('datetime'));
       const now      = new Date();
diff --git a/app/javascript/styles/mastodon/admin.scss b/app/javascript/styles/mastodon/admin.scss
index 77a837e21..3de70c969 100644
--- a/app/javascript/styles/mastodon/admin.scss
+++ b/app/javascript/styles/mastodon/admin.scss
@@ -1682,7 +1682,7 @@ a.sparkline {
   min-height: 100%;
 
   a {
-    text: &highlight-text-color;
+    color: $highlight-text-color;
     text-decoration: none;
 
     &:hover {
diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss
index 44aef6f4e..713144f7d 100644
--- a/app/javascript/styles/mastodon/components.scss
+++ b/app/javascript/styles/mastodon/components.scss
@@ -166,6 +166,30 @@
     &:disabled {
       opacity: 0.5;
     }
+
+    &.button--confirmation {
+      color: $valid-value-color;
+      border-color: $valid-value-color;
+
+      &:active,
+      &:focus,
+      &:hover {
+        background: $valid-value-color;
+        color: $primary-text-color;
+      }
+    }
+
+    &.button--destructive {
+      color: $error-value-color;
+      border-color: $error-value-color;
+
+      &:active,
+      &:focus,
+      &:hover {
+        background: $error-value-color;
+        color: $primary-text-color;
+      }
+    }
   }
 
   &.button--block {
@@ -2474,8 +2498,7 @@ $ui-header-height: 55px;
     height: calc(100% - 10px) !important;
   }
 
-  .getting-started__wrapper,
-  .search {
+  .getting-started__wrapper {
     margin-bottom: 10px;
   }
 
@@ -2528,7 +2551,7 @@ $ui-header-height: 55px;
     }
   }
 
-  .ui__header {
+  .layout-single-column .ui__header {
     display: flex;
     background: $ui-base-color;
     border-bottom: 1px solid lighten($ui-base-color, 8%);
@@ -4671,6 +4694,7 @@ a.status-card.compact:hover {
 }
 
 .search {
+  margin-bottom: 10px;
   position: relative;
 }
 
@@ -6722,7 +6746,8 @@ noscript {
   }
 }
 
-.moved-account-banner {
+.moved-account-banner,
+.follow-request-banner {
   padding: 20px;
   background: lighten($ui-base-color, 4%);
   display: flex;
@@ -6745,6 +6770,7 @@ noscript {
     justify-content: space-between;
     align-items: center;
     gap: 15px;
+    width: 100%;
   }
 
   .detailed-status__display-name {
@@ -6752,6 +6778,10 @@ noscript {
   }
 }
 
+.follow-request-banner .button {
+  width: 100%;
+}
+
 .column-inline-form {
   padding: 15px;
   display: flex;
@@ -7021,7 +7051,6 @@ noscript {
       display: block;
       flex: 0 0 auto;
       width: 94px;
-      margin-left: -2px;
 
       .account__avatar {
         background: darken($ui-base-color, 8%);
@@ -7038,6 +7067,7 @@ noscript {
     padding-top: 10px;
     gap: 8px;
     overflow: hidden;
+    margin-left: -2px; // aligns the pfp with content below
 
     &__buttons {
       display: flex;
@@ -7666,7 +7696,7 @@ noscript {
       left: 0;
       width: 100%;
       height: 100%;
-      border-left: 2px solid $highlight-text-color;
+      border-left: 4px solid $highlight-text-color;
       pointer-events: none;
     }
   }
diff --git a/app/javascript/styles/mastodon/modal.scss b/app/javascript/styles/mastodon/modal.scss
index 6c6de4206..a333926dd 100644
--- a/app/javascript/styles/mastodon/modal.scss
+++ b/app/javascript/styles/mastodon/modal.scss
@@ -1,5 +1,5 @@
 .modal-layout {
-  background: $ui-base-color 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)}"/></svg>') repeat-x bottom fixed;
+  background: $ui-base-color 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>') repeat-x bottom fixed;
   display: flex;
   flex-direction: column;
   height: 100vh;
diff --git a/app/javascript/styles/mastodon/widgets.scss b/app/javascript/styles/mastodon/widgets.scss
index 0e39dc87b..7a25d121b 100644
--- a/app/javascript/styles/mastodon/widgets.scss
+++ b/app/javascript/styles/mastodon/widgets.scss
@@ -39,6 +39,8 @@
       width: 20px;
       height: 20px;
       margin: -3px 0 0;
+      margin-left: 0.075em;
+      margin-right: 0.075em;
     }
 
     p {
diff --git a/app/lib/admin/system_check/elasticsearch_check.rb b/app/lib/admin/system_check/elasticsearch_check.rb
index 8aee18267..a63988224 100644
--- a/app/lib/admin/system_check/elasticsearch_check.rb
+++ b/app/lib/admin/system_check/elasticsearch_check.rb
@@ -34,6 +34,7 @@ class Admin::SystemCheck::ElasticsearchCheck < Admin::SystemCheck::BaseCheck
   end
 
   def compatible_version?
+    return false if running_version.nil?
     Gem::Version.new(running_version) >= Gem::Version.new(required_version)
   end
 end
diff --git a/app/lib/request.rb b/app/lib/request.rb
index 96d934a8f..b2819c8ed 100644
--- a/app/lib/request.rb
+++ b/app/lib/request.rb
@@ -30,7 +30,8 @@ class Request
     @verb        = verb
     @url         = Addressable::URI.parse(url).normalize
     @http_client = options.delete(:http_client)
-    @options     = options.merge(socket_class: use_proxy? ? ProxySocket : Socket)
+    @allow_local = options.delete(:allow_local)
+    @options     = options.merge(socket_class: use_proxy? || @allow_local ? ProxySocket : Socket)
     @options     = @options.merge(proxy_url) if use_proxy?
     @headers     = {}
 
diff --git a/app/lib/status_reach_finder.rb b/app/lib/status_reach_finder.rb
index ccf1e9e3a..36fb0e80f 100644
--- a/app/lib/status_reach_finder.rb
+++ b/app/lib/status_reach_finder.rb
@@ -70,7 +70,7 @@ class StatusReachFinder
 
   def followers_inboxes
     if @status.in_reply_to_local_account? && distributable?
-      @status.account.followers.or(@status.thread.account.followers).inboxes
+      @status.account.followers.or(@status.thread.account.followers.not_domain_blocked_by_account(@status.account)).inboxes
     elsif @status.direct_visibility? || @status.limited_visibility?
       []
     else
diff --git a/app/lib/translation_service/libre_translate.rb b/app/lib/translation_service/libre_translate.rb
index 43576e306..4ebe21e45 100644
--- a/app/lib/translation_service/libre_translate.rb
+++ b/app/lib/translation_service/libre_translate.rb
@@ -27,7 +27,7 @@ class TranslationService::LibreTranslate < TranslationService
 
   def request(text, source_language, target_language)
     body = Oj.dump(q: text, source: source_language.presence || 'auto', target: target_language, format: 'html', api_key: @api_key)
-    req = Request.new(:post, "#{@base_url}/translate", body: body)
+    req = Request.new(:post, "#{@base_url}/translate", body: body, allow_local: true)
     req.add_headers('Content-Type': 'application/json')
     req
   end
diff --git a/app/models/account.rb b/app/models/account.rb
index 7059c555f..4a7219624 100644
--- a/app/models/account.rb
+++ b/app/models/account.rb
@@ -339,9 +339,15 @@ class Account < ApplicationRecord
 
   def save_with_optional_media!
     save!
-  rescue ActiveRecord::RecordInvalid
-    self.avatar = nil
-    self.header = nil
+  rescue ActiveRecord::RecordInvalid => e
+    errors = e.record.errors.errors
+    errors.each do |err|
+      if err.attribute == :avatar
+        self.avatar = nil
+      elsif err.attribute == :header
+        self.header = nil
+      end
+    end
 
     save!
   end
diff --git a/app/models/account_filter.rb b/app/models/account_filter.rb
index 3a4ac0492..d27bb46fc 100644
--- a/app/models/account_filter.rb
+++ b/app/models/account_filter.rb
@@ -81,7 +81,7 @@ class AccountFilter
     when 'suspended'
       Account.suspended
     when 'disabled'
-      accounts_with_users.merge(User.disabled)
+      accounts_with_users.merge(User.disabled).without_suspended
     when 'silenced'
       Account.silenced
     when 'sensitized'
diff --git a/app/models/concerns/account_interactions.rb b/app/models/concerns/account_interactions.rb
index 15c49f2fe..de8bf338f 100644
--- a/app/models/concerns/account_interactions.rb
+++ b/app/models/concerns/account_interactions.rb
@@ -44,6 +44,10 @@ module AccountInteractions
       end
     end
 
+    def requested_by_map(target_account_ids, account_id)
+      follow_mapping(FollowRequest.where(account_id: target_account_ids, target_account_id: account_id), :account_id)
+    end
+
     def endorsed_map(target_account_ids, account_id)
       follow_mapping(AccountPin.where(account_id: account_id, target_account_id: target_account_ids), :target_account_id)
     end
diff --git a/app/models/media_attachment.rb b/app/models/media_attachment.rb
index f2b34e4cd..4dd3042ab 100644
--- a/app/models/media_attachment.rb
+++ b/app/models/media_attachment.rb
@@ -210,6 +210,8 @@ class MediaAttachment < ApplicationRecord
 
   default_scope { order(id: :asc) }
 
+  attr_accessor :skip_download
+
   def local?
     remote_url.blank?
   end
diff --git a/app/models/user.rb b/app/models/user.rb
index 209bfa521..4344da2ff 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -386,6 +386,15 @@ class User < ApplicationRecord
     super
   end
 
+  def revoke_access!
+    Doorkeeper::AccessGrant.by_resource_owner(self).update_all(revoked_at: Time.now.utc)
+
+    Doorkeeper::AccessToken.by_resource_owner(self).in_batches do |batch|
+      batch.update_all(revoked_at: Time.now.utc)
+      Web::PushSubscription.where(access_token_id: batch).delete_all
+    end
+  end
+
   def reset_password!
     # First, change password to something random and deactivate all sessions
     transaction do
@@ -394,12 +403,7 @@ class User < ApplicationRecord
     end
 
     # Then, remove all authorized applications and connected push subscriptions
-    Doorkeeper::AccessGrant.by_resource_owner(self).in_batches.update_all(revoked_at: Time.now.utc)
-
-    Doorkeeper::AccessToken.by_resource_owner(self).in_batches do |batch|
-      batch.update_all(revoked_at: Time.now.utc)
-      Web::PushSubscription.where(access_token_id: batch).delete_all
-    end
+    revoke_access!
 
     # Finally, send a reset password prompt to the user
     send_reset_password_instructions
diff --git a/app/presenters/account_relationships_presenter.rb b/app/presenters/account_relationships_presenter.rb
index d662380f6..ab8bac412 100644
--- a/app/presenters/account_relationships_presenter.rb
+++ b/app/presenters/account_relationships_presenter.rb
@@ -2,7 +2,7 @@
 
 class AccountRelationshipsPresenter
   attr_reader :following, :followed_by, :blocking, :blocked_by,
-              :muting, :requested, :domain_blocking,
+              :muting, :requested, :requested_by, :domain_blocking,
               :endorsed, :account_note
 
   def initialize(account_ids, current_account_id, **options)
@@ -15,6 +15,7 @@ class AccountRelationshipsPresenter
     @blocked_by      = cached[:blocked_by].merge(Account.blocked_by_map(@uncached_account_ids, @current_account_id))
     @muting          = cached[:muting].merge(Account.muting_map(@uncached_account_ids, @current_account_id))
     @requested       = cached[:requested].merge(Account.requested_map(@uncached_account_ids, @current_account_id))
+    @requested_by    = cached[:requested_by].merge(Account.requested_by_map(@uncached_account_ids, @current_account_id))
     @domain_blocking = cached[:domain_blocking].merge(Account.domain_blocking_map(@uncached_account_ids, @current_account_id))
     @endorsed        = cached[:endorsed].merge(Account.endorsed_map(@uncached_account_ids, @current_account_id))
     @account_note    = cached[:account_note].merge(Account.account_note_map(@uncached_account_ids, @current_account_id))
@@ -27,6 +28,7 @@ class AccountRelationshipsPresenter
     @blocked_by.merge!(options[:blocked_by_map] || {})
     @muting.merge!(options[:muting_map] || {})
     @requested.merge!(options[:requested_map] || {})
+    @requested_by.merge!(options[:requested_by_map] || {})
     @domain_blocking.merge!(options[:domain_blocking_map] || {})
     @endorsed.merge!(options[:endorsed_map] || {})
     @account_note.merge!(options[:account_note_map] || {})
@@ -44,6 +46,7 @@ class AccountRelationshipsPresenter
       blocked_by: {},
       muting: {},
       requested: {},
+      requested_by: {},
       domain_blocking: {},
       endorsed: {},
       account_note: {},
@@ -73,6 +76,7 @@ class AccountRelationshipsPresenter
         blocked_by:      { account_id => blocked_by[account_id] },
         muting:          { account_id => muting[account_id] },
         requested:       { account_id => requested[account_id] },
+        requested_by:    { account_id => requested_by[account_id] },
         domain_blocking: { account_id => domain_blocking[account_id] },
         endorsed:        { account_id => endorsed[account_id] },
         account_note:    { account_id => account_note[account_id] },
diff --git a/app/serializers/initial_state_serializer.rb b/app/serializers/initial_state_serializer.rb
index d23daaf85..d753fa51a 100644
--- a/app/serializers/initial_state_serializer.rb
+++ b/app/serializers/initial_state_serializer.rb
@@ -30,7 +30,7 @@ class InitialStateSerializer < ActiveModel::Serializer
       streaming_api_base_url: Rails.configuration.x.streaming_api_base_url,
       access_token: object.token,
       locale: I18n.locale,
-      domain: instance_presenter.domain,
+      domain: Addressable::IDNA.to_unicode(instance_presenter.domain),
       title: instance_presenter.title,
       admin: object.admin&.id&.to_s,
       search_enabled: Chewy.enabled?,
diff --git a/app/serializers/rest/relationship_serializer.rb b/app/serializers/rest/relationship_serializer.rb
index 31fc60eb2..b53387401 100644
--- a/app/serializers/rest/relationship_serializer.rb
+++ b/app/serializers/rest/relationship_serializer.rb
@@ -2,8 +2,8 @@
 
 class REST::RelationshipSerializer < ActiveModel::Serializer
   attributes :id, :following, :showing_reblogs, :notifying, :languages, :followed_by,
-             :blocking, :blocked_by, :muting, :muting_notifications, :requested,
-             :domain_blocking, :endorsed, :note
+             :blocking, :blocked_by, :muting, :muting_notifications,
+             :requested, :requested_by, :domain_blocking, :endorsed, :note
 
   def id
     object.id.to_s
@@ -54,6 +54,10 @@ class REST::RelationshipSerializer < ActiveModel::Serializer
     instance_options[:relationships].requested[object.id] ? true : false
   end
 
+  def requested_by
+    instance_options[:relationships].requested_by[object.id] ? true : false
+  end
+
   def domain_blocking
     instance_options[:relationships].domain_blocking[object.id] || false
   end
diff --git a/app/services/activitypub/process_status_update_service.rb b/app/services/activitypub/process_status_update_service.rb
index fad19f87f..11b38ab92 100644
--- a/app/services/activitypub/process_status_update_service.rb
+++ b/app/services/activitypub/process_status_update_service.rb
@@ -45,6 +45,7 @@ class ActivityPub::ProcessStatusUpdateService < BaseService
         create_edits!
       end
 
+      download_media_files!
       queue_poll_notifications!
 
       next unless significant_changes?
@@ -66,12 +67,12 @@ class ActivityPub::ProcessStatusUpdateService < BaseService
   def update_media_attachments!
     previous_media_attachments     = @status.media_attachments.to_a
     previous_media_attachments_ids = @status.ordered_media_attachment_ids || previous_media_attachments.map(&:id)
-    next_media_attachments         = []
+    @next_media_attachments        = []
 
     as_array(@json['attachment']).each do |attachment|
       media_attachment_parser = ActivityPub::Parser::MediaAttachmentParser.new(attachment)
 
-      next if media_attachment_parser.remote_url.blank? || next_media_attachments.size > 4
+      next if media_attachment_parser.remote_url.blank? || @next_media_attachments.size > 4
 
       begin
         media_attachment   = previous_media_attachments.find { |previous_media_attachment| previous_media_attachment.remote_url == media_attachment_parser.remote_url }
@@ -87,34 +88,39 @@ class ActivityPub::ProcessStatusUpdateService < BaseService
         media_attachment.focus                = media_attachment_parser.focus
         media_attachment.thumbnail_remote_url = media_attachment_parser.thumbnail_remote_url
         media_attachment.blurhash             = media_attachment_parser.blurhash
+        media_attachment.status_id            = @status.id
+        media_attachment.skip_download        = unsupported_media_type?(media_attachment_parser.file_content_type) || skip_download?
         media_attachment.save!
 
-        next_media_attachments << media_attachment
-
-        next if unsupported_media_type?(media_attachment_parser.file_content_type) || skip_download?
-
-        begin
-          media_attachment.download_file! if media_attachment.remote_url_previously_changed?
-          media_attachment.download_thumbnail! if media_attachment.thumbnail_remote_url_previously_changed?
-          media_attachment.save
-        rescue Mastodon::UnexpectedResponseError, HTTP::TimeoutError, HTTP::ConnectionError, OpenSSL::SSL::SSLError
-          RedownloadMediaWorker.perform_in(rand(30..600).seconds, media_attachment.id)
-        end
+        @next_media_attachments << media_attachment
       rescue Addressable::URI::InvalidURIError => e
         Rails.logger.debug "Invalid URL in attachment: #{e}"
       end
     end
 
-    added_media_attachments = next_media_attachments - previous_media_attachments
+    added_media_attachments = @next_media_attachments - previous_media_attachments
 
-    MediaAttachment.where(id: added_media_attachments.map(&:id)).update_all(status_id: @status.id)
-
-    @status.ordered_media_attachment_ids = next_media_attachments.map(&:id)
-    @status.media_attachments.reload
+    @status.ordered_media_attachment_ids = @next_media_attachments.map(&:id)
 
     @media_attachments_changed = true if @status.ordered_media_attachment_ids != previous_media_attachments_ids
   end
 
+  def download_media_files!
+    @next_media_attachments.each do |media_attachment|
+      next if media_attachment.skip_download
+
+      media_attachment.download_file! if media_attachment.remote_url_previously_changed?
+      media_attachment.download_thumbnail! if media_attachment.thumbnail_remote_url_previously_changed?
+      media_attachment.save
+    rescue Mastodon::UnexpectedResponseError, HTTP::TimeoutError, HTTP::ConnectionError, OpenSSL::SSL::SSLError
+      RedownloadMediaWorker.perform_in(rand(30..600).seconds, media_attachment.id)
+    rescue Seahorse::Client::NetworkingError => e
+      Rails.logger.warn "Error storing media attachment: #{e}"
+    end
+
+    @status.media_attachments.reload
+  end
+
   def update_poll!(allow_significant_changes: true)
     previous_poll        = @status.preloadable_poll
     @previous_expires_at = previous_poll&.expires_at
diff --git a/app/services/post_status_service.rb b/app/services/post_status_service.rb
index 36592a531..bcda001f5 100644
--- a/app/services/post_status_service.rb
+++ b/app/services/post_status_service.rb
@@ -37,12 +37,15 @@ class PostStatusService < BaseService
       schedule_status!
     else
       process_status!
-      postprocess_status!
-      bump_potential_friendship!
     end
 
     redis.setex(idempotency_key, 3_600, @status.id) if idempotency_given?
 
+    unless scheduled?
+      postprocess_status!
+      bump_potential_friendship!
+    end
+
     @status
   end
 
@@ -75,9 +78,6 @@ class PostStatusService < BaseService
     ApplicationRecord.transaction do
       @status = @account.statuses.create!(status_attributes)
     end
-
-    process_hashtags_service.call(@status)
-    process_mentions_service.call(@status)
   end
 
   def schedule_status!
@@ -101,6 +101,8 @@ class PostStatusService < BaseService
   end
 
   def postprocess_status!
+    process_hashtags_service.call(@status)
+    process_mentions_service.call(@status)
     Trends.tags.register(@status)
     LinkCrawlWorker.perform_async(@status.id)
     DistributionWorker.perform_async(@status.id)
diff --git a/app/services/tag_search_service.rb b/app/services/tag_search_service.rb
index b78d65625..b66ccced9 100644
--- a/app/services/tag_search_service.rb
+++ b/app/services/tag_search_service.rb
@@ -76,11 +76,27 @@ class TagSearchService < BaseService
     definition = TagsIndex.query(query)
     definition = definition.filter(filter) if @options[:exclude_unreviewed]
 
-    definition.limit(@limit).offset(@offset).objects.compact
+    ensure_exact_match(definition.limit(@limit).offset(@offset).objects.compact)
   rescue Faraday::ConnectionFailed, Parslet::ParseFailed
     nil
   end
 
+  # Since the ElasticSearch Query doesn't guarantee the exact match will be the
+  # first result or that it will even be returned, patch the results accordingly
+  def ensure_exact_match(results)
+    return results unless @offset.nil? || @offset.zero?
+
+    normalized_query = Tag.normalize(@query)
+    exact_match = results.find { |tag| tag.name.downcase == normalized_query }
+    exact_match ||= Tag.find_normalized(normalized_query)
+    unless exact_match.nil?
+      results.delete(exact_match)
+      results = [exact_match] + results
+    end
+
+    results
+  end
+
   def from_database
     Tag.search_for(@query, @limit, @offset, @options)
   end
diff --git a/app/views/admin/accounts/index.html.haml b/app/views/admin/accounts/index.html.haml
index 9571f27b4..63a88ded2 100644
--- a/app/views/admin/accounts/index.html.haml
+++ b/app/views/admin/accounts/index.html.haml
@@ -10,7 +10,7 @@
     .filter-subset.filter-subset--with-select
       %strong= t('admin.accounts.moderation.title')
       .input.select.optional
-        = select_tag :status, options_for_select([[t('admin.accounts.moderation.active'), 'active'], [t('admin.accounts.moderation.silenced'), 'silenced'], [t('admin.accounts.moderation.suspended'), 'suspended'], [safe_join([t('admin.accounts.moderation.pending'), "(#{number_with_delimiter(User.pending.count)})"], ' '), 'pending']], params[:status]), prompt: I18n.t('generic.all')
+        = select_tag :status, options_for_select([[t('admin.accounts.moderation.active'), 'active'], [t('admin.accounts.moderation.silenced'), 'silenced'], [t('admin.accounts.moderation.disabled'), 'disabled'], [t('admin.accounts.moderation.suspended'), 'suspended'], [safe_join([t('admin.accounts.moderation.pending'), "(#{number_with_delimiter(User.pending.count)})"], ' '), 'pending']], params[:status]), prompt: I18n.t('generic.all')
     .filter-subset.filter-subset--with-select
       %strong= t('admin.accounts.role')
       .input.select.optional
diff --git a/app/views/admin/accounts/show.html.haml b/app/views/admin/accounts/show.html.haml
index dc3b35956..db5c255c9 100644
--- a/app/views/admin/accounts/show.html.haml
+++ b/app/views/admin/accounts/show.html.haml
@@ -195,9 +195,13 @@
   - if @account.suspended?
     %hr.spacer/
 
-    %p.muted-hint= @deletion_request.present? ? t('admin.accounts.suspension_reversible_hint_html', date: content_tag(:strong, l(@deletion_request.due_at.to_date))) : t('admin.accounts.suspension_irreversible')
+    - if @account.suspension_origin_remote?
+      %p.muted-hint= @deletion_request.present? ? t('admin.accounts.remote_suspension_reversible_hint_html', date: content_tag(:strong, l(@deletion_request.due_at.to_date))) : t('admin.accounts.remote_suspension_irreversible')
+    - else
+      %p.muted-hint= @deletion_request.present? ? t('admin.accounts.suspension_reversible_hint_html', date: content_tag(:strong, l(@deletion_request.due_at.to_date))) : t('admin.accounts.suspension_irreversible')
 
     = link_to t('admin.accounts.undo_suspension'), unsuspend_admin_account_path(@account.id), method: :post, class: 'button' if can?(:unsuspend, @account)
+    = link_to t('admin.accounts.redownload'), redownload_admin_account_path(@account.id), method: :post, class: 'button' if can?(:redownload, @account) && @account.suspension_origin_remote?
 
     - if @deletion_request.present?
       = link_to t('admin.accounts.delete'), admin_account_path(@account.id), method: :delete, class: 'button button--destructive', data: { confirm: t('admin.accounts.are_you_sure') } if can?(:destroy, @account)
diff --git a/app/views/admin/report_notes/_report_note.html.haml b/app/views/admin/report_notes/_report_note.html.haml
index 3bccd3b4b..14df2f609 100644
--- a/app/views/admin/report_notes/_report_note.html.haml
+++ b/app/views/admin/report_notes/_report_note.html.haml
@@ -4,11 +4,8 @@
   .report-notes__item__header
     %span.username
       = link_to report_note.account.username, admin_account_path(report_note.account_id)
-    %time{ datetime: report_note.created_at.iso8601, title: l(report_note.created_at) }
-      - if report_note.created_at.today?
-        = t('admin.report_notes.today_at', time: l(report_note.created_at, format: :time))
-      - else
-        = l report_note.created_at.to_date
+    %time.relative-formatted{ datetime: report_note.created_at }
+      = t('admin.report_notes.created_at')
 
   .report-notes__item__content
     = simple_format(h(report_note.content))
diff --git a/app/views/admin/reports/show.html.haml b/app/views/admin/reports/show.html.haml
index e5ea56779..1535e5003 100644
--- a/app/views/admin/reports/show.html.haml
+++ b/app/views/admin/reports/show.html.haml
@@ -140,11 +140,8 @@
             = link_to @report.account.username, admin_account_path(@report.account_id)
           - else
             = link_to @report.account.domain, admin_instance_path(@report.account.domain)
-        %time{ datetime: @report.created_at.iso8601, title: l(@report.created_at) }
-          - if @report.created_at.today?
-            = t('admin.report_notes.today_at', time: l(@report.created_at, format: :time))
-          - else
-            = l @report.created_at.to_date
+        %time.relative-formatted{ datetime: @report.created_at.iso8601 }
+          = t('admin.report_notes.created_at')
 
       .report-notes__item__content
         = simple_format(h(@report.comment))
diff --git a/app/views/auth/sessions/new.html.haml b/app/views/auth/sessions/new.html.haml
index 304e3ab84..e98c1ff3d 100644
--- a/app/views/auth/sessions/new.html.haml
+++ b/app/views/auth/sessions/new.html.haml
@@ -6,6 +6,8 @@
 
 - unless omniauth_only?
   = simple_form_for(resource, as: resource_name, url: session_path(resource_name)) do |f|
+    %h1.title= t('auth.sign_in.title', domain: site_hostname)
+    %p.lead= t('auth.sign_in.preamble_html', domain: site_hostname)
     .fields-group
       - if use_seamless_external_login?
         = f.input :email, autofocus: true, wrapper: :with_label, label: t('simple_form.labels.defaults.username_or_email'), input_html: { 'aria-label': t('simple_form.labels.defaults.username_or_email') }, hint: false
diff --git a/app/views/disputes/strikes/show.html.haml b/app/views/disputes/strikes/show.html.haml
index 4a3005f72..cab0a17eb 100644
--- a/app/views/disputes/strikes/show.html.haml
+++ b/app/views/disputes/strikes/show.html.haml
@@ -110,11 +110,8 @@
       .report-notes__item__header
         %span.username
           = link_to @appeal.account.username, can?(:show, @appeal.account) ? admin_account_path(@appeal.account_id) : short_account_url(@appeal.account)
-        %time{ datetime: @appeal.created_at.iso8601, title: l(@appeal.created_at) }
-          - if @appeal.created_at.today?
-            = t('admin.report_notes.today_at', time: l(@appeal.created_at, format: :time))
-          - else
-            = l @appeal.created_at.to_date
+        %time.relative-formatted{ datetime: @appeal.created_at.iso8601 }
+          = t('admin.report_notes.created_at')
 
       .report-notes__item__content
         = simple_format(h(@appeal.text))
diff --git a/app/views/settings/featured_tags/index.html.haml b/app/views/settings/featured_tags/index.html.haml
index 595094fc7..078abd788 100644
--- a/app/views/settings/featured_tags/index.html.haml
+++ b/app/views/settings/featured_tags/index.html.haml
@@ -26,6 +26,6 @@
           - if featured_tag.last_status_at.nil?
             = t('accounts.nothing_here')
           - else
-            %time{ datetime: featured_tag.last_status_at.iso8601, title: l(featured_tag.last_status_at) }= l featured_tag.last_status_at
+            %time.formatted{ datetime: featured_tag.last_status_at.iso8601, title: l(featured_tag.last_status_at) }= l featured_tag.last_status_at
           = table_link_to 'trash', t('filters.index.delete'), settings_featured_tag_path(featured_tag), method: :delete, data: { confirm: t('admin.accounts.are_you_sure') }
       .trends__item__current= friendly_number_to_human featured_tag.statuses_count
diff --git a/app/workers/scheduler/suspended_user_cleanup_scheduler.rb b/app/workers/scheduler/suspended_user_cleanup_scheduler.rb
index 50768f83c..87e22161b 100644
--- a/app/workers/scheduler/suspended_user_cleanup_scheduler.rb
+++ b/app/workers/scheduler/suspended_user_cleanup_scheduler.rb
@@ -9,7 +9,7 @@ class Scheduler::SuspendedUserCleanupScheduler
   MAX_PULL_SIZE = 50
 
   # Since account deletion is very expensive, we want to avoid
-  # overloading the server by queing too much at once.
+  # overloading the server by queuing too much at once.
   # This job runs approximately once per 2 minutes, so with a
   # value of `MAX_DELETIONS_PER_JOB` of 10, a server can
   # handle the deletion of 7200 accounts per day, provided it
diff --git a/config/application.rb b/config/application.rb
index 83124cfda..929a44948 100644
--- a/config/application.rb
+++ b/config/application.rb
@@ -93,6 +93,7 @@ module Mastodon
       :fa,
       :fi,
       :fr,
+      :fy,
       :ga,
       :gd,
       :gl,
diff --git a/config/initializers/devise.rb b/config/initializers/devise.rb
index c55bea7a7..d7b252c3f 100644
--- a/config/initializers/devise.rb
+++ b/config/initializers/devise.rb
@@ -159,7 +159,7 @@ Devise.setup do |config|
   # config.request_keys = []
 
   # Configure which authentication keys should be case-insensitive.
-  # These keys will be downcased upon creating or modifying a user and when used
+  # These keys will be lowercased upon creating or modifying a user and when used
   # to authenticate or find a user. Default is :email.
   config.case_insensitive_keys = [:email]
 
diff --git a/config/locales/doorkeeper.en.yml b/config/locales/doorkeeper.en.yml
index 5567724ae..2df0056c2 100644
--- a/config/locales/doorkeeper.en.yml
+++ b/config/locales/doorkeeper.en.yml
@@ -149,9 +149,19 @@ en:
     scopes:
       admin:read: read all data on the server
       admin:read:accounts: read sensitive information of all accounts
+      admin:read:canonical_email_blocks: read sensitive information of all canonical email blocks
+      admin:read:domain_allows: read sensitive information of all domain allows
+      admin:read:domain_blocks: read sensitive information of all domain blocks
+      admin:read:email_domain_blocks: read sensitive information of all email domain blocks
+      admin:read:ip_blocks: read sensitive information of all IP blocks
       admin:read:reports: read sensitive information of all reports and reported accounts
       admin:write: modify all data on the server
       admin:write:accounts: perform moderation actions on accounts
+      admin:write:canonical_email_blocks: perform moderation actions on canonical email blocks
+      admin:write:domain_allows: perform moderation actions on domain allows
+      admin:write:domain_blocks: perform moderation actions on domain blocks
+      admin:write:email_domain_blocks: perform moderation actions on email domain blocks
+      admin:write:ip_blocks: perform moderation actions on IP blocks
       admin:write:reports: perform moderation actions on reports
       crypto: use end-to-end encryption
       follow: modify account relationships
diff --git a/config/locales/en.yml b/config/locales/en.yml
index a045db1ab..075ce2136 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -116,6 +116,8 @@ en:
       redownloaded_msg: Successfully refreshed %{username}'s profile from origin
       reject: Reject
       rejected_msg: Successfully rejected %{username}'s sign-up application
+      remote_suspension_irreversible: The data of this account has been irreversibly deleted.
+      remote_suspension_reversible_hint_html: The account has been suspended on their server, and the data will be fully removed on %{date}. Until then, the remote server can restore this account without any ill effects. If you wish to remove all of the account's data immediately, you can do so below.
       remove_avatar: Remove avatar
       remove_header: Remove header
       removed_avatar_msg: Successfully removed %{username}'s avatar image
@@ -555,13 +557,12 @@ en:
       pending: Waiting for relay's approval
       save_and_enable: Save and enable
       setup: Setup a relay connection
-      signatures_not_enabled: Relays will not work correctly while secure mode or limited federation mode is enabled
+      signatures_not_enabled: Relays may not work correctly while secure mode or limited federation mode is enabled
       status: Status
       title: Relays
     report_notes:
       created_msg: Report note successfully created!
       destroyed_msg: Report note successfully deleted!
-      today_at: Today at %{time}
     reports:
       account:
         notes:
@@ -974,6 +975,9 @@ en:
       email_below_hint_html: If the below e-mail address is incorrect, you can change it here and receive a new confirmation e-mail.
       email_settings_hint_html: The confirmation e-mail was sent to %{email}. If that e-mail address is not correct, you can change it in account settings.
       title: Setup
+    sign_in:
+      preamble_html: Sign in with your <strong>%{domain}</strong> credentials. If your account is hosted on a different server, you will not be able to log in here.
+      title: Sign in to %{domain}
     sign_up:
       preamble: With an account on this Mastodon server, you'll be able to follow any other person on the network, regardless of where their account is hosted.
       title: Let's get you set up on %{domain}.
diff --git a/config/sidekiq.yml b/config/sidekiq.yml
index 05c5b28c8..b8739aab3 100644
--- a/config/sidekiq.yml
+++ b/config/sidekiq.yml
@@ -1,5 +1,5 @@
 ---
-:concurrency: 5
+:concurrency: <%= ENV.fetch('SIDEKIQ_CONCURRENCY', 5) %>
 :queues:
   - [default, 8]
   - [push, 6]
diff --git a/config/webpack/production.js b/config/webpack/production.js
index 79dcebc7c..143a23b99 100644
--- a/config/webpack/production.js
+++ b/config/webpack/production.js
@@ -34,6 +34,12 @@ module.exports = merge(sharedConfig, {
       cache: true,
       test: /\.(js|css|html|json|ico|svg|eot|otf|ttf|map)$/,
     }),
+    new CompressionPlugin({
+      filename: '[path][base].br[query]',
+      algorithm: 'brotliCompress',
+      cache: true,
+      test: /\.(js|css|html|json|ico|svg|eot|otf|ttf|map)$/,
+    }),
     new BundleAnalyzerPlugin({ // generates report.html
       analyzerMode: 'static',
       openAnalyzer: false,
diff --git a/lib/mastodon/accounts_cli.rb b/lib/mastodon/accounts_cli.rb
index 77cbef84e..0dd852131 100644
--- a/lib/mastodon/accounts_cli.rb
+++ b/lib/mastodon/accounts_cli.rb
@@ -200,21 +200,44 @@ module Mastodon
       end
     end
 
-    desc 'delete USERNAME', 'Delete a user'
+    option :email
+    option :dry_run, type: :boolean
+    desc 'delete [USERNAME]', 'Delete a user'
     long_desc <<-LONG_DESC
       Remove a user account with a given USERNAME.
-    LONG_DESC
-    def delete(username)
-      account = Account.find_local(username)
 
-      if account.nil?
-        say('No user with such username', :red)
+      With the --email option, the user is selected based on email
+      rather than username.
+    LONG_DESC
+    def delete(username = nil)
+      if username.present? && options[:email].present?
+        say('Use username or --email, not both', :red)
+        exit(1)
+      elsif username.blank? && options[:email].blank?
+        say('No username provided', :red)
         exit(1)
       end
 
-      say("Deleting user with #{account.statuses_count} statuses, this might take a while...")
-      DeleteAccountService.new.call(account, reserve_email: false)
-      say('OK', :green)
+      dry_run = options[:dry_run] ? ' (DRY RUN)' : ''
+      account = nil
+
+      if username.present?
+        account = Account.find_local(username)
+        if account.nil?
+          say('No user with such username', :red)
+          exit(1)
+        end
+      else
+        account = Account.left_joins(:user).find_by(user: { email: options[:email] })
+        if account.nil?
+          say('No user with such email', :red)
+          exit(1)
+        end
+      end
+
+      say("Deleting user with #{account.statuses_count} statuses, this might take a while...#{dry_run}")
+      DeleteAccountService.new.call(account, reserve_email: false) unless options[:dry_run]
+      say("OK#{dry_run}", :green)
     end
 
     option :force, type: :boolean, aliases: [:f], description: 'Override public key check'
diff --git a/lib/mastodon/media_cli.rb b/lib/mastodon/media_cli.rb
index bba4a1bd7..24cc98964 100644
--- a/lib/mastodon/media_cli.rb
+++ b/lib/mastodon/media_cli.rb
@@ -14,35 +14,78 @@ module Mastodon
     end
 
     option :days, type: :numeric, default: 7, aliases: [:d]
+    option :prune_profiles, type: :boolean, default: false
+    option :remove_headers, type: :boolean, default: false
+    option :include_follows, type: :boolean, default: false
     option :concurrency, type: :numeric, default: 5, aliases: [:c]
-    option :verbose, type: :boolean, default: false, aliases: [:v]
     option :dry_run, type: :boolean, default: false
-    desc 'remove', 'Remove remote media files'
+    desc 'remove', 'Remove remote media files, headers or avatars'
     long_desc <<-DESC
-      Removes locally cached copies of media attachments from other servers.
-
+      Removes locally cached copies of media attachments (and optionally profile
+      headers and avatars) from other servers. By default, only media attachements
+      are removed.
       The --days option specifies how old media attachments have to be before
-      they are removed. It defaults to 7 days.
+      they are removed. In case of avatars and headers, it specifies how old
+      the last webfinger request and update to the user has to be before they
+      are pruned. It defaults to 7 days.
+      If --prune-profiles is specified, only avatars and headers are removed.
+      If --remove-headers is specified, only headers are removed.
+      If --include-follows is specified along with --prune-profiles or
+      --remove-headers, all non-local profiles will be pruned irrespective of
+      follow status. By default, only accounts that are not followed by or
+      following anyone locally are pruned.
     DESC
+    # rubocop:disable Metrics/PerceivedComplexity
     def remove
-      time_ago = options[:days].days.ago
-      dry_run  = options[:dry_run] ? '(DRY RUN)' : ''
+      if options[:prune_profiles] && options[:remove_headers]
+        say('--prune-profiles and --remove-headers should not be specified simultaneously', :red, true)
+        exit(1)
+      end
+      if options[:include_follows] && !(options[:prune_profiles] || options[:remove_headers])
+        say('--include-follows can only be used with --prune-profiles or --remove-headers', :red, true)
+        exit(1)
+      end
+      time_ago        = options[:days].days.ago
+      dry_run         = options[:dry_run] ? ' (DRY RUN)' : ''
 
-      processed, aggregate = parallelize_with_progress(MediaAttachment.cached.where.not(remote_url: '').where('created_at < ?', time_ago)) do |media_attachment|
-        next if media_attachment.file.blank?
+      if options[:prune_profiles] || options[:remove_headers]
+        processed, aggregate = parallelize_with_progress(Account.remote.where({ last_webfingered_at: ..time_ago, updated_at: ..time_ago })) do |account|
+          next if !options[:include_follows] && Follow.where(account: account).or(Follow.where(target_account: account)).exists?
+          next if account.avatar.blank? && account.header.blank?
+          next if options[:remove_headers] && account.header.blank?
 
-        size = (media_attachment.file_file_size || 0) + (media_attachment.thumbnail_file_size || 0)
+          size = (account.header_file_size || 0)
+          size += (account.avatar_file_size || 0) if options[:prune_profiles]
 
-        unless options[:dry_run]
-          media_attachment.file.destroy
-          media_attachment.thumbnail.destroy
-          media_attachment.save
+          unless options[:dry_run]
+            account.header.destroy
+            account.avatar.destroy if options[:prune_profiles]
+            account.save!
+          end
+
+          size
         end
 
-        size
+        say("Visited #{processed} accounts and removed profile media totaling #{number_to_human_size(aggregate)}#{dry_run}", :green, true)
       end
 
-      say("Removed #{processed} media attachments (approx. #{number_to_human_size(aggregate)}) #{dry_run}", :green, true)
+      unless options[:prune_profiles] || options[:remove_headers]
+        processed, aggregate = parallelize_with_progress(MediaAttachment.cached.where.not(remote_url: '').where(created_at: ..time_ago)) do |media_attachment|
+          next if media_attachment.file.blank?
+
+          size = (media_attachment.file_file_size || 0) + (media_attachment.thumbnail_file_size || 0)
+
+          unless options[:dry_run]
+            media_attachment.file.destroy
+            media_attachment.thumbnail.destroy
+            media_attachment.save
+          end
+
+          size
+        end
+
+        say("Removed #{processed} media attachments (approx. #{number_to_human_size(aggregate)})#{dry_run}", :green, true)
+      end
     end
 
     option :start_after
@@ -183,6 +226,7 @@ module Mastodon
 
       say("Removed #{removed} orphans (approx. #{number_to_human_size(reclaimed_bytes)})#{dry_run}", :green, true)
     end
+    # rubocop:enable Metrics/PerceivedComplexity
 
     option :account, type: :string
     option :domain, type: :string
@@ -269,7 +313,7 @@ module Mastodon
     def lookup(url)
       path = Addressable::URI.parse(url).path
 
-      path_segments = path.split('/')[2..-1]
+      path_segments = path.split('/')[2..]
       path_segments.delete('cache')
 
       unless [7, 10].include?(path_segments.size)
diff --git a/lib/tasks/mastodon.rake b/lib/tasks/mastodon.rake
index c1e5bd2b4..3c891a07f 100644
--- a/lib/tasks/mastodon.rake
+++ b/lib/tasks/mastodon.rake
@@ -194,7 +194,7 @@ namespace :mastodon do
 
           env['S3_HOSTNAME'] = prompt.ask('S3 hostname:') do |q|
             q.required true
-            q.default 's3-us-east-1.amazonaws.com'
+            q.default 's3.us-east-1.amazonaws.com'
             q.modify :strip
           end
 
diff --git a/package.json b/package.json
index 3c182b308..482d79dca 100644
--- a/package.json
+++ b/package.json
@@ -2,7 +2,7 @@
   "name": "@mastodon/mastodon",
   "license": "AGPL-3.0-or-later",
   "engines": {
-    "node": ">=14"
+    "node": ">=16"
   },
   "scripts": {
     "postversion": "git push --tags",
diff --git a/public/embed.js b/public/embed.js
index 5607c24d5..defba403e 100644
--- a/public/embed.js
+++ b/public/embed.js
@@ -1,24 +1,28 @@
 // @ts-check
 
-(function() {
+(function () {
   'use strict';
 
   /**
    * @param {() => void} loaded
    */
-  var ready = function(loaded) {
-    if (['interactive', 'complete'].indexOf(document.readyState) !== -1) {
+  var ready = function (loaded) {
+    if (document.readyState === 'complete') {
       loaded();
     } else {
-      document.addEventListener('DOMContentLoaded', loaded);
+      document.addEventListener('readystatechange', function () {
+        if (document.readyState === 'complete') {
+          loaded();
+        }
+      });
     }
   };
 
-  ready(function() {
+  ready(function () {
     /** @type {Map<number, HTMLIFrameElement>} */
     var iframes = new Map();
 
-    window.addEventListener('message', function(e) {
+    window.addEventListener('message', function (e) {
       var data = e.data || {};
 
       if (typeof data !== 'object' || data.type !== 'setHeight' || !iframes.has(data.id)) {
@@ -34,7 +38,7 @@
       iframe.height = data.height;
     });
 
-    [].forEach.call(document.querySelectorAll('iframe.mastodon-embed'), function(iframe) {
+    [].forEach.call(document.querySelectorAll('iframe.mastodon-embed'), function (iframe) {
       // select unique id for each iframe
       var id = 0, failCount = 0, idBuffer = new Uint32Array(1);
       while (id === 0 || iframes.has(id)) {
@@ -49,10 +53,10 @@
 
       iframes.set(id, iframe);
 
-      iframe.scrolling      = 'no';
+      iframe.scrolling = 'no';
       iframe.style.overflow = 'hidden';
 
-      iframe.onload = function() {
+      iframe.onload = function () {
         iframe.contentWindow.postMessage({
           type: 'setHeight',
           id: id,
diff --git a/spec/controllers/admin/domain_blocks_controller_spec.rb b/spec/controllers/admin/domain_blocks_controller_spec.rb
index 98cda5004..f432060d9 100644
--- a/spec/controllers/admin/domain_blocks_controller_spec.rb
+++ b/spec/controllers/admin/domain_blocks_controller_spec.rb
@@ -70,6 +70,53 @@ RSpec.describe Admin::DomainBlocksController, type: :controller do
     end
   end
 
+  describe 'PUT #update' do
+    let!(:remote_account) { Fabricate(:account, domain: 'example.com') }
+    let(:domain_block)    { Fabricate(:domain_block, domain: 'example.com', severity: original_severity) }
+
+    before do
+      BlockDomainService.new.call(domain_block)
+    end
+
+    let(:subject) do
+      post :update, params: { id: domain_block.id, domain_block: { domain: 'example.com', severity: new_severity } }
+    end
+
+    context 'downgrading a domain suspension to silence' do
+      let(:original_severity) { 'suspend' }
+      let(:new_severity)      { 'silence' }
+
+      it 'changes the block severity' do
+        expect { subject }.to change { domain_block.reload.severity }.from('suspend').to('silence')
+      end
+
+      it 'undoes individual suspensions' do
+        expect { subject }.to change { remote_account.reload.suspended? }.from(true).to(false)
+      end
+
+      it 'performs individual silences' do
+        expect { subject }.to change { remote_account.reload.silenced? }.from(false).to(true)
+      end
+    end
+
+    context 'upgrading a domain silence to suspend' do
+      let(:original_severity) { 'silence' }
+      let(:new_severity)      { 'suspend' }
+
+      it 'changes the block severity' do
+        expect { subject }.to change { domain_block.reload.severity }.from('silence').to('suspend')
+      end
+
+      it 'undoes individual silences' do
+        expect { subject }.to change { remote_account.reload.silenced? }.from(true).to(false)
+      end
+
+      it 'performs individual suspends' do
+        expect { subject }.to change { remote_account.reload.suspended? }.from(false).to(true)
+      end
+    end
+  end
+
   describe 'DELETE #destroy' do
     it 'unblocks the domain' do
       service = double(call: true)
diff --git a/spec/controllers/api/v1/admin/domain_blocks_controller_spec.rb b/spec/controllers/api/v1/admin/domain_blocks_controller_spec.rb
index f12285b2a..606def602 100644
--- a/spec/controllers/api/v1/admin/domain_blocks_controller_spec.rb
+++ b/spec/controllers/api/v1/admin/domain_blocks_controller_spec.rb
@@ -71,6 +71,53 @@ RSpec.describe Api::V1::Admin::DomainBlocksController, type: :controller do
     end
   end
 
+  describe 'PUT #update' do
+    let!(:remote_account) { Fabricate(:account, domain: 'example.com') }
+    let(:domain_block)    { Fabricate(:domain_block, domain: 'example.com', severity: original_severity) }
+
+    before do
+      BlockDomainService.new.call(domain_block)
+    end
+
+    let(:subject) do
+      post :update, params: { id: domain_block.id, domain: 'example.com', severity: new_severity }
+    end
+
+    context 'downgrading a domain suspension to silence' do
+      let(:original_severity) { 'suspend' }
+      let(:new_severity)      { 'silence' }
+
+      it 'changes the block severity' do
+        expect { subject }.to change { domain_block.reload.severity }.from('suspend').to('silence')
+      end
+
+      it 'undoes individual suspensions' do
+        expect { subject }.to change { remote_account.reload.suspended? }.from(true).to(false)
+      end
+
+      it 'performs individual silences' do
+        expect { subject }.to change { remote_account.reload.silenced? }.from(false).to(true)
+      end
+    end
+
+    context 'upgrading a domain silence to suspend' do
+      let(:original_severity) { 'silence' }
+      let(:new_severity)      { 'suspend' }
+
+      it 'changes the block severity' do
+        expect { subject }.to change { domain_block.reload.severity }.from('silence').to('suspend')
+      end
+
+      it 'undoes individual silences' do
+        expect { subject }.to change { remote_account.reload.silenced? }.from(true).to(false)
+      end
+
+      it 'performs individual suspends' do
+        expect { subject }.to change { remote_account.reload.suspended? }.from(false).to(true)
+      end
+    end
+  end
+
   describe 'DELETE #destroy' do
     let!(:block) { Fabricate(:domain_block) }
 
diff --git a/spec/controllers/auth/passwords_controller_spec.rb b/spec/controllers/auth/passwords_controller_spec.rb
index dcfdebb17..1c6874f08 100644
--- a/spec/controllers/auth/passwords_controller_spec.rb
+++ b/spec/controllers/auth/passwords_controller_spec.rb
@@ -35,4 +35,65 @@ describe Auth::PasswordsController, type: :controller do
       end
     end
   end
+
+  describe 'POST #update' do
+    let(:user) { Fabricate(:user) }
+
+    before do
+      @password = 'reset0password'
+      request.env['devise.mapping'] = Devise.mappings[:user]
+    end
+
+    context 'with valid reset_password_token' do
+      let!(:session_activation) { Fabricate(:session_activation, user: user) }
+      let!(:access_token) { Fabricate(:access_token, resource_owner_id: user.id) }
+      let!(:web_push_subscription) { Fabricate(:web_push_subscription, access_token: access_token) }
+
+      before do
+        @token = user.send_reset_password_instructions
+
+        post :update, params: { user: { password: @password, password_confirmation: @password, reset_password_token: @token } }
+      end
+
+      it 'redirect to sign in' do
+        expect(response).to redirect_to '/auth/sign_in'
+      end
+
+      it 'changes password' do
+        this_user = User.find(user.id)
+
+        expect(this_user).to_not be_nil
+        expect(this_user.valid_password?(@password)).to be true
+      end
+
+      it 'deactivates all sessions' do
+        expect(user.session_activations.count).to eq 0
+      end
+
+      it 'revokes all access tokens' do
+        expect(Doorkeeper::AccessToken.active_for(user).count).to eq 0
+      end
+
+      it 'removes push subscriptions' do
+        expect(Web::PushSubscription.where(user: user).or(Web::PushSubscription.where(access_token: access_token)).count).to eq 0
+      end
+    end
+
+    context 'with invalid reset_password_token' do
+      before do
+        post :update, params: { user: { password: @password, password_confirmation: @password, reset_password_token: 'some_invalid_value' } }
+      end
+
+      it 'renders reset password' do
+        expect(response).to render_template(:new)
+      end
+
+      it 'retains password' do
+        this_user = User.find(user.id)
+
+        expect(this_user).to_not be_nil
+        expect(this_user.external_or_valid_password?(user.password)).to be true
+      end
+    end
+  end
 end
diff --git a/spec/controllers/well_known/nodeinfo_controller_spec.rb b/spec/controllers/well_known/nodeinfo_controller_spec.rb
index 694bb0fb9..36e85f20d 100644
--- a/spec/controllers/well_known/nodeinfo_controller_spec.rb
+++ b/spec/controllers/well_known/nodeinfo_controller_spec.rb
@@ -27,6 +27,8 @@ describe WellKnown::NodeInfoController, type: :controller do
 
       json = body_as_json
 
+      expect({ "foo" => 0 }).not_to match_json_schema("nodeinfo_2.0")
+      expect(json).to match_json_schema("nodeinfo_2.0")
       expect(json[:version]).to eq '2.0'
       expect(json[:usage]).to be_a Hash
       expect(json[:software]).to be_a Hash
diff --git a/spec/helpers/application_helper_spec.rb b/spec/helpers/application_helper_spec.rb
index 20ee32aa0..1dbd985bf 100644
--- a/spec/helpers/application_helper_spec.rb
+++ b/spec/helpers/application_helper_spec.rb
@@ -113,7 +113,7 @@ describe ApplicationHelper do
       Setting.site_title = site_title
     end
 
-    it 'returns site title on production enviroment' do
+    it 'returns site title on production environment' do
       Setting.site_title = 'site title'
       expect(Rails.env).to receive(:production?).and_return(true)
       expect(helper.title).to eq 'site title'
diff --git a/spec/helpers/formatting_helper_spec.rb b/spec/helpers/formatting_helper_spec.rb
new file mode 100644
index 000000000..af604a87b
--- /dev/null
+++ b/spec/helpers/formatting_helper_spec.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+describe FormattingHelper, type: :helper do
+  include Devise::Test::ControllerHelpers
+
+  describe '#rss_status_content_format' do
+    let(:status) { Fabricate(:status, text: 'Hello world<>', spoiler_text: 'This is a spoiler<>', poll: Fabricate(:poll, options: %w(Yes<> No))) }
+    let(:html) { helper.rss_status_content_format(status) }
+
+    it 'renders the spoiler text' do
+      expect(html).to include('<p>This is a spoiler&lt;&gt;</p><hr>')
+    end
+
+    it 'renders the status text' do
+      expect(html).to include('<p>Hello world&lt;&gt;</p>')
+    end
+
+    it 'renders the poll' do
+      expect(html).to include('<radio disabled="disabled">Yes&lt;&gt;</radio><br>')
+    end
+  end
+end
diff --git a/spec/models/account_spec.rb b/spec/models/account_spec.rb
index edae05f9d..6cd769dc8 100644
--- a/spec/models/account_spec.rb
+++ b/spec/models/account_spec.rb
@@ -160,7 +160,7 @@ RSpec.describe Account, type: :model do
         expect(account.avatar_remote_url).to eq 'https://remote.test/invalid_avatar'
         expect(account.header_remote_url).to eq expectation.header_remote_url
         expect(account.avatar_file_name).to  eq nil
-        expect(account.header_file_name).to  eq nil
+        expect(account.header_file_name).to  eq expectation.header_file_name
       end
     end
   end
@@ -658,6 +658,12 @@ RSpec.describe Account, type: :model do
     end
   end
 
+  describe '.requested_by_map' do
+    it 'returns an hash' do
+      expect(Account.requested_by_map([], 1)).to be_a Hash
+    end
+  end
+
   describe 'MENTION_RE' do
     subject { Account::MENTION_RE }
 
diff --git a/spec/presenters/account_relationships_presenter_spec.rb b/spec/presenters/account_relationships_presenter_spec.rb
index edfbbb354..8a485d2b9 100644
--- a/spec/presenters/account_relationships_presenter_spec.rb
+++ b/spec/presenters/account_relationships_presenter_spec.rb
@@ -10,6 +10,7 @@ RSpec.describe AccountRelationshipsPresenter do
       allow(Account).to receive(:blocking_map).with(account_ids, current_account_id).and_return(default_map)
       allow(Account).to receive(:muting_map).with(account_ids, current_account_id).and_return(default_map)
       allow(Account).to receive(:requested_map).with(account_ids, current_account_id).and_return(default_map)
+      allow(Account).to receive(:requested_by_map).with(account_ids, current_account_id).and_return(default_map)
       allow(Account).to receive(:domain_blocking_map).with(account_ids, current_account_id).and_return(default_map)
     end
 
@@ -71,6 +72,14 @@ RSpec.describe AccountRelationshipsPresenter do
       end
     end
 
+    context 'options[:requested_by_map] is set' do
+      let(:options) { { requested_by_map: { 6 => true } } }
+
+      it 'sets @requested merged with default_map and options[:requested_by_map]' do
+        expect(presenter.requested_by).to eq default_map.merge(options[:requested_by_map])
+      end
+    end
+
     context 'options[:domain_blocking_map] is set' do
       let(:options) { { domain_blocking_map: { 7 => true } } }
 
diff --git a/spec/support/matchers/json/match_json_schema.rb b/spec/support/matchers/json/match_json_schema.rb
new file mode 100644
index 000000000..5d9c9a618
--- /dev/null
+++ b/spec/support/matchers/json/match_json_schema.rb
@@ -0,0 +1,6 @@
+RSpec::Matchers.define :match_json_schema do |schema|
+  match do |input_json|
+    schema_path = Rails.root.join('spec', 'support', 'schema', "#{schema}.json").to_s
+    JSON::Validator.validate(schema_path, input_json, validate_schema: true)
+  end
+end
diff --git a/spec/support/schema/nodeinfo_2.0.json b/spec/support/schema/nodeinfo_2.0.json
new file mode 100644
index 000000000..085ce542b
--- /dev/null
+++ b/spec/support/schema/nodeinfo_2.0.json
@@ -0,0 +1,170 @@
+{
+  "$schema": "http://json-schema.org/draft-04/schema#",
+  "id": "http://nodeinfo.diaspora.software/ns/schema/2.0#",
+  "description": "NodeInfo schema version 2.0.",
+  "type": "object",
+  "additionalProperties": false,
+  "required": [
+    "version",
+    "software",
+    "protocols",
+    "services",
+    "openRegistrations",
+    "usage",
+    "metadata"
+  ],
+  "properties": {
+    "version": {
+      "description": "The schema version, must be 2.0.",
+      "enum": ["2.0"]
+    },
+    "software": {
+      "description": "Metadata about server software in use.",
+      "type": "object",
+      "additionalProperties": false,
+      "required": ["name", "version"],
+      "properties": {
+        "name": {
+          "description": "The canonical name of this server software.",
+          "type": "string",
+          "pattern": "^[a-z0-9-]+$"
+        },
+        "version": {
+          "description": "The version of this server software.",
+          "type": "string"
+        }
+      }
+    },
+    "protocols": {
+      "description": "The protocols supported on this server.",
+      "type": "array",
+      "minItems": 1,
+      "items": {
+        "enum": [
+          "activitypub",
+          "buddycloud",
+          "dfrn",
+          "diaspora",
+          "libertree",
+          "ostatus",
+          "pumpio",
+          "tent",
+          "xmpp",
+          "zot"
+        ]
+      }
+    },
+    "services": {
+      "description": "The third party sites this server can connect to via their application API.",
+      "type": "object",
+      "additionalProperties": false,
+      "required": ["inbound", "outbound"],
+      "properties": {
+        "inbound": {
+          "description": "The third party sites this server can retrieve messages from for combined display with regular traffic.",
+          "type": "array",
+          "minItems": 0,
+          "items": {
+            "enum": [
+              "atom1.0",
+              "gnusocial",
+              "imap",
+              "pnut",
+              "pop3",
+              "pumpio",
+              "rss2.0",
+              "twitter"
+            ]
+          }
+        },
+        "outbound": {
+          "description": "The third party sites this server can publish messages to on the behalf of a user.",
+          "type": "array",
+          "minItems": 0,
+          "items": {
+            "enum": [
+              "atom1.0",
+              "blogger",
+              "buddycloud",
+              "diaspora",
+              "dreamwidth",
+              "drupal",
+              "facebook",
+              "friendica",
+              "gnusocial",
+              "google",
+              "insanejournal",
+              "libertree",
+              "linkedin",
+              "livejournal",
+              "mediagoblin",
+              "myspace",
+              "pinterest",
+              "pnut",
+              "posterous",
+              "pumpio",
+              "redmatrix",
+              "rss2.0",
+              "smtp",
+              "tent",
+              "tumblr",
+              "twitter",
+              "wordpress",
+              "xmpp"
+            ]
+          }
+        }
+      }
+    },
+    "openRegistrations": {
+      "description": "Whether this server allows open self-registration.",
+      "type": "boolean"
+    },
+    "usage": {
+      "description": "Usage statistics for this server.",
+      "type": "object",
+      "additionalProperties": false,
+      "required": ["users"],
+      "properties": {
+        "users": {
+          "description": "statistics about the users of this server.",
+          "type": "object",
+          "additionalProperties": false,
+          "properties": {
+            "total": {
+              "description": "The total amount of on this server registered users.",
+              "type": "integer",
+              "minimum": 0
+            },
+            "activeHalfyear": {
+              "description": "The amount of users that signed in at least once in the last 180 days.",
+              "type": "integer",
+              "minimum": 0
+            },
+            "activeMonth": {
+              "description": "The amount of users that signed in at least once in the last 30 days.",
+              "type": "integer",
+              "minimum": 0
+            }
+          }
+        },
+        "localPosts": {
+          "description": "The amount of posts that were made by users that are registered on this server.",
+          "type": "integer",
+          "minimum": 0
+        },
+        "localComments": {
+          "description": "The amount of comments that were made by users that are registered on this server.",
+          "type": "integer",
+          "minimum": 0
+        }
+      }
+    },
+    "metadata": {
+      "description": "Free form key value pairs for software specific values. Clients should not rely on any specific key present.",
+      "type": "object",
+      "minProperties": 0,
+      "additionalProperties": true
+    }
+  }
+}