about summary refs log tree commit diff
path: root/app
diff options
context:
space:
mode:
authorThibaut Girka <thib@sitedethib.com>2020-03-08 19:38:53 +0100
committerThibaut Girka <thib@sitedethib.com>2020-03-08 19:38:53 +0100
commitc790ecb14d8b06c6242886ff4d2cdf06e70c5cac (patch)
treedff0bfefe5a1922c7227ea1ec0236b92e11db699 /app
parent13ef4d5fb0dbb66074f42df7989ae40509a4724f (diff)
parent764b89939fe2fcb8c4389738af8685949104c144 (diff)
Merge branch 'master' into glitch-soc/merge-upstream
Conflicts:
- `app/controllers/api/v1/statuses_controller.rb`:
  Conflict due to upstream adding a new parameter (with_rate_limit),
  too close to glitch-soc's own additional parameter (content_type).
  Added upstream's parameter.
- `app/services/post_status_service.rb`:
  Conflict due to upstream adding a new parameter (rate_limit),
  too close to glitch-soc's own additional parameter (content_type).
  Added upstream's parameter.
- `app/views/settings/preferences/appearance/show.html.haml`:
  Conflict due to us not exposing theme settings here (as we have
  a different flavour/skin menu).
  Took upstream change, while still not exposing theme settings.
- `config/webpack/shared.js`:
  Coding style fixes for a part we have rewritten.
  Discarded upstream changes.
Diffstat (limited to 'app')
-rw-r--r--app/controllers/account_follow_controller.rb2
-rw-r--r--app/controllers/admin/site_uploads_controller.rb21
-rw-r--r--app/controllers/api/base_controller.rb4
-rw-r--r--app/controllers/api/v1/accounts/follower_accounts_controller.rb2
-rw-r--r--app/controllers/api/v1/accounts/following_accounts_controller.rb2
-rw-r--r--app/controllers/api/v1/accounts/identity_proofs_controller.rb2
-rw-r--r--app/controllers/api/v1/accounts/lists_controller.rb2
-rw-r--r--app/controllers/api/v1/accounts/pins_controller.rb2
-rw-r--r--app/controllers/api/v1/accounts/relationships_controller.rb2
-rw-r--r--app/controllers/api/v1/accounts/search_controller.rb2
-rw-r--r--app/controllers/api/v1/accounts/statuses_controller.rb2
-rw-r--r--app/controllers/api/v1/accounts_controller.rb4
-rw-r--r--app/controllers/api/v1/apps/credentials_controller.rb2
-rw-r--r--app/controllers/api/v1/blocks_controller.rb2
-rw-r--r--app/controllers/api/v1/bookmarks_controller.rb2
-rw-r--r--app/controllers/api/v1/conversations_controller.rb2
-rw-r--r--app/controllers/api/v1/custom_emojis_controller.rb2
-rw-r--r--app/controllers/api/v1/domain_blocks_controller.rb2
-rw-r--r--app/controllers/api/v1/endorsements_controller.rb2
-rw-r--r--app/controllers/api/v1/favourites_controller.rb2
-rw-r--r--app/controllers/api/v1/featured_tags/suggestions_controller.rb3
-rw-r--r--app/controllers/api/v1/filters_controller.rb2
-rw-r--r--app/controllers/api/v1/instances/activity_controller.rb2
-rw-r--r--app/controllers/api/v1/instances/peers_controller.rb2
-rw-r--r--app/controllers/api/v1/instances_controller.rb2
-rw-r--r--app/controllers/api/v1/media_controller.rb2
-rw-r--r--app/controllers/api/v1/mutes_controller.rb2
-rw-r--r--app/controllers/api/v1/notifications_controller.rb2
-rw-r--r--app/controllers/api/v1/polls/votes_controller.rb2
-rw-r--r--app/controllers/api/v1/polls_controller.rb2
-rw-r--r--app/controllers/api/v1/preferences_controller.rb2
-rw-r--r--app/controllers/api/v1/reports_controller.rb2
-rw-r--r--app/controllers/api/v1/statuses/bookmarks_controller.rb2
-rw-r--r--app/controllers/api/v1/statuses/favourited_by_accounts_controller.rb2
-rw-r--r--app/controllers/api/v1/statuses/favourites_controller.rb2
-rw-r--r--app/controllers/api/v1/statuses/mutes_controller.rb2
-rw-r--r--app/controllers/api/v1/statuses/pins_controller.rb2
-rw-r--r--app/controllers/api/v1/statuses/reblogged_by_accounts_controller.rb2
-rw-r--r--app/controllers/api/v1/statuses/reblogs_controller.rb3
-rw-r--r--app/controllers/api/v1/statuses_controller.rb5
-rw-r--r--app/controllers/api/v1/streaming_controller.rb2
-rw-r--r--app/controllers/api/v1/suggestions_controller.rb2
-rw-r--r--app/controllers/api/v1/timelines/home_controller.rb2
-rw-r--r--app/controllers/api/v1/timelines/public_controller.rb2
-rw-r--r--app/controllers/api/v1/timelines/tag_controller.rb2
-rw-r--r--app/controllers/api/v1/trends_controller.rb2
-rw-r--r--app/controllers/api/v2/search_controller.rb2
-rw-r--r--app/controllers/api/web/embeds_controller.rb2
-rw-r--r--app/controllers/api/web/push_subscriptions_controller.rb2
-rw-r--r--app/controllers/api/web/settings_controller.rb2
-rw-r--r--app/controllers/application_controller.rb5
-rw-r--r--app/controllers/authorize_interactions_controller.rb2
-rw-r--r--app/controllers/concerns/rate_limit_headers.rb16
-rw-r--r--app/helpers/admin/settings_helper.rb11
-rw-r--r--app/javascript/mastodon/actions/accounts.js2
-rw-r--r--app/javascript/mastodon/components/intersection_observer_article.js2
-rw-r--r--app/javascript/mastodon/components/media_gallery.js2
-rw-r--r--app/javascript/mastodon/components/status_action_bar.js4
-rw-r--r--app/javascript/mastodon/features/account/components/header.js4
-rw-r--r--app/javascript/mastodon/features/audio/index.js6
-rw-r--r--app/javascript/mastodon/features/blocks/index.js2
-rw-r--r--app/javascript/mastodon/features/compose/components/poll_form.js1
-rw-r--r--app/javascript/mastodon/features/compose/components/privacy_dropdown.js8
-rw-r--r--app/javascript/mastodon/features/domain_blocks/index.js2
-rw-r--r--app/javascript/mastodon/features/favourites/index.js2
-rw-r--r--app/javascript/mastodon/features/follow_requests/index.js2
-rw-r--r--app/javascript/mastodon/features/followers/index.js2
-rw-r--r--app/javascript/mastodon/features/following/index.js2
-rw-r--r--app/javascript/mastodon/features/getting_started/components/announcements.js11
-rw-r--r--app/javascript/mastodon/features/getting_started/index.js8
-rw-r--r--app/javascript/mastodon/features/lists/index.js2
-rw-r--r--app/javascript/mastodon/features/mutes/index.js2
-rw-r--r--app/javascript/mastodon/features/reblogs/index.js2
-rw-r--r--app/javascript/mastodon/features/status/components/action_bar.js4
-rw-r--r--app/javascript/mastodon/features/status/components/card.js2
-rw-r--r--app/javascript/mastodon/features/ui/components/__tests__/column-test.js2
-rw-r--r--app/javascript/mastodon/features/video/index.js14
-rw-r--r--app/javascript/mastodon/locales/bg.json2
-rw-r--r--app/javascript/mastodon/locales/br.json2
-rw-r--r--app/javascript/mastodon/locales/defaultMessages.json28
-rw-r--r--app/javascript/mastodon/locales/en.json14
-rw-r--r--app/javascript/mastodon/locales/ga.json2
-rw-r--r--app/javascript/mastodon/locales/hi.json2
-rw-r--r--app/javascript/mastodon/locales/kn.json2
-rw-r--r--app/javascript/mastodon/locales/lt.json2
-rw-r--r--app/javascript/mastodon/locales/lv.json2
-rw-r--r--app/javascript/mastodon/locales/mk.json2
-rw-r--r--app/javascript/mastodon/locales/ml.json2
-rw-r--r--app/javascript/mastodon/locales/mr.json2
-rw-r--r--app/javascript/mastodon/locales/ms.json2
-rw-r--r--app/javascript/mastodon/locales/ur.json2
-rw-r--r--app/javascript/mastodon/reducers/notifications.js4
-rw-r--r--app/javascript/mastodon/reducers/timelines.js4
-rw-r--r--app/javascript/mastodon/selectors/index.js2
-rw-r--r--app/javascript/mastodon/service_worker/web_push_notifications.js2
-rw-r--r--app/javascript/mastodon/store/configureStore.js2
-rw-r--r--app/javascript/styles/mastodon/components.scss23
-rw-r--r--app/lib/exceptions.rb1
-rw-r--r--app/lib/rate_limiter.rb64
-rw-r--r--app/models/account.rb1
-rw-r--r--app/models/account_filter.rb27
-rw-r--r--app/models/announcement.rb4
-rw-r--r--app/models/concerns/account_interactions.rb16
-rw-r--r--app/models/concerns/rate_limitable.rb36
-rw-r--r--app/models/follow.rb3
-rw-r--r--app/models/follow_request.rb3
-rw-r--r--app/models/media_attachment.rb1
-rw-r--r--app/models/status.rb18
-rw-r--r--app/policies/settings_policy.rb4
-rw-r--r--app/serializers/rest/announcement_serializer.rb13
-rw-r--r--app/services/account_search_service.rb2
-rw-r--r--app/services/follow_service.rb78
-rw-r--r--app/services/post_status_service.rb13
-rw-r--r--app/services/reblog_service.rb16
-rw-r--r--app/views/admin/accounts/_account.html.haml2
-rw-r--r--app/views/admin/accounts/index.html.haml6
-rw-r--r--app/views/admin/settings/edit.html.haml11
-rw-r--r--app/views/errors/429.html.haml5
-rw-r--r--app/views/settings/preferences/appearance/show.html.haml5
-rw-r--r--app/views/settings/preferences/notifications/show.html.haml8
-rw-r--r--app/views/settings/preferences/other/show.html.haml5
-rw-r--r--app/views/settings/profiles/show.html.haml5
122 files changed, 452 insertions, 248 deletions
diff --git a/app/controllers/account_follow_controller.rb b/app/controllers/account_follow_controller.rb
index 185a355f8..33394074d 100644
--- a/app/controllers/account_follow_controller.rb
+++ b/app/controllers/account_follow_controller.rb
@@ -6,7 +6,7 @@ class AccountFollowController < ApplicationController
   before_action :authenticate_user!
 
   def create
-    FollowService.new.call(current_user.account, @account.acct)
+    FollowService.new.call(current_user.account, @account, with_rate_limit: true)
     redirect_to account_path(@account)
   end
 end
diff --git a/app/controllers/admin/site_uploads_controller.rb b/app/controllers/admin/site_uploads_controller.rb
new file mode 100644
index 000000000..cacecedb0
--- /dev/null
+++ b/app/controllers/admin/site_uploads_controller.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+module Admin
+  class SiteUploadsController < BaseController
+    before_action :set_site_upload
+
+    def destroy
+      authorize :settings, :destroy?
+
+      @site_upload.destroy!
+
+      redirect_to edit_admin_settings_path, notice: I18n.t('admin.site_uploads.destroyed_msg')
+    end
+
+    private
+
+    def set_site_upload
+      @site_upload = SiteUpload.find(params[:id])
+    end
+  end
+end
diff --git a/app/controllers/api/base_controller.rb b/app/controllers/api/base_controller.rb
index 68bf425f4..153ade253 100644
--- a/app/controllers/api/base_controller.rb
+++ b/app/controllers/api/base_controller.rb
@@ -44,6 +44,10 @@ class Api::BaseController < ApplicationController
     render json: { error: 'There was a temporary problem serving your request, please try again' }, status: 503
   end
 
+  rescue_from Mastodon::RateLimitExceededError do
+    render json: { error: I18n.t('errors.429') }, status: 429
+  end
+
   rescue_from ActionController::ParameterMissing do |e|
     render json: { error: e.to_s }, status: 400
   end
diff --git a/app/controllers/api/v1/accounts/follower_accounts_controller.rb b/app/controllers/api/v1/accounts/follower_accounts_controller.rb
index e360b8a92..850702cca 100644
--- a/app/controllers/api/v1/accounts/follower_accounts_controller.rb
+++ b/app/controllers/api/v1/accounts/follower_accounts_controller.rb
@@ -5,8 +5,6 @@ class Api::V1::Accounts::FollowerAccountsController < Api::BaseController
   before_action :set_account
   after_action :insert_pagination_headers
 
-  respond_to :json
-
   def index
     @accounts = load_accounts
     render json: @accounts, each_serializer: REST::AccountSerializer
diff --git a/app/controllers/api/v1/accounts/following_accounts_controller.rb b/app/controllers/api/v1/accounts/following_accounts_controller.rb
index a405b365f..830dcd8a1 100644
--- a/app/controllers/api/v1/accounts/following_accounts_controller.rb
+++ b/app/controllers/api/v1/accounts/following_accounts_controller.rb
@@ -5,8 +5,6 @@ class Api::V1::Accounts::FollowingAccountsController < Api::BaseController
   before_action :set_account
   after_action :insert_pagination_headers
 
-  respond_to :json
-
   def index
     @accounts = load_accounts
     render json: @accounts, each_serializer: REST::AccountSerializer
diff --git a/app/controllers/api/v1/accounts/identity_proofs_controller.rb b/app/controllers/api/v1/accounts/identity_proofs_controller.rb
index bea51ae11..8dad6fee9 100644
--- a/app/controllers/api/v1/accounts/identity_proofs_controller.rb
+++ b/app/controllers/api/v1/accounts/identity_proofs_controller.rb
@@ -4,8 +4,6 @@ class Api::V1::Accounts::IdentityProofsController < Api::BaseController
   before_action :require_user!
   before_action :set_account
 
-  respond_to :json
-
   def index
     @proofs = @account.identity_proofs.active
     render json: @proofs, each_serializer: REST::IdentityProofSerializer
diff --git a/app/controllers/api/v1/accounts/lists_controller.rb b/app/controllers/api/v1/accounts/lists_controller.rb
index 72392453c..ccb751f8f 100644
--- a/app/controllers/api/v1/accounts/lists_controller.rb
+++ b/app/controllers/api/v1/accounts/lists_controller.rb
@@ -5,8 +5,6 @@ class Api::V1::Accounts::ListsController < Api::BaseController
   before_action :require_user!
   before_action :set_account
 
-  respond_to :json
-
   def index
     @lists = @account.lists.where(account: current_account)
     render json: @lists, each_serializer: REST::ListSerializer
diff --git a/app/controllers/api/v1/accounts/pins_controller.rb b/app/controllers/api/v1/accounts/pins_controller.rb
index 0a0239c42..3915b5669 100644
--- a/app/controllers/api/v1/accounts/pins_controller.rb
+++ b/app/controllers/api/v1/accounts/pins_controller.rb
@@ -7,8 +7,6 @@ class Api::V1::Accounts::PinsController < Api::BaseController
   before_action :require_user!
   before_action :set_account
 
-  respond_to :json
-
   def create
     AccountPin.create!(account: current_account, target_account: @account)
     render json: @account, serializer: REST::RelationshipSerializer, relationships: relationships_presenter
diff --git a/app/controllers/api/v1/accounts/relationships_controller.rb b/app/controllers/api/v1/accounts/relationships_controller.rb
index ab8a0461f..1d3992a28 100644
--- a/app/controllers/api/v1/accounts/relationships_controller.rb
+++ b/app/controllers/api/v1/accounts/relationships_controller.rb
@@ -4,8 +4,6 @@ class Api::V1::Accounts::RelationshipsController < Api::BaseController
   before_action -> { doorkeeper_authorize! :read, :'read:follows' }
   before_action :require_user!
 
-  respond_to :json
-
   def index
     accounts = Account.where(id: account_ids).select('id')
     # .where doesn't guarantee that our results are in the same order
diff --git a/app/controllers/api/v1/accounts/search_controller.rb b/app/controllers/api/v1/accounts/search_controller.rb
index 4217b527a..3061fcb7e 100644
--- a/app/controllers/api/v1/accounts/search_controller.rb
+++ b/app/controllers/api/v1/accounts/search_controller.rb
@@ -4,8 +4,6 @@ class Api::V1::Accounts::SearchController < Api::BaseController
   before_action -> { doorkeeper_authorize! :read, :'read:accounts' }
   before_action :require_user!
 
-  respond_to :json
-
   def show
     @accounts = account_search
     render json: @accounts, each_serializer: REST::AccountSerializer
diff --git a/app/controllers/api/v1/accounts/statuses_controller.rb b/app/controllers/api/v1/accounts/statuses_controller.rb
index 333db9618..114ee0a82 100644
--- a/app/controllers/api/v1/accounts/statuses_controller.rb
+++ b/app/controllers/api/v1/accounts/statuses_controller.rb
@@ -6,8 +6,6 @@ class Api::V1::Accounts::StatusesController < Api::BaseController
 
   after_action :insert_pagination_headers, unless: -> { truthy_param?(:pinned) }
 
-  respond_to :json
-
   def index
     @statuses = load_statuses
     render json: @statuses, each_serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id)
diff --git a/app/controllers/api/v1/accounts_controller.rb b/app/controllers/api/v1/accounts_controller.rb
index d68d2715f..0080faf33 100644
--- a/app/controllers/api/v1/accounts_controller.rb
+++ b/app/controllers/api/v1/accounts_controller.rb
@@ -14,7 +14,7 @@ class Api::V1::AccountsController < Api::BaseController
 
   skip_before_action :require_authenticated_user!, only: :create
 
-  respond_to :json
+  override_rate_limit_headers :follow, family: :follows
 
   def show
     render json: @account, serializer: REST::AccountSerializer
@@ -31,7 +31,7 @@ class Api::V1::AccountsController < Api::BaseController
   end
 
   def follow
-    FollowService.new.call(current_user.account, @account, reblogs: truthy_param?(:reblogs))
+    FollowService.new.call(current_user.account, @account, reblogs: truthy_param?(:reblogs), with_rate_limit: true)
 
     options = @account.locked? || current_user.account.silenced? ? {} : { following_map: { @account.id => { reblogs: truthy_param?(:reblogs) } }, requested_map: { @account.id => false } }
 
diff --git a/app/controllers/api/v1/apps/credentials_controller.rb b/app/controllers/api/v1/apps/credentials_controller.rb
index 8b63d0490..0475b2d4a 100644
--- a/app/controllers/api/v1/apps/credentials_controller.rb
+++ b/app/controllers/api/v1/apps/credentials_controller.rb
@@ -3,8 +3,6 @@
 class Api::V1::Apps::CredentialsController < Api::BaseController
   before_action -> { doorkeeper_authorize! :read }
 
-  respond_to :json
-
   def show
     render json: doorkeeper_token.application, serializer: REST::ApplicationSerializer, fields: %i(name website vapid_key)
   end
diff --git a/app/controllers/api/v1/blocks_controller.rb b/app/controllers/api/v1/blocks_controller.rb
index 4cff04cad..a2baeef90 100644
--- a/app/controllers/api/v1/blocks_controller.rb
+++ b/app/controllers/api/v1/blocks_controller.rb
@@ -5,8 +5,6 @@ class Api::V1::BlocksController < Api::BaseController
   before_action :require_user!
   after_action :insert_pagination_headers
 
-  respond_to :json
-
   def index
     @accounts = load_accounts
     render json: @accounts, each_serializer: REST::AccountSerializer
diff --git a/app/controllers/api/v1/bookmarks_controller.rb b/app/controllers/api/v1/bookmarks_controller.rb
index e1b244e76..c15212f0a 100644
--- a/app/controllers/api/v1/bookmarks_controller.rb
+++ b/app/controllers/api/v1/bookmarks_controller.rb
@@ -5,8 +5,6 @@ class Api::V1::BookmarksController < Api::BaseController
   before_action :require_user!
   after_action :insert_pagination_headers
 
-  respond_to :json
-
   def index
     @statuses = load_statuses
     render json: @statuses, each_serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id)
diff --git a/app/controllers/api/v1/conversations_controller.rb b/app/controllers/api/v1/conversations_controller.rb
index b19f27ebf..bc8013379 100644
--- a/app/controllers/api/v1/conversations_controller.rb
+++ b/app/controllers/api/v1/conversations_controller.rb
@@ -9,8 +9,6 @@ class Api::V1::ConversationsController < Api::BaseController
   before_action :set_conversation, except: :index
   after_action :insert_pagination_headers, only: :index
 
-  respond_to :json
-
   def index
     @conversations = paginated_conversations
     render json: @conversations, each_serializer: REST::ConversationSerializer
diff --git a/app/controllers/api/v1/custom_emojis_controller.rb b/app/controllers/api/v1/custom_emojis_controller.rb
index 4e6d5d7c6..08b3474cc 100644
--- a/app/controllers/api/v1/custom_emojis_controller.rb
+++ b/app/controllers/api/v1/custom_emojis_controller.rb
@@ -1,8 +1,6 @@
 # frozen_string_literal: true
 
 class Api::V1::CustomEmojisController < Api::BaseController
-  respond_to :json
-
   skip_before_action :set_cache_headers
 
   def index
diff --git a/app/controllers/api/v1/domain_blocks_controller.rb b/app/controllers/api/v1/domain_blocks_controller.rb
index af9e7a20f..5bb02d834 100644
--- a/app/controllers/api/v1/domain_blocks_controller.rb
+++ b/app/controllers/api/v1/domain_blocks_controller.rb
@@ -8,8 +8,6 @@ class Api::V1::DomainBlocksController < Api::BaseController
   before_action :require_user!
   after_action :insert_pagination_headers, only: :show
 
-  respond_to :json
-
   def show
     @blocks = load_domain_blocks
     render json: @blocks.map(&:domain)
diff --git a/app/controllers/api/v1/endorsements_controller.rb b/app/controllers/api/v1/endorsements_controller.rb
index 2770c7aef..c87dbc4ce 100644
--- a/app/controllers/api/v1/endorsements_controller.rb
+++ b/app/controllers/api/v1/endorsements_controller.rb
@@ -5,8 +5,6 @@ class Api::V1::EndorsementsController < Api::BaseController
   before_action :require_user!
   after_action :insert_pagination_headers
 
-  respond_to :json
-
   def index
     @accounts = load_accounts
     render json: @accounts, each_serializer: REST::AccountSerializer
diff --git a/app/controllers/api/v1/favourites_controller.rb b/app/controllers/api/v1/favourites_controller.rb
index db827f9d4..3e242905d 100644
--- a/app/controllers/api/v1/favourites_controller.rb
+++ b/app/controllers/api/v1/favourites_controller.rb
@@ -5,8 +5,6 @@ class Api::V1::FavouritesController < Api::BaseController
   before_action :require_user!
   after_action :insert_pagination_headers
 
-  respond_to :json
-
   def index
     @statuses = load_statuses
     render json: @statuses, each_serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id)
diff --git a/app/controllers/api/v1/featured_tags/suggestions_controller.rb b/app/controllers/api/v1/featured_tags/suggestions_controller.rb
index fb27ef88b..8c1b81a0f 100644
--- a/app/controllers/api/v1/featured_tags/suggestions_controller.rb
+++ b/app/controllers/api/v1/featured_tags/suggestions_controller.rb
@@ -2,12 +2,9 @@
 
 class Api::V1::FeaturedTags::SuggestionsController < Api::BaseController
   before_action -> { doorkeeper_authorize! :read, :'read:accounts' }, only: :index
-
   before_action :require_user!
   before_action :set_most_used_tags, only: :index
 
-  respond_to :json
-
   def index
     render json: @most_used_tags, each_serializer: REST::TagSerializer
   end
diff --git a/app/controllers/api/v1/filters_controller.rb b/app/controllers/api/v1/filters_controller.rb
index e5ebaff4d..b0ace3af0 100644
--- a/app/controllers/api/v1/filters_controller.rb
+++ b/app/controllers/api/v1/filters_controller.rb
@@ -7,8 +7,6 @@ class Api::V1::FiltersController < Api::BaseController
   before_action :set_filters, only: :index
   before_action :set_filter, only: [:show, :update, :destroy]
 
-  respond_to :json
-
   def index
     render json: @filters, each_serializer: REST::FilterSerializer
   end
diff --git a/app/controllers/api/v1/instances/activity_controller.rb b/app/controllers/api/v1/instances/activity_controller.rb
index b30e8464c..4f6b4bcbf 100644
--- a/app/controllers/api/v1/instances/activity_controller.rb
+++ b/app/controllers/api/v1/instances/activity_controller.rb
@@ -6,8 +6,6 @@ class Api::V1::Instances::ActivityController < Api::BaseController
   skip_before_action :set_cache_headers
   skip_before_action :require_authenticated_user!, unless: :whitelist_mode?
 
-  respond_to :json
-
   def show
     expires_in 1.day, public: true
     render_with_cache json: :activity, expires_in: 1.day
diff --git a/app/controllers/api/v1/instances/peers_controller.rb b/app/controllers/api/v1/instances/peers_controller.rb
index cc00d8a6b..9fa440935 100644
--- a/app/controllers/api/v1/instances/peers_controller.rb
+++ b/app/controllers/api/v1/instances/peers_controller.rb
@@ -6,8 +6,6 @@ class Api::V1::Instances::PeersController < Api::BaseController
   skip_before_action :set_cache_headers
   skip_before_action :require_authenticated_user!, unless: :whitelist_mode?
 
-  respond_to :json
-
   def index
     expires_in 1.day, public: true
     render_with_cache(expires_in: 1.day) { Account.remote.domains }
diff --git a/app/controllers/api/v1/instances_controller.rb b/app/controllers/api/v1/instances_controller.rb
index c323b60b4..5b5058a7b 100644
--- a/app/controllers/api/v1/instances_controller.rb
+++ b/app/controllers/api/v1/instances_controller.rb
@@ -1,8 +1,6 @@
 # frozen_string_literal: true
 
 class Api::V1::InstancesController < Api::BaseController
-  respond_to :json
-
   skip_before_action :set_cache_headers
   skip_before_action :require_authenticated_user!, unless: :whitelist_mode?
 
diff --git a/app/controllers/api/v1/media_controller.rb b/app/controllers/api/v1/media_controller.rb
index 81825db15..d87d7b946 100644
--- a/app/controllers/api/v1/media_controller.rb
+++ b/app/controllers/api/v1/media_controller.rb
@@ -4,8 +4,6 @@ class Api::V1::MediaController < Api::BaseController
   before_action -> { doorkeeper_authorize! :write, :'write:media' }
   before_action :require_user!
 
-  respond_to :json
-
   def create
     @media = current_account.media_attachments.create!(media_params)
     render json: @media, serializer: REST::MediaAttachmentSerializer
diff --git a/app/controllers/api/v1/mutes_controller.rb b/app/controllers/api/v1/mutes_controller.rb
index 3b3a39943..5dc047b43 100644
--- a/app/controllers/api/v1/mutes_controller.rb
+++ b/app/controllers/api/v1/mutes_controller.rb
@@ -5,8 +5,6 @@ class Api::V1::MutesController < Api::BaseController
   before_action :require_user!
   after_action :insert_pagination_headers
 
-  respond_to :json
-
   def index
     @data = @accounts = load_accounts
     render json: @accounts, each_serializer: REST::AccountSerializer
diff --git a/app/controllers/api/v1/notifications_controller.rb b/app/controllers/api/v1/notifications_controller.rb
index c91753ae7..9dce9b807 100644
--- a/app/controllers/api/v1/notifications_controller.rb
+++ b/app/controllers/api/v1/notifications_controller.rb
@@ -6,8 +6,6 @@ class Api::V1::NotificationsController < Api::BaseController
   before_action :require_user!
   after_action :insert_pagination_headers, only: :index
 
-  respond_to :json
-
   DEFAULT_NOTIFICATIONS_LIMIT = 15
 
   def index
diff --git a/app/controllers/api/v1/polls/votes_controller.rb b/app/controllers/api/v1/polls/votes_controller.rb
index 3fa0b6a76..e1d26106a 100644
--- a/app/controllers/api/v1/polls/votes_controller.rb
+++ b/app/controllers/api/v1/polls/votes_controller.rb
@@ -7,8 +7,6 @@ class Api::V1::Polls::VotesController < Api::BaseController
   before_action :require_user!
   before_action :set_poll
 
-  respond_to :json
-
   def create
     VoteService.new.call(current_account, @poll, vote_params[:choices])
     render json: @poll, serializer: REST::PollSerializer
diff --git a/app/controllers/api/v1/polls_controller.rb b/app/controllers/api/v1/polls_controller.rb
index 031e6d42d..744baf7bb 100644
--- a/app/controllers/api/v1/polls_controller.rb
+++ b/app/controllers/api/v1/polls_controller.rb
@@ -7,8 +7,6 @@ class Api::V1::PollsController < Api::BaseController
   before_action :set_poll
   before_action :refresh_poll
 
-  respond_to :json
-
   def show
     render json: @poll, serializer: REST::PollSerializer, include_results: true
   end
diff --git a/app/controllers/api/v1/preferences_controller.rb b/app/controllers/api/v1/preferences_controller.rb
index 077d39f5d..1640a8224 100644
--- a/app/controllers/api/v1/preferences_controller.rb
+++ b/app/controllers/api/v1/preferences_controller.rb
@@ -4,8 +4,6 @@ class Api::V1::PreferencesController < Api::BaseController
   before_action -> { doorkeeper_authorize! :read, :'read:accounts' }
   before_action :require_user!
 
-  respond_to :json
-
   def index
     render json: current_account, serializer: REST::PreferencesSerializer
   end
diff --git a/app/controllers/api/v1/reports_controller.rb b/app/controllers/api/v1/reports_controller.rb
index 1b0b4b05b..66c40f6f4 100644
--- a/app/controllers/api/v1/reports_controller.rb
+++ b/app/controllers/api/v1/reports_controller.rb
@@ -4,8 +4,6 @@ class Api::V1::ReportsController < Api::BaseController
   before_action -> { doorkeeper_authorize! :write, :'write:reports' }, only: [:create]
   before_action :require_user!
 
-  respond_to :json
-
   def create
     @report = ReportService.new.call(
       current_account,
diff --git a/app/controllers/api/v1/statuses/bookmarks_controller.rb b/app/controllers/api/v1/statuses/bookmarks_controller.rb
index a7f1eed00..3954af3c9 100644
--- a/app/controllers/api/v1/statuses/bookmarks_controller.rb
+++ b/app/controllers/api/v1/statuses/bookmarks_controller.rb
@@ -7,8 +7,6 @@ class Api::V1::Statuses::BookmarksController < Api::BaseController
   before_action :require_user!
   before_action :set_status
 
-  respond_to :json
-
   def create
     current_account.bookmarks.find_or_create_by!(account: current_account, status: @status)
     render json: @status, serializer: REST::StatusSerializer
diff --git a/app/controllers/api/v1/statuses/favourited_by_accounts_controller.rb b/app/controllers/api/v1/statuses/favourited_by_accounts_controller.rb
index 05f4acc33..8229786d6 100644
--- a/app/controllers/api/v1/statuses/favourited_by_accounts_controller.rb
+++ b/app/controllers/api/v1/statuses/favourited_by_accounts_controller.rb
@@ -7,8 +7,6 @@ class Api::V1::Statuses::FavouritedByAccountsController < Api::BaseController
   before_action :set_status
   after_action :insert_pagination_headers
 
-  respond_to :json
-
   def index
     @accounts = load_accounts
     render json: @accounts, each_serializer: REST::AccountSerializer
diff --git a/app/controllers/api/v1/statuses/favourites_controller.rb b/app/controllers/api/v1/statuses/favourites_controller.rb
index f18ace996..7afa822ed 100644
--- a/app/controllers/api/v1/statuses/favourites_controller.rb
+++ b/app/controllers/api/v1/statuses/favourites_controller.rb
@@ -7,8 +7,6 @@ class Api::V1::Statuses::FavouritesController < Api::BaseController
   before_action :require_user!
   before_action :set_status
 
-  respond_to :json
-
   def create
     FavouriteService.new.call(current_account, @status)
     render json: @status, serializer: REST::StatusSerializer
diff --git a/app/controllers/api/v1/statuses/mutes_controller.rb b/app/controllers/api/v1/statuses/mutes_controller.rb
index b02469b4f..43c7a525a 100644
--- a/app/controllers/api/v1/statuses/mutes_controller.rb
+++ b/app/controllers/api/v1/statuses/mutes_controller.rb
@@ -8,8 +8,6 @@ class Api::V1::Statuses::MutesController < Api::BaseController
   before_action :set_status
   before_action :set_conversation
 
-  respond_to :json
-
   def create
     current_account.mute_conversation!(@conversation)
     @mutes_map = { @conversation.id => true }
diff --git a/app/controllers/api/v1/statuses/pins_controller.rb b/app/controllers/api/v1/statuses/pins_controller.rb
index 4118a8ce4..51b1621b6 100644
--- a/app/controllers/api/v1/statuses/pins_controller.rb
+++ b/app/controllers/api/v1/statuses/pins_controller.rb
@@ -7,8 +7,6 @@ class Api::V1::Statuses::PinsController < Api::BaseController
   before_action :require_user!
   before_action :set_status
 
-  respond_to :json
-
   def create
     StatusPin.create!(account: current_account, status: @status)
     distribute_add_activity!
diff --git a/app/controllers/api/v1/statuses/reblogged_by_accounts_controller.rb b/app/controllers/api/v1/statuses/reblogged_by_accounts_controller.rb
index fa60e7d84..6c9e49d90 100644
--- a/app/controllers/api/v1/statuses/reblogged_by_accounts_controller.rb
+++ b/app/controllers/api/v1/statuses/reblogged_by_accounts_controller.rb
@@ -7,8 +7,6 @@ class Api::V1::Statuses::RebloggedByAccountsController < Api::BaseController
   before_action :set_status
   after_action :insert_pagination_headers
 
-  respond_to :json
-
   def index
     @accounts = load_accounts
     render json: @accounts, each_serializer: REST::AccountSerializer
diff --git a/app/controllers/api/v1/statuses/reblogs_controller.rb b/app/controllers/api/v1/statuses/reblogs_controller.rb
index 67106ccbe..7fa774a4d 100644
--- a/app/controllers/api/v1/statuses/reblogs_controller.rb
+++ b/app/controllers/api/v1/statuses/reblogs_controller.rb
@@ -7,10 +7,11 @@ class Api::V1::Statuses::ReblogsController < Api::BaseController
   before_action :require_user!
   before_action :set_reblog
 
-  respond_to :json
+  override_rate_limit_headers :create, family: :statuses
 
   def create
     @status = ReblogService.new.call(current_account, @reblog, reblog_params)
+
     render json: @status, serializer: REST::StatusSerializer
   end
 
diff --git a/app/controllers/api/v1/statuses_controller.rb b/app/controllers/api/v1/statuses_controller.rb
index 486004f9c..544e8e3c9 100644
--- a/app/controllers/api/v1/statuses_controller.rb
+++ b/app/controllers/api/v1/statuses_controller.rb
@@ -8,7 +8,7 @@ class Api::V1::StatusesController < Api::BaseController
   before_action :require_user!, except:  [:show, :context]
   before_action :set_status, only:       [:show, :context]
 
-  respond_to :json
+  override_rate_limit_headers :create, family: :statuses
 
   # This API was originally unlimited, pagination cannot be introduced without
   # breaking backwards-compatibility. Arbitrarily high number to cover most
@@ -45,7 +45,8 @@ class Api::V1::StatusesController < Api::BaseController
                                          application: doorkeeper_token.application,
                                          poll: status_params[:poll],
                                          content_type: status_params[:content_type],
-                                         idempotency: request.headers['Idempotency-Key'])
+                                         idempotency: request.headers['Idempotency-Key'],
+                                         with_rate_limit: true)
 
     render json: @status, serializer: @status.is_a?(ScheduledStatus) ? REST::ScheduledStatusSerializer : REST::StatusSerializer
   end
diff --git a/app/controllers/api/v1/streaming_controller.rb b/app/controllers/api/v1/streaming_controller.rb
index ebb17608c..7cd60615a 100644
--- a/app/controllers/api/v1/streaming_controller.rb
+++ b/app/controllers/api/v1/streaming_controller.rb
@@ -1,8 +1,6 @@
 # frozen_string_literal: true
 
 class Api::V1::StreamingController < Api::BaseController
-  respond_to :json
-
   def index
     if Rails.configuration.x.streaming_api_base_url != request.host
       redirect_to streaming_api_url, status: 301
diff --git a/app/controllers/api/v1/suggestions_controller.rb b/app/controllers/api/v1/suggestions_controller.rb
index 9da2b60ae..52054160d 100644
--- a/app/controllers/api/v1/suggestions_controller.rb
+++ b/app/controllers/api/v1/suggestions_controller.rb
@@ -7,8 +7,6 @@ class Api::V1::SuggestionsController < Api::BaseController
   before_action :require_user!
   before_action :set_accounts
 
-  respond_to :json
-
   def index
     render json: @accounts, each_serializer: REST::AccountSerializer
   end
diff --git a/app/controllers/api/v1/timelines/home_controller.rb b/app/controllers/api/v1/timelines/home_controller.rb
index ff5ede138..ae6dbcb8b 100644
--- a/app/controllers/api/v1/timelines/home_controller.rb
+++ b/app/controllers/api/v1/timelines/home_controller.rb
@@ -5,8 +5,6 @@ class Api::V1::Timelines::HomeController < Api::BaseController
   before_action :require_user!, only: [:show]
   after_action :insert_pagination_headers, unless: -> { @statuses.empty? }
 
-  respond_to :json
-
   def show
     @statuses = load_statuses
 
diff --git a/app/controllers/api/v1/timelines/public_controller.rb b/app/controllers/api/v1/timelines/public_controller.rb
index ccc10f966..581befef1 100644
--- a/app/controllers/api/v1/timelines/public_controller.rb
+++ b/app/controllers/api/v1/timelines/public_controller.rb
@@ -4,8 +4,6 @@ class Api::V1::Timelines::PublicController < Api::BaseController
   before_action :require_user!, only: [:show], if: :require_auth?
   after_action :insert_pagination_headers, unless: -> { @statuses.empty? }
 
-  respond_to :json
-
   def show
     @statuses = load_statuses
     render json: @statuses, each_serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id)
diff --git a/app/controllers/api/v1/timelines/tag_controller.rb b/app/controllers/api/v1/timelines/tag_controller.rb
index 9adc4ad29..2d6ad5a80 100644
--- a/app/controllers/api/v1/timelines/tag_controller.rb
+++ b/app/controllers/api/v1/timelines/tag_controller.rb
@@ -4,8 +4,6 @@ class Api::V1::Timelines::TagController < Api::BaseController
   before_action :load_tag
   after_action :insert_pagination_headers, unless: -> { @statuses.empty? }
 
-  respond_to :json
-
   def show
     @statuses = load_statuses
     render json: @statuses, each_serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id)
diff --git a/app/controllers/api/v1/trends_controller.rb b/app/controllers/api/v1/trends_controller.rb
index bcea9857e..c875e9041 100644
--- a/app/controllers/api/v1/trends_controller.rb
+++ b/app/controllers/api/v1/trends_controller.rb
@@ -3,8 +3,6 @@
 class Api::V1::TrendsController < Api::BaseController
   before_action :set_tags
 
-  respond_to :json
-
   def index
     render json: @tags, each_serializer: REST::TagSerializer
   end
diff --git a/app/controllers/api/v2/search_controller.rb b/app/controllers/api/v2/search_controller.rb
index 76decdb25..ddcf92200 100644
--- a/app/controllers/api/v2/search_controller.rb
+++ b/app/controllers/api/v2/search_controller.rb
@@ -8,8 +8,6 @@ class Api::V2::SearchController < Api::BaseController
   before_action -> { doorkeeper_authorize! :read, :'read:search' }
   before_action :require_user!
 
-  respond_to :json
-
   def index
     @search = Search.new(search_results)
     render json: @search, serializer: REST::SearchSerializer
diff --git a/app/controllers/api/web/embeds_controller.rb b/app/controllers/api/web/embeds_controller.rb
index 4aa31695c..741ba910f 100644
--- a/app/controllers/api/web/embeds_controller.rb
+++ b/app/controllers/api/web/embeds_controller.rb
@@ -1,8 +1,6 @@
 # frozen_string_literal: true
 
 class Api::Web::EmbedsController < Api::Web::BaseController
-  respond_to :json
-
   before_action :require_user!
 
   def create
diff --git a/app/controllers/api/web/push_subscriptions_controller.rb b/app/controllers/api/web/push_subscriptions_controller.rb
index f388b17e5..7916b82fa 100644
--- a/app/controllers/api/web/push_subscriptions_controller.rb
+++ b/app/controllers/api/web/push_subscriptions_controller.rb
@@ -1,8 +1,6 @@
 # frozen_string_literal: true
 
 class Api::Web::PushSubscriptionsController < Api::Web::BaseController
-  respond_to :json
-
   before_action :require_user!
 
   def create
diff --git a/app/controllers/api/web/settings_controller.rb b/app/controllers/api/web/settings_controller.rb
index e3178bf48..3d65e46ed 100644
--- a/app/controllers/api/web/settings_controller.rb
+++ b/app/controllers/api/web/settings_controller.rb
@@ -1,8 +1,6 @@
 # frozen_string_literal: true
 
 class Api::Web::SettingsController < Api::Web::BaseController
-  respond_to :json
-
   before_action :require_user!
 
   def update
diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
index c882d40ab..63d9f91fb 100644
--- a/app/controllers/application_controller.rb
+++ b/app/controllers/application_controller.rb
@@ -30,6 +30,7 @@ class ApplicationController < ActionController::Base
   rescue_from Mastodon::NotPermittedError, with: :forbidden
   rescue_from HTTP::Error, OpenSSL::SSL::SSLError, with: :internal_server_error
   rescue_from Mastodon::RaceConditionError, with: :service_unavailable
+  rescue_from Mastodon::RateLimitExceededError, with: :too_many_requests
 
   before_action :store_current_location, except: :raise_not_found, unless: :devise_controller?
   before_action :require_functional!, if: :user_signed_in?
@@ -181,6 +182,10 @@ class ApplicationController < ActionController::Base
     respond_with_error(503)
   end
 
+  def too_many_requests
+    respond_with_error(429)
+  end
+
   def single_user_mode?
     @single_user_mode ||= Rails.configuration.x.single_user_mode && Account.where('id > 0').exists?
   end
diff --git a/app/controllers/authorize_interactions_controller.rb b/app/controllers/authorize_interactions_controller.rb
index 20b3fa94b..f0bcac75b 100644
--- a/app/controllers/authorize_interactions_controller.rb
+++ b/app/controllers/authorize_interactions_controller.rb
@@ -21,7 +21,7 @@ class AuthorizeInteractionsController < ApplicationController
   end
 
   def create
-    if @resource.is_a?(Account) && FollowService.new.call(current_account, @resource)
+    if @resource.is_a?(Account) && FollowService.new.call(current_account, @resource, with_rate_limit: true)
       render :success
     else
       render :error
diff --git a/app/controllers/concerns/rate_limit_headers.rb b/app/controllers/concerns/rate_limit_headers.rb
index b79c558d8..86fe58a71 100644
--- a/app/controllers/concerns/rate_limit_headers.rb
+++ b/app/controllers/concerns/rate_limit_headers.rb
@@ -3,6 +3,20 @@
 module RateLimitHeaders
   extend ActiveSupport::Concern
 
+  class_methods do
+    def override_rate_limit_headers(method_name, options = {})
+      around_action(only: method_name, if: :current_account) do |_controller, block|
+        begin
+          block.call
+        ensure
+          rate_limiter = RateLimiter.new(current_account, options)
+          rate_limit_headers = rate_limiter.to_headers
+          response.headers.merge!(rate_limit_headers) unless response.headers['X-RateLimit-Remaining'].present? && rate_limit_headers['X-RateLimit-Remaining'].to_i > response.headers['X-RateLimit-Remaining'].to_i
+        end
+      end
+    end
+  end
+
   included do
     before_action :set_rate_limit_headers, if: :rate_limited_request?
   end
@@ -44,7 +58,7 @@ module RateLimitHeaders
   end
 
   def api_throttle_data
-    most_limited_type, = request.env['rack.attack.throttle_data'].min_by { |_, v| v[:limit] }
+    most_limited_type, = request.env['rack.attack.throttle_data'].min_by { |_, v| v[:limit] - v[:count] }
     request.env['rack.attack.throttle_data'][most_limited_type]
   end
 
diff --git a/app/helpers/admin/settings_helper.rb b/app/helpers/admin/settings_helper.rb
new file mode 100644
index 000000000..baf14ab25
--- /dev/null
+++ b/app/helpers/admin/settings_helper.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+module Admin::SettingsHelper
+  def site_upload_delete_hint(hint, var)
+    upload = SiteUpload.find_by(var: var.to_s)
+    return hint unless upload
+
+    link = link_to t('admin.site_uploads.delete'), admin_site_upload_path(upload), data: { method: :delete }
+    safe_join([hint, link], '<br/>'.html_safe)
+  end
+end
diff --git a/app/javascript/mastodon/actions/accounts.js b/app/javascript/mastodon/actions/accounts.js
index d4a824e2c..4af36e998 100644
--- a/app/javascript/mastodon/actions/accounts.js
+++ b/app/javascript/mastodon/actions/accounts.js
@@ -106,7 +106,7 @@ export function fetchAccount(id) {
       dispatch,
       getState,
       db.transaction('accounts', 'read').objectStore('accounts').index('id'),
-      id
+      id,
     ).then(() => db.close(), error => {
       db.close();
       throw error;
diff --git a/app/javascript/mastodon/components/intersection_observer_article.js b/app/javascript/mastodon/components/intersection_observer_article.js
index e453730ba..124b34b02 100644
--- a/app/javascript/mastodon/components/intersection_observer_article.js
+++ b/app/javascript/mastodon/components/intersection_observer_article.js
@@ -44,7 +44,7 @@ export default class IntersectionObserverArticle extends React.Component {
     intersectionObserverWrapper.observe(
       id,
       this.node,
-      this.handleIntersection
+      this.handleIntersection,
     );
 
     this.componentMounted = true;
diff --git a/app/javascript/mastodon/components/media_gallery.js b/app/javascript/mastodon/components/media_gallery.js
index cfe164a50..283d7e0a5 100644
--- a/app/javascript/mastodon/components/media_gallery.js
+++ b/app/javascript/mastodon/components/media_gallery.js
@@ -10,7 +10,7 @@ import { autoPlayGif, cropImages, displayMedia, useBlurhash } from '../initial_s
 import { decode } from 'blurhash';
 
 const messages = defineMessages({
-  toggle_visible: { id: 'media_gallery.toggle_visible', defaultMessage: 'Toggle visibility' },
+  toggle_visible: { id: 'media_gallery.toggle_visible', defaultMessage: 'Hide media' },
 });
 
 class Item extends React.PureComponent {
diff --git a/app/javascript/mastodon/components/status_action_bar.js b/app/javascript/mastodon/components/status_action_bar.js
index e2c8d43c9..bebbbcb5a 100644
--- a/app/javascript/mastodon/components/status_action_bar.js
+++ b/app/javascript/mastodon/components/status_action_bar.js
@@ -36,8 +36,8 @@ const messages = defineMessages({
   admin_account: { id: 'status.admin_account', defaultMessage: 'Open moderation interface for @{name}' },
   admin_status: { id: 'status.admin_status', defaultMessage: 'Open this status in the moderation interface' },
   copy: { id: 'status.copy', defaultMessage: 'Copy link to status' },
-  blockDomain: { id: 'account.block_domain', defaultMessage: 'Hide everything from {domain}' },
-  unblockDomain: { id: 'account.unblock_domain', defaultMessage: 'Unhide {domain}' },
+  blockDomain: { id: 'account.block_domain', defaultMessage: 'Block domain {domain}' },
+  unblockDomain: { id: 'account.unblock_domain', defaultMessage: 'Unblock domain {domain}' },
   unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' },
   unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' },
 });
diff --git a/app/javascript/mastodon/features/account/components/header.js b/app/javascript/mastodon/features/account/components/header.js
index 8bd7f2db5..35cc3952f 100644
--- a/app/javascript/mastodon/features/account/components/header.js
+++ b/app/javascript/mastodon/features/account/components/header.js
@@ -29,8 +29,8 @@ const messages = defineMessages({
   report: { id: 'account.report', defaultMessage: 'Report @{name}' },
   share: { id: 'account.share', defaultMessage: 'Share @{name}\'s profile' },
   media: { id: 'account.media', defaultMessage: 'Media' },
-  blockDomain: { id: 'account.block_domain', defaultMessage: 'Hide everything from {domain}' },
-  unblockDomain: { id: 'account.unblock_domain', defaultMessage: 'Unhide {domain}' },
+  blockDomain: { id: 'account.block_domain', defaultMessage: 'Block domain {domain}' },
+  unblockDomain: { id: 'account.unblock_domain', defaultMessage: 'Unblock domain {domain}' },
   hideReblogs: { id: 'account.hide_reblogs', defaultMessage: 'Hide boosts from @{name}' },
   showReblogs: { id: 'account.show_reblogs', defaultMessage: 'Show boosts from @{name}' },
   pins: { id: 'navigation_bar.pins', defaultMessage: 'Pinned toots' },
diff --git a/app/javascript/mastodon/features/audio/index.js b/app/javascript/mastodon/features/audio/index.js
index fda5a074f..95c9c7751 100644
--- a/app/javascript/mastodon/features/audio/index.js
+++ b/app/javascript/mastodon/features/audio/index.js
@@ -214,8 +214,8 @@ class Audio extends React.PureComponent {
         <div className='video-player__controls active'>
           <div className='video-player__buttons-bar'>
             <div className='video-player__buttons left'>
-              <button type='button' aria-label={intl.formatMessage(paused ? messages.play : messages.pause)} onClick={this.togglePlay}><Icon id={paused ? 'play' : 'pause'} fixedWidth /></button>
-              <button type='button' aria-label={intl.formatMessage(muted ? messages.unmute : messages.mute)} onClick={this.toggleMute}><Icon id={muted ? 'volume-off' : 'volume-up'} fixedWidth /></button>
+              <button type='button' title={intl.formatMessage(paused ? messages.play : messages.pause)} aria-label={intl.formatMessage(paused ? messages.play : messages.pause)} onClick={this.togglePlay}><Icon id={paused ? 'play' : 'pause'} fixedWidth /></button>
+              <button type='button' title={intl.formatMessage(muted ? messages.unmute : messages.mute)} aria-label={intl.formatMessage(muted ? messages.unmute : messages.mute)} onClick={this.toggleMute}><Icon id={muted ? 'volume-off' : 'volume-up'} fixedWidth /></button>
 
               <div className='video-player__volume' onMouseDown={this.handleVolumeMouseDown} ref={this.setVolumeRef}>
                 &nbsp;
@@ -236,7 +236,7 @@ class Audio extends React.PureComponent {
             </div>
 
             <div className='video-player__buttons right'>
-              <button type='button' aria-label={intl.formatMessage(messages.download)}>
+              <button type='button' title={intl.formatMessage(messages.download)} aria-label={intl.formatMessage(messages.download)}>
                 <a className='video-player__download__icon' href={this.props.src} download>
                   <Icon id={'download'} fixedWidth />
                 </a>
diff --git a/app/javascript/mastodon/features/blocks/index.js b/app/javascript/mastodon/features/blocks/index.js
index 051431ed2..870c0de09 100644
--- a/app/javascript/mastodon/features/blocks/index.js
+++ b/app/javascript/mastodon/features/blocks/index.js
@@ -68,7 +68,7 @@ class Blocks extends ImmutablePureComponent {
           bindToDocument={!multiColumn}
         >
           {accountIds.map(id =>
-            <AccountContainer key={id} id={id} />
+            <AccountContainer key={id} id={id} />,
           )}
         </ScrollableList>
       </Column>
diff --git a/app/javascript/mastodon/features/compose/components/poll_form.js b/app/javascript/mastodon/features/compose/components/poll_form.js
index cac3776bb..cc740def2 100644
--- a/app/javascript/mastodon/features/compose/components/poll_form.js
+++ b/app/javascript/mastodon/features/compose/components/poll_form.js
@@ -155,6 +155,7 @@ class PollForm extends ImmutablePureComponent {
         <div className='poll__footer'>
           <button disabled={options.size >= 5} className='button button-secondary' onClick={this.handleAddOption}><Icon id='plus' /> <FormattedMessage {...messages.add_option} /></button>
 
+          {/* eslint-disable-next-line jsx-a11y/no-onchange */}
           <select value={expiresIn} onChange={this.handleSelectDuration}>
             <option value={300}>{intl.formatMessage(messages.minutes, { number: 5 })}</option>
             <option value={1800}>{intl.formatMessage(messages.minutes, { number: 30 })}</option>
diff --git a/app/javascript/mastodon/features/compose/components/privacy_dropdown.js b/app/javascript/mastodon/features/compose/components/privacy_dropdown.js
index 7cbfe463a..de030b7a2 100644
--- a/app/javascript/mastodon/features/compose/components/privacy_dropdown.js
+++ b/app/javascript/mastodon/features/compose/components/privacy_dropdown.js
@@ -11,13 +11,13 @@ import Icon from 'mastodon/components/icon';
 
 const messages = defineMessages({
   public_short: { id: 'privacy.public.short', defaultMessage: 'Public' },
-  public_long: { id: 'privacy.public.long', defaultMessage: 'Post to public timelines' },
+  public_long: { id: 'privacy.public.long', defaultMessage: 'Visible for all, shown in public timelines' },
   unlisted_short: { id: 'privacy.unlisted.short', defaultMessage: 'Unlisted' },
-  unlisted_long: { id: 'privacy.unlisted.long', defaultMessage: 'Do not show in public timelines' },
+  unlisted_long: { id: 'privacy.unlisted.long', defaultMessage: 'Visible for all, but not in public timelines' },
   private_short: { id: 'privacy.private.short', defaultMessage: 'Followers-only' },
-  private_long: { id: 'privacy.private.long', defaultMessage: 'Post to followers only' },
+  private_long: { id: 'privacy.private.long', defaultMessage: 'Visible for followers only' },
   direct_short: { id: 'privacy.direct.short', defaultMessage: 'Direct' },
-  direct_long: { id: 'privacy.direct.long', defaultMessage: 'Post to mentioned users only' },
+  direct_long: { id: 'privacy.direct.long', defaultMessage: 'Visible for mentioned users only' },
   change_privacy: { id: 'privacy.change', defaultMessage: 'Adjust status privacy' },
 });
 
diff --git a/app/javascript/mastodon/features/domain_blocks/index.js b/app/javascript/mastodon/features/domain_blocks/index.js
index 482245c86..06533d5ac 100644
--- a/app/javascript/mastodon/features/domain_blocks/index.js
+++ b/app/javascript/mastodon/features/domain_blocks/index.js
@@ -69,7 +69,7 @@ class Blocks extends ImmutablePureComponent {
           bindToDocument={!multiColumn}
         >
           {domains.map(domain =>
-            <DomainContainer key={domain} domain={domain} />
+            <DomainContainer key={domain} domain={domain} />,
           )}
         </ScrollableList>
       </Column>
diff --git a/app/javascript/mastodon/features/favourites/index.js b/app/javascript/mastodon/features/favourites/index.js
index 249e6a044..75cb00c0e 100644
--- a/app/javascript/mastodon/features/favourites/index.js
+++ b/app/javascript/mastodon/features/favourites/index.js
@@ -79,7 +79,7 @@ class Favourites extends ImmutablePureComponent {
           bindToDocument={!multiColumn}
         >
           {accountIds.map(id =>
-            <AccountContainer key={id} id={id} withNote={false} />
+            <AccountContainer key={id} id={id} withNote={false} />,
           )}
         </ScrollableList>
       </Column>
diff --git a/app/javascript/mastodon/features/follow_requests/index.js b/app/javascript/mastodon/features/follow_requests/index.js
index 57ef44145..bef56fab5 100644
--- a/app/javascript/mastodon/features/follow_requests/index.js
+++ b/app/javascript/mastodon/features/follow_requests/index.js
@@ -68,7 +68,7 @@ class FollowRequests extends ImmutablePureComponent {
           bindToDocument={!multiColumn}
         >
           {accountIds.map(id =>
-            <AccountAuthorizeContainer key={id} id={id} />
+            <AccountAuthorizeContainer key={id} id={id} />,
           )}
         </ScrollableList>
       </Column>
diff --git a/app/javascript/mastodon/features/followers/index.js b/app/javascript/mastodon/features/followers/index.js
index 9e635d250..f8723e055 100644
--- a/app/javascript/mastodon/features/followers/index.js
+++ b/app/javascript/mastodon/features/followers/index.js
@@ -93,7 +93,7 @@ class Followers extends ImmutablePureComponent {
           bindToDocument={!multiColumn}
         >
           {blockedBy ? [] : accountIds.map(id =>
-            <AccountContainer key={id} id={id} withNote={false} />
+            <AccountContainer key={id} id={id} withNote={false} />,
           )}
         </ScrollableList>
       </Column>
diff --git a/app/javascript/mastodon/features/following/index.js b/app/javascript/mastodon/features/following/index.js
index 284ae2c11..5112bfa9d 100644
--- a/app/javascript/mastodon/features/following/index.js
+++ b/app/javascript/mastodon/features/following/index.js
@@ -93,7 +93,7 @@ class Following extends ImmutablePureComponent {
           bindToDocument={!multiColumn}
         >
           {blockedBy ? [] : accountIds.map(id =>
-            <AccountContainer key={id} id={id} withNote={false} />
+            <AccountContainer key={id} id={id} withNote={false} />,
           )}
         </ScrollableList>
       </Column>
diff --git a/app/javascript/mastodon/features/getting_started/components/announcements.js b/app/javascript/mastodon/features/getting_started/components/announcements.js
index 91cf6215e..fb4a6bf0d 100644
--- a/app/javascript/mastodon/features/getting_started/components/announcements.js
+++ b/app/javascript/mastodon/features/getting_started/components/announcements.js
@@ -95,6 +95,10 @@ class Content extends ImmutablePureComponent {
       } else if (link.textContent[0] === '#' || (link.previousSibling && link.previousSibling.textContent && link.previousSibling.textContent[link.previousSibling.textContent.length - 1] === '#')) {
         link.addEventListener('click', this.onHashtagClick.bind(this, link.text), false);
       } else {
+        let status = this.props.announcement.get('statuses').find(item => link.href === item.get('url'));
+        if (status) {
+          link.addEventListener('click', this.onStatusClick.bind(this, status), false);
+        }
         link.setAttribute('title', link.href);
         link.classList.add('unhandled-link');
       }
@@ -120,6 +124,13 @@ class Content extends ImmutablePureComponent {
     }
   }
 
+  onStatusClick = (status, e) => {
+    if (this.context.router && e.button === 0 && !(e.ctrlKey || e.metaKey)) {
+      e.preventDefault();
+      this.context.router.history.push(`/statuses/${status.get('id')}`);
+    }
+  }
+
   handleEmojiMouseEnter = ({ target }) => {
     target.src = target.getAttribute('data-original');
   }
diff --git a/app/javascript/mastodon/features/getting_started/index.js b/app/javascript/mastodon/features/getting_started/index.js
index adbc147d1..d9838e1c7 100644
--- a/app/javascript/mastodon/features/getting_started/index.js
+++ b/app/javascript/mastodon/features/getting_started/index.js
@@ -106,20 +106,20 @@ class GettingStarted extends ImmutablePureComponent {
 
       if (profile_directory) {
         navItems.push(
-          <ColumnLink key={i++} icon='address-book' text={intl.formatMessage(messages.profile_directory)} to='/directory' />
+          <ColumnLink key={i++} icon='address-book' text={intl.formatMessage(messages.profile_directory)} to='/directory' />,
         );
 
         height += 48;
       }
 
       navItems.push(
-        <ColumnSubheading key={i++} text={intl.formatMessage(messages.personal)} />
+        <ColumnSubheading key={i++} text={intl.formatMessage(messages.personal)} />,
       );
 
       height += 34;
     } else if (profile_directory) {
       navItems.push(
-        <ColumnLink key={i++} icon='address-book' text={intl.formatMessage(messages.profile_directory)} to='/directory' />
+        <ColumnLink key={i++} icon='address-book' text={intl.formatMessage(messages.profile_directory)} to='/directory' />,
       );
 
       height += 48;
@@ -129,7 +129,7 @@ class GettingStarted extends ImmutablePureComponent {
       <ColumnLink key={i++} icon='envelope' text={intl.formatMessage(messages.direct)} to='/timelines/direct' />,
       <ColumnLink key={i++} icon='bookmark' text={intl.formatMessage(messages.bookmarks)} to='/bookmarks' />,
       <ColumnLink key={i++} icon='star' text={intl.formatMessage(messages.favourites)} to='/favourites' />,
-      <ColumnLink key={i++} icon='list-ul' text={intl.formatMessage(messages.lists)} to='/lists' />
+      <ColumnLink key={i++} icon='list-ul' text={intl.formatMessage(messages.lists)} to='/lists' />,
     );
 
     height += 48*4;
diff --git a/app/javascript/mastodon/features/lists/index.js b/app/javascript/mastodon/features/lists/index.js
index 7f7f5009c..ca1fa1f5e 100644
--- a/app/javascript/mastodon/features/lists/index.js
+++ b/app/javascript/mastodon/features/lists/index.js
@@ -74,7 +74,7 @@ class Lists extends ImmutablePureComponent {
           bindToDocument={!multiColumn}
         >
           {lists.map(list =>
-            <ColumnLink key={list.get('id')} to={`/timelines/list/${list.get('id')}`} icon='list-ul' text={list.get('title')} />
+            <ColumnLink key={list.get('id')} to={`/timelines/list/${list.get('id')}`} icon='list-ul' text={list.get('title')} />,
           )}
         </ScrollableList>
       </Column>
diff --git a/app/javascript/mastodon/features/mutes/index.js b/app/javascript/mastodon/features/mutes/index.js
index 91dd268c1..3f58a62d2 100644
--- a/app/javascript/mastodon/features/mutes/index.js
+++ b/app/javascript/mastodon/features/mutes/index.js
@@ -68,7 +68,7 @@ class Mutes extends ImmutablePureComponent {
           bindToDocument={!multiColumn}
         >
           {accountIds.map(id =>
-            <AccountContainer key={id} id={id} />
+            <AccountContainer key={id} id={id} />,
           )}
         </ScrollableList>
       </Column>
diff --git a/app/javascript/mastodon/features/reblogs/index.js b/app/javascript/mastodon/features/reblogs/index.js
index 9179e51db..4becb5fb7 100644
--- a/app/javascript/mastodon/features/reblogs/index.js
+++ b/app/javascript/mastodon/features/reblogs/index.js
@@ -79,7 +79,7 @@ class Reblogs extends ImmutablePureComponent {
           bindToDocument={!multiColumn}
         >
           {accountIds.map(id =>
-            <AccountContainer key={id} id={id} withNote={false} />
+            <AccountContainer key={id} id={id} withNote={false} />,
           )}
         </ScrollableList>
       </Column>
diff --git a/app/javascript/mastodon/features/status/components/action_bar.js b/app/javascript/mastodon/features/status/components/action_bar.js
index 959774da4..ba62d7b10 100644
--- a/app/javascript/mastodon/features/status/components/action_bar.js
+++ b/app/javascript/mastodon/features/status/components/action_bar.js
@@ -32,8 +32,8 @@ const messages = defineMessages({
   admin_account: { id: 'status.admin_account', defaultMessage: 'Open moderation interface for @{name}' },
   admin_status: { id: 'status.admin_status', defaultMessage: 'Open this status in the moderation interface' },
   copy: { id: 'status.copy', defaultMessage: 'Copy link to status' },
-  blockDomain: { id: 'account.block_domain', defaultMessage: 'Hide everything from {domain}' },
-  unblockDomain: { id: 'account.unblock_domain', defaultMessage: 'Unhide {domain}' },
+  blockDomain: { id: 'account.block_domain', defaultMessage: 'Block domain {domain}' },
+  unblockDomain: { id: 'account.unblock_domain', defaultMessage: 'Unblock domain {domain}' },
   unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' },
   unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' },
 });
diff --git a/app/javascript/mastodon/features/status/components/card.js b/app/javascript/mastodon/features/status/components/card.js
index 2993fe29a..b8344a667 100644
--- a/app/javascript/mastodon/features/status/components/card.js
+++ b/app/javascript/mastodon/features/status/components/card.js
@@ -98,7 +98,7 @@ export default class Card extends React.PureComponent {
           },
         },
       ]),
-      0
+      0,
     );
   };
 
diff --git a/app/javascript/mastodon/features/ui/components/__tests__/column-test.js b/app/javascript/mastodon/features/ui/components/__tests__/column-test.js
index 11cc1b6e8..89cb2458d 100644
--- a/app/javascript/mastodon/features/ui/components/__tests__/column-test.js
+++ b/app/javascript/mastodon/features/ui/components/__tests__/column-test.js
@@ -19,7 +19,7 @@ describe('<Column />', () => {
       const wrapper = mount(
         <Column heading='notifications'>
           <div className='scrollable' />
-        </Column>
+        </Column>,
       );
       wrapper.find(ColumnHeader).find('button').simulate('click');
       expect(global.requestAnimationFrame.mock.calls.length).toEqual(1);
diff --git a/app/javascript/mastodon/features/video/index.js b/app/javascript/mastodon/features/video/index.js
index 8ac9c8db7..42ded9d21 100644
--- a/app/javascript/mastodon/features/video/index.js
+++ b/app/javascript/mastodon/features/video/index.js
@@ -492,8 +492,8 @@ class Video extends React.PureComponent {
 
           <div className='video-player__buttons-bar'>
             <div className='video-player__buttons left'>
-              <button type='button' aria-label={intl.formatMessage(paused ? messages.play : messages.pause)} onClick={this.togglePlay} autoFocus={detailed}><Icon id={paused ? 'play' : 'pause'} fixedWidth /></button>
-              <button type='button' aria-label={intl.formatMessage(muted ? messages.unmute : messages.mute)} onClick={this.toggleMute}><Icon id={muted ? 'volume-off' : 'volume-up'} fixedWidth /></button>
+              <button type='button' title={intl.formatMessage(paused ? messages.play : messages.pause)} aria-label={intl.formatMessage(paused ? messages.play : messages.pause)} onClick={this.togglePlay} autoFocus={detailed}><Icon id={paused ? 'play' : 'pause'} fixedWidth /></button>
+              <button type='button' title={intl.formatMessage(muted ? messages.unmute : messages.mute)} aria-label={intl.formatMessage(muted ? messages.unmute : messages.mute)} onClick={this.toggleMute}><Icon id={muted ? 'volume-off' : 'volume-up'} fixedWidth /></button>
 
               <div className='video-player__volume' onMouseDown={this.handleVolumeMouseDown} ref={this.setVolumeRef}>
                 &nbsp;
@@ -517,11 +517,11 @@ class Video extends React.PureComponent {
             </div>
 
             <div className='video-player__buttons right'>
-              {(!onCloseVideo && !editable && !fullscreen) && <button type='button' aria-label={intl.formatMessage(messages.hide)} onClick={this.toggleReveal}><Icon id='eye-slash' fixedWidth /></button>}
-              {(!fullscreen && onOpenVideo) && <button type='button' aria-label={intl.formatMessage(messages.expand)} onClick={this.handleOpenVideo}><Icon id='expand' fixedWidth /></button>}
-              {onCloseVideo && <button type='button' aria-label={intl.formatMessage(messages.close)} onClick={this.handleCloseVideo}><Icon id='compress' fixedWidth /></button>}
-              <button type='button' aria-label={intl.formatMessage(messages.download)}><a className='video-player__download__icon' href={this.props.src} download><Icon id={'download'} fixedWidth /></a></button>
-              <button type='button' aria-label={intl.formatMessage(fullscreen ? messages.exit_fullscreen : messages.fullscreen)} onClick={this.toggleFullscreen}><Icon id={fullscreen ? 'compress' : 'arrows-alt'} fixedWidth /></button>
+              {(!onCloseVideo && !editable && !fullscreen) && <button type='button' title={intl.formatMessage(messages.hide)} aria-label={intl.formatMessage(messages.hide)} onClick={this.toggleReveal}><Icon id='eye-slash' fixedWidth /></button>}
+              {(!fullscreen && onOpenVideo) && <button type='button' title={intl.formatMessage(messages.expand)} aria-label={intl.formatMessage(messages.expand)} onClick={this.handleOpenVideo}><Icon id='expand' fixedWidth /></button>}
+              {onCloseVideo && <button type='button' title={intl.formatMessage(messages.close)} aria-label={intl.formatMessage(messages.close)} onClick={this.handleCloseVideo}><Icon id='compress' fixedWidth /></button>}
+              <button type='button' title={intl.formatMessage(messages.download)} aria-label={intl.formatMessage(messages.download)}><a className='video-player__download__icon' href={this.props.src} download><Icon id={'download'} fixedWidth /></a></button>
+              <button type='button' title={intl.formatMessage(fullscreen ? messages.exit_fullscreen : messages.fullscreen)} aria-label={intl.formatMessage(fullscreen ? messages.exit_fullscreen : messages.fullscreen)} onClick={this.toggleFullscreen}><Icon id={fullscreen ? 'compress' : 'arrows-alt'} fixedWidth /></button>
             </div>
           </div>
         </div>
diff --git a/app/javascript/mastodon/locales/bg.json b/app/javascript/mastodon/locales/bg.json
index 5429f358f..c1a7bb533 100644
--- a/app/javascript/mastodon/locales/bg.json
+++ b/app/javascript/mastodon/locales/bg.json
@@ -254,7 +254,7 @@
   "lists.subheading": "Your lists",
   "load_pending": "{count, plural, one {# new item} other {# new items}}",
   "loading_indicator.label": "Зареждане...",
-  "media_gallery.toggle_visible": "Toggle visibility",
+  "media_gallery.toggle_visible": "Hide media",
   "missing_indicator.label": "Not found",
   "missing_indicator.sublabel": "This resource could not be found",
   "mute_modal.hide_notifications": "Hide notifications from this user?",
diff --git a/app/javascript/mastodon/locales/br.json b/app/javascript/mastodon/locales/br.json
index 96092457f..b6bfa6648 100644
--- a/app/javascript/mastodon/locales/br.json
+++ b/app/javascript/mastodon/locales/br.json
@@ -254,7 +254,7 @@
   "lists.subheading": "Your lists",
   "load_pending": "{count, plural, one {# new item} other {# new items}}",
   "loading_indicator.label": "Loading...",
-  "media_gallery.toggle_visible": "Toggle visibility",
+  "media_gallery.toggle_visible": "Hide media",
   "missing_indicator.label": "Not found",
   "missing_indicator.sublabel": "This resource could not be found",
   "mute_modal.hide_notifications": "Hide notifications from this user?",
diff --git a/app/javascript/mastodon/locales/defaultMessages.json b/app/javascript/mastodon/locales/defaultMessages.json
index 18692bc44..93cfa2431 100644
--- a/app/javascript/mastodon/locales/defaultMessages.json
+++ b/app/javascript/mastodon/locales/defaultMessages.json
@@ -142,7 +142,7 @@
   {
     "descriptors": [
       {
-        "defaultMessage": "Unhide {domain}",
+        "defaultMessage": "Unblock domain {domain}",
         "id": "account.unblock_domain"
       }
     ],
@@ -217,7 +217,7 @@
   {
     "descriptors": [
       {
-        "defaultMessage": "Toggle visibility",
+        "defaultMessage": "Hide media",
         "id": "media_gallery.toggle_visible"
       },
       {
@@ -451,11 +451,11 @@
         "id": "status.copy"
       },
       {
-        "defaultMessage": "Hide everything from {domain}",
+        "defaultMessage": "Block domain {domain}",
         "id": "account.block_domain"
       },
       {
-        "defaultMessage": "Unhide {domain}",
+        "defaultMessage": "Unblock domain {domain}",
         "id": "account.unblock_domain"
       },
       {
@@ -697,11 +697,11 @@
         "id": "account.media"
       },
       {
-        "defaultMessage": "Hide everything from {domain}",
+        "defaultMessage": "Block domain {domain}",
         "id": "account.block_domain"
       },
       {
-        "defaultMessage": "Unhide {domain}",
+        "defaultMessage": "Unblock domain {domain}",
         "id": "account.unblock_domain"
       },
       {
@@ -1073,7 +1073,7 @@
         "id": "privacy.public.short"
       },
       {
-        "defaultMessage": "Post to public timelines",
+        "defaultMessage": "Visible for all, shown in public timelines",
         "id": "privacy.public.long"
       },
       {
@@ -1081,7 +1081,7 @@
         "id": "privacy.unlisted.short"
       },
       {
-        "defaultMessage": "Do not show in public timelines",
+        "defaultMessage": "Visible for all, but not in public timelines",
         "id": "privacy.unlisted.long"
       },
       {
@@ -1089,7 +1089,7 @@
         "id": "privacy.private.short"
       },
       {
-        "defaultMessage": "Post to followers only",
+        "defaultMessage": "Visible for followers only",
         "id": "privacy.private.long"
       },
       {
@@ -1097,7 +1097,7 @@
         "id": "privacy.direct.short"
       },
       {
-        "defaultMessage": "Post to mentioned users only",
+        "defaultMessage": "Visible for mentioned users only",
         "id": "privacy.direct.long"
       },
       {
@@ -1470,7 +1470,7 @@
         "id": "column.domain_blocks"
       },
       {
-        "defaultMessage": "Unhide {domain}",
+        "defaultMessage": "Unblock domain {domain}",
         "id": "account.unblock_domain"
       },
       {
@@ -2384,11 +2384,11 @@
         "id": "status.copy"
       },
       {
-        "defaultMessage": "Hide everything from {domain}",
+        "defaultMessage": "Block domain {domain}",
         "id": "account.block_domain"
       },
       {
-        "defaultMessage": "Unhide {domain}",
+        "defaultMessage": "Unblock domain {domain}",
         "id": "account.unblock_domain"
       },
       {
@@ -2957,4 +2957,4 @@
     ],
     "path": "app/javascript/mastodon/features/video/index.json"
   }
-]
\ No newline at end of file
+]
diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json
index e25199905..3ec951903 100644
--- a/app/javascript/mastodon/locales/en.json
+++ b/app/javascript/mastodon/locales/en.json
@@ -3,7 +3,7 @@
   "account.badges.bot": "Bot",
   "account.badges.group": "Group",
   "account.block": "Block @{name}",
-  "account.block_domain": "Hide everything from {domain}",
+  "account.block_domain": "Block domain {domain}",
   "account.blocked": "Blocked",
   "account.cancel_follow_request": "Cancel follow request",
   "account.direct": "Direct message @{name}",
@@ -34,7 +34,7 @@
   "account.share": "Share @{name}'s profile",
   "account.show_reblogs": "Show boosts from @{name}",
   "account.unblock": "Unblock @{name}",
-  "account.unblock_domain": "Unhide {domain}",
+  "account.unblock_domain": "Unblock domain {domain}",
   "account.unendorse": "Don't feature on profile",
   "account.unfollow": "Unfollow",
   "account.unmute": "Unmute @{name}",
@@ -258,7 +258,7 @@
   "lists.subheading": "Your lists",
   "load_pending": "{count, plural, one {# new item} other {# new items}}",
   "loading_indicator.label": "Loading...",
-  "media_gallery.toggle_visible": "Toggle visibility",
+  "media_gallery.toggle_visible": "Hide media",
   "missing_indicator.label": "Not found",
   "missing_indicator.sublabel": "This resource could not be found",
   "mute_modal.hide_notifications": "Hide notifications from this user?",
@@ -324,13 +324,13 @@
   "poll_button.add_poll": "Add a poll",
   "poll_button.remove_poll": "Remove poll",
   "privacy.change": "Adjust status privacy",
-  "privacy.direct.long": "Post to mentioned users only",
+  "privacy.direct.long": "Visible for mentioned users only",
   "privacy.direct.short": "Direct",
-  "privacy.private.long": "Post to followers only",
+  "privacy.private.long": "Visible for followers only",
   "privacy.private.short": "Followers-only",
-  "privacy.public.long": "Post to public timelines",
+  "privacy.public.long": "Visible for all, shown in public timelines",
   "privacy.public.short": "Public",
-  "privacy.unlisted.long": "Do not post to public timelines",
+  "privacy.unlisted.long": "Visible for all, but not in public timelines",
   "privacy.unlisted.short": "Unlisted",
   "refresh": "Refresh",
   "regeneration_indicator.label": "Loading…",
diff --git a/app/javascript/mastodon/locales/ga.json b/app/javascript/mastodon/locales/ga.json
index 0e9c9d6d1..b69ec2b95 100644
--- a/app/javascript/mastodon/locales/ga.json
+++ b/app/javascript/mastodon/locales/ga.json
@@ -254,7 +254,7 @@
   "lists.subheading": "Your lists",
   "load_pending": "{count, plural, one {# new item} other {# new items}}",
   "loading_indicator.label": "Loading...",
-  "media_gallery.toggle_visible": "Toggle visibility",
+  "media_gallery.toggle_visible": "Hide media",
   "missing_indicator.label": "Not found",
   "missing_indicator.sublabel": "This resource could not be found",
   "mute_modal.hide_notifications": "Hide notifications from this user?",
diff --git a/app/javascript/mastodon/locales/hi.json b/app/javascript/mastodon/locales/hi.json
index d4659bc2e..ee9d701b0 100644
--- a/app/javascript/mastodon/locales/hi.json
+++ b/app/javascript/mastodon/locales/hi.json
@@ -254,7 +254,7 @@
   "lists.subheading": "Your lists",
   "load_pending": "{count, plural, one {# new item} other {# new items}}",
   "loading_indicator.label": "लोड हो रहा है...",
-  "media_gallery.toggle_visible": "Toggle visibility",
+  "media_gallery.toggle_visible": "Hide media",
   "missing_indicator.label": "नहीं मिला",
   "missing_indicator.sublabel": "यह संसाधन नहीं मिल सका।",
   "mute_modal.hide_notifications": "Hide notifications from this user?",
diff --git a/app/javascript/mastodon/locales/kn.json b/app/javascript/mastodon/locales/kn.json
index 5385de456..eafb7ede7 100644
--- a/app/javascript/mastodon/locales/kn.json
+++ b/app/javascript/mastodon/locales/kn.json
@@ -254,7 +254,7 @@
   "lists.subheading": "Your lists",
   "load_pending": "{count, plural, one {# new item} other {# new items}}",
   "loading_indicator.label": "Loading...",
-  "media_gallery.toggle_visible": "Toggle visibility",
+  "media_gallery.toggle_visible": "Hide media",
   "missing_indicator.label": "Not found",
   "missing_indicator.sublabel": "This resource could not be found",
   "mute_modal.hide_notifications": "Hide notifications from this user?",
diff --git a/app/javascript/mastodon/locales/lt.json b/app/javascript/mastodon/locales/lt.json
index 5385de456..eafb7ede7 100644
--- a/app/javascript/mastodon/locales/lt.json
+++ b/app/javascript/mastodon/locales/lt.json
@@ -254,7 +254,7 @@
   "lists.subheading": "Your lists",
   "load_pending": "{count, plural, one {# new item} other {# new items}}",
   "loading_indicator.label": "Loading...",
-  "media_gallery.toggle_visible": "Toggle visibility",
+  "media_gallery.toggle_visible": "Hide media",
   "missing_indicator.label": "Not found",
   "missing_indicator.sublabel": "This resource could not be found",
   "mute_modal.hide_notifications": "Hide notifications from this user?",
diff --git a/app/javascript/mastodon/locales/lv.json b/app/javascript/mastodon/locales/lv.json
index 1b5b7cf5e..82ec3b8e8 100644
--- a/app/javascript/mastodon/locales/lv.json
+++ b/app/javascript/mastodon/locales/lv.json
@@ -254,7 +254,7 @@
   "lists.subheading": "Your lists",
   "load_pending": "{count, plural, one {# new item} other {# new items}}",
   "loading_indicator.label": "Loading...",
-  "media_gallery.toggle_visible": "Toggle visibility",
+  "media_gallery.toggle_visible": "Hide media",
   "missing_indicator.label": "Not found",
   "missing_indicator.sublabel": "This resource could not be found",
   "mute_modal.hide_notifications": "Hide notifications from this user?",
diff --git a/app/javascript/mastodon/locales/mk.json b/app/javascript/mastodon/locales/mk.json
index 4a1f736cf..ee8b13ace 100644
--- a/app/javascript/mastodon/locales/mk.json
+++ b/app/javascript/mastodon/locales/mk.json
@@ -254,7 +254,7 @@
   "lists.subheading": "Your lists",
   "load_pending": "{count, plural, one {# new item} other {# new items}}",
   "loading_indicator.label": "Loading...",
-  "media_gallery.toggle_visible": "Toggle visibility",
+  "media_gallery.toggle_visible": "Hide media",
   "missing_indicator.label": "Not found",
   "missing_indicator.sublabel": "This resource could not be found",
   "mute_modal.hide_notifications": "Hide notifications from this user?",
diff --git a/app/javascript/mastodon/locales/ml.json b/app/javascript/mastodon/locales/ml.json
index 58bb9b723..b3a06d6ed 100644
--- a/app/javascript/mastodon/locales/ml.json
+++ b/app/javascript/mastodon/locales/ml.json
@@ -254,7 +254,7 @@
   "lists.subheading": "Your lists",
   "load_pending": "{count, plural, one {# new item} other {# new items}}",
   "loading_indicator.label": "Loading...",
-  "media_gallery.toggle_visible": "Toggle visibility",
+  "media_gallery.toggle_visible": "Hide media",
   "missing_indicator.label": "Not found",
   "missing_indicator.sublabel": "This resource could not be found",
   "mute_modal.hide_notifications": "Hide notifications from this user?",
diff --git a/app/javascript/mastodon/locales/mr.json b/app/javascript/mastodon/locales/mr.json
index 8c0fed9b0..be5d9a396 100644
--- a/app/javascript/mastodon/locales/mr.json
+++ b/app/javascript/mastodon/locales/mr.json
@@ -254,7 +254,7 @@
   "lists.subheading": "Your lists",
   "load_pending": "{count, plural, one {# new item} other {# new items}}",
   "loading_indicator.label": "Loading...",
-  "media_gallery.toggle_visible": "Toggle visibility",
+  "media_gallery.toggle_visible": "Hide media",
   "missing_indicator.label": "Not found",
   "missing_indicator.sublabel": "This resource could not be found",
   "mute_modal.hide_notifications": "Hide notifications from this user?",
diff --git a/app/javascript/mastodon/locales/ms.json b/app/javascript/mastodon/locales/ms.json
index 0d23e0d2c..19f3ca257 100644
--- a/app/javascript/mastodon/locales/ms.json
+++ b/app/javascript/mastodon/locales/ms.json
@@ -254,7 +254,7 @@
   "lists.subheading": "Your lists",
   "load_pending": "{count, plural, one {# new item} other {# new items}}",
   "loading_indicator.label": "Loading...",
-  "media_gallery.toggle_visible": "Toggle visibility",
+  "media_gallery.toggle_visible": "Hide media",
   "missing_indicator.label": "Not found",
   "missing_indicator.sublabel": "This resource could not be found",
   "mute_modal.hide_notifications": "Hide notifications from this user?",
diff --git a/app/javascript/mastodon/locales/ur.json b/app/javascript/mastodon/locales/ur.json
index 664eebfbf..d625a88bf 100644
--- a/app/javascript/mastodon/locales/ur.json
+++ b/app/javascript/mastodon/locales/ur.json
@@ -254,7 +254,7 @@
   "lists.subheading": "Your lists",
   "load_pending": "{count, plural, one {# new item} other {# new items}}",
   "loading_indicator.label": "Loading...",
-  "media_gallery.toggle_visible": "Toggle visibility",
+  "media_gallery.toggle_visible": "Hide media",
   "missing_indicator.label": "Not found",
   "missing_indicator.sublabel": "This resource could not be found",
   "mute_modal.hide_notifications": "Hide notifications from this user?",
diff --git a/app/javascript/mastodon/reducers/notifications.js b/app/javascript/mastodon/reducers/notifications.js
index 60e901e39..ed1ba0272 100644
--- a/app/javascript/mastodon/reducers/notifications.js
+++ b/app/javascript/mastodon/reducers/notifications.js
@@ -72,11 +72,11 @@ const expandNormalizedNotifications = (state, notifications, next, isLoadingRece
 
       mutable.update(usePendingItems ? 'pendingItems' : 'items', list => {
         const lastIndex = 1 + list.findLastIndex(
-          item => item !== null && (compareId(item.get('id'), items.last().get('id')) > 0 || item.get('id') === items.last().get('id'))
+          item => item !== null && (compareId(item.get('id'), items.last().get('id')) > 0 || item.get('id') === items.last().get('id')),
         );
 
         const firstIndex = 1 + list.take(lastIndex).findLastIndex(
-          item => item !== null && compareId(item.get('id'), items.first().get('id')) > 0
+          item => item !== null && compareId(item.get('id'), items.first().get('id')) > 0,
         );
 
         return list.take(firstIndex).concat(items, list.skip(lastIndex));
diff --git a/app/javascript/mastodon/reducers/timelines.js b/app/javascript/mastodon/reducers/timelines.js
index 0d7222e10..63b76773d 100644
--- a/app/javascript/mastodon/reducers/timelines.js
+++ b/app/javascript/mastodon/reducers/timelines.js
@@ -54,7 +54,7 @@ const expandNormalizedTimeline = (state, timeline, statuses, next, isPartial, is
 
         return oldIds.take(firstIndex + 1).concat(
           isPartial && oldIds.get(firstIndex) !== null ? newIds.unshift(null) : newIds,
-          oldIds.skip(lastIndex)
+          oldIds.skip(lastIndex),
         );
       });
     }
@@ -166,7 +166,7 @@ export default function timelines(state = initialState, action) {
     return state.update(
       action.timeline,
       initialTimeline,
-      map => map.set('online', false).update(action.usePendingItems ? 'pendingItems' : 'items', items => items.first() ? items.unshift(null) : items)
+      map => map.set('online', false).update(action.usePendingItems ? 'pendingItems' : 'items', items => items.first() ? items.unshift(null) : items),
     );
   default:
     return state;
diff --git a/app/javascript/mastodon/selectors/index.js b/app/javascript/mastodon/selectors/index.js
index 6a48f3b3f..673268c5a 100644
--- a/app/javascript/mastodon/selectors/index.js
+++ b/app/javascript/mastodon/selectors/index.js
@@ -117,7 +117,7 @@ export const makeGetStatus = () => {
         map.set('account', accountBase);
         map.set('filtered', filtered);
       });
-    }
+    },
   );
 };
 
diff --git a/app/javascript/mastodon/service_worker/web_push_notifications.js b/app/javascript/mastodon/service_worker/web_push_notifications.js
index 1ab0dc0fa..958e5fc12 100644
--- a/app/javascript/mastodon/service_worker/web_push_notifications.js
+++ b/app/javascript/mastodon/service_worker/web_push_notifications.js
@@ -117,7 +117,7 @@ const handlePush = (event) => {
         badge: '/badge.png',
         data: { access_token, preferred_locale, url: '/web/notifications' },
       });
-    })
+    }),
   );
 };
 
diff --git a/app/javascript/mastodon/store/configureStore.js b/app/javascript/mastodon/store/configureStore.js
index 7e7472841..e18af842f 100644
--- a/app/javascript/mastodon/store/configureStore.js
+++ b/app/javascript/mastodon/store/configureStore.js
@@ -10,6 +10,6 @@ export default function configureStore() {
     thunk,
     loadingBarMiddleware({ promiseTypeSuffixes: ['REQUEST', 'SUCCESS', 'FAIL'] }),
     errorsMiddleware(),
-    soundsMiddleware()
+    soundsMiddleware(),
   ), window.__REDUX_DEVTOOLS_EXTENSION__ ? window.__REDUX_DEVTOOLS_EXTENSION__() : f => f));
 };
diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss
index aa885e241..143384d9e 100644
--- a/app/javascript/styles/mastodon/components.scss
+++ b/app/javascript/styles/mastodon/components.scss
@@ -388,8 +388,8 @@
 
   .emoji-picker-dropdown {
     position: absolute;
-    top: 5px;
-    right: 5px;
+    top: 0;
+    right: 0;
   }
 
   .compose-form__autosuggest-wrapper {
@@ -870,6 +870,7 @@
 
 .announcements__item__content {
   word-wrap: break-word;
+  overflow-y: auto;
 
   .emojione {
     width: 20px;
@@ -4060,10 +4061,7 @@ a.status-card.compact:hover {
 
 .emoji-button {
   display: block;
-  font-size: 24px;
-  line-height: 24px;
-  margin-left: 2px;
-  width: 24px;
+  padding: 5px 5px 2px 2px;
   outline: 0;
   cursor: pointer;
 
@@ -4079,7 +4077,6 @@ a.status-card.compact:hover {
     margin: 0;
     width: 22px;
     height: 22px;
-    margin-top: 2px;
   }
 
   &:hover,
@@ -5058,12 +5055,6 @@ a.status-card.compact:hover {
 }
 
 .media-gallery__gifv {
-  &.autoplay {
-    .media-gallery__gifv__label {
-      display: none;
-    }
-  }
-
   &:hover {
     .media-gallery__gifv__label {
       opacity: 1;
@@ -6682,17 +6673,21 @@ noscript {
     box-sizing: border-box;
     width: 100%;
     padding: 15px;
-    padding-right: 15px + 18px;
     position: relative;
     font-size: 15px;
     line-height: 20px;
     word-wrap: break-word;
     font-weight: 400;
+    max-height: 50vh;
+    overflow: hidden;
+    display: flex;
+    flex-direction: column;
 
     &__range {
       display: block;
       font-weight: 500;
       margin-bottom: 10px;
+      padding-right: 18px;
     }
 
     &__unread {
diff --git a/app/lib/exceptions.rb b/app/lib/exceptions.rb
index 01346bfe5..3362576b0 100644
--- a/app/lib/exceptions.rb
+++ b/app/lib/exceptions.rb
@@ -8,6 +8,7 @@ module Mastodon
   class LengthValidationError < ValidationError; end
   class DimensionsValidationError < ValidationError; end
   class RaceConditionError < Error; end
+  class RateLimitExceededError < Error; end
 
   class UnexpectedResponseError < Error
     def initialize(response = nil)
diff --git a/app/lib/rate_limiter.rb b/app/lib/rate_limiter.rb
new file mode 100644
index 000000000..68dae9add
--- /dev/null
+++ b/app/lib/rate_limiter.rb
@@ -0,0 +1,64 @@
+# frozen_string_literal: true
+
+class RateLimiter
+  include Redisable
+
+  FAMILIES = {
+    follows: {
+      limit: 400,
+      period: 24.hours.freeze,
+    }.freeze,
+
+    statuses: {
+      limit: 300,
+      period: 3.hours.freeze,
+    }.freeze,
+
+    media: {
+      limit: 30,
+      period: 30.minutes.freeze,
+    }.freeze,
+  }.freeze
+
+  def initialize(by, options = {})
+    @by     = by
+    @family = options[:family]
+    @limit  = FAMILIES[@family][:limit]
+    @period = FAMILIES[@family][:period].to_i
+  end
+
+  def record!
+    count = redis.get(key)
+
+    if count.nil?
+      redis.set(key, 0)
+      redis.expire(key, (@period - (last_epoch_time % @period) + 1).to_i)
+    end
+
+    raise Mastodon::RateLimitExceededError if count.present? && count.to_i >= @limit
+
+    redis.incr(key)
+  end
+
+  def rollback!
+    redis.decr(key)
+  end
+
+  def to_headers(now = Time.now.utc)
+    {
+      'X-RateLimit-Limit' => @limit.to_s,
+      'X-RateLimit-Remaining' => (@limit - (redis.get(key) || 0).to_i).to_s,
+      'X-RateLimit-Reset' => (now + (@period - now.to_i % @period)).iso8601(6),
+    }
+  end
+
+  private
+
+  def key
+    @key ||= "rate_limit:#{@by.id}:#{@family}:#{(last_epoch_time / @period).to_i}"
+  end
+
+  def last_epoch_time
+    @last_epoch_time ||= Time.now.to_i
+  end
+end
diff --git a/app/models/account.rb b/app/models/account.rb
index 0fcf897c9..73421ee5a 100644
--- a/app/models/account.rb
+++ b/app/models/account.rb
@@ -106,6 +106,7 @@ class Account < ApplicationRecord
   scope :discoverable, -> { searchable.without_silenced.where(discoverable: true).left_outer_joins(:account_stat) }
   scope :tagged_with, ->(tag) { joins(:accounts_tags).where(accounts_tags: { tag_id: tag }) }
   scope :by_recent_status, -> { order(Arel.sql('(case when account_stats.last_status_at is null then 1 else 0 end) asc, account_stats.last_status_at desc, accounts.id desc')) }
+  scope :by_recent_sign_in, -> { order(Arel.sql('(case when users.current_sign_in_at is null then 1 else 0 end) asc, users.current_sign_in_at desc, accounts.id desc')) }
   scope :popular, -> { order('account_stats.followers_count desc') }
   scope :by_domain_and_subdomains, ->(domain) { where(domain: domain).or(where(arel_table[:domain].matches('%.' + domain))) }
   scope :not_excluded_by_account, ->(account) { where.not(id: account.excluded_from_timeline_account_ids) }
diff --git a/app/models/account_filter.rb b/app/models/account_filter.rb
index c7bf07787..7b6012e0f 100644
--- a/app/models/account_filter.rb
+++ b/app/models/account_filter.rb
@@ -14,6 +14,7 @@ class AccountFilter
     email
     ip
     staff
+    order
   ).freeze
 
   attr_reader :params
@@ -24,7 +25,7 @@ class AccountFilter
   end
 
   def results
-    scope = Account.recent.includes(:user)
+    scope = Account.includes(:user).reorder(nil)
 
     params.each do |key, value|
       scope.merge!(scope_for(key, value.to_s.strip)) if value.present?
@@ -38,6 +39,7 @@ class AccountFilter
   def set_defaults!
     params['local']  = '1' if params['remote'].blank?
     params['active'] = '1' if params['suspended'].blank? && params['silenced'].blank? && params['pending'].blank?
+    params['order']  = 'recent' if params['order'].blank?
   end
 
   def scope_for(key, value)
@@ -51,9 +53,9 @@ class AccountFilter
     when 'active'
       Account.without_suspended
     when 'pending'
-      accounts_with_users.merge User.pending
+      accounts_with_users.merge(User.pending)
     when 'disabled'
-      accounts_with_users.merge User.disabled
+      accounts_with_users.merge(User.disabled)
     when 'silenced'
       Account.silenced
     when 'suspended'
@@ -63,16 +65,31 @@ class AccountFilter
     when 'display_name'
       Account.matches_display_name(value)
     when 'email'
-      accounts_with_users.merge User.matches_email(value)
+      accounts_with_users.merge(User.matches_email(value))
     when 'ip'
       valid_ip?(value) ? accounts_with_users.merge(User.matches_ip(value)) : Account.none
     when 'staff'
-      accounts_with_users.merge User.staff
+      accounts_with_users.merge(User.staff)
+    when 'order'
+      order_scope(value)
     else
       raise "Unknown filter: #{key}"
     end
   end
 
+  def order_scope(value)
+    case value
+    when 'active'
+      params['remote'] ? Account.joins(:account_stat).by_recent_status : Account.joins(:user).by_recent_sign_in
+    when 'recent'
+      Account.recent
+    when 'alphabetic'
+      Account.alphabetic
+    else
+      raise "Unknown order: #{value}"
+    end
+  end
+
   def accounts_with_users
     Account.joins(:user)
   end
diff --git a/app/models/announcement.rb b/app/models/announcement.rb
index d99502f44..f8ac4e09d 100644
--- a/app/models/announcement.rb
+++ b/app/models/announcement.rb
@@ -48,6 +48,10 @@ class Announcement < ApplicationRecord
     @mentions ||= Account.from_text(text)
   end
 
+  def statuses
+    @statuses ||= Status.from_text(text)
+  end
+
   def tags
     @tags ||= Tag.find_or_create_by_names(Extractor.extract_hashtags(text))
   end
diff --git a/app/models/concerns/account_interactions.rb b/app/models/concerns/account_interactions.rb
index 14bcf7bb1..32fcb5397 100644
--- a/app/models/concerns/account_interactions.rb
+++ b/app/models/concerns/account_interactions.rb
@@ -87,10 +87,10 @@ module AccountInteractions
     has_many :announcement_mutes, dependent: :destroy
   end
 
-  def follow!(other_account, reblogs: nil, uri: nil)
+  def follow!(other_account, reblogs: nil, uri: nil, rate_limit: false)
     reblogs = true if reblogs.nil?
 
-    rel = active_relationships.create_with(show_reblogs: reblogs, uri: uri)
+    rel = active_relationships.create_with(show_reblogs: reblogs, uri: uri, rate_limit: rate_limit)
                               .find_or_create_by!(target_account: other_account)
 
     rel.update!(show_reblogs: reblogs)
@@ -99,6 +99,18 @@ module AccountInteractions
     rel
   end
 
+  def request_follow!(other_account, reblogs: nil, uri: nil, rate_limit: false)
+    reblogs = true if reblogs.nil?
+
+    rel = follow_requests.create_with(show_reblogs: reblogs, uri: uri, rate_limit: rate_limit)
+                         .find_or_create_by!(target_account: other_account)
+
+    rel.update!(show_reblogs: reblogs)
+    remove_potential_friendship(other_account)
+
+    rel
+  end
+
   def block!(other_account, uri: nil)
     remove_potential_friendship(other_account)
     block_relationships.create_with(uri: uri)
diff --git a/app/models/concerns/rate_limitable.rb b/app/models/concerns/rate_limitable.rb
new file mode 100644
index 000000000..ad1b5e44e
--- /dev/null
+++ b/app/models/concerns/rate_limitable.rb
@@ -0,0 +1,36 @@
+# frozen_string_literal: true
+
+module RateLimitable
+  extend ActiveSupport::Concern
+
+  def rate_limit=(value)
+    @rate_limit = value
+  end
+
+  def rate_limit?
+    @rate_limit
+  end
+
+  def rate_limiter(by, options = {})
+    return @rate_limiter if defined?(@rate_limiter)
+
+    @rate_limiter = RateLimiter.new(by, options)
+  end
+
+  class_methods do
+    def rate_limit(options = {})
+      after_create do
+        by = public_send(options[:by])
+
+        if rate_limit? && by&.local?
+          rate_limiter(by, options).record!
+          @rate_limit_recorded = true
+        end
+      end
+
+      after_rollback do
+        rate_limiter(public_send(options[:by]), options).rollback! if @rate_limit_recorded
+      end
+    end
+  end
+end
diff --git a/app/models/follow.rb b/app/models/follow.rb
index 87fa11425..f3e48a2ed 100644
--- a/app/models/follow.rb
+++ b/app/models/follow.rb
@@ -15,6 +15,9 @@
 class Follow < ApplicationRecord
   include Paginable
   include RelationshipCacheable
+  include RateLimitable
+
+  rate_limit by: :account, family: :follows
 
   belongs_to :account
   belongs_to :target_account, class_name: 'Account'
diff --git a/app/models/follow_request.rb b/app/models/follow_request.rb
index 96ac7eaa5..3325e264c 100644
--- a/app/models/follow_request.rb
+++ b/app/models/follow_request.rb
@@ -15,6 +15,9 @@
 class FollowRequest < ApplicationRecord
   include Paginable
   include RelationshipCacheable
+  include RateLimitable
+
+  rate_limit by: :account, family: :follows
 
   belongs_to :account
   belongs_to :target_account, class_name: 'Account'
diff --git a/app/models/media_attachment.rb b/app/models/media_attachment.rb
index 6a0b892f6..2813d9200 100644
--- a/app/models/media_attachment.rb
+++ b/app/models/media_attachment.rb
@@ -170,6 +170,7 @@ class MediaAttachment < ApplicationRecord
 
   def variant?(other_file_name)
     return true if file_file_name == other_file_name
+    return false if file_file_name.nil?
 
     formats = file.styles.values.map(&:format).compact
 
diff --git a/app/models/status.rb b/app/models/status.rb
index f4284f771..b2d3c3f3b 100644
--- a/app/models/status.rb
+++ b/app/models/status.rb
@@ -35,6 +35,9 @@ class Status < ApplicationRecord
   include Paginable
   include Cacheable
   include StatusThreadingConcern
+  include RateLimitable
+
+  rate_limit by: :account, family: :statuses
 
   self.discard_column = :deleted_at
 
@@ -416,6 +419,21 @@ class Status < ApplicationRecord
       end
     end
 
+    def from_text(text)
+      return [] if text.blank?
+
+      text.scan(FetchLinkCardService::URL_PATTERN).map(&:first).uniq.map do |url|
+        status = begin
+          if TagManager.instance.local_url?(url)
+            ActivityPub::TagManager.instance.uri_to_resource(url, Status)
+          else
+            Status.find_by(uri: url) || Status.find_by(url: url)
+          end
+        end
+        status&.distributable? ? status : nil
+      end.compact
+    end
+
     private
 
     def timeline_scope(local_only = false)
diff --git a/app/policies/settings_policy.rb b/app/policies/settings_policy.rb
index 2dcb79f51..874f97bab 100644
--- a/app/policies/settings_policy.rb
+++ b/app/policies/settings_policy.rb
@@ -8,4 +8,8 @@ class SettingsPolicy < ApplicationPolicy
   def show?
     admin?
   end
+
+  def destroy?
+    admin?
+  end
 end
diff --git a/app/serializers/rest/announcement_serializer.rb b/app/serializers/rest/announcement_serializer.rb
index f27feb669..9343b97d2 100644
--- a/app/serializers/rest/announcement_serializer.rb
+++ b/app/serializers/rest/announcement_serializer.rb
@@ -7,6 +7,7 @@ class REST::AnnouncementSerializer < ActiveModel::Serializer
   attribute :read, if: :current_user?
 
   has_many :mentions
+  has_many :statuses
   has_many :tags, serializer: REST::StatusSerializer::TagSerializer
   has_many :emojis, serializer: REST::CustomEmojiSerializer
   has_many :reactions, serializer: REST::ReactionSerializer
@@ -46,4 +47,16 @@ class REST::AnnouncementSerializer < ActiveModel::Serializer
       object.pretty_acct
     end
   end
+
+  class StatusSerializer < ActiveModel::Serializer
+    attributes :id, :url
+
+    def id
+      object.id.to_s
+    end
+
+    def url
+      ActivityPub::TagManager.instance.url_for(object)
+    end
+  end
 end
diff --git a/app/services/account_search_service.rb b/app/services/account_search_service.rb
index d217dabb3..493813aab 100644
--- a/app/services/account_search_service.rb
+++ b/app/services/account_search_service.rb
@@ -171,7 +171,7 @@ class AccountSearchService < BaseService
   end
 
   def username_complete?
-    query.include?('@') && "@#{query}" =~ Account::MENTION_RE
+    query.include?('@') && "@#{query}" =~ /\A#{Account::MENTION_RE}\Z/
   end
 
   def likely_acct?
diff --git a/app/services/follow_service.rb b/app/services/follow_service.rb
index 4d19002c4..311ae7fa6 100644
--- a/app/services/follow_service.rb
+++ b/app/services/follow_service.rb
@@ -7,54 +7,68 @@ class FollowService < BaseService
   # Follow a remote user, notify remote user about the follow
   # @param [Account] source_account From which to follow
   # @param [String, Account] uri User URI to follow in the form of username@domain (or account record)
-  # @param [true, false, nil] reblogs Whether or not to show reblogs, defaults to true
-  def call(source_account, target_account, reblogs: nil, bypass_locked: false)
-    reblogs = true if reblogs.nil?
-    target_account = ResolveAccountService.new.call(target_account, skip_webfinger: true)
-
-    raise ActiveRecord::RecordNotFound if target_account.nil? || target_account.id == source_account.id || target_account.suspended?
-    raise Mastodon::NotPermittedError  if target_account.blocking?(source_account) || source_account.blocking?(target_account) || target_account.moved? || (!target_account.local? && target_account.ostatus?) || source_account.domain_blocking?(target_account.domain)
-
-    if source_account.following?(target_account)
-      # We're already following this account, but we'll call follow! again to
-      # make sure the reblogs status is set correctly.
-      return source_account.follow!(target_account, reblogs: reblogs)
-    elsif source_account.requested?(target_account)
-      # This isn't managed by a method in AccountInteractions, so we modify it
-      # ourselves if necessary.
-      req = source_account.follow_requests.find_by(target_account: target_account)
-      req.update!(show_reblogs: reblogs)
-      return req
+  # @param [Hash] options
+  # @option [Boolean] :reblogs Whether or not to show reblogs, defaults to true
+  # @option [Boolean] :bypass_locked
+  # @option [Boolean] :with_rate_limit
+  def call(source_account, target_account, options = {})
+    @source_account = source_account
+    @target_account = ResolveAccountService.new.call(target_account, skip_webfinger: true)
+    @options        = { reblogs: true, bypass_locked: false, with_rate_limit: false }.merge(options)
+
+    raise ActiveRecord::RecordNotFound if following_not_possible?
+    raise Mastodon::NotPermittedError  if following_not_allowed?
+
+    if @source_account.following?(@target_account)
+      return change_follow_options!
+    elsif @source_account.requested?(@target_account)
+      return change_follow_request_options!
     end
 
     ActivityTracker.increment('activity:interactions')
 
-    if (target_account.locked? && !bypass_locked) || source_account.silenced? || target_account.activitypub?
-      request_follow(source_account, target_account, reblogs: reblogs)
-    elsif target_account.local?
-      direct_follow(source_account, target_account, reblogs: reblogs)
+    if (@target_account.locked? && !@options[:bypass_locked]) || @source_account.silenced? || @target_account.activitypub?
+      request_follow!
+    elsif @target_account.local?
+      direct_follow!
     end
   end
 
   private
 
-  def request_follow(source_account, target_account, reblogs: true)
-    follow_request = FollowRequest.create!(account: source_account, target_account: target_account, show_reblogs: reblogs)
+  def following_not_possible?
+    @target_account.nil? || @target_account.id == @source_account.id || @target_account.suspended?
+  end
+
+  def following_not_allowed?
+    @target_account.blocking?(@source_account) || @source_account.blocking?(@target_account) || @target_account.moved? || (!@target_account.local? && @target_account.ostatus?) || @source_account.domain_blocking?(@target_account.domain)
+  end
+
+  def change_follow_options!
+    @source_account.follow!(@target_account, reblogs: @options[:reblogs])
+  end
+
+  def change_follow_request_options!
+    @source_account.request_follow!(@target_account, reblogs: @options[:reblogs])
+  end
+
+  def request_follow!
+    follow_request = @source_account.request_follow!(@target_account, reblogs: @options[:reblogs], rate_limit: @options[:with_rate_limit])
 
-    if target_account.local?
-      LocalNotificationWorker.perform_async(target_account.id, follow_request.id, follow_request.class.name)
-    elsif target_account.activitypub?
-      ActivityPub::DeliveryWorker.perform_async(build_json(follow_request), source_account.id, target_account.inbox_url)
+    if @target_account.local?
+      LocalNotificationWorker.perform_async(@target_account.id, follow_request.id, follow_request.class.name)
+    elsif @target_account.activitypub?
+      ActivityPub::DeliveryWorker.perform_async(build_json(follow_request), @source_account.id, @target_account.inbox_url)
     end
 
     follow_request
   end
 
-  def direct_follow(source_account, target_account, reblogs: true)
-    follow = source_account.follow!(target_account, reblogs: reblogs)
+  def direct_follow!
+    follow = @source_account.follow!(@target_account, reblogs: @options[:reblogs], rate_limit: @options[:with_rate_limit])
 
-    LocalNotificationWorker.perform_async(target_account.id, follow.id, follow.class.name)
-    MergeWorker.perform_async(target_account.id, source_account.id)
+    LocalNotificationWorker.perform_async(@target_account.id, follow.id, follow.class.name)
+    MergeWorker.perform_async(@target_account.id, @source_account.id)
 
     follow
   end
diff --git a/app/services/post_status_service.rb b/app/services/post_status_service.rb
index 936e6ac55..5d3b8d725 100644
--- a/app/services/post_status_service.rb
+++ b/app/services/post_status_service.rb
@@ -19,6 +19,7 @@ class PostStatusService < BaseService
   # @option [Enumerable] :media_ids Optional array of media IDs to attach
   # @option [Doorkeeper::Application] :application
   # @option [String] :idempotency Optional idempotency key
+  # @option [Boolean] :with_rate_limit
   # @return [Status]
   def call(account, options = {})
     @account     = account
@@ -58,7 +59,7 @@ class PostStatusService < BaseService
      end
     end
     @visibility   = @options[:visibility] || @account.user&.setting_default_privacy
-    @visibility   = :unlisted if @visibility == :public && @account.silenced?
+    @visibility   = :unlisted if @visibility&.to_sym == :public && @account.silenced?
     @scheduled_at = @options[:scheduled_at]&.to_datetime
     @scheduled_at = nil if scheduled_in_the_past?
   rescue ArgumentError
@@ -170,6 +171,7 @@ class PostStatusService < BaseService
       language: language_from_option(@options[:language]) || @account.user&.setting_default_language&.presence || LanguageDetector.instance.detect(@text, @account),
       application: @options[:application],
       content_type: @options[:content_type] || @account.user&.setting_default_content_type,
+      rate_limit: @options[:with_rate_limit],
     }.compact
   end
 
@@ -189,10 +191,11 @@ class PostStatusService < BaseService
 
   def scheduled_options
     @options.tap do |options_hash|
-      options_hash[:in_reply_to_id] = options_hash.delete(:thread)&.id
-      options_hash[:application_id] = options_hash.delete(:application)&.id
-      options_hash[:scheduled_at]   = nil
-      options_hash[:idempotency]    = nil
+      options_hash[:in_reply_to_id]  = options_hash.delete(:thread)&.id
+      options_hash[:application_id]  = options_hash.delete(:application)&.id
+      options_hash[:scheduled_at]    = nil
+      options_hash[:idempotency]     = nil
+      options_hash[:with_rate_limit] = false
     end
   end
 end
diff --git a/app/services/reblog_service.rb b/app/services/reblog_service.rb
index 0b12f143c..0a46509f8 100644
--- a/app/services/reblog_service.rb
+++ b/app/services/reblog_service.rb
@@ -8,6 +8,8 @@ class ReblogService < BaseService
   # @param [Account] account Account to reblog from
   # @param [Status] reblogged_status Status to be reblogged
   # @param [Hash] options
+  # @option [String]  :visibility
+  # @option [Boolean] :with_rate_limit
   # @return [Status]
   def call(account, reblogged_status, options = {})
     reblogged_status = reblogged_status.reblog if reblogged_status.reblog?
@@ -18,9 +20,15 @@ class ReblogService < BaseService
 
     return reblog unless reblog.nil?
 
-    visibility = options[:visibility] || account.user&.setting_default_privacy
-    visibility = reblogged_status.visibility if reblogged_status.hidden?
-    reblog = account.statuses.create!(reblog: reblogged_status, text: '', visibility: visibility)
+    visibility = begin
+      if reblogged_status.hidden?
+        reblogged_status.visibility
+      else
+        options[:visibility] || account.user&.setting_default_privacy
+      end
+    end
+
+    reblog = account.statuses.create!(reblog: reblogged_status, text: '', visibility: visibility, rate_limit: options[:with_rate_limit])
 
     DistributionWorker.perform_async(reblog.id)
     ActivityPub::DistributionWorker.perform_async(reblog.id) unless reblogged_status.local_only?
@@ -45,7 +53,9 @@ class ReblogService < BaseService
 
   def bump_potential_friendship(account, reblog)
     ActivityTracker.increment('activity:interactions')
+
     return if account.following?(reblog.reblog.account_id)
+
     PotentialFriendshipTracker.record(account.id, reblog.reblog.account_id, :reblog)
   end
 
diff --git a/app/views/admin/accounts/_account.html.haml b/app/views/admin/accounts/_account.html.haml
index b057d3e42..44b10af6e 100644
--- a/app/views/admin/accounts/_account.html.haml
+++ b/app/views/admin/accounts/_account.html.haml
@@ -11,6 +11,8 @@
   %td
     - if account.user_current_sign_in_at
       %time.time-ago{ datetime: account.user_current_sign_in_at.iso8601, title: l(account.user_current_sign_in_at) }= l account.user_current_sign_in_at
+    - elsif account.last_status_at.present?
+      %time.time-ago{ datetime: account.last_status_at.iso8601, title: l(account.last_status_at) }= l account.last_status_at
     - else
       \-
   %td
diff --git a/app/views/admin/accounts/index.html.haml b/app/views/admin/accounts/index.html.haml
index 3a85324c9..7592161c9 100644
--- a/app/views/admin/accounts/index.html.haml
+++ b/app/views/admin/accounts/index.html.haml
@@ -19,6 +19,12 @@
     %ul
       %li= filter_link_to t('admin.accounts.moderation.all'), staff: nil
       %li= filter_link_to t('admin.accounts.roles.staff'), staff: '1'
+  .filter-subset
+    %strong= t 'generic.order_by'
+    %ul
+      %li= filter_link_to t('relationships.most_recent'), order: nil
+      %li= filter_link_to t('admin.accounts.username'), order: 'alphabetic'
+      %li= filter_link_to t('relationships.last_active'), order: 'active'
 
 = form_tag admin_accounts_url, method: 'GET', class: 'simple_form' do
   .fields-group
diff --git a/app/views/admin/settings/edit.html.haml b/app/views/admin/settings/edit.html.haml
index 63b352361..bff706389 100644
--- a/app/views/admin/settings/edit.html.haml
+++ b/app/views/admin/settings/edit.html.haml
@@ -1,7 +1,10 @@
 - content_for :page_title do
   = t('admin.settings.title')
 
-= simple_form_for @admin_settings, url: admin_settings_path, html: { method: :patch } do |f|
+  - content_for :heading_actions do
+    = button_tag t('generic.save_changes'), class: 'button', form: 'edit_admin'
+
+= simple_form_for @admin_settings, url: admin_settings_path, html: { method: :patch, id: 'edit_admin' } do |f|
   = render 'shared/error_messages', object: @admin_settings
 
   .fields-group
@@ -27,13 +30,13 @@
 
   .fields-row
     .fields-row__column.fields-row__column-6.fields-group
-      = f.input :thumbnail, as: :file, wrapper: :with_block_label, label: t('admin.settings.thumbnail.title'), hint: t('admin.settings.thumbnail.desc_html')
+      = f.input :thumbnail, as: :file, wrapper: :with_block_label, label: t('admin.settings.thumbnail.title'), hint: site_upload_delete_hint(t('admin.settings.thumbnail.desc_html'), :thumbnail)
     .fields-row__column.fields-row__column-6.fields-group
-      = f.input :hero, as: :file, wrapper: :with_block_label, label: t('admin.settings.hero.title'), hint: t('admin.settings.hero.desc_html')
+      = f.input :hero, as: :file, wrapper: :with_block_label, label: t('admin.settings.hero.title'), hint: site_upload_delete_hint(t('admin.settings.hero.desc_html'), :hero)
 
   .fields-row
     .fields-row__column.fields-row__column-6.fields-group
-      = f.input :mascot, as: :file, wrapper: :with_block_label, label: t('admin.settings.mascot.title'), hint: t('admin.settings.mascot.desc_html')
+      = f.input :mascot, as: :file, wrapper: :with_block_label, label: t('admin.settings.mascot.title'), hint: site_upload_delete_hint(t('admin.settings.mascot.desc_html'), :mascot)
 
   %hr.spacer/
 
diff --git a/app/views/errors/429.html.haml b/app/views/errors/429.html.haml
new file mode 100644
index 000000000..2df4f4175
--- /dev/null
+++ b/app/views/errors/429.html.haml
@@ -0,0 +1,5 @@
+- content_for :page_title do
+  = t('errors.429')
+
+- content_for :content do
+  = t('errors.429')
diff --git a/app/views/settings/preferences/appearance/show.html.haml b/app/views/settings/preferences/appearance/show.html.haml
index f460cebba..5453177fd 100644
--- a/app/views/settings/preferences/appearance/show.html.haml
+++ b/app/views/settings/preferences/appearance/show.html.haml
@@ -1,7 +1,10 @@
 - content_for :page_title do
   = t('settings.appearance')
 
-= simple_form_for current_user, url: settings_preferences_appearance_path, html: { method: :put } do |f|
+- content_for :heading_actions do
+  = button_tag t('generic.save_changes'), class: 'button', form: 'edit_user'
+
+= simple_form_for current_user, url: settings_preferences_appearance_path, html: { method: :put, id: 'edit_user' } do |f|
   .fields-group
     = f.input :locale, collection: I18n.available_locales, wrapper: :with_label, include_blank: false, label_method: lambda { |locale| human_locale(locale) }, selected: I18n.locale, hint: false
 
diff --git a/app/views/settings/preferences/notifications/show.html.haml b/app/views/settings/preferences/notifications/show.html.haml
index a496be21b..d7cc1ed5d 100644
--- a/app/views/settings/preferences/notifications/show.html.haml
+++ b/app/views/settings/preferences/notifications/show.html.haml
@@ -1,7 +1,10 @@
 - content_for :page_title do
   = t('settings.notifications')
 
-= simple_form_for current_user, url: settings_preferences_notifications_path, html: { method: :put } do |f|
+- content_for :heading_actions do
+  = button_tag t('generic.save_changes'), class: 'button', form: 'edit_notification'
+
+= simple_form_for current_user, url: settings_preferences_notifications_path, html: { method: :put, id: 'edit_notification' } do |f|
   = render 'shared/error_messages', object: current_user
 
   %h4= t 'notifications.email_events'
@@ -32,6 +35,3 @@
       = ff.input :must_be_follower, as: :boolean, wrapper: :with_label
       = ff.input :must_be_following, as: :boolean, wrapper: :with_label
       = ff.input :must_be_following_dm, as: :boolean, wrapper: :with_label
-
-  .actions
-    = f.button :button, t('generic.save_changes'), type: :submit
diff --git a/app/views/settings/preferences/other/show.html.haml b/app/views/settings/preferences/other/show.html.haml
index 9bdcb368d..3b5c7016d 100644
--- a/app/views/settings/preferences/other/show.html.haml
+++ b/app/views/settings/preferences/other/show.html.haml
@@ -1,7 +1,10 @@
 - content_for :page_title do
   = t('settings.preferences')
 
-= simple_form_for current_user, url: settings_preferences_other_path, html: { method: :put } do |f|
+- content_for :heading_actions do
+  = button_tag t('generic.save_changes'), class: 'button', form: 'edit_preferences'
+
+= simple_form_for current_user, url: settings_preferences_other_path, html: { method: :put, id: 'edit_preferences' } do |f|
   = render 'shared/error_messages', object: current_user
 
   .fields-group
diff --git a/app/views/settings/profiles/show.html.haml b/app/views/settings/profiles/show.html.haml
index f5d928233..7413be1db 100644
--- a/app/views/settings/profiles/show.html.haml
+++ b/app/views/settings/profiles/show.html.haml
@@ -1,7 +1,10 @@
 - content_for :page_title do
   = t('settings.edit_profile')
 
-= simple_form_for @account, url: settings_profile_path, html: { method: :put } do |f|
+- content_for :heading_actions do
+  = button_tag t('generic.save_changes'), class: 'button', form: 'edit_profile'
+
+= simple_form_for @account, url: settings_profile_path, html: { method: :put, id: 'edit_profile' } do |f|
   = render 'shared/error_messages', object: @account
 
   .fields-row