about summary refs log tree commit diff
path: root/app
diff options
context:
space:
mode:
Diffstat (limited to 'app')
-rw-r--r--app/controllers/admin/account_moderation_notes_controller.rb2
-rw-r--r--app/controllers/admin/accounts_controller.rb3
-rw-r--r--app/controllers/admin/action_logs_controller.rb9
-rw-r--r--app/controllers/admin/base_controller.rb1
-rw-r--r--app/controllers/admin/confirmations_controller.rb1
-rw-r--r--app/controllers/admin/custom_emojis_controller.rb8
-rw-r--r--app/controllers/admin/domain_blocks_controller.rb2
-rw-r--r--app/controllers/admin/email_domain_blocks_controller.rb4
-rw-r--r--app/controllers/admin/invites_controller.rb47
-rw-r--r--app/controllers/admin/reported_statuses_controller.rb6
-rw-r--r--app/controllers/admin/reports_controller.rb9
-rw-r--r--app/controllers/admin/resets_controller.rb1
-rw-r--r--app/controllers/admin/roles_controller.rb2
-rw-r--r--app/controllers/admin/settings_controller.rb3
-rw-r--r--app/controllers/admin/silences_controller.rb6
-rw-r--r--app/controllers/admin/statuses_controller.rb6
-rw-r--r--app/controllers/admin/suspensions_controller.rb2
-rw-r--r--app/controllers/admin/two_factor_authentications_controller.rb1
-rw-r--r--app/controllers/api/v1/accounts_controller.rb6
-rw-r--r--app/controllers/auth/registrations_controller.rb21
-rw-r--r--app/controllers/concerns/accountable_concern.rb9
-rw-r--r--app/controllers/invites_controller.rb43
-rw-r--r--app/controllers/settings/migrations_controller.rb33
-rw-r--r--app/helpers/admin/action_logs_helper.rb103
-rw-r--r--app/helpers/admin/filter_helper.rb3
-rw-r--r--app/helpers/jsonld_helper.rb18
-rw-r--r--app/javascript/flavours/glitch/components/status.js40
-rw-r--r--app/javascript/flavours/glitch/containers/status_container.js1
-rw-r--r--app/javascript/flavours/glitch/features/notifications/components/follow.js5
-rw-r--r--app/javascript/flavours/glitch/features/notifications/components/notification.js117
-rw-r--r--app/javascript/flavours/glitch/features/notifications/containers/notification_container.js1
-rw-r--r--app/javascript/flavours/glitch/features/status/components/detailed_status.js4
-rw-r--r--app/javascript/flavours/glitch/features/status/index.js17
-rw-r--r--app/javascript/flavours/glitch/features/ui/index.js1
-rw-r--r--app/javascript/flavours/glitch/styles/_mixins.scss1
-rw-r--r--app/javascript/flavours/glitch/styles/components.scss16
-rw-r--r--app/javascript/glitch/locales/pl.json44
-rw-r--r--app/javascript/mastodon/actions/accounts.js10
-rw-r--r--app/javascript/mastodon/actions/lists.js28
-rw-r--r--app/javascript/mastodon/actions/mutes.js4
-rw-r--r--app/javascript/mastodon/actions/streaming.js1
-rw-r--r--app/javascript/mastodon/actions/timelines.js2
-rw-r--r--app/javascript/mastodon/components/account.js2
-rw-r--r--app/javascript/mastodon/components/dropdown_menu.js8
-rw-r--r--app/javascript/mastodon/components/status_action_bar.js2
-rw-r--r--app/javascript/mastodon/features/account/components/action_bar.js12
-rw-r--r--app/javascript/mastodon/features/account/components/header.js7
-rw-r--r--app/javascript/mastodon/features/account_timeline/components/header.js9
-rw-r--r--app/javascript/mastodon/features/account_timeline/components/moved_note.js48
-rw-r--r--app/javascript/mastodon/features/account_timeline/containers/header_container.js8
-rw-r--r--app/javascript/mastodon/features/compose/components/navigation_bar.js2
-rw-r--r--app/javascript/mastodon/features/getting_started/index.js2
-rw-r--r--app/javascript/mastodon/features/keyboard_shortcuts/index.js98
-rw-r--r--app/javascript/mastodon/features/list_timeline/index.js106
-rw-r--r--app/javascript/mastodon/features/status/components/action_bar.js2
-rw-r--r--app/javascript/mastodon/features/status/components/card.js36
-rw-r--r--app/javascript/mastodon/features/status/components/detailed_status.js2
-rw-r--r--app/javascript/mastodon/features/ui/components/columns_area.js3
-rw-r--r--app/javascript/mastodon/features/ui/components/image_loader.js4
-rw-r--r--app/javascript/mastodon/features/ui/components/media_modal.js2
-rw-r--r--app/javascript/mastodon/features/ui/index.js14
-rw-r--r--app/javascript/mastodon/features/ui/util/async-components.js8
-rw-r--r--app/javascript/mastodon/locales/ar.json26
-rw-r--r--app/javascript/mastodon/locales/bg.json26
-rw-r--r--app/javascript/mastodon/locales/ca.json44
-rw-r--r--app/javascript/mastodon/locales/de.json26
-rw-r--r--app/javascript/mastodon/locales/defaultMessages.json157
-rw-r--r--app/javascript/mastodon/locales/en.json26
-rw-r--r--app/javascript/mastodon/locales/eo.json26
-rw-r--r--app/javascript/mastodon/locales/es.json26
-rw-r--r--app/javascript/mastodon/locales/fa.json26
-rw-r--r--app/javascript/mastodon/locales/fi.json26
-rw-r--r--app/javascript/mastodon/locales/fr.json26
-rw-r--r--app/javascript/mastodon/locales/he.json26
-rw-r--r--app/javascript/mastodon/locales/hr.json26
-rw-r--r--app/javascript/mastodon/locales/hu.json26
-rw-r--r--app/javascript/mastodon/locales/id.json26
-rw-r--r--app/javascript/mastodon/locales/io.json26
-rw-r--r--app/javascript/mastodon/locales/it.json26
-rw-r--r--app/javascript/mastodon/locales/ja.json26
-rw-r--r--app/javascript/mastodon/locales/ko.json30
-rw-r--r--app/javascript/mastodon/locales/nl.json26
-rw-r--r--app/javascript/mastodon/locales/no.json26
-rw-r--r--app/javascript/mastodon/locales/oc.json26
-rw-r--r--app/javascript/mastodon/locales/pl.json26
-rw-r--r--app/javascript/mastodon/locales/pt-BR.json26
-rw-r--r--app/javascript/mastodon/locales/pt.json26
-rw-r--r--app/javascript/mastodon/locales/ru.json26
-rw-r--r--app/javascript/mastodon/locales/sv.json34
-rw-r--r--app/javascript/mastodon/locales/th.json26
-rw-r--r--app/javascript/mastodon/locales/tr.json26
-rw-r--r--app/javascript/mastodon/locales/uk.json26
-rw-r--r--app/javascript/mastodon/locales/whitelist_sv.json2
-rw-r--r--app/javascript/mastodon/locales/zh-CN.json49
-rw-r--r--app/javascript/mastodon/locales/zh-HK.json26
-rw-r--r--app/javascript/mastodon/locales/zh-TW.json26
-rw-r--r--app/javascript/mastodon/reducers/accounts.js5
-rw-r--r--app/javascript/mastodon/reducers/accounts_counters.js3
-rw-r--r--app/javascript/mastodon/reducers/contexts.js13
-rw-r--r--app/javascript/mastodon/reducers/index.js2
-rw-r--r--app/javascript/mastodon/reducers/lists.js15
-rw-r--r--app/javascript/mastodon/reducers/statuses.js19
-rw-r--r--app/javascript/mastodon/reducers/timelines.js5
-rw-r--r--app/javascript/mastodon/selectors/index.js8
-rw-r--r--app/javascript/styles/mastodon/admin.scss117
-rw-r--r--app/javascript/styles/mastodon/components.scss83
-rw-r--r--app/javascript/styles/mastodon/landing_strip.scss63
-rw-r--r--app/lib/activitypub/activity/create.rb53
-rw-r--r--app/lib/activitypub/activity/delete.rb7
-rw-r--r--app/lib/activitypub/adapter.rb1
-rw-r--r--app/lib/feed_manager.rb40
-rw-r--r--app/lib/formatter.rb15
-rw-r--r--app/models/account.rb12
-rw-r--r--app/models/admin.rb7
-rw-r--r--app/models/admin/action_log.rb40
-rw-r--r--app/models/concerns/account_interactions.rb4
-rw-r--r--app/models/form/admin_settings.rb4
-rw-r--r--app/models/form/migration.rb25
-rw-r--r--app/models/form/status_batch.rb8
-rw-r--r--app/models/glitch/keyword_mute.rb72
-rw-r--r--app/models/invite.rb52
-rw-r--r--app/models/invite_filter.rb32
-rw-r--r--app/models/notification.rb10
-rw-r--r--app/models/status.rb3
-rw-r--r--app/models/user.rb22
-rw-r--r--app/policies/invite_policy.rb25
-rw-r--r--app/serializers/activitypub/actor_serializer.rb8
-rw-r--r--app/serializers/rest/account_serializer.rb4
-rw-r--r--app/serializers/rest/list_serializer.rb4
-rw-r--r--app/services/activitypub/fetch_remote_status_service.rb4
-rw-r--r--app/services/activitypub/process_account_service.rb14
-rw-r--r--app/services/fetch_link_card_service.rb3
-rw-r--r--app/services/process_mentions_service.rb2
-rw-r--r--app/services/remove_status_service.rb12
-rw-r--r--app/views/accounts/_header.html.haml20
-rw-r--r--app/views/accounts/_moved_strip.html.haml17
-rw-r--r--app/views/accounts/show.html.haml2
-rw-r--r--app/views/admin/action_logs/_action_log.html.haml15
-rw-r--r--app/views/admin/action_logs/index.html.haml7
-rw-r--r--app/views/admin/invites/_invite.html.haml21
-rw-r--r--app/views/admin/invites/index.html.haml30
-rw-r--r--app/views/admin/settings/edit.html.haml8
-rw-r--r--app/views/auth/registrations/new.html.haml1
-rw-r--r--app/views/invites/_form.html.haml9
-rw-r--r--app/views/invites/_invite.html.haml17
-rw-r--r--app/views/invites/index.html.haml19
-rw-r--r--app/views/settings/migrations/show.html.haml17
-rw-r--r--app/views/settings/profiles/show.html.haml5
-rw-r--r--app/views/stream_entries/show.html.haml2
-rw-r--r--app/workers/activitypub/raw_distribution_worker.rb4
150 files changed, 2772 insertions, 298 deletions
diff --git a/app/controllers/admin/account_moderation_notes_controller.rb b/app/controllers/admin/account_moderation_notes_controller.rb
index 7f69a3363..7d5b9bf52 100644
--- a/app/controllers/admin/account_moderation_notes_controller.rb
+++ b/app/controllers/admin/account_moderation_notes_controller.rb
@@ -21,7 +21,7 @@ module Admin
 
     def destroy
       authorize @account_moderation_note, :destroy?
-      @account_moderation_note.destroy
+      @account_moderation_note.destroy!
       redirect_to admin_account_path(@account_moderation_note.target_account_id), notice: I18n.t('admin.account_moderation_notes.destroyed_msg')
     end
 
diff --git a/app/controllers/admin/accounts_controller.rb b/app/controllers/admin/accounts_controller.rb
index 0829bc769..e9a512e70 100644
--- a/app/controllers/admin/accounts_controller.rb
+++ b/app/controllers/admin/accounts_controller.rb
@@ -32,18 +32,21 @@ module Admin
     def memorialize
       authorize @account, :memorialize?
       @account.memorialize!
+      log_action :memorialize, @account
       redirect_to admin_account_path(@account.id)
     end
 
     def enable
       authorize @account.user, :enable?
       @account.user.enable!
+      log_action :enable, @account.user
       redirect_to admin_account_path(@account.id)
     end
 
     def disable
       authorize @account.user, :disable?
       @account.user.disable!
+      log_action :disable, @account.user
       redirect_to admin_account_path(@account.id)
     end
 
diff --git a/app/controllers/admin/action_logs_controller.rb b/app/controllers/admin/action_logs_controller.rb
new file mode 100644
index 000000000..e273dfeae
--- /dev/null
+++ b/app/controllers/admin/action_logs_controller.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+module Admin
+  class ActionLogsController < BaseController
+    def index
+      @action_logs = Admin::ActionLog.page(params[:page])
+    end
+  end
+end
diff --git a/app/controllers/admin/base_controller.rb b/app/controllers/admin/base_controller.rb
index 726134509..fc299f74c 100644
--- a/app/controllers/admin/base_controller.rb
+++ b/app/controllers/admin/base_controller.rb
@@ -3,6 +3,7 @@
 module Admin
   class BaseController < ApplicationController
     include Authorization
+    include AccountableConcern
 
     layout 'admin'
 
diff --git a/app/controllers/admin/confirmations_controller.rb b/app/controllers/admin/confirmations_controller.rb
index c10b0ebee..34dfb458e 100644
--- a/app/controllers/admin/confirmations_controller.rb
+++ b/app/controllers/admin/confirmations_controller.rb
@@ -7,6 +7,7 @@ module Admin
     def create
       authorize @user, :confirm?
       @user.confirm!
+      log_action :confirm, @user
       redirect_to admin_accounts_path
     end
 
diff --git a/app/controllers/admin/custom_emojis_controller.rb b/app/controllers/admin/custom_emojis_controller.rb
index 509f7a48f..3fa2a0b72 100644
--- a/app/controllers/admin/custom_emojis_controller.rb
+++ b/app/controllers/admin/custom_emojis_controller.rb
@@ -20,6 +20,7 @@ module Admin
       @custom_emoji = CustomEmoji.new(resource_params)
 
       if @custom_emoji.save
+        log_action :create, @custom_emoji
         redirect_to admin_custom_emojis_path, notice: I18n.t('admin.custom_emojis.created_msg')
       else
         render :new
@@ -30,6 +31,7 @@ module Admin
       authorize @custom_emoji, :update?
 
       if @custom_emoji.update(resource_params)
+        log_action :update, @custom_emoji
         redirect_to admin_custom_emojis_path, notice: I18n.t('admin.custom_emojis.updated_msg')
       else
         redirect_to admin_custom_emojis_path, notice: I18n.t('admin.custom_emojis.update_failed_msg')
@@ -38,7 +40,8 @@ module Admin
 
     def destroy
       authorize @custom_emoji, :destroy?
-      @custom_emoji.destroy
+      @custom_emoji.destroy!
+      log_action :destroy, @custom_emoji
       redirect_to admin_custom_emojis_path, notice: I18n.t('admin.custom_emojis.destroyed_msg')
     end
 
@@ -49,6 +52,7 @@ module Admin
       emoji.image = @custom_emoji.image
 
       if emoji.save
+        log_action :create, emoji
         flash[:notice] = I18n.t('admin.custom_emojis.copied_msg')
       else
         flash[:alert] = I18n.t('admin.custom_emojis.copy_failed_msg')
@@ -60,12 +64,14 @@ module Admin
     def enable
       authorize @custom_emoji, :enable?
       @custom_emoji.update!(disabled: false)
+      log_action :enable, @custom_emoji
       redirect_to admin_custom_emojis_path, notice: I18n.t('admin.custom_emojis.enabled_msg')
     end
 
     def disable
       authorize @custom_emoji, :disable?
       @custom_emoji.update!(disabled: true)
+      log_action :disable, @custom_emoji
       redirect_to admin_custom_emojis_path, notice: I18n.t('admin.custom_emojis.disabled_msg')
     end
 
diff --git a/app/controllers/admin/domain_blocks_controller.rb b/app/controllers/admin/domain_blocks_controller.rb
index e383dc831..64de2cbf0 100644
--- a/app/controllers/admin/domain_blocks_controller.rb
+++ b/app/controllers/admin/domain_blocks_controller.rb
@@ -21,6 +21,7 @@ module Admin
 
       if @domain_block.save
         DomainBlockWorker.perform_async(@domain_block.id)
+        log_action :create, @domain_block
         redirect_to admin_domain_blocks_path, notice: I18n.t('admin.domain_blocks.created_msg')
       else
         render :new
@@ -34,6 +35,7 @@ module Admin
     def destroy
       authorize @domain_block, :destroy?
       UnblockDomainService.new.call(@domain_block, retroactive_unblock?)
+      log_action :destroy, @domain_block
       redirect_to admin_domain_blocks_path, notice: I18n.t('admin.domain_blocks.destroyed_msg')
     end
 
diff --git a/app/controllers/admin/email_domain_blocks_controller.rb b/app/controllers/admin/email_domain_blocks_controller.rb
index 01058bf46..9fe85064e 100644
--- a/app/controllers/admin/email_domain_blocks_controller.rb
+++ b/app/controllers/admin/email_domain_blocks_controller.rb
@@ -20,6 +20,7 @@ module Admin
       @email_domain_block = EmailDomainBlock.new(resource_params)
 
       if @email_domain_block.save
+        log_action :create, @email_domain_block
         redirect_to admin_email_domain_blocks_path, notice: I18n.t('admin.email_domain_blocks.created_msg')
       else
         render :new
@@ -28,7 +29,8 @@ module Admin
 
     def destroy
       authorize @email_domain_block, :destroy?
-      @email_domain_block.destroy
+      @email_domain_block.destroy!
+      log_action :destroy, @email_domain_block
       redirect_to admin_email_domain_blocks_path, notice: I18n.t('admin.email_domain_blocks.destroyed_msg')
     end
 
diff --git a/app/controllers/admin/invites_controller.rb b/app/controllers/admin/invites_controller.rb
new file mode 100644
index 000000000..faccaa7c8
--- /dev/null
+++ b/app/controllers/admin/invites_controller.rb
@@ -0,0 +1,47 @@
+# frozen_string_literal: true
+
+module Admin
+  class InvitesController < BaseController
+    def index
+      authorize :invite, :index?
+
+      @invites = filtered_invites.includes(user: :account).page(params[:page])
+      @invite  = Invite.new
+    end
+
+    def create
+      authorize :invite, :create?
+
+      @invite      = Invite.new(resource_params)
+      @invite.user = current_user
+
+      if @invite.save
+        redirect_to admin_invites_path
+      else
+        @invites = Invite.page(params[:page])
+        render :index
+      end
+    end
+
+    def destroy
+      @invite = Invite.find(params[:id])
+      authorize @invite, :destroy?
+      @invite.expire!
+      redirect_to admin_invites_path
+    end
+
+    private
+
+    def resource_params
+      params.require(:invite).permit(:max_uses, :expires_in)
+    end
+
+    def filtered_invites
+      InviteFilter.new(filter_params).results
+    end
+
+    def filter_params
+      params.permit(:available, :expired)
+    end
+  end
+end
diff --git a/app/controllers/admin/reported_statuses_controller.rb b/app/controllers/admin/reported_statuses_controller.rb
index 4f66ce708..535bd11d4 100644
--- a/app/controllers/admin/reported_statuses_controller.rb
+++ b/app/controllers/admin/reported_statuses_controller.rb
@@ -8,7 +8,7 @@ module Admin
     def create
       authorize :status, :update?
 
-      @form         = Form::StatusBatch.new(form_status_batch_params)
+      @form         = Form::StatusBatch.new(form_status_batch_params.merge(current_account: current_account))
       flash[:alert] = I18n.t('admin.statuses.failed_to_execute') unless @form.save
 
       redirect_to admin_report_path(@report)
@@ -16,13 +16,15 @@ module Admin
 
     def update
       authorize @status, :update?
-      @status.update(status_params)
+      @status.update!(status_params)
+      log_action :update, @status
       redirect_to admin_report_path(@report)
     end
 
     def destroy
       authorize @status, :destroy?
       RemovalWorker.perform_async(@status.id)
+      log_action :destroy, @status
       render json: @status
     end
 
diff --git a/app/controllers/admin/reports_controller.rb b/app/controllers/admin/reports_controller.rb
index 745757ee8..75db6b78a 100644
--- a/app/controllers/admin/reports_controller.rb
+++ b/app/controllers/admin/reports_controller.rb
@@ -25,12 +25,17 @@ module Admin
     def process_report
       case params[:outcome].to_s
       when 'resolve'
-        @report.update(action_taken_by_current_attributes)
+        @report.update!(action_taken_by_current_attributes)
+        log_action :resolve, @report
       when 'suspend'
         Admin::SuspensionWorker.perform_async(@report.target_account.id)
+        log_action :resolve, @report
+        log_action :suspend, @report.target_account
         resolve_all_target_account_reports
       when 'silence'
-        @report.target_account.update(silenced: true)
+        @report.target_account.update!(silenced: true)
+        log_action :resolve, @report
+        log_action :silence, @report.target_account
         resolve_all_target_account_reports
       else
         raise ActiveRecord::RecordNotFound
diff --git a/app/controllers/admin/resets_controller.rb b/app/controllers/admin/resets_controller.rb
index 00b590bf6..3e27d01ac 100644
--- a/app/controllers/admin/resets_controller.rb
+++ b/app/controllers/admin/resets_controller.rb
@@ -7,6 +7,7 @@ module Admin
     def create
       authorize @user, :reset_password?
       @user.send_reset_password_instructions
+      log_action :reset_password, @user
       redirect_to admin_accounts_path
     end
 
diff --git a/app/controllers/admin/roles_controller.rb b/app/controllers/admin/roles_controller.rb
index 8f8685827..af7ec0740 100644
--- a/app/controllers/admin/roles_controller.rb
+++ b/app/controllers/admin/roles_controller.rb
@@ -7,12 +7,14 @@ module Admin
     def promote
       authorize @user, :promote?
       @user.promote!
+      log_action :promote, @user
       redirect_to admin_account_path(@user.account_id)
     end
 
     def demote
       authorize @user, :demote?
       @user.demote!
+      log_action :demote, @user
       redirect_to admin_account_path(@user.account_id)
     end
 
diff --git a/app/controllers/admin/settings_controller.rb b/app/controllers/admin/settings_controller.rb
index e81290228..eed5fb6b5 100644
--- a/app/controllers/admin/settings_controller.rb
+++ b/app/controllers/admin/settings_controller.rb
@@ -13,14 +13,17 @@ module Admin
       closed_registrations_message
       open_deletion
       timeline_preview
+      show_staff_badge
       bootstrap_timeline_accounts
       thumbnail
+      min_invite_role
     ).freeze
 
     BOOLEAN_SETTINGS = %w(
       open_registrations
       open_deletion
       timeline_preview
+      show_staff_badge
     ).freeze
 
     UPLOAD_SETTINGS = %w(
diff --git a/app/controllers/admin/silences_controller.rb b/app/controllers/admin/silences_controller.rb
index 01fb292de..4c06a9c0c 100644
--- a/app/controllers/admin/silences_controller.rb
+++ b/app/controllers/admin/silences_controller.rb
@@ -6,13 +6,15 @@ module Admin
 
     def create
       authorize @account, :silence?
-      @account.update(silenced: true)
+      @account.update!(silenced: true)
+      log_action :silence, @account
       redirect_to admin_accounts_path
     end
 
     def destroy
       authorize @account, :unsilence?
-      @account.update(silenced: false)
+      @account.update!(silenced: false)
+      log_action :unsilence, @account
       redirect_to admin_accounts_path
     end
 
diff --git a/app/controllers/admin/statuses_controller.rb b/app/controllers/admin/statuses_controller.rb
index b54a9b824..5d4325f57 100644
--- a/app/controllers/admin/statuses_controller.rb
+++ b/app/controllers/admin/statuses_controller.rb
@@ -26,7 +26,7 @@ module Admin
     def create
       authorize :status, :update?
 
-      @form         = Form::StatusBatch.new(form_status_batch_params)
+      @form         = Form::StatusBatch.new(form_status_batch_params.merge(current_account: current_account))
       flash[:alert] = I18n.t('admin.statuses.failed_to_execute') unless @form.save
 
       redirect_to admin_account_statuses_path(@account.id, current_params)
@@ -34,13 +34,15 @@ module Admin
 
     def update
       authorize @status, :update?
-      @status.update(status_params)
+      @status.update!(status_params)
+      log_action :update, @status
       redirect_to admin_account_statuses_path(@account.id, current_params)
     end
 
     def destroy
       authorize @status, :destroy?
       RemovalWorker.perform_async(@status.id)
+      log_action :destroy, @status
       render json: @status
     end
 
diff --git a/app/controllers/admin/suspensions_controller.rb b/app/controllers/admin/suspensions_controller.rb
index 778feea5e..5f222e125 100644
--- a/app/controllers/admin/suspensions_controller.rb
+++ b/app/controllers/admin/suspensions_controller.rb
@@ -7,12 +7,14 @@ module Admin
     def create
       authorize @account, :suspend?
       Admin::SuspensionWorker.perform_async(@account.id)
+      log_action :suspend, @account
       redirect_to admin_accounts_path
     end
 
     def destroy
       authorize @account, :unsuspend?
       @account.unsuspend!
+      log_action :unsuspend, @account
       redirect_to admin_accounts_path
     end
 
diff --git a/app/controllers/admin/two_factor_authentications_controller.rb b/app/controllers/admin/two_factor_authentications_controller.rb
index 5a45d25cd..022107203 100644
--- a/app/controllers/admin/two_factor_authentications_controller.rb
+++ b/app/controllers/admin/two_factor_authentications_controller.rb
@@ -7,6 +7,7 @@ module Admin
     def destroy
       authorize @user, :disable_2fa?
       @user.disable_two_factor!
+      log_action :disable_2fa, @user
       redirect_to admin_accounts_path
     end
 
diff --git a/app/controllers/api/v1/accounts_controller.rb b/app/controllers/api/v1/accounts_controller.rb
index 85eb2d60e..b1a2ed573 100644
--- a/app/controllers/api/v1/accounts_controller.rb
+++ b/app/controllers/api/v1/accounts_controller.rb
@@ -13,11 +13,9 @@ class Api::V1::AccountsController < Api::BaseController
   end
 
   def follow
-    reblogs_arg = { reblogs: params[:reblogs] }
-    
-    FollowService.new.call(current_user.account, @account.acct, reblogs_arg)
+    FollowService.new.call(current_user.account, @account.acct, reblogs: params[:reblogs])
 
-    options = @account.locked? ? {} : { following_map: { @account.id => reblogs_arg }, requested_map: { @account.id => false } }
+    options = @account.locked? ? {} : { following_map: { @account.id => { reblogs: params[:reblogs] } }, requested_map: { @account.id => false } }
 
     render json: @account, serializer: REST::RelationshipSerializer, relationships: relationships(options)
   end
diff --git a/app/controllers/auth/registrations_controller.rb b/app/controllers/auth/registrations_controller.rb
index 42e852c04..f4247fd95 100644
--- a/app/controllers/auth/registrations_controller.rb
+++ b/app/controllers/auth/registrations_controller.rb
@@ -17,13 +17,16 @@ class Auth::RegistrationsController < Devise::RegistrationsController
 
   def build_resource(hash = nil)
     super(hash)
-    resource.locale = I18n.locale
+
+    resource.locale      = I18n.locale
+    resource.invite_code = params[:invite_code] if resource.invite_code.blank?
+
     resource.build_account if resource.account.nil?
   end
 
   def configure_sign_up_params
     devise_parameter_sanitizer.permit(:sign_up) do |u|
-      u.permit({ account_attributes: [:username] }, :email, :password, :password_confirmation)
+      u.permit({ account_attributes: [:username] }, :email, :password, :password_confirmation, :invite_code)
     end
   end
 
@@ -36,7 +39,19 @@ class Auth::RegistrationsController < Devise::RegistrationsController
   end
 
   def check_enabled_registrations
-    redirect_to root_path if single_user_mode? || !Setting.open_registrations
+    redirect_to root_path if single_user_mode? || !allowed_registrations?
+  end
+
+  def allowed_registrations?
+    Setting.open_registrations || (invite_code.present? && Invite.find_by(code: invite_code)&.valid_for_use?)
+  end
+
+  def invite_code
+    if params[:user]
+      params[:user][:invite_code]
+    else
+      params[:invite_code]
+    end
   end
 
   private
diff --git a/app/controllers/concerns/accountable_concern.rb b/app/controllers/concerns/accountable_concern.rb
new file mode 100644
index 000000000..3cdcffc51
--- /dev/null
+++ b/app/controllers/concerns/accountable_concern.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+module AccountableConcern
+  extend ActiveSupport::Concern
+
+  def log_action(action, target)
+    Admin::ActionLog.create(account: current_account, action: action, target: target)
+  end
+end
diff --git a/app/controllers/invites_controller.rb b/app/controllers/invites_controller.rb
new file mode 100644
index 000000000..38d6c8d73
--- /dev/null
+++ b/app/controllers/invites_controller.rb
@@ -0,0 +1,43 @@
+# frozen_string_literal: true
+
+class InvitesController < ApplicationController
+  include Authorization
+
+  layout 'admin'
+
+  before_action :authenticate_user!
+
+  def index
+    authorize :invite, :create?
+
+    @invites = Invite.where(user: current_user)
+    @invite  = Invite.new(expires_in: 1.day.to_i)
+  end
+
+  def create
+    authorize :invite, :create?
+
+    @invite      = Invite.new(resource_params)
+    @invite.user = current_user
+
+    if @invite.save
+      redirect_to invites_path
+    else
+      @invites = Invite.where(user: current_user)
+      render :index
+    end
+  end
+
+  def destroy
+    @invite = Invite.where(user: current_user).find(params[:id])
+    authorize @invite, :destroy?
+    @invite.expire!
+    redirect_to invites_path
+  end
+
+  private
+
+  def resource_params
+    params.require(:invite).permit(:max_uses, :expires_in)
+  end
+end
diff --git a/app/controllers/settings/migrations_controller.rb b/app/controllers/settings/migrations_controller.rb
new file mode 100644
index 000000000..b18403a7f
--- /dev/null
+++ b/app/controllers/settings/migrations_controller.rb
@@ -0,0 +1,33 @@
+# frozen_string_literal: true
+
+class Settings::MigrationsController < ApplicationController
+  layout 'admin'
+
+  before_action :authenticate_user!
+
+  def show
+    @migration = Form::Migration.new(account: current_account.moved_to_account)
+  end
+
+  def update
+    @migration = Form::Migration.new(resource_params)
+
+    if @migration.valid? && migration_account_changed?
+      current_account.update!(moved_to_account: @migration.account)
+      ActivityPub::UpdateDistributionWorker.perform_async(current_account.id)
+      redirect_to settings_migration_path, notice: I18n.t('migrations.updated_msg')
+    else
+      render :show
+    end
+  end
+
+  private
+
+  def resource_params
+    params.require(:migration).permit(:acct)
+  end
+
+  def migration_account_changed?
+    current_account.moved_to_account_id != @migration.account&.id
+  end
+end
diff --git a/app/helpers/admin/action_logs_helper.rb b/app/helpers/admin/action_logs_helper.rb
new file mode 100644
index 000000000..e85243e57
--- /dev/null
+++ b/app/helpers/admin/action_logs_helper.rb
@@ -0,0 +1,103 @@
+# frozen_string_literal: true
+
+module Admin::ActionLogsHelper
+  def log_target(log)
+    if log.target
+      linkable_log_target(log.target)
+    else
+      log_target_from_history(log.target_type, log.recorded_changes)
+    end
+  end
+
+  def linkable_log_target(record)
+    case record.class.name
+    when 'Account'
+      link_to record.acct, admin_account_path(record.id)
+    when 'User'
+      link_to record.account.acct, admin_account_path(record.account_id)
+    when 'CustomEmoji'
+      record.shortcode
+    when 'Report'
+      link_to "##{record.id}", admin_report_path(record)
+    when 'DomainBlock', 'EmailDomainBlock'
+      link_to record.domain, "https://#{record.domain}"
+    when 'Status'
+      link_to record.account.acct, TagManager.instance.url_for(record)
+    end
+  end
+
+  def log_target_from_history(type, attributes)
+    case type
+    when 'CustomEmoji'
+      attributes['shortcode']
+    when 'DomainBlock', 'EmailDomainBlock'
+      link_to attributes['domain'], "https://#{attributes['domain']}"
+    when 'Status'
+      tmp_status = Status.new(attributes)
+      link_to tmp_status.account.acct, TagManager.instance.url_for(tmp_status)
+    end
+  end
+
+  def relevant_log_changes(log)
+    if log.target_type == 'CustomEmoji' && [:enable, :disable, :destroy].include?(log.action)
+      log.recorded_changes.slice('domain')
+    elsif log.target_type == 'CustomEmoji' && log.action == :update
+      log.recorded_changes.slice('domain', 'visible_in_picker')
+    elsif log.target_type == 'User' && [:promote, :demote].include?(log.action)
+      log.recorded_changes.slice('moderator', 'admin')
+    elsif log.target_type == 'DomainBlock'
+      log.recorded_changes.slice('severity', 'reject_media')
+    elsif log.target_type == 'Status' && log.action == :update
+      log.recorded_changes.slice('sensitive')
+    end
+  end
+
+  def log_extra_attributes(hash)
+    safe_join(hash.to_a.map { |key, value| safe_join([content_tag(:span, key, class: 'diff-key'), '=', log_change(value)]) }, ' ')
+  end
+
+  def log_change(val)
+    return content_tag(:span, val, class: 'diff-neutral') unless val.is_a?(Array)
+    safe_join([content_tag(:span, val.first, class: 'diff-old'), content_tag(:span, val.last, class: 'diff-new')], '→')
+  end
+
+  def icon_for_log(log)
+    case log.target_type
+    when 'Account', 'User'
+      'user'
+    when 'CustomEmoji'
+      'file'
+    when 'Report'
+      'flag'
+    when 'DomainBlock'
+      'lock'
+    when 'EmailDomainBlock'
+      'envelope'
+    when 'Status'
+      'pencil'
+    end
+  end
+
+  def class_for_log_icon(log)
+    case log.action
+    when :enable, :unsuspend, :unsilence, :confirm, :promote, :resolve
+      'positive'
+    when :create
+      opposite_verbs?(log) ? 'negative' : 'positive'
+    when :update, :reset_password, :disable_2fa, :memorialize
+      'neutral'
+    when :demote, :silence, :disable, :suspend
+      'negative'
+    when :destroy
+      opposite_verbs?(log) ? 'positive' : 'negative'
+    else
+      ''
+    end
+  end
+
+  private
+
+  def opposite_verbs?(log)
+    %w(DomainBlock EmailDomainBlock).include?(log.target_type)
+  end
+end
diff --git a/app/helpers/admin/filter_helper.rb b/app/helpers/admin/filter_helper.rb
index e0fae9d9a..73250cbf5 100644
--- a/app/helpers/admin/filter_helper.rb
+++ b/app/helpers/admin/filter_helper.rb
@@ -3,8 +3,9 @@
 module Admin::FilterHelper
   ACCOUNT_FILTERS = %i(local remote by_domain silenced suspended recent username display_name email ip).freeze
   REPORT_FILTERS = %i(resolved account_id target_account_id).freeze
+  INVITE_FILTER = %i(available expired).freeze
 
-  FILTERS = ACCOUNT_FILTERS + REPORT_FILTERS
+  FILTERS = ACCOUNT_FILTERS + REPORT_FILTERS + INVITE_FILTER
 
   def filter_link_to(text, link_to_params, link_class_params = link_to_params)
     new_url = filtered_url_for(link_to_params)
diff --git a/app/helpers/jsonld_helper.rb b/app/helpers/jsonld_helper.rb
index a3441e6f9..6c7c38070 100644
--- a/app/helpers/jsonld_helper.rb
+++ b/app/helpers/jsonld_helper.rb
@@ -9,6 +9,24 @@ module JsonLdHelper
     value.is_a?(Array) ? value.first : value
   end
 
+  # The url attribute can be a string, an array of strings, or an array of objects.
+  # The objects could include a mimeType. Not-included mimeType means it's text/html.
+  def url_to_href(value, preferred_type = nil)
+    single_value = if value.is_a?(Array) && !value.first.is_a?(String)
+                     value.find { |link| preferred_type.nil? || ((link['mimeType'].presence || 'text/html') == preferred_type) }
+                   elsif value.is_a?(Array)
+                     value.first
+                   else
+                     value
+                   end
+
+    if single_value.nil? || single_value.is_a?(String)
+      single_value
+    else
+      single_value['href']
+    end
+  end
+
   def as_array(value)
     value.is_a?(Array) ? value : [value]
   end
diff --git a/app/javascript/flavours/glitch/components/status.js b/app/javascript/flavours/glitch/components/status.js
index 6662285d0..b0d9e3757 100644
--- a/app/javascript/flavours/glitch/components/status.js
+++ b/app/javascript/flavours/glitch/components/status.js
@@ -9,6 +9,7 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
 import { MediaGallery, Video } from 'flavours/glitch/util/async-components';
 import { HotKeys } from 'react-hotkeys';
 import NotificationOverlayContainer from 'flavours/glitch/features/notifications/containers/overlay_container';
+import classNames from 'classnames';
 
 // We use the component (and not the container) since we do not want
 // to use the progress bar to show download progress
@@ -21,6 +22,7 @@ export default class Status extends ImmutablePureComponent {
   };
 
   static propTypes = {
+    containerId: PropTypes.string,
     id: PropTypes.string,
     status: ImmutablePropTypes.map,
     account: ImmutablePropTypes.map,
@@ -59,6 +61,7 @@ export default class Status extends ImmutablePureComponent {
     'muted',
     'collapse',
     'notification',
+    'hidden',
   ]
 
   updateOnStates = [
@@ -187,7 +190,9 @@ export default class Status extends ImmutablePureComponent {
   }
 
   handleExpandedToggle = () => {
-    this.setExpansion(this.state.isExpanded || !this.props.status.get('spoiler') ? null : true);
+    if (this.props.status.get('spoiler_text')) {
+      this.setExpansion(this.state.isExpanded ? null : true);
+    }
   };
 
   handleOpenVideo = startTime => {
@@ -221,11 +226,11 @@ export default class Status extends ImmutablePureComponent {
   }
 
   handleHotkeyMoveUp = () => {
-    this.props.onMoveUp(this.props.status.get('id'));
+    this.props.onMoveUp(this.props.containerId || this.props.id);
   }
 
   handleHotkeyMoveDown = () => {
-    this.props.onMoveDown(this.props.status.get('id'));
+    this.props.onMoveDown(this.props.containerId || this.props.id);
   }
 
   handleRef = c => {
@@ -370,31 +375,24 @@ export default class Status extends ImmutablePureComponent {
       openProfile: this.handleHotkeyOpenProfile,
       moveUp: this.handleHotkeyMoveUp,
       moveDown: this.handleHotkeyMoveDown,
+      toggleSpoiler: this.handleExpandedToggle,
     };
 
+    const computedClass = classNames('status', `status-${status.get('visibility')}`, {
+      collapsed: isExpanded === false,
+      'has-background': isExpanded === false && background,
+      'marked-for-delete': this.state.markedForDelete,
+      muted,
+    }, 'focusable');
+
     return (
       <HotKeys handlers={handlers}>
         <div
-          className={
-            `status${
-              muted ? ' muted' : ''
-            } status-${status.get('visibility')}${
-              isExpanded === false ? ' collapsed' : ''
-            }${
-              isExpanded === false && background ? ' has-background' : ''
-            }${
-              this.state.markedForDelete ? ' marked-for-delete' : ''
-            }`
-          }
-          style={{
-            backgroundImage: (
-              isExpanded === false && background ?
-              `url(${background})` :
-              'none'
-            ),
-          }}
+          className={computedClass}
+          style={isExpanded === false && background ? { backgroundImage: `url(${background})` } : null}
           {...selectorAttribs}
           ref={handleRef}
+          tabIndex='0'
         >
           {prepend && account ? (
             <StatusPrepend
diff --git a/app/javascript/flavours/glitch/containers/status_container.js b/app/javascript/flavours/glitch/containers/status_container.js
index 1a4f35278..b753de7b3 100644
--- a/app/javascript/flavours/glitch/containers/status_container.js
+++ b/app/javascript/flavours/glitch/containers/status_container.js
@@ -45,6 +45,7 @@ const makeMapStateToProps = () => {
     }
 
     return {
+      containerId : props.containerId || props.id,  //  Should match reblogStatus's id for reblogs
       status      : status,
       account     : account || props.account,
       settings    : state.get('local_settings'),
diff --git a/app/javascript/flavours/glitch/features/notifications/components/follow.js b/app/javascript/flavours/glitch/features/notifications/components/follow.js
index 4b682733e..54506f67c 100644
--- a/app/javascript/flavours/glitch/features/notifications/components/follow.js
+++ b/app/javascript/flavours/glitch/features/notifications/components/follow.js
@@ -14,6 +14,7 @@ import NotificationOverlayContainer from '../containers/overlay_container';
 export default class NotificationFollow extends ImmutablePureComponent {
 
   static propTypes = {
+    hidden: PropTypes.bool,
     id: PropTypes.string.isRequired,
     account: ImmutablePropTypes.map.isRequired,
     notification: ImmutablePropTypes.map.isRequired,
@@ -57,7 +58,7 @@ export default class NotificationFollow extends ImmutablePureComponent {
   }
 
   render () {
-    const { account, notification } = this.props;
+    const { account, notification, hidden } = this.props;
 
     //  Links to the display name.
     const displayName = account.get('display_name_html') || account.get('username');
@@ -87,7 +88,7 @@ export default class NotificationFollow extends ImmutablePureComponent {
             />
           </div>
 
-          <AccountContainer id={account.get('id')} withNote={false} />
+          <AccountContainer hidden={hidden} id={account.get('id')} withNote={false} />
           <NotificationOverlayContainer notification={notification} />
         </div>
       </HotKeys>
diff --git a/app/javascript/flavours/glitch/features/notifications/components/notification.js b/app/javascript/flavours/glitch/features/notifications/components/notification.js
index 185da8395..cc77426d3 100644
--- a/app/javascript/flavours/glitch/features/notifications/components/notification.js
+++ b/app/javascript/flavours/glitch/features/notifications/components/notification.js
@@ -16,70 +16,75 @@ export default class Notification extends ImmutablePureComponent {
     onMoveUp: PropTypes.func.isRequired,
     onMoveDown: PropTypes.func.isRequired,
     onMention: PropTypes.func.isRequired,
-    settings: ImmutablePropTypes.map.isRequired,
   };
 
-  renderFollow () {
-    const { notification } = this.props;
-    return (
-      <NotificationFollow
-        id={notification.get('id')}
-        account={notification.get('account')}
-        notification={notification}
-      />
-    );
-  }
-
-  renderMention () {
-    const { notification } = this.props;
-    return (
-      <StatusContainer
-        id={notification.get('status')}
-        notification={notification}
-        withDismiss
-      />
-    );
-  }
-
-  renderFavourite () {
-    const { notification } = this.props;
-    return (
-      <StatusContainer
-        id={notification.get('status')}
-        account={notification.get('account')}
-        prepend='favourite'
-        muted
-        notification={notification}
-        withDismiss
-      />
-    );
-  }
-
-  renderReblog () {
-    const { notification } = this.props;
-    return (
-      <StatusContainer
-        id={notification.get('status')}
-        account={notification.get('account')}
-        prepend='reblog'
-        muted
-        notification={notification}
-        withDismiss
-      />
-    );
-  }
-
   render () {
-    const { notification } = this.props;
+    const {
+      hidden,
+      notification,
+      onMoveDown,
+      onMoveUp,
+      onMention,
+    } = this.props;
+
     switch(notification.get('type')) {
     case 'follow':
-      return this.renderFollow();
+      return (
+        <NotificationFollow
+          hidden={hidden}
+          id={notification.get('id')}
+          account={notification.get('account')}
+          notification={notification}
+          onMoveDown={onMoveDown}
+          onMoveUp={onMoveUp}
+          onMention={onMention}
+        />
+      );
     case 'mention':
-      return this.renderMention();
+      return (
+        <StatusContainer
+          containerId={notification.get('id')}
+          hidden={hidden}
+          id={notification.get('status')}
+          notification={notification}
+          onMoveDown={onMoveDown}
+          onMoveUp={onMoveUp}
+          onMention={onMention}
+          withDismiss
+        />
+      );
     case 'favourite':
-      return this.renderFavourite();
+      return (
+        <StatusContainer
+          containerId={notification.get('id')}
+          hidden={hidden}
+          id={notification.get('status')}
+          account={notification.get('account')}
+          prepend='favourite'
+          muted
+          notification={notification}
+          onMoveDown={onMoveDown}
+          onMoveUp={onMoveUp}
+          onMention={onMention}
+          withDismiss
+        />
+      );
     case 'reblog':
-      return this.renderReblog();
+      return (
+        <StatusContainer
+          containerId={notification.get('id')}
+          hidden={hidden}
+          id={notification.get('status')}
+          account={notification.get('account')}
+          prepend='reblog'
+          muted
+          notification={notification}
+          onMoveDown={onMoveDown}
+          onMoveUp={onMoveUp}
+          onMention={onMention}
+          withDismiss
+        />
+      );
     default:
       return null;
     }
diff --git a/app/javascript/flavours/glitch/features/notifications/containers/notification_container.js b/app/javascript/flavours/glitch/features/notifications/containers/notification_container.js
index c60e72e1c..be007f30b 100644
--- a/app/javascript/flavours/glitch/features/notifications/containers/notification_container.js
+++ b/app/javascript/flavours/glitch/features/notifications/containers/notification_container.js
@@ -11,7 +11,6 @@ const makeMapStateToProps = () => {
 
   const mapStateToProps = (state, props) => ({
     notification: getNotification(state, props.notification, props.accountId),
-    settings: state.get('local_settings'),
     notifCleaning: state.getIn(['notifications', 'cleaningMode']),
   });
 
diff --git a/app/javascript/flavours/glitch/features/status/components/detailed_status.js b/app/javascript/flavours/glitch/features/status/components/detailed_status.js
index 7c6f436d6..0cb5238b0 100644
--- a/app/javascript/flavours/glitch/features/status/components/detailed_status.js
+++ b/app/javascript/flavours/glitch/features/status/components/detailed_status.js
@@ -41,7 +41,7 @@ export default class DetailedStatus extends ImmutablePureComponent {
 
   render () {
     const status = this.props.status.get('reblog') ? this.props.status.get('reblog') : this.props.status;
-    const { settings } = this.props;
+    const { expanded, setExpansion, settings } = this.props;
 
     let media           = '';
     let mediaIcon       = null;
@@ -109,6 +109,8 @@ export default class DetailedStatus extends ImmutablePureComponent {
           status={status}
           media={media}
           mediaIcon={mediaIcon}
+          expanded={expanded}
+          setExpansion={setExpansion}
         />
 
         <div className='detailed-status__meta'>
diff --git a/app/javascript/flavours/glitch/features/status/index.js b/app/javascript/flavours/glitch/features/status/index.js
index 73b212bba..93b0fe9d9 100644
--- a/app/javascript/flavours/glitch/features/status/index.js
+++ b/app/javascript/flavours/glitch/features/status/index.js
@@ -71,6 +71,7 @@ export default class Status extends ImmutablePureComponent {
 
   state = {
     fullscreen: false,
+    isExpanded: null,
   };
 
   componentWillMount () {
@@ -88,6 +89,12 @@ export default class Status extends ImmutablePureComponent {
     }
   }
 
+  handleExpandedToggle = () => {
+    if (this.props.status.get('spoiler_text')) {
+      this.setExpansion(this.state.isExpanded ? null : true);
+    }
+  };
+
   handleFavouriteClick = (status) => {
     if (status.get('favourited')) {
       this.props.dispatch(unfavourite(status));
@@ -241,6 +248,10 @@ export default class Status extends ImmutablePureComponent {
     ));
   }
 
+  setExpansion = value => {
+    this.setState({ isExpanded: value ? true : null });
+  }
+
   setRef = c => {
     this.node = c;
   }
@@ -272,8 +283,9 @@ export default class Status extends ImmutablePureComponent {
 
   render () {
     let ancestors, descendants;
+    const { setExpansion } = this;
     const { status, settings, ancestorsIds, descendantsIds } = this.props;
-    const { fullscreen } = this.state;
+    const { fullscreen, isExpanded } = this.state;
 
     if (status === null) {
       return (
@@ -300,6 +312,7 @@ export default class Status extends ImmutablePureComponent {
       boost: this.handleHotkeyBoost,
       mention: this.handleHotkeyMention,
       openProfile: this.handleHotkeyOpenProfile,
+      toggleSpoiler: this.handleExpandedToggle,
     };
 
     return (
@@ -317,6 +330,8 @@ export default class Status extends ImmutablePureComponent {
                   settings={settings}
                   onOpenVideo={this.handleOpenVideo}
                   onOpenMedia={this.handleOpenMedia}
+                  expanded={isExpanded}
+                  setExpansion={setExpansion}
                 />
 
                 <ActionBar
diff --git a/app/javascript/flavours/glitch/features/ui/index.js b/app/javascript/flavours/glitch/features/ui/index.js
index 620630faf..4a1982916 100644
--- a/app/javascript/flavours/glitch/features/ui/index.js
+++ b/app/javascript/flavours/glitch/features/ui/index.js
@@ -84,6 +84,7 @@ const keyMap = {
   goToProfile: 'g u',
   goToBlocked: 'g b',
   goToMuted: 'g m',
+  toggleSpoiler: 'x',
 };
 
 @connect(mapStateToProps)
diff --git a/app/javascript/flavours/glitch/styles/_mixins.scss b/app/javascript/flavours/glitch/styles/_mixins.scss
index 79a8149fd..102723e39 100644
--- a/app/javascript/flavours/glitch/styles/_mixins.scss
+++ b/app/javascript/flavours/glitch/styles/_mixins.scss
@@ -46,6 +46,7 @@
     margin-left: -22px;
     margin-right: -22px;
     width: inherit;
+    max-width: none;
     height: 250px;
   }
 }
diff --git a/app/javascript/flavours/glitch/styles/components.scss b/app/javascript/flavours/glitch/styles/components.scss
index 3be8db4b4..ade8df018 100644
--- a/app/javascript/flavours/glitch/styles/components.scss
+++ b/app/javascript/flavours/glitch/styles/components.scss
@@ -1058,6 +1058,14 @@
     color: $ui-secondary-color;
   }
 
+  .account__avatar {
+    @include avatar-radius();
+    @include avatar-size(90px);
+    display: block;
+    margin: 0 auto 10px;
+    overflow: hidden;
+  }
+
   .account__header__display-name {
     color: $primary-text-color;
     display: inline-block;
@@ -1248,14 +1256,6 @@
   }
 }
 
-.account__header__avatar {
-  @include avatar-radius();
-  @include avatar-size(90px);
-  display: block;
-  margin: 0 auto 10px;
-  overflow: hidden;
-}
-
 .account-authorize {
   padding: 14px 10px;
 
diff --git a/app/javascript/glitch/locales/pl.json b/app/javascript/glitch/locales/pl.json
new file mode 100644
index 000000000..1481b6a2a
--- /dev/null
+++ b/app/javascript/glitch/locales/pl.json
@@ -0,0 +1,44 @@
+{
+  "getting_started.open_source_notice": "Glitchsoc jest wolnym i otwartoźródłowym forkiem oprogramowania {Mastodon}. Możesz współtworzyć projekt lub zgłaszać błędy na GitHubie pod adresem {github}.",
+  "layout.auto": "Automatyczny",
+  "layout.current_is": "Twój obecny układ to:",
+  "layout.desktop": "Desktopowy",
+  "layout.mobile": "Mobilny",
+  "navigation_bar.app_settings": "Ustawienia aplikacji",
+  "getting_started.onboarding": "Rozejrzyj się",
+  "onboarding.page_one.federation": "{domain} jest 'instancją' Mastodona. Mastodon to sieć działających niezależnie serwerów tworzących jedną sieć społecznościową. Te serwery nazywane są instancjami.",
+  "onboarding.page_one.welcome": "Witamy na {domain}!",
+  "onboarding.page_six.github": "{domain} jest oparty na Glitchsoc. Glitchsoc jest {forkiem} {Mastodon}a kompatybilnym z każdym klientem i aplikacją Mastodona. Glitchsoc jest całkowicie wolnym i otwartoźródłowym oprogramowaniem. Możesz zgłaszać błędy i sugestie funkcji oraz współtworzyć projekt na {github}.",
+  "settings.auto_collapse": "Automatyczne zwijanie",
+  "settings.auto_collapse_all": "Wszystko",
+  "settings.auto_collapse_lengthy": "Długie wpisy",
+  "settings.auto_collapse_media": "Wpisy z zawartością multimedialną",
+  "settings.auto_collapse_notifications": "Powiadomienia",
+  "settings.auto_collapse_reblogs": "Podbicia",
+  "settings.auto_collapse_replies": "Odpowiedzi",
+  "settings.close": "Zamknij",
+  "settings.collapsed_statuses": "Zwijanie wpisów",
+  "settings.enable_collapsed": "Włącz zwijanie wpisów",
+  "settings.general": "Ogólne",
+  "settings.image_backgrounds": "Obrazy w tle",
+  "settings.image_backgrounds_media": "Wyświetlaj zawartość multimedialną zwiniętych wpisów",
+  "settings.image_backgrounds_users": "Nadaj tło zwiniętym wpisom",
+  "settings.media": "Zawartość multimedialna",
+  "settings.media_letterbox": "Letterbox media",
+  "settings.media_fullwidth": "Podgląd zawartości multimedialnej o pełnej szerokości",
+  "settings.preferences": "Preferencje użyytkownika",
+  "settings.wide_view": "Szeroki widok (tylko w trybie desktopowym)",
+  "settings.navbar_under": "Pasek nawigacji na dole (tylko w trybie mobilnym)",
+  "status.collapse": "Zwiń",
+  "status.uncollapse": "Rozwiń",
+
+  "notification.markForDeletion": "Oznacz do usunięcia",
+  "notifications.clear": "Wyczyść wszystkie powiadomienia",
+  "notifications.marked_clear_confirmation": "Czy na pewno chcesz bezpowrtonie usunąć wszystkie powiadomienia?",
+  "notifications.marked_clear": "Usuń zaznaczone powiadomienia",
+
+  "notification_purge.btn_all": "Zaznacz\nwszystkie",
+  "notification_purge.btn_none": "Odznacz\nwszystkie",
+  "notification_purge.btn_invert": "Odwróć\nzaznaczenie",
+  "notification_purge.btn_apply": "Usuń\nzaznaczone"
+}
diff --git a/app/javascript/mastodon/actions/accounts.js b/app/javascript/mastodon/actions/accounts.js
index fbaebf786..f63325658 100644
--- a/app/javascript/mastodon/actions/accounts.js
+++ b/app/javascript/mastodon/actions/accounts.js
@@ -105,12 +105,13 @@ export function fetchAccountFail(id, error) {
   };
 };
 
-export function followAccount(id) {
+export function followAccount(id, reblogs = true) {
   return (dispatch, getState) => {
+    const alreadyFollowing = getState().getIn(['relationships', id, 'following']);
     dispatch(followAccountRequest(id));
 
-    api(getState).post(`/api/v1/accounts/${id}/follow`).then(response => {
-      dispatch(followAccountSuccess(response.data));
+    api(getState).post(`/api/v1/accounts/${id}/follow`, { reblogs }).then(response => {
+      dispatch(followAccountSuccess(response.data, alreadyFollowing));
     }).catch(error => {
       dispatch(followAccountFail(error));
     });
@@ -136,10 +137,11 @@ export function followAccountRequest(id) {
   };
 };
 
-export function followAccountSuccess(relationship) {
+export function followAccountSuccess(relationship, alreadyFollowing) {
   return {
     type: ACCOUNT_FOLLOW_SUCCESS,
     relationship,
+    alreadyFollowing,
   };
 };
 
diff --git a/app/javascript/mastodon/actions/lists.js b/app/javascript/mastodon/actions/lists.js
new file mode 100644
index 000000000..332e42166
--- /dev/null
+++ b/app/javascript/mastodon/actions/lists.js
@@ -0,0 +1,28 @@
+import api from '../api';
+
+export const LIST_FETCH_REQUEST = 'LIST_FETCH_REQUEST';
+export const LIST_FETCH_SUCCESS = 'LIST_FETCH_SUCCESS';
+export const LIST_FETCH_FAIL    = 'LIST_FETCH_FAIL';
+
+export const fetchList = id => (dispatch, getState) => {
+  dispatch(fetchListRequest(id));
+
+  api(getState).get(`/api/v1/lists/${id}`)
+    .then(({ data }) => dispatch(fetchListSuccess(data)))
+    .catch(err => dispatch(fetchListFail(err)));
+};
+
+export const fetchListRequest = id => ({
+  type: LIST_FETCH_REQUEST,
+  id,
+});
+
+export const fetchListSuccess = list => ({
+  type: LIST_FETCH_SUCCESS,
+  list,
+});
+
+export const fetchListFail = error => ({
+  type: LIST_FETCH_FAIL,
+  error,
+});
diff --git a/app/javascript/mastodon/actions/mutes.js b/app/javascript/mastodon/actions/mutes.js
index 3474250fe..daa76a8f7 100644
--- a/app/javascript/mastodon/actions/mutes.js
+++ b/app/javascript/mastodon/actions/mutes.js
@@ -1,6 +1,6 @@
 import api, { getLinks } from '../api';
 import { fetchRelationships } from './accounts';
-import { openModal } from '../../mastodon/actions/modal';
+import { openModal } from './modal';
 
 export const MUTES_FETCH_REQUEST = 'MUTES_FETCH_REQUEST';
 export const MUTES_FETCH_SUCCESS = 'MUTES_FETCH_SUCCESS';
@@ -100,4 +100,4 @@ export function toggleHideNotifications() {
   return dispatch => {
     dispatch({ type: MUTES_TOGGLE_HIDE_NOTIFICATIONS });
   };
-}
\ No newline at end of file
+}
diff --git a/app/javascript/mastodon/actions/streaming.js b/app/javascript/mastodon/actions/streaming.js
index dcce048ca..c22152edd 100644
--- a/app/javascript/mastodon/actions/streaming.js
+++ b/app/javascript/mastodon/actions/streaming.js
@@ -51,3 +51,4 @@ export const connectCommunityStream = () => connectTimelineStream('community', '
 export const connectMediaStream = () => connectTimelineStream('community', 'public:local');
 export const connectPublicStream = () => connectTimelineStream('public', 'public');
 export const connectHashtagStream = (tag) => connectTimelineStream(`hashtag:${tag}`, `hashtag&tag=${tag}`);
+export const connectListStream = (id) => connectTimelineStream(`list:${id}`, `list&list=${id}`);
diff --git a/app/javascript/mastodon/actions/timelines.js b/app/javascript/mastodon/actions/timelines.js
index 09abe2702..f8843d1d9 100644
--- a/app/javascript/mastodon/actions/timelines.js
+++ b/app/javascript/mastodon/actions/timelines.js
@@ -118,6 +118,7 @@ export const refreshCommunityTimeline    = () => refreshTimeline('community', '/
 export const refreshAccountTimeline      = accountId => refreshTimeline(`account:${accountId}`, `/api/v1/accounts/${accountId}/statuses`);
 export const refreshAccountMediaTimeline = accountId => refreshTimeline(`account:${accountId}:media`, `/api/v1/accounts/${accountId}/statuses`, { only_media: true });
 export const refreshHashtagTimeline      = hashtag => refreshTimeline(`hashtag:${hashtag}`, `/api/v1/timelines/tag/${hashtag}`);
+export const refreshListTimeline         = id => refreshTimeline(`list:${id}`, `/api/v1/timelines/list/${id}`);
 
 export function refreshTimelineFail(timeline, error, skipLoading) {
   return {
@@ -158,6 +159,7 @@ export const expandCommunityTimeline    = () => expandTimeline('community', '/ap
 export const expandAccountTimeline      = accountId => expandTimeline(`account:${accountId}`, `/api/v1/accounts/${accountId}/statuses`);
 export const expandAccountMediaTimeline = accountId => expandTimeline(`account:${accountId}:media`, `/api/v1/accounts/${accountId}/statuses`, { only_media: true });
 export const expandHashtagTimeline      = hashtag => expandTimeline(`hashtag:${hashtag}`, `/api/v1/timelines/tag/${hashtag}`);
+export const expandListTimeline         = id => expandTimeline(`list:${id}`, `/api/v1/timelines/list/${id}`);
 
 export function expandTimelineRequest(timeline) {
   return {
diff --git a/app/javascript/mastodon/components/account.js b/app/javascript/mastodon/components/account.js
index 724b10980..1f2d7690f 100644
--- a/app/javascript/mastodon/components/account.js
+++ b/app/javascript/mastodon/components/account.js
@@ -93,7 +93,7 @@ export default class Account extends ImmutablePureComponent {
           </div>
         );
       } else {
-        buttons = <IconButton icon={following ? 'user-times' : 'user-plus'} title={intl.formatMessage(following ? messages.unfollow : messages.follow)} onClick={this.handleFollow} active={following} />;
+        buttons = <IconButton icon={following ? 'user-times' : 'user-plus'} title={intl.formatMessage(following ? messages.unfollow : messages.follow)} onClick={this.handleFollow} active={following ? true : false} />;
       }
     }
 
diff --git a/app/javascript/mastodon/components/dropdown_menu.js b/app/javascript/mastodon/components/dropdown_menu.js
index 3a3ebf487..43dc0d6e3 100644
--- a/app/javascript/mastodon/components/dropdown_menu.js
+++ b/app/javascript/mastodon/components/dropdown_menu.js
@@ -110,7 +110,7 @@ export default class Dropdown extends React.PureComponent {
     icon: PropTypes.string.isRequired,
     items: PropTypes.array.isRequired,
     size: PropTypes.number.isRequired,
-    ariaLabel: PropTypes.string,
+    title: PropTypes.string,
     disabled: PropTypes.bool,
     status: ImmutablePropTypes.map,
     isUserTouching: PropTypes.func,
@@ -120,7 +120,7 @@ export default class Dropdown extends React.PureComponent {
   };
 
   static defaultProps = {
-    ariaLabel: 'Menu',
+    title: 'Menu',
   };
 
   state = {
@@ -186,14 +186,14 @@ export default class Dropdown extends React.PureComponent {
   }
 
   render () {
-    const { icon, items, size, ariaLabel, disabled } = this.props;
+    const { icon, items, size, title, disabled } = this.props;
     const { expanded } = this.state;
 
     return (
       <div onKeyDown={this.handleKeyDown}>
         <IconButton
           icon={icon}
-          title={ariaLabel}
+          title={title}
           active={expanded}
           disabled={disabled}
           size={size}
diff --git a/app/javascript/mastodon/components/status_action_bar.js b/app/javascript/mastodon/components/status_action_bar.js
index 7021c198e..cd59c7845 100644
--- a/app/javascript/mastodon/components/status_action_bar.js
+++ b/app/javascript/mastodon/components/status_action_bar.js
@@ -179,7 +179,7 @@ export default class StatusActionBar extends ImmutablePureComponent {
         {shareButton}
 
         <div className='status__action-bar-dropdown'>
-          <DropdownMenuContainer disabled={anonymousAccess} status={status} items={menu} icon='ellipsis-h' size={18} direction='right' ariaLabel={intl.formatMessage(messages.more)} />
+          <DropdownMenuContainer disabled={anonymousAccess} status={status} items={menu} icon='ellipsis-h' size={18} direction='right' title={intl.formatMessage(messages.more)} />
         </div>
       </div>
     );
diff --git a/app/javascript/mastodon/features/account/components/action_bar.js b/app/javascript/mastodon/features/account/components/action_bar.js
index e375131d4..389296c42 100644
--- a/app/javascript/mastodon/features/account/components/action_bar.js
+++ b/app/javascript/mastodon/features/account/components/action_bar.js
@@ -20,6 +20,8 @@ const messages = defineMessages({
   media: { id: 'account.media', defaultMessage: 'Media' },
   blockDomain: { id: 'account.block_domain', defaultMessage: 'Hide everything from {domain}' },
   unblockDomain: { id: 'account.unblock_domain', defaultMessage: 'Unhide {domain}' },
+  hideReblogs: { id: 'account.hide_reblogs', defaultMessage: 'Hide boosts from @{name}' },
+  showReblogs: { id: 'account.show_reblogs', defaultMessage: 'Show boosts from @{name}' },
 });
 
 @injectIntl
@@ -30,6 +32,7 @@ export default class ActionBar extends React.PureComponent {
     onFollow: PropTypes.func,
     onBlock: PropTypes.func.isRequired,
     onMention: PropTypes.func.isRequired,
+    onReblogToggle: PropTypes.func.isRequired,
     onReport: PropTypes.func.isRequired,
     onMute: PropTypes.func.isRequired,
     onBlockDomain: PropTypes.func.isRequired,
@@ -60,6 +63,15 @@ export default class ActionBar extends React.PureComponent {
     if (account.get('id') === me) {
       menu.push({ text: intl.formatMessage(messages.edit_profile), href: '/settings/profile' });
     } else {
+      const following = account.getIn(['relationship', 'following']);
+      if (following) {
+        if (following.get('reblogs')) {
+          menu.push({ text: intl.formatMessage(messages.hideReblogs, { name: account.get('username') }), action: this.props.onReblogToggle });
+        } else {
+          menu.push({ text: intl.formatMessage(messages.showReblogs, { name: account.get('username') }), action: this.props.onReblogToggle });
+        }
+      }
+
       if (account.getIn(['relationship', 'muting'])) {
         menu.push({ text: intl.formatMessage(messages.unmute, { name: account.get('username') }), action: this.props.onMute });
       } else {
diff --git a/app/javascript/mastodon/features/account/components/header.js b/app/javascript/mastodon/features/account/components/header.js
index f0d2d481f..b2399ae9b 100644
--- a/app/javascript/mastodon/features/account/components/header.js
+++ b/app/javascript/mastodon/features/account/components/header.js
@@ -7,6 +7,7 @@ import Motion from '../../ui/util/optional_motion';
 import spring from 'react-motion/lib/spring';
 import ImmutablePureComponent from 'react-immutable-pure-component';
 import { autoPlayGif, me } from '../../../initial_state';
+import classNames from 'classnames';
 
 const messages = defineMessages({
   unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
@@ -102,6 +103,10 @@ export default class Header extends ImmutablePureComponent {
       }
     }
 
+    if (account.get('moved')) {
+      actionBtn = '';
+    }
+
     if (account.get('locked')) {
       lockedIcon = <i className='fa fa-lock' />;
     }
@@ -110,7 +115,7 @@ export default class Header extends ImmutablePureComponent {
     const displayNameHtml = { __html: account.get('display_name_html') };
 
     return (
-      <div className='account__header' style={{ backgroundImage: `url(${account.get('header')})` }}>
+      <div className={classNames('account__header', { inactive: !!account.get('moved') })} style={{ backgroundImage: `url(${account.get('header')})` }}>
         <div>
           <Avatar account={account} />
 
diff --git a/app/javascript/mastodon/features/account_timeline/components/header.js b/app/javascript/mastodon/features/account_timeline/components/header.js
index 8cf7b92ca..0ddb6b6c1 100644
--- a/app/javascript/mastodon/features/account_timeline/components/header.js
+++ b/app/javascript/mastodon/features/account_timeline/components/header.js
@@ -5,6 +5,7 @@ import InnerHeader from '../../account/components/header';
 import ActionBar from '../../account/components/action_bar';
 import MissingIndicator from '../../../components/missing_indicator';
 import ImmutablePureComponent from 'react-immutable-pure-component';
+import MovedNote from './moved_note';
 
 export default class Header extends ImmutablePureComponent {
 
@@ -13,6 +14,7 @@ export default class Header extends ImmutablePureComponent {
     onFollow: PropTypes.func.isRequired,
     onBlock: PropTypes.func.isRequired,
     onMention: PropTypes.func.isRequired,
+    onReblogToggle: PropTypes.func.isRequired,
     onReport: PropTypes.func.isRequired,
     onMute: PropTypes.func.isRequired,
     onBlockDomain: PropTypes.func.isRequired,
@@ -39,6 +41,10 @@ export default class Header extends ImmutablePureComponent {
     this.props.onReport(this.props.account);
   }
 
+  handleReblogToggle = () => {
+    this.props.onReblogToggle(this.props.account);
+  }
+
   handleMute = () => {
     this.props.onMute(this.props.account);
   }
@@ -68,6 +74,8 @@ export default class Header extends ImmutablePureComponent {
 
     return (
       <div className='account-timeline__header'>
+        {account.get('moved') && <MovedNote from={account} to={account.get('moved')} />}
+
         <InnerHeader
           account={account}
           onFollow={this.handleFollow}
@@ -77,6 +85,7 @@ export default class Header extends ImmutablePureComponent {
           account={account}
           onBlock={this.handleBlock}
           onMention={this.handleMention}
+          onReblogToggle={this.handleReblogToggle}
           onReport={this.handleReport}
           onMute={this.handleMute}
           onBlockDomain={this.handleBlockDomain}
diff --git a/app/javascript/mastodon/features/account_timeline/components/moved_note.js b/app/javascript/mastodon/features/account_timeline/components/moved_note.js
new file mode 100644
index 000000000..1c0e081cc
--- /dev/null
+++ b/app/javascript/mastodon/features/account_timeline/components/moved_note.js
@@ -0,0 +1,48 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { FormattedMessage } from 'react-intl';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import AvatarOverlay from '../../../components/avatar_overlay';
+import DisplayName from '../../../components/display_name';
+
+export default class MovedNote extends ImmutablePureComponent {
+
+  static contextTypes = {
+    router: PropTypes.object,
+  };
+
+  static propTypes = {
+    from: ImmutablePropTypes.map.isRequired,
+    to: ImmutablePropTypes.map.isRequired,
+  };
+
+  handleAccountClick = e => {
+    if (e.button === 0) {
+      e.preventDefault();
+      this.context.router.history.push(`/accounts/${this.props.to.get('id')}`);
+    }
+
+    e.stopPropagation();
+  }
+
+  render () {
+    const { from, to } = this.props;
+    const displayNameHtml = { __html: from.get('display_name_html') };
+
+    return (
+      <div className='account__moved-note'>
+        <div className='account__moved-note__message'>
+          <div className='account__moved-note__icon-wrapper'><i className='fa fa-fw fa-suitcase account__moved-note__icon' /></div>
+          <FormattedMessage id='account.moved_to' defaultMessage='{name} has moved to:' values={{ name: <strong dangerouslySetInnerHTML={displayNameHtml} /> }} />
+        </div>
+
+        <a href={to.get('url')} onClick={this.handleAccountClick} className='detailed-status__display-name'>
+          <div className='detailed-status__display-avatar'><AvatarOverlay account={to} friend={from} /></div>
+          <DisplayName account={to} />
+        </a>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/mastodon/features/account_timeline/containers/header_container.js b/app/javascript/mastodon/features/account_timeline/containers/header_container.js
index 8e50ec405..b41eb19d4 100644
--- a/app/javascript/mastodon/features/account_timeline/containers/header_container.js
+++ b/app/javascript/mastodon/features/account_timeline/containers/header_container.js
@@ -67,6 +67,14 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
     dispatch(mentionCompose(account, router));
   },
 
+  onReblogToggle (account) {
+    if (account.getIn(['relationship', 'following', 'reblogs'])) {
+      dispatch(followAccount(account.get('id'), false));
+    } else {
+      dispatch(followAccount(account.get('id'), true));
+    }
+  },
+
   onReport (account) {
     dispatch(initReport(account));
   },
diff --git a/app/javascript/mastodon/features/compose/components/navigation_bar.js b/app/javascript/mastodon/features/compose/components/navigation_bar.js
index 7f346854c..3014c4033 100644
--- a/app/javascript/mastodon/features/compose/components/navigation_bar.js
+++ b/app/javascript/mastodon/features/compose/components/navigation_bar.js
@@ -11,7 +11,7 @@ export default class NavigationBar extends ImmutablePureComponent {
 
   static propTypes = {
     account: ImmutablePropTypes.map.isRequired,
-    onClose: PropTypes.func.isRequired,
+    onClose: PropTypes.func,
   };
 
   render () {
diff --git a/app/javascript/mastodon/features/getting_started/index.js b/app/javascript/mastodon/features/getting_started/index.js
index 4b4ae6947..df4284207 100644
--- a/app/javascript/mastodon/features/getting_started/index.js
+++ b/app/javascript/mastodon/features/getting_started/index.js
@@ -25,6 +25,7 @@ const messages = defineMessages({
   mutes: { id: 'navigation_bar.mutes', defaultMessage: 'Muted users' },
   info: { id: 'navigation_bar.info', defaultMessage: 'Extended information' },
   pins: { id: 'navigation_bar.pins', defaultMessage: 'Pinned toots' },
+  keyboard_shortcuts: { id: 'navigation_bar.keyboard_shortcuts', defaultMessage: 'Keyboard shortcuts' },
 });
 
 const mapStateToProps = state => ({
@@ -78,6 +79,7 @@ export default class GettingStarted extends ImmutablePureComponent {
     navItems = navItems.concat([
       <ColumnLink key='7' icon='volume-off' text={intl.formatMessage(messages.mutes)} to='/mutes' />,
       <ColumnLink key='8' icon='ban' text={intl.formatMessage(messages.blocks)} to='/blocks' />,
+      <ColumnLink key='9' icon='question' text={intl.formatMessage(messages.keyboard_shortcuts)} to='/keyboard-shortcuts' hideOnMobile />,
     ]);
 
     return (
diff --git a/app/javascript/mastodon/features/keyboard_shortcuts/index.js b/app/javascript/mastodon/features/keyboard_shortcuts/index.js
new file mode 100644
index 000000000..22991fcba
--- /dev/null
+++ b/app/javascript/mastodon/features/keyboard_shortcuts/index.js
@@ -0,0 +1,98 @@
+import React from 'react';
+import Column from '../ui/components/column';
+import ColumnBackButtonSlim from '../../components/column_back_button_slim';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import PropTypes from 'prop-types';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+const messages = defineMessages({
+  heading: { id: 'keyboard_shortcuts.heading', defaultMessage: 'Keyboard Shortcuts' },
+});
+
+@injectIntl
+export default class KeyboardShortcuts extends ImmutablePureComponent {
+
+  static propTypes = {
+    intl: PropTypes.object.isRequired,
+    multiColumn: PropTypes.bool,
+  };
+
+  render () {
+    const { intl } = this.props;
+
+    return (
+      <Column icon='question' heading={intl.formatMessage(messages.heading)}>
+        <ColumnBackButtonSlim />
+        <div className='keyboard-shortcuts scrollable optionally-scrollable'>
+          <table>
+            <thead>
+              <tr>
+                <th><FormattedMessage id='keyboard_shortcuts.hotkey' defaultMessage='Hotkey' /></th>
+                <th><FormattedMessage id='keyboard_shortcuts.description' defaultMessage='Description' /></th>
+              </tr>
+            </thead>
+            <tbody>
+              <tr>
+                <td><code>r</code></td>
+                <td><FormattedMessage id='keyboard_shortcuts.reply' defaultMessage='to reply' /></td>
+              </tr>
+              <tr>
+                <td><code>m</code></td>
+                <td><FormattedMessage id='keyboard_shortcuts.mention' defaultMessage='to mention author' /></td>
+              </tr>
+              <tr>
+                <td><code>f</code></td>
+                <td><FormattedMessage id='keyboard_shortcuts.favourite' defaultMessage='to favourite' /></td>
+              </tr>
+              <tr>
+                <td><code>b</code></td>
+                <td><FormattedMessage id='keyboard_shortcuts.boost' defaultMessage='to boost' /></td>
+              </tr>
+              <tr>
+                <td><code>enter</code></td>
+                <td><FormattedMessage id='keyboard_shortcuts.enter' defaultMessage='to open status' /></td>
+              </tr>
+              <tr>
+                <td><code>up</code></td>
+                <td><FormattedMessage id='keyboard_shortcuts.up' defaultMessage='to move up in the list' /></td>
+              </tr>
+              <tr>
+                <td><code>down</code></td>
+                <td><FormattedMessage id='keyboard_shortcuts.down' defaultMessage='to move down in the list' /></td>
+              </tr>
+              <tr>
+                <td><code>1</code>-<code>9</code></td>
+                <td><FormattedMessage id='keyboard_shortcuts.column' defaultMessage='to focus a status in one of the columns' /></td>
+              </tr>
+              <tr>
+                <td><code>n</code></td>
+                <td><FormattedMessage id='keyboard_shortcuts.compose' defaultMessage='to focus the compose textarea' /></td>
+              </tr>
+              <tr>
+                <td><code>alt</code>+<code>n</code></td>
+                <td><FormattedMessage id='keyboard_shortcuts.toot' defaultMessage='to start a brand new toot' /></td>
+              </tr>
+              <tr>
+                <td><code>backspace</code></td>
+                <td><FormattedMessage id='keyboard_shortcuts.back' defaultMessage='to navigate back' /></td>
+              </tr>
+              <tr>
+                <td><code>s</code></td>
+                <td><FormattedMessage id='keyboard_shortcuts.search' defaultMessage='to focus search' /></td>
+              </tr>
+              <tr>
+                <td><code>esc</code></td>
+                <td><FormattedMessage id='keyboard_shortcuts.unfocus' defaultMessage='to un-focus compose textarea/search' /></td>
+              </tr>
+              <tr>
+                <td><code>?</code></td>
+                <td><FormattedMessage id='keyboard_shortcuts.legend' defaultMessage='to display this legend' /></td>
+              </tr>
+            </tbody>
+          </table>
+        </div>
+      </Column>
+    );
+  }
+
+}
diff --git a/app/javascript/mastodon/features/list_timeline/index.js b/app/javascript/mastodon/features/list_timeline/index.js
new file mode 100644
index 000000000..71f6e36a8
--- /dev/null
+++ b/app/javascript/mastodon/features/list_timeline/index.js
@@ -0,0 +1,106 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import StatusListContainer from '../ui/containers/status_list_container';
+import Column from '../../components/column';
+import ColumnHeader from '../../components/column_header';
+import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
+import { FormattedMessage } from 'react-intl';
+import { connectListStream } from '../../actions/streaming';
+import { refreshListTimeline, expandListTimeline } from '../../actions/timelines';
+import { fetchList } from '../../actions/lists';
+
+const mapStateToProps = (state, props) => ({
+  list: state.getIn(['lists', props.params.id]),
+  hasUnread: state.getIn(['timelines', `list:${props.params.id}`, 'unread']) > 0,
+});
+
+@connect(mapStateToProps)
+export default class ListTimeline extends React.PureComponent {
+
+  static propTypes = {
+    params: PropTypes.object.isRequired,
+    dispatch: PropTypes.func.isRequired,
+    columnId: PropTypes.string,
+    hasUnread: PropTypes.bool,
+    multiColumn: PropTypes.bool,
+    list: ImmutablePropTypes.map,
+  };
+
+  handlePin = () => {
+    const { columnId, dispatch } = this.props;
+
+    if (columnId) {
+      dispatch(removeColumn(columnId));
+    } else {
+      dispatch(addColumn('LIST', { id: this.props.params.id }));
+    }
+  }
+
+  handleMove = (dir) => {
+    const { columnId, dispatch } = this.props;
+    dispatch(moveColumn(columnId, dir));
+  }
+
+  handleHeaderClick = () => {
+    this.column.scrollTop();
+  }
+
+  componentDidMount () {
+    const { dispatch } = this.props;
+    const { id } = this.props.params;
+
+    dispatch(fetchList(id));
+    dispatch(refreshListTimeline(id));
+
+    this.disconnect = dispatch(connectListStream(id));
+  }
+
+  componentWillUnmount () {
+    if (this.disconnect) {
+      this.disconnect();
+      this.disconnect = null;
+    }
+  }
+
+  setRef = c => {
+    this.column = c;
+  }
+
+  handleLoadMore = () => {
+    const { id } = this.props.params;
+    this.props.dispatch(expandListTimeline(id));
+  }
+
+  render () {
+    const { hasUnread, columnId, multiColumn, list } = this.props;
+    const { id } = this.props.params;
+    const pinned = !!columnId;
+    const title  = list ? list.get('title') : id;
+
+    return (
+      <Column ref={this.setRef}>
+        <ColumnHeader
+          icon='bars'
+          active={hasUnread}
+          title={title}
+          onPin={this.handlePin}
+          onMove={this.handleMove}
+          onClick={this.handleHeaderClick}
+          pinned={pinned}
+          multiColumn={multiColumn}
+        />
+
+        <StatusListContainer
+          trackScroll={!pinned}
+          scrollKey={`list_timeline-${columnId}`}
+          timelineId={`list:${id}`}
+          loadMore={this.handleLoadMore}
+          emptyMessage={<FormattedMessage id='empty_column.list' defaultMessage='There is nothing in this list yet.' />}
+        />
+      </Column>
+    );
+  }
+
+}
diff --git a/app/javascript/mastodon/features/status/components/action_bar.js b/app/javascript/mastodon/features/status/components/action_bar.js
index 7b65420d0..99834df6c 100644
--- a/app/javascript/mastodon/features/status/components/action_bar.js
+++ b/app/javascript/mastodon/features/status/components/action_bar.js
@@ -120,7 +120,7 @@ export default class ActionBar extends React.PureComponent {
         {shareButton}
 
         <div className='detailed-status__action-bar-dropdown'>
-          <DropdownMenuContainer size={18} icon='ellipsis-h' items={menu} direction='left' ariaLabel='More' />
+          <DropdownMenuContainer size={18} icon='ellipsis-h' items={menu} direction='left' title='More' />
         </div>
       </div>
     );
diff --git a/app/javascript/mastodon/features/status/components/card.js b/app/javascript/mastodon/features/status/components/card.js
index bb83374b9..680bf63ab 100644
--- a/app/javascript/mastodon/features/status/components/card.js
+++ b/app/javascript/mastodon/features/status/components/card.js
@@ -1,5 +1,6 @@
 import React from 'react';
 import PropTypes from 'prop-types';
+import Immutable from 'immutable';
 import ImmutablePropTypes from 'react-immutable-proptypes';
 import punycode from 'punycode';
 import classnames from 'classnames';
@@ -24,6 +25,7 @@ export default class Card extends React.PureComponent {
   static propTypes = {
     card: ImmutablePropTypes.map,
     maxDescription: PropTypes.number,
+    onOpenMedia: PropTypes.func.isRequired,
   };
 
   static defaultProps = {
@@ -34,6 +36,27 @@ export default class Card extends React.PureComponent {
     width: 0,
   };
 
+  handlePhotoClick = () => {
+    const { card, onOpenMedia } = this.props;
+
+    onOpenMedia(
+      Immutable.fromJS([
+        {
+          type: 'image',
+          url: card.get('url'),
+          description: card.get('title'),
+          meta: {
+            original: {
+              width: card.get('width'),
+              height: card.get('height'),
+            },
+          },
+        },
+      ]),
+      0
+    );
+  };
+
   renderLink () {
     const { card, maxDescription } = this.props;
 
@@ -73,9 +96,16 @@ export default class Card extends React.PureComponent {
     const { card } = this.props;
 
     return (
-      <a href={card.get('url')} className='status-card-photo' target='_blank' rel='noopener'>
-        <img src={card.get('url')} alt={card.get('title')} width={card.get('width')} height={card.get('height')} />
-      </a>
+      <img
+        className='status-card-photo'
+        onClick={this.handlePhotoClick}
+        role='button'
+        tabIndex='0'
+        src={card.get('url')}
+        alt={card.get('title')}
+        width={card.get('width')}
+        height={card.get('height')}
+      />
     );
   }
 
diff --git a/app/javascript/mastodon/features/status/components/detailed_status.js b/app/javascript/mastodon/features/status/components/detailed_status.js
index 81f71749b..abdb9a3f6 100644
--- a/app/javascript/mastodon/features/status/components/detailed_status.js
+++ b/app/javascript/mastodon/features/status/components/detailed_status.js
@@ -73,7 +73,7 @@ export default class DetailedStatus extends ImmutablePureComponent {
         );
       }
     } else if (status.get('spoiler_text').length === 0) {
-      media = <CardContainer statusId={status.get('id')} />;
+      media = <CardContainer onOpenMedia={this.props.onOpenMedia} statusId={status.get('id')} />;
     }
 
     if (status.get('application')) {
diff --git a/app/javascript/mastodon/features/ui/components/columns_area.js b/app/javascript/mastodon/features/ui/components/columns_area.js
index 5610095b9..93ed9e605 100644
--- a/app/javascript/mastodon/features/ui/components/columns_area.js
+++ b/app/javascript/mastodon/features/ui/components/columns_area.js
@@ -11,7 +11,7 @@ import BundleContainer from '../containers/bundle_container';
 import ColumnLoading from './column_loading';
 import DrawerLoading from './drawer_loading';
 import BundleColumnError from './bundle_column_error';
-import { Compose, Notifications, HomeTimeline, CommunityTimeline, PublicTimeline, HashtagTimeline, FavouritedStatuses } from '../../ui/util/async-components';
+import { Compose, Notifications, HomeTimeline, CommunityTimeline, PublicTimeline, HashtagTimeline, FavouritedStatuses, ListTimeline } from '../../ui/util/async-components';
 
 import detectPassiveEvents from 'detect-passive-events';
 import { scrollRight } from '../../../scroll';
@@ -24,6 +24,7 @@ const componentMap = {
   'COMMUNITY': CommunityTimeline,
   'HASHTAG': HashtagTimeline,
   'FAVOURITES': FavouritedStatuses,
+  'LIST': ListTimeline,
 };
 
 @component => injectIntl(component, { withRef: true })
diff --git a/app/javascript/mastodon/features/ui/components/image_loader.js b/app/javascript/mastodon/features/ui/components/image_loader.js
index aad594380..e3e7197c5 100644
--- a/app/javascript/mastodon/features/ui/components/image_loader.js
+++ b/app/javascript/mastodon/features/ui/components/image_loader.js
@@ -7,7 +7,7 @@ export default class ImageLoader extends React.PureComponent {
   static propTypes = {
     alt: PropTypes.string,
     src: PropTypes.string.isRequired,
-    previewSrc: PropTypes.string.isRequired,
+    previewSrc: PropTypes.string,
     width: PropTypes.number,
     height: PropTypes.number,
   }
@@ -47,7 +47,7 @@ export default class ImageLoader extends React.PureComponent {
     this.removeEventListeners();
     this.setState({ loading: true, error: false });
     Promise.all([
-      this.loadPreviewCanvas(props),
+      props.previewSrc && this.loadPreviewCanvas(props),
       this.hasSize() && this.loadOriginalImage(props),
     ].filter(Boolean))
       .then(() => {
diff --git a/app/javascript/mastodon/features/ui/components/media_modal.js b/app/javascript/mastodon/features/ui/components/media_modal.js
index f41a83089..02591a51f 100644
--- a/app/javascript/mastodon/features/ui/components/media_modal.js
+++ b/app/javascript/mastodon/features/ui/components/media_modal.js
@@ -92,7 +92,7 @@ export default class MediaModal extends ImmutablePureComponent {
       const height = image.getIn(['meta', 'original', 'height']) || null;
 
       if (image.get('type') === 'image') {
-        return <ImageLoader previewSrc={image.get('preview_url')} src={image.get('url')} width={width} height={height} alt={image.get('description')} key={image.get('preview_url')} />;
+        return <ImageLoader previewSrc={image.get('preview_url')} src={image.get('url')} width={width} height={height} alt={image.get('description')} key={image.get('url')} />;
       } else if (image.get('type') === 'gifv') {
         return <ExtendedVideoPlayer src={image.get('url')} muted controls={false} width={width} height={height} key={image.get('preview_url')} alt={image.get('description')} />;
       }
diff --git a/app/javascript/mastodon/features/ui/index.js b/app/javascript/mastodon/features/ui/index.js
index f28b37099..361326961 100644
--- a/app/javascript/mastodon/features/ui/index.js
+++ b/app/javascript/mastodon/features/ui/index.js
@@ -19,6 +19,7 @@ import {
   Compose,
   Status,
   GettingStarted,
+  KeyboardShortcuts,
   PublicTimeline,
   CommunityTimeline,
   AccountTimeline,
@@ -33,6 +34,7 @@ import {
   FollowRequests,
   GenericNotFound,
   FavouritedStatuses,
+  ListTimeline,
   Blocks,
   Mutes,
   PinnedStatuses,
@@ -55,6 +57,7 @@ const mapStateToProps = state => ({
 });
 
 const keyMap = {
+  help: '?',
   new: 'n',
   search: 's',
   forceNew: 'option+n',
@@ -297,6 +300,14 @@ export default class UI extends React.Component {
     this.hotkeys = c;
   }
 
+  handleHotkeyToggleHelp = () => {
+    if (this.props.location.pathname === '/keyboard-shortcuts') {
+      this.context.router.history.goBack();
+    } else {
+      this.context.router.history.push('/keyboard-shortcuts');
+    }
+  }
+
   handleHotkeyGoToHome = () => {
     this.context.router.history.push('/timelines/home');
   }
@@ -342,6 +353,7 @@ export default class UI extends React.Component {
     const { children } = this.props;
 
     const handlers = {
+      help: this.handleHotkeyToggleHelp,
       new: this.handleHotkeyNew,
       search: this.handleHotkeySearch,
       forceNew: this.handleHotkeyForceNew,
@@ -368,10 +380,12 @@ export default class UI extends React.Component {
             <WrappedSwitch>
               <Redirect from='/' to='/getting-started' exact />
               <WrappedRoute path='/getting-started' component={GettingStarted} content={children} />
+              <WrappedRoute path='/keyboard-shortcuts' component={KeyboardShortcuts} content={children} />
               <WrappedRoute path='/timelines/home' component={HomeTimeline} content={children} />
               <WrappedRoute path='/timelines/public' exact component={PublicTimeline} content={children} />
               <WrappedRoute path='/timelines/public/local' component={CommunityTimeline} content={children} />
               <WrappedRoute path='/timelines/tag/:id' component={HashtagTimeline} content={children} />
+              <WrappedRoute path='/timelines/list/:id' component={ListTimeline} content={children} />
 
               <WrappedRoute path='/notifications' component={Notifications} content={children} />
               <WrappedRoute path='/favourites' component={FavouritedStatuses} content={children} />
diff --git a/app/javascript/mastodon/features/ui/util/async-components.js b/app/javascript/mastodon/features/ui/util/async-components.js
index 39663d5ca..b741f668e 100644
--- a/app/javascript/mastodon/features/ui/util/async-components.js
+++ b/app/javascript/mastodon/features/ui/util/async-components.js
@@ -26,6 +26,10 @@ export function HashtagTimeline () {
   return import(/* webpackChunkName: "features/hashtag_timeline" */'../../hashtag_timeline');
 }
 
+export function ListTimeline () {
+  return import(/* webpackChunkName: "features/list_timeline" */'../../list_timeline');
+}
+
 export function Status () {
   return import(/* webpackChunkName: "features/status" */'../../status');
 }
@@ -34,6 +38,10 @@ export function GettingStarted () {
   return import(/* webpackChunkName: "features/getting_started" */'../../getting_started');
 }
 
+export function KeyboardShortcuts () {
+  return import(/* webpackChunkName: "features/keyboard_shortcuts" */'../../keyboard_shortcuts');
+}
+
 export function PinnedStatuses () {
   return import(/* webpackChunkName: "features/pinned_statuses" */'../../pinned_statuses');
 }
diff --git a/app/javascript/mastodon/locales/ar.json b/app/javascript/mastodon/locales/ar.json
index 7cc8ea237..596a519bd 100644
--- a/app/javascript/mastodon/locales/ar.json
+++ b/app/javascript/mastodon/locales/ar.json
@@ -7,17 +7,22 @@
   "account.followers": "المتابعون",
   "account.follows": "يتبع",
   "account.follows_you": "يتابعك",
+  "account.hide_reblogs": "Hide boosts from @{name}",
   "account.media": "وسائط",
   "account.mention": "أُذكُر @{name}",
+  "account.moved_to": "{name} has moved to:",
   "account.mute": "أكتم @{name}",
+  "account.mute_notifications": "Mute notifications from @{name}",
   "account.posts": "المشاركات",
   "account.report": "أبلغ عن @{name}",
   "account.requested": "في انتظار الموافقة",
   "account.share": "مشاركة @{name}'s profile",
+  "account.show_reblogs": "Show boosts from @{name}",
   "account.unblock": "إلغاء الحظر عن @{name}",
   "account.unblock_domain": "فك حظر {domain}",
   "account.unfollow": "إلغاء المتابعة",
   "account.unmute": "إلغاء الكتم عن @{name}",
+  "account.unmute_notifications": "Unmute notifications from @{name}",
   "account.view_full_profile": "عرض الملف الشخصي كاملا",
   "boost_modal.combo": "يمكنك ضغط {combo} لتخطّي هذه في المرّة القادمة",
   "bundle_column_error.body": "لقد وقع هناك خطأ أثناء عملية تحميل هذا العنصر.",
@@ -83,6 +88,7 @@
   "empty_column.hashtag": "ليس هناك بعدُ أي محتوى ذو علاقة بهذا الوسم.",
   "empty_column.home": "إنك لا تتبع بعد أي شخص إلى حد الآن. زر {public} أو استخدام حقل البحث لكي تبدأ على التعرف على مستخدمين آخرين.",
   "empty_column.home.public_timeline": "الخيط العام",
+  "empty_column.list": "There is nothing in this list yet.",
   "empty_column.notifications": "لم تتلق أي إشعار بعدُ. تفاعل مع المستخدمين الآخرين لإنشاء محادثة.",
   "empty_column.public": "لا يوجد شيء هنا ! قم بتحرير شيء ما بشكل عام، أو اتبع مستخدمين آخرين في الخوادم المثيلة الأخرى لملء خيط المحادثات العام.",
   "follow_request.authorize": "ترخيص",
@@ -98,18 +104,37 @@
   "home.column_settings.show_reblogs": "عرض الترقيات",
   "home.column_settings.show_replies": "عرض الردود",
   "home.settings": "إعدادات العمود",
+  "keyboard_shortcuts.back": "to navigate back",
+  "keyboard_shortcuts.boost": "to boost",
+  "keyboard_shortcuts.column": "to focus a status in one of the columns",
+  "keyboard_shortcuts.compose": "to focus the compose textarea",
+  "keyboard_shortcuts.description": "Description",
+  "keyboard_shortcuts.down": "to move down in the list",
+  "keyboard_shortcuts.enter": "to open status",
+  "keyboard_shortcuts.favourite": "to favourite",
+  "keyboard_shortcuts.heading": "Keyboard Shortcuts",
+  "keyboard_shortcuts.hotkey": "Hotkey",
+  "keyboard_shortcuts.legend": "to display this legend",
+  "keyboard_shortcuts.mention": "to mention author",
+  "keyboard_shortcuts.reply": "to reply",
+  "keyboard_shortcuts.search": "to focus search",
+  "keyboard_shortcuts.toot": "to start a brand new toot",
+  "keyboard_shortcuts.unfocus": "to un-focus compose textarea/search",
+  "keyboard_shortcuts.up": "to move up in the list",
   "lightbox.close": "إغلاق",
   "lightbox.next": "التالي",
   "lightbox.previous": "العودة",
   "loading_indicator.label": "تحميل ...",
   "media_gallery.toggle_visible": "عرض / إخفاء",
   "missing_indicator.label": "تعذر العثور عليه",
+  "mute_modal.hide_notifications": "Hide notifications from this user?",
   "navigation_bar.blocks": "الحسابات المحجوبة",
   "navigation_bar.community_timeline": "الخيط العام المحلي",
   "navigation_bar.edit_profile": "تعديل الملف الشخصي",
   "navigation_bar.favourites": "المفضلة",
   "navigation_bar.follow_requests": "طلبات المتابعة",
   "navigation_bar.info": "معلومات إضافية",
+  "navigation_bar.keyboard_shortcuts": "Keyboard shortcuts",
   "navigation_bar.logout": "خروج",
   "navigation_bar.mutes": "الحسابات المكتومة",
   "navigation_bar.pins": "التبويقات المثبتة",
@@ -204,6 +229,7 @@
   "tabs_bar.home": "الرئيسية",
   "tabs_bar.local_timeline": "المحلي",
   "tabs_bar.notifications": "الإخطارات",
+  "ui.beforeunload": "Your draft will be lost if you leave Mastodon.",
   "upload_area.title": "إسحب ثم أفلت للرفع",
   "upload_button.label": "إضافة وسائط",
   "upload_form.description": "وصف للمعاقين بصريا",
diff --git a/app/javascript/mastodon/locales/bg.json b/app/javascript/mastodon/locales/bg.json
index da2372cff..82f092648 100644
--- a/app/javascript/mastodon/locales/bg.json
+++ b/app/javascript/mastodon/locales/bg.json
@@ -7,17 +7,22 @@
   "account.followers": "Последователи",
   "account.follows": "Следвам",
   "account.follows_you": "Твой последовател",
+  "account.hide_reblogs": "Hide boosts from @{name}",
   "account.media": "Media",
   "account.mention": "Споменаване",
+  "account.moved_to": "{name} has moved to:",
   "account.mute": "Mute @{name}",
+  "account.mute_notifications": "Mute notifications from @{name}",
   "account.posts": "Публикации",
   "account.report": "Report @{name}",
   "account.requested": "В очакване на одобрение",
   "account.share": "Share @{name}'s profile",
+  "account.show_reblogs": "Show boosts from @{name}",
   "account.unblock": "Не блокирай",
   "account.unblock_domain": "Unhide {domain}",
   "account.unfollow": "Не следвай",
   "account.unmute": "Unmute @{name}",
+  "account.unmute_notifications": "Unmute notifications from @{name}",
   "account.view_full_profile": "View full profile",
   "boost_modal.combo": "You can press {combo} to skip this next time",
   "bundle_column_error.body": "Something went wrong while loading this component.",
@@ -83,6 +88,7 @@
   "empty_column.hashtag": "There is nothing in this hashtag yet.",
   "empty_column.home": "Your home timeline is empty! Visit {public} or use search to get started and meet other users.",
   "empty_column.home.public_timeline": "the public timeline",
+  "empty_column.list": "There is nothing in this list yet.",
   "empty_column.notifications": "You don't have any notifications yet. Interact with others to start the conversation.",
   "empty_column.public": "There is nothing here! Write something publicly, or manually follow users from other instances to fill it up",
   "follow_request.authorize": "Authorize",
@@ -98,18 +104,37 @@
   "home.column_settings.show_reblogs": "Show boosts",
   "home.column_settings.show_replies": "Show replies",
   "home.settings": "Column settings",
+  "keyboard_shortcuts.back": "to navigate back",
+  "keyboard_shortcuts.boost": "to boost",
+  "keyboard_shortcuts.column": "to focus a status in one of the columns",
+  "keyboard_shortcuts.compose": "to focus the compose textarea",
+  "keyboard_shortcuts.description": "Description",
+  "keyboard_shortcuts.down": "to move down in the list",
+  "keyboard_shortcuts.enter": "to open status",
+  "keyboard_shortcuts.favourite": "to favourite",
+  "keyboard_shortcuts.heading": "Keyboard Shortcuts",
+  "keyboard_shortcuts.hotkey": "Hotkey",
+  "keyboard_shortcuts.legend": "to display this legend",
+  "keyboard_shortcuts.mention": "to mention author",
+  "keyboard_shortcuts.reply": "to reply",
+  "keyboard_shortcuts.search": "to focus search",
+  "keyboard_shortcuts.toot": "to start a brand new toot",
+  "keyboard_shortcuts.unfocus": "to un-focus compose textarea/search",
+  "keyboard_shortcuts.up": "to move up in the list",
   "lightbox.close": "Затвори",
   "lightbox.next": "Next",
   "lightbox.previous": "Previous",
   "loading_indicator.label": "Зареждане...",
   "media_gallery.toggle_visible": "Toggle visibility",
   "missing_indicator.label": "Not found",
+  "mute_modal.hide_notifications": "Hide notifications from this user?",
   "navigation_bar.blocks": "Blocked users",
   "navigation_bar.community_timeline": "Local timeline",
   "navigation_bar.edit_profile": "Редактирай профил",
   "navigation_bar.favourites": "Favourites",
   "navigation_bar.follow_requests": "Follow requests",
   "navigation_bar.info": "Extended information",
+  "navigation_bar.keyboard_shortcuts": "Keyboard shortcuts",
   "navigation_bar.logout": "Излизане",
   "navigation_bar.mutes": "Muted users",
   "navigation_bar.pins": "Pinned toots",
@@ -204,6 +229,7 @@
   "tabs_bar.home": "Начало",
   "tabs_bar.local_timeline": "Local",
   "tabs_bar.notifications": "Известия",
+  "ui.beforeunload": "Your draft will be lost if you leave Mastodon.",
   "upload_area.title": "Drag & drop to upload",
   "upload_button.label": "Добави медия",
   "upload_form.description": "Describe for the visually impaired",
diff --git a/app/javascript/mastodon/locales/ca.json b/app/javascript/mastodon/locales/ca.json
index af732921d..baa9432de 100644
--- a/app/javascript/mastodon/locales/ca.json
+++ b/app/javascript/mastodon/locales/ca.json
@@ -7,17 +7,22 @@
   "account.followers": "Seguidors",
   "account.follows": "Seguint",
   "account.follows_you": "et segueix",
+  "account.hide_reblogs": "Hide boosts from @{name}",
   "account.media": "Media",
   "account.mention": "Esmentar @{name}",
+  "account.moved_to": "{name} s'ha mogut a:",
   "account.mute": "Silenciar @{name}",
+  "account.mute_notifications": "Notificacions desactivades de @{name}",
   "account.posts": "Publicacions",
   "account.report": "Informe @{name}",
-  "account.requested": "Esperant aprovació",
+  "account.requested": "Esperant aprovació. Clic per a cancel·lar la petició de seguiment",
   "account.share": "Compartir el perfil de @{name}",
+  "account.show_reblogs": "Show boosts from @{name}",
   "account.unblock": "Desbloquejar @{name}",
   "account.unblock_domain": "Mostra {domain}",
   "account.unfollow": "Deixar de seguir",
   "account.unmute": "Treure silenci de @{name}",
+  "account.unmute_notifications": "Activar notificacions de @{name}",
   "account.view_full_profile": "Veure el perfil complet",
   "boost_modal.combo": "Pots premer {combo} per saltar-te això el proper cop",
   "bundle_column_error.body": "S'ha produït un error en carregar aquest component.",
@@ -51,7 +56,7 @@
   "compose_form.publish_loud": "{publish}!",
   "compose_form.sensitive": "Marcar multimèdia com a sensible",
   "compose_form.spoiler": "Amagar text darrera l'advertència",
-  "compose_form.spoiler_placeholder": "Advertència de contingut",
+  "compose_form.spoiler_placeholder": "Escriu l'advertència aquí",
   "confirmation_modal.cancel": "Cancel·lar",
   "confirmations.block.confirm": "Bloquejar",
   "confirmations.block.message": "Estàs segur que vols bloquejar {name}?",
@@ -64,7 +69,7 @@
   "confirmations.unfollow.confirm": "Deixar de seguir",
   "confirmations.unfollow.message": "Estàs segur que vols deixar de seguir {name}?",
   "embed.instructions": "Incrusta aquest estat al lloc web copiant el codi a continuació.",
-  "embed.preview": "A continuació s'explica com:",
+  "embed.preview": "Aquí tenim quin aspecte tindrá:",
   "emoji_button.activity": "Activitat",
   "emoji_button.custom": "Personalitzat",
   "emoji_button.flags": "Flags",
@@ -83,6 +88,7 @@
   "empty_column.hashtag": "Encara no hi ha res amb aquesta etiqueta.",
   "empty_column.home": "Encara no segueixes ningú. Visita {public} o fes cerca per començar i conèixer altres usuaris.",
   "empty_column.home.public_timeline": "la línia de temps pública",
+  "empty_column.list": "There is nothing in this list yet.",
   "empty_column.notifications": "Encara no tens notificacions. Interactua amb altres per iniciar la conversa.",
   "empty_column.public": "No hi ha res aquí! Escriu alguna cosa públicament o segueix manualment usuaris d'altres instàncies per omplir-ho",
   "follow_request.authorize": "Autoritzar",
@@ -95,21 +101,40 @@
   "home.column_settings.advanced": "Avançat",
   "home.column_settings.basic": "Bàsic",
   "home.column_settings.filter_regex": "Filtrar per expressió regular",
-  "home.column_settings.show_reblogs": "Mostrar 'boosts'",
+  "home.column_settings.show_reblogs": "Mostrar impulsos",
   "home.column_settings.show_replies": "Mostrar respostes",
   "home.settings": "Ajustos de columna",
+  "keyboard_shortcuts.back": "to navigate back",
+  "keyboard_shortcuts.boost": "to boost",
+  "keyboard_shortcuts.column": "to focus a status in one of the columns",
+  "keyboard_shortcuts.compose": "to focus the compose textarea",
+  "keyboard_shortcuts.description": "Description",
+  "keyboard_shortcuts.down": "to move down in the list",
+  "keyboard_shortcuts.enter": "to open status",
+  "keyboard_shortcuts.favourite": "to favourite",
+  "keyboard_shortcuts.heading": "Keyboard Shortcuts",
+  "keyboard_shortcuts.hotkey": "Hotkey",
+  "keyboard_shortcuts.legend": "to display this legend",
+  "keyboard_shortcuts.mention": "to mention author",
+  "keyboard_shortcuts.reply": "to reply",
+  "keyboard_shortcuts.search": "to focus search",
+  "keyboard_shortcuts.toot": "to start a brand new toot",
+  "keyboard_shortcuts.unfocus": "to un-focus compose textarea/search",
+  "keyboard_shortcuts.up": "to move up in the list",
   "lightbox.close": "Tancar",
   "lightbox.next": "Següent",
   "lightbox.previous": "Anterior",
   "loading_indicator.label": "Carregant...",
   "media_gallery.toggle_visible": "Alternar visibilitat",
   "missing_indicator.label": "No trobat",
+  "mute_modal.hide_notifications": "Amagar notificacions d'aquest usuari?",
   "navigation_bar.blocks": "Usuaris bloquejats",
   "navigation_bar.community_timeline": "Línia de temps Local",
   "navigation_bar.edit_profile": "Editar perfil",
   "navigation_bar.favourites": "Favorits",
   "navigation_bar.follow_requests": "Sol·licituds de seguiment",
   "navigation_bar.info": "Informació addicional",
+  "navigation_bar.keyboard_shortcuts": "Keyboard shortcuts",
   "navigation_bar.logout": "Tancar sessió",
   "navigation_bar.mutes": "Usuaris silenciats",
   "navigation_bar.pins": "Toots fixats",
@@ -127,7 +152,7 @@
   "notifications.column_settings.mention": "Mencions:",
   "notifications.column_settings.push": "Push notificacions",
   "notifications.column_settings.push_meta": "Aquest dispositiu",
-  "notifications.column_settings.reblog": "Boosts:",
+  "notifications.column_settings.reblog": "Impulsos:",
   "notifications.column_settings.show": "Mostrar en la columna",
   "notifications.column_settings.sound": "Reproduïr so",
   "onboarding.done": "Fet",
@@ -159,11 +184,11 @@
   "privacy.public.short": "Públic",
   "privacy.unlisted.long": "No publicar en línies de temps públiques",
   "privacy.unlisted.short": "No llistat",
-  "relative_time.days": "fa {number} jorns",
+  "relative_time.days": "fa {number} dies",
   "relative_time.hours": "fa {number} hores",
   "relative_time.just_now": "ara",
-  "relative_time.minutes": "fa {number} minutes",
-  "relative_time.seconds": "fa {number} segondes",
+  "relative_time.minutes": "fa {number} minuts",
+  "relative_time.seconds": "fa {number} segons",
   "reply_indicator.cancel": "Cancel·lar",
   "report.placeholder": "Comentaris addicionals",
   "report.submit": "Enviar",
@@ -187,7 +212,7 @@
   "status.mute_conversation": "Silenciar conversació",
   "status.open": "Ampliar aquest estat",
   "status.pin": "Fixat en el perfil",
-  "status.reblog": "Boost",
+  "status.reblog": "Impuls",
   "status.reblogged_by": "{name} ha retootejat",
   "status.reply": "Respondre",
   "status.replyAll": "Respondre al tema",
@@ -204,6 +229,7 @@
   "tabs_bar.home": "Inici",
   "tabs_bar.local_timeline": "Local",
   "tabs_bar.notifications": "Notificacions",
+  "ui.beforeunload": "El vostre esborrany es perdrà si sortiu de Mastodon.",
   "upload_area.title": "Arrossega i deixa anar per carregar",
   "upload_button.label": "Afegir multimèdia",
   "upload_form.description": "Descriure els problemes visuals",
diff --git a/app/javascript/mastodon/locales/de.json b/app/javascript/mastodon/locales/de.json
index 283a2946f..343528899 100644
--- a/app/javascript/mastodon/locales/de.json
+++ b/app/javascript/mastodon/locales/de.json
@@ -7,17 +7,22 @@
   "account.followers": "Folgende",
   "account.follows": "Folgt",
   "account.follows_you": "Folgt dir",
+  "account.hide_reblogs": "Hide boosts from @{name}",
   "account.media": "Medien",
   "account.mention": "@{name} erwähnen",
+  "account.moved_to": "{name} has moved to:",
   "account.mute": "@{name} stummschalten",
+  "account.mute_notifications": "Mute notifications from @{name}",
   "account.posts": "Beiträge",
   "account.report": "@{name} melden",
   "account.requested": "Warte auf Erlaubnis. Klicke zum Abbrechen",
   "account.share": "Profil von @{name} teilen",
+  "account.show_reblogs": "Show boosts from @{name}",
   "account.unblock": "@{name} entblocken",
   "account.unblock_domain": "{domain} wieder anzeigen",
   "account.unfollow": "Entfolgen",
   "account.unmute": "@{name} nicht mehr stummschalten",
+  "account.unmute_notifications": "Unmute notifications from @{name}",
   "account.view_full_profile": "Vollständiges Profil anzeigen",
   "boost_modal.combo": "Du kannst {combo} drücken, um dies beim nächsten Mal zu überspringen",
   "bundle_column_error.body": "Etwas ist beim Laden schiefgelaufen.",
@@ -83,6 +88,7 @@
   "empty_column.hashtag": "Unter diesem Hashtag gibt es noch nichts.",
   "empty_column.home": "Deine Startseite ist leer! Besuche {public} oder nutze die Suche, um loszulegen und andere Leute zu finden.",
   "empty_column.home.public_timeline": "die öffentliche Zeitleiste",
+  "empty_column.list": "There is nothing in this list yet.",
   "empty_column.notifications": "Du hast noch keine Mitteilungen. Interagiere mit anderen, um ins Gespräch zu kommen.",
   "empty_column.public": "Hier ist nichts zu sehen! Schreibe etwas öffentlich oder folge Profilen von anderen Instanzen, um die Zeitleiste aufzufüllen",
   "follow_request.authorize": "Erlauben",
@@ -98,18 +104,37 @@
   "home.column_settings.show_reblogs": "Geteilte Beiträge anzeigen",
   "home.column_settings.show_replies": "Antworten anzeigen",
   "home.settings": "Spalteneinstellungen",
+  "keyboard_shortcuts.back": "to navigate back",
+  "keyboard_shortcuts.boost": "to boost",
+  "keyboard_shortcuts.column": "to focus a status in one of the columns",
+  "keyboard_shortcuts.compose": "to focus the compose textarea",
+  "keyboard_shortcuts.description": "Description",
+  "keyboard_shortcuts.down": "to move down in the list",
+  "keyboard_shortcuts.enter": "to open status",
+  "keyboard_shortcuts.favourite": "to favourite",
+  "keyboard_shortcuts.heading": "Keyboard Shortcuts",
+  "keyboard_shortcuts.hotkey": "Hotkey",
+  "keyboard_shortcuts.legend": "to display this legend",
+  "keyboard_shortcuts.mention": "to mention author",
+  "keyboard_shortcuts.reply": "to reply",
+  "keyboard_shortcuts.search": "to focus search",
+  "keyboard_shortcuts.toot": "to start a brand new toot",
+  "keyboard_shortcuts.unfocus": "to un-focus compose textarea/search",
+  "keyboard_shortcuts.up": "to move up in the list",
   "lightbox.close": "Schließen",
   "lightbox.next": "Weiter",
   "lightbox.previous": "Zurück",
   "loading_indicator.label": "Wird geladen …",
   "media_gallery.toggle_visible": "Sichtbarkeit umschalten",
   "missing_indicator.label": "Nicht gefunden",
+  "mute_modal.hide_notifications": "Hide notifications from this user?",
   "navigation_bar.blocks": "Blockierte Profile",
   "navigation_bar.community_timeline": "Lokale Zeitleiste",
   "navigation_bar.edit_profile": "Profil bearbeiten",
   "navigation_bar.favourites": "Favoriten",
   "navigation_bar.follow_requests": "Folgeanfragen",
   "navigation_bar.info": "Über diese Instanz",
+  "navigation_bar.keyboard_shortcuts": "Keyboard shortcuts",
   "navigation_bar.logout": "Abmelden",
   "navigation_bar.mutes": "Stummgeschaltete Profile",
   "navigation_bar.pins": "Angeheftete Beiträge",
@@ -204,6 +229,7 @@
   "tabs_bar.home": "Startseite",
   "tabs_bar.local_timeline": "Lokal",
   "tabs_bar.notifications": "Mitteilungen",
+  "ui.beforeunload": "Your draft will be lost if you leave Mastodon.",
   "upload_area.title": "Zum Hochladen hereinziehen",
   "upload_button.label": "Mediendatei hinzufügen",
   "upload_form.description": "Für Menschen mit Sehbehinderung beschreiben",
diff --git a/app/javascript/mastodon/locales/defaultMessages.json b/app/javascript/mastodon/locales/defaultMessages.json
index f400b283f..be751589e 100644
--- a/app/javascript/mastodon/locales/defaultMessages.json
+++ b/app/javascript/mastodon/locales/defaultMessages.json
@@ -29,6 +29,14 @@
       {
         "defaultMessage": "Unmute @{name}",
         "id": "account.unmute"
+      },
+      {
+        "defaultMessage": "Mute notifications from @{name}",
+        "id": "account.mute_notifications"
+      },
+      {
+        "defaultMessage": "Unmute notifications from @{name}",
+        "id": "account.unmute_notifications"
       }
     ],
     "path": "app/javascript/mastodon/components/account.json"
@@ -284,16 +292,8 @@
         "id": "confirmations.block.confirm"
       },
       {
-        "defaultMessage": "Mute",
-        "id": "confirmations.mute.confirm"
-      },
-      {
         "defaultMessage": "Are you sure you want to block {name}?",
         "id": "confirmations.block.message"
-      },
-      {
-        "defaultMessage": "Are you sure you want to mute {name}?",
-        "id": "confirmations.mute.message"
       }
     ],
     "path": "app/javascript/mastodon/containers/status_container.json"
@@ -310,6 +310,15 @@
   {
     "descriptors": [
       {
+        "defaultMessage": "{name} has moved to:",
+        "id": "account.moved_to"
+      }
+    ],
+    "path": "app/javascript/mastodon/features/account_timeline/components/moved_note.json"
+  },
+  {
+    "descriptors": [
+      {
         "defaultMessage": "Unfollow",
         "id": "confirmations.unfollow.confirm"
       },
@@ -318,10 +327,6 @@
         "id": "confirmations.block.confirm"
       },
       {
-        "defaultMessage": "Mute",
-        "id": "confirmations.mute.confirm"
-      },
-      {
         "defaultMessage": "Hide entire domain",
         "id": "confirmations.domain_block.confirm"
       },
@@ -334,10 +339,6 @@
         "id": "confirmations.block.message"
       },
       {
-        "defaultMessage": "Are you sure you want to mute {name}?",
-        "id": "confirmations.mute.message"
-      },
-      {
         "defaultMessage": "Are you really, really sure you want to block the entire {domain}? In most cases a few targeted blocks or mutes are sufficient and preferable.",
         "id": "confirmations.domain_block.message"
       }
@@ -399,6 +400,14 @@
         "id": "account.unblock_domain"
       },
       {
+        "defaultMessage": "Hide boosts from @{name}",
+        "id": "account.hide_reblogs"
+      },
+      {
+        "defaultMessage": "Show boosts from @{name}",
+        "id": "account.show_reblogs"
+      },
+      {
         "defaultMessage": "Information below may reflect the user's profile incompletely.",
         "id": "account.disclaimer_full"
       },
@@ -849,6 +858,10 @@
         "id": "navigation_bar.pins"
       },
       {
+        "defaultMessage": "Keyboard shortcuts",
+        "id": "navigation_bar.keyboard_shortcuts"
+      },
+      {
         "defaultMessage": "FAQ",
         "id": "getting_started.faq"
       },
@@ -925,6 +938,88 @@
   {
     "descriptors": [
       {
+        "defaultMessage": "Keyboard Shortcuts",
+        "id": "keyboard_shortcuts.heading"
+      },
+      {
+        "defaultMessage": "Hotkey",
+        "id": "keyboard_shortcuts.hotkey"
+      },
+      {
+        "defaultMessage": "Description",
+        "id": "keyboard_shortcuts.description"
+      },
+      {
+        "defaultMessage": "to reply",
+        "id": "keyboard_shortcuts.reply"
+      },
+      {
+        "defaultMessage": "to mention author",
+        "id": "keyboard_shortcuts.mention"
+      },
+      {
+        "defaultMessage": "to favourite",
+        "id": "keyboard_shortcuts.favourite"
+      },
+      {
+        "defaultMessage": "to boost",
+        "id": "keyboard_shortcuts.boost"
+      },
+      {
+        "defaultMessage": "to open status",
+        "id": "keyboard_shortcuts.enter"
+      },
+      {
+        "defaultMessage": "to move up in the list",
+        "id": "keyboard_shortcuts.up"
+      },
+      {
+        "defaultMessage": "to move down in the list",
+        "id": "keyboard_shortcuts.down"
+      },
+      {
+        "defaultMessage": "to focus a status in one of the columns",
+        "id": "keyboard_shortcuts.column"
+      },
+      {
+        "defaultMessage": "to focus the compose textarea",
+        "id": "keyboard_shortcuts.compose"
+      },
+      {
+        "defaultMessage": "to start a brand new toot",
+        "id": "keyboard_shortcuts.toot"
+      },
+      {
+        "defaultMessage": "to navigate back",
+        "id": "keyboard_shortcuts.back"
+      },
+      {
+        "defaultMessage": "to focus search",
+        "id": "keyboard_shortcuts.search"
+      },
+      {
+        "defaultMessage": "to un-focus compose textarea/search",
+        "id": "keyboard_shortcuts.unfocus"
+      },
+      {
+        "defaultMessage": "to display this legend",
+        "id": "keyboard_shortcuts.legend"
+      }
+    ],
+    "path": "app/javascript/mastodon/features/keyboard_shortcuts/index.json"
+  },
+  {
+    "descriptors": [
+      {
+        "defaultMessage": "There is nothing in this list yet.",
+        "id": "empty_column.list"
+      }
+    ],
+    "path": "app/javascript/mastodon/features/list_timeline/index.json"
+  },
+  {
+    "descriptors": [
+      {
         "defaultMessage": "Muted users",
         "id": "column.mutes"
       }
@@ -1210,6 +1305,27 @@
   {
     "descriptors": [
       {
+        "defaultMessage": "Are you sure you want to mute {name}?",
+        "id": "confirmations.mute.message"
+      },
+      {
+        "defaultMessage": "Hide notifications from this user?",
+        "id": "mute_modal.hide_notifications"
+      },
+      {
+        "defaultMessage": "Cancel",
+        "id": "confirmation_modal.cancel"
+      },
+      {
+        "defaultMessage": "Mute",
+        "id": "confirmations.mute.confirm"
+      }
+    ],
+    "path": "app/javascript/mastodon/features/ui/components/mute_modal.json"
+  },
+  {
+    "descriptors": [
+      {
         "defaultMessage": "Home",
         "id": "column.home"
       },
@@ -1362,6 +1478,15 @@
   {
     "descriptors": [
       {
+        "defaultMessage": "Your draft will be lost if you leave Mastodon.",
+        "id": "ui.beforeunload"
+      }
+    ],
+    "path": "app/javascript/mastodon/features/ui/index.json"
+  },
+  {
+    "descriptors": [
+      {
         "defaultMessage": "Play",
         "id": "video.play"
       },
diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json
index 1d0bbcee5..4e0b838b0 100644
--- a/app/javascript/mastodon/locales/en.json
+++ b/app/javascript/mastodon/locales/en.json
@@ -7,17 +7,22 @@
   "account.followers": "Followers",
   "account.follows": "Follows",
   "account.follows_you": "Follows you",
+  "account.hide_reblogs": "Hide boosts from @{name}",
   "account.media": "Media",
   "account.mention": "Mention @{name}",
+  "account.moved_to": "{name} has moved to:",
   "account.mute": "Mute @{name}",
+  "account.mute_notifications": "Mute notifications from @{name}",
   "account.posts": "Posts",
   "account.report": "Report @{name}",
   "account.requested": "Awaiting approval. Click to cancel follow request",
   "account.share": "Share @{name}'s profile",
+  "account.show_reblogs": "Show boosts from @{name}",
   "account.unblock": "Unblock @{name}",
   "account.unblock_domain": "Unhide {domain}",
   "account.unfollow": "Unfollow",
   "account.unmute": "Unmute @{name}",
+  "account.unmute_notifications": "Unmute notifications from @{name}",
   "account.view_full_profile": "View full profile",
   "boost_modal.combo": "You can press {combo} to skip this next time",
   "bundle_column_error.body": "Something went wrong while loading this component.",
@@ -83,6 +88,7 @@
   "empty_column.hashtag": "There is nothing in this hashtag yet.",
   "empty_column.home": "Your home timeline is empty! Visit {public} or use search to get started and meet other users.",
   "empty_column.home.public_timeline": "the public timeline",
+  "empty_column.list": "There is nothing in this list yet.",
   "empty_column.notifications": "You don't have any notifications yet. Interact with others to start the conversation.",
   "empty_column.public": "There is nothing here! Write something publicly, or manually follow users from other instances to fill it up",
   "follow_request.authorize": "Authorize",
@@ -98,18 +104,37 @@
   "home.column_settings.show_reblogs": "Show boosts",
   "home.column_settings.show_replies": "Show replies",
   "home.settings": "Column settings",
+  "keyboard_shortcuts.back": "to navigate back",
+  "keyboard_shortcuts.boost": "to boost",
+  "keyboard_shortcuts.column": "to focus a status in one of the columns",
+  "keyboard_shortcuts.compose": "to focus the compose textarea",
+  "keyboard_shortcuts.description": "Description",
+  "keyboard_shortcuts.down": "to move down in the list",
+  "keyboard_shortcuts.enter": "to open status",
+  "keyboard_shortcuts.favourite": "to favourite",
+  "keyboard_shortcuts.heading": "Keyboard shortcuts",
+  "keyboard_shortcuts.hotkey": "Hotkey",
+  "keyboard_shortcuts.legend": "to display this legend",
+  "keyboard_shortcuts.mention": "to mention author",
+  "keyboard_shortcuts.reply": "to reply",
+  "keyboard_shortcuts.search": "to focus search",
+  "keyboard_shortcuts.toot": "to start a brand new toot",
+  "keyboard_shortcuts.unfocus": "to un-focus compose textarea/search",
+  "keyboard_shortcuts.up": "to move up in the list",
   "lightbox.close": "Close",
   "lightbox.next": "Next",
   "lightbox.previous": "Previous",
   "loading_indicator.label": "Loading...",
   "media_gallery.toggle_visible": "Toggle visibility",
   "missing_indicator.label": "Not found",
+  "mute_modal.hide_notifications": "Hide notifications from this user?",
   "navigation_bar.blocks": "Blocked users",
   "navigation_bar.community_timeline": "Local timeline",
   "navigation_bar.edit_profile": "Edit profile",
   "navigation_bar.favourites": "Favourites",
   "navigation_bar.follow_requests": "Follow requests",
   "navigation_bar.info": "About this instance",
+  "navigation_bar.keyboard_shortcuts": "Keyboard shortcuts",
   "navigation_bar.logout": "Logout",
   "navigation_bar.mutes": "Muted users",
   "navigation_bar.pins": "Pinned toots",
@@ -204,6 +229,7 @@
   "tabs_bar.home": "Home",
   "tabs_bar.local_timeline": "Local",
   "tabs_bar.notifications": "Notifications",
+  "ui.beforeunload": "Your draft will be lost if you leave Mastodon.",
   "upload_area.title": "Drag & drop to upload",
   "upload_button.label": "Add media",
   "upload_form.description": "Describe for the visually impaired",
diff --git a/app/javascript/mastodon/locales/eo.json b/app/javascript/mastodon/locales/eo.json
index 3f67a8fff..f9e32ba5e 100644
--- a/app/javascript/mastodon/locales/eo.json
+++ b/app/javascript/mastodon/locales/eo.json
@@ -7,17 +7,22 @@
   "account.followers": "Sekvantoj",
   "account.follows": "Sekvatoj",
   "account.follows_you": "Sekvas vin",
+  "account.hide_reblogs": "Hide boosts from @{name}",
   "account.media": "Sonbildaĵoj",
   "account.mention": "Mencii @{name}",
+  "account.moved_to": "{name} has moved to:",
   "account.mute": "Silentigi @{name}",
+  "account.mute_notifications": "Mute notifications from @{name}",
   "account.posts": "Mesaĝoj",
   "account.report": "Signali @{name}",
   "account.requested": "Atendas aprobon",
   "account.share": "Diskonigi la profilon de @{name}",
+  "account.show_reblogs": "Show boosts from @{name}",
   "account.unblock": "Malbloki @{name}",
   "account.unblock_domain": "Malkaŝi {domain}",
   "account.unfollow": "Ne plus sekvi",
   "account.unmute": "Malsilentigi @{name}",
+  "account.unmute_notifications": "Unmute notifications from @{name}",
   "account.view_full_profile": "Vidi plenan profilon",
   "boost_modal.combo": "La proksiman fojon, premu {combo} por pasigi",
   "bundle_column_error.body": "Io malfunkciis ŝargante tiun ĉi komponanton.",
@@ -83,6 +88,7 @@
   "empty_column.hashtag": "Ĝise, neniu enhavo estas asociita kun tiu kradvorto.",
   "empty_column.home": "Via hejma tempolinio estas malplena! Vizitu {public} aŭ uzu la serĉilon por renkonti aliajn uzantojn.",
   "empty_column.home.public_timeline": "la publika tempolinio",
+  "empty_column.list": "There is nothing in this list yet.",
   "empty_column.notifications": "Vi dume ne havas sciigojn. Interagi kun aliajn uzantojn por komenci la konversacion.",
   "empty_column.public": "Estas nenio ĉi tie! Publike skribu ion, aŭ mane sekvu uzantojn de aliaj instancoj por plenigi la publikan tempolinion.",
   "follow_request.authorize": "Akcepti",
@@ -98,18 +104,37 @@
   "home.column_settings.show_reblogs": "Montri diskonigojn",
   "home.column_settings.show_replies": "Montri respondojn",
   "home.settings": "Agordoj de la kolumno",
+  "keyboard_shortcuts.back": "to navigate back",
+  "keyboard_shortcuts.boost": "to boost",
+  "keyboard_shortcuts.column": "to focus a status in one of the columns",
+  "keyboard_shortcuts.compose": "to focus the compose textarea",
+  "keyboard_shortcuts.description": "Description",
+  "keyboard_shortcuts.down": "to move down in the list",
+  "keyboard_shortcuts.enter": "to open status",
+  "keyboard_shortcuts.favourite": "to favourite",
+  "keyboard_shortcuts.heading": "Keyboard Shortcuts",
+  "keyboard_shortcuts.hotkey": "Hotkey",
+  "keyboard_shortcuts.legend": "to display this legend",
+  "keyboard_shortcuts.mention": "to mention author",
+  "keyboard_shortcuts.reply": "to reply",
+  "keyboard_shortcuts.search": "to focus search",
+  "keyboard_shortcuts.toot": "to start a brand new toot",
+  "keyboard_shortcuts.unfocus": "to un-focus compose textarea/search",
+  "keyboard_shortcuts.up": "to move up in the list",
   "lightbox.close": "Fermi",
   "lightbox.next": "Malantaŭa",
   "lightbox.previous": "Antaŭa",
   "loading_indicator.label": "Ŝarganta…",
   "media_gallery.toggle_visible": "Baskuli videblecon",
   "missing_indicator.label": "Ne trovita",
+  "mute_modal.hide_notifications": "Hide notifications from this user?",
   "navigation_bar.blocks": "Blokitaj uzantoj",
   "navigation_bar.community_timeline": "Loka tempolinio",
   "navigation_bar.edit_profile": "Redakti la profilon",
   "navigation_bar.favourites": "Favoritaj",
   "navigation_bar.follow_requests": "Abonpetoj",
   "navigation_bar.info": "Plia informo",
+  "navigation_bar.keyboard_shortcuts": "Keyboard shortcuts",
   "navigation_bar.logout": "Elsaluti",
   "navigation_bar.mutes": "Silentigitaj uzantoj",
   "navigation_bar.pins": "Alpinglitaj pepoj",
@@ -204,6 +229,7 @@
   "tabs_bar.home": "Hejmo",
   "tabs_bar.local_timeline": "Loka tempolinio",
   "tabs_bar.notifications": "Sciigoj",
+  "ui.beforeunload": "Your draft will be lost if you leave Mastodon.",
   "upload_area.title": "Algliti por alŝuti",
   "upload_button.label": "Aldoni sonbildaĵon",
   "upload_form.description": "Priskribi por la misvidantaj",
diff --git a/app/javascript/mastodon/locales/es.json b/app/javascript/mastodon/locales/es.json
index 6e8e94700..38ba291e4 100644
--- a/app/javascript/mastodon/locales/es.json
+++ b/app/javascript/mastodon/locales/es.json
@@ -7,17 +7,22 @@
   "account.followers": "Seguidores",
   "account.follows": "Sigue",
   "account.follows_you": "Te sigue",
+  "account.hide_reblogs": "Hide boosts from @{name}",
   "account.media": "Media",
   "account.mention": "Mencionar a @{name}",
+  "account.moved_to": "{name} has moved to:",
   "account.mute": "Silenciar a @{name}",
+  "account.mute_notifications": "Mute notifications from @{name}",
   "account.posts": "Publicaciones",
   "account.report": "Reportar a @{name}",
   "account.requested": "Esperando aprobación",
   "account.share": "Compartir el perfil de @{name}",
+  "account.show_reblogs": "Show boosts from @{name}",
   "account.unblock": "Desbloquear a @{name}",
   "account.unblock_domain": "Mostrar a {domain}",
   "account.unfollow": "Dejar de seguir",
   "account.unmute": "Dejar de silenciar a @{name}",
+  "account.unmute_notifications": "Unmute notifications from @{name}",
   "account.view_full_profile": "Ver perfil completo",
   "boost_modal.combo": "Puedes presionar {combo} para saltear este aviso la próxima vez",
   "bundle_column_error.body": "Algo salió mal al cargar este componente.",
@@ -83,6 +88,7 @@
   "empty_column.hashtag": "No hay nada en este hashtag aún.",
   "empty_column.home": "No estás siguiendo a nadie aún. Visita {public} o haz búsquedas para empezar y conocer gente nueva.",
   "empty_column.home.public_timeline": "la línea de tiempo pública",
+  "empty_column.list": "There is nothing in this list yet.",
   "empty_column.notifications": "No tienes ninguna notificación aún. Interactúa con otros para empezar una conversación.",
   "empty_column.public": "¡No hay nada aquí! Escribe algo públicamente, o sigue usuarios de otras instancias manualmente para llenarlo.",
   "follow_request.authorize": "Autorizar",
@@ -98,18 +104,37 @@
   "home.column_settings.show_reblogs": "Mostrar retoots",
   "home.column_settings.show_replies": "Mostrar respuestas",
   "home.settings": "Ajustes de columna",
+  "keyboard_shortcuts.back": "to navigate back",
+  "keyboard_shortcuts.boost": "to boost",
+  "keyboard_shortcuts.column": "to focus a status in one of the columns",
+  "keyboard_shortcuts.compose": "to focus the compose textarea",
+  "keyboard_shortcuts.description": "Description",
+  "keyboard_shortcuts.down": "to move down in the list",
+  "keyboard_shortcuts.enter": "to open status",
+  "keyboard_shortcuts.favourite": "to favourite",
+  "keyboard_shortcuts.heading": "Keyboard Shortcuts",
+  "keyboard_shortcuts.hotkey": "Hotkey",
+  "keyboard_shortcuts.legend": "to display this legend",
+  "keyboard_shortcuts.mention": "to mention author",
+  "keyboard_shortcuts.reply": "to reply",
+  "keyboard_shortcuts.search": "to focus search",
+  "keyboard_shortcuts.toot": "to start a brand new toot",
+  "keyboard_shortcuts.unfocus": "to un-focus compose textarea/search",
+  "keyboard_shortcuts.up": "to move up in the list",
   "lightbox.close": "Cerrar",
   "lightbox.next": "Siguiente",
   "lightbox.previous": "Anterior",
   "loading_indicator.label": "Cargando…",
   "media_gallery.toggle_visible": "Cambiar visibilidad",
   "missing_indicator.label": "No encontrado",
+  "mute_modal.hide_notifications": "Hide notifications from this user?",
   "navigation_bar.blocks": "Usuarios bloqueados",
   "navigation_bar.community_timeline": "Historia local",
   "navigation_bar.edit_profile": "Editar perfil",
   "navigation_bar.favourites": "Favoritos",
   "navigation_bar.follow_requests": "Solicitudes para seguirte",
   "navigation_bar.info": "Información adicional",
+  "navigation_bar.keyboard_shortcuts": "Keyboard shortcuts",
   "navigation_bar.logout": "Cerrar sesión",
   "navigation_bar.mutes": "Usuarios silenciados",
   "navigation_bar.pins": "Toots fijados",
@@ -204,6 +229,7 @@
   "tabs_bar.home": "Inicio",
   "tabs_bar.local_timeline": "Local",
   "tabs_bar.notifications": "Notificaciones",
+  "ui.beforeunload": "Your draft will be lost if you leave Mastodon.",
   "upload_area.title": "Arrastra y suelta para subir",
   "upload_button.label": "Subir multimedia",
   "upload_form.description": "Describe for the visually impaired",
diff --git a/app/javascript/mastodon/locales/fa.json b/app/javascript/mastodon/locales/fa.json
index 995d1b5ae..dafb52bb5 100644
--- a/app/javascript/mastodon/locales/fa.json
+++ b/app/javascript/mastodon/locales/fa.json
@@ -7,17 +7,22 @@
   "account.followers": "پیگیران",
   "account.follows": "پی می‌گیرد",
   "account.follows_you": "پیگیر شماست",
+  "account.hide_reblogs": "Hide boosts from @{name}",
   "account.media": "رسانه",
   "account.mention": "نام‌بردن از @{name}",
+  "account.moved_to": "{name} has moved to:",
   "account.mute": "بی‌صدا کردن @{name}",
+  "account.mute_notifications": "Mute notifications from @{name}",
   "account.posts": "نوشته‌ها",
   "account.report": "گزارش @{name}",
   "account.requested": "در انتظار پذیرش",
   "account.share": "هم‌رسانی نمایهٔ @{name}",
+  "account.show_reblogs": "Show boosts from @{name}",
   "account.unblock": "رفع انسداد @{name}",
   "account.unblock_domain": "رفع پنهان‌سازی از {domain}",
   "account.unfollow": "پایان پیگیری",
   "account.unmute": "باصدا کردن @{name}",
+  "account.unmute_notifications": "Unmute notifications from @{name}",
   "account.view_full_profile": "نمایش نمایهٔ کامل",
   "boost_modal.combo": "دکمهٔ {combo} را بزنید تا دیگر این را نبینید",
   "bundle_column_error.body": "هنگام بازکردن این بخش خطایی رخ داد.",
@@ -83,6 +88,7 @@
   "empty_column.hashtag": "هنوز هیچ چیزی با این هشتگ نیست.",
   "empty_column.home": "شما هنوز پیگیر کسی نیستید. {public} را ببینید یا چیزی را جستجو کنید تا کاربران دیگر را ببینید.",
   "empty_column.home.public_timeline": "فهرست نوشته‌های همه‌جا",
+  "empty_column.list": "There is nothing in this list yet.",
   "empty_column.notifications": "هنوز هیچ اعلانی ندارید. به نوشته‌های دیگران واکنش نشان دهید تا گفتگو آغاز شود.",
   "empty_column.public": "این‌جا هنوز چیزی نیست! خودتان چیزی بنویسید یا کاربران دیگر را پی بگیرید تا این‌جا پر شود",
   "follow_request.authorize": "اجازه دهید",
@@ -98,18 +104,37 @@
   "home.column_settings.show_reblogs": "نمایش بازبوق‌ها",
   "home.column_settings.show_replies": "نمایش پاسخ‌ها",
   "home.settings": "تنظیمات ستون",
+  "keyboard_shortcuts.back": "to navigate back",
+  "keyboard_shortcuts.boost": "to boost",
+  "keyboard_shortcuts.column": "to focus a status in one of the columns",
+  "keyboard_shortcuts.compose": "to focus the compose textarea",
+  "keyboard_shortcuts.description": "Description",
+  "keyboard_shortcuts.down": "to move down in the list",
+  "keyboard_shortcuts.enter": "to open status",
+  "keyboard_shortcuts.favourite": "to favourite",
+  "keyboard_shortcuts.heading": "Keyboard Shortcuts",
+  "keyboard_shortcuts.hotkey": "Hotkey",
+  "keyboard_shortcuts.legend": "to display this legend",
+  "keyboard_shortcuts.mention": "to mention author",
+  "keyboard_shortcuts.reply": "to reply",
+  "keyboard_shortcuts.search": "to focus search",
+  "keyboard_shortcuts.toot": "to start a brand new toot",
+  "keyboard_shortcuts.unfocus": "to un-focus compose textarea/search",
+  "keyboard_shortcuts.up": "to move up in the list",
   "lightbox.close": "بستن",
   "lightbox.next": "بعدی",
   "lightbox.previous": "قبلی",
   "loading_indicator.label": "بارگیری...",
   "media_gallery.toggle_visible": "تغییر پیدایی",
   "missing_indicator.label": "پیدا نشد",
+  "mute_modal.hide_notifications": "Hide notifications from this user?",
   "navigation_bar.blocks": "کاربران مسدودشده",
   "navigation_bar.community_timeline": "نوشته‌های محلی",
   "navigation_bar.edit_profile": "ویرایش نمایه",
   "navigation_bar.favourites": "پسندیده‌ها",
   "navigation_bar.follow_requests": "درخواست‌های پیگیری",
   "navigation_bar.info": "اطلاعات تکمیلی",
+  "navigation_bar.keyboard_shortcuts": "Keyboard shortcuts",
   "navigation_bar.logout": "خروج",
   "navigation_bar.mutes": "کاربران بی‌صداشده",
   "navigation_bar.pins": "نوشته‌های ثابت",
@@ -204,6 +229,7 @@
   "tabs_bar.home": "خانه",
   "tabs_bar.local_timeline": "محلی",
   "tabs_bar.notifications": "اعلان‌ها",
+  "ui.beforeunload": "Your draft will be lost if you leave Mastodon.",
   "upload_area.title": "برای بارگذاری به این‌جا بکشید",
   "upload_button.label": "افزودن تصویر",
   "upload_form.description": "نوشتهٔ توضیحی برای کم‌بینایان و نابینایان",
diff --git a/app/javascript/mastodon/locales/fi.json b/app/javascript/mastodon/locales/fi.json
index af08be5d1..e3739eb68 100644
--- a/app/javascript/mastodon/locales/fi.json
+++ b/app/javascript/mastodon/locales/fi.json
@@ -7,17 +7,22 @@
   "account.followers": "Seuraajia",
   "account.follows": "Seuraa",
   "account.follows_you": "Seuraa sinua",
+  "account.hide_reblogs": "Hide boosts from @{name}",
   "account.media": "Media",
   "account.mention": "Mainitse @{name}",
+  "account.moved_to": "{name} has moved to:",
   "account.mute": "Mute @{name}",
+  "account.mute_notifications": "Mute notifications from @{name}",
   "account.posts": "Postit",
   "account.report": "Report @{name}",
   "account.requested": "Odottaa hyväksyntää",
   "account.share": "Share @{name}'s profile",
+  "account.show_reblogs": "Show boosts from @{name}",
   "account.unblock": "Salli @{name}",
   "account.unblock_domain": "Unhide {domain}",
   "account.unfollow": "Lopeta seuraaminen",
   "account.unmute": "Unmute @{name}",
+  "account.unmute_notifications": "Unmute notifications from @{name}",
   "account.view_full_profile": "View full profile",
   "boost_modal.combo": "You can press {combo} to skip this next time",
   "bundle_column_error.body": "Something went wrong while loading this component.",
@@ -83,6 +88,7 @@
   "empty_column.hashtag": "There is nothing in this hashtag yet.",
   "empty_column.home": "Your home timeline is empty! Visit {public} or use search to get started and meet other users.",
   "empty_column.home.public_timeline": "the public timeline",
+  "empty_column.list": "There is nothing in this list yet.",
   "empty_column.notifications": "You don't have any notifications yet. Interact with others to start the conversation.",
   "empty_column.public": "There is nothing here! Write something publicly, or manually follow users from other instances to fill it up",
   "follow_request.authorize": "Authorize",
@@ -98,18 +104,37 @@
   "home.column_settings.show_reblogs": "Show boosts",
   "home.column_settings.show_replies": "Show replies",
   "home.settings": "Column settings",
+  "keyboard_shortcuts.back": "to navigate back",
+  "keyboard_shortcuts.boost": "to boost",
+  "keyboard_shortcuts.column": "to focus a status in one of the columns",
+  "keyboard_shortcuts.compose": "to focus the compose textarea",
+  "keyboard_shortcuts.description": "Description",
+  "keyboard_shortcuts.down": "to move down in the list",
+  "keyboard_shortcuts.enter": "to open status",
+  "keyboard_shortcuts.favourite": "to favourite",
+  "keyboard_shortcuts.heading": "Keyboard Shortcuts",
+  "keyboard_shortcuts.hotkey": "Hotkey",
+  "keyboard_shortcuts.legend": "to display this legend",
+  "keyboard_shortcuts.mention": "to mention author",
+  "keyboard_shortcuts.reply": "to reply",
+  "keyboard_shortcuts.search": "to focus search",
+  "keyboard_shortcuts.toot": "to start a brand new toot",
+  "keyboard_shortcuts.unfocus": "to un-focus compose textarea/search",
+  "keyboard_shortcuts.up": "to move up in the list",
   "lightbox.close": "Sulje",
   "lightbox.next": "Next",
   "lightbox.previous": "Previous",
   "loading_indicator.label": "Ladataan...",
   "media_gallery.toggle_visible": "Toggle visibility",
   "missing_indicator.label": "Not found",
+  "mute_modal.hide_notifications": "Hide notifications from this user?",
   "navigation_bar.blocks": "Blocked users",
   "navigation_bar.community_timeline": "Paikallinen aikajana",
   "navigation_bar.edit_profile": "Muokkaa profiilia",
   "navigation_bar.favourites": "Favourites",
   "navigation_bar.follow_requests": "Follow requests",
   "navigation_bar.info": "Extended information",
+  "navigation_bar.keyboard_shortcuts": "Keyboard shortcuts",
   "navigation_bar.logout": "Kirjaudu ulos",
   "navigation_bar.mutes": "Muted users",
   "navigation_bar.pins": "Pinned toots",
@@ -204,6 +229,7 @@
   "tabs_bar.home": "Koti",
   "tabs_bar.local_timeline": "Local",
   "tabs_bar.notifications": "Ilmoitukset",
+  "ui.beforeunload": "Your draft will be lost if you leave Mastodon.",
   "upload_area.title": "Drag & drop to upload",
   "upload_button.label": "Lisää mediaa",
   "upload_form.description": "Describe for the visually impaired",
diff --git a/app/javascript/mastodon/locales/fr.json b/app/javascript/mastodon/locales/fr.json
index 219bf4da1..e4cabf52c 100644
--- a/app/javascript/mastodon/locales/fr.json
+++ b/app/javascript/mastodon/locales/fr.json
@@ -7,17 +7,22 @@
   "account.followers": "Abonné⋅e⋅s",
   "account.follows": "Abonnements",
   "account.follows_you": "Vous suit",
+  "account.hide_reblogs": "Hide boosts from @{name}",
   "account.media": "Média",
   "account.mention": "Mentionner",
+  "account.moved_to": "{name} has moved to:",
   "account.mute": "Masquer",
+  "account.mute_notifications": "Mute notifications from @{name}",
   "account.posts": "Statuts",
   "account.report": "Signaler",
   "account.requested": "Invitation envoyée",
   "account.share": "Partager le profil de @{name}",
+  "account.show_reblogs": "Show boosts from @{name}",
   "account.unblock": "Débloquer",
   "account.unblock_domain": "Ne plus masquer {domain}",
   "account.unfollow": "Ne plus suivre",
   "account.unmute": "Ne plus masquer",
+  "account.unmute_notifications": "Unmute notifications from @{name}",
   "account.view_full_profile": "Afficher le profil complet",
   "boost_modal.combo": "Vous pouvez appuyer sur {combo} pour pouvoir passer ceci, la prochaine fois",
   "bundle_column_error.body": "Une erreur s’est produite lors du chargement de ce composant.",
@@ -83,6 +88,7 @@
   "empty_column.hashtag": "Il n’y a encore aucun contenu associé à ce hashtag",
   "empty_column.home": "Vous ne suivez encore personne. Visitez {public} ou bien utilisez la recherche pour vous connecter à d’autres utilisateur⋅ice⋅s.",
   "empty_column.home.public_timeline": "le fil public",
+  "empty_column.list": "There is nothing in this list yet.",
   "empty_column.notifications": "Vous n’avez pas encore de notification. Interagissez avec d’autres utilisateur⋅ice⋅s pour débuter la conversation.",
   "empty_column.public": "Il n’y a rien ici ! Écrivez quelque chose publiquement, ou bien suivez manuellement des utilisateur⋅ice⋅s d’autres instances pour remplir le fil public.",
   "follow_request.authorize": "Accepter",
@@ -98,18 +104,37 @@
   "home.column_settings.show_reblogs": "Afficher les partages",
   "home.column_settings.show_replies": "Afficher les réponses",
   "home.settings": "Paramètres de la colonne",
+  "keyboard_shortcuts.back": "to navigate back",
+  "keyboard_shortcuts.boost": "to boost",
+  "keyboard_shortcuts.column": "to focus a status in one of the columns",
+  "keyboard_shortcuts.compose": "to focus the compose textarea",
+  "keyboard_shortcuts.description": "Description",
+  "keyboard_shortcuts.down": "to move down in the list",
+  "keyboard_shortcuts.enter": "to open status",
+  "keyboard_shortcuts.favourite": "to favourite",
+  "keyboard_shortcuts.heading": "Keyboard Shortcuts",
+  "keyboard_shortcuts.hotkey": "Hotkey",
+  "keyboard_shortcuts.legend": "to display this legend",
+  "keyboard_shortcuts.mention": "to mention author",
+  "keyboard_shortcuts.reply": "to reply",
+  "keyboard_shortcuts.search": "to focus search",
+  "keyboard_shortcuts.toot": "to start a brand new toot",
+  "keyboard_shortcuts.unfocus": "to un-focus compose textarea/search",
+  "keyboard_shortcuts.up": "to move up in the list",
   "lightbox.close": "Fermer",
   "lightbox.next": "Suivant",
   "lightbox.previous": "Précédent",
   "loading_indicator.label": "Chargement…",
   "media_gallery.toggle_visible": "Modifier la visibilité",
   "missing_indicator.label": "Non trouvé",
+  "mute_modal.hide_notifications": "Hide notifications from this user?",
   "navigation_bar.blocks": "Comptes bloqués",
   "navigation_bar.community_timeline": "Fil public local",
   "navigation_bar.edit_profile": "Modifier le profil",
   "navigation_bar.favourites": "Favoris",
   "navigation_bar.follow_requests": "Demandes de suivi",
   "navigation_bar.info": "Plus d’informations",
+  "navigation_bar.keyboard_shortcuts": "Keyboard shortcuts",
   "navigation_bar.logout": "Déconnexion",
   "navigation_bar.mutes": "Comptes masqués",
   "navigation_bar.pins": "Pouets épinglés",
@@ -204,6 +229,7 @@
   "tabs_bar.home": "Accueil",
   "tabs_bar.local_timeline": "Fil public local",
   "tabs_bar.notifications": "Notifications",
+  "ui.beforeunload": "Your draft will be lost if you leave Mastodon.",
   "upload_area.title": "Glissez et déposez pour envoyer",
   "upload_button.label": "Joindre un média",
   "upload_form.description": "Décrire pour les malvoyants",
diff --git a/app/javascript/mastodon/locales/he.json b/app/javascript/mastodon/locales/he.json
index a260f0968..fdebdf92a 100644
--- a/app/javascript/mastodon/locales/he.json
+++ b/app/javascript/mastodon/locales/he.json
@@ -7,17 +7,22 @@
   "account.followers": "עוקבים",
   "account.follows": "נעקבים",
   "account.follows_you": "במעקב אחריך",
+  "account.hide_reblogs": "Hide boosts from @{name}",
   "account.media": "מדיה",
   "account.mention": "אזכור של @{name}",
+  "account.moved_to": "{name} has moved to:",
   "account.mute": "להשתיק את @{name}",
+  "account.mute_notifications": "Mute notifications from @{name}",
   "account.posts": "הודעות",
   "account.report": "לדווח על @{name}",
   "account.requested": "בהמתנה לאישור",
   "account.share": "Share @{name}'s profile",
+  "account.show_reblogs": "Show boosts from @{name}",
   "account.unblock": "הסרת חסימה מעל @{name}",
   "account.unblock_domain": "הסר חסימה מקהילת {domain}",
   "account.unfollow": "הפסקת מעקב",
   "account.unmute": "הפסקת השתקת @{name}",
+  "account.unmute_notifications": "Unmute notifications from @{name}",
   "account.view_full_profile": "View full profile",
   "boost_modal.combo": "ניתן להקיש {combo} כדי לדלג בפעם הבאה",
   "bundle_column_error.body": "Something went wrong while loading this component.",
@@ -83,6 +88,7 @@
   "empty_column.hashtag": "אין כלום בהאשתג הזה עדיין.",
   "empty_column.home": "אף אחד לא במעקב עדיין. אפשר לבקר ב{public} או להשתמש בחיפוש כדי להתחיל ולהכיר חצוצרנים אחרים.",
   "empty_column.home.public_timeline": "ציר זמן בין-קהילתי",
+  "empty_column.list": "There is nothing in this list yet.",
   "empty_column.notifications": "אין התראות עדיין. יאללה, הגיע הזמן להתחיל להתערבב!",
   "empty_column.public": "אין פה כלום! כדי למלא את הטור הזה אפשר לכתוב משהו, או להתחיל לעקוב אחרי אנשים מקהילות אחרות.",
   "follow_request.authorize": "קבלה",
@@ -98,18 +104,37 @@
   "home.column_settings.show_reblogs": "הצגת הדהודים",
   "home.column_settings.show_replies": "הצגת תגובות",
   "home.settings": "הגדרות טור",
+  "keyboard_shortcuts.back": "to navigate back",
+  "keyboard_shortcuts.boost": "to boost",
+  "keyboard_shortcuts.column": "to focus a status in one of the columns",
+  "keyboard_shortcuts.compose": "to focus the compose textarea",
+  "keyboard_shortcuts.description": "Description",
+  "keyboard_shortcuts.down": "to move down in the list",
+  "keyboard_shortcuts.enter": "to open status",
+  "keyboard_shortcuts.favourite": "to favourite",
+  "keyboard_shortcuts.heading": "Keyboard Shortcuts",
+  "keyboard_shortcuts.hotkey": "Hotkey",
+  "keyboard_shortcuts.legend": "to display this legend",
+  "keyboard_shortcuts.mention": "to mention author",
+  "keyboard_shortcuts.reply": "to reply",
+  "keyboard_shortcuts.search": "to focus search",
+  "keyboard_shortcuts.toot": "to start a brand new toot",
+  "keyboard_shortcuts.unfocus": "to un-focus compose textarea/search",
+  "keyboard_shortcuts.up": "to move up in the list",
   "lightbox.close": "סגירה",
   "lightbox.next": "Next",
   "lightbox.previous": "Previous",
   "loading_indicator.label": "טוען...",
   "media_gallery.toggle_visible": "נראה\\בלתי נראה",
   "missing_indicator.label": "לא נמצא",
+  "mute_modal.hide_notifications": "Hide notifications from this user?",
   "navigation_bar.blocks": "חסימות",
   "navigation_bar.community_timeline": "ציר זמן מקומי",
   "navigation_bar.edit_profile": "עריכת פרופיל",
   "navigation_bar.favourites": "חיבובים",
   "navigation_bar.follow_requests": "בקשות מעקב",
   "navigation_bar.info": "מידע נוסף",
+  "navigation_bar.keyboard_shortcuts": "Keyboard shortcuts",
   "navigation_bar.logout": "יציאה",
   "navigation_bar.mutes": "השתקות",
   "navigation_bar.pins": "Pinned toots",
@@ -204,6 +229,7 @@
   "tabs_bar.home": "בבית",
   "tabs_bar.local_timeline": "ציר זמן מקומי",
   "tabs_bar.notifications": "התראות",
+  "ui.beforeunload": "Your draft will be lost if you leave Mastodon.",
   "upload_area.title": "ניתן להעלות על ידי Drag & drop",
   "upload_button.label": "הוספת מדיה",
   "upload_form.description": "Describe for the visually impaired",
diff --git a/app/javascript/mastodon/locales/hr.json b/app/javascript/mastodon/locales/hr.json
index 6ac7fc3b4..497917b9c 100644
--- a/app/javascript/mastodon/locales/hr.json
+++ b/app/javascript/mastodon/locales/hr.json
@@ -7,17 +7,22 @@
   "account.followers": "Sljedbenici",
   "account.follows": "Slijedi",
   "account.follows_you": "te slijedi",
+  "account.hide_reblogs": "Hide boosts from @{name}",
   "account.media": "Media",
   "account.mention": "Spomeni @{name}",
+  "account.moved_to": "{name} has moved to:",
   "account.mute": "Utišaj @{name}",
+  "account.mute_notifications": "Mute notifications from @{name}",
   "account.posts": "Postovi",
   "account.report": "Prijavi @{name}",
   "account.requested": "Čeka pristanak",
   "account.share": "Share @{name}'s profile",
+  "account.show_reblogs": "Show boosts from @{name}",
   "account.unblock": "Deblokiraj @{name}",
   "account.unblock_domain": "Poništi sakrivanje {domain}",
   "account.unfollow": "Prestani slijediti",
   "account.unmute": "Poništi utišavanje @{name}",
+  "account.unmute_notifications": "Unmute notifications from @{name}",
   "account.view_full_profile": "View full profile",
   "boost_modal.combo": "Možeš pritisnuti {combo} kako bi ovo preskočio sljedeći put",
   "bundle_column_error.body": "Something went wrong while loading this component.",
@@ -83,6 +88,7 @@
   "empty_column.hashtag": "Još ne postoji ništa s ovim hashtagom.",
   "empty_column.home": "Još ne slijediš nikoga. Posjeti {public} ili koristi tražilicu kako bi počeo i upoznao druge korisnike.",
   "empty_column.home.public_timeline": "javni timeline",
+  "empty_column.list": "There is nothing in this list yet.",
   "empty_column.notifications": "Još nemaš notifikacija. Komuniciraj sa drugima kako bi započeo razgovor.",
   "empty_column.public": "Ovdje nema ništa! Napiši nešto javno, ili ručno slijedi korisnike sa drugih instanci kako bi popunio",
   "follow_request.authorize": "Autoriziraj",
@@ -98,18 +104,37 @@
   "home.column_settings.show_reblogs": "Pokaži boostove",
   "home.column_settings.show_replies": "Pokaži odgovore",
   "home.settings": "Postavke Stupca",
+  "keyboard_shortcuts.back": "to navigate back",
+  "keyboard_shortcuts.boost": "to boost",
+  "keyboard_shortcuts.column": "to focus a status in one of the columns",
+  "keyboard_shortcuts.compose": "to focus the compose textarea",
+  "keyboard_shortcuts.description": "Description",
+  "keyboard_shortcuts.down": "to move down in the list",
+  "keyboard_shortcuts.enter": "to open status",
+  "keyboard_shortcuts.favourite": "to favourite",
+  "keyboard_shortcuts.heading": "Keyboard Shortcuts",
+  "keyboard_shortcuts.hotkey": "Hotkey",
+  "keyboard_shortcuts.legend": "to display this legend",
+  "keyboard_shortcuts.mention": "to mention author",
+  "keyboard_shortcuts.reply": "to reply",
+  "keyboard_shortcuts.search": "to focus search",
+  "keyboard_shortcuts.toot": "to start a brand new toot",
+  "keyboard_shortcuts.unfocus": "to un-focus compose textarea/search",
+  "keyboard_shortcuts.up": "to move up in the list",
   "lightbox.close": "Zatvori",
   "lightbox.next": "Next",
   "lightbox.previous": "Previous",
   "loading_indicator.label": "Učitavam...",
   "media_gallery.toggle_visible": "Preklopi vidljivost",
   "missing_indicator.label": "Nije nađen",
+  "mute_modal.hide_notifications": "Hide notifications from this user?",
   "navigation_bar.blocks": "Blokirani korisnici",
   "navigation_bar.community_timeline": "Lokalni timeline",
   "navigation_bar.edit_profile": "Uredi profil",
   "navigation_bar.favourites": "Favoriti",
   "navigation_bar.follow_requests": "Zahtjevi za slijeđenje",
   "navigation_bar.info": "Više informacija",
+  "navigation_bar.keyboard_shortcuts": "Keyboard shortcuts",
   "navigation_bar.logout": "Odjavi se",
   "navigation_bar.mutes": "Utišani korisnici",
   "navigation_bar.pins": "Pinned toots",
@@ -204,6 +229,7 @@
   "tabs_bar.home": "Dom",
   "tabs_bar.local_timeline": "Lokalno",
   "tabs_bar.notifications": "Notifikacije",
+  "ui.beforeunload": "Your draft will be lost if you leave Mastodon.",
   "upload_area.title": "Povuci i spusti kako bi uploadao",
   "upload_button.label": "Dodaj media",
   "upload_form.description": "Describe for the visually impaired",
diff --git a/app/javascript/mastodon/locales/hu.json b/app/javascript/mastodon/locales/hu.json
index 5892e606e..6df09aea9 100644
--- a/app/javascript/mastodon/locales/hu.json
+++ b/app/javascript/mastodon/locales/hu.json
@@ -7,17 +7,22 @@
   "account.followers": "Követők",
   "account.follows": "Követve",
   "account.follows_you": "Követnek téged",
+  "account.hide_reblogs": "Hide boosts from @{name}",
   "account.media": "Media",
   "account.mention": "Említés",
+  "account.moved_to": "{name} has moved to:",
   "account.mute": "Mute @{name}",
+  "account.mute_notifications": "Mute notifications from @{name}",
   "account.posts": "Posts",
   "account.report": "Report @{name}",
   "account.requested": "Awaiting approval",
   "account.share": "Share @{name}'s profile",
+  "account.show_reblogs": "Show boosts from @{name}",
   "account.unblock": "Blokkolás levétele",
   "account.unblock_domain": "Unhide {domain}",
   "account.unfollow": "Követés abbahagyása",
   "account.unmute": "Unmute @{name}",
+  "account.unmute_notifications": "Unmute notifications from @{name}",
   "account.view_full_profile": "View full profile",
   "boost_modal.combo": "You can press {combo} to skip this next time",
   "bundle_column_error.body": "Something went wrong while loading this component.",
@@ -83,6 +88,7 @@
   "empty_column.hashtag": "There is nothing in this hashtag yet.",
   "empty_column.home": "Your home timeline is empty! Visit {public} or use search to get started and meet other users.",
   "empty_column.home.public_timeline": "the public timeline",
+  "empty_column.list": "There is nothing in this list yet.",
   "empty_column.notifications": "You don't have any notifications yet. Interact with others to start the conversation.",
   "empty_column.public": "There is nothing here! Write something publicly, or manually follow users from other instances to fill it up",
   "follow_request.authorize": "Authorize",
@@ -98,18 +104,37 @@
   "home.column_settings.show_reblogs": "Show boosts",
   "home.column_settings.show_replies": "Show replies",
   "home.settings": "Column settings",
+  "keyboard_shortcuts.back": "to navigate back",
+  "keyboard_shortcuts.boost": "to boost",
+  "keyboard_shortcuts.column": "to focus a status in one of the columns",
+  "keyboard_shortcuts.compose": "to focus the compose textarea",
+  "keyboard_shortcuts.description": "Description",
+  "keyboard_shortcuts.down": "to move down in the list",
+  "keyboard_shortcuts.enter": "to open status",
+  "keyboard_shortcuts.favourite": "to favourite",
+  "keyboard_shortcuts.heading": "Keyboard Shortcuts",
+  "keyboard_shortcuts.hotkey": "Hotkey",
+  "keyboard_shortcuts.legend": "to display this legend",
+  "keyboard_shortcuts.mention": "to mention author",
+  "keyboard_shortcuts.reply": "to reply",
+  "keyboard_shortcuts.search": "to focus search",
+  "keyboard_shortcuts.toot": "to start a brand new toot",
+  "keyboard_shortcuts.unfocus": "to un-focus compose textarea/search",
+  "keyboard_shortcuts.up": "to move up in the list",
   "lightbox.close": "Bezárás",
   "lightbox.next": "Next",
   "lightbox.previous": "Previous",
   "loading_indicator.label": "Betöltés...",
   "media_gallery.toggle_visible": "Toggle visibility",
   "missing_indicator.label": "Not found",
+  "mute_modal.hide_notifications": "Hide notifications from this user?",
   "navigation_bar.blocks": "Blocked users",
   "navigation_bar.community_timeline": "Local timeline",
   "navigation_bar.edit_profile": "Profil szerkesztése",
   "navigation_bar.favourites": "Favourites",
   "navigation_bar.follow_requests": "Follow requests",
   "navigation_bar.info": "Extended information",
+  "navigation_bar.keyboard_shortcuts": "Keyboard shortcuts",
   "navigation_bar.logout": "Kijelentkezés",
   "navigation_bar.mutes": "Muted users",
   "navigation_bar.pins": "Pinned toots",
@@ -204,6 +229,7 @@
   "tabs_bar.home": "Kezdőlap",
   "tabs_bar.local_timeline": "Local",
   "tabs_bar.notifications": "Notifications",
+  "ui.beforeunload": "Your draft will be lost if you leave Mastodon.",
   "upload_area.title": "Drag & drop to upload",
   "upload_button.label": "Média hozzáadása",
   "upload_form.description": "Describe for the visually impaired",
diff --git a/app/javascript/mastodon/locales/id.json b/app/javascript/mastodon/locales/id.json
index f73ef0e19..66aff71f0 100644
--- a/app/javascript/mastodon/locales/id.json
+++ b/app/javascript/mastodon/locales/id.json
@@ -7,17 +7,22 @@
   "account.followers": "Pengikut",
   "account.follows": "Mengikuti",
   "account.follows_you": "Mengikuti anda",
+  "account.hide_reblogs": "Hide boosts from @{name}",
   "account.media": "Media",
   "account.mention": "Balasan @{name}",
+  "account.moved_to": "{name} has moved to:",
   "account.mute": "Bisukan @{name}",
+  "account.mute_notifications": "Mute notifications from @{name}",
   "account.posts": "Postingan",
   "account.report": "Laporkan @{name}",
   "account.requested": "Menunggu persetujuan",
   "account.share": "Share @{name}'s profile",
+  "account.show_reblogs": "Show boosts from @{name}",
   "account.unblock": "Hapus blokir @{name}",
   "account.unblock_domain": "Unhide {domain}",
   "account.unfollow": "Berhenti mengikuti",
   "account.unmute": "Berhenti membisukan @{name}",
+  "account.unmute_notifications": "Unmute notifications from @{name}",
   "account.view_full_profile": "View full profile",
   "boost_modal.combo": "Anda dapat menekan {combo} untuk melewati ini",
   "bundle_column_error.body": "Something went wrong while loading this component.",
@@ -83,6 +88,7 @@
   "empty_column.hashtag": "Tidak ada apapun dalam hashtag ini.",
   "empty_column.home": "Anda sedang tidak mengikuti siapapun. Kunjungi {public} atau gunakan pencarian untuk memulai dan bertemu pengguna lain.",
   "empty_column.home.public_timeline": "linimasa publik",
+  "empty_column.list": "There is nothing in this list yet.",
   "empty_column.notifications": "Anda tidak memiliki notifikasi apapun. Berinteraksi dengan orang lain untuk memulai percakapan.",
   "empty_column.public": "Tidak ada apapun disini! Tulis sesuatu, atau ikuti pengguna lain dari server lain untuk mengisinya secara manual",
   "follow_request.authorize": "Izinkan",
@@ -98,18 +104,37 @@
   "home.column_settings.show_reblogs": "Tampilkan Boost",
   "home.column_settings.show_replies": "Tampilkan balasan",
   "home.settings": "Pengaturan kolom",
+  "keyboard_shortcuts.back": "to navigate back",
+  "keyboard_shortcuts.boost": "to boost",
+  "keyboard_shortcuts.column": "to focus a status in one of the columns",
+  "keyboard_shortcuts.compose": "to focus the compose textarea",
+  "keyboard_shortcuts.description": "Description",
+  "keyboard_shortcuts.down": "to move down in the list",
+  "keyboard_shortcuts.enter": "to open status",
+  "keyboard_shortcuts.favourite": "to favourite",
+  "keyboard_shortcuts.heading": "Keyboard Shortcuts",
+  "keyboard_shortcuts.hotkey": "Hotkey",
+  "keyboard_shortcuts.legend": "to display this legend",
+  "keyboard_shortcuts.mention": "to mention author",
+  "keyboard_shortcuts.reply": "to reply",
+  "keyboard_shortcuts.search": "to focus search",
+  "keyboard_shortcuts.toot": "to start a brand new toot",
+  "keyboard_shortcuts.unfocus": "to un-focus compose textarea/search",
+  "keyboard_shortcuts.up": "to move up in the list",
   "lightbox.close": "Tutup",
   "lightbox.next": "Next",
   "lightbox.previous": "Previous",
   "loading_indicator.label": "Tunggu sebentar...",
   "media_gallery.toggle_visible": "Tampil/Sembunyikan",
   "missing_indicator.label": "Tidak ditemukan",
+  "mute_modal.hide_notifications": "Hide notifications from this user?",
   "navigation_bar.blocks": "Pengguna diblokir",
   "navigation_bar.community_timeline": "Linimasa lokal",
   "navigation_bar.edit_profile": "Ubah profil",
   "navigation_bar.favourites": "Favorit",
   "navigation_bar.follow_requests": "Permintaan mengikuti",
   "navigation_bar.info": "Informasi selengkapnya",
+  "navigation_bar.keyboard_shortcuts": "Keyboard shortcuts",
   "navigation_bar.logout": "Keluar",
   "navigation_bar.mutes": "Pengguna dibisukan",
   "navigation_bar.pins": "Pinned toots",
@@ -204,6 +229,7 @@
   "tabs_bar.home": "Beranda",
   "tabs_bar.local_timeline": "Lokal",
   "tabs_bar.notifications": "Notifikasi",
+  "ui.beforeunload": "Your draft will be lost if you leave Mastodon.",
   "upload_area.title": "Seret & lepaskan untuk mengunggah",
   "upload_button.label": "Tambahkan media",
   "upload_form.description": "Describe for the visually impaired",
diff --git a/app/javascript/mastodon/locales/io.json b/app/javascript/mastodon/locales/io.json
index 53371bece..673f895fe 100644
--- a/app/javascript/mastodon/locales/io.json
+++ b/app/javascript/mastodon/locales/io.json
@@ -7,17 +7,22 @@
   "account.followers": "Sequanti",
   "account.follows": "Sequas",
   "account.follows_you": "Sequas tu",
+  "account.hide_reblogs": "Hide boosts from @{name}",
   "account.media": "Media",
   "account.mention": "Mencionar @{name}",
+  "account.moved_to": "{name} has moved to:",
   "account.mute": "Celar @{name}",
+  "account.mute_notifications": "Mute notifications from @{name}",
   "account.posts": "Mesaji",
   "account.report": "Denuncar @{name}",
   "account.requested": "Vartante aprobo",
   "account.share": "Share @{name}'s profile",
+  "account.show_reblogs": "Show boosts from @{name}",
   "account.unblock": "Desblokusar @{name}",
   "account.unblock_domain": "Unhide {domain}",
   "account.unfollow": "Ne plus sequar",
   "account.unmute": "Ne plus celar @{name}",
+  "account.unmute_notifications": "Unmute notifications from @{name}",
   "account.view_full_profile": "View full profile",
   "boost_modal.combo": "Tu povas presar sur {combo} por omisar co en la venonta foyo",
   "bundle_column_error.body": "Something went wrong while loading this component.",
@@ -83,6 +88,7 @@
   "empty_column.hashtag": "Esas ankore nulo en ta gretovorto.",
   "empty_column.home": "Tu sequas ankore nulu. Vizitez {public} od uzez la serchilo por komencar e renkontrar altra uzeri.",
   "empty_column.home.public_timeline": "la publika tempolineo",
+  "empty_column.list": "There is nothing in this list yet.",
   "empty_column.notifications": "Tu havas ankore nula savigo. Komunikez kun altri por debutar la konverso.",
   "empty_column.public": "Esas nulo hike! Skribez ulo publike, o manuale sequez uzeri de altra instaluri por plenigar ol.",
   "follow_request.authorize": "Yurizar",
@@ -98,18 +104,37 @@
   "home.column_settings.show_reblogs": "Montrar repeti",
   "home.column_settings.show_replies": "Montrar respondi",
   "home.settings": "Aranji di la kolumno",
+  "keyboard_shortcuts.back": "to navigate back",
+  "keyboard_shortcuts.boost": "to boost",
+  "keyboard_shortcuts.column": "to focus a status in one of the columns",
+  "keyboard_shortcuts.compose": "to focus the compose textarea",
+  "keyboard_shortcuts.description": "Description",
+  "keyboard_shortcuts.down": "to move down in the list",
+  "keyboard_shortcuts.enter": "to open status",
+  "keyboard_shortcuts.favourite": "to favourite",
+  "keyboard_shortcuts.heading": "Keyboard Shortcuts",
+  "keyboard_shortcuts.hotkey": "Hotkey",
+  "keyboard_shortcuts.legend": "to display this legend",
+  "keyboard_shortcuts.mention": "to mention author",
+  "keyboard_shortcuts.reply": "to reply",
+  "keyboard_shortcuts.search": "to focus search",
+  "keyboard_shortcuts.toot": "to start a brand new toot",
+  "keyboard_shortcuts.unfocus": "to un-focus compose textarea/search",
+  "keyboard_shortcuts.up": "to move up in the list",
   "lightbox.close": "Klozar",
   "lightbox.next": "Next",
   "lightbox.previous": "Previous",
   "loading_indicator.label": "Kargante...",
   "media_gallery.toggle_visible": "Chanjar videbleso",
   "missing_indicator.label": "Ne trovita",
+  "mute_modal.hide_notifications": "Hide notifications from this user?",
   "navigation_bar.blocks": "Blokusita uzeri",
   "navigation_bar.community_timeline": "Lokala tempolineo",
   "navigation_bar.edit_profile": "Modifikar profilo",
   "navigation_bar.favourites": "Favorati",
   "navigation_bar.follow_requests": "Demandi di sequado",
   "navigation_bar.info": "Detaloza informi",
+  "navigation_bar.keyboard_shortcuts": "Keyboard shortcuts",
   "navigation_bar.logout": "Ekirar",
   "navigation_bar.mutes": "Celita uzeri",
   "navigation_bar.pins": "Pinned toots",
@@ -204,6 +229,7 @@
   "tabs_bar.home": "Hemo",
   "tabs_bar.local_timeline": "Lokala",
   "tabs_bar.notifications": "Savigi",
+  "ui.beforeunload": "Your draft will be lost if you leave Mastodon.",
   "upload_area.title": "Tranar faligar por kargar",
   "upload_button.label": "Adjuntar kontenajo",
   "upload_form.description": "Describe for the visually impaired",
diff --git a/app/javascript/mastodon/locales/it.json b/app/javascript/mastodon/locales/it.json
index 3873d797e..3d2d47471 100644
--- a/app/javascript/mastodon/locales/it.json
+++ b/app/javascript/mastodon/locales/it.json
@@ -7,17 +7,22 @@
   "account.followers": "Seguaci",
   "account.follows": "Segue",
   "account.follows_you": "Ti segue",
+  "account.hide_reblogs": "Hide boosts from @{name}",
   "account.media": "Media",
   "account.mention": "Menziona @{name}",
+  "account.moved_to": "{name} has moved to:",
   "account.mute": "Silenzia @{name}",
+  "account.mute_notifications": "Mute notifications from @{name}",
   "account.posts": "Posts",
   "account.report": "Segnala @{name}",
   "account.requested": "In attesa di approvazione",
   "account.share": "Share @{name}'s profile",
+  "account.show_reblogs": "Show boosts from @{name}",
   "account.unblock": "Sblocca @{name}",
   "account.unblock_domain": "Unhide {domain}",
   "account.unfollow": "Non seguire",
   "account.unmute": "Non silenziare @{name}",
+  "account.unmute_notifications": "Unmute notifications from @{name}",
   "account.view_full_profile": "View full profile",
   "boost_modal.combo": "Puoi premere {combo} per saltare questo passaggio la prossima volta",
   "bundle_column_error.body": "Something went wrong while loading this component.",
@@ -83,6 +88,7 @@
   "empty_column.hashtag": "Non c'è ancora nessun post con questo hashtag.",
   "empty_column.home": "Non stai ancora seguendo nessuno. Visita {public} o usa la ricerca per incontrare nuove persone.",
   "empty_column.home.public_timeline": "la timeline pubblica",
+  "empty_column.list": "There is nothing in this list yet.",
   "empty_column.notifications": "Non hai ancora nessuna notifica. Interagisci con altri per iniziare conversazioni.",
   "empty_column.public": "Qui non c'è nulla! Scrivi qualcosa pubblicamente, o aggiungi utenti da altri server per riempire questo spazio.",
   "follow_request.authorize": "Autorizza",
@@ -98,18 +104,37 @@
   "home.column_settings.show_reblogs": "Mostra post condivisi",
   "home.column_settings.show_replies": "Mostra risposte",
   "home.settings": "Impostazioni colonna",
+  "keyboard_shortcuts.back": "to navigate back",
+  "keyboard_shortcuts.boost": "to boost",
+  "keyboard_shortcuts.column": "to focus a status in one of the columns",
+  "keyboard_shortcuts.compose": "to focus the compose textarea",
+  "keyboard_shortcuts.description": "Description",
+  "keyboard_shortcuts.down": "to move down in the list",
+  "keyboard_shortcuts.enter": "to open status",
+  "keyboard_shortcuts.favourite": "to favourite",
+  "keyboard_shortcuts.heading": "Keyboard Shortcuts",
+  "keyboard_shortcuts.hotkey": "Hotkey",
+  "keyboard_shortcuts.legend": "to display this legend",
+  "keyboard_shortcuts.mention": "to mention author",
+  "keyboard_shortcuts.reply": "to reply",
+  "keyboard_shortcuts.search": "to focus search",
+  "keyboard_shortcuts.toot": "to start a brand new toot",
+  "keyboard_shortcuts.unfocus": "to un-focus compose textarea/search",
+  "keyboard_shortcuts.up": "to move up in the list",
   "lightbox.close": "Chiudi",
   "lightbox.next": "Next",
   "lightbox.previous": "Previous",
   "loading_indicator.label": "Carico...",
   "media_gallery.toggle_visible": "Imposta visibilità",
   "missing_indicator.label": "Non trovato",
+  "mute_modal.hide_notifications": "Hide notifications from this user?",
   "navigation_bar.blocks": "Utenti bloccati",
   "navigation_bar.community_timeline": "Timeline locale",
   "navigation_bar.edit_profile": "Modifica profilo",
   "navigation_bar.favourites": "Apprezzati",
   "navigation_bar.follow_requests": "Richieste di amicizia",
   "navigation_bar.info": "Informazioni estese",
+  "navigation_bar.keyboard_shortcuts": "Keyboard shortcuts",
   "navigation_bar.logout": "Logout",
   "navigation_bar.mutes": "Utenti silenziati",
   "navigation_bar.pins": "Pinned toots",
@@ -204,6 +229,7 @@
   "tabs_bar.home": "Home",
   "tabs_bar.local_timeline": "Locale",
   "tabs_bar.notifications": "Notifiche",
+  "ui.beforeunload": "Your draft will be lost if you leave Mastodon.",
   "upload_area.title": "Trascina per caricare",
   "upload_button.label": "Aggiungi file multimediale",
   "upload_form.description": "Describe for the visually impaired",
diff --git a/app/javascript/mastodon/locales/ja.json b/app/javascript/mastodon/locales/ja.json
index fb6d11ebe..388dd57e8 100644
--- a/app/javascript/mastodon/locales/ja.json
+++ b/app/javascript/mastodon/locales/ja.json
@@ -7,17 +7,22 @@
   "account.followers": "フォロワー",
   "account.follows": "フォロー",
   "account.follows_you": "フォローされています",
+  "account.hide_reblogs": "Hide boosts from @{name}",
   "account.media": "メディア",
   "account.mention": "返信",
+  "account.moved_to": "{name}さんは引っ越しました:",
   "account.mute": "ミュート",
+  "account.mute_notifications": "@{name}からの通知を受け取る",
   "account.posts": "投稿",
   "account.report": "通報",
   "account.requested": "承認待ち",
   "account.share": "@{name} のプロフィールを共有する",
+  "account.show_reblogs": "Show boosts from @{name}",
   "account.unblock": "ブロック解除",
   "account.unblock_domain": "{domain}を表示",
   "account.unfollow": "フォロー解除",
   "account.unmute": "ミュート解除",
+  "account.unmute_notifications": "@{name}からの通知を受け取らない",
   "account.view_full_profile": "全ての情報を見る",
   "boost_modal.combo": "次からは{combo}を押せば、これをスキップできます。",
   "bundle_column_error.body": "コンポーネントの読み込み中に問題が発生しました。",
@@ -83,6 +88,7 @@
   "empty_column.hashtag": "このハッシュタグはまだ使われていません。",
   "empty_column.home": "まだ誰もフォローしていません。{public}を見に行くか、検索を使って他のユーザーを見つけましょう。",
   "empty_column.home.public_timeline": "連合タイムライン",
+  "empty_column.list": "このリストにはまだなにもありません。",
   "empty_column.notifications": "まだ通知がありません。他の人とふれ合って会話を始めましょう。",
   "empty_column.public": "ここにはまだ何もありません!公開で何かを投稿したり、他のインスタンスのユーザーをフォローしたりしていっぱいにしましょう!",
   "follow_request.authorize": "許可",
@@ -98,18 +104,37 @@
   "home.column_settings.show_reblogs": "ブースト表示",
   "home.column_settings.show_replies": "返信表示",
   "home.settings": "カラム設定",
+  "keyboard_shortcuts.back": "戻る",
+  "keyboard_shortcuts.boost": "ブースト",
+  "keyboard_shortcuts.column": "左からn番目のカラム内最新トゥートに移動",
+  "keyboard_shortcuts.compose": "トゥート入力欄に移動",
+  "keyboard_shortcuts.description": "説明",
+  "keyboard_shortcuts.down": "カラム内一つ下に移動",
+  "keyboard_shortcuts.enter": "トゥートの詳細を表示",
+  "keyboard_shortcuts.favourite": "お気に入り",
+  "keyboard_shortcuts.heading": "キーボードショートカット一覧",
+  "keyboard_shortcuts.hotkey": "ホットキー",
+  "keyboard_shortcuts.legend": "この一覧を表示",
+  "keyboard_shortcuts.mention": "メンション",
+  "keyboard_shortcuts.reply": "返信",
+  "keyboard_shortcuts.search": "検索欄に移動",
+  "keyboard_shortcuts.toot": "新規トゥート",
+  "keyboard_shortcuts.unfocus": "トゥート入力欄・検索欄から離れる",
+  "keyboard_shortcuts.up": "カラム内一つ上に移動",
   "lightbox.close": "閉じる",
   "lightbox.next": "次",
   "lightbox.previous": "前",
   "loading_indicator.label": "読み込み中...",
   "media_gallery.toggle_visible": "表示切り替え",
   "missing_indicator.label": "見つかりません",
+  "mute_modal.hide_notifications": "このユーザーからの通知を隠しますか?",
   "navigation_bar.blocks": "ブロックしたユーザー",
   "navigation_bar.community_timeline": "ローカルタイムライン",
   "navigation_bar.edit_profile": "プロフィールを編集",
   "navigation_bar.favourites": "お気に入り",
   "navigation_bar.follow_requests": "フォローリクエスト",
   "navigation_bar.info": "このインスタンスについて",
+  "navigation_bar.keyboard_shortcuts": "キーボードショートカット",
   "navigation_bar.logout": "ログアウト",
   "navigation_bar.mutes": "ミュートしたユーザー",
   "navigation_bar.pins": "固定されたトゥート",
@@ -204,6 +229,7 @@
   "tabs_bar.home": "ホーム",
   "tabs_bar.local_timeline": "ローカル",
   "tabs_bar.notifications": "通知",
+  "ui.beforeunload": "Mastodonから離れるとあなたのドラフトは失われます。",
   "upload_area.title": "ドラッグ&ドロップでアップロード",
   "upload_button.label": "メディアを追加",
   "upload_form.description": "視覚障害者のための説明",
diff --git a/app/javascript/mastodon/locales/ko.json b/app/javascript/mastodon/locales/ko.json
index 2919928af..03028fc31 100644
--- a/app/javascript/mastodon/locales/ko.json
+++ b/app/javascript/mastodon/locales/ko.json
@@ -7,17 +7,22 @@
   "account.followers": "팔로워",
   "account.follows": "팔로우",
   "account.follows_you": "날 팔로우합니다",
+  "account.hide_reblogs": "Hide boosts from @{name}",
   "account.media": "미디어",
   "account.mention": "답장",
+  "account.moved_to": "{name} has moved to:",
   "account.mute": "뮤트",
+  "account.mute_notifications": "Mute notifications from @{name}",
   "account.posts": "포스트",
   "account.report": "신고",
   "account.requested": "승인 대기 중",
   "account.share": "Share @{name}'s profile",
+  "account.show_reblogs": "Show boosts from @{name}",
   "account.unblock": "차단 해제",
   "account.unblock_domain": "{domain} 숨김 해제",
   "account.unfollow": "팔로우 해제",
   "account.unmute": "뮤트 해제",
+  "account.unmute_notifications": "Unmute notifications from @{name}",
   "account.view_full_profile": "전체 프로필 보기",
   "boost_modal.combo": "다음부터 {combo}를 누르면 이 과정을 건너뛸 수 있습니다.",
   "bundle_column_error.body": "Something went wrong while loading this component.",
@@ -83,11 +88,12 @@
   "empty_column.hashtag": "이 해시태그는 아직 사용되지 않았습니다.",
   "empty_column.home": "아직 아무도 팔로우 하고 있지 않습니다. {public}를 보러 가거나, 검색하여 다른 사용자를 찾아 보세요.",
   "empty_column.home.public_timeline": "연합 타임라인",
+  "empty_column.list": "There is nothing in this list yet.",
   "empty_column.notifications": "아직 알림이 없습니다. 다른 사람과 대화를 시작해 보세요!",
   "empty_column.public": "여기엔 아직 아무 것도 없습니다! 공개적으로 무언가 포스팅하거나, 다른 인스턴스 유저를 팔로우 해서 가득 채워보세요!",
   "follow_request.authorize": "허가",
   "follow_request.reject": "거부",
-  "getting_started.appsshort": "어플리케이션",
+  "getting_started.appsshort": "애플리케이션",
   "getting_started.faq": "자주 있는 질문",
   "getting_started.heading": "시작",
   "getting_started.open_source_notice": "Mastodon은 오픈 소스 소프트웨어입니다. 누구나 GitHub({github})에서 개발에 참여하거나, 문제를 보고할 수 있습니다.",
@@ -98,18 +104,37 @@
   "home.column_settings.show_reblogs": "부스트 표시",
   "home.column_settings.show_replies": "답글 표시",
   "home.settings": "컬럼 설정",
+  "keyboard_shortcuts.back": "to navigate back",
+  "keyboard_shortcuts.boost": "to boost",
+  "keyboard_shortcuts.column": "to focus a status in one of the columns",
+  "keyboard_shortcuts.compose": "to focus the compose textarea",
+  "keyboard_shortcuts.description": "Description",
+  "keyboard_shortcuts.down": "to move down in the list",
+  "keyboard_shortcuts.enter": "to open status",
+  "keyboard_shortcuts.favourite": "to favourite",
+  "keyboard_shortcuts.heading": "Keyboard Shortcuts",
+  "keyboard_shortcuts.hotkey": "Hotkey",
+  "keyboard_shortcuts.legend": "to display this legend",
+  "keyboard_shortcuts.mention": "to mention author",
+  "keyboard_shortcuts.reply": "to reply",
+  "keyboard_shortcuts.search": "to focus search",
+  "keyboard_shortcuts.toot": "to start a brand new toot",
+  "keyboard_shortcuts.unfocus": "to un-focus compose textarea/search",
+  "keyboard_shortcuts.up": "to move up in the list",
   "lightbox.close": "닫기",
   "lightbox.next": "Next",
   "lightbox.previous": "Previous",
   "loading_indicator.label": "불러오는 중...",
   "media_gallery.toggle_visible": "표시 전환",
   "missing_indicator.label": "찾을 수 없습니다",
+  "mute_modal.hide_notifications": "Hide notifications from this user?",
   "navigation_bar.blocks": "차단한 사용자",
   "navigation_bar.community_timeline": "로컬 타임라인",
   "navigation_bar.edit_profile": "프로필 편집",
   "navigation_bar.favourites": "즐겨찾기",
   "navigation_bar.follow_requests": "팔로우 요청",
   "navigation_bar.info": "이 인스턴스에 대해서",
+  "navigation_bar.keyboard_shortcuts": "Keyboard shortcuts",
   "navigation_bar.logout": "로그아웃",
   "navigation_bar.mutes": "뮤트 중인 사용자",
   "navigation_bar.pins": "고정된 툿",
@@ -145,7 +170,7 @@
   "onboarding.page_six.github": "Mastodon는 오픈 소스 소프트웨어입니다. 버그 보고나 기능 추가 요청, 기여는 {github}에서 할 수 있습니다.",
   "onboarding.page_six.guidelines": "커뮤니티 가이드라인",
   "onboarding.page_six.read_guidelines": "{guidelines}을 확인하는 것을 잊지 마세요.",
-  "onboarding.page_six.various_app": "다양한 모바일 어플리케이션",
+  "onboarding.page_six.various_app": "다양한 모바일 애플리케이션",
   "onboarding.page_three.profile": "[프로필 편집] 에서 자기 소개나 이름을 변경할 수 있습니다. 또한 다른 설정도 변경할 수 있습니다.",
   "onboarding.page_three.search": "검색 바에서 {illustration} 나 {introductions} 와 같이 특정 해시태그가 달린 포스트를 보거나, 사용자를 찾을 수 있습니다.",
   "onboarding.page_two.compose": "이 폼에서 포스팅 할 수 있습니다. 이미지나 공개 범위 설정, 스포일러 경고 설정은 아래 아이콘으로 설정할 수 있습니다.",
@@ -204,6 +229,7 @@
   "tabs_bar.home": "홈",
   "tabs_bar.local_timeline": "로컬",
   "tabs_bar.notifications": "알림",
+  "ui.beforeunload": "Your draft will be lost if you leave Mastodon.",
   "upload_area.title": "드래그 & 드롭으로 업로드",
   "upload_button.label": "미디어 추가",
   "upload_form.description": "Describe for the visually impaired",
diff --git a/app/javascript/mastodon/locales/nl.json b/app/javascript/mastodon/locales/nl.json
index d0727a24d..218709899 100644
--- a/app/javascript/mastodon/locales/nl.json
+++ b/app/javascript/mastodon/locales/nl.json
@@ -7,17 +7,22 @@
   "account.followers": "Volgers",
   "account.follows": "Volgt",
   "account.follows_you": "Volgt jou",
+  "account.hide_reblogs": "Hide boosts from @{name}",
   "account.media": "Media",
   "account.mention": "Vermeld @{name}",
+  "account.moved_to": "{name} has moved to:",
   "account.mute": "Negeer @{name}",
+  "account.mute_notifications": "Mute notifications from @{name}",
   "account.posts": "Toots",
   "account.report": "Rapporteer @{name}",
   "account.requested": "Wacht op goedkeuring. Klik om volgverzoek te annuleren.",
   "account.share": "Profiel van @{name} delen",
+  "account.show_reblogs": "Show boosts from @{name}",
   "account.unblock": "Deblokkeer @{name}",
   "account.unblock_domain": "{domain} niet meer negeren",
   "account.unfollow": "Ontvolgen",
   "account.unmute": "@{name} niet meer negeren",
+  "account.unmute_notifications": "Unmute notifications from @{name}",
   "account.view_full_profile": "Volledig profiel tonen",
   "boost_modal.combo": "Je kunt {combo} klikken om dit de volgende keer over te slaan",
   "bundle_column_error.body": "Tijdens het laden van dit onderdeel is er iets fout gegaan.",
@@ -83,6 +88,7 @@
   "empty_column.hashtag": "Er is nog niks te vinden onder deze hashtag.",
   "empty_column.home": "Jij volgt nog niemand. Bezoek {public} of gebruik het zoekvenster om andere mensen te ontmoeten.",
   "empty_column.home.public_timeline": "de globale tijdlijn",
+  "empty_column.list": "There is nothing in this list yet.",
   "empty_column.notifications": "Je hebt nog geen meldingen. Heb interactie met andere mensen om het gesprek aan te gaan.",
   "empty_column.public": "Er is hier helemaal niks! Toot iets in het openbaar of volg mensen van andere Mastodon-servers om het te vullen.",
   "follow_request.authorize": "Goedkeuren",
@@ -98,18 +104,37 @@
   "home.column_settings.show_reblogs": "Boosts tonen",
   "home.column_settings.show_replies": "Reacties tonen",
   "home.settings": "Kolom-instellingen",
+  "keyboard_shortcuts.back": "to navigate back",
+  "keyboard_shortcuts.boost": "to boost",
+  "keyboard_shortcuts.column": "to focus a status in one of the columns",
+  "keyboard_shortcuts.compose": "to focus the compose textarea",
+  "keyboard_shortcuts.description": "Description",
+  "keyboard_shortcuts.down": "to move down in the list",
+  "keyboard_shortcuts.enter": "to open status",
+  "keyboard_shortcuts.favourite": "to favourite",
+  "keyboard_shortcuts.heading": "Keyboard Shortcuts",
+  "keyboard_shortcuts.hotkey": "Hotkey",
+  "keyboard_shortcuts.legend": "to display this legend",
+  "keyboard_shortcuts.mention": "to mention author",
+  "keyboard_shortcuts.reply": "to reply",
+  "keyboard_shortcuts.search": "to focus search",
+  "keyboard_shortcuts.toot": "to start a brand new toot",
+  "keyboard_shortcuts.unfocus": "to un-focus compose textarea/search",
+  "keyboard_shortcuts.up": "to move up in the list",
   "lightbox.close": "Sluiten",
   "lightbox.next": "Volgende",
   "lightbox.previous": "Vorige",
   "loading_indicator.label": "Laden…",
   "media_gallery.toggle_visible": "Media wel/niet tonen",
   "missing_indicator.label": "Niet gevonden",
+  "mute_modal.hide_notifications": "Hide notifications from this user?",
   "navigation_bar.blocks": "Geblokkeerde gebruikers",
   "navigation_bar.community_timeline": "Lokale tijdlijn",
   "navigation_bar.edit_profile": "Profiel bewerken",
   "navigation_bar.favourites": "Favorieten",
   "navigation_bar.follow_requests": "Volgverzoeken",
   "navigation_bar.info": "Uitgebreide informatie",
+  "navigation_bar.keyboard_shortcuts": "Keyboard shortcuts",
   "navigation_bar.logout": "Afmelden",
   "navigation_bar.mutes": "Genegeerde gebruikers",
   "navigation_bar.pins": "Vastgezette toots",
@@ -204,6 +229,7 @@
   "tabs_bar.home": "Start",
   "tabs_bar.local_timeline": "Lokaal",
   "tabs_bar.notifications": "Meldingen",
+  "ui.beforeunload": "Your draft will be lost if you leave Mastodon.",
   "upload_area.title": "Hierin slepen om te uploaden",
   "upload_button.label": "Media toevoegen",
   "upload_form.description": "Omschrijf dit voor mensen met een visuele beperking",
diff --git a/app/javascript/mastodon/locales/no.json b/app/javascript/mastodon/locales/no.json
index d74ae0091..234589d34 100644
--- a/app/javascript/mastodon/locales/no.json
+++ b/app/javascript/mastodon/locales/no.json
@@ -7,17 +7,22 @@
   "account.followers": "Følgere",
   "account.follows": "Følger",
   "account.follows_you": "Følger deg",
+  "account.hide_reblogs": "Hide boosts from @{name}",
   "account.media": "Media",
   "account.mention": "Nevn @{name}",
+  "account.moved_to": "{name} has moved to:",
   "account.mute": "Demp @{name}",
+  "account.mute_notifications": "Mute notifications from @{name}",
   "account.posts": "Innlegg",
   "account.report": "Rapportér @{name}",
   "account.requested": "Venter på godkjennelse",
   "account.share": "Share @{name}'s profile",
+  "account.show_reblogs": "Show boosts from @{name}",
   "account.unblock": "Avblokker @{name}",
   "account.unblock_domain": "Vis {domain}",
   "account.unfollow": "Avfølg",
   "account.unmute": "Avdemp @{name}",
+  "account.unmute_notifications": "Unmute notifications from @{name}",
   "account.view_full_profile": "View full profile",
   "boost_modal.combo": "You kan trykke {combo} for å hoppe over dette neste gang",
   "bundle_column_error.body": "Something went wrong while loading this component.",
@@ -83,6 +88,7 @@
   "empty_column.hashtag": "Det er ingenting i denne hashtagen ennå.",
   "empty_column.home": "Du har ikke fulgt noen ennå. Besøk {publlic} eller bruk søk for å komme i gang og møte andre brukere.",
   "empty_column.home.public_timeline": "en offentlig tidslinje",
+  "empty_column.list": "There is nothing in this list yet.",
   "empty_column.notifications": "Du har ingen varsler ennå. Kommuniser med andre for å begynne samtalen.",
   "empty_column.public": "Det er ingenting her! Skriv noe offentlig, eller følg brukere manuelt fra andre instanser for å fylle den opp",
   "follow_request.authorize": "Autorisér",
@@ -98,18 +104,37 @@
   "home.column_settings.show_reblogs": "Vis fremhevinger",
   "home.column_settings.show_replies": "Vis svar",
   "home.settings": "Kolonneinnstillinger",
+  "keyboard_shortcuts.back": "to navigate back",
+  "keyboard_shortcuts.boost": "to boost",
+  "keyboard_shortcuts.column": "to focus a status in one of the columns",
+  "keyboard_shortcuts.compose": "to focus the compose textarea",
+  "keyboard_shortcuts.description": "Description",
+  "keyboard_shortcuts.down": "to move down in the list",
+  "keyboard_shortcuts.enter": "to open status",
+  "keyboard_shortcuts.favourite": "to favourite",
+  "keyboard_shortcuts.heading": "Keyboard Shortcuts",
+  "keyboard_shortcuts.hotkey": "Hotkey",
+  "keyboard_shortcuts.legend": "to display this legend",
+  "keyboard_shortcuts.mention": "to mention author",
+  "keyboard_shortcuts.reply": "to reply",
+  "keyboard_shortcuts.search": "to focus search",
+  "keyboard_shortcuts.toot": "to start a brand new toot",
+  "keyboard_shortcuts.unfocus": "to un-focus compose textarea/search",
+  "keyboard_shortcuts.up": "to move up in the list",
   "lightbox.close": "Lukk",
   "lightbox.next": "Next",
   "lightbox.previous": "Previous",
   "loading_indicator.label": "Laster...",
   "media_gallery.toggle_visible": "Veksle synlighet",
   "missing_indicator.label": "Ikke funnet",
+  "mute_modal.hide_notifications": "Hide notifications from this user?",
   "navigation_bar.blocks": "Blokkerte brukere",
   "navigation_bar.community_timeline": "Lokal tidslinje",
   "navigation_bar.edit_profile": "Rediger profil",
   "navigation_bar.favourites": "Likt",
   "navigation_bar.follow_requests": "Følgeforespørsler",
   "navigation_bar.info": "Utvidet informasjon",
+  "navigation_bar.keyboard_shortcuts": "Keyboard shortcuts",
   "navigation_bar.logout": "Logg ut",
   "navigation_bar.mutes": "Dempede brukere",
   "navigation_bar.pins": "Pinned toots",
@@ -204,6 +229,7 @@
   "tabs_bar.home": "Hjem",
   "tabs_bar.local_timeline": "Lokal",
   "tabs_bar.notifications": "Varslinger",
+  "ui.beforeunload": "Your draft will be lost if you leave Mastodon.",
   "upload_area.title": "Dra og slipp for å laste opp",
   "upload_button.label": "Legg til media",
   "upload_form.description": "Describe for the visually impaired",
diff --git a/app/javascript/mastodon/locales/oc.json b/app/javascript/mastodon/locales/oc.json
index d826423b5..6e68d6810 100644
--- a/app/javascript/mastodon/locales/oc.json
+++ b/app/javascript/mastodon/locales/oc.json
@@ -7,17 +7,22 @@
   "account.followers": "Seguidors",
   "account.follows": "Abonaments",
   "account.follows_you": "Vos sèc",
+  "account.hide_reblogs": "Hide boosts from @{name}",
   "account.media": "Mèdias",
   "account.mention": "Mencionar @{name}",
+  "account.moved_to": "{name} has moved to:",
   "account.mute": "Rescondre @{name}",
+  "account.mute_notifications": "Mute notifications from @{name}",
   "account.posts": "Estatuts",
   "account.report": "Senhalar @{name}",
   "account.requested": "Invitacion mandada. Clicatz per anullar.",
   "account.share": "Partejar lo perfil a @{name}",
+  "account.show_reblogs": "Show boosts from @{name}",
   "account.unblock": "Desblocar @{name}",
   "account.unblock_domain": "Desblocar {domain}",
   "account.unfollow": "Quitar de sègre",
   "account.unmute": "Quitar de rescondre @{name}",
+  "account.unmute_notifications": "Unmute notifications from @{name}",
   "account.view_full_profile": "Veire lo perfil complet",
   "boost_modal.combo": "Podètz botar {combo} per passar aquò lo còp que ven",
   "bundle_column_error.body": "Quicòm a fach meuca pendent lo cargament d’aqueste compausant.",
@@ -83,6 +88,7 @@
   "empty_column.hashtag": "I a pas encara de contengut ligat a aqueste hashtag",
   "empty_column.home": "Vòstre flux d’acuèlh es void. Visitatz {public} o utilizatz la recèrca per vos connectar a d’autras personas.",
   "empty_column.home.public_timeline": "lo flux public",
+  "empty_column.list": "There is nothing in this list yet.",
   "empty_column.notifications": "Avètz pas encara de notificacions. Respondètz a qualqu’un per començar una conversacion.",
   "empty_column.public": "I a pas res aquí ! Escrivètz quicòm de public, o seguètz de personas d’autras instàncias per garnir lo flux public.",
   "follow_request.authorize": "Autorizar",
@@ -98,18 +104,37 @@
   "home.column_settings.show_reblogs": "Mostrar los partatges",
   "home.column_settings.show_replies": "Mostrar las responsas",
   "home.settings": "Paramètres de la colomna",
+  "keyboard_shortcuts.back": "to navigate back",
+  "keyboard_shortcuts.boost": "to boost",
+  "keyboard_shortcuts.column": "to focus a status in one of the columns",
+  "keyboard_shortcuts.compose": "to focus the compose textarea",
+  "keyboard_shortcuts.description": "Description",
+  "keyboard_shortcuts.down": "to move down in the list",
+  "keyboard_shortcuts.enter": "to open status",
+  "keyboard_shortcuts.favourite": "to favourite",
+  "keyboard_shortcuts.heading": "Keyboard Shortcuts",
+  "keyboard_shortcuts.hotkey": "Hotkey",
+  "keyboard_shortcuts.legend": "to display this legend",
+  "keyboard_shortcuts.mention": "to mention author",
+  "keyboard_shortcuts.reply": "to reply",
+  "keyboard_shortcuts.search": "to focus search",
+  "keyboard_shortcuts.toot": "to start a brand new toot",
+  "keyboard_shortcuts.unfocus": "to un-focus compose textarea/search",
+  "keyboard_shortcuts.up": "to move up in the list",
   "lightbox.close": "Tampar",
   "lightbox.next": "Seguent",
   "lightbox.previous": "Precedent",
   "loading_indicator.label": "Cargament…",
   "media_gallery.toggle_visible": "Modificar la visibilitat",
   "missing_indicator.label": "Pas trobat",
+  "mute_modal.hide_notifications": "Hide notifications from this user?",
   "navigation_bar.blocks": "Personas blocadas",
   "navigation_bar.community_timeline": "Flux public local",
   "navigation_bar.edit_profile": "Modificar lo perfil",
   "navigation_bar.favourites": "Favorits",
   "navigation_bar.follow_requests": "Demandas d'abonament",
   "navigation_bar.info": "Mai informacions",
+  "navigation_bar.keyboard_shortcuts": "Keyboard shortcuts",
   "navigation_bar.logout": "Desconnexion",
   "navigation_bar.mutes": "Personas rescondudas",
   "navigation_bar.pins": "Tuts penjats",
@@ -204,6 +229,7 @@
   "tabs_bar.home": "Acuèlh",
   "tabs_bar.local_timeline": "Flux public local",
   "tabs_bar.notifications": "Notificacions",
+  "ui.beforeunload": "Your draft will be lost if you leave Mastodon.",
   "upload_area.title": "Lisatz e depausatz per mandar",
   "upload_button.label": "Ajustar un mèdia",
   "upload_form.description": "Descripcion pels mal vesents",
diff --git a/app/javascript/mastodon/locales/pl.json b/app/javascript/mastodon/locales/pl.json
index b23a5e69f..907999940 100644
--- a/app/javascript/mastodon/locales/pl.json
+++ b/app/javascript/mastodon/locales/pl.json
@@ -7,17 +7,22 @@
   "account.followers": "Śledzący",
   "account.follows": "Śledzeni",
   "account.follows_you": "Śledzi Cię",
+  "account.hide_reblogs": "Ukryj podbicia od @{name}",
   "account.media": "Media",
   "account.mention": "Wspomnij o @{name}",
+  "account.moved_to": "{name} przeniósł się do:",
   "account.mute": "Wycisz @{name}",
+  "account.mute_notifications": "Wycisz powiadomienia o @{name}",
   "account.posts": "Wpisy",
   "account.report": "Zgłoś @{name}",
   "account.requested": "Oczekująca prośba, kliknij aby anulować",
   "account.share": "Udostępnij profil @{name}",
+  "account.show_reblogs": "Pokazuj podbicia od @{name}",
   "account.unblock": "Odblokuj @{name}",
   "account.unblock_domain": "Odblokuj domenę {domain}",
   "account.unfollow": "Przestań śledzić",
   "account.unmute": "Cofnij wyciszenie @{name}",
+  "account.unmute_notifications": "Cofnij wyciszenie powiadomień od @{name}",
   "account.view_full_profile": "Wyświetl pełny profil",
   "boost_modal.combo": "Naciśnij {combo}, aby pominąć to następnym razem",
   "bundle_column_error.body": "Coś poszło nie tak podczas ładowania tego składnika.",
@@ -83,6 +88,7 @@
   "empty_column.hashtag": "Nie ma wpisów oznaczonych tym hashtagiem. Możesz napisać pierwszy!",
   "empty_column.home": "Nie śledzisz nikogo. Odwiedź publiczną oś czasu lub użyj wyszukiwarki, aby znaleźć interesujące Cię profile.",
   "empty_column.home.public_timeline": "publiczna oś czasu",
+  "empty_column.list": "Nie ma nic na tej liście.",
   "empty_column.notifications": "Nie masz żadnych powiadomień. Rozpocznij interakcje z innymi użytkownikami.",
   "empty_column.public": "Tu nic nie ma! Napisz coś publicznie, lub dodaj ludzi z innych instancji, aby to wyświetlić.",
   "follow_request.authorize": "Autoryzuj",
@@ -98,18 +104,37 @@
   "home.column_settings.show_reblogs": "Pokazuj podbicia",
   "home.column_settings.show_replies": "Pokazuj odpowiedzi",
   "home.settings": "Ustawienia kolumny",
+  "keyboard_shortcuts.back": "aby cofnąć się",
+  "keyboard_shortcuts.boost": "aby podbić wpis",
+  "keyboard_shortcuts.column": "aby przejść do wpisu z jednej z kolumn",
+  "keyboard_shortcuts.compose": "aby przejść do pola tworzenia wpisu",
+  "keyboard_shortcuts.description": "Opis",
+  "keyboard_shortcuts.down": "aby przejść na dół listy",
+  "keyboard_shortcuts.enter": "aby otworzyć wpis",
+  "keyboard_shortcuts.favourite": "aby dodać do ulubionych",
+  "keyboard_shortcuts.heading": "Skróty klawiszowe",
+  "keyboard_shortcuts.hotkey": "Klawisz",
+  "keyboard_shortcuts.legend": "aby wyświetlić tą legendę",
+  "keyboard_shortcuts.mention": "aby wspomnieć o autorze",
+  "keyboard_shortcuts.reply": "aby odpowiedzieć",
+  "keyboard_shortcuts.search": "aby przejść do pola wyszukiwania",
+  "keyboard_shortcuts.toot": "aby utworzyć nowy wpis",
+  "keyboard_shortcuts.unfocus": "aby opuścić pole wyszukiwania/pisania",
+  "keyboard_shortcuts.up": "aby przejść na górę listy",
   "lightbox.close": "Zamknij",
   "lightbox.next": "Następne",
   "lightbox.previous": "Poprzednie",
   "loading_indicator.label": "Ładowanie…",
   "media_gallery.toggle_visible": "Przełącz widoczność",
   "missing_indicator.label": "Nie znaleziono",
+  "mute_modal.hide_notifications": "Chcesz ukryć powiadomienia od tego użytkownika?",
   "navigation_bar.blocks": "Zablokowani użytkownicy",
   "navigation_bar.community_timeline": "Lokalna oś czasu",
   "navigation_bar.edit_profile": "Edytuj profil",
   "navigation_bar.favourites": "Ulubione",
   "navigation_bar.follow_requests": "Prośby o śledzenie",
   "navigation_bar.info": "Szczegółowe informacje",
+  "navigation_bar.keyboard_shortcuts": "Skróty klawiszowe",
   "navigation_bar.logout": "Wyloguj",
   "navigation_bar.mutes": "Wyciszeni użytkownicy",
   "navigation_bar.pins": "Przypięte wpisy",
@@ -204,6 +229,7 @@
   "tabs_bar.home": "Strona główna",
   "tabs_bar.local_timeline": "Lokalne",
   "tabs_bar.notifications": "Powiadomienia",
+  "ui.beforeunload": "Utracisz tworzony wpis, jeżeli opuścisz Mastodona.",
   "upload_area.title": "Przeciągnij i upuść aby wysłać",
   "upload_button.label": "Dodaj zawartość multimedialną",
   "upload_form.description": "Wprowadź opis dla niewidomych i niedowidzących",
diff --git a/app/javascript/mastodon/locales/pt-BR.json b/app/javascript/mastodon/locales/pt-BR.json
index a04d1cc31..ba0d2093e 100644
--- a/app/javascript/mastodon/locales/pt-BR.json
+++ b/app/javascript/mastodon/locales/pt-BR.json
@@ -7,17 +7,22 @@
   "account.followers": "Seguidores",
   "account.follows": "Segue",
   "account.follows_you": "Segue você",
+  "account.hide_reblogs": "Hide boosts from @{name}",
   "account.media": "Mídia",
   "account.mention": "Mencionar @{name}",
+  "account.moved_to": "{name} has moved to:",
   "account.mute": "Silenciar @{name}",
+  "account.mute_notifications": "Mute notifications from @{name}",
   "account.posts": "Posts",
   "account.report": "Denunciar @{name}",
   "account.requested": "Aguardando aprovação. Clique para cancelar a solicitação.",
   "account.share": "Compartilhar perfil de @{name}",
+  "account.show_reblogs": "Show boosts from @{name}",
   "account.unblock": "Desbloquear @{name}",
   "account.unblock_domain": "Desbloquear {domain}",
   "account.unfollow": "Deixar de seguir",
   "account.unmute": "Não silenciar @{name}",
+  "account.unmute_notifications": "Unmute notifications from @{name}",
   "account.view_full_profile": "Ver perfil completo",
   "boost_modal.combo": "Você pode pressionar {combo} para ignorar este diálogo na próxima vez",
   "bundle_column_error.body": "Algo de errado aconteceu enquanto este componente era carregado.",
@@ -83,6 +88,7 @@
   "empty_column.hashtag": "Ainda não há qualquer conteúdo com essa hashtag",
   "empty_column.home": "Você ainda não segue usuário algo. Visite a timeline {public} ou use o buscador para procurar e conhecer outros usuários.",
   "empty_column.home.public_timeline": "global",
+  "empty_column.list": "There is nothing in this list yet.",
   "empty_column.notifications": "Você ainda não possui notificações. Interaja com outros usuários para começar a conversar!",
   "empty_column.public": "Não há nada aqui! Escreva algo publicamente ou siga manualmente usuários de outras instâncias.",
   "follow_request.authorize": "Autorizar",
@@ -98,18 +104,37 @@
   "home.column_settings.show_reblogs": "Mostrar compartilhamentos",
   "home.column_settings.show_replies": "Mostrar as respostas",
   "home.settings": "Configurações de colunas",
+  "keyboard_shortcuts.back": "to navigate back",
+  "keyboard_shortcuts.boost": "to boost",
+  "keyboard_shortcuts.column": "to focus a status in one of the columns",
+  "keyboard_shortcuts.compose": "to focus the compose textarea",
+  "keyboard_shortcuts.description": "Description",
+  "keyboard_shortcuts.down": "to move down in the list",
+  "keyboard_shortcuts.enter": "to open status",
+  "keyboard_shortcuts.favourite": "to favourite",
+  "keyboard_shortcuts.heading": "Keyboard Shortcuts",
+  "keyboard_shortcuts.hotkey": "Hotkey",
+  "keyboard_shortcuts.legend": "to display this legend",
+  "keyboard_shortcuts.mention": "to mention author",
+  "keyboard_shortcuts.reply": "to reply",
+  "keyboard_shortcuts.search": "to focus search",
+  "keyboard_shortcuts.toot": "to start a brand new toot",
+  "keyboard_shortcuts.unfocus": "to un-focus compose textarea/search",
+  "keyboard_shortcuts.up": "to move up in the list",
   "lightbox.close": "Fechar",
   "lightbox.next": "Próximo",
   "lightbox.previous": "Anterior",
   "loading_indicator.label": "Carregando...",
   "media_gallery.toggle_visible": "Esconder/Mostrar",
   "missing_indicator.label": "Não encontrado",
+  "mute_modal.hide_notifications": "Hide notifications from this user?",
   "navigation_bar.blocks": "Usuários bloqueados",
   "navigation_bar.community_timeline": "Local",
   "navigation_bar.edit_profile": "Editar perfil",
   "navigation_bar.favourites": "Favoritos",
   "navigation_bar.follow_requests": "Seguidores pendentes",
   "navigation_bar.info": "Mais informações",
+  "navigation_bar.keyboard_shortcuts": "Keyboard shortcuts",
   "navigation_bar.logout": "Sair",
   "navigation_bar.mutes": "Usuários silenciados",
   "navigation_bar.pins": "Postagens fixadas",
@@ -204,6 +229,7 @@
   "tabs_bar.home": "Página inicial",
   "tabs_bar.local_timeline": "Local",
   "tabs_bar.notifications": "Notificações",
+  "ui.beforeunload": "Your draft will be lost if you leave Mastodon.",
   "upload_area.title": "Arraste e solte para enviar",
   "upload_button.label": "Adicionar mídia",
   "upload_form.description": "Descreva a imagem para deficientes visuais",
diff --git a/app/javascript/mastodon/locales/pt.json b/app/javascript/mastodon/locales/pt.json
index 9ae140df9..f349bd191 100644
--- a/app/javascript/mastodon/locales/pt.json
+++ b/app/javascript/mastodon/locales/pt.json
@@ -7,17 +7,22 @@
   "account.followers": "Seguidores",
   "account.follows": "Segue",
   "account.follows_you": "É teu seguidor",
+  "account.hide_reblogs": "Hide boosts from @{name}",
   "account.media": "Media",
   "account.mention": "Mencionar @{name}",
+  "account.moved_to": "{name} has moved to:",
   "account.mute": "Silenciar @{name}",
+  "account.mute_notifications": "Mute notifications from @{name}",
   "account.posts": "Posts",
   "account.report": "Denunciar @{name}",
   "account.requested": "A aguardar aprovação",
   "account.share": "Share @{name}'s profile",
+  "account.show_reblogs": "Show boosts from @{name}",
   "account.unblock": "Não bloquear @{name}",
   "account.unblock_domain": "Unhide {domain}",
   "account.unfollow": "Deixar de seguir",
   "account.unmute": "Não silenciar @{name}",
+  "account.unmute_notifications": "Unmute notifications from @{name}",
   "account.view_full_profile": "View full profile",
   "boost_modal.combo": "Pode clicar {combo} para não voltar a ver",
   "bundle_column_error.body": "Something went wrong while loading this component.",
@@ -83,6 +88,7 @@
   "empty_column.hashtag": "Ainda não existe qualquer conteúdo com essa hashtag",
   "empty_column.home": "Ainda não segues qualquer utilizador. Visita {public} ou utiliza a pesquisa para procurar outros utilizadores.",
   "empty_column.home.public_timeline": "global",
+  "empty_column.list": "There is nothing in this list yet.",
   "empty_column.notifications": "Não tens notificações. Interage com outros utilizadores para iniciar uma conversa.",
   "empty_column.public": "Não há nada aqui! Escreve algo publicamente ou segue outros utilizadores para ver aqui os conteúdos públicos.",
   "follow_request.authorize": "Autorizar",
@@ -98,18 +104,37 @@
   "home.column_settings.show_reblogs": "Mostrar as partilhas",
   "home.column_settings.show_replies": "Mostrar as respostas",
   "home.settings": "Parâmetros da listagem",
+  "keyboard_shortcuts.back": "to navigate back",
+  "keyboard_shortcuts.boost": "to boost",
+  "keyboard_shortcuts.column": "to focus a status in one of the columns",
+  "keyboard_shortcuts.compose": "to focus the compose textarea",
+  "keyboard_shortcuts.description": "Description",
+  "keyboard_shortcuts.down": "to move down in the list",
+  "keyboard_shortcuts.enter": "to open status",
+  "keyboard_shortcuts.favourite": "to favourite",
+  "keyboard_shortcuts.heading": "Keyboard Shortcuts",
+  "keyboard_shortcuts.hotkey": "Hotkey",
+  "keyboard_shortcuts.legend": "to display this legend",
+  "keyboard_shortcuts.mention": "to mention author",
+  "keyboard_shortcuts.reply": "to reply",
+  "keyboard_shortcuts.search": "to focus search",
+  "keyboard_shortcuts.toot": "to start a brand new toot",
+  "keyboard_shortcuts.unfocus": "to un-focus compose textarea/search",
+  "keyboard_shortcuts.up": "to move up in the list",
   "lightbox.close": "Fechar",
   "lightbox.next": "Next",
   "lightbox.previous": "Previous",
   "loading_indicator.label": "Carregando...",
   "media_gallery.toggle_visible": "Esconder/Mostrar",
   "missing_indicator.label": "Não encontrado",
+  "mute_modal.hide_notifications": "Hide notifications from this user?",
   "navigation_bar.blocks": "Utilizadores bloqueados",
   "navigation_bar.community_timeline": "Local",
   "navigation_bar.edit_profile": "Editar perfil",
   "navigation_bar.favourites": "Favoritos",
   "navigation_bar.follow_requests": "Seguidores pendentes",
   "navigation_bar.info": "Mais informações",
+  "navigation_bar.keyboard_shortcuts": "Keyboard shortcuts",
   "navigation_bar.logout": "Sair",
   "navigation_bar.mutes": "Utilizadores silenciados",
   "navigation_bar.pins": "Pinned toots",
@@ -204,6 +229,7 @@
   "tabs_bar.home": "Home",
   "tabs_bar.local_timeline": "Local",
   "tabs_bar.notifications": "Notificações",
+  "ui.beforeunload": "Your draft will be lost if you leave Mastodon.",
   "upload_area.title": "Arraste e solte para enviar",
   "upload_button.label": "Adicionar media",
   "upload_form.description": "Describe for the visually impaired",
diff --git a/app/javascript/mastodon/locales/ru.json b/app/javascript/mastodon/locales/ru.json
index 65bc5b374..df52eca14 100644
--- a/app/javascript/mastodon/locales/ru.json
+++ b/app/javascript/mastodon/locales/ru.json
@@ -7,17 +7,22 @@
   "account.followers": "Подписаны",
   "account.follows": "Подписки",
   "account.follows_you": "Подписан(а) на Вас",
+  "account.hide_reblogs": "Hide boosts from @{name}",
   "account.media": "Медиаконтент",
   "account.mention": "Упомянуть",
+  "account.moved_to": "{name} has moved to:",
   "account.mute": "Заглушить",
+  "account.mute_notifications": "Mute notifications from @{name}",
   "account.posts": "Посты",
   "account.report": "Пожаловаться",
   "account.requested": "Ожидает подтверждения",
   "account.share": "Поделиться профилем @{name}",
+  "account.show_reblogs": "Show boosts from @{name}",
   "account.unblock": "Разблокировать",
   "account.unblock_domain": "Разблокировать {domain}",
   "account.unfollow": "Отписаться",
   "account.unmute": "Снять глушение",
+  "account.unmute_notifications": "Unmute notifications from @{name}",
   "account.view_full_profile": "Показать полный профиль",
   "boost_modal.combo": "Нажмите {combo}, чтобы пропустить это в следующий раз",
   "bundle_column_error.body": "Что-то пошло не так при загрузке этого компонента.",
@@ -83,6 +88,7 @@
   "empty_column.hashtag": "Статусов с таким хэштегом еще не существует.",
   "empty_column.home": "Пока Вы ни на кого не подписаны. Полистайте {public} или используйте поиск, чтобы освоиться и завести новые знакомства.",
   "empty_column.home.public_timeline": "публичные ленты",
+  "empty_column.list": "There is nothing in this list yet.",
   "empty_column.notifications": "У Вас еще нет уведомлений. Заведите знакомство с другими пользователями, чтобы начать разговор.",
   "empty_column.public": "Здесь ничего нет! Опубликуйте что-нибудь или подпишитесь на пользователей с других узлов, чтобы заполнить ленту.",
   "follow_request.authorize": "Авторизовать",
@@ -98,18 +104,37 @@
   "home.column_settings.show_reblogs": "Показывать продвижения",
   "home.column_settings.show_replies": "Показывать ответы",
   "home.settings": "Настройки колонки",
+  "keyboard_shortcuts.back": "to navigate back",
+  "keyboard_shortcuts.boost": "to boost",
+  "keyboard_shortcuts.column": "to focus a status in one of the columns",
+  "keyboard_shortcuts.compose": "to focus the compose textarea",
+  "keyboard_shortcuts.description": "Description",
+  "keyboard_shortcuts.down": "to move down in the list",
+  "keyboard_shortcuts.enter": "to open status",
+  "keyboard_shortcuts.favourite": "to favourite",
+  "keyboard_shortcuts.heading": "Keyboard Shortcuts",
+  "keyboard_shortcuts.hotkey": "Hotkey",
+  "keyboard_shortcuts.legend": "to display this legend",
+  "keyboard_shortcuts.mention": "to mention author",
+  "keyboard_shortcuts.reply": "to reply",
+  "keyboard_shortcuts.search": "to focus search",
+  "keyboard_shortcuts.toot": "to start a brand new toot",
+  "keyboard_shortcuts.unfocus": "to un-focus compose textarea/search",
+  "keyboard_shortcuts.up": "to move up in the list",
   "lightbox.close": "Закрыть",
   "lightbox.next": "Далее",
   "lightbox.previous": "Назад",
   "loading_indicator.label": "Загрузка...",
   "media_gallery.toggle_visible": "Показать/скрыть",
   "missing_indicator.label": "Не найдено",
+  "mute_modal.hide_notifications": "Hide notifications from this user?",
   "navigation_bar.blocks": "Список блокировки",
   "navigation_bar.community_timeline": "Локальная лента",
   "navigation_bar.edit_profile": "Изменить профиль",
   "navigation_bar.favourites": "Понравившееся",
   "navigation_bar.follow_requests": "Запросы на подписку",
   "navigation_bar.info": "Об узле",
+  "navigation_bar.keyboard_shortcuts": "Keyboard shortcuts",
   "navigation_bar.logout": "Выйти",
   "navigation_bar.mutes": "Список глушения",
   "navigation_bar.pins": "Pinned toots",
@@ -204,6 +229,7 @@
   "tabs_bar.home": "Главная",
   "tabs_bar.local_timeline": "Локальная",
   "tabs_bar.notifications": "Уведомления",
+  "ui.beforeunload": "Your draft will be lost if you leave Mastodon.",
   "upload_area.title": "Перетащите сюда, чтобы загрузить",
   "upload_button.label": "Добавить медиаконтент",
   "upload_form.description": "Описать для людей с нарушениями зрения",
diff --git a/app/javascript/mastodon/locales/sv.json b/app/javascript/mastodon/locales/sv.json
index 70beb70f7..5f887ef34 100644
--- a/app/javascript/mastodon/locales/sv.json
+++ b/app/javascript/mastodon/locales/sv.json
@@ -7,17 +7,22 @@
   "account.followers": "Följare",
   "account.follows": "Följer",
   "account.follows_you": "Följer dig",
+  "account.hide_reblogs": "Hide boosts from @{name}",
   "account.media": "Media",
   "account.mention": "Nämna @{name}",
+  "account.moved_to": "{name} has moved to:",
   "account.mute": "Tysta @{name}",
+  "account.mute_notifications": "Mute notifications from @{name}",
   "account.posts": "Inlägg",
   "account.report": "Rapportera @{name}",
   "account.requested": "Inväntar godkännande. Klicka för att avbryta följförfrågan",
   "account.share": "Dela @{name}'s profil",
+  "account.show_reblogs": "Show boosts from @{name}",
   "account.unblock": "Avblockera @{name}",
   "account.unblock_domain": "Ta fram {domain}",
   "account.unfollow": "Sluta följa",
   "account.unmute": "Ta bort tystad @{name}",
+  "account.unmute_notifications": "Unmute notifications from @{name}",
   "account.view_full_profile": "Visa hela profilen",
   "boost_modal.combo": "Du kan trycka {combo} för att slippa denna nästa gång",
   "bundle_column_error.body": "Något gick fel när du laddade denna komponent.",
@@ -82,8 +87,8 @@
   "empty_column.community": "Den lokala tidslinjen är tom. Skriv något offentligt för att få bollen att rulla!",
   "empty_column.hashtag": "Det finns inget i denna hashtag ännu.",
   "empty_column.home": "Din hemma-tidslinje är tom! Besök {public} eller använd sökning för att komma igång och träffa andra användare.",
-  "empty_column.home.inactivity": "Ditt hemmafeed är tomt. Om du har varit inaktiv ett tag kommer det att regenereras för dig snart.",
   "empty_column.home.public_timeline": "den publika tidslinjen",
+  "empty_column.list": "There is nothing in this list yet.",
   "empty_column.notifications": "Du har inga meddelanden än. Interagera med andra för att starta konversationen.",
   "empty_column.public": "Det finns inget här! Skriv något offentligt, eller följ manuellt användarna från andra instanser för att fylla på det",
   "follow_request.authorize": "Godkänn",
@@ -99,22 +104,40 @@
   "home.column_settings.show_reblogs": "Visa knuffar",
   "home.column_settings.show_replies": "Visa svar",
   "home.settings": "Kolumninställningar",
+  "keyboard_shortcuts.back": "to navigate back",
+  "keyboard_shortcuts.boost": "to boost",
+  "keyboard_shortcuts.column": "to focus a status in one of the columns",
+  "keyboard_shortcuts.compose": "to focus the compose textarea",
+  "keyboard_shortcuts.description": "Description",
+  "keyboard_shortcuts.down": "to move down in the list",
+  "keyboard_shortcuts.enter": "to open status",
+  "keyboard_shortcuts.favourite": "to favourite",
+  "keyboard_shortcuts.heading": "Keyboard Shortcuts",
+  "keyboard_shortcuts.hotkey": "Hotkey",
+  "keyboard_shortcuts.legend": "to display this legend",
+  "keyboard_shortcuts.mention": "to mention author",
+  "keyboard_shortcuts.reply": "to reply",
+  "keyboard_shortcuts.search": "to focus search",
+  "keyboard_shortcuts.toot": "to start a brand new toot",
+  "keyboard_shortcuts.unfocus": "to un-focus compose textarea/search",
+  "keyboard_shortcuts.up": "to move up in the list",
   "lightbox.close": "Stäng",
   "lightbox.next": "Nästa",
   "lightbox.previous": "Tidigare",
   "loading_indicator.label": "Laddar...",
   "media_gallery.toggle_visible": "Växla synlighet",
   "missing_indicator.label": "Hittades inte",
+  "mute_modal.hide_notifications": "Hide notifications from this user?",
   "navigation_bar.blocks": "Blockerade användare",
   "navigation_bar.community_timeline": "Lokal tidslinje",
   "navigation_bar.edit_profile": "Redigera profil",
   "navigation_bar.favourites": "Favoriter",
   "navigation_bar.follow_requests": "Följförfrågningar",
   "navigation_bar.info": "Om denna instans",
+  "navigation_bar.keyboard_shortcuts": "Keyboard shortcuts",
   "navigation_bar.logout": "Logga ut",
   "navigation_bar.mutes": "Tystade användare",
   "navigation_bar.pins": "Nålade inlägg (toots)",
-
   "navigation_bar.preferences": "Inställningar",
   "navigation_bar.public_timeline": "Förenad tidslinje",
   "notification.favourite": "{name} favoriserade din status",
@@ -161,6 +184,11 @@
   "privacy.public.short": "Publik",
   "privacy.unlisted.long": "Skicka inte till publik tidslinje",
   "privacy.unlisted.short": "Olistad",
+  "relative_time.days": "{number}d",
+  "relative_time.hours": "{number}h",
+  "relative_time.just_now": "now",
+  "relative_time.minutes": "{number}m",
+  "relative_time.seconds": "{number}s",
   "reply_indicator.cancel": "Ångra",
   "report.placeholder": "Ytterligare kommentarer",
   "report.submit": "Skicka",
@@ -180,6 +208,7 @@
   "status.load_more": "Ladda fler",
   "status.media_hidden": "Media dold",
   "status.mention": "Omnämn @{name}",
+  "status.more": "More",
   "status.mute_conversation": "Tysta konversation",
   "status.open": "Utvidga denna status",
   "status.pin": "Fäst i profil",
@@ -200,6 +229,7 @@
   "tabs_bar.home": "Hem",
   "tabs_bar.local_timeline": "Lokal",
   "tabs_bar.notifications": "Meddelanden",
+  "ui.beforeunload": "Your draft will be lost if you leave Mastodon.",
   "upload_area.title": "Dra & släpp för att ladda upp",
   "upload_button.label": "Lägg till media",
   "upload_form.description": "Beskriv för synskadade",
diff --git a/app/javascript/mastodon/locales/th.json b/app/javascript/mastodon/locales/th.json
index db3f3dd0d..7b2b13202 100644
--- a/app/javascript/mastodon/locales/th.json
+++ b/app/javascript/mastodon/locales/th.json
@@ -7,17 +7,22 @@
   "account.followers": "Followers",
   "account.follows": "Follows",
   "account.follows_you": "Follows you",
+  "account.hide_reblogs": "Hide boosts from @{name}",
   "account.media": "Media",
   "account.mention": "Mention @{name}",
+  "account.moved_to": "{name} has moved to:",
   "account.mute": "Mute @{name}",
+  "account.mute_notifications": "Mute notifications from @{name}",
   "account.posts": "Posts",
   "account.report": "Report @{name}",
   "account.requested": "Awaiting approval",
   "account.share": "Share @{name}'s profile",
+  "account.show_reblogs": "Show boosts from @{name}",
   "account.unblock": "Unblock @{name}",
   "account.unblock_domain": "Unhide {domain}",
   "account.unfollow": "Unfollow",
   "account.unmute": "Unmute @{name}",
+  "account.unmute_notifications": "Unmute notifications from @{name}",
   "account.view_full_profile": "View full profile",
   "boost_modal.combo": "You can press {combo} to skip this next time",
   "bundle_column_error.body": "Something went wrong while loading this component.",
@@ -83,6 +88,7 @@
   "empty_column.hashtag": "There is nothing in this hashtag yet.",
   "empty_column.home": "Your home timeline is empty! Visit {public} or use search to get started and meet other users.",
   "empty_column.home.public_timeline": "the public timeline",
+  "empty_column.list": "There is nothing in this list yet.",
   "empty_column.notifications": "You don't have any notifications yet. Interact with others to start the conversation.",
   "empty_column.public": "There is nothing here! Write something publicly, or manually follow users from other instances to fill it up",
   "follow_request.authorize": "Authorize",
@@ -98,18 +104,37 @@
   "home.column_settings.show_reblogs": "Show boosts",
   "home.column_settings.show_replies": "Show replies",
   "home.settings": "Column settings",
+  "keyboard_shortcuts.back": "to navigate back",
+  "keyboard_shortcuts.boost": "to boost",
+  "keyboard_shortcuts.column": "to focus a status in one of the columns",
+  "keyboard_shortcuts.compose": "to focus the compose textarea",
+  "keyboard_shortcuts.description": "Description",
+  "keyboard_shortcuts.down": "to move down in the list",
+  "keyboard_shortcuts.enter": "to open status",
+  "keyboard_shortcuts.favourite": "to favourite",
+  "keyboard_shortcuts.heading": "Keyboard Shortcuts",
+  "keyboard_shortcuts.hotkey": "Hotkey",
+  "keyboard_shortcuts.legend": "to display this legend",
+  "keyboard_shortcuts.mention": "to mention author",
+  "keyboard_shortcuts.reply": "to reply",
+  "keyboard_shortcuts.search": "to focus search",
+  "keyboard_shortcuts.toot": "to start a brand new toot",
+  "keyboard_shortcuts.unfocus": "to un-focus compose textarea/search",
+  "keyboard_shortcuts.up": "to move up in the list",
   "lightbox.close": "Close",
   "lightbox.next": "Next",
   "lightbox.previous": "Previous",
   "loading_indicator.label": "Loading...",
   "media_gallery.toggle_visible": "Toggle visibility",
   "missing_indicator.label": "Not found",
+  "mute_modal.hide_notifications": "Hide notifications from this user?",
   "navigation_bar.blocks": "Blocked users",
   "navigation_bar.community_timeline": "Local timeline",
   "navigation_bar.edit_profile": "Edit profile",
   "navigation_bar.favourites": "Favourites",
   "navigation_bar.follow_requests": "Follow requests",
   "navigation_bar.info": "About this instance",
+  "navigation_bar.keyboard_shortcuts": "Keyboard shortcuts",
   "navigation_bar.logout": "Logout",
   "navigation_bar.mutes": "Muted users",
   "navigation_bar.pins": "Pinned toots",
@@ -204,6 +229,7 @@
   "tabs_bar.home": "Home",
   "tabs_bar.local_timeline": "Local",
   "tabs_bar.notifications": "Notifications",
+  "ui.beforeunload": "Your draft will be lost if you leave Mastodon.",
   "upload_area.title": "Drag & drop to upload",
   "upload_button.label": "Add media",
   "upload_form.description": "Describe for the visually impaired",
diff --git a/app/javascript/mastodon/locales/tr.json b/app/javascript/mastodon/locales/tr.json
index cdd3581da..dd5a96bdb 100644
--- a/app/javascript/mastodon/locales/tr.json
+++ b/app/javascript/mastodon/locales/tr.json
@@ -7,17 +7,22 @@
   "account.followers": "Takipçiler",
   "account.follows": "Takip ettikleri",
   "account.follows_you": "Seni takip ediyor",
+  "account.hide_reblogs": "Hide boosts from @{name}",
   "account.media": "Media",
   "account.mention": "Bahset @{name}",
+  "account.moved_to": "{name} has moved to:",
   "account.mute": "Sustur @{name}",
+  "account.mute_notifications": "Mute notifications from @{name}",
   "account.posts": "Gönderiler",
   "account.report": "Rapor et @{name}",
   "account.requested": "Onay bekleniyor",
   "account.share": "Share @{name}'s profile",
+  "account.show_reblogs": "Show boosts from @{name}",
   "account.unblock": "Engeli kaldır @{name}",
   "account.unblock_domain": "Unhide {domain}",
   "account.unfollow": "Takipten vazgeç",
   "account.unmute": "Sesi aç @{name}",
+  "account.unmute_notifications": "Unmute notifications from @{name}",
   "account.view_full_profile": "View full profile",
   "boost_modal.combo": "Bir dahaki sefere {combo} tuşuna basabilirsiniz",
   "bundle_column_error.body": "Something went wrong while loading this component.",
@@ -83,6 +88,7 @@
   "empty_column.hashtag": "Henüz bu hashtag’e sahip hiçbir gönderi yok.",
   "empty_column.home": "Henüz kimseyi takip etmiyorsunuz. {public} ziyaret edebilir veya arama kısmını kullanarak diğer kullanıcılarla iletişime geçebilirsiniz.",
   "empty_column.home.public_timeline": "herkese açık zaman tüneli",
+  "empty_column.list": "There is nothing in this list yet.",
   "empty_column.notifications": "Henüz hiçbir bildiriminiz yok. Diğer insanlarla sobhet edebilmek için etkileşime geçebilirsiniz.",
   "empty_column.public": "Burada hiçbir gönderi yok! Herkese açık bir şeyler yazın, veya diğer sunucudaki insanları takip ederek bu alanın dolmasını sağlayın",
   "follow_request.authorize": "Yetkilendir",
@@ -98,18 +104,37 @@
   "home.column_settings.show_reblogs": "Boost edilenleri göster",
   "home.column_settings.show_replies": "Cevapları göster",
   "home.settings": "Kolon ayarları",
+  "keyboard_shortcuts.back": "to navigate back",
+  "keyboard_shortcuts.boost": "to boost",
+  "keyboard_shortcuts.column": "to focus a status in one of the columns",
+  "keyboard_shortcuts.compose": "to focus the compose textarea",
+  "keyboard_shortcuts.description": "Description",
+  "keyboard_shortcuts.down": "to move down in the list",
+  "keyboard_shortcuts.enter": "to open status",
+  "keyboard_shortcuts.favourite": "to favourite",
+  "keyboard_shortcuts.heading": "Keyboard Shortcuts",
+  "keyboard_shortcuts.hotkey": "Hotkey",
+  "keyboard_shortcuts.legend": "to display this legend",
+  "keyboard_shortcuts.mention": "to mention author",
+  "keyboard_shortcuts.reply": "to reply",
+  "keyboard_shortcuts.search": "to focus search",
+  "keyboard_shortcuts.toot": "to start a brand new toot",
+  "keyboard_shortcuts.unfocus": "to un-focus compose textarea/search",
+  "keyboard_shortcuts.up": "to move up in the list",
   "lightbox.close": "Kapat",
   "lightbox.next": "Next",
   "lightbox.previous": "Previous",
   "loading_indicator.label": "Yükleniyor...",
   "media_gallery.toggle_visible": "Görünürlüğü değiştir",
   "missing_indicator.label": "Bulunamadı",
+  "mute_modal.hide_notifications": "Hide notifications from this user?",
   "navigation_bar.blocks": "Engellenen kullanıcılar",
   "navigation_bar.community_timeline": "Yerel zaman tüneli",
   "navigation_bar.edit_profile": "Profili düzenle",
   "navigation_bar.favourites": "Favoriler",
   "navigation_bar.follow_requests": "Takip istekleri",
   "navigation_bar.info": "Genişletilmiş bilgi",
+  "navigation_bar.keyboard_shortcuts": "Keyboard shortcuts",
   "navigation_bar.logout": "Çıkış",
   "navigation_bar.mutes": "Sessize alınmış kullanıcılar",
   "navigation_bar.pins": "Pinned toots",
@@ -204,6 +229,7 @@
   "tabs_bar.home": "Ana sayfa",
   "tabs_bar.local_timeline": "Yerel",
   "tabs_bar.notifications": "Bildirimler",
+  "ui.beforeunload": "Your draft will be lost if you leave Mastodon.",
   "upload_area.title": "Upload için sürükle bırak yapınız",
   "upload_button.label": "Görsel ekle",
   "upload_form.description": "Describe for the visually impaired",
diff --git a/app/javascript/mastodon/locales/uk.json b/app/javascript/mastodon/locales/uk.json
index 930529f15..6206cd65b 100644
--- a/app/javascript/mastodon/locales/uk.json
+++ b/app/javascript/mastodon/locales/uk.json
@@ -7,17 +7,22 @@
   "account.followers": "Підписники",
   "account.follows": "Підписки",
   "account.follows_you": "Підписаний(-а) на Вас",
+  "account.hide_reblogs": "Hide boosts from @{name}",
   "account.media": "Медія",
   "account.mention": "Згадати",
+  "account.moved_to": "{name} has moved to:",
   "account.mute": "Заглушити",
+  "account.mute_notifications": "Mute notifications from @{name}",
   "account.posts": "Пости",
   "account.report": "Поскаржитися",
   "account.requested": "Очікує підтвердження",
   "account.share": "Share @{name}'s profile",
+  "account.show_reblogs": "Show boosts from @{name}",
   "account.unblock": "Розблокувати",
   "account.unblock_domain": "Розблокувати {domain}",
   "account.unfollow": "Відписатися",
   "account.unmute": "Зняти глушення",
+  "account.unmute_notifications": "Unmute notifications from @{name}",
   "account.view_full_profile": "View full profile",
   "boost_modal.combo": "Ви можете натиснути {combo}, щоб пропустити це наступного разу",
   "bundle_column_error.body": "Something went wrong while loading this component.",
@@ -83,6 +88,7 @@
   "empty_column.hashtag": "Дописів з цим хештегом поки не існує.",
   "empty_column.home": "Ви поки ні на кого не підписані. Погортайте {public}, або скористуйтесь пошуком, щоб освоїтися та познайомитися з іншими користувачами.",
   "empty_column.home.public_timeline": "публічні стрічки",
+  "empty_column.list": "There is nothing in this list yet.",
   "empty_column.notifications": "У вас ще немає сповіщень. Переписуйтесь з іншими користувачами, щоб почати розмову.",
   "empty_column.public": "Тут поки нічого немає! Опублікуйте щось, або вручну підпишіться на користувачів інших інстанцій, щоб заповнити стрічку.",
   "follow_request.authorize": "Авторизувати",
@@ -98,18 +104,37 @@
   "home.column_settings.show_reblogs": "Показувати передмухи",
   "home.column_settings.show_replies": "Показувати відповіді",
   "home.settings": "Налаштування колонок",
+  "keyboard_shortcuts.back": "to navigate back",
+  "keyboard_shortcuts.boost": "to boost",
+  "keyboard_shortcuts.column": "to focus a status in one of the columns",
+  "keyboard_shortcuts.compose": "to focus the compose textarea",
+  "keyboard_shortcuts.description": "Description",
+  "keyboard_shortcuts.down": "to move down in the list",
+  "keyboard_shortcuts.enter": "to open status",
+  "keyboard_shortcuts.favourite": "to favourite",
+  "keyboard_shortcuts.heading": "Keyboard Shortcuts",
+  "keyboard_shortcuts.hotkey": "Hotkey",
+  "keyboard_shortcuts.legend": "to display this legend",
+  "keyboard_shortcuts.mention": "to mention author",
+  "keyboard_shortcuts.reply": "to reply",
+  "keyboard_shortcuts.search": "to focus search",
+  "keyboard_shortcuts.toot": "to start a brand new toot",
+  "keyboard_shortcuts.unfocus": "to un-focus compose textarea/search",
+  "keyboard_shortcuts.up": "to move up in the list",
   "lightbox.close": "Закрити",
   "lightbox.next": "Next",
   "lightbox.previous": "Previous",
   "loading_indicator.label": "Завантаження...",
   "media_gallery.toggle_visible": "Показати/приховати",
   "missing_indicator.label": "Не знайдено",
+  "mute_modal.hide_notifications": "Hide notifications from this user?",
   "navigation_bar.blocks": "Заблоковані користувачі",
   "navigation_bar.community_timeline": "Локальна стрічка",
   "navigation_bar.edit_profile": "Редагувати профіль",
   "navigation_bar.favourites": "Вподобане",
   "navigation_bar.follow_requests": "Запити на підписку",
   "navigation_bar.info": "Про інстанцію",
+  "navigation_bar.keyboard_shortcuts": "Keyboard shortcuts",
   "navigation_bar.logout": "Вийти",
   "navigation_bar.mutes": "Заглушені користувачі",
   "navigation_bar.pins": "Pinned toots",
@@ -204,6 +229,7 @@
   "tabs_bar.home": "Головна",
   "tabs_bar.local_timeline": "Локальна",
   "tabs_bar.notifications": "Сповіщення",
+  "ui.beforeunload": "Your draft will be lost if you leave Mastodon.",
   "upload_area.title": "Перетягніть сюди, щоб завантажити",
   "upload_button.label": "Додати медіаконтент",
   "upload_form.description": "Describe for the visually impaired",
diff --git a/app/javascript/mastodon/locales/whitelist_sv.json b/app/javascript/mastodon/locales/whitelist_sv.json
new file mode 100644
index 000000000..0d4f101c7
--- /dev/null
+++ b/app/javascript/mastodon/locales/whitelist_sv.json
@@ -0,0 +1,2 @@
+[
+]
diff --git a/app/javascript/mastodon/locales/zh-CN.json b/app/javascript/mastodon/locales/zh-CN.json
index 0f4bbb7c1..c23868cda 100644
--- a/app/javascript/mastodon/locales/zh-CN.json
+++ b/app/javascript/mastodon/locales/zh-CN.json
@@ -1,23 +1,28 @@
 {
   "account.block": "屏蔽 @{name}",
-  "account.block_domain": "隐藏一切来自 {domain} 的嘟文",
+  "account.block_domain": "隐藏来自 {domain} 的内容",
   "account.disclaimer_full": "此处显示的信息可能不是全部内容。",
   "account.edit_profile": "修改个人资料",
   "account.follow": "关注",
   "account.followers": "关注者",
   "account.follows": "正在关注",
   "account.follows_you": "关注了你",
+  "account.hide_reblogs": "Hide boosts from @{name}",
   "account.media": "媒体",
   "account.mention": "提及 @{name}",
-  "account.mute": "静音 @{name}",
+  "account.moved_to": "{name} 已经迁移到:",
+  "account.mute": "隐藏 @{name}",
+  "account.mute_notifications": "隐藏来自 @{name} 的通知",
   "account.posts": "嘟文",
   "account.report": "举报 @{name}",
   "account.requested": "正在等待对方同意。点击以取消发送关注请求",
   "account.share": "分享 @{name} 的个人资料",
+  "account.show_reblogs": "Show boosts from @{name}",
   "account.unblock": "不再屏蔽 @{name}",
-  "account.unblock_domain": "不再隐藏 {domain}",
+  "account.unblock_domain": "不再隐藏来自 {domain} 的内容",
   "account.unfollow": "取消关注",
-  "account.unmute": "不再静音 @{name}",
+  "account.unmute": "不再隐藏 @{name}",
+  "account.unmute_notifications": "不再隐藏来自 @{name} 的通知",
   "account.view_full_profile": "查看完整资料",
   "boost_modal.combo": "下次按住 {combo} 即可跳过此提示",
   "bundle_column_error.body": "载入组件出错。",
@@ -31,7 +36,7 @@
   "column.favourites": "收藏过的嘟文",
   "column.follow_requests": "关注请求",
   "column.home": "主页",
-  "column.mutes": "被静音的用户",
+  "column.mutes": "被隐藏的用户",
   "column.notifications": "通知",
   "column.pins": "置顶嘟文",
   "column.public": "跨站公共时间轴",
@@ -57,10 +62,10 @@
   "confirmations.block.message": "想好了,真的要屏蔽 {name}?",
   "confirmations.delete.confirm": "删除",
   "confirmations.delete.message": "想好了,真的要删除这条嘟文?",
-  "confirmations.domain_block.confirm": "隐藏整个网站",
-  "confirmations.domain_block.message": "你真的真的确定要隐藏整个 {domain}?多数情况下,屏蔽或静音几个特定的用户就应该能满足你的需要了。",
-  "confirmations.mute.confirm": "静音",
-  "confirmations.mute.message": "想好了,真的要静音 {name}?",
+  "confirmations.domain_block.confirm": "隐藏整个网站的内容",
+  "confirmations.domain_block.message": "你真的真的确定要隐藏所有来自 {domain} 的内容吗?多数情况下,屏蔽或隐藏几个特定的用户就应该能满足你的需要了。",
+  "confirmations.mute.confirm": "隐藏",
+  "confirmations.mute.message": "想好了,真的要隐藏 {name}?",
   "confirmations.unfollow.confirm": "取消关注",
   "confirmations.unfollow.message": "确定要取消关注 {name} 吗?",
   "embed.instructions": "要在你的网站上嵌入这条嘟文,请复制以下代码。",
@@ -83,6 +88,7 @@
   "empty_column.hashtag": "这个话题标签下暂时没有内容。",
   "empty_column.home": "你还没有关注任何用户。快看看{public},向其他用户搭讪吧。",
   "empty_column.home.public_timeline": "公共时间轴",
+  "empty_column.list": "这个列表中暂时没有内容。",
   "empty_column.notifications": "你还没有收到过通知信息,快向其他用户搭讪吧。",
   "empty_column.public": "这里神马都没有!写一些公开的嘟文,或者关注其他实例的用户,这里就会有嘟文出现了哦!",
   "follow_request.authorize": "同意",
@@ -98,20 +104,39 @@
   "home.column_settings.show_reblogs": "显示转嘟",
   "home.column_settings.show_replies": "显示回复",
   "home.settings": "栏目设置",
+  "keyboard_shortcuts.back": "to navigate back",
+  "keyboard_shortcuts.boost": "to boost",
+  "keyboard_shortcuts.column": "to focus a status in one of the columns",
+  "keyboard_shortcuts.compose": "to focus the compose textarea",
+  "keyboard_shortcuts.description": "Description",
+  "keyboard_shortcuts.down": "to move down in the list",
+  "keyboard_shortcuts.enter": "to open status",
+  "keyboard_shortcuts.favourite": "to favourite",
+  "keyboard_shortcuts.heading": "Keyboard Shortcuts",
+  "keyboard_shortcuts.hotkey": "Hotkey",
+  "keyboard_shortcuts.legend": "to display this legend",
+  "keyboard_shortcuts.mention": "to mention author",
+  "keyboard_shortcuts.reply": "to reply",
+  "keyboard_shortcuts.search": "to focus search",
+  "keyboard_shortcuts.toot": "to start a brand new toot",
+  "keyboard_shortcuts.unfocus": "to un-focus compose textarea/search",
+  "keyboard_shortcuts.up": "to move up in the list",
   "lightbox.close": "关闭",
   "lightbox.next": "下一步",
   "lightbox.previous": "上一步",
   "loading_indicator.label": "加载中……",
   "media_gallery.toggle_visible": "切换显示/隐藏",
   "missing_indicator.label": "找不到内容",
+  "mute_modal.hide_notifications": "隐藏来自这个用户的通知",
   "navigation_bar.blocks": "被屏蔽的用户",
   "navigation_bar.community_timeline": "本站时间轴",
   "navigation_bar.edit_profile": "修改个人资料",
   "navigation_bar.favourites": "收藏的内容",
   "navigation_bar.follow_requests": "关注请求",
   "navigation_bar.info": "关于本站",
+  "navigation_bar.keyboard_shortcuts": "Keyboard shortcuts",
   "navigation_bar.logout": "注销",
-  "navigation_bar.mutes": "被静音的用户",
+  "navigation_bar.mutes": "被隐藏的用户",
   "navigation_bar.pins": "置顶嘟文",
   "navigation_bar.preferences": "首选项",
   "navigation_bar.public_timeline": "跨站公共时间轴",
@@ -184,7 +209,7 @@
   "status.media_hidden": "隐藏媒体内容",
   "status.mention": "提及 @{name}",
   "status.more": "更多",
-  "status.mute_conversation": "静音此对话",
+  "status.mute_conversation": "隐藏此对话",
   "status.open": "展开嘟文",
   "status.pin": "在个人资料页面置顶",
   "status.reblog": "转嘟",
@@ -197,7 +222,7 @@
   "status.share": "分享",
   "status.show_less": "隐藏内容",
   "status.show_more": "显示内容",
-  "status.unmute_conversation": "不再静音此对话",
+  "status.unmute_conversation": "不再隐藏此对话",
   "status.unpin": "在个人资料页面取消置顶",
   "tabs_bar.compose": "撰写",
   "tabs_bar.federated_timeline": "跨站",
diff --git a/app/javascript/mastodon/locales/zh-HK.json b/app/javascript/mastodon/locales/zh-HK.json
index f6f56fee1..a1c7aa875 100644
--- a/app/javascript/mastodon/locales/zh-HK.json
+++ b/app/javascript/mastodon/locales/zh-HK.json
@@ -7,17 +7,22 @@
   "account.followers": "關注的人",
   "account.follows": "正關注",
   "account.follows_you": "關注你",
+  "account.hide_reblogs": "Hide boosts from @{name}",
   "account.media": "媒體",
   "account.mention": "提及 @{name}",
+  "account.moved_to": "{name} has moved to:",
   "account.mute": "將 @{name} 靜音",
+  "account.mute_notifications": "Mute notifications from @{name}",
   "account.posts": "文章",
   "account.report": "舉報 @{name}",
   "account.requested": "等候審批",
   "account.share": "分享 @{name} 的個人資料",
+  "account.show_reblogs": "Show boosts from @{name}",
   "account.unblock": "解除對 @{name} 的封鎖",
   "account.unblock_domain": "不再隱藏 {domain}",
   "account.unfollow": "取消關注",
   "account.unmute": "取消 @{name} 的靜音",
+  "account.unmute_notifications": "Unmute notifications from @{name}",
   "account.view_full_profile": "查看完整資料",
   "boost_modal.combo": "如你想在下次路過這顯示,請按{combo},",
   "bundle_column_error.body": "加載本組件出錯。",
@@ -83,6 +88,7 @@
   "empty_column.hashtag": "這個標籤暫時未有內容。",
   "empty_column.home": "你還沒有關注任何用戶。快看看{public},向其他用戶搭訕吧。",
   "empty_column.home.public_timeline": "公共時間軸",
+  "empty_column.list": "There is nothing in this list yet.",
   "empty_column.notifications": "你沒有任何通知紀錄,快向其他用戶搭訕吧。",
   "empty_column.public": "跨站時間軸暫時沒有內容!快寫一些公共的文章,或者關注另一些服務站的用戶吧!你和本站、友站的交流,將決定這裏出現的內容。",
   "follow_request.authorize": "批准",
@@ -98,18 +104,37 @@
   "home.column_settings.show_reblogs": "顯示被轉推的文章",
   "home.column_settings.show_replies": "顯示回應文章",
   "home.settings": "欄位設定",
+  "keyboard_shortcuts.back": "to navigate back",
+  "keyboard_shortcuts.boost": "to boost",
+  "keyboard_shortcuts.column": "to focus a status in one of the columns",
+  "keyboard_shortcuts.compose": "to focus the compose textarea",
+  "keyboard_shortcuts.description": "Description",
+  "keyboard_shortcuts.down": "to move down in the list",
+  "keyboard_shortcuts.enter": "to open status",
+  "keyboard_shortcuts.favourite": "to favourite",
+  "keyboard_shortcuts.heading": "Keyboard Shortcuts",
+  "keyboard_shortcuts.hotkey": "Hotkey",
+  "keyboard_shortcuts.legend": "to display this legend",
+  "keyboard_shortcuts.mention": "to mention author",
+  "keyboard_shortcuts.reply": "to reply",
+  "keyboard_shortcuts.search": "to focus search",
+  "keyboard_shortcuts.toot": "to start a brand new toot",
+  "keyboard_shortcuts.unfocus": "to un-focus compose textarea/search",
+  "keyboard_shortcuts.up": "to move up in the list",
   "lightbox.close": "關閉",
   "lightbox.next": "繼續",
   "lightbox.previous": "回退",
   "loading_indicator.label": "載入中...",
   "media_gallery.toggle_visible": "打開或關上",
   "missing_indicator.label": "找不到內容",
+  "mute_modal.hide_notifications": "Hide notifications from this user?",
   "navigation_bar.blocks": "被你封鎖的用戶",
   "navigation_bar.community_timeline": "本站時間軸",
   "navigation_bar.edit_profile": "修改個人資料",
   "navigation_bar.favourites": "最愛的內容",
   "navigation_bar.follow_requests": "關注請求",
   "navigation_bar.info": "關於本服務站",
+  "navigation_bar.keyboard_shortcuts": "Keyboard shortcuts",
   "navigation_bar.logout": "登出",
   "navigation_bar.mutes": "被你靜音的用戶",
   "navigation_bar.pins": "置頂文章",
@@ -204,6 +229,7 @@
   "tabs_bar.home": "主頁",
   "tabs_bar.local_timeline": "本站",
   "tabs_bar.notifications": "通知",
+  "ui.beforeunload": "Your draft will be lost if you leave Mastodon.",
   "upload_area.title": "將檔案拖放至此上載",
   "upload_button.label": "上載媒體檔案",
   "upload_form.description": "Describe for the visually impaired",
diff --git a/app/javascript/mastodon/locales/zh-TW.json b/app/javascript/mastodon/locales/zh-TW.json
index 1f43c6a20..b52c5a810 100644
--- a/app/javascript/mastodon/locales/zh-TW.json
+++ b/app/javascript/mastodon/locales/zh-TW.json
@@ -7,17 +7,22 @@
   "account.followers": "專注者",
   "account.follows": "正關注",
   "account.follows_you": "關注你",
+  "account.hide_reblogs": "Hide boosts from @{name}",
   "account.media": "媒體",
   "account.mention": "提到 @{name}",
+  "account.moved_to": "{name} has moved to:",
   "account.mute": "消音 @{name}",
+  "account.mute_notifications": "Mute notifications from @{name}",
   "account.posts": "貼文",
   "account.report": "檢舉 @{name}",
   "account.requested": "正在等待許可",
   "account.share": "分享 @{name} 的用者資訊",
+  "account.show_reblogs": "Show boosts from @{name}",
   "account.unblock": "取消封鎖 @{name}",
   "account.unblock_domain": "不再隱藏 {domain}",
   "account.unfollow": "取消關注",
   "account.unmute": "不再消音 @{name}",
+  "account.unmute_notifications": "Unmute notifications from @{name}",
   "account.view_full_profile": "查看完整資訊",
   "boost_modal.combo": "下次你可以按 {combo} 來跳過",
   "bundle_column_error.body": "加載本組件出錯。",
@@ -83,6 +88,7 @@
   "empty_column.hashtag": "這個主題標籤下什麼都沒有。",
   "empty_column.home": "你還沒關注任何人。造訪{public}或利用搜尋功能找到其他用者。",
   "empty_column.home.public_timeline": "公開時間軸",
+  "empty_column.list": "There is nothing in this list yet.",
   "empty_column.notifications": "還沒有任何通知。和別的使用者互動來開始對話。",
   "empty_column.public": "這裡什麼都沒有!公開寫些什麼,或是關注其他副本的使用者。",
   "follow_request.authorize": "授權",
@@ -98,18 +104,37 @@
   "home.column_settings.show_reblogs": "顯示轉推",
   "home.column_settings.show_replies": "顯示回應",
   "home.settings": "欄位設定",
+  "keyboard_shortcuts.back": "to navigate back",
+  "keyboard_shortcuts.boost": "to boost",
+  "keyboard_shortcuts.column": "to focus a status in one of the columns",
+  "keyboard_shortcuts.compose": "to focus the compose textarea",
+  "keyboard_shortcuts.description": "Description",
+  "keyboard_shortcuts.down": "to move down in the list",
+  "keyboard_shortcuts.enter": "to open status",
+  "keyboard_shortcuts.favourite": "to favourite",
+  "keyboard_shortcuts.heading": "Keyboard Shortcuts",
+  "keyboard_shortcuts.hotkey": "Hotkey",
+  "keyboard_shortcuts.legend": "to display this legend",
+  "keyboard_shortcuts.mention": "to mention author",
+  "keyboard_shortcuts.reply": "to reply",
+  "keyboard_shortcuts.search": "to focus search",
+  "keyboard_shortcuts.toot": "to start a brand new toot",
+  "keyboard_shortcuts.unfocus": "to un-focus compose textarea/search",
+  "keyboard_shortcuts.up": "to move up in the list",
   "lightbox.close": "關閉",
   "lightbox.next": "繼續",
   "lightbox.previous": "回退",
   "loading_indicator.label": "讀取中...",
   "media_gallery.toggle_visible": "切換可見性",
   "missing_indicator.label": "找不到",
+  "mute_modal.hide_notifications": "Hide notifications from this user?",
   "navigation_bar.blocks": "封鎖的使用者",
   "navigation_bar.community_timeline": "本地時間軸",
   "navigation_bar.edit_profile": "編輯用者資訊",
   "navigation_bar.favourites": "最愛",
   "navigation_bar.follow_requests": "關注請求",
   "navigation_bar.info": "關於本站",
+  "navigation_bar.keyboard_shortcuts": "Keyboard shortcuts",
   "navigation_bar.logout": "登出",
   "navigation_bar.mutes": "消音的使用者",
   "navigation_bar.pins": "置頂貼文",
@@ -204,6 +229,7 @@
   "tabs_bar.home": "家",
   "tabs_bar.local_timeline": "本地",
   "tabs_bar.notifications": "通知",
+  "ui.beforeunload": "Your draft will be lost if you leave Mastodon.",
   "upload_area.title": "拖放來上傳",
   "upload_button.label": "增加媒體",
   "upload_form.description": "Describe for the visually impaired",
diff --git a/app/javascript/mastodon/reducers/accounts.js b/app/javascript/mastodon/reducers/accounts.js
index 8a4d69f26..5891d0fdd 100644
--- a/app/javascript/mastodon/reducers/accounts.js
+++ b/app/javascript/mastodon/reducers/accounts.js
@@ -59,6 +59,11 @@ const normalizeAccount = (state, account) => {
   account.display_name_html = emojify(escapeTextContentForBrowser(displayName));
   account.note_emojified = emojify(account.note);
 
+  if (account.moved) {
+    state = normalizeAccount(state, account.moved);
+    account.moved = account.moved.id;
+  }
+
   return state.set(account.id, fromJS(account));
 };
 
diff --git a/app/javascript/mastodon/reducers/accounts_counters.js b/app/javascript/mastodon/reducers/accounts_counters.js
index 1ed0fe3e3..2a78a9f34 100644
--- a/app/javascript/mastodon/reducers/accounts_counters.js
+++ b/app/javascript/mastodon/reducers/accounts_counters.js
@@ -126,7 +126,8 @@ export default function accountsCounters(state = initialState, action) {
   case STATUS_FETCH_SUCCESS:
     return normalizeAccountFromStatus(state, action.status);
   case ACCOUNT_FOLLOW_SUCCESS:
-    return state.updateIn([action.relationship.id, 'followers_count'], num => num + 1);
+    return action.alreadyFollowing ? state :
+      state.updateIn([action.relationship.id, 'followers_count'], num => num + 1);
   case ACCOUNT_UNFOLLOW_SUCCESS:
     return state.updateIn([action.relationship.id, 'followers_count'], num => Math.max(0, num - 1));
   default:
diff --git a/app/javascript/mastodon/reducers/contexts.js b/app/javascript/mastodon/reducers/contexts.js
index 64d584a01..fe8308d0c 100644
--- a/app/javascript/mastodon/reducers/contexts.js
+++ b/app/javascript/mastodon/reducers/contexts.js
@@ -1,3 +1,7 @@
+import {
+  ACCOUNT_BLOCK_SUCCESS,
+  ACCOUNT_MUTE_SUCCESS,
+} from '../actions/accounts';
 import { CONTEXT_FETCH_SUCCESS } from '../actions/statuses';
 import { TIMELINE_DELETE, TIMELINE_CONTEXT_UPDATE } from '../actions/timelines';
 import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
@@ -31,6 +35,12 @@ const deleteFromContexts = (state, id) => {
   return state;
 };
 
+const filterContexts = (state, relationship) => {
+  return state.map(
+    statuses => statuses.filter(
+      status => status.get('account') !== relationship.id));
+};
+
 const updateContext = (state, status, references) => {
   return state.update('descendants', map => {
     references.forEach(parentId => {
@@ -49,6 +59,9 @@ const updateContext = (state, status, references) => {
 
 export default function contexts(state = initialState, action) {
   switch(action.type) {
+  case ACCOUNT_BLOCK_SUCCESS:
+  case ACCOUNT_MUTE_SUCCESS:
+    return filterContexts(state, action.relationship);
   case CONTEXT_FETCH_SUCCESS:
     return normalizeContext(state, action.id, action.ancestors, action.descendants);
   case TIMELINE_DELETE:
diff --git a/app/javascript/mastodon/reducers/index.js b/app/javascript/mastodon/reducers/index.js
index 17c870351..425a2acdd 100644
--- a/app/javascript/mastodon/reducers/index.js
+++ b/app/javascript/mastodon/reducers/index.js
@@ -22,6 +22,7 @@ import media_attachments from './media_attachments';
 import notifications from './notifications';
 import height_cache from './height_cache';
 import custom_emojis from './custom_emojis';
+import lists from './lists';
 
 const reducers = {
   timelines,
@@ -47,6 +48,7 @@ const reducers = {
   notifications,
   height_cache,
   custom_emojis,
+  lists,
 };
 
 export default combineReducers(reducers);
diff --git a/app/javascript/mastodon/reducers/lists.js b/app/javascript/mastodon/reducers/lists.js
new file mode 100644
index 000000000..3e3908869
--- /dev/null
+++ b/app/javascript/mastodon/reducers/lists.js
@@ -0,0 +1,15 @@
+import { LIST_FETCH_SUCCESS } from '../actions/lists';
+import { Map as ImmutableMap, fromJS } from 'immutable';
+
+const initialState = ImmutableMap();
+
+const normalizeList = (state, list) => state.set(list.id, fromJS(list));
+
+export default function lists(state = initialState, action) {
+  switch(action.type) {
+  case LIST_FETCH_SUCCESS:
+    return normalizeList(state, action.list);
+  default:
+    return state;
+  }
+};
diff --git a/app/javascript/mastodon/reducers/statuses.js b/app/javascript/mastodon/reducers/statuses.js
index b1fb4c5da..5120b2b67 100644
--- a/app/javascript/mastodon/reducers/statuses.js
+++ b/app/javascript/mastodon/reducers/statuses.js
@@ -23,10 +23,6 @@ import {
   TIMELINE_EXPAND_SUCCESS,
 } from '../actions/timelines';
 import {
-  ACCOUNT_BLOCK_SUCCESS,
-  ACCOUNT_MUTE_SUCCESS,
-} from '../actions/accounts';
-import {
   NOTIFICATIONS_UPDATE,
   NOTIFICATIONS_REFRESH_SUCCESS,
   NOTIFICATIONS_EXPAND_SUCCESS,
@@ -88,18 +84,6 @@ const deleteStatus = (state, id, references) => {
   return state.delete(id);
 };
 
-const filterStatuses = (state, relationship) => {
-  state.forEach(status => {
-    if (status.get('account') !== relationship.id) {
-      return;
-    }
-
-    state = deleteStatus(state, status.get('id'), state.filter(item => item.get('reblog') === status.get('id')));
-  });
-
-  return state;
-};
-
 const initialState = ImmutableMap();
 
 export default function statuses(state = initialState, action) {
@@ -139,9 +123,6 @@ export default function statuses(state = initialState, action) {
     return normalizeStatuses(state, action.statuses);
   case TIMELINE_DELETE:
     return deleteStatus(state, action.id, action.references);
-  case ACCOUNT_BLOCK_SUCCESS:
-  case ACCOUNT_MUTE_SUCCESS:
-    return filterStatuses(state, action.relationship);
   default:
     return state;
   }
diff --git a/app/javascript/mastodon/reducers/timelines.js b/app/javascript/mastodon/reducers/timelines.js
index bee4c4ef9..9984c3b5d 100644
--- a/app/javascript/mastodon/reducers/timelines.js
+++ b/app/javascript/mastodon/reducers/timelines.js
@@ -55,7 +55,7 @@ const appendNormalizedTimeline = (state, timeline, statuses, next) => {
   }));
 };
 
-const updateTimeline = (state, timeline, status, references) => {
+const updateTimeline = (state, timeline, status) => {
   const top        = state.getIn([timeline, 'top']);
   const ids        = state.getIn([timeline, 'items'], ImmutableList());
   const includesId = ids.includes(status.get('id'));
@@ -70,7 +70,6 @@ const updateTimeline = (state, timeline, status, references) => {
   return state.update(timeline, initialTimeline, map => map.withMutations(mMap => {
     if (!top) mMap.set('unread', unread + 1);
     if (top && ids.size > 40) newIds = newIds.take(20);
-    if (status.getIn(['reblog', 'id'], null) !== null) newIds = newIds.filterNot(item => references.includes(item));
     mMap.set('items', newIds.unshift(status.get('id')));
   }));
 };
@@ -129,7 +128,7 @@ export default function timelines(state = initialState, action) {
   case TIMELINE_EXPAND_SUCCESS:
     return appendNormalizedTimeline(state, action.timeline, fromJS(action.statuses), action.next);
   case TIMELINE_UPDATE:
-    return updateTimeline(state, action.timeline, fromJS(action.status), action.references);
+    return updateTimeline(state, action.timeline, fromJS(action.status));
   case TIMELINE_DELETE:
     return deleteStatus(state, action.id, action.accountId, action.references, action.reblogOf);
   case ACCOUNT_BLOCK_SUCCESS:
diff --git a/app/javascript/mastodon/selectors/index.js b/app/javascript/mastodon/selectors/index.js
index d26d1b727..e47ec5183 100644
--- a/app/javascript/mastodon/selectors/index.js
+++ b/app/javascript/mastodon/selectors/index.js
@@ -4,14 +4,18 @@ import { List as ImmutableList } from 'immutable';
 const getAccountBase         = (state, id) => state.getIn(['accounts', id], null);
 const getAccountCounters     = (state, id) => state.getIn(['accounts_counters', id], null);
 const getAccountRelationship = (state, id) => state.getIn(['relationships', id], null);
+const getAccountMoved        = (state, id) => state.getIn(['accounts', state.getIn(['accounts', id, 'moved'])]);
 
 export const makeGetAccount = () => {
-  return createSelector([getAccountBase, getAccountCounters, getAccountRelationship], (base, counters, relationship) => {
+  return createSelector([getAccountBase, getAccountCounters, getAccountRelationship, getAccountMoved], (base, counters, relationship, moved) => {
     if (base === null) {
       return null;
     }
 
-    return base.merge(counters).set('relationship', relationship);
+    return base.merge(counters).withMutations(map => {
+      map.set('relationship', relationship);
+      map.set('moved', moved);
+    });
   });
 };
 
diff --git a/app/javascript/styles/mastodon/admin.scss b/app/javascript/styles/mastodon/admin.scss
index 87bc710af..7f078470e 100644
--- a/app/javascript/styles/mastodon/admin.scss
+++ b/app/javascript/styles/mastodon/admin.scss
@@ -347,3 +347,120 @@
     }
   }
 }
+
+.spacer {
+  flex: 1 1 auto;
+}
+
+.log-entry {
+  margin-bottom: 8px;
+  line-height: 20px;
+
+  &__header {
+    display: flex;
+    justify-content: flex-start;
+    align-items: center;
+    padding: 10px;
+    background: $ui-base-color;
+    color: $ui-primary-color;
+    border-radius: 4px 4px 0 0;
+    font-size: 14px;
+    position: relative;
+  }
+
+  &__avatar {
+    margin-right: 10px;
+
+    .avatar {
+      display: block;
+      margin: 0;
+      border-radius: 50%;
+      width: 40px;
+      height: 40px;
+    }
+  }
+
+  &__title {
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+  }
+
+  &__timestamp {
+    color: lighten($ui-base-color, 34%);
+  }
+
+  &__extras {
+    background: lighten($ui-base-color, 6%);
+    border-radius: 0 0 4px 4px;
+    padding: 10px;
+    color: $ui-primary-color;
+    font-family: 'mastodon-font-monospace', monospace;
+    font-size: 12px;
+    white-space: nowrap;
+    min-height: 20px;
+  }
+
+  &__icon {
+    font-size: 28px;
+    margin-right: 10px;
+    color: lighten($ui-base-color, 34%);
+  }
+
+  &__icon__overlay {
+    position: absolute;
+    top: 10px;
+    right: 10px;
+    width: 10px;
+    height: 10px;
+    border-radius: 50%;
+
+    &.positive {
+      background: $success-green;
+    }
+
+    &.negative {
+      background: $error-red;
+    }
+
+    &.neutral {
+      background: $ui-highlight-color;
+    }
+  }
+
+  a,
+  .username,
+  .target {
+    color: $ui-secondary-color;
+    text-decoration: none;
+    font-weight: 500;
+  }
+
+  .diff-old {
+    color: $error-red;
+  }
+
+  .diff-neutral {
+    color: $ui-secondary-color;
+  }
+
+  .diff-new {
+    color: $success-green;
+  }
+}
+
+.name-tag {
+  display: flex;
+  align-items: center;
+
+  .avatar {
+    display: block;
+    margin: 0;
+    margin-right: 5px;
+    border-radius: 50%;
+  }
+
+  .username {
+    font-weight: 500;
+  }
+}
diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss
index 8566585c5..942a3a3fa 100644
--- a/app/javascript/styles/mastodon/components.scss
+++ b/app/javascript/styles/mastodon/components.scss
@@ -1,5 +1,3 @@
-@import 'variables';
-
 .app-body {
   -webkit-overflow-scrolling: touch;
   -ms-overflow-style: -ms-autohiding-scrollbar;
@@ -917,6 +915,18 @@
   background-position: center;
   position: relative;
 
+  &.inactive {
+    opacity: 0.5;
+
+    .account__header__avatar {
+      filter: grayscale(100%);
+    }
+
+    .account__header__username {
+      color: $ui-primary-color;
+    }
+  }
+
   & > div {
     background: rgba(lighten($ui-base-color, 4%), 0.9);
     padding: 20px 10px;
@@ -2087,6 +2097,27 @@
   }
 }
 
+.keyboard-shortcuts {
+  padding: 8px 0 0;
+  overflow: hidden;
+
+  thead {
+    position: absolute;
+    left: -9999px;
+  }
+
+  td {
+    padding: 0 10px 8px;
+  }
+
+  code {
+    display: inline-block;
+    padding: 3px 5px;
+    background-color: lighten($ui-base-color, 8%);
+    border: 1px solid darken($ui-base-color, 4%);
+  }
+}
+
 .setting-text {
   color: $ui-primary-color;
   background: transparent;
@@ -2170,15 +2201,12 @@ button.icon-button.active i.fa-retweet {
 }
 
 .status-card-photo {
+  cursor: zoom-in;
   display: block;
   text-decoration: none;
-
-  img {
-    display: block;
-    width: 100%;
-    height: auto;
-    margin: 0;
-  }
+  width: 100%;
+  height: auto;
+  margin: 0;
 }
 
 .status-card-video {
@@ -4375,3 +4403,40 @@ noscript {
     }
   }
 }
+
+.account__moved-note {
+  padding: 14px 10px;
+  padding-bottom: 16px;
+  background: lighten($ui-base-color, 4%);
+  border-top: 1px solid lighten($ui-base-color, 8%);
+  border-bottom: 1px solid lighten($ui-base-color, 8%);
+
+  &__message {
+    position: relative;
+    margin-left: 58px;
+    color: $ui-base-lighter-color;
+    padding: 8px 0;
+    padding-top: 0;
+    padding-bottom: 4px;
+    font-size: 14px;
+
+    > span {
+      display: block;
+      overflow: hidden;
+      text-overflow: ellipsis;
+    }
+  }
+
+  &__icon-wrapper {
+    left: -26px;
+    position: absolute;
+  }
+
+  .detailed-status__display-avatar {
+    position: relative;
+  }
+
+  .detailed-status__display-name {
+    margin-bottom: 0;
+  }
+}
diff --git a/app/javascript/styles/mastodon/landing_strip.scss b/app/javascript/styles/mastodon/landing_strip.scss
index 0bf9daafd..2f7c98d54 100644
--- a/app/javascript/styles/mastodon/landing_strip.scss
+++ b/app/javascript/styles/mastodon/landing_strip.scss
@@ -34,3 +34,66 @@
 .memoriam-strip {
   background: rgba($base-shadow-color, 0.7);
 }
+
+.moved-strip {
+  padding: 14px;
+  border-radius: 4px;
+  background: rgba(darken($ui-base-color, 7%), 0.8);
+  color: $ui-secondary-color;
+  font-weight: 400;
+  margin-bottom: 20px;
+
+  strong,
+  a {
+    font-weight: 500;
+  }
+
+  a {
+    color: inherit;
+    text-decoration: underline;
+
+    &.mention {
+      text-decoration: none;
+
+      span {
+        text-decoration: none;
+      }
+
+      &:focus,
+      &:hover,
+      &:active {
+        text-decoration: none;
+
+        span {
+          text-decoration: underline;
+        }
+      }
+    }
+  }
+
+  &__message {
+    margin-bottom: 15px;
+
+    .fa {
+      margin-right: 5px;
+      color: $ui-primary-color;
+    }
+  }
+
+  &__card {
+    .detailed-status__display-avatar {
+      position: relative;
+      cursor: pointer;
+    }
+
+    .detailed-status__display-name {
+      margin-bottom: 0;
+      text-decoration: none;
+
+      span {
+        color: $ui-highlight-color;
+        font-weight: 400;
+      }
+    }
+  }
+}
diff --git a/app/lib/activitypub/activity/create.rb b/app/lib/activitypub/activity/create.rb
index 66e4f7c5e..31e0abe39 100644
--- a/app/lib/activitypub/activity/create.rb
+++ b/app/lib/activitypub/activity/create.rb
@@ -1,6 +1,9 @@
 # frozen_string_literal: true
 
 class ActivityPub::Activity::Create < ActivityPub::Activity
+  SUPPORTED_TYPES = %w(Article Note).freeze
+  CONVERTED_TYPES = %w(Image Video).freeze
+
   def perform
     return if delete_arrived_first?(object_uri) || unsupported_object_type?
 
@@ -41,7 +44,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
       url: object_url || @object['id'],
       account: @account,
       text: text_from_content || '',
-      language: language_from_content,
+      language: detected_language,
       spoiler_text: @object['summary'] || '',
       created_at: @options[:override_timestamps] ? nil : @object['published'],
       reply: @object['inReplyTo'].present?,
@@ -165,40 +168,62 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
   end
 
   def text_from_content
+    return Formatter.instance.linkify([text_from_name, object_url || @object['id']].join(' ')) if converted_object_type?
+
     if @object['content'].present?
       @object['content']
-    elsif language_map?
+    elsif content_language_map?
       @object['contentMap'].values.first
     end
   end
 
-  def language_from_content
-    return LanguageDetector.instance.detect(text_from_content, @account) unless language_map?
-    @object['contentMap'].keys.first
+  def text_from_name
+    if @object['name'].present?
+      @object['name']
+    elsif name_language_map?
+      @object['nameMap'].values.first
+    end
+  end
+
+  def detected_language
+    if content_language_map?
+      @object['contentMap'].keys.first
+    elsif name_language_map?
+      @object['nameMap'].keys.first
+    elsif supported_object_type?
+      LanguageDetector.instance.detect(text_from_content, @account)
+    end
   end
 
   def object_url
     return if @object['url'].blank?
-
-    value = first_of_value(@object['url'])
-
-    return value if value.is_a?(String)
-
-    value['href']
+    url_to_href(@object['url'], 'text/html')
   end
 
-  def language_map?
+  def content_language_map?
     @object['contentMap'].is_a?(Hash) && !@object['contentMap'].empty?
   end
 
+  def name_language_map?
+    @object['nameMap'].is_a?(Hash) && !@object['nameMap'].empty?
+  end
+
   def unsupported_object_type?
-    @object.is_a?(String) || !%w(Article Note).include?(@object['type'])
+    @object.is_a?(String) || !(supported_object_type? || converted_object_type?)
   end
 
   def unsupported_media_type?(mime_type)
     mime_type.present? && !(MediaAttachment::IMAGE_MIME_TYPES + MediaAttachment::VIDEO_MIME_TYPES).include?(mime_type)
   end
 
+  def supported_object_type?
+    SUPPORTED_TYPES.include?(@object['type'])
+  end
+
+  def converted_object_type?
+    CONVERTED_TYPES.include?(@object['type'])
+  end
+
   def skip_download?
     return @skip_download if defined?(@skip_download)
     @skip_download ||= DomainBlock.find_by(domain: @account.domain)&.reject_media?
@@ -210,7 +235,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
 
   def forward_for_reply
     return unless @json['signature'].present? && reply_to_local?
-    ActivityPub::RawDistributionWorker.perform_async(Oj.dump(@json), replied_to_status.account_id)
+    ActivityPub::RawDistributionWorker.perform_async(Oj.dump(@json), replied_to_status.account_id, [@account.preferred_inbox_url])
   end
 
   def lock_options
diff --git a/app/lib/activitypub/activity/delete.rb b/app/lib/activitypub/activity/delete.rb
index 4c6afb090..d0fb49342 100644
--- a/app/lib/activitypub/activity/delete.rb
+++ b/app/lib/activitypub/activity/delete.rb
@@ -30,8 +30,11 @@ class ActivityPub::Activity::Delete < ActivityPub::Activity
   def forward_for_reblogs(status)
     return if @json['signature'].blank?
 
-    ActivityPub::RawDistributionWorker.push_bulk(status.reblogs.includes(:account).references(:account).merge(Account.local).pluck(:account_id)) do |account_id|
-      [payload, account_id]
+    rebloggers_ids = status.reblogs.includes(:account).references(:account).merge(Account.local).pluck(:account_id)
+    inboxes        = Account.where(id: ::Follow.where(target_account_id: rebloggers_ids).select(:account_id)).inboxes - [@account.preferred_inbox_url]
+
+    ActivityPub::DeliveryWorker.push_bulk(inboxes) do |inbox_url|
+      [payload, rebloggers_ids.first, inbox_url]
     end
   end
 
diff --git a/app/lib/activitypub/adapter.rb b/app/lib/activitypub/adapter.rb
index 790d2025c..90d589d90 100644
--- a/app/lib/activitypub/adapter.rb
+++ b/app/lib/activitypub/adapter.rb
@@ -9,6 +9,7 @@ class ActivityPub::Adapter < ActiveModelSerializers::Adapter::Base
       {
         'manuallyApprovesFollowers' => 'as:manuallyApprovesFollowers',
         'sensitive'                 => 'as:sensitive',
+        'movedTo'                   => 'as:movedTo',
         'Hashtag'                   => 'as:Hashtag',
         'ostatus'                   => 'http://ostatus.org#',
         'atomUri'                   => 'ostatus:atomUri',
diff --git a/app/lib/feed_manager.rb b/app/lib/feed_manager.rb
index 76365c7d3..fe5ebfc36 100644
--- a/app/lib/feed_manager.rb
+++ b/app/lib/feed_manager.rb
@@ -149,7 +149,7 @@ class FeedManager
     return false if receiver_id == status.account_id
     return true  if status.reply? && (status.in_reply_to_id.nil? || status.in_reply_to_account_id.nil?)
 
-    return true if keyword_filter?(status, Glitch::KeywordMute.matcher_for(receiver_id))
+    return true if keyword_filter?(status, receiver_id)
 
     check_for_mutes = [status.account_id]
     check_for_mutes.concat(status.mentions.pluck(:account_id))
@@ -162,32 +162,38 @@ class FeedManager
 
     return true if Block.where(account_id: receiver_id, target_account_id: check_for_blocks).any?
 
-    if status.reply? && !status.in_reply_to_account_id.nil?                                                              # Filter out if it's a reply
-      should_filter   = !Follow.where(account_id: receiver_id, target_account_id: status.in_reply_to_account_id).exists? # and I'm not following the person it's a reply to
-      should_filter &&= receiver_id != status.in_reply_to_account_id                                                     # and it's not a reply to me
-      should_filter &&= status.account_id != status.in_reply_to_account_id                                               # and it's not a self-reply
+    if status.reply? && !status.in_reply_to_account_id.nil?                                                                      # Filter out if it's a reply
+      should_filter   = !Follow.where(account_id: receiver_id, target_account_id: status.in_reply_to_account_id).exists?         # and I'm not following the person it's a reply to
+      should_filter &&= receiver_id != status.in_reply_to_account_id                                                             # and it's not a reply to me
+      should_filter &&= status.account_id != status.in_reply_to_account_id                                                       # and it's not a self-reply
       return should_filter
-    elsif status.reblog?                                                                                                 # Filter out a reblog
-      src_id = status.account_id
-      should_filter   = Follow.where(account_id: receiver_id, target_account_id: src_id, show_reblogs: false).exists?    # if the reblogger's reblogs are suppressed
-      should_filter ||= Block.where(account_id: status.reblog.account_id, target_account_id: receiver_id).exists?        # or if the author of the reblogged status is blocking me
-      should_filter ||= AccountDomainBlock.where(account_id: receiver_id, domain: status.reblog.account.domain).exists?  # or the author's domain is blocked
+    elsif status.reblog?                                                                                                         # Filter out a reblog
+      should_filter   = Follow.where(account_id: receiver_id, target_account_id: status.account_id, show_reblogs: false).exists? # if the reblogger's reblogs are suppressed
+      should_filter ||= Block.where(account_id: status.reblog.account_id, target_account_id: receiver_id).exists?                # or if the author of the reblogged status is blocking me
+      should_filter ||= AccountDomainBlock.where(account_id: receiver_id, domain: status.reblog.account.domain).exists?          # or the author's domain is blocked
       return should_filter
     end
 
     false
   end
 
-  def keyword_filter?(status, matcher)
-    should_filter   = matcher =~ status.text
-    should_filter ||= matcher =~ status.spoiler_text
+  def keyword_filter?(status, receiver_id)
+    text_matcher = Glitch::KeywordMute.text_matcher_for(receiver_id)
+    tag_matcher  = Glitch::KeywordMute.tag_matcher_for(receiver_id)
+
+    should_filter   = text_matcher.matches?(status.text)
+    should_filter ||= text_matcher.matches?(status.spoiler_text)
+    should_filter ||= tag_matcher.matches?(status.tags)
 
     if status.reblog?
-      should_filter ||= matcher =~ status.reblog.text
-      should_filter ||= matcher =~ status.reblog.spoiler_text
+      reblog = status.reblog
+
+      should_filter ||= text_matcher.matches?(reblog.text)
+      should_filter ||= text_matcher.matches?(reblog.spoiler_text)
+      should_filter ||= tag_matcher.matches?(status.tags)
     end
 
-    !!should_filter
+    should_filter
   end
 
   def filter_from_mentions?(status, receiver_id)
@@ -199,7 +205,7 @@ class FeedManager
 
     should_filter   = Block.where(account_id: receiver_id, target_account_id: check_for_blocks).any?                                     # Filter if it's from someone I blocked, in reply to someone I blocked, or mentioning someone I blocked
     should_filter ||= (status.account.silenced? && !Follow.where(account_id: receiver_id, target_account_id: status.account_id).exists?) # of if the account is silenced and I'm not following them
-    should_filter ||= keyword_filter?(status, Glitch::KeywordMute.matcher_for(receiver_id))                                              # or if the mention contains a muted keyword
+    should_filter ||= keyword_filter?(status, receiver_id)                                                                               # or if the mention contains a muted keyword
 
     should_filter
   end
diff --git a/app/lib/formatter.rb b/app/lib/formatter.rb
index 733a1c4b7..9d8bc52db 100644
--- a/app/lib/formatter.rb
+++ b/app/lib/formatter.rb
@@ -51,12 +51,7 @@ class Formatter
 
   def simplified_format(account)
     return reformat(account.note).html_safe unless account.local? # rubocop:disable Rails/OutputSafety
-
-    html = encode_and_link_urls(account.note)
-    html = simple_format(html, {}, sanitize: false)
-    html = html.delete("\n")
-
-    html.html_safe # rubocop:disable Rails/OutputSafety
+    linkify(account.note)
   end
 
   def sanitize(html, config)
@@ -69,6 +64,14 @@ class Formatter
     html.html_safe # rubocop:disable Rails/OutputSafety
   end
 
+  def linkify(text)
+    html = encode_and_link_urls(text)
+    html = simple_format(html, {}, sanitize: false)
+    html = html.delete("\n")
+
+    html.html_safe # rubocop:disable Rails/OutputSafety
+  end
+
   private
 
   def encode(html)
diff --git a/app/models/account.rb b/app/models/account.rb
index a4b8e1c0b..ffd19fa52 100644
--- a/app/models/account.rb
+++ b/app/models/account.rb
@@ -42,6 +42,7 @@
 #  followers_url           :string           default(""), not null
 #  protocol                :integer          default("ostatus"), not null
 #  memorial                :boolean          default(FALSE), not null
+#  moved_to_account_id     :integer
 #
 
 class Account < ApplicationRecord
@@ -102,6 +103,9 @@ class Account < ApplicationRecord
   has_many :list_accounts, inverse_of: :account, dependent: :destroy
   has_many :lists, through: :list_accounts
 
+  # Account migrations
+  belongs_to :moved_to_account, class_name: 'Account'
+
   scope :remote, -> { where.not(domain: nil) }
   scope :local, -> { where(domain: nil) }
   scope :without_followers, -> { where(followers_count: 0) }
@@ -135,6 +139,10 @@ class Account < ApplicationRecord
     domain.nil?
   end
 
+  def moved?
+    moved_to_account_id.present?
+  end
+
   def acct
     local? ? username : "#{username}@#{domain}"
   end
@@ -208,6 +216,10 @@ class Account < ApplicationRecord
     Rails.cache.fetch("exclude_domains_for:#{id}") { domain_blocks.pluck(:domain) }
   end
 
+  def preferred_inbox_url
+    shared_inbox_url.presence || inbox_url
+  end
+
   class << self
     def readonly_attributes
       super - %w(statuses_count following_count followers_count)
diff --git a/app/models/admin.rb b/app/models/admin.rb
new file mode 100644
index 000000000..d41d18449
--- /dev/null
+++ b/app/models/admin.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+module Admin
+  def self.table_name_prefix
+    'admin_'
+  end
+end
diff --git a/app/models/admin/action_log.rb b/app/models/admin/action_log.rb
new file mode 100644
index 000000000..4e950fbf7
--- /dev/null
+++ b/app/models/admin/action_log.rb
@@ -0,0 +1,40 @@
+# frozen_string_literal: true
+# == Schema Information
+#
+# Table name: admin_action_logs
+#
+#  id               :integer          not null, primary key
+#  account_id       :integer
+#  action           :string           default(""), not null
+#  target_type      :string
+#  target_id        :integer
+#  recorded_changes :text             default(""), not null
+#  created_at       :datetime         not null
+#  updated_at       :datetime         not null
+#
+
+class Admin::ActionLog < ApplicationRecord
+  serialize :recorded_changes
+
+  belongs_to :account, required: true
+  belongs_to :target, required: true, polymorphic: true
+
+  default_scope -> { order('id desc') }
+
+  def action
+    super.to_sym
+  end
+
+  before_validation :set_changes
+
+  private
+
+  def set_changes
+    case action
+    when :destroy, :create
+      self.recorded_changes = target.attributes
+    when :update, :promote, :demote
+      self.recorded_changes = target.previous_changes
+    end
+  end
+end
diff --git a/app/models/concerns/account_interactions.rb b/app/models/concerns/account_interactions.rb
index c41f92581..fdf35a4e3 100644
--- a/app/models/concerns/account_interactions.rb
+++ b/app/models/concerns/account_interactions.rb
@@ -7,7 +7,7 @@ module AccountInteractions
     def following_map(target_account_ids, account_id)
       Follow.where(target_account_id: target_account_ids, account_id: account_id).each_with_object({}) do |follow, mapping|
         mapping[follow.target_account_id] = {
-          reblogs: follow.show_reblogs?
+          reblogs: follow.show_reblogs?,
         }
       end
     end
@@ -31,7 +31,7 @@ module AccountInteractions
     def requested_map(target_account_ids, account_id)
       FollowRequest.where(target_account_id: target_account_ids, account_id: account_id).each_with_object({}) do |follow_request, mapping|
         mapping[follow_request.target_account_id] = {
-          reblogs: follow_request.show_reblogs?
+          reblogs: follow_request.show_reblogs?,
         }
       end
     end
diff --git a/app/models/form/admin_settings.rb b/app/models/form/admin_settings.rb
index 2b148c82b..c1d2cf420 100644
--- a/app/models/form/admin_settings.rb
+++ b/app/models/form/admin_settings.rb
@@ -24,8 +24,12 @@ class Form::AdminSettings
     :open_deletion=,
     :timeline_preview,
     :timeline_preview=,
+    :show_staff_badge,
+    :show_staff_badge=,
     :bootstrap_timeline_accounts,
     :bootstrap_timeline_accounts=,
+    :min_invite_role,
+    :min_invite_role=,
     to: Setting
   )
 end
diff --git a/app/models/form/migration.rb b/app/models/form/migration.rb
new file mode 100644
index 000000000..b74987337
--- /dev/null
+++ b/app/models/form/migration.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+class Form::Migration
+  include ActiveModel::Validations
+
+  attr_accessor :acct, :account
+
+  def initialize(attrs = {})
+    @account = attrs[:account]
+    @acct    = attrs[:account].acct unless @account.nil?
+    @acct    = attrs[:acct].gsub(/\A@/, '').strip unless attrs[:acct].nil?
+  end
+
+  def valid?
+    return false unless super
+    set_account
+    errors.empty?
+  end
+
+  private
+
+  def set_account
+    self.account = (ResolveRemoteAccountService.new.call(acct) if account.nil? && acct.present?)
+  end
+end
diff --git a/app/models/form/status_batch.rb b/app/models/form/status_batch.rb
index a97b4aa28..4f08a3049 100644
--- a/app/models/form/status_batch.rb
+++ b/app/models/form/status_batch.rb
@@ -2,8 +2,9 @@
 
 class Form::StatusBatch
   include ActiveModel::Model
+  include AccountableConcern
 
-  attr_accessor :status_ids, :action
+  attr_accessor :status_ids, :action, :current_account
 
   ACTION_TYPE = %w(nsfw_on nsfw_off delete).freeze
 
@@ -20,11 +21,14 @@ class Form::StatusBatch
 
   def change_sensitive(sensitive)
     media_attached_status_ids = MediaAttachment.where(status_id: status_ids).pluck(:status_id)
+
     ApplicationRecord.transaction do
       Status.where(id: media_attached_status_ids).find_each do |status|
         status.update!(sensitive: sensitive)
+        log_action :update, status
       end
     end
+
     true
   rescue ActiveRecord::RecordInvalid
     false
@@ -33,7 +37,9 @@ class Form::StatusBatch
   def delete_statuses
     Status.where(id: status_ids).find_each do |status|
       RemovalWorker.perform_async(status.id)
+      log_action :destroy, status
     end
+
     true
   end
 end
diff --git a/app/models/glitch/keyword_mute.rb b/app/models/glitch/keyword_mute.rb
index 009de1880..a2481308f 100644
--- a/app/models/glitch/keyword_mute.rb
+++ b/app/models/glitch/keyword_mute.rb
@@ -16,51 +16,85 @@ class Glitch::KeywordMute < ApplicationRecord
 
   validates_presence_of :keyword
 
-  after_commit :invalidate_cached_matcher
+  after_commit :invalidate_cached_matchers
 
-  def self.matcher_for(account_id)
-    Matcher.new(account_id)
+  def self.text_matcher_for(account_id)
+    TextMatcher.new(account_id)
+  end
+
+  def self.tag_matcher_for(account_id)
+    TagMatcher.new(account_id)
   end
 
   private
 
-  def invalidate_cached_matcher
-    Rails.cache.delete("keyword_mutes:regex:#{account_id}")
+  def invalidate_cached_matchers
+    Rails.cache.delete(TextMatcher.cache_key(account_id))
+    Rails.cache.delete(TagMatcher.cache_key(account_id))
   end
 
-  class Matcher
+  class RegexpMatcher
     attr_reader :account_id
     attr_reader :regex
 
     def initialize(account_id)
       @account_id = account_id
-      regex_text = Rails.cache.fetch("keyword_mutes:regex:#{account_id}") { regex_text_for_account }
+      regex_text = Rails.cache.fetch(self.class.cache_key(account_id)) { make_regex_text }
       @regex = /#{regex_text}/
     end
 
-    def =~(str)
-      regex =~ str
+    protected
+
+    def keywords
+      Glitch::KeywordMute.where(account_id: account_id).pluck(:whole_word, :keyword)
     end
 
-    private
+    def boundary_regex_for_keyword(keyword)
+      sb = keyword =~ /\A[[:word:]]/ ? '\b' : ''
+      eb = keyword =~ /[[:word:]]\Z/ ? '\b' : ''
 
-    def keywords
-      Glitch::KeywordMute.where(account_id: account_id).select(:keyword, :id, :whole_word)
+      /(?mix:#{sb}#{Regexp.escape(keyword)}#{eb})/
+    end
+  end
+
+  class TextMatcher < RegexpMatcher
+    def self.cache_key(account_id)
+      format('keyword_mutes:regex:text:%s', account_id)
+    end
+
+    def matches?(str)
+      !!(regex =~ str)
     end
 
-    def regex_text_for_account
-      kws = keywords.find_each.with_object([]) do |kw, a|
-        a << (kw.whole_word ? boundary_regex_for_keyword(kw.keyword) : kw.keyword)
+    private
+
+    def make_regex_text
+      kws = keywords.map! do |whole_word, keyword|
+        whole_word ? boundary_regex_for_keyword(keyword) : keyword
       end
 
       Regexp.union(kws).source
     end
+  end
 
-    def boundary_regex_for_keyword(keyword)
-      sb = keyword =~ /\A[[:word:]]/ ? '\b' : ''
-      eb = keyword =~ /[[:word:]]\Z/ ? '\b' : ''
+  class TagMatcher < RegexpMatcher
+    def self.cache_key(account_id)
+      format('keyword_mutes:regex:tag:%s', account_id)
+    end
 
-      /(?mix:#{sb}#{Regexp.escape(keyword)}#{eb})/
+    def matches?(tags)
+      tags.pluck(:name).any? { |n| regex =~ n }
+    end
+
+    private
+
+    def make_regex_text
+      kws = keywords.map! do |whole_word, keyword|
+        term = (Tag::HASHTAG_RE =~ keyword) ? $1 : keyword
+        whole_word ? boundary_regex_for_keyword(term) : term
+      end
+
+      Regexp.union(kws).source
     end
   end
 end
diff --git a/app/models/invite.rb b/app/models/invite.rb
new file mode 100644
index 000000000..6907c1f1d
--- /dev/null
+++ b/app/models/invite.rb
@@ -0,0 +1,52 @@
+# frozen_string_literal: true
+# == Schema Information
+#
+# Table name: invites
+#
+#  id         :integer          not null, primary key
+#  user_id    :integer
+#  code       :string           default(""), not null
+#  expires_at :datetime
+#  max_uses   :integer
+#  uses       :integer          default(0), not null
+#  created_at :datetime         not null
+#  updated_at :datetime         not null
+#
+
+class Invite < ApplicationRecord
+  belongs_to :user, required: true
+  has_many :users, inverse_of: :invite
+
+  scope :available, -> { where(expires_at: nil).or(where('expires_at >= ?', Time.now.utc)) }
+  scope :expired, -> { where.not(expires_at: nil).where('expires_at < ?', Time.now.utc) }
+
+  before_validation :set_code
+
+  attr_reader :expires_in
+
+  def expires_in=(interval)
+    self.expires_at = interval.to_i.seconds.from_now unless interval.blank?
+    @expires_in     = interval
+  end
+
+  def valid_for_use?
+    (max_uses.nil? || uses < max_uses) && !expired?
+  end
+
+  def expire!
+    touch(:expires_at)
+  end
+
+  def expired?
+    !expires_at.nil? && expires_at < Time.now.utc
+  end
+
+  private
+
+  def set_code
+    loop do
+      self.code = ([*('a'..'z'), *('A'..'Z'), *('0'..'9')] - %w(0 1 I l O)).sample(8).join
+      break if Invite.find_by(code: code).nil?
+    end
+  end
+end
diff --git a/app/models/invite_filter.rb b/app/models/invite_filter.rb
new file mode 100644
index 000000000..7d89bad4a
--- /dev/null
+++ b/app/models/invite_filter.rb
@@ -0,0 +1,32 @@
+# frozen_string_literal: true
+
+class InviteFilter
+  attr_reader :params
+
+  def initialize(params)
+    @params = params
+  end
+
+  def results
+    scope = Invite.order(created_at: :desc)
+
+    params.each do |key, value|
+      scope.merge!(scope_for(key, value)) if value.present?
+    end
+
+    scope
+  end
+
+  private
+
+  def scope_for(key, _value)
+    case key.to_s
+    when 'available'
+      Invite.available
+    when 'expired'
+      Invite.expired
+    else
+      raise "Unknown filter: #{key}"
+    end
+  end
+end
diff --git a/app/models/notification.rb b/app/models/notification.rb
index a3ffb1f45..976963528 100644
--- a/app/models/notification.rb
+++ b/app/models/notification.rb
@@ -24,7 +24,7 @@ class Notification < ApplicationRecord
     favourite:      'Favourite',
   }.freeze
 
-  STATUS_INCLUDES = [:account, :stream_entry, :media_attachments, :tags, mentions: :account, reblog: [:stream_entry, :account, :media_attachments, :tags, mentions: :account]].freeze
+  STATUS_INCLUDES = [:account, :application, :stream_entry, :media_attachments, :tags, mentions: :account, reblog: [:stream_entry, :account, :application, :media_attachments, :tags, mentions: :account]].freeze
 
   belongs_to :account
   belongs_to :from_account, class_name: 'Account'
@@ -55,9 +55,11 @@ class Notification < ApplicationRecord
   def target_status
     case type
     when :reblog
-      activity&.reblog
-    when :favourite, :mention
-      activity&.status
+      status&.reblog
+    when :favourite
+      favourite&.status
+    when :mention
+      mention&.status
     end
   end
 
diff --git a/app/models/status.rb b/app/models/status.rb
index 172d3a665..70cfdc1c7 100644
--- a/app/models/status.rb
+++ b/app/models/status.rb
@@ -183,7 +183,7 @@ class Status < ApplicationRecord
     end
 
     def reblogs_map(status_ids, account_id)
-      select('reblog_of_id').where(reblog_of_id: status_ids).where(account_id: account_id).map { |s| [s.reblog_of_id, true] }.to_h
+      select('reblog_of_id').where(reblog_of_id: status_ids).where(account_id: account_id).reorder(nil).map { |s| [s.reblog_of_id, true] }.to_h
     end
 
     def mutes_map(conversation_ids, account_id)
@@ -291,6 +291,7 @@ class Status < ApplicationRecord
 
   def set_visibility
     self.visibility = (account.locked? ? :private : :public) if visibility.nil?
+    self.visibility = reblog.visibility if reblog?
     self.sensitive  = false if sensitive.nil?
   end
 
diff --git a/app/models/user.rb b/app/models/user.rb
index 2458cf298..29bdcbd67 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -33,6 +33,7 @@
 #  account_id                :integer          not null
 #  disabled                  :boolean          default(FALSE), not null
 #  moderator                 :boolean          default(FALSE), not null
+#  invite_id                 :integer
 #
 
 class User < ApplicationRecord
@@ -47,6 +48,7 @@ class User < ApplicationRecord
          otp_number_of_backup_codes: 10
 
   belongs_to :account, inverse_of: :user, required: true
+  belongs_to :invite, counter_cache: :uses
   accepts_nested_attributes_for :account
 
   has_many :applications, class_name: 'Doorkeeper::Application', as: :owner
@@ -77,6 +79,8 @@ class User < ApplicationRecord
            :reduce_motion, :system_font_ui, :noindex, :flavour, :skin,
            to: :settings, prefix: :setting, allow_nil: false
 
+  attr_accessor :invite_code
+
   def confirmed?
     confirmed_at.present?
   end
@@ -95,6 +99,19 @@ class User < ApplicationRecord
     end
   end
 
+  def role?(role)
+    case role
+    when 'user'
+      true
+    when 'moderator'
+      staff?
+    when 'admin'
+      admin?
+    else
+      false
+    end
+  end
+
   def disable!
     update!(disabled: true,
             last_sign_in_at: current_sign_in_at,
@@ -169,6 +186,11 @@ class User < ApplicationRecord
     session.web_push_subscription.nil? ? nil : session.web_push_subscription.as_payload
   end
 
+  def invite_code=(code)
+    self.invite  = Invite.find_by(code: code) unless code.blank?
+    @invite_code = code
+  end
+
   protected
 
   def send_devise_notification(notification, *args)
diff --git a/app/policies/invite_policy.rb b/app/policies/invite_policy.rb
new file mode 100644
index 000000000..a2a65f934
--- /dev/null
+++ b/app/policies/invite_policy.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+class InvitePolicy < ApplicationPolicy
+  def index?
+    staff?
+  end
+
+  def create?
+    min_required_role?
+  end
+
+  def destroy?
+    owner? || (Setting.min_invite_role == 'admin' ? admin? : staff?)
+  end
+
+  private
+
+  def owner?
+    record.user_id == current_user&.id
+  end
+
+  def min_required_role?
+    current_user&.role?(Setting.min_invite_role)
+  end
+end
diff --git a/app/serializers/activitypub/actor_serializer.rb b/app/serializers/activitypub/actor_serializer.rb
index 896d67115..622bdde0c 100644
--- a/app/serializers/activitypub/actor_serializer.rb
+++ b/app/serializers/activitypub/actor_serializer.rb
@@ -10,6 +10,8 @@ class ActivityPub::ActorSerializer < ActiveModel::Serializer
 
   has_one :public_key, serializer: ActivityPub::PublicKeySerializer
 
+  attribute :moved_to, if: :moved?
+
   class EndpointsSerializer < ActiveModel::Serializer
     include RoutingHelper
 
@@ -25,6 +27,8 @@ class ActivityPub::ActorSerializer < ActiveModel::Serializer
   has_one :icon,  serializer: ActivityPub::ImageSerializer, if: :avatar_exists?
   has_one :image, serializer: ActivityPub::ImageSerializer, if: :header_exists?
 
+  delegate :moved?, to: :object
+
   def id
     account_url(object)
   end
@@ -92,4 +96,8 @@ class ActivityPub::ActorSerializer < ActiveModel::Serializer
   def manually_approves_followers
     object.locked
   end
+
+  def moved_to
+    ActivityPub::TagManager.instance.uri_for(object.moved_to_account)
+  end
 end
diff --git a/app/serializers/rest/account_serializer.rb b/app/serializers/rest/account_serializer.rb
index 65fdb0308..bab944c5a 100644
--- a/app/serializers/rest/account_serializer.rb
+++ b/app/serializers/rest/account_serializer.rb
@@ -7,6 +7,10 @@ class REST::AccountSerializer < ActiveModel::Serializer
              :note, :url, :avatar, :avatar_static, :header, :header_static,
              :followers_count, :following_count, :statuses_count
 
+  has_one :moved_to_account, key: :moved, serializer: REST::AccountSerializer, if: :moved?
+
+  delegate :moved?, to: :object
+
   def id
     object.id.to_s
   end
diff --git a/app/serializers/rest/list_serializer.rb b/app/serializers/rest/list_serializer.rb
index c0150888e..977da7439 100644
--- a/app/serializers/rest/list_serializer.rb
+++ b/app/serializers/rest/list_serializer.rb
@@ -2,4 +2,8 @@
 
 class REST::ListSerializer < ActiveModel::Serializer
   attributes :id, :title
+
+  def id
+    object.id.to_s
+  end
 end
diff --git a/app/services/activitypub/fetch_remote_status_service.rb b/app/services/activitypub/fetch_remote_status_service.rb
index 8d7b7a17c..7649bceca 100644
--- a/app/services/activitypub/fetch_remote_status_service.rb
+++ b/app/services/activitypub/fetch_remote_status_service.rb
@@ -18,7 +18,7 @@ class ActivityPub::FetchRemoteStatusService < BaseService
     actor = ActivityPub::TagManager.instance.uri_to_resource(actor_id, Account)
     actor = ActivityPub::FetchRemoteAccountService.new.call(actor_id, id: true) if actor.nil? || needs_update(actor)
 
-    return if actor.suspended?
+    return if actor.nil? || actor.suspended?
 
     ActivityPub::Activity.factory(activity_json, actor).perform
   end
@@ -42,7 +42,7 @@ class ActivityPub::FetchRemoteStatusService < BaseService
   end
 
   def expected_type?
-    %w(Note Article).include? @json['type']
+    (ActivityPub::Activity::Create::SUPPORTED_TYPES + ActivityPub::Activity::Create::CONVERTED_TYPES).include? @json['type']
   end
 
   def needs_update(actor)
diff --git a/app/services/activitypub/process_account_service.rb b/app/services/activitypub/process_account_service.rb
index f93baf4b5..06ca75563 100644
--- a/app/services/activitypub/process_account_service.rb
+++ b/app/services/activitypub/process_account_service.rb
@@ -74,6 +74,7 @@ class ActivityPub::ProcessAccountService < BaseService
     @account.statuses_count    = outbox_total_items    if outbox_total_items.present?
     @account.following_count   = following_total_items if following_total_items.present?
     @account.followers_count   = followers_total_items if followers_total_items.present?
+    @account.moved_to_account  = moved_account         if @json['movedTo'].present?
   end
 
   def after_protocol_change!
@@ -106,12 +107,7 @@ class ActivityPub::ProcessAccountService < BaseService
 
   def url
     return if @json['url'].blank?
-
-    value = first_of_value(@json['url'])
-
-    return value if value.is_a?(String)
-
-    value['href']
+    url_to_href(@json['url'], 'text/html')
   end
 
   def outbox_total_items
@@ -137,6 +133,12 @@ class ActivityPub::ProcessAccountService < BaseService
     @collections[type] = nil
   end
 
+  def moved_account
+    account   = ActivityPub::TagManager.instance.uri_to_resource(@json['movedTo'], Account)
+    account ||= ActivityPub::FetchRemoteAccountService.new.call(@json['movedTo'], id: true)
+    account
+  end
+
   def skip_download?
     @account.suspended? || domain_block&.reject_media?
   end
diff --git a/app/services/fetch_link_card_service.rb b/app/services/fetch_link_card_service.rb
index 14c21b6cc..37cf75379 100644
--- a/app/services/fetch_link_card_service.rb
+++ b/app/services/fetch_link_card_service.rb
@@ -74,6 +74,9 @@ class FetchLinkCardService < BaseService
 
     return false unless response.respond_to?(:type)
 
+    # The photo will change the URL. So, to avoid duplication of URLs, PreviewCard needs to be checked again.
+    @card = PreviewCard.find_by(url: response.url) || @card if response.type == 'photo'
+
     @card.type          = response.type
     @card.title         = response.respond_to?(:title)         ? response.title         : ''
     @card.author_name   = response.respond_to?(:author_name)   ? response.author_name   : ''
diff --git a/app/services/process_mentions_service.rb b/app/services/process_mentions_service.rb
index a229d4ff8..e12721c46 100644
--- a/app/services/process_mentions_service.rb
+++ b/app/services/process_mentions_service.rb
@@ -18,7 +18,7 @@ class ProcessMentionsService < BaseService
       end
 
       if mentioned_account.nil?
-        username, domain  = match.first.split('@')
+        username, domain  = $1.split('@')
         mentioned_account = Account.find_remote(username, domain)
       end
 
diff --git a/app/services/remove_status_service.rb b/app/services/remove_status_service.rb
index 9617081fd..7789bd441 100644
--- a/app/services/remove_status_service.rb
+++ b/app/services/remove_status_service.rb
@@ -3,7 +3,7 @@
 class RemoveStatusService < BaseService
   include StreamEntryRenderer
 
-  def call(status)
+  def call(status, options = {})
     @payload      = Oj.dump(event: :delete, payload: status.id.to_s)
     @status       = status
     @account      = status.account
@@ -11,6 +11,7 @@ class RemoveStatusService < BaseService
     @mentions     = status.mentions.includes(:account).to_a
     @reblogs      = status.reblogs.to_a
     @stream_entry = status.stream_entry
+    @options      = options
 
     remove_from_self if status.account.local?
     remove_from_followers
@@ -23,7 +24,12 @@ class RemoveStatusService < BaseService
 
     @status.destroy!
 
-    return unless @account.local?
+    # There is no reason to send out Undo activities when the
+    # cause is that the original object has been removed, since
+    # original object being removed implicitly removes reblogs
+    # of it. The Delete activity of the original is forwarded
+    # separately.
+    return if !@account.local? || @options[:original_removed]
 
     remove_from_remote_followers
     remove_from_remote_affected
@@ -105,7 +111,7 @@ class RemoveStatusService < BaseService
     # without us being able to do all the fancy stuff
 
     @reblogs.each do |reblog|
-      RemoveStatusService.new.call(reblog)
+      RemoveStatusService.new.call(reblog, original_removed: true)
     end
   end
 
diff --git a/app/views/accounts/_header.html.haml b/app/views/accounts/_header.html.haml
index 94ec5ae5b..b0062752c 100644
--- a/app/views/accounts/_header.html.haml
+++ b/app/views/accounts/_header.html.haml
@@ -1,7 +1,7 @@
 - processed_bio = FrontmatterHandler.instance.process_bio Formatter.instance.simplified_format account
 .card.h-card.p-author{ style: "background-image: url(#{account.header.url(:original)})" }
   .card__illustration
-    - unless account.memorial?
+    - unless account.memorial? || account.moved?
       - if user_signed_in? && current_account.id != account.id && !current_account.requested?(account)
         .controls
           - if current_account.following?(account)
@@ -27,15 +27,15 @@
       %small
         %span @#{account.local_username_and_domain}
         = fa_icon('lock') if account.locked?
-
-    - if account.user_admin?
-      .roles
-        .account-role.admin
-          = t 'accounts.roles.admin'
-    - elsif account.user_moderator?
-      .roles
-        .account-role.moderator
-          = t 'accounts.roles.moderator'
+    - if Setting.show_staff_badge
+      - if account.user_admin?
+        .roles
+          .account-role.admin
+            = t 'accounts.roles.admin'
+      - elsif account.user_moderator?
+        .roles
+          .account-role.moderator
+            = t 'accounts.roles.moderator'
     .bio
       .account__header__content.p-note.emojify!=processed_bio[:text]
       - if processed_bio[:metadata].length > 0
diff --git a/app/views/accounts/_moved_strip.html.haml b/app/views/accounts/_moved_strip.html.haml
new file mode 100644
index 000000000..6a14a5dd3
--- /dev/null
+++ b/app/views/accounts/_moved_strip.html.haml
@@ -0,0 +1,17 @@
+- moved_to_account = account.moved_to_account
+
+.moved-strip
+  .moved-strip__message
+    = fa_icon 'suitcase'
+    = t('accounts.moved_html', name: content_tag(:strong, display_name(account), class: :emojify), new_profile_link: link_to(content_tag(:strong, safe_join(['@', content_tag(:span, moved_to_account.acct)])), TagManager.instance.url_for(moved_to_account), class: 'mention'))
+
+  .moved-strip__card
+    = link_to TagManager.instance.url_for(moved_to_account), class: 'detailed-status__display-name p-author h-card', target: '_blank', rel: 'noopener' do
+      .detailed-status__display-avatar
+        .account__avatar-overlay
+          .account__avatar-overlay-base{ style: "background-image: url('#{moved_to_account.avatar.url(:original)}')" }
+          .account__avatar-overlay-overlay{ style: "background-image: url('#{account.avatar.url(:original)}')" }
+
+      %span.display-name
+        %strong.emojify= display_name(moved_to_account)
+        %span @#{moved_to_account.acct}
diff --git a/app/views/accounts/show.html.haml b/app/views/accounts/show.html.haml
index fd8ad5530..accad5f78 100644
--- a/app/views/accounts/show.html.haml
+++ b/app/views/accounts/show.html.haml
@@ -14,6 +14,8 @@
 
 - if @account.memorial?
   .memoriam-strip= t('in_memoriam_html')
+- elsif @account.moved?
+  = render partial: 'moved_strip', locals: { account: @account }
 - elsif show_landing_strip?
   = render partial: 'shared/landing_strip', locals: { account: @account }
 
diff --git a/app/views/admin/action_logs/_action_log.html.haml b/app/views/admin/action_logs/_action_log.html.haml
new file mode 100644
index 000000000..ec90961cb
--- /dev/null
+++ b/app/views/admin/action_logs/_action_log.html.haml
@@ -0,0 +1,15 @@
+%li.log-entry
+  .log-entry__header
+    .log-entry__avatar
+      = image_tag action_log.account.avatar.url(:original), alt: '', width: 40, height: 40, class: 'avatar'
+    .log-entry__content
+      .log-entry__title
+        = t("admin.action_logs.actions.#{action_log.action}_#{action_log.target_type.underscore}", name: content_tag(:span, action_log.account.username, class: 'username'), target: content_tag(:span, log_target(action_log), class: 'target')).html_safe
+      .log-entry__timestamp
+        %time= l action_log.created_at
+    .spacer
+    .log-entry__icon
+      = fa_icon icon_for_log(action_log)
+      .log-entry__icon__overlay{ class: class_for_log_icon(action_log) }
+  .log-entry__extras
+    = log_extra_attributes relevant_log_changes(action_log)
diff --git a/app/views/admin/action_logs/index.html.haml b/app/views/admin/action_logs/index.html.haml
new file mode 100644
index 000000000..bb6d7b5d7
--- /dev/null
+++ b/app/views/admin/action_logs/index.html.haml
@@ -0,0 +1,7 @@
+- content_for :page_title do
+  = t('admin.action_logs.title')
+
+%ul
+  = render @action_logs
+
+= paginate @action_logs
diff --git a/app/views/admin/invites/_invite.html.haml b/app/views/admin/invites/_invite.html.haml
new file mode 100644
index 000000000..d7b697286
--- /dev/null
+++ b/app/views/admin/invites/_invite.html.haml
@@ -0,0 +1,21 @@
+%tr
+  %td
+    .name-tag
+      = image_tag invite.user.account.avatar.url(:original), alt: '', width: 16, height: 16, class: 'avatar'
+      %span.username= invite.user.account.username
+  %td
+    = invite.uses
+    = " / #{invite.max_uses}" unless invite.max_uses.nil?
+  %td
+    - if invite.expired?
+      = t('invites.expired')
+    - else
+      - if invite.expires_at.nil?
+        ∞
+      - else
+        %time.formatted{ datetime: invite.expires_at.iso8601, title: l(invite.expires_at) }
+          = l invite.expires_at
+  %td= table_link_to 'link', public_invite_url(invite_code: invite.code), public_invite_url(invite_code: invite.code)
+  %td
+    - if !invite.expired? && policy(invite).destroy?
+      = table_link_to 'times', t('invites.delete'), admin_invite_path(invite), method: :delete
diff --git a/app/views/admin/invites/index.html.haml b/app/views/admin/invites/index.html.haml
new file mode 100644
index 000000000..944a60471
--- /dev/null
+++ b/app/views/admin/invites/index.html.haml
@@ -0,0 +1,30 @@
+- content_for :page_title do
+  = t('admin.invites.title')
+
+.filters
+  .filter-subset
+    %strong= t('admin.invites.filter.title')
+    %ul
+      %li= filter_link_to t('admin.invites.filter.all'), available: nil, expired: nil
+      %li= filter_link_to t('admin.invites.filter.available'), available: 1, expired: nil
+      %li= filter_link_to t('admin.invites.filter.expired'), available: nil, expired: 1
+
+- if policy(:invite).create?
+  %p= t('invites.prompt')
+
+  = render 'invites/form'
+
+  %hr/
+
+%table.table
+  %thead
+    %tr
+      %th
+      %th= t('invites.table.uses')
+      %th= t('invites.table.expires_at')
+      %th
+      %th
+  %tbody
+    = render @invites
+
+= paginate @invites
diff --git a/app/views/admin/settings/edit.html.haml b/app/views/admin/settings/edit.html.haml
index 468166035..c7c25f528 100644
--- a/app/views/admin/settings/edit.html.haml
+++ b/app/views/admin/settings/edit.html.haml
@@ -19,6 +19,9 @@
     = f.input :timeline_preview, as: :boolean, wrapper: :with_label, label: t('admin.settings.timeline_preview.title'), hint: t('admin.settings.timeline_preview.desc_html')
 
   .fields-group
+    = f.input :show_staff_badge, as: :boolean, wrapper: :with_label, label: t('admin.settings.show_staff_badge.title'), hint: t('admin.settings.show_staff_badge.desc_html')
+
+  .fields-group
     = f.input :open_registrations, as: :boolean, wrapper: :with_label, label: t('admin.settings.registrations.open.title'), hint: t('admin.settings.registrations.open.desc_html')
 
   .fields-group
@@ -30,6 +33,11 @@
   %hr/
 
   .fields-group
+    = f.input :min_invite_role, wrapper: :with_label, collection: %i(disabled user moderator admin), label: t('admin.settings.registrations.min_invite_role.title'), label_method: lambda { |role| role == :disabled ? t('admin.settings.registrations.min_invite_role.disabled') : t("admin.accounts.roles.#{role}") }, as: :radio_buttons, include_blank: false, collection_wrapper_tag: 'ul', item_wrapper_tag: 'li'
+
+  %hr/
+
+  .fields-group
     = f.input :site_extended_description, wrapper: :with_block_label, as: :text, label: t('admin.settings.site_description_extended.title'), hint: t('admin.settings.site_description_extended.desc_html'), input_html: { rows: 8 }
     = f.input :site_terms, wrapper: :with_block_label, as: :text, label: t('admin.settings.site_terms.title'), hint: t('admin.settings.site_terms.desc_html'), input_html: { rows: 8 }
 
diff --git a/app/views/auth/registrations/new.html.haml b/app/views/auth/registrations/new.html.haml
index f71675df0..2d4c0f5ac 100644
--- a/app/views/auth/registrations/new.html.haml
+++ b/app/views/auth/registrations/new.html.haml
@@ -16,6 +16,7 @@
   = f.input :email, placeholder: t('simple_form.labels.defaults.email'), required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.email'), :autocomplete => 'off' }
   = f.input :password, placeholder: t('simple_form.labels.defaults.password'), required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.password'), :autocomplete => 'off' }
   = f.input :password_confirmation, placeholder: t('simple_form.labels.defaults.confirm_password'), required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.confirm_password'), :autocomplete => 'off' }
+  = f.input :invite_code, as: :hidden
 
   .actions
     = f.button :button, t('auth.register'), type: :submit
diff --git a/app/views/invites/_form.html.haml b/app/views/invites/_form.html.haml
new file mode 100644
index 000000000..a01cf5946
--- /dev/null
+++ b/app/views/invites/_form.html.haml
@@ -0,0 +1,9 @@
+= simple_form_for(@invite, url: controller.is_a?(Admin::InvitesController) ? admin_invites_path : invites_path) do |f|
+  = render 'shared/error_messages', object: @invite
+
+  .fields-group
+    = f.input :max_uses, wrapper: :with_label, collection: [1, 5, 10, 25, 50, 100], label_method: lambda { |num| I18n.t('invites.max_uses', count: num) }, prompt: I18n.t('invites.max_uses_prompt')
+    = f.input :expires_in, wrapper: :with_label, collection: [30.minutes, 1.hour, 6.hours, 12.hours, 1.day].map(&:to_i), label_method: lambda { |i| I18n.t("invites.expires_in.#{i}") }, prompt: I18n.t('invites.expires_in_prompt')
+
+  .actions
+    = f.button :button, t('invites.generate'), type: :submit
diff --git a/app/views/invites/_invite.html.haml b/app/views/invites/_invite.html.haml
new file mode 100644
index 000000000..81d67eb7d
--- /dev/null
+++ b/app/views/invites/_invite.html.haml
@@ -0,0 +1,17 @@
+%tr
+  %td
+    = invite.uses
+    = " / #{invite.max_uses}" unless invite.max_uses.nil?
+  %td
+    - if invite.expired?
+      = t('invites.expired')
+    - else
+      - if invite.expires_at.nil?
+        ∞
+      - else
+        %time.formatted{ datetime: invite.expires_at.iso8601, title: l(invite.expires_at) }
+          = l invite.expires_at
+  %td= table_link_to 'link', public_invite_url(invite_code: invite.code), public_invite_url(invite_code: invite.code)
+  %td
+    - if invite.expired? && policy(invite).destroy?
+      = table_link_to 'times', t('invites.delete'), invite_path(invite), method: :delete
diff --git a/app/views/invites/index.html.haml b/app/views/invites/index.html.haml
new file mode 100644
index 000000000..f4c5047fa
--- /dev/null
+++ b/app/views/invites/index.html.haml
@@ -0,0 +1,19 @@
+- content_for :page_title do
+  = t('invites.title')
+
+- if policy(:invite).create?
+  %p= t('invites.prompt')
+
+  = render 'form'
+
+  %hr/
+
+%table.table
+  %thead
+    %tr
+      %th= t('invites.table.uses')
+      %th= t('invites.table.expires_at')
+      %th
+      %th
+  %tbody
+    = render @invites
diff --git a/app/views/settings/migrations/show.html.haml b/app/views/settings/migrations/show.html.haml
new file mode 100644
index 000000000..b7c34761f
--- /dev/null
+++ b/app/views/settings/migrations/show.html.haml
@@ -0,0 +1,17 @@
+- content_for :page_title do
+  = t('settings.migrate')
+
+= simple_form_for @migration, as: :migration, url: settings_migration_path, html: { method: :put } do |f|
+  - if @migration.account
+    %p.hint= t('migrations.currently_redirecting')
+
+    .fields-group
+      = render partial: 'authorize_follows/card', locals: { account: @migration.account }
+
+  = render 'shared/error_messages', object: @migration
+
+  .fields-group
+    = f.input :acct, placeholder: t('migrations.acct')
+
+  .actions
+    = f.button :button, t('migrations.proceed'), type: :submit, class: 'negative'
diff --git a/app/views/settings/profiles/show.html.haml b/app/views/settings/profiles/show.html.haml
index 551a7ca49..be7bd0ba0 100644
--- a/app/views/settings/profiles/show.html.haml
+++ b/app/views/settings/profiles/show.html.haml
@@ -21,3 +21,8 @@
 
   .actions
     = f.button :button, t('generic.save_changes'), type: :submit
+
+%hr/
+
+%h6= t('auth.migrate_account')
+%p.muted-hint= t('auth.migrate_account_html', path: settings_migration_path)
diff --git a/app/views/stream_entries/show.html.haml b/app/views/stream_entries/show.html.haml
index 428069931..895a61247 100644
--- a/app/views/stream_entries/show.html.haml
+++ b/app/views/stream_entries/show.html.haml
@@ -8,7 +8,7 @@
 
   = opengraph 'og:site_name', site_title
   = opengraph 'og:type', 'article'
-  = opengraph 'og:title', "#{@account.username} on #{site_hostname}"
+  = opengraph 'og:title', "#{@account.display_name.presence || @account.username} on #{site_hostname}"
   = opengraph 'og:url', account_stream_entry_url(@account, @stream_entry)
 
   = render 'stream_entries/og_description', activity: @stream_entry.activity
diff --git a/app/workers/activitypub/raw_distribution_worker.rb b/app/workers/activitypub/raw_distribution_worker.rb
index d73466f6e..41e61132f 100644
--- a/app/workers/activitypub/raw_distribution_worker.rb
+++ b/app/workers/activitypub/raw_distribution_worker.rb
@@ -5,10 +5,10 @@ class ActivityPub::RawDistributionWorker
 
   sidekiq_options queue: 'push'
 
-  def perform(json, source_account_id)
+  def perform(json, source_account_id, exclude_inboxes = [])
     @account = Account.find(source_account_id)
 
-    ActivityPub::DeliveryWorker.push_bulk(inboxes) do |inbox_url|
+    ActivityPub::DeliveryWorker.push_bulk(inboxes - exclude_inboxes) do |inbox_url|
       [json, @account.id, inbox_url]
     end
   rescue ActiveRecord::RecordNotFound