about summary refs log tree commit diff
path: root/app
diff options
context:
space:
mode:
authorDavid Yip <yipdw@member.fsf.org>2018-05-03 17:23:44 -0500
committerDavid Yip <yipdw@member.fsf.org>2018-05-03 17:23:44 -0500
commitc816701550d7cdb593371dc47d0b9430c78308b0 (patch)
treecc4417d14de20e69fd5f9a58d66f84af4a623329 /app
parent3a47842223ff93d8c057f804809f1b111dfd6f76 (diff)
parenta7e71bbd08e089938fbf20ddef5768c2f3ee0702 (diff)
Merge remote-tracking branch 'origin/master' into gs-master
  Conflicts:
 	.travis.yml
 	Gemfile.lock
 	README.md
 	app/controllers/settings/follower_domains_controller.rb
 	app/controllers/statuses_controller.rb
 	app/javascript/mastodon/locales/ja.json
 	app/lib/feed_manager.rb
 	app/models/media_attachment.rb
 	app/models/mute.rb
 	app/models/status.rb
 	app/services/mute_service.rb
 	app/views/home/index.html.haml
 	app/views/stream_entries/_simple_status.html.haml
 	config/locales/ca.yml
 	config/locales/en.yml
 	config/locales/es.yml
 	config/locales/fr.yml
 	config/locales/nl.yml
 	config/locales/pl.yml
 	config/locales/pt-BR.yml
 	config/themes.yml
Diffstat (limited to 'app')
-rw-r--r--app/controllers/accounts_controller.rb10
-rw-r--r--app/controllers/admin/reported_statuses_controller.rb14
-rw-r--r--app/controllers/admin/reports_controller.rb8
-rw-r--r--app/controllers/api/base_controller.rb4
-rw-r--r--app/controllers/api/v1/accounts/credentials_controller.rb2
-rw-r--r--app/controllers/api/v1/accounts_controller.rb5
-rw-r--r--app/controllers/api/v1/statuses_controller.rb2
-rw-r--r--app/controllers/api/web/embeds_controller.rb11
-rw-r--r--app/controllers/concerns/localized.rb8
-rw-r--r--app/controllers/statuses_controller.rb76
-rw-r--r--app/controllers/stream_entries_controller.rb1
-rw-r--r--app/controllers/tags_controller.rb11
-rw-r--r--app/helpers/admin/account_moderation_notes_helper.rb16
-rw-r--r--app/helpers/application_helper.rb4
-rw-r--r--app/helpers/jsonld_helper.rb21
-rw-r--r--app/helpers/settings_helper.rb5
-rw-r--r--app/helpers/stream_entries_helper.rb25
-rw-r--r--app/javascript/core/admin.js1
-rw-r--r--app/javascript/mastodon/actions/compose.js19
-rw-r--r--app/javascript/mastodon/actions/push_notifications/registerer.js9
-rw-r--r--app/javascript/mastodon/base_polyfills.js21
-rw-r--r--app/javascript/mastodon/components/autosuggest_textarea.js24
-rw-r--r--app/javascript/mastodon/components/dropdown_menu.js2
-rw-r--r--app/javascript/mastodon/components/relative_timestamp.js15
-rw-r--r--app/javascript/mastodon/components/scrollable_list.js13
-rw-r--r--app/javascript/mastodon/components/status.js10
-rw-r--r--app/javascript/mastodon/components/status_action_bar.js4
-rw-r--r--app/javascript/mastodon/components/status_list.js20
-rw-r--r--app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js13
-rw-r--r--app/javascript/mastodon/features/compose/components/reply_indicator.js2
-rw-r--r--app/javascript/mastodon/features/status/components/action_bar.js4
-rw-r--r--app/javascript/mastodon/load_polyfills.js7
-rw-r--r--app/javascript/mastodon/locales/ar.json27
-rw-r--r--app/javascript/mastodon/locales/bg.json1
-rw-r--r--app/javascript/mastodon/locales/ca.json31
-rw-r--r--app/javascript/mastodon/locales/de.json79
-rw-r--r--app/javascript/mastodon/locales/el.json296
-rw-r--r--app/javascript/mastodon/locales/en.json1
-rw-r--r--app/javascript/mastodon/locales/eo.json1
-rw-r--r--app/javascript/mastodon/locales/es.json1
-rw-r--r--app/javascript/mastodon/locales/eu.json296
-rw-r--r--app/javascript/mastodon/locales/fa.json1
-rw-r--r--app/javascript/mastodon/locales/fi.json1
-rw-r--r--app/javascript/mastodon/locales/fr.json29
-rw-r--r--app/javascript/mastodon/locales/gl.json5
-rw-r--r--app/javascript/mastodon/locales/he.json1
-rw-r--r--app/javascript/mastodon/locales/hr.json1
-rw-r--r--app/javascript/mastodon/locales/hu.json1
-rw-r--r--app/javascript/mastodon/locales/hy.json1
-rw-r--r--app/javascript/mastodon/locales/id.json1
-rw-r--r--app/javascript/mastodon/locales/io.json1
-rw-r--r--app/javascript/mastodon/locales/it.json9
-rw-r--r--app/javascript/mastodon/locales/ja.json13
-rw-r--r--app/javascript/mastodon/locales/ko.json5
-rw-r--r--app/javascript/mastodon/locales/nl.json29
-rw-r--r--app/javascript/mastodon/locales/no.json1
-rw-r--r--app/javascript/mastodon/locales/oc.json37
-rw-r--r--app/javascript/mastodon/locales/pl.json10
-rw-r--r--app/javascript/mastodon/locales/pt-BR.json23
-rw-r--r--app/javascript/mastodon/locales/pt.json1
-rw-r--r--app/javascript/mastodon/locales/ru.json1
-rw-r--r--app/javascript/mastodon/locales/sk.json21
-rw-r--r--app/javascript/mastodon/locales/sr-Latn.json1
-rw-r--r--app/javascript/mastodon/locales/sr.json1
-rw-r--r--app/javascript/mastodon/locales/sv.json29
-rw-r--r--app/javascript/mastodon/locales/te.json296
-rw-r--r--app/javascript/mastodon/locales/th.json1
-rw-r--r--app/javascript/mastodon/locales/tr.json1
-rw-r--r--app/javascript/mastodon/locales/uk.json1
-rw-r--r--app/javascript/mastodon/locales/whitelist_el.json2
-rw-r--r--app/javascript/mastodon/locales/whitelist_eu.json2
-rw-r--r--app/javascript/mastodon/locales/whitelist_te.json2
-rw-r--r--app/javascript/mastodon/locales/zh-CN.json1
-rw-r--r--app/javascript/mastodon/locales/zh-HK.json1
-rw-r--r--app/javascript/mastodon/locales/zh-TW.json1
-rw-r--r--app/javascript/mastodon/reducers/notifications.js2
-rw-r--r--app/javascript/mastodon/reducers/timelines.js2
-rw-r--r--app/javascript/mastodon/utils/__tests__/base64-test.js10
-rw-r--r--app/javascript/mastodon/utils/base64.js10
-rw-r--r--app/javascript/mastodon/utils/resize_image.js66
-rw-r--r--app/javascript/styles/contrast.scss3
-rw-r--r--app/javascript/styles/contrast/diff.scss14
-rw-r--r--app/javascript/styles/contrast/variables.scss24
-rw-r--r--app/javascript/styles/mastodon/about.scss32
-rw-r--r--app/javascript/styles/mastodon/accounts.scss22
-rw-r--r--app/javascript/styles/mastodon/admin.scss123
-rw-r--r--app/javascript/styles/mastodon/compact_header.scss4
-rw-r--r--app/javascript/styles/mastodon/components.scss175
-rw-r--r--app/javascript/styles/mastodon/containers.scss2
-rw-r--r--app/javascript/styles/mastodon/emoji_picker.scss4
-rw-r--r--app/javascript/styles/mastodon/forms.scss10
-rw-r--r--app/javascript/styles/mastodon/landing_strip.scss2
-rw-r--r--app/javascript/styles/mastodon/stream_entries.scss14
-rw-r--r--app/javascript/styles/mastodon/tables.scss116
-rw-r--r--app/javascript/styles/mastodon/variables.scss20
-rw-r--r--app/lib/activitypub/activity.rb2
-rw-r--r--app/lib/activitypub/activity/announce.rb2
-rw-r--r--app/lib/activitypub/activity/create.rb13
-rw-r--r--app/lib/activitypub/activity/update.rb7
-rw-r--r--app/lib/entity_cache.rb34
-rw-r--r--app/lib/exceptions.rb1
-rw-r--r--app/lib/feed_manager.rb17
-rw-r--r--app/lib/formatter.rb10
-rw-r--r--app/lib/ostatus/activity/creation.rb4
-rw-r--r--app/lib/ostatus/atom_serializer.rb2
-rw-r--r--app/lib/provider_discovery.rb47
-rw-r--r--app/lib/request.rb19
-rw-r--r--app/lib/rss_builder.rb130
-rw-r--r--app/lib/status_filter.rb17
-rw-r--r--app/models/account.rb15
-rw-r--r--app/models/account_domain_block.rb4
-rw-r--r--app/models/account_moderation_note.rb6
-rw-r--r--app/models/admin/action_log.rb6
-rw-r--r--app/models/backup.rb4
-rw-r--r--app/models/block.rb6
-rw-r--r--app/models/concerns/account_interactions.rb13
-rw-r--r--app/models/concerns/attachmentable.rb32
-rw-r--r--app/models/concerns/cacheable.rb15
-rw-r--r--app/models/concerns/remotable.rb2
-rw-r--r--app/models/concerns/status_threading_concern.rb38
-rw-r--r--app/models/conversation.rb2
-rw-r--r--app/models/conversation_mute.rb6
-rw-r--r--app/models/custom_emoji.rb14
-rw-r--r--app/models/domain_block.rb2
-rw-r--r--app/models/email_domain_block.rb2
-rw-r--r--app/models/favourite.rb6
-rw-r--r--app/models/follow.rb6
-rw-r--r--app/models/follow_request.rb6
-rw-r--r--app/models/import.rb4
-rw-r--r--app/models/invite.rb4
-rw-r--r--app/models/list.rb4
-rw-r--r--app/models/list_account.rb8
-rw-r--r--app/models/media_attachment.rb19
-rw-r--r--app/models/mention.rb6
-rw-r--r--app/models/mute.rb6
-rw-r--r--app/models/notification.rb8
-rw-r--r--app/models/preview_card.rb21
-rw-r--r--app/models/report.rb12
-rw-r--r--app/models/report_note.rb6
-rw-r--r--app/models/session_activation.rb8
-rw-r--r--app/models/setting.rb4
-rw-r--r--app/models/site_upload.rb2
-rw-r--r--app/models/status.rb17
-rw-r--r--app/models/status_pin.rb6
-rw-r--r--app/models/stream_entry.rb6
-rw-r--r--app/models/subscription.rb4
-rw-r--r--app/models/tag.rb2
-rw-r--r--app/models/user.rb6
-rw-r--r--app/models/web/push_subscription.rb2
-rw-r--r--app/models/web/setting.rb4
-rw-r--r--app/policies/status_policy.rb46
-rw-r--r--app/serializers/rest/credential_account_serializer.rb2
-rw-r--r--app/serializers/rss/account_serializer.rb39
-rw-r--r--app/serializers/rss/tag_serializer.rb37
-rw-r--r--app/services/account_search_service.rb4
-rw-r--r--app/services/activitypub/fetch_featured_collection_service.rb2
-rw-r--r--app/services/activitypub/fetch_remote_account_service.rb2
-rw-r--r--app/services/activitypub/fetch_remote_key_service.rb4
-rw-r--r--app/services/activitypub/fetch_remote_status_service.rb2
-rw-r--r--app/services/activitypub/process_account_service.rb5
-rw-r--r--app/services/fan_out_on_write_service.rb2
-rw-r--r--app/services/favourite_service.rb2
-rw-r--r--app/services/fetch_atom_service.rb4
-rw-r--r--app/services/fetch_link_card_service.rb38
-rw-r--r--app/services/fetch_oembed_service.rb71
-rw-r--r--app/services/mute_service.rb7
-rw-r--r--app/services/process_hashtags_service.rb2
-rw-r--r--app/services/process_mentions_service.rb44
-rw-r--r--app/services/resolve_account_service.rb2
-rw-r--r--app/services/resolve_url_service.rb5
-rw-r--r--app/validators/disallowed_hashtags_validator.rb22
-rw-r--r--app/views/admin/action_logs/_action_log.html.haml2
-rw-r--r--app/views/admin/action_logs/index.html.haml3
-rw-r--r--app/views/admin/report_notes/_report_note.html.haml14
-rw-r--r--app/views/admin/reports/_account.html.haml19
-rw-r--r--app/views/admin/reports/_account_details.html.haml20
-rw-r--r--app/views/admin/reports/_action_log.html.haml6
-rw-r--r--app/views/admin/reports/_report.html.haml6
-rw-r--r--app/views/admin/reports/_status.html.haml28
-rw-r--r--app/views/admin/reports/index.html.haml27
-rw-r--r--app/views/admin/reports/show.html.haml117
-rw-r--r--app/views/home/index.html.haml5
-rw-r--r--app/views/stream_entries/_detailed_status.html.haml6
-rw-r--r--app/views/stream_entries/_more.html.haml2
-rw-r--r--app/views/stream_entries/_simple_status.html.haml13
-rw-r--r--app/views/stream_entries/_status.html.haml31
-rw-r--r--app/views/well_known/host_meta/show.xml.ruby16
-rw-r--r--app/views/well_known/webfinger/show.xml.ruby57
-rw-r--r--app/workers/activitypub/processing_worker.rb2
-rw-r--r--app/workers/local_notification_worker.rb12
-rw-r--r--app/workers/processing_worker.rb2
-rw-r--r--app/workers/scheduler/backup_cleanup_scheduler.rb1
-rw-r--r--app/workers/scheduler/doorkeeper_cleanup_scheduler.rb1
-rw-r--r--app/workers/scheduler/email_scheduler.rb1
-rw-r--r--app/workers/scheduler/feed_cleanup_scheduler.rb1
-rw-r--r--app/workers/scheduler/ip_cleanup_scheduler.rb1
-rw-r--r--app/workers/scheduler/media_cleanup_scheduler.rb1
-rw-r--r--app/workers/scheduler/subscriptions_cleanup_scheduler.rb2
-rw-r--r--app/workers/scheduler/subscriptions_scheduler.rb3
-rw-r--r--app/workers/scheduler/user_cleanup_scheduler.rb1
-rw-r--r--app/workers/soft_block_domain_followers_worker.rb2
201 files changed, 2807 insertions, 892 deletions
diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb
index 1efaf619b..50f5d0b11 100644
--- a/app/controllers/accounts_controller.rb
+++ b/app/controllers/accounts_controller.rb
@@ -21,9 +21,10 @@ class AccountsController < ApplicationController
         @pinned_statuses = cache_collection(@account.pinned_statuses, Status) if show_pinned_statuses?
         @statuses        = filtered_status_page(params)
         @statuses        = cache_collection(@statuses, Status)
+
         unless @statuses.empty?
-          @older_url        = older_url if @statuses.last.id > filtered_statuses.last.id
-          @newer_url        = newer_url if @statuses.first.id < filtered_statuses.first.id
+          @older_url = older_url if @statuses.last.id > filtered_statuses.last.id
+          @newer_url = newer_url if @statuses.first.id < filtered_statuses.first.id
         end
       end
 
@@ -32,6 +33,11 @@ class AccountsController < ApplicationController
         render xml: OStatus::AtomSerializer.render(OStatus::AtomSerializer.new.feed(@account, @entries.reject { |entry| entry.status.nil? }))
       end
 
+      format.rss do
+        @statuses = cache_collection(default_statuses.without_reblogs.without_replies.limit(PAGE_SIZE), Status)
+        render xml: RSS::AccountSerializer.render(@account, @statuses)
+      end
+
       format.json do
         skip_session!
 
diff --git a/app/controllers/admin/reported_statuses_controller.rb b/app/controllers/admin/reported_statuses_controller.rb
index 535bd11d4..522f68c98 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.merge(current_account: current_account))
+      @form         = Form::StatusBatch.new(form_status_batch_params.merge(current_account: current_account, action: action_from_button))
       flash[:alert] = I18n.t('admin.statuses.failed_to_execute') unless @form.save
 
       redirect_to admin_report_path(@report)
@@ -35,7 +35,17 @@ module Admin
     end
 
     def form_status_batch_params
-      params.require(:form_status_batch).permit(:action, status_ids: [])
+      params.require(:form_status_batch).permit(status_ids: [])
+    end
+
+    def action_from_button
+      if params[:nsfw_on]
+        'nsfw_on'
+      elsif params[:nsfw_off]
+        'nsfw_off'
+      elsif params[:delete]
+        'delete'
+      end
     end
 
     def set_report
diff --git a/app/controllers/admin/reports_controller.rb b/app/controllers/admin/reports_controller.rb
index a4ae9507d..d00b3d222 100644
--- a/app/controllers/admin/reports_controller.rb
+++ b/app/controllers/admin/reports_controller.rb
@@ -11,10 +11,10 @@ module Admin
 
     def show
       authorize @report, :show?
-      @report_note = @report.notes.new
-      @report_notes = @report.notes.latest
-      @report_history = @report.history
-      @form = Form::StatusBatch.new
+
+      @report_note  = @report.notes.new
+      @report_notes = (@report.notes.latest + @report.history).sort_by(&:created_at)
+      @form         = Form::StatusBatch.new
     end
 
     def update
diff --git a/app/controllers/api/base_controller.rb b/app/controllers/api/base_controller.rb
index 7b5168b31..b5c084e14 100644
--- a/app/controllers/api/base_controller.rb
+++ b/app/controllers/api/base_controller.rb
@@ -66,8 +66,10 @@ class Api::BaseController < ApplicationController
   end
 
   def require_user!
-    if current_user
+    if current_user && !current_user.disabled?
       set_user_activity
+    elsif current_user
+      render json: { error: 'Your login is currently disabled' }, status: 403
     else
       render json: { error: 'This method requires an authenticated user' }, status: 422
     end
diff --git a/app/controllers/api/v1/accounts/credentials_controller.rb b/app/controllers/api/v1/accounts/credentials_controller.rb
index 062d490a7..a3c4008e6 100644
--- a/app/controllers/api/v1/accounts/credentials_controller.rb
+++ b/app/controllers/api/v1/accounts/credentials_controller.rb
@@ -21,7 +21,7 @@ class Api::V1::Accounts::CredentialsController < Api::BaseController
   private
 
   def account_params
-    params.permit(:display_name, :note, :avatar, :header, :locked)
+    params.permit(:display_name, :note, :avatar, :header, :locked, fields_attributes: [:name, :value])
   end
 
   def user_settings_params
diff --git a/app/controllers/api/v1/accounts_controller.rb b/app/controllers/api/v1/accounts_controller.rb
index d64325944..b7133ca8e 100644
--- a/app/controllers/api/v1/accounts_controller.rb
+++ b/app/controllers/api/v1/accounts_controller.rb
@@ -5,6 +5,7 @@ class Api::V1::AccountsController < Api::BaseController
   before_action -> { doorkeeper_authorize! :follow }, only: [:follow, :unfollow, :block, :unblock, :mute, :unmute]
   before_action :require_user!, except: [:show]
   before_action :set_account
+  before_action :check_account_suspension, only: [:show]
 
   respond_to :json
 
@@ -54,4 +55,8 @@ class Api::V1::AccountsController < Api::BaseController
   def relationships(**options)
     AccountRelationshipsPresenter.new([@account.id], current_user.account_id, options)
   end
+
+  def check_account_suspension
+    gone if @account.suspended?
+  end
 end
diff --git a/app/controllers/api/v1/statuses_controller.rb b/app/controllers/api/v1/statuses_controller.rb
index e98241323..01880565c 100644
--- a/app/controllers/api/v1/statuses_controller.rb
+++ b/app/controllers/api/v1/statuses_controller.rb
@@ -18,7 +18,7 @@ class Api::V1::StatusesController < Api::BaseController
 
   def context
     ancestors_results   = @status.in_reply_to_id.nil? ? [] : @status.ancestors(DEFAULT_STATUSES_LIMIT, current_account)
-    descendants_results = @status.descendants(current_account)
+    descendants_results = @status.descendants(DEFAULT_STATUSES_LIMIT, current_account)
     loaded_ancestors    = cache_collection(ancestors_results, Status)
     loaded_descendants  = cache_collection(descendants_results, Status)
 
diff --git a/app/controllers/api/web/embeds_controller.rb b/app/controllers/api/web/embeds_controller.rb
index f2fe74b17..987290a14 100644
--- a/app/controllers/api/web/embeds_controller.rb
+++ b/app/controllers/api/web/embeds_controller.rb
@@ -9,9 +9,12 @@ class Api::Web::EmbedsController < Api::Web::BaseController
     status = StatusFinder.new(params[:url]).status
     render json: status, serializer: OEmbedSerializer, width: 400
   rescue ActiveRecord::RecordNotFound
-    oembed = OEmbed::Providers.get(params[:url])
-    render json: Oj.dump(oembed.fields)
-  rescue OEmbed::NotFound
-    render json: {}, status: :not_found
+    oembed = FetchOEmbedService.new.call(params[:url])
+
+    if oembed
+      render json: oembed
+    else
+      render json: {}, status: :not_found
+    end
   end
 end
diff --git a/app/controllers/concerns/localized.rb b/app/controllers/concerns/localized.rb
index abd85ea27..145549bcd 100644
--- a/app/controllers/concerns/localized.rb
+++ b/app/controllers/concerns/localized.rb
@@ -29,10 +29,14 @@ module Localized
   end
 
   def preferred_locale
-    http_accept_language.preferred_language_from(I18n.available_locales)
+    http_accept_language.preferred_language_from(available_locales)
   end
 
   def compatible_locale
-    http_accept_language.compatible_language_from(I18n.available_locales)
+    http_accept_language.compatible_language_from(available_locales)
+  end
+
+  def available_locales
+    I18n.available_locales.reverse
   end
 end
diff --git a/app/controllers/statuses_controller.rb b/app/controllers/statuses_controller.rb
index 3237a15b9..2e9cf14e0 100644
--- a/app/controllers/statuses_controller.rb
+++ b/app/controllers/statuses_controller.rb
@@ -4,7 +4,9 @@ class StatusesController < ApplicationController
   include SignatureAuthentication
   include Authorization
 
-  ANCESTORS_LIMIT = 20
+  ANCESTORS_LIMIT         = 40
+  DESCENDANTS_LIMIT       = 60
+  DESCENDANTS_DEPTH_LIMIT = 20
 
   layout 'public'
 
@@ -20,9 +22,8 @@ class StatusesController < ApplicationController
     respond_to do |format|
       format.html do
         use_pack 'public'
-        @ancestors     = @status.reply? ? cache_collection(@status.ancestors(ANCESTORS_LIMIT, current_account), Status) : []
-        @next_ancestor = @ancestors.size < ANCESTORS_LIMIT ? nil : @ancestors.shift
-        @descendants   = cache_collection(@status.descendants(current_account), Status)
+        set_ancestors
+        set_descendants
 
         render 'stream_entries/show'
       end
@@ -53,10 +54,77 @@ class StatusesController < ApplicationController
 
   private
 
+  def create_descendant_thread(depth, statuses)
+    if depth < DESCENDANTS_DEPTH_LIMIT
+      { statuses: statuses }
+    else
+      next_status = statuses.pop
+      { statuses: statuses, next_status: next_status }
+    end
+  end
+
   def set_account
     @account = Account.find_local!(params[:account_username])
   end
 
+  def set_ancestors
+    @ancestors     = @status.reply? ? cache_collection(@status.ancestors(ANCESTORS_LIMIT, current_account), Status) : []
+    @next_ancestor = @ancestors.size < ANCESTORS_LIMIT ? nil : @ancestors.shift
+  end
+
+  def set_descendants
+    @max_descendant_thread_id   = params[:max_descendant_thread_id]&.to_i
+    @since_descendant_thread_id = params[:since_descendant_thread_id]&.to_i
+
+    descendants = cache_collection(
+      @status.descendants(
+        DESCENDANTS_LIMIT,
+        current_account,
+        @max_descendant_thread_id,
+        @since_descendant_thread_id,
+        DESCENDANTS_DEPTH_LIMIT
+      ),
+      Status
+    )
+
+    @descendant_threads = []
+
+    if descendants.present?
+      statuses = [descendants.first]
+      depth    = 1
+
+      descendants.drop(1).each_with_index do |descendant, index|
+        if descendants[index].id == descendant.in_reply_to_id
+          depth += 1
+          statuses << descendant
+        else
+          @descendant_threads << create_descendant_thread(depth, statuses)
+
+          @descendant_threads.reverse_each do |descendant_thread|
+            statuses = descendant_thread[:statuses]
+
+            index = statuses.find_index do |thread_status|
+              thread_status.id == descendant.in_reply_to_id
+            end
+
+            if index.present?
+              depth += index - statuses.size
+              break
+            end
+
+            depth -= statuses.size
+          end
+
+          statuses = [descendant]
+        end
+      end
+
+      @descendant_threads << create_descendant_thread(depth, statuses)
+    end
+
+    @max_descendant_thread_id = @descendant_threads.pop[:statuses].first.id if descendants.size >= DESCENDANTS_LIMIT
+  end
+
   def set_link_headers
     response.headers['Link'] = LinkHeader.new(
       [
diff --git a/app/controllers/stream_entries_controller.rb b/app/controllers/stream_entries_controller.rb
index 44e9c0bb8..8cb54a148 100644
--- a/app/controllers/stream_entries_controller.rb
+++ b/app/controllers/stream_entries_controller.rb
@@ -24,6 +24,7 @@ class StreamEntriesController < ApplicationController
           skip_session!
           expires_in 3.minutes, public: true
         end
+
         render xml: OStatus::AtomSerializer.render(OStatus::AtomSerializer.new.entry(@stream_entry, true))
       end
     end
diff --git a/app/controllers/tags_controller.rb b/app/controllers/tags_controller.rb
index 5d11a8139..a76be26e5 100644
--- a/app/controllers/tags_controller.rb
+++ b/app/controllers/tags_controller.rb
@@ -1,6 +1,8 @@
 # frozen_string_literal: true
 
 class TagsController < ApplicationController
+  PAGE_SIZE = 20
+
   before_action :set_body_classes
   before_action :set_instance_presenter
 
@@ -14,8 +16,15 @@ class TagsController < ApplicationController
         @initial_state_json   = serializable_resource.to_json
       end
 
+      format.rss do
+        @statuses = Status.as_tag_timeline(@tag).limit(PAGE_SIZE)
+        @statuses = cache_collection(@statuses, Status)
+
+        render xml: RSS::TagSerializer.render(@tag, @statuses)
+      end
+
       format.json do
-        @statuses = Status.as_tag_timeline(@tag, current_account, params[:local]).paginate_by_max_id(20, params[:max_id])
+        @statuses = Status.as_tag_timeline(@tag, current_account, params[:local]).paginate_by_max_id(PAGE_SIZE, params[:max_id])
         @statuses = cache_collection(@statuses, Status)
 
         render json: collection_presenter,
diff --git a/app/helpers/admin/account_moderation_notes_helper.rb b/app/helpers/admin/account_moderation_notes_helper.rb
index b17c52264..fdfadef08 100644
--- a/app/helpers/admin/account_moderation_notes_helper.rb
+++ b/app/helpers/admin/account_moderation_notes_helper.rb
@@ -1,4 +1,20 @@
 # frozen_string_literal: true
 
 module Admin::AccountModerationNotesHelper
+  def admin_account_link_to(account)
+    link_to admin_account_path(account.id), class: name_tag_classes(account) do
+      safe_join([
+                  image_tag(account.avatar.url, width: 15, height: 15, alt: display_name(account), class: 'avatar'),
+                  content_tag(:span, account.acct, class: 'username'),
+                ], ' ')
+    end
+  end
+
+  private
+
+  def name_tag_classes(account)
+    classes = ['name-tag']
+    classes << 'suspended' if account.suspended?
+    classes.join(' ')
+  end
 end
diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb
index bab4615a1..95863ab1f 100644
--- a/app/helpers/application_helper.rb
+++ b/app/helpers/application_helper.rb
@@ -63,4 +63,8 @@ module ApplicationHelper
   def opengraph(property, content)
     tag(:meta, content: content, property: property)
   end
+
+  def react_component(name, props = {})
+    content_tag(:div, nil, data: { component: name.to_s.camelcase, props: Oj.dump(props) })
+  end
 end
diff --git a/app/helpers/jsonld_helper.rb b/app/helpers/jsonld_helper.rb
index dfb8fcb8b..e9056166c 100644
--- a/app/helpers/jsonld_helper.rb
+++ b/app/helpers/jsonld_helper.rb
@@ -5,6 +5,10 @@ module JsonLdHelper
     haystack.is_a?(Array) ? haystack.include?(needle) : haystack == needle
   end
 
+  def equals_or_includes_any?(haystack, needles)
+    needles.any? { |needle| equals_or_includes?(haystack, needle) }
+  end
+
   def first_of_value(value)
     value.is_a?(Array) ? value.first : value
   end
@@ -44,7 +48,7 @@ module JsonLdHelper
   end
 
   def canonicalize(json)
-    graph = RDF::Graph.new << JSON::LD::API.toRdf(json)
+    graph = RDF::Graph.new << JSON::LD::API.toRdf(json, documentLoader: method(:load_jsonld_context))
     graph.dump(:normalize)
   end
 
@@ -86,4 +90,19 @@ module JsonLdHelper
     request.add_headers('Accept' => 'application/activity+json, application/ld+json')
     request
   end
+
+  def load_jsonld_context(url, _options = {}, &_block)
+    json = Rails.cache.fetch("jsonld:context:#{url}", expires_in: 30.days, raw: true) do
+      request = Request.new(:get, url)
+      request.add_headers('Accept' => 'application/ld+json')
+
+      request.perform do |res|
+        raise JSON::LD::JsonLdError::LoadingDocumentFailed unless res.code == 200 && res.mime_type == 'application/ld+json'
+        res.body_with_limit
+      end
+    end
+
+    doc = JSON::LD::API::RemoteDocument.new(url, json)
+    block_given? ? yield(doc) : doc
+  end
 end
diff --git a/app/helpers/settings_helper.rb b/app/helpers/settings_helper.rb
index a2f5917f9..f78e5fbc3 100644
--- a/app/helpers/settings_helper.rb
+++ b/app/helpers/settings_helper.rb
@@ -7,12 +7,14 @@ module SettingsHelper
     bg: 'Български',
     ca: 'Català',
     de: 'Deutsch',
+    el: 'Ελληνικά',
     eo: 'Esperanto',
     es: 'Español',
+    eu: 'Euskara',
     fa: 'فارسی',
-    gl: 'Galego',
     fi: 'Suomi',
     fr: 'Français',
+    gl: 'Galego',
     he: 'עברית',
     hr: 'Hrvatski',
     hu: 'Magyar',
@@ -33,6 +35,7 @@ module SettingsHelper
     sr: 'Српски',
     'sr-Latn': 'Srpski (latinica)',
     sv: 'Svenska',
+    te: 'తెలుగు',
     th: 'ภาษาไทย',
     tr: 'Türkçe',
     uk: 'Українська',
diff --git a/app/helpers/stream_entries_helper.rb b/app/helpers/stream_entries_helper.rb
index 3992432db..c6f12ecd4 100644
--- a/app/helpers/stream_entries_helper.rb
+++ b/app/helpers/stream_entries_helper.rb
@@ -12,17 +12,17 @@ module StreamEntriesHelper
     prepend_str = [
       [
         number_to_human(account.statuses_count, strip_insignificant_zeros: true),
-        t('accounts.posts'),
+        I18n.t('accounts.posts'),
       ].join(' '),
 
       [
         number_to_human(account.following_count, strip_insignificant_zeros: true),
-        t('accounts.following'),
+        I18n.t('accounts.following'),
       ].join(' '),
 
       [
         number_to_human(account.followers_count, strip_insignificant_zeros: true),
-        t('accounts.followers'),
+        I18n.t('accounts.followers'),
       ].join(' '),
     ].join(', ')
 
@@ -40,16 +40,16 @@ module StreamEntriesHelper
       end
     end
 
-    text = attachments.to_a.reject { |_, value| value.zero? }.map { |key, value| t("statuses.attached.#{key}", count: value) }.join(' · ')
+    text = attachments.to_a.reject { |_, value| value.zero? }.map { |key, value| I18n.t("statuses.attached.#{key}", count: value) }.join(' · ')
 
     return if text.blank?
 
-    t('statuses.attached.description', attached: text)
+    I18n.t('statuses.attached.description', attached: text)
   end
 
   def status_text_summary(status)
     return if status.spoiler_text.blank?
-    t('statuses.content_warning', warning: status.spoiler_text)
+    I18n.t('statuses.content_warning', warning: status.spoiler_text)
   end
 
   def status_description(status)
@@ -113,6 +113,19 @@ module StreamEntriesHelper
     end
   end
 
+  def fa_visibility_icon(status)
+    case status.visibility
+    when 'public'
+      fa_icon 'globe fw'
+    when 'unlisted'
+      fa_icon 'unlock-alt fw'
+    when 'private'
+      fa_icon 'lock fw'
+    when 'direct'
+      fa_icon 'envelope fw'
+    end
+  end
+
   private
 
   def simplified_text(text)
diff --git a/app/javascript/core/admin.js b/app/javascript/core/admin.js
index b4125e84e..28f27fbc6 100644
--- a/app/javascript/core/admin.js
+++ b/app/javascript/core/admin.js
@@ -26,6 +26,7 @@ delegate(document, batchCheckboxClassName, 'change', () => {
   const checkAllElement = document.querySelector('#batch_checkbox_all');
   if (checkAllElement) {
     checkAllElement.checked = [].every.call(document.querySelectorAll(batchCheckboxClassName), (content) => content.checked);
+    checkAllElement.indeterminate = !checkAllElement.checked && [].some.call(document.querySelectorAll(batchCheckboxClassName), (content) => content.checked);
   }
 });
 
diff --git a/app/javascript/mastodon/actions/compose.js b/app/javascript/mastodon/actions/compose.js
index eee9c6928..fe3e831d5 100644
--- a/app/javascript/mastodon/actions/compose.js
+++ b/app/javascript/mastodon/actions/compose.js
@@ -4,6 +4,7 @@ import { throttle } from 'lodash';
 import { search as emojiSearch } from '../features/emoji/emoji_mart_search_light';
 import { tagHistory } from '../settings';
 import { useEmoji } from './emojis';
+import resizeImage from '../utils/resize_image';
 import { importFetchedAccounts } from './importer';
 import { updateTimeline } from './timelines';
 import { showAlertForError } from './alerts';
@@ -182,18 +183,14 @@ export function uploadCompose(files) {
 
     dispatch(uploadComposeRequest());
 
-    let data = new FormData();
-    data.append('file', files[0]);
+    resizeImage(files[0]).then(file => {
+      const data = new FormData();
+      data.append('file', file);
 
-    api(getState).post('/api/v1/media', data, {
-      onUploadProgress: function (e) {
-        dispatch(uploadComposeProgress(e.loaded, e.total));
-      },
-    }).then(function (response) {
-      dispatch(uploadComposeSuccess(response.data));
-    }).catch(function (error) {
-      dispatch(uploadComposeFail(error));
-    });
+      return api(getState).post('/api/v1/media', data, {
+        onUploadProgress: ({ loaded, total }) => dispatch(uploadComposeProgress(loaded, total)),
+      }).then(({ data }) => dispatch(uploadComposeSuccess(data)));
+    }).catch(error => dispatch(uploadComposeFail(error)));
   };
 };
 
diff --git a/app/javascript/mastodon/actions/push_notifications/registerer.js b/app/javascript/mastodon/actions/push_notifications/registerer.js
index 60b215f02..82fe4519a 100644
--- a/app/javascript/mastodon/actions/push_notifications/registerer.js
+++ b/app/javascript/mastodon/actions/push_notifications/registerer.js
@@ -1,4 +1,5 @@
 import api from '../../api';
+import { decode as decodeBase64 } from '../../utils/base64';
 import { pushNotificationsSetting } from '../../settings';
 import { setBrowserSupport, setSubscription, clearSubscription } from './setter';
 import { me } from '../../initial_state';
@@ -10,13 +11,7 @@ const urlBase64ToUint8Array = (base64String) => {
     .replace(/\-/g, '+')
     .replace(/_/g, '/');
 
-  const rawData = window.atob(base64);
-  const outputArray = new Uint8Array(rawData.length);
-
-  for (let i = 0; i < rawData.length; ++i) {
-    outputArray[i] = rawData.charCodeAt(i);
-  }
-  return outputArray;
+  return decodeBase64(base64);
 };
 
 const getApplicationServerKey = () => document.querySelector('[name="applicationServerKey"]').getAttribute('content');
diff --git a/app/javascript/mastodon/base_polyfills.js b/app/javascript/mastodon/base_polyfills.js
index 8fbb17785..997813a04 100644
--- a/app/javascript/mastodon/base_polyfills.js
+++ b/app/javascript/mastodon/base_polyfills.js
@@ -5,6 +5,7 @@ import includes from 'array-includes';
 import assign from 'object-assign';
 import values from 'object.values';
 import isNaN from 'is-nan';
+import { decode as decodeBase64 } from './utils/base64';
 
 if (!Array.prototype.includes) {
   includes.shim();
@@ -21,3 +22,23 @@ if (!Object.values) {
 if (!Number.isNaN) {
   Number.isNaN = isNaN;
 }
+
+if (!HTMLCanvasElement.prototype.toBlob) {
+  const BASE64_MARKER = ';base64,';
+
+  Object.defineProperty(HTMLCanvasElement.prototype, 'toBlob', {
+    value(callback, type = 'image/png', quality) {
+      const dataURL = this.toDataURL(type, quality);
+      let data;
+
+      if (dataURL.indexOf(BASE64_MARKER) >= 0) {
+        const [, base64] = dataURL.split(BASE64_MARKER);
+        data = decodeBase64(base64);
+      } else {
+        [, data] = dataURL.split(',');
+      }
+
+      callback(new Blob([data], { type }));
+    },
+  });
+}
diff --git a/app/javascript/mastodon/components/autosuggest_textarea.js b/app/javascript/mastodon/components/autosuggest_textarea.js
index 34904194f..a4f5cf50c 100644
--- a/app/javascript/mastodon/components/autosuggest_textarea.js
+++ b/app/javascript/mastodon/components/autosuggest_textarea.js
@@ -84,9 +84,17 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
       return;
     }
 
+    if (e.which === 229 || e.isComposing) {
+      // Ignore key events during text composition
+      // e.key may be a name of the physical key even in this case (e.x. Safari / Chrome on Mac)
+      return;
+    }
+
     switch(e.key) {
     case 'Escape':
-      if (!suggestionsHidden) {
+      if (suggestions.size === 0 || suggestionsHidden) {
+        document.querySelector('.ui').parentElement.focus();
+      } else {
         e.preventDefault();
         this.setState({ suggestionsHidden: true });
       }
@@ -125,16 +133,6 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
     this.props.onKeyDown(e);
   }
 
-  onKeyUp = e => {
-    if (e.key === 'Escape' && this.state.suggestionsHidden) {
-      document.querySelector('.ui').parentElement.focus();
-    }
-
-    if (this.props.onKeyUp) {
-      this.props.onKeyUp(e);
-    }
-  }
-
   onBlur = () => {
     this.setState({ suggestionsHidden: true });
   }
@@ -186,7 +184,7 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
   }
 
   render () {
-    const { value, suggestions, disabled, placeholder, autoFocus } = this.props;
+    const { value, suggestions, disabled, placeholder, onKeyUp, autoFocus } = this.props;
     const { suggestionsHidden } = this.state;
     const style = { direction: 'ltr' };
 
@@ -208,7 +206,7 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
             value={value}
             onChange={this.onChange}
             onKeyDown={this.onKeyDown}
-            onKeyUp={this.onKeyUp}
+            onKeyUp={onKeyUp}
             onBlur={this.onBlur}
             onPaste={this.onPaste}
             style={style}
diff --git a/app/javascript/mastodon/components/dropdown_menu.js b/app/javascript/mastodon/components/dropdown_menu.js
index c5c6f73b3..982d34718 100644
--- a/app/javascript/mastodon/components/dropdown_menu.js
+++ b/app/javascript/mastodon/components/dropdown_menu.js
@@ -63,7 +63,7 @@ class DropdownMenu extends React.PureComponent {
 
     if (typeof action === 'function') {
       e.preventDefault();
-      action();
+      action(e);
     } else if (to) {
       e.preventDefault();
       this.context.router.history.push(to);
diff --git a/app/javascript/mastodon/components/relative_timestamp.js b/app/javascript/mastodon/components/relative_timestamp.js
index 51588e78c..3c8db7092 100644
--- a/app/javascript/mastodon/components/relative_timestamp.js
+++ b/app/javascript/mastodon/components/relative_timestamp.js
@@ -20,7 +20,7 @@ const dateFormatOptions = {
 };
 
 const shortDateFormatOptions = {
-  month: 'numeric',
+  month: 'short',
   day: 'numeric',
 };
 
@@ -66,12 +66,17 @@ export default class RelativeTimestamp extends React.Component {
   static propTypes = {
     intl: PropTypes.object.isRequired,
     timestamp: PropTypes.string.isRequired,
+    year: PropTypes.number.isRequired,
   };
 
   state = {
     now: this.props.intl.now(),
   };
 
+  static defaultProps = {
+    year: (new Date()).getFullYear(),
+  };
+
   shouldComponentUpdate (nextProps, nextState) {
     // As of right now the locale doesn't change without a new page load,
     // but we might as well check in case that ever changes.
@@ -114,7 +119,7 @@ export default class RelativeTimestamp extends React.Component {
   }
 
   render () {
-    const { timestamp, intl } = this.props;
+    const { timestamp, intl, year } = this.props;
 
     const date  = new Date(timestamp);
     const delta = this.state.now - date.getTime();
@@ -123,7 +128,7 @@ export default class RelativeTimestamp extends React.Component {
 
     if (delta < 10 * SECOND) {
       relativeTime = intl.formatMessage(messages.just_now);
-    } else if (delta < 3 * DAY) {
+    } else if (delta < 7 * DAY) {
       if (delta < MINUTE) {
         relativeTime = intl.formatMessage(messages.seconds, { number: Math.floor(delta / SECOND) });
       } else if (delta < HOUR) {
@@ -133,8 +138,10 @@ export default class RelativeTimestamp extends React.Component {
       } else {
         relativeTime = intl.formatMessage(messages.days, { number: Math.floor(delta / DAY) });
       }
-    } else {
+    } else if (date.getFullYear() === year) {
       relativeTime = intl.formatDate(date, shortDateFormatOptions);
+    } else {
+      relativeTime = intl.formatDate(date, { ...shortDateFormatOptions, year: 'numeric' });
     }
 
     return (
diff --git a/app/javascript/mastodon/components/scrollable_list.js b/app/javascript/mastodon/components/scrollable_list.js
index fd6858d05..f8a7f91d2 100644
--- a/app/javascript/mastodon/components/scrollable_list.js
+++ b/app/javascript/mastodon/components/scrollable_list.js
@@ -35,6 +35,7 @@ export default class ScrollableList extends PureComponent {
 
   state = {
     fullscreen: null,
+    mouseOver: false,
   };
 
   intersectionObserverWrapper = new IntersectionObserverWrapper();
@@ -71,7 +72,7 @@ export default class ScrollableList extends PureComponent {
     const someItemInserted = React.Children.count(prevProps.children) > 0 &&
       React.Children.count(prevProps.children) < React.Children.count(this.props.children) &&
       this.getFirstChildKey(prevProps) !== this.getFirstChildKey(this.props);
-    if (someItemInserted && this.node.scrollTop > 0) {
+    if (someItemInserted && this.node.scrollTop > 0 || this.state.mouseOver) {
       return this.node.scrollHeight - this.node.scrollTop;
     } else {
       return null;
@@ -139,6 +140,14 @@ export default class ScrollableList extends PureComponent {
     this.props.onLoadMore();
   }
 
+  handleMouseEnter = () => {
+    this.setState({ mouseOver: true });
+  }
+
+  handleMouseLeave = () => {
+    this.setState({ mouseOver: false });
+  }
+
   render () {
     const { children, scrollKey, trackScroll, shouldUpdateScroll, isLoading, hasMore, prepend, emptyMessage, onLoadMore } = this.props;
     const { fullscreen } = this.state;
@@ -149,7 +158,7 @@ export default class ScrollableList extends PureComponent {
 
     if (isLoading || childrenCount > 0 || !emptyMessage) {
       scrollableArea = (
-        <div className={classNames('scrollable', { fullscreen })} ref={this.setRef}>
+        <div className={classNames('scrollable', { fullscreen })} ref={this.setRef} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}>
           <div role='feed' className='item-list'>
             {prepend}
 
diff --git a/app/javascript/mastodon/components/status.js b/app/javascript/mastodon/components/status.js
index e5f7c9399..402d558c4 100644
--- a/app/javascript/mastodon/components/status.js
+++ b/app/javascript/mastodon/components/status.js
@@ -114,12 +114,12 @@ export default class Status extends ImmutablePureComponent {
     this.context.router.history.push(`/accounts/${this._properStatus().getIn(['account', 'id'])}`);
   }
 
-  handleHotkeyMoveUp = () => {
-    this.props.onMoveUp(this.props.status.get('id'));
+  handleHotkeyMoveUp = e => {
+    this.props.onMoveUp(this.props.status.get('id'), e.target.getAttribute('data-featured'));
   }
 
-  handleHotkeyMoveDown = () => {
-    this.props.onMoveDown(this.props.status.get('id'));
+  handleHotkeyMoveDown = e => {
+    this.props.onMoveDown(this.props.status.get('id'), e.target.getAttribute('data-featured'));
   }
 
   handleHotkeyToggleHidden = () => {
@@ -233,7 +233,7 @@ export default class Status extends ImmutablePureComponent {
 
     return (
       <HotKeys handlers={handlers}>
-        <div className={classNames('status__wrapper', `status__wrapper-${status.get('visibility')}`, { focusable: !this.props.muted })} tabIndex={this.props.muted ? null : 0}>
+        <div className={classNames('status__wrapper', `status__wrapper-${status.get('visibility')}`, { focusable: !this.props.muted })} tabIndex={this.props.muted ? null : 0} data-featured={featured ? 'true' : null}>
           {prepend}
 
           <div className={classNames('status', `status-${status.get('visibility')}`, { muted: this.props.muted })} data-id={status.get('id')}>
diff --git a/app/javascript/mastodon/components/status_action_bar.js b/app/javascript/mastodon/components/status_action_bar.js
index e58625582..d605dbc8a 100644
--- a/app/javascript/mastodon/components/status_action_bar.js
+++ b/app/javascript/mastodon/components/status_action_bar.js
@@ -153,7 +153,9 @@ export default class StatusActionBar extends ImmutablePureComponent {
       if (publicStatus) {
         menu.push({ text: intl.formatMessage(status.get('pinned') ? messages.unpin : messages.pin), action: this.handlePinClick });
       } else {
-        menu.push({ text: intl.formatMessage(status.get('reblog') ? messages.reblog_private : messages.cancel_reblog_private), action: this.handleReblogClick });
+        if (status.get('visibility') === 'private') {
+          menu.push({ text: intl.formatMessage(status.get('reblogged') ? messages.cancel_reblog_private : messages.reblog_private), action: this.handleReblogClick });
+        }
       }
 
       menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick });
diff --git a/app/javascript/mastodon/components/status_list.js b/app/javascript/mastodon/components/status_list.js
index c98d4564e..0c971ceb0 100644
--- a/app/javascript/mastodon/components/status_list.js
+++ b/app/javascript/mastodon/components/status_list.js
@@ -30,13 +30,25 @@ export default class StatusList extends ImmutablePureComponent {
     trackScroll: true,
   };
 
-  handleMoveUp = id => {
-    const elementIndex = this.props.statusIds.indexOf(id) - 1;
+  getFeaturedStatusCount = () => {
+    return this.props.featuredStatusIds ? this.props.featuredStatusIds.size : 0;
+  }
+
+  getCurrentStatusIndex = (id, featured) => {
+    if (featured) {
+      return this.props.featuredStatusIds.indexOf(id);
+    } else {
+      return this.props.statusIds.indexOf(id) + this.getFeaturedStatusCount();
+    }
+  }
+
+  handleMoveUp = (id, featured) => {
+    const elementIndex = this.getCurrentStatusIndex(id, featured) - 1;
     this._selectChild(elementIndex);
   }
 
-  handleMoveDown = id => {
-    const elementIndex = this.props.statusIds.indexOf(id) + 1;
+  handleMoveDown = (id, featured) => {
+    const elementIndex = this.getCurrentStatusIndex(id, featured) + 1;
     this._selectChild(elementIndex);
   }
 
diff --git a/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js b/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js
index dc8fc02ba..84665a7e8 100644
--- a/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js
+++ b/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js
@@ -162,12 +162,12 @@ class EmojiPickerMenu extends React.PureComponent {
   static defaultProps = {
     style: {},
     loading: true,
-    placement: 'bottom',
     frequentlyUsedEmojis: [],
   };
 
   state = {
     modifierOpen: false,
+    placement: null,
   };
 
   handleDocumentClick = e => {
@@ -298,7 +298,7 @@ export default class EmojiPickerDropdown extends React.PureComponent {
     this.dropdown = c;
   }
 
-  onShowDropdown = () => {
+  onShowDropdown = ({ target }) => {
     this.setState({ active: true });
 
     if (!EmojiPicker) {
@@ -313,6 +313,9 @@ export default class EmojiPickerDropdown extends React.PureComponent {
         this.setState({ loading: false });
       });
     }
+
+    const { top } = target.getBoundingClientRect();
+    this.setState({ placement: top * 2 < innerHeight ? 'bottom' : 'top' });
   }
 
   onHideDropdown = () => {
@@ -324,7 +327,7 @@ export default class EmojiPickerDropdown extends React.PureComponent {
       if (this.state.active) {
         this.onHideDropdown();
       } else {
-        this.onShowDropdown();
+        this.onShowDropdown(e);
       }
     }
   }
@@ -346,7 +349,7 @@ export default class EmojiPickerDropdown extends React.PureComponent {
   render () {
     const { intl, onPickEmoji, onSkinTone, skinTone, frequentlyUsedEmojis } = this.props;
     const title = intl.formatMessage(messages.emoji);
-    const { active, loading } = this.state;
+    const { active, loading, placement } = this.state;
 
     return (
       <div className='emoji-picker-dropdown' onKeyDown={this.handleKeyDown}>
@@ -358,7 +361,7 @@ export default class EmojiPickerDropdown extends React.PureComponent {
           />
         </div>
 
-        <Overlay show={active} placement='bottom' target={this.findTarget}>
+        <Overlay show={active} placement={placement} target={this.findTarget}>
           <EmojiPickerMenu
             custom_emojis={this.props.custom_emojis}
             loading={loading}
diff --git a/app/javascript/mastodon/features/compose/components/reply_indicator.js b/app/javascript/mastodon/features/compose/components/reply_indicator.js
index d8cda96f3..5b4b81eac 100644
--- a/app/javascript/mastodon/features/compose/components/reply_indicator.js
+++ b/app/javascript/mastodon/features/compose/components/reply_indicator.js
@@ -51,7 +51,7 @@ export default class ReplyIndicator extends ImmutablePureComponent {
     return (
       <div className='reply-indicator'>
         <div className='reply-indicator__header'>
-          <div className='reply-indicator__cancel'><IconButton title={intl.formatMessage(messages.cancel)} icon='times' onClick={this.handleClick} /></div>
+          <div className='reply-indicator__cancel'><IconButton title={intl.formatMessage(messages.cancel)} icon='times' onClick={this.handleClick} inverted /></div>
 
           <a href={status.getIn(['account', 'url'])} onClick={this.handleAccountClick} className='reply-indicator__display-name'>
             <div className='reply-indicator__display-avatar'><Avatar account={status.get('account')} size={24} /></div>
diff --git a/app/javascript/mastodon/features/status/components/action_bar.js b/app/javascript/mastodon/features/status/components/action_bar.js
index fc34c8cdc..bb9b75505 100644
--- a/app/javascript/mastodon/features/status/components/action_bar.js
+++ b/app/javascript/mastodon/features/status/components/action_bar.js
@@ -123,7 +123,9 @@ export default class ActionBar extends React.PureComponent {
       if (publicStatus) {
         menu.push({ text: intl.formatMessage(status.get('pinned') ? messages.unpin : messages.pin), action: this.handlePinClick });
       } else {
-        menu.push({ text: intl.formatMessage(status.get('reblog') ? messages.reblog_private : messages.cancel_reblog_private), action: this.handleReblogClick });
+        if (status.get('visibility') === 'private') {
+          menu.push({ text: intl.formatMessage(status.get('reblogged') ? messages.cancel_reblog_private : messages.reblog_private), action: this.handleReblogClick });
+        }
       }
 
       menu.push(null);
diff --git a/app/javascript/mastodon/load_polyfills.js b/app/javascript/mastodon/load_polyfills.js
index 815e1905b..8cb81c1a6 100644
--- a/app/javascript/mastodon/load_polyfills.js
+++ b/app/javascript/mastodon/load_polyfills.js
@@ -12,12 +12,13 @@ function importExtraPolyfills() {
 
 function loadPolyfills() {
   const needsBasePolyfills = !(
+    Array.prototype.includes &&
+    HTMLCanvasElement.prototype.toBlob &&
     window.Intl &&
+    Number.isNaN &&
     Object.assign &&
     Object.values &&
-    Number.isNaN &&
-    window.Symbol &&
-    Array.prototype.includes
+    window.Symbol
   );
 
   // Latest version of Firefox and Safari do not have IntersectionObserver.
diff --git a/app/javascript/mastodon/locales/ar.json b/app/javascript/mastodon/locales/ar.json
index 24c8a5b54..947348f70 100644
--- a/app/javascript/mastodon/locales/ar.json
+++ b/app/javascript/mastodon/locales/ar.json
@@ -2,7 +2,7 @@
   "account.block": "حظر @{name}",
   "account.block_domain": "إخفاء كل شيئ قادم من إسم النطاق {domain}",
   "account.blocked": "محظور",
-  "account.direct": "Direct message @{name}",
+  "account.direct": "رسالة خاصة إلى @{name}",
   "account.disclaimer_full": "قد لا تعكس المعلومات أدناه الملف الشخصي الكامل للمستخدم.",
   "account.domain_blocked": "النطاق مخفي",
   "account.edit_profile": "تعديل الملف الشخصي",
@@ -18,7 +18,7 @@
   "account.mute_notifications": "كتم إخطارات @{name}",
   "account.muted": "مكتوم",
   "account.posts": "التبويقات",
-  "account.posts_with_replies": "تبويقات تحتوي على رُدود",
+  "account.posts_with_replies": "التبويقات و الردود",
   "account.report": "أبلغ عن @{name}",
   "account.requested": "في انتظار الموافقة",
   "account.share": "مشاركة @{name}'s profile",
@@ -29,8 +29,8 @@
   "account.unmute": "إلغاء الكتم عن @{name}",
   "account.unmute_notifications": "إلغاء كتم إخطارات @{name}",
   "account.view_full_profile": "عرض الملف الشخصي كاملا",
-  "alert.unexpected.message": "An unexpected error occurred.",
-  "alert.unexpected.title": "Oops!",
+  "alert.unexpected.message": "لقد طرأ هناك خطأ غير متوقّع.",
+  "alert.unexpected.title": "المعذرة !",
   "boost_modal.combo": "يمكنك ضغط {combo} لتخطّي هذه في المرّة القادمة",
   "bundle_column_error.body": "لقد وقع هناك خطأ أثناء عملية تحميل هذا العنصر.",
   "bundle_column_error.retry": "إعادة المحاولة",
@@ -40,8 +40,8 @@
   "bundle_modal_error.retry": "إعادة المحاولة",
   "column.blocks": "الحسابات المحجوبة",
   "column.community": "الخيط العام المحلي",
-  "column.direct": "Direct messages",
-  "column.domain_blocks": "Hidden domains",
+  "column.direct": "الرسائل المباشرة",
+  "column.domain_blocks": "النطاقات المخفية",
   "column.favourites": "المفضلة",
   "column.follow_requests": "طلبات المتابعة",
   "column.home": "الرئيسية",
@@ -59,7 +59,7 @@
   "column_header.unpin": "فك التدبيس",
   "column_subheading.navigation": "التصفح",
   "column_subheading.settings": "الإعدادات",
-  "compose_form.direct_message_warning": "This toot will only be visible to all the mentioned users.",
+  "compose_form.direct_message_warning": "لن يَظهر هذا التبويق إلا للمستخدمين المذكورين.",
   "compose_form.hashtag_warning": "هذا التبويق لن يُدرَج تحت أي وسم كان بما أنه غير مُدرَج. لا يُسمح بالبحث إلّا عن التبويقات العمومية عن طريق الوسوم.",
   "compose_form.lock_disclaimer": "حسابك ليس {locked}. يمكن لأي شخص متابعتك و عرض المنشورات.",
   "compose_form.lock_disclaimer.lock": "مقفل",
@@ -101,7 +101,7 @@
   "emoji_button.symbols": "رموز",
   "emoji_button.travel": "أماكن و أسفار",
   "empty_column.community": "الخط الزمني المحلي فارغ. أكتب شيئا ما للعامة كبداية !",
-  "empty_column.direct": "You don't have any direct messages yet. When you send or receive one, it will show up here.",
+  "empty_column.direct": "لم تتلق أية رسالة خاصة مباشِرة بعد. سوف يتم عرض الرسائل المباشرة هنا إن قمت بإرسال واحدة أو تلقيت البعض منها.",
   "empty_column.hashtag": "ليس هناك بعدُ أي محتوى ذو علاقة بهذا الوسم.",
   "empty_column.home": "إنك لا تتبع بعد أي شخص إلى حد الآن. زر {public} أو استخدام حقل البحث لكي تبدأ على التعرف على مستخدمين آخرين.",
   "empty_column.home.public_timeline": "الخيط العام",
@@ -135,6 +135,7 @@
   "keyboard_shortcuts.mention": "لذِكر الناشر",
   "keyboard_shortcuts.reply": "للردّ",
   "keyboard_shortcuts.search": "للتركيز على البحث",
+  "keyboard_shortcuts.toggle_hidden": "لعرض أو إخفاء النص مِن وراء التحذير",
   "keyboard_shortcuts.toot": "لتحرير تبويق جديد",
   "keyboard_shortcuts.unfocus": "لإلغاء التركيز على حقل النص أو نافذة البحث",
   "keyboard_shortcuts.up": "للإنتقال إلى أعلى القائمة",
@@ -156,8 +157,8 @@
   "mute_modal.hide_notifications": "هل تود إخفاء الإخطارات القادمة من هذا المستخدم ؟",
   "navigation_bar.blocks": "الحسابات المحجوبة",
   "navigation_bar.community_timeline": "الخيط العام المحلي",
-  "navigation_bar.direct": "Direct messages",
-  "navigation_bar.domain_blocks": "Hidden domains",
+  "navigation_bar.direct": "الرسائل المباشِرة",
+  "navigation_bar.domain_blocks": "النطاقات المخفية",
   "navigation_bar.edit_profile": "تعديل الملف الشخصي",
   "navigation_bar.favourites": "المفضلة",
   "navigation_bar.follow_requests": "طلبات المتابعة",
@@ -241,10 +242,10 @@
   "search_results.total": "{count, number} {count, plural, one {result} و {results}}",
   "standalone.public_title": "نظرة على ...",
   "status.block": "Block @{name}",
-  "status.cancel_reblog_private": "Unboost",
+  "status.cancel_reblog_private": "إلغاء الترقية",
   "status.cannot_reblog": "تعذرت ترقية هذا المنشور",
   "status.delete": "إحذف",
-  "status.direct": "Direct message @{name}",
+  "status.direct": "رسالة خاصة إلى @{name}",
   "status.embed": "إدماج",
   "status.favourite": "أضف إلى المفضلة",
   "status.load_more": "حمّل المزيد",
@@ -275,7 +276,7 @@
   "tabs_bar.home": "الرئيسية",
   "tabs_bar.local_timeline": "المحلي",
   "tabs_bar.notifications": "الإخطارات",
-  "tabs_bar.search": "Search",
+  "tabs_bar.search": "البحث",
   "ui.beforeunload": "سوف تفقد مسودتك إن تركت ماستدون.",
   "upload_area.title": "إسحب ثم أفلت للرفع",
   "upload_button.label": "إضافة وسائط",
diff --git a/app/javascript/mastodon/locales/bg.json b/app/javascript/mastodon/locales/bg.json
index 25ef6db65..971475114 100644
--- a/app/javascript/mastodon/locales/bg.json
+++ b/app/javascript/mastodon/locales/bg.json
@@ -135,6 +135,7 @@
   "keyboard_shortcuts.mention": "to mention author",
   "keyboard_shortcuts.reply": "to reply",
   "keyboard_shortcuts.search": "to focus search",
+  "keyboard_shortcuts.toggle_hidden": "to show/hide text behind CW",
   "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",
diff --git a/app/javascript/mastodon/locales/ca.json b/app/javascript/mastodon/locales/ca.json
index 6a44808e0..f2e3699d5 100644
--- a/app/javascript/mastodon/locales/ca.json
+++ b/app/javascript/mastodon/locales/ca.json
@@ -2,7 +2,7 @@
   "account.block": "Bloca @{name}",
   "account.block_domain": "Amaga-ho tot de {domain}",
   "account.blocked": "Bloquejat",
-  "account.direct": "Direct message @{name}",
+  "account.direct": "Missatge directe @{name}",
   "account.disclaimer_full": "La informació següent pot reflectir incompleta el perfil de l'usuari.",
   "account.domain_blocked": "Domini ocult",
   "account.edit_profile": "Edita el perfil",
@@ -18,7 +18,7 @@
   "account.mute_notifications": "Notificacions desactivades de @{name}",
   "account.muted": "Silenciat",
   "account.posts": "Toots",
-  "account.posts_with_replies": "Toots amb respostes",
+  "account.posts_with_replies": "Toots i respostes",
   "account.report": "Informe @{name}",
   "account.requested": "Esperant aprovació. Clic per a cancel·lar la petició de seguiment",
   "account.share": "Comparteix el perfil de @{name}",
@@ -29,8 +29,8 @@
   "account.unmute": "Treure silenci de @{name}",
   "account.unmute_notifications": "Activar notificacions de @{name}",
   "account.view_full_profile": "Mostra el perfil complet",
-  "alert.unexpected.message": "An unexpected error occurred.",
-  "alert.unexpected.title": "Oops!",
+  "alert.unexpected.message": "S'ha produït un error inesperat.",
+  "alert.unexpected.title": "Vaja!",
   "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.",
   "bundle_column_error.retry": "Torna-ho a provar",
@@ -40,8 +40,8 @@
   "bundle_modal_error.retry": "Torna-ho a provar",
   "column.blocks": "Usuaris blocats",
   "column.community": "Línia de temps local",
-  "column.direct": "Direct messages",
-  "column.domain_blocks": "Hidden domains",
+  "column.direct": "Missatges directes",
+  "column.domain_blocks": "Dominis ocults",
   "column.favourites": "Favorits",
   "column.follow_requests": "Peticions per seguir-te",
   "column.home": "Inici",
@@ -59,7 +59,7 @@
   "column_header.unpin": "No fixis",
   "column_subheading.navigation": "Navegació",
   "column_subheading.settings": "Configuració",
-  "compose_form.direct_message_warning": "This toot will only be visible to all the mentioned users.",
+  "compose_form.direct_message_warning": "Aquest toot només serà visible per a tots els usuaris esmentats.",
   "compose_form.hashtag_warning": "Aquest toot no es mostrarà en cap etiqueta ja que no està llistat. Només els toots públics poden ser cercats per etiqueta.",
   "compose_form.lock_disclaimer": "El teu compte no està bloquejat {locked}. Tothom pot seguir-te i veure els teus missatges a seguidors.",
   "compose_form.lock_disclaimer.lock": "blocat",
@@ -68,7 +68,7 @@
   "compose_form.publish_loud": "{publish}!",
   "compose_form.sensitive.marked": "Mèdia marcat com a sensible",
   "compose_form.sensitive.unmarked": "Mèdia no està marcat com a sensible",
-  "compose_form.spoiler.marked": "Text ocult sota l'avís",
+  "compose_form.spoiler.marked": "Text es ocult sota l'avís",
   "compose_form.spoiler.unmarked": "Text no ocult",
   "compose_form.spoiler_placeholder": "Escriu l'avís aquí",
   "confirmation_modal.cancel": "Cancel·la",
@@ -101,7 +101,7 @@
   "emoji_button.symbols": "Símbols",
   "emoji_button.travel": "Viatges i Llocs",
   "empty_column.community": "La línia de temps local és buida. Escriu alguna cosa públicament per fer rodar la pilota!",
-  "empty_column.direct": "You don't have any direct messages yet. When you send or receive one, it will show up here.",
+  "empty_column.direct": "Encara no tens missatges directes. Quan enviïs o rebis un, es mostrarà aquí.",
   "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",
@@ -135,6 +135,7 @@
   "keyboard_shortcuts.mention": "per esmentar l'autor",
   "keyboard_shortcuts.reply": "respondre",
   "keyboard_shortcuts.search": "per centrar la cerca",
+  "keyboard_shortcuts.toggle_hidden": "per a mostrar/amagar text sota CW",
   "keyboard_shortcuts.toot": "per a començar un toot nou de trinca",
   "keyboard_shortcuts.unfocus": "descentrar l'area de composició de text/cerca",
   "keyboard_shortcuts.up": "moure amunt en la llista",
@@ -156,8 +157,8 @@
   "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.direct": "Direct messages",
-  "navigation_bar.domain_blocks": "Hidden domains",
+  "navigation_bar.direct": "Missatges directes",
+  "navigation_bar.domain_blocks": "Dominis ocults",
   "navigation_bar.edit_profile": "Editar perfil",
   "navigation_bar.favourites": "Favorits",
   "navigation_bar.follow_requests": "Sol·licituds de seguiment",
@@ -241,10 +242,10 @@
   "search_results.total": "{count, number} {count, plural, un {result} altres {results}}",
   "standalone.public_title": "Una mirada a l'interior ...",
   "status.block": "Block @{name}",
-  "status.cancel_reblog_private": "Unboost",
+  "status.cancel_reblog_private": "Desfer l'impuls",
   "status.cannot_reblog": "Aquesta publicació no pot ser retootejada",
   "status.delete": "Esborrar",
-  "status.direct": "Direct message @{name}",
+  "status.direct": "Missatge directe @{name}",
   "status.embed": "Incrustar",
   "status.favourite": "Favorit",
   "status.load_more": "Carrega més",
@@ -257,7 +258,7 @@
   "status.pin": "Fixat en el perfil",
   "status.pinned": "Toot fixat",
   "status.reblog": "Impuls",
-  "status.reblog_private": "Boost to original audience",
+  "status.reblog_private": "Impulsar a l'audiència original",
   "status.reblogged_by": "{name} ha retootejat",
   "status.reply": "Respondre",
   "status.replyAll": "Respondre al tema",
@@ -275,7 +276,7 @@
   "tabs_bar.home": "Inici",
   "tabs_bar.local_timeline": "Local",
   "tabs_bar.notifications": "Notificacions",
-  "tabs_bar.search": "Search",
+  "tabs_bar.search": "Cerca",
   "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",
diff --git a/app/javascript/mastodon/locales/de.json b/app/javascript/mastodon/locales/de.json
index 69c2ae8d8..f442e0675 100644
--- a/app/javascript/mastodon/locales/de.json
+++ b/app/javascript/mastodon/locales/de.json
@@ -18,7 +18,7 @@
   "account.mute_notifications": "Benachrichtigungen von @{name} verbergen",
   "account.muted": "Stummgeschaltet",
   "account.posts": "Beiträge",
-  "account.posts_with_replies": "Beiträge mit Antworten",
+  "account.posts_with_replies": "Beiträge und Antworten",
   "account.report": "@{name} melden",
   "account.requested": "Warte auf Erlaubnis. Klicke zum Abbrechen",
   "account.share": "Profil von @{name} teilen",
@@ -29,8 +29,8 @@
   "account.unmute": "@{name} nicht mehr stummschalten",
   "account.unmute_notifications": "Benachrichtigungen von @{name} einschalten",
   "account.view_full_profile": "Vollständiges Profil anzeigen",
-  "alert.unexpected.message": "An unexpected error occurred.",
-  "alert.unexpected.title": "Oops!",
+  "alert.unexpected.message": "Ein unerwarteter Fehler ist aufgetreten.",
+  "alert.unexpected.title": "Hoppla!",
   "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.",
   "bundle_column_error.retry": "Erneut versuchen",
@@ -40,8 +40,8 @@
   "bundle_modal_error.retry": "Erneut versuchen",
   "column.blocks": "Blockierte Profile",
   "column.community": "Lokale Zeitleiste",
-  "column.direct": "Direct messages",
-  "column.domain_blocks": "Hidden domains",
+  "column.direct": "Direktnachrichten",
+  "column.domain_blocks": "Versteckte Domains",
   "column.favourites": "Favoriten",
   "column.follow_requests": "Folgeanfragen",
   "column.home": "Startseite",
@@ -59,17 +59,17 @@
   "column_header.unpin": "Lösen",
   "column_subheading.navigation": "Navigation",
   "column_subheading.settings": "Einstellungen",
-  "compose_form.direct_message_warning": "This toot will only be visible to all the mentioned users.",
+  "compose_form.direct_message_warning": "Dieser Beitrag wird nur für die erwähnten Nutzer sichtbar sein.",
   "compose_form.hashtag_warning": "Dieser Beitrag wird nicht unter einen dieser Hashtags sichtbar sein, solange er ungelistet ist. Bei einer Suche kann er nicht gefunden werden.",
   "compose_form.lock_disclaimer": "Dein Profil ist nicht {locked}. Wer dir folgen will, kann das jederzeit tun und dann auch deine privaten Beiträge sehen.",
   "compose_form.lock_disclaimer.lock": "gesperrt",
   "compose_form.placeholder": "Worüber möchtest du schreiben?",
   "compose_form.publish": "Tröt",
   "compose_form.publish_loud": "{publish}!",
-  "compose_form.sensitive.marked": "Media is marked as sensitive",
-  "compose_form.sensitive.unmarked": "Media is not marked as sensitive",
-  "compose_form.spoiler.marked": "Text is hidden behind warning",
-  "compose_form.spoiler.unmarked": "Text is not hidden",
+  "compose_form.sensitive.marked": "Medien sind als heikel markiert",
+  "compose_form.sensitive.unmarked": "Medien sind nicht als heikel markiert",
+  "compose_form.spoiler.marked": "Text ist hinter einer Warnung versteckt",
+  "compose_form.spoiler.unmarked": "Text ist nicht versteckt",
   "compose_form.spoiler_placeholder": "Inhaltswarnung",
   "confirmation_modal.cancel": "Abbrechen",
   "confirmations.block.confirm": "Blockieren",
@@ -101,7 +101,7 @@
   "emoji_button.symbols": "Symbole",
   "emoji_button.travel": "Reisen und Orte",
   "empty_column.community": "Die lokale Zeitleiste ist leer. Schreibe einen öffentlichen Beitrag, um den Ball ins Rollen zu bringen!",
-  "empty_column.direct": "You don't have any direct messages yet. When you send or receive one, it will show up here.",
+  "empty_column.direct": "Du hast noch keine Direktnachrichten erhalten. Wenn du eine sendest oder empfängst, wird sie hier zu sehen sein.",
   "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",
@@ -130,11 +130,12 @@
   "keyboard_shortcuts.enter": "um den Status zu öffnen",
   "keyboard_shortcuts.favourite": "um zu favorisieren",
   "keyboard_shortcuts.heading": "Tastenkombinationen",
-  "keyboard_shortcuts.hotkey": "Hotkey",
+  "keyboard_shortcuts.hotkey": "Tastenkürzel",
   "keyboard_shortcuts.legend": "um diese Übersicht anzuzeigen",
   "keyboard_shortcuts.mention": "um Autor_in zu erwähnen",
   "keyboard_shortcuts.reply": "um zu antworten",
   "keyboard_shortcuts.search": "um die Suche zu fokussieren",
+  "keyboard_shortcuts.toggle_hidden": "um den Text hinter einer Inhaltswarnung zu verstecken oder ihn anzuzeigen",
   "keyboard_shortcuts.toot": "um einen neuen Toot zu beginnen",
   "keyboard_shortcuts.unfocus": "um das Textfeld/die Suche nicht mehr zu fokussieren",
   "keyboard_shortcuts.up": "sich in der Liste hinauf bewegen",
@@ -156,8 +157,8 @@
   "mute_modal.hide_notifications": "Benachrichtigungen von diesem Account verbergen?",
   "navigation_bar.blocks": "Blockierte Profile",
   "navigation_bar.community_timeline": "Lokale Zeitleiste",
-  "navigation_bar.direct": "Direct messages",
-  "navigation_bar.domain_blocks": "Hidden domains",
+  "navigation_bar.direct": "Direktnachrichten",
+  "navigation_bar.domain_blocks": "Versteckte Domains",
   "navigation_bar.edit_profile": "Profil bearbeiten",
   "navigation_bar.favourites": "Favoriten",
   "navigation_bar.follow_requests": "Folgeanfragen",
@@ -190,8 +191,8 @@
   "onboarding.page_four.home": "Die Startseite zeigt dir Beiträge von Leuten, denen du folgst.",
   "onboarding.page_four.notifications": "Wenn jemand mit dir interagiert, bekommst du eine Mitteilung.",
   "onboarding.page_one.federation": "Mastodon ist ein soziales Netzwerk, das aus unabhängigen Servern besteht. Diese Server nennen wir auch Instanzen.",
-  "onboarding.page_one.full_handle": "Your full handle",
-  "onboarding.page_one.handle_hint": "This is what you would tell your friends to search for.",
+  "onboarding.page_one.full_handle": "Dein vollständiger Benutzername",
+  "onboarding.page_one.handle_hint": "Das ist das, was du deinen Freunden sagst, um nach dir zu suchen.",
   "onboarding.page_one.welcome": "Willkommen bei Mastodon!",
   "onboarding.page_six.admin": "Für deine Instanz ist {admin} zuständig.",
   "onboarding.page_six.almost_done": "Fast fertig …",
@@ -214,50 +215,50 @@
   "privacy.public.short": "Öffentlich",
   "privacy.unlisted.long": "Nicht in öffentlichen Zeitleisten anzeigen",
   "privacy.unlisted.short": "Nicht gelistet",
-  "regeneration_indicator.label": "Loading…",
-  "regeneration_indicator.sublabel": "Your home feed is being prepared!",
+  "regeneration_indicator.label": "Laden…",
+  "regeneration_indicator.sublabel": "Deine Heimzeitleiste wird gerade vorbereitet!",
   "relative_time.days": "{number}d",
   "relative_time.hours": "{number}h",
-  "relative_time.just_now": "now",
+  "relative_time.just_now": "jetzt",
   "relative_time.minutes": "{number}m",
   "relative_time.seconds": "{number}s",
   "reply_indicator.cancel": "Abbrechen",
-  "report.forward": "Forward to {target}",
-  "report.forward_hint": "The account is from another server. Send an anonymized copy of the report there as well?",
-  "report.hint": "The report will be sent to your instance moderators. You can provide an explanation of why you are reporting this account below:",
+  "report.forward": "An {target} weiterleiten",
+  "report.forward_hint": "Dieses Konto ist von einem anderen Server. Soll eine anonymisierte Kopie des Berichts auch dorthin geschickt werden?",
+  "report.hint": "Der Bericht wird an die Moderatoren deiner Instanz geschickt. Du kannst hier eine Erklärung angeben, warum du dieses Konto meldest:",
   "report.placeholder": "Zusätzliche Kommentare",
   "report.submit": "Absenden",
   "report.target": "{target} melden",
   "search.placeholder": "Suche",
-  "search_popout.search_format": "Advanced search format",
-  "search_popout.tips.full_text": "Simple text returns statuses you have written, favourited, boosted, or have been mentioned in, as well as matching usernames, display names, and hashtags.",
-  "search_popout.tips.hashtag": "hashtag",
+  "search_popout.search_format": "Fortgeschrittenes Suchformat",
+  "search_popout.tips.full_text": "Simpler Text gibt Beiträge, die du geschrieben, favorisiert und geteilt hast zurück. Außerdem auch Beiträge in denen du erwähnt wurdest, als auch passende Nutzernamen, Anzeigenamen oder Hashtags.",
+  "search_popout.tips.hashtag": "Hashtag",
   "search_popout.tips.status": "status",
-  "search_popout.tips.text": "Simple text returns matching display names, usernames and hashtags",
-  "search_popout.tips.user": "user",
-  "search_results.accounts": "People",
+  "search_popout.tips.text": "Einfacher Text gibt Anzeigenamen, Benutzernamen und Hashtags zurück",
+  "search_popout.tips.user": "Nutzer",
+  "search_results.accounts": "Personen",
   "search_results.hashtags": "Hashtags",
-  "search_results.statuses": "Toots",
+  "search_results.statuses": "Beiträge",
   "search_results.total": "{count, number} {count, plural, one {Ergebnis} other {Ergebnisse}}",
   "standalone.public_title": "Ein kleiner Einblick …",
   "status.block": "Block @{name}",
-  "status.cancel_reblog_private": "Unboost",
+  "status.cancel_reblog_private": "Nicht mehr teilen",
   "status.cannot_reblog": "Dieser Beitrag kann nicht geteilt werden",
   "status.delete": "Löschen",
-  "status.direct": "Direct message @{name}",
+  "status.direct": "Direktnachricht @{name}",
   "status.embed": "Einbetten",
   "status.favourite": "Favorisieren",
   "status.load_more": "Weitere laden",
   "status.media_hidden": "Medien versteckt",
   "status.mention": "@{name} erwähnen",
   "status.more": "Mehr",
-  "status.mute": "Mute @{name}",
+  "status.mute": "@{name} stummschalten",
   "status.mute_conversation": "Thread stummschalten",
   "status.open": "Diesen Beitrag öffnen",
   "status.pin": "Im Profil anheften",
-  "status.pinned": "Pinned toot",
+  "status.pinned": "Angehefteter Beitrag",
   "status.reblog": "Teilen",
-  "status.reblog_private": "Boost to original audience",
+  "status.reblog_private": "An das eigentliche Publikum teilen",
   "status.reblogged_by": "{name} teilte",
   "status.reply": "Antworten",
   "status.replyAll": "Auf Thread antworten",
@@ -266,21 +267,21 @@
   "status.sensitive_warning": "Heikle Inhalte",
   "status.share": "Teilen",
   "status.show_less": "Weniger anzeigen",
-  "status.show_less_all": "Show less for all",
+  "status.show_less_all": "Zeige weniger für alles",
   "status.show_more": "Mehr anzeigen",
-  "status.show_more_all": "Show more for all",
+  "status.show_more_all": "Zeige mehr für alles",
   "status.unmute_conversation": "Stummschaltung von Thread aufheben",
   "status.unpin": "Vom Profil lösen",
   "tabs_bar.federated_timeline": "Föderation",
   "tabs_bar.home": "Startseite",
   "tabs_bar.local_timeline": "Lokal",
   "tabs_bar.notifications": "Mitteilungen",
-  "tabs_bar.search": "Search",
-  "ui.beforeunload": "Your draft will be lost if you leave Mastodon.",
+  "tabs_bar.search": "Suchen",
+  "ui.beforeunload": "Dein Entwurf geht verloren, wenn du Mastodon verlässt.",
   "upload_area.title": "Zum Hochladen hereinziehen",
   "upload_button.label": "Mediendatei hinzufügen",
   "upload_form.description": "Für Menschen mit Sehbehinderung beschreiben",
-  "upload_form.focus": "Crop",
+  "upload_form.focus": "Zuschneiden",
   "upload_form.undo": "Entfernen",
   "upload_progress.label": "Wird hochgeladen …",
   "video.close": "Video schließen",
diff --git a/app/javascript/mastodon/locales/el.json b/app/javascript/mastodon/locales/el.json
new file mode 100644
index 000000000..a7e1c408f
--- /dev/null
+++ b/app/javascript/mastodon/locales/el.json
@@ -0,0 +1,296 @@
+{
+  "account.block": "Απόκλεισε τον/την @{name}",
+  "account.block_domain": "Απόκρυψε τα πάντα από τον/την",
+  "account.blocked": "Αποκλεισμένος/η",
+  "account.direct": "Απευθείας μήνυμα προς @{name}",
+  "account.disclaimer_full": "Οι παρακάτω πληροφορίες μπορει να μην αντανακλούν το προφίλ του χρήστη επαρκως.",
+  "account.domain_blocked": "Domain hidden",
+  "account.edit_profile": "Επεξεργάσου το προφίλ",
+  "account.follow": "Ακολούθησε",
+  "account.followers": "Ακόλουθοι",
+  "account.follows": "Ακολουθεί",
+  "account.follows_you": "Σε ακολουθεί",
+  "account.hide_reblogs": "Απόκρυψη προωθήσεων από τον/την @{name}",
+  "account.media": "Πολυμέσα",
+  "account.mention": "Ανέφερε τον/την @{name}",
+  "account.moved_to": "{name} μετακόμισε στο:",
+  "account.mute": "Σώπασε τον/την @{name}",
+  "account.mute_notifications": "Σώπασε τις ειδοποιήσεις από τον/την @{name}",
+  "account.muted": "Αποσιωπημένος/η",
+  "account.posts": "Τουτ",
+  "account.posts_with_replies": "Τουτ και απαντήσεις",
+  "account.report": "Ανέφερε τον/την @{name}",
+  "account.requested": "Εκκρεμεί έγκριση. Κάνε κλικ για να ακυρώσεις το αίτημα ακολούθησης",
+  "account.share": "Μοιράσου το προφίλ του/της @{name}",
+  "account.show_reblogs": "Δείξε τις προωθήσεις του/της @{name}",
+  "account.unblock": "Unblock @{name}",
+  "account.unblock_domain": "Αποκάλυψε το {domain}",
+  "account.unfollow": "Unfollow",
+  "account.unmute": "Unmute @{name}",
+  "account.unmute_notifications": "Unmute notifications from @{name}",
+  "account.view_full_profile": "Δες το πλήρες προφίλ",
+  "alert.unexpected.message": "Προέκυψε απροσδόκητο σφάλμα.",
+  "alert.unexpected.title": "Εεπ!",
+  "boost_modal.combo": "You can press {combo} to skip this next time",
+  "bundle_column_error.body": "Κάτι πήγε στραβά ενώ φορτωνόταν αυτό το στοιχείο.",
+  "bundle_column_error.retry": "Δοκίμασε ξανά",
+  "bundle_column_error.title": "Σφάλμα δικτύου",
+  "bundle_modal_error.close": "Κλείσε",
+  "bundle_modal_error.message": "Κάτι πήγε στραβά ενώ φορτωνόταν αυτό το στοιχείο.",
+  "bundle_modal_error.retry": "Δοκίμασε ξανά",
+  "column.blocks": "Αποκλεισμένοι χρήστες",
+  "column.community": "Τοπική ροή",
+  "column.direct": "Απευθείας μηνύματα",
+  "column.domain_blocks": "Hidden domains",
+  "column.favourites": "Αγαπημένα",
+  "column.follow_requests": "Αιτήματα παρακολούθησης",
+  "column.home": "Αρχική",
+  "column.lists": "Λίστες",
+  "column.mutes": "Αποσιωπημένοι χρήστες",
+  "column.notifications": "Ειδοποιήσεις",
+  "column.pins": "Καρφιτσωμένα τουτ",
+  "column.public": "Ομοσπονδιακή ροή",
+  "column_back_button.label": "Πίσω",
+  "column_header.hide_settings": "Απόκρυψη ρυθμίσεων",
+  "column_header.moveLeft_settings": "Μεταφορά κολώνας αριστερά",
+  "column_header.moveRight_settings": "Μεταφορά κολώνας δεξιά",
+  "column_header.pin": "Καρφίτσωμα",
+  "column_header.show_settings": "Εμφάνιση ρυθμίσεων",
+  "column_header.unpin": "Ξεκαρφίτσωμα",
+  "column_subheading.navigation": "Πλοήγηση",
+  "column_subheading.settings": "Ρυθμίσεις",
+  "compose_form.direct_message_warning": "Αυτό το τουτ θα εμφανίζεται μόνο σε όλους τους αναφερόμενους χρήστες.",
+  "compose_form.hashtag_warning": "This toot won't be listed under any hashtag as it is unlisted. Only public toots can be searched by hashtag.",
+  "compose_form.lock_disclaimer": "Ο λογαριασμός σου δεν είναι {locked}. Οποιοσδήποτε μπορεί να σε ακολουθήσει για να δει τις δημοσιεύσεις σας προς τους ακολούθους σας.",
+  "compose_form.lock_disclaimer.lock": "κλειδωμένος",
+  "compose_form.placeholder": "Τι σκέφτεσαι;",
+  "compose_form.publish": "Τουτ",
+  "compose_form.publish_loud": "{publish}!",
+  "compose_form.sensitive.marked": "Το πολυμέσο έχει σημειωθεί ως ευαίσθητο",
+  "compose_form.sensitive.unmarked": "Το πολυμέσο δεν έχει σημειωθεί ως ευαίσθητο",
+  "compose_form.spoiler.marked": "Κείμενο κρυμμένο πίσω από προειδοποίηση",
+  "compose_form.spoiler.unmarked": "Κείμενο μη κρυμμένο",
+  "compose_form.spoiler_placeholder": "Γράψε την προειδοποίησή σου εδώ",
+  "confirmation_modal.cancel": "Άκυρο",
+  "confirmations.block.confirm": "Απόκλεισε",
+  "confirmations.block.message": "Σίγουρα θες να αποκλείσεις τον/την {name};",
+  "confirmations.delete.confirm": "Διέγραψε",
+  "confirmations.delete.message": "Σίγουρα θες να διαγράψεις αυτή την κατάσταση;",
+  "confirmations.delete_list.confirm": "Διέγραψε",
+  "confirmations.delete_list.message": "Σίγουρα θες να διαγράψεις οριστικά αυτή τη λίστα;",
+  "confirmations.domain_block.confirm": "Hide entire domain",
+  "confirmations.domain_block.message": "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.",
+  "confirmations.mute.confirm": "Mute",
+  "confirmations.mute.message": "Are you sure you want to mute {name}?",
+  "confirmations.unfollow.confirm": "Unfollow",
+  "confirmations.unfollow.message": "Are you sure you want to unfollow {name}?",
+  "embed.instructions": "Embed this status on your website by copying the code below.",
+  "embed.preview": "Here is what it will look like:",
+  "emoji_button.activity": "Activity",
+  "emoji_button.custom": "Custom",
+  "emoji_button.flags": "Flags",
+  "emoji_button.food": "Food & Drink",
+  "emoji_button.label": "Insert emoji",
+  "emoji_button.nature": "Nature",
+  "emoji_button.not_found": "No emojos!! (╯°□°)╯︵ ┻━┻",
+  "emoji_button.objects": "Objects",
+  "emoji_button.people": "People",
+  "emoji_button.recent": "Frequently used",
+  "emoji_button.search": "Search...",
+  "emoji_button.search_results": "Search results",
+  "emoji_button.symbols": "Symbols",
+  "emoji_button.travel": "Travel & Places",
+  "empty_column.community": "The local timeline is empty. Write something publicly to get the ball rolling!",
+  "empty_column.direct": "You don't have any direct messages yet. When you send or receive one, it will show up here.",
+  "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. When members of this list post new statuses, they will appear here.",
+  "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",
+  "follow_request.reject": "Reject",
+  "getting_started.appsshort": "Apps",
+  "getting_started.faq": "FAQ",
+  "getting_started.heading": "Getting started",
+  "getting_started.open_source_notice": "Mastodon is open source software. You can contribute or report issues on GitHub at {github}.",
+  "getting_started.userguide": "User Guide",
+  "home.column_settings.advanced": "Advanced",
+  "home.column_settings.basic": "Basic",
+  "home.column_settings.filter_regex": "Filter out by regular expressions",
+  "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.toggle_hidden": "to show/hide text behind CW",
+  "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",
+  "lists.account.add": "Add to list",
+  "lists.account.remove": "Remove from list",
+  "lists.delete": "Delete list",
+  "lists.edit": "Edit list",
+  "lists.new.create": "Add list",
+  "lists.new.title_placeholder": "New list title",
+  "lists.search": "Search among people you follow",
+  "lists.subheading": "Your lists",
+  "loading_indicator.label": "Loading...",
+  "media_gallery.toggle_visible": "Toggle visibility",
+  "missing_indicator.label": "Not found",
+  "missing_indicator.sublabel": "This resource could not be found",
+  "mute_modal.hide_notifications": "Hide notifications from this user?",
+  "navigation_bar.blocks": "Blocked users",
+  "navigation_bar.community_timeline": "Local timeline",
+  "navigation_bar.direct": "Direct messages",
+  "navigation_bar.domain_blocks": "Hidden domains",
+  "navigation_bar.edit_profile": "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.lists": "Lists",
+  "navigation_bar.logout": "Logout",
+  "navigation_bar.mutes": "Muted users",
+  "navigation_bar.pins": "Pinned toots",
+  "navigation_bar.preferences": "Preferences",
+  "navigation_bar.public_timeline": "Federated timeline",
+  "notification.favourite": "{name} favourited your status",
+  "notification.follow": "{name} followed you",
+  "notification.mention": "{name} mentioned you",
+  "notification.reblog": "{name} boosted your status",
+  "notifications.clear": "Clear notifications",
+  "notifications.clear_confirmation": "Are you sure you want to permanently clear all your notifications?",
+  "notifications.column_settings.alert": "Desktop notifications",
+  "notifications.column_settings.favourite": "Favourites:",
+  "notifications.column_settings.follow": "New followers:",
+  "notifications.column_settings.mention": "Mentions:",
+  "notifications.column_settings.push": "Push notifications",
+  "notifications.column_settings.push_meta": "This device",
+  "notifications.column_settings.reblog": "Boosts:",
+  "notifications.column_settings.show": "Show in column",
+  "notifications.column_settings.sound": "Play sound",
+  "onboarding.done": "Done",
+  "onboarding.next": "Next",
+  "onboarding.page_five.public_timelines": "The local timeline shows public posts from everyone on {domain}. The federated timeline shows public posts from everyone who people on {domain} follow. These are the Public Timelines, a great way to discover new people.",
+  "onboarding.page_four.home": "The home timeline shows posts from people you follow.",
+  "onboarding.page_four.notifications": "The notifications column shows when someone interacts with you.",
+  "onboarding.page_one.federation": "Mastodon is a network of independent servers joining up to make one larger social network. We call these servers instances.",
+  "onboarding.page_one.full_handle": "Your full handle",
+  "onboarding.page_one.handle_hint": "This is what you would tell your friends to search for.",
+  "onboarding.page_one.welcome": "Welcome to Mastodon!",
+  "onboarding.page_six.admin": "Your instance's admin is {admin}.",
+  "onboarding.page_six.almost_done": "Almost done...",
+  "onboarding.page_six.appetoot": "Bon Appetoot!",
+  "onboarding.page_six.apps_available": "There are {apps} available for iOS, Android and other platforms.",
+  "onboarding.page_six.github": "Mastodon is free open-source software. You can report bugs, request features, or contribute to the code on {github}.",
+  "onboarding.page_six.guidelines": "community guidelines",
+  "onboarding.page_six.read_guidelines": "Please read {domain}'s {guidelines}!",
+  "onboarding.page_six.various_app": "mobile apps",
+  "onboarding.page_three.profile": "Edit your profile to change your avatar, bio, and display name. There, you will also find other preferences.",
+  "onboarding.page_three.search": "Use the search bar to find people and look at hashtags, such as {illustration} and {introductions}. To look for a person who is not on this instance, use their full handle.",
+  "onboarding.page_two.compose": "Write posts from the compose column. You can upload images, change privacy settings, and add content warnings with the icons below.",
+  "onboarding.skip": "Skip",
+  "privacy.change": "Adjust status privacy",
+  "privacy.direct.long": "Post to mentioned users only",
+  "privacy.direct.short": "Direct",
+  "privacy.private.long": "Post to followers only",
+  "privacy.private.short": "Followers-only",
+  "privacy.public.long": "Post to public timelines",
+  "privacy.public.short": "Public",
+  "privacy.unlisted.long": "Do not show in public timelines",
+  "privacy.unlisted.short": "Unlisted",
+  "regeneration_indicator.label": "Loading…",
+  "regeneration_indicator.sublabel": "Your home feed is being prepared!",
+  "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": "Cancel",
+  "report.forward": "Forward to {target}",
+  "report.forward_hint": "The account is from another server. Send an anonymized copy of the report there as well?",
+  "report.hint": "The report will be sent to your instance moderators. You can provide an explanation of why you are reporting this account below:",
+  "report.placeholder": "Additional comments",
+  "report.submit": "Submit",
+  "report.target": "Report {target}",
+  "search.placeholder": "Search",
+  "search_popout.search_format": "Advanced search format",
+  "search_popout.tips.full_text": "Simple text returns statuses you have written, favourited, boosted, or have been mentioned in, as well as matching usernames, display names, and hashtags.",
+  "search_popout.tips.hashtag": "hashtag",
+  "search_popout.tips.status": "status",
+  "search_popout.tips.text": "Simple text returns matching display names, usernames and hashtags",
+  "search_popout.tips.user": "user",
+  "search_results.accounts": "People",
+  "search_results.hashtags": "Hashtags",
+  "search_results.statuses": "Toots",
+  "search_results.total": "{count, number} {count, plural, one {result} other {results}}",
+  "standalone.public_title": "A look inside...",
+  "status.block": "Block @{name}",
+  "status.cancel_reblog_private": "Unboost",
+  "status.cannot_reblog": "This post cannot be boosted",
+  "status.delete": "Delete",
+  "status.direct": "Direct message @{name}",
+  "status.embed": "Embed",
+  "status.favourite": "Favourite",
+  "status.load_more": "Load more",
+  "status.media_hidden": "Media hidden",
+  "status.mention": "Mention @{name}",
+  "status.more": "More",
+  "status.mute": "Mute @{name}",
+  "status.mute_conversation": "Mute conversation",
+  "status.open": "Expand this status",
+  "status.pin": "Pin on profile",
+  "status.pinned": "Pinned toot",
+  "status.reblog": "Boost",
+  "status.reblog_private": "Boost to original audience",
+  "status.reblogged_by": "{name} boosted",
+  "status.reply": "Reply",
+  "status.replyAll": "Reply to thread",
+  "status.report": "Report @{name}",
+  "status.sensitive_toggle": "Click to view",
+  "status.sensitive_warning": "Sensitive content",
+  "status.share": "Share",
+  "status.show_less": "Show less",
+  "status.show_less_all": "Show less for all",
+  "status.show_more": "Show more",
+  "status.show_more_all": "Show more for all",
+  "status.unmute_conversation": "Unmute conversation",
+  "status.unpin": "Unpin from profile",
+  "tabs_bar.federated_timeline": "Federated",
+  "tabs_bar.home": "Home",
+  "tabs_bar.local_timeline": "Local",
+  "tabs_bar.notifications": "Notifications",
+  "tabs_bar.search": "Search",
+  "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",
+  "upload_form.focus": "Crop",
+  "upload_form.undo": "Undo",
+  "upload_progress.label": "Uploading...",
+  "video.close": "Close video",
+  "video.exit_fullscreen": "Exit full screen",
+  "video.expand": "Expand video",
+  "video.fullscreen": "Full screen",
+  "video.hide": "Hide video",
+  "video.mute": "Mute sound",
+  "video.pause": "Pause",
+  "video.play": "Play",
+  "video.unmute": "Unmute sound"
+}
diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json
index ad6f3b712..d8e69fd3c 100644
--- a/app/javascript/mastodon/locales/en.json
+++ b/app/javascript/mastodon/locales/en.json
@@ -138,6 +138,7 @@
   "keyboard_shortcuts.mention": "to mention author",
   "keyboard_shortcuts.reply": "to reply",
   "keyboard_shortcuts.search": "to focus search",
+  "keyboard_shortcuts.toggle_hidden": "to show/hide text behind CW",
   "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",
diff --git a/app/javascript/mastodon/locales/eo.json b/app/javascript/mastodon/locales/eo.json
index e51163971..37587c14c 100644
--- a/app/javascript/mastodon/locales/eo.json
+++ b/app/javascript/mastodon/locales/eo.json
@@ -135,6 +135,7 @@
   "keyboard_shortcuts.mention": "por mencii la aŭtoron",
   "keyboard_shortcuts.reply": "por respondi",
   "keyboard_shortcuts.search": "por fokusigi la serĉilon",
+  "keyboard_shortcuts.toggle_hidden": "to show/hide text behind CW",
   "keyboard_shortcuts.toot": "por komenci tute novan mesaĝon",
   "keyboard_shortcuts.unfocus": "por malfokusigi la tekstujon aŭ la serĉilon",
   "keyboard_shortcuts.up": "por iri supren en la listo",
diff --git a/app/javascript/mastodon/locales/es.json b/app/javascript/mastodon/locales/es.json
index 61ea0588d..41d7db9da 100644
--- a/app/javascript/mastodon/locales/es.json
+++ b/app/javascript/mastodon/locales/es.json
@@ -135,6 +135,7 @@
   "keyboard_shortcuts.mention": "para mencionar al autor",
   "keyboard_shortcuts.reply": "para responder",
   "keyboard_shortcuts.search": "para poner el foco en la búsqueda",
+  "keyboard_shortcuts.toggle_hidden": "to show/hide text behind CW",
   "keyboard_shortcuts.toot": "para comenzar un nuevo toot",
   "keyboard_shortcuts.unfocus": "para retirar el foco de la caja de redacción/búsqueda",
   "keyboard_shortcuts.up": "para ir hacia arriba en la lista",
diff --git a/app/javascript/mastodon/locales/eu.json b/app/javascript/mastodon/locales/eu.json
new file mode 100644
index 000000000..49cdf5630
--- /dev/null
+++ b/app/javascript/mastodon/locales/eu.json
@@ -0,0 +1,296 @@
+{
+  "account.block": "Blokeatu @{name}",
+  "account.block_domain": "{domain}(e)ko guztia ezkutatu",
+  "account.blocked": "Blokeatuta",
+  "account.direct": "@{name}(e)ri mezu zuzena bidali",
+  "account.disclaimer_full": "Baliteke beheko informazioak erabiltzailearen profilaren zati bat baino ez erakustea.",
+  "account.domain_blocked": "Ezkutatutako domeinua",
+  "account.edit_profile": "Profila aldatu",
+  "account.follow": "Jarraitu",
+  "account.followers": "Jarraitzaileak",
+  "account.follows": "Jarraitzen",
+  "account.follows_you": "Jarraitzen dizu",
+  "account.hide_reblogs": "@{name}(e)k sustatutakoak ezkutatu",
+  "account.media": "Media",
+  "account.mention": "@{name} aipatu",
+  "account.moved_to": "{name} hona lekualdatu da:",
+  "account.mute": "@{name} isilarazi",
+  "account.mute_notifications": "@{name}(e)ren jakinarazpenak isilarazi",
+  "account.muted": "Isilarazita",
+  "account.posts": "Toots",
+  "account.posts_with_replies": "Toots and replies",
+  "account.report": "@{name} salatu",
+  "account.requested": "Onarpenaren zain. Klikatu jarraitzeko eskaera ezeztatzeko",
+  "account.share": "@{name}(e)ren profila elkarbanatu",
+  "account.show_reblogs": "@{name}(e)k sustatutakoak erakutsi",
+  "account.unblock": "@{name} desblokeatu",
+  "account.unblock_domain": "Berriz erakutsi {domain}",
+  "account.unfollow": "Jarraitzeari utzi",
+  "account.unmute": "Unmute @{name}",
+  "account.unmute_notifications": "Unmute notifications from @{name}",
+  "account.view_full_profile": "View full profile",
+  "alert.unexpected.message": "An unexpected error occurred.",
+  "alert.unexpected.title": "Oops!",
+  "boost_modal.combo": "You can press {combo} to skip this next time",
+  "bundle_column_error.body": "Something went wrong while loading this component.",
+  "bundle_column_error.retry": "Try again",
+  "bundle_column_error.title": "Network error",
+  "bundle_modal_error.close": "Close",
+  "bundle_modal_error.message": "Something went wrong while loading this component.",
+  "bundle_modal_error.retry": "Try again",
+  "column.blocks": "Blocked users",
+  "column.community": "Local timeline",
+  "column.direct": "Direct messages",
+  "column.domain_blocks": "Hidden domains",
+  "column.favourites": "Favourites",
+  "column.follow_requests": "Follow requests",
+  "column.home": "Home",
+  "column.lists": "Lists",
+  "column.mutes": "Muted users",
+  "column.notifications": "Notifications",
+  "column.pins": "Pinned toot",
+  "column.public": "Federated timeline",
+  "column_back_button.label": "Back",
+  "column_header.hide_settings": "Hide settings",
+  "column_header.moveLeft_settings": "Move column to the left",
+  "column_header.moveRight_settings": "Move column to the right",
+  "column_header.pin": "Pin",
+  "column_header.show_settings": "Show settings",
+  "column_header.unpin": "Unpin",
+  "column_subheading.navigation": "Navigation",
+  "column_subheading.settings": "Settings",
+  "compose_form.direct_message_warning": "This toot will only be visible to all the mentioned users.",
+  "compose_form.hashtag_warning": "This toot won't be listed under any hashtag as it is unlisted. Only public toots can be searched by hashtag.",
+  "compose_form.lock_disclaimer": "Your account is not {locked}. Anyone can follow you to view your follower-only posts.",
+  "compose_form.lock_disclaimer.lock": "locked",
+  "compose_form.placeholder": "What is on your mind?",
+  "compose_form.publish": "Toot",
+  "compose_form.publish_loud": "{publish}!",
+  "compose_form.sensitive.marked": "Media is marked as sensitive",
+  "compose_form.sensitive.unmarked": "Media is not marked as sensitive",
+  "compose_form.spoiler.marked": "Text is hidden behind warning",
+  "compose_form.spoiler.unmarked": "Text is not hidden",
+  "compose_form.spoiler_placeholder": "Write your warning here",
+  "confirmation_modal.cancel": "Cancel",
+  "confirmations.block.confirm": "Block",
+  "confirmations.block.message": "Are you sure you want to block {name}?",
+  "confirmations.delete.confirm": "Delete",
+  "confirmations.delete.message": "Are you sure you want to delete this status?",
+  "confirmations.delete_list.confirm": "Delete",
+  "confirmations.delete_list.message": "Are you sure you want to permanently delete this list?",
+  "confirmations.domain_block.confirm": "Hide entire domain",
+  "confirmations.domain_block.message": "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.",
+  "confirmations.mute.confirm": "Mute",
+  "confirmations.mute.message": "Are you sure you want to mute {name}?",
+  "confirmations.unfollow.confirm": "Unfollow",
+  "confirmations.unfollow.message": "Are you sure you want to unfollow {name}?",
+  "embed.instructions": "Embed this status on your website by copying the code below.",
+  "embed.preview": "Here is what it will look like:",
+  "emoji_button.activity": "Activity",
+  "emoji_button.custom": "Custom",
+  "emoji_button.flags": "Flags",
+  "emoji_button.food": "Food & Drink",
+  "emoji_button.label": "Insert emoji",
+  "emoji_button.nature": "Nature",
+  "emoji_button.not_found": "No emojos!! (╯°□°)╯︵ ┻━┻",
+  "emoji_button.objects": "Objects",
+  "emoji_button.people": "People",
+  "emoji_button.recent": "Frequently used",
+  "emoji_button.search": "Search...",
+  "emoji_button.search_results": "Search results",
+  "emoji_button.symbols": "Symbols",
+  "emoji_button.travel": "Travel & Places",
+  "empty_column.community": "The local timeline is empty. Write something publicly to get the ball rolling!",
+  "empty_column.direct": "You don't have any direct messages yet. When you send or receive one, it will show up here.",
+  "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. When members of this list post new statuses, they will appear here.",
+  "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",
+  "follow_request.reject": "Reject",
+  "getting_started.appsshort": "Apps",
+  "getting_started.faq": "FAQ",
+  "getting_started.heading": "Getting started",
+  "getting_started.open_source_notice": "Mastodon is open source software. You can contribute or report issues on GitHub at {github}.",
+  "getting_started.userguide": "User Guide",
+  "home.column_settings.advanced": "Advanced",
+  "home.column_settings.basic": "Basic",
+  "home.column_settings.filter_regex": "Filter out by regular expressions",
+  "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.toggle_hidden": "to show/hide text behind CW",
+  "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",
+  "lists.account.add": "Add to list",
+  "lists.account.remove": "Remove from list",
+  "lists.delete": "Delete list",
+  "lists.edit": "Edit list",
+  "lists.new.create": "Add list",
+  "lists.new.title_placeholder": "New list title",
+  "lists.search": "Search among people you follow",
+  "lists.subheading": "Your lists",
+  "loading_indicator.label": "Loading...",
+  "media_gallery.toggle_visible": "Toggle visibility",
+  "missing_indicator.label": "Not found",
+  "missing_indicator.sublabel": "This resource could not be found",
+  "mute_modal.hide_notifications": "Hide notifications from this user?",
+  "navigation_bar.blocks": "Blocked users",
+  "navigation_bar.community_timeline": "Local timeline",
+  "navigation_bar.direct": "Direct messages",
+  "navigation_bar.domain_blocks": "Hidden domains",
+  "navigation_bar.edit_profile": "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.lists": "Lists",
+  "navigation_bar.logout": "Logout",
+  "navigation_bar.mutes": "Muted users",
+  "navigation_bar.pins": "Pinned toots",
+  "navigation_bar.preferences": "Preferences",
+  "navigation_bar.public_timeline": "Federated timeline",
+  "notification.favourite": "{name} favourited your status",
+  "notification.follow": "{name} followed you",
+  "notification.mention": "{name} mentioned you",
+  "notification.reblog": "{name} boosted your status",
+  "notifications.clear": "Clear notifications",
+  "notifications.clear_confirmation": "Are you sure you want to permanently clear all your notifications?",
+  "notifications.column_settings.alert": "Desktop notifications",
+  "notifications.column_settings.favourite": "Favourites:",
+  "notifications.column_settings.follow": "New followers:",
+  "notifications.column_settings.mention": "Mentions:",
+  "notifications.column_settings.push": "Push notifications",
+  "notifications.column_settings.push_meta": "This device",
+  "notifications.column_settings.reblog": "Boosts:",
+  "notifications.column_settings.show": "Show in column",
+  "notifications.column_settings.sound": "Play sound",
+  "onboarding.done": "Done",
+  "onboarding.next": "Next",
+  "onboarding.page_five.public_timelines": "The local timeline shows public posts from everyone on {domain}. The federated timeline shows public posts from everyone who people on {domain} follow. These are the Public Timelines, a great way to discover new people.",
+  "onboarding.page_four.home": "The home timeline shows posts from people you follow.",
+  "onboarding.page_four.notifications": "The notifications column shows when someone interacts with you.",
+  "onboarding.page_one.federation": "Mastodon is a network of independent servers joining up to make one larger social network. We call these servers instances.",
+  "onboarding.page_one.full_handle": "Your full handle",
+  "onboarding.page_one.handle_hint": "This is what you would tell your friends to search for.",
+  "onboarding.page_one.welcome": "Welcome to Mastodon!",
+  "onboarding.page_six.admin": "Your instance's admin is {admin}.",
+  "onboarding.page_six.almost_done": "Almost done...",
+  "onboarding.page_six.appetoot": "Bon Appetoot!",
+  "onboarding.page_six.apps_available": "There are {apps} available for iOS, Android and other platforms.",
+  "onboarding.page_six.github": "Mastodon is free open-source software. You can report bugs, request features, or contribute to the code on {github}.",
+  "onboarding.page_six.guidelines": "community guidelines",
+  "onboarding.page_six.read_guidelines": "Please read {domain}'s {guidelines}!",
+  "onboarding.page_six.various_app": "mobile apps",
+  "onboarding.page_three.profile": "Edit your profile to change your avatar, bio, and display name. There, you will also find other preferences.",
+  "onboarding.page_three.search": "Use the search bar to find people and look at hashtags, such as {illustration} and {introductions}. To look for a person who is not on this instance, use their full handle.",
+  "onboarding.page_two.compose": "Write posts from the compose column. You can upload images, change privacy settings, and add content warnings with the icons below.",
+  "onboarding.skip": "Skip",
+  "privacy.change": "Adjust status privacy",
+  "privacy.direct.long": "Post to mentioned users only",
+  "privacy.direct.short": "Direct",
+  "privacy.private.long": "Post to followers only",
+  "privacy.private.short": "Followers-only",
+  "privacy.public.long": "Post to public timelines",
+  "privacy.public.short": "Public",
+  "privacy.unlisted.long": "Do not show in public timelines",
+  "privacy.unlisted.short": "Unlisted",
+  "regeneration_indicator.label": "Loading…",
+  "regeneration_indicator.sublabel": "Your home feed is being prepared!",
+  "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": "Cancel",
+  "report.forward": "Forward to {target}",
+  "report.forward_hint": "The account is from another server. Send an anonymized copy of the report there as well?",
+  "report.hint": "The report will be sent to your instance moderators. You can provide an explanation of why you are reporting this account below:",
+  "report.placeholder": "Additional comments",
+  "report.submit": "Submit",
+  "report.target": "Report {target}",
+  "search.placeholder": "Search",
+  "search_popout.search_format": "Advanced search format",
+  "search_popout.tips.full_text": "Simple text returns statuses you have written, favourited, boosted, or have been mentioned in, as well as matching usernames, display names, and hashtags.",
+  "search_popout.tips.hashtag": "hashtag",
+  "search_popout.tips.status": "status",
+  "search_popout.tips.text": "Simple text returns matching display names, usernames and hashtags",
+  "search_popout.tips.user": "user",
+  "search_results.accounts": "People",
+  "search_results.hashtags": "Hashtags",
+  "search_results.statuses": "Toots",
+  "search_results.total": "{count, number} {count, plural, one {result} other {results}}",
+  "standalone.public_title": "A look inside...",
+  "status.block": "Block @{name}",
+  "status.cancel_reblog_private": "Unboost",
+  "status.cannot_reblog": "This post cannot be boosted",
+  "status.delete": "Delete",
+  "status.direct": "Direct message @{name}",
+  "status.embed": "Embed",
+  "status.favourite": "Favourite",
+  "status.load_more": "Load more",
+  "status.media_hidden": "Media hidden",
+  "status.mention": "Mention @{name}",
+  "status.more": "More",
+  "status.mute": "Mute @{name}",
+  "status.mute_conversation": "Mute conversation",
+  "status.open": "Expand this status",
+  "status.pin": "Pin on profile",
+  "status.pinned": "Pinned toot",
+  "status.reblog": "Boost",
+  "status.reblog_private": "Boost to original audience",
+  "status.reblogged_by": "{name} boosted",
+  "status.reply": "Reply",
+  "status.replyAll": "Reply to thread",
+  "status.report": "Report @{name}",
+  "status.sensitive_toggle": "Click to view",
+  "status.sensitive_warning": "Sensitive content",
+  "status.share": "Share",
+  "status.show_less": "Show less",
+  "status.show_less_all": "Show less for all",
+  "status.show_more": "Show more",
+  "status.show_more_all": "Show more for all",
+  "status.unmute_conversation": "Unmute conversation",
+  "status.unpin": "Unpin from profile",
+  "tabs_bar.federated_timeline": "Federated",
+  "tabs_bar.home": "Home",
+  "tabs_bar.local_timeline": "Local",
+  "tabs_bar.notifications": "Notifications",
+  "tabs_bar.search": "Search",
+  "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",
+  "upload_form.focus": "Crop",
+  "upload_form.undo": "Undo",
+  "upload_progress.label": "Uploading...",
+  "video.close": "Close video",
+  "video.exit_fullscreen": "Exit full screen",
+  "video.expand": "Expand video",
+  "video.fullscreen": "Full screen",
+  "video.hide": "Hide video",
+  "video.mute": "Mute sound",
+  "video.pause": "Pause",
+  "video.play": "Play",
+  "video.unmute": "Unmute sound"
+}
diff --git a/app/javascript/mastodon/locales/fa.json b/app/javascript/mastodon/locales/fa.json
index cfe93007d..99aba00c3 100644
--- a/app/javascript/mastodon/locales/fa.json
+++ b/app/javascript/mastodon/locales/fa.json
@@ -135,6 +135,7 @@
   "keyboard_shortcuts.mention": "برای نام‌بردن از نویسنده",
   "keyboard_shortcuts.reply": "برای پاسخ‌دادن",
   "keyboard_shortcuts.search": "برای فعال‌کردن جستجو",
+  "keyboard_shortcuts.toggle_hidden": "to show/hide text behind CW",
   "keyboard_shortcuts.toot": "برای آغاز یک بوق تازه",
   "keyboard_shortcuts.unfocus": "برای برداشتن توجه از نوشتن/جستجو",
   "keyboard_shortcuts.up": "برای بالا رفتن در فهرست",
diff --git a/app/javascript/mastodon/locales/fi.json b/app/javascript/mastodon/locales/fi.json
index 1677c3c6c..07d4d9aa5 100644
--- a/app/javascript/mastodon/locales/fi.json
+++ b/app/javascript/mastodon/locales/fi.json
@@ -135,6 +135,7 @@
   "keyboard_shortcuts.mention": "mainitse julkaisija",
   "keyboard_shortcuts.reply": "vastaa",
   "keyboard_shortcuts.search": "siirry hakukenttään",
+  "keyboard_shortcuts.toggle_hidden": "to show/hide text behind CW",
   "keyboard_shortcuts.toot": "ala kirjoittaa uutta tuuttausta",
   "keyboard_shortcuts.unfocus": "siirry pois tekstikentästä tai hakukentästä",
   "keyboard_shortcuts.up": "siirry listassa ylöspäin",
diff --git a/app/javascript/mastodon/locales/fr.json b/app/javascript/mastodon/locales/fr.json
index 98c1c43d2..a4af97dda 100644
--- a/app/javascript/mastodon/locales/fr.json
+++ b/app/javascript/mastodon/locales/fr.json
@@ -2,7 +2,7 @@
   "account.block": "Bloquer @{name}",
   "account.block_domain": "Tout masquer venant de {domain}",
   "account.blocked": "Bloqué",
-  "account.direct": "Direct message @{name}",
+  "account.direct": "Message direct @{name}",
   "account.disclaimer_full": "Les données ci-dessous peuvent ne pas refléter ce profil dans sa totalité.",
   "account.domain_blocked": "Domaine caché",
   "account.edit_profile": "Modifier le profil",
@@ -18,7 +18,7 @@
   "account.mute_notifications": "Ignorer les notifications de @{name}",
   "account.muted": "Silencé",
   "account.posts": "Pouets",
-  "account.posts_with_replies": "Pouets avec réponses",
+  "account.posts_with_replies": "Pouets et réponses",
   "account.report": "Signaler",
   "account.requested": "En attente d'approbation. Cliquez pour annuler la requête",
   "account.share": "Partager le profil de @{name}",
@@ -29,8 +29,8 @@
   "account.unmute": "Ne plus masquer",
   "account.unmute_notifications": "Réactiver les notifications de @{name}",
   "account.view_full_profile": "Afficher le profil complet",
-  "alert.unexpected.message": "An unexpected error occurred.",
-  "alert.unexpected.title": "Oops!",
+  "alert.unexpected.message": "Une erreur non-attendue s'est produite.",
+  "alert.unexpected.title": "Oups !",
   "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.",
   "bundle_column_error.retry": "Réessayer",
@@ -40,8 +40,8 @@
   "bundle_modal_error.retry": "Réessayer",
   "column.blocks": "Comptes bloqués",
   "column.community": "Fil public local",
-  "column.direct": "Direct messages",
-  "column.domain_blocks": "Hidden domains",
+  "column.direct": "Messages directs",
+  "column.domain_blocks": "Domaines cachés",
   "column.favourites": "Favoris",
   "column.follow_requests": "Demandes de suivi",
   "column.home": "Accueil",
@@ -59,7 +59,7 @@
   "column_header.unpin": "Retirer",
   "column_subheading.navigation": "Navigation",
   "column_subheading.settings": "Paramètres",
-  "compose_form.direct_message_warning": "This toot will only be visible to all the mentioned users.",
+  "compose_form.direct_message_warning": "Ce pouet sera uniquement visible à tous les utilisateurs mentionnés.",
   "compose_form.hashtag_warning": "Ce pouet ne sera pas listé dans les recherches par hashtag car sa visibilité est réglée sur \"non-listé\". Seuls les pouets avec une visibilité \"publique\" peuvent être recherchés par hashtag.",
   "compose_form.lock_disclaimer": "Votre compte n’est pas {locked}. Tout le monde peut vous suivre et voir vos pouets privés.",
   "compose_form.lock_disclaimer.lock": "verrouillé",
@@ -101,7 +101,7 @@
   "emoji_button.symbols": "Symboles",
   "emoji_button.travel": "Lieux & Voyages",
   "empty_column.community": "Le fil public local est vide. Écrivez donc quelque chose pour le remplir !",
-  "empty_column.direct": "You don't have any direct messages yet. When you send or receive one, it will show up here.",
+  "empty_column.direct": "Vous n'avez pas encore de messages directs. Lorsque vous en enverrez ou recevrez un, il s'affichera ici.",
   "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 personnes.",
   "empty_column.home.public_timeline": "le fil public",
@@ -135,6 +135,7 @@
   "keyboard_shortcuts.mention": "pour mentionner l'auteur",
   "keyboard_shortcuts.reply": "pour répondre",
   "keyboard_shortcuts.search": "pour cibler la recherche",
+  "keyboard_shortcuts.toggle_hidden": "pour afficher/cacher un texte derrière CW",
   "keyboard_shortcuts.toot": "pour démarrer un tout nouveau pouet",
   "keyboard_shortcuts.unfocus": "pour recentrer composer textarea/search",
   "keyboard_shortcuts.up": "pour remonter dans la liste",
@@ -156,8 +157,8 @@
   "mute_modal.hide_notifications": "Masquer les notifications de cette personne ?",
   "navigation_bar.blocks": "Comptes bloqués",
   "navigation_bar.community_timeline": "Fil public local",
-  "navigation_bar.direct": "Direct messages",
-  "navigation_bar.domain_blocks": "Hidden domains",
+  "navigation_bar.direct": "Messages directs",
+  "navigation_bar.domain_blocks": "Domaines cachés",
   "navigation_bar.edit_profile": "Modifier le profil",
   "navigation_bar.favourites": "Favoris",
   "navigation_bar.follow_requests": "Demandes de suivi",
@@ -241,10 +242,10 @@
   "search_results.total": "{count, number} {count, plural, one {résultat} other {résultats}}",
   "standalone.public_title": "Un aperçu …",
   "status.block": "Block @{name}",
-  "status.cancel_reblog_private": "Unboost",
+  "status.cancel_reblog_private": "Dé-booster",
   "status.cannot_reblog": "Cette publication ne peut être boostée",
   "status.delete": "Effacer",
-  "status.direct": "Direct message @{name}",
+  "status.direct": "Message direct @{name}",
   "status.embed": "Intégrer",
   "status.favourite": "Ajouter aux favoris",
   "status.load_more": "Charger plus",
@@ -257,7 +258,7 @@
   "status.pin": "Épingler sur le profil",
   "status.pinned": "Pouet épinglé",
   "status.reblog": "Partager",
-  "status.reblog_private": "Boost to original audience",
+  "status.reblog_private": "Booster vers l'audience originale",
   "status.reblogged_by": "{name} a partagé :",
   "status.reply": "Répondre",
   "status.replyAll": "Répondre au fil",
@@ -275,7 +276,7 @@
   "tabs_bar.home": "Accueil",
   "tabs_bar.local_timeline": "Fil public local",
   "tabs_bar.notifications": "Notifications",
-  "tabs_bar.search": "Search",
+  "tabs_bar.search": "Chercher",
   "ui.beforeunload": "Votre brouillon sera perdu si vous quittez Mastodon.",
   "upload_area.title": "Glissez et déposez pour envoyer",
   "upload_button.label": "Joindre un média",
diff --git a/app/javascript/mastodon/locales/gl.json b/app/javascript/mastodon/locales/gl.json
index fca42374d..652ca31d1 100644
--- a/app/javascript/mastodon/locales/gl.json
+++ b/app/javascript/mastodon/locales/gl.json
@@ -18,7 +18,7 @@
   "account.mute_notifications": "Acalar as notificacións de @{name}",
   "account.muted": "Muted",
   "account.posts": "Toots",
-  "account.posts_with_replies": "Toots with replies",
+  "account.posts_with_replies": "Toots e respostas",
   "account.report": "Informar sobre @{name}",
   "account.requested": "Agardando aceptación. Pulse para cancelar a solicitude de seguimento",
   "account.share": "Compartir o perfil de @{name}",
@@ -135,6 +135,7 @@
   "keyboard_shortcuts.mention": "para mencionar o autor",
   "keyboard_shortcuts.reply": "para responder",
   "keyboard_shortcuts.search": "para centrar a busca",
+  "keyboard_shortcuts.toggle_hidden": "to show/hide text behind CW",
   "keyboard_shortcuts.toot": "escribir un toot novo",
   "keyboard_shortcuts.unfocus": "quitar o foco do área de escritura/busca",
   "keyboard_shortcuts.up": "ir hacia arriba na lista",
@@ -242,7 +243,7 @@
   "standalone.public_title": "Ollada dentro...",
   "status.block": "Block @{name}",
   "status.cancel_reblog_private": "Unboost",
-  "status.cannot_reblog": "Esta mensaxe non pode ser promocionada",
+  "status.cannot_reblog": "Esta mensaxe non pode ser promovida",
   "status.delete": "Eliminar",
   "status.direct": "Direct message @{name}",
   "status.embed": "Incrustar",
diff --git a/app/javascript/mastodon/locales/he.json b/app/javascript/mastodon/locales/he.json
index e3e87f1d0..0ffbb14f3 100644
--- a/app/javascript/mastodon/locales/he.json
+++ b/app/javascript/mastodon/locales/he.json
@@ -135,6 +135,7 @@
   "keyboard_shortcuts.mention": "לאזכר את המחבר(ת)",
   "keyboard_shortcuts.reply": "לענות",
   "keyboard_shortcuts.search": "להתמקד בחלון החיפוש",
+  "keyboard_shortcuts.toggle_hidden": "to show/hide text behind CW",
   "keyboard_shortcuts.toot": "להתחיל חיצרוץ חדש",
   "keyboard_shortcuts.unfocus": "לצאת מתיבת חיבור/חיפוש",
   "keyboard_shortcuts.up": "לנוע במעלה הרשימה",
diff --git a/app/javascript/mastodon/locales/hr.json b/app/javascript/mastodon/locales/hr.json
index b41c98394..c41cc3ea1 100644
--- a/app/javascript/mastodon/locales/hr.json
+++ b/app/javascript/mastodon/locales/hr.json
@@ -135,6 +135,7 @@
   "keyboard_shortcuts.mention": "to mention author",
   "keyboard_shortcuts.reply": "to reply",
   "keyboard_shortcuts.search": "to focus search",
+  "keyboard_shortcuts.toggle_hidden": "to show/hide text behind CW",
   "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",
diff --git a/app/javascript/mastodon/locales/hu.json b/app/javascript/mastodon/locales/hu.json
index 956accc67..a0c186184 100644
--- a/app/javascript/mastodon/locales/hu.json
+++ b/app/javascript/mastodon/locales/hu.json
@@ -135,6 +135,7 @@
   "keyboard_shortcuts.mention": "szerző megjelenítése",
   "keyboard_shortcuts.reply": "válaszolás",
   "keyboard_shortcuts.search": "kereső kiemelése",
+  "keyboard_shortcuts.toggle_hidden": "to show/hide text behind CW",
   "keyboard_shortcuts.toot": "új tülk megkezdése",
   "keyboard_shortcuts.unfocus": "tülk szerkesztés/keresés fókuszpontból való kivétele",
   "keyboard_shortcuts.up": "fennebb helyezés a listában",
diff --git a/app/javascript/mastodon/locales/hy.json b/app/javascript/mastodon/locales/hy.json
index 33e079201..a0442bad4 100644
--- a/app/javascript/mastodon/locales/hy.json
+++ b/app/javascript/mastodon/locales/hy.json
@@ -135,6 +135,7 @@
   "keyboard_shortcuts.mention": "հեղինակին նշելու համար",
   "keyboard_shortcuts.reply": "պատասխանելու համար",
   "keyboard_shortcuts.search": "որոնման դաշտին սեւեռվելու համար",
+  "keyboard_shortcuts.toggle_hidden": "to show/hide text behind CW",
   "keyboard_shortcuts.toot": "թարմ թութ սկսելու համար",
   "keyboard_shortcuts.unfocus": "տեքստի/որոնման տիրույթից ապասեւեռվելու համար",
   "keyboard_shortcuts.up": "ցանկով վերեւ շարժվելու համար",
diff --git a/app/javascript/mastodon/locales/id.json b/app/javascript/mastodon/locales/id.json
index 412ffd3a0..2fd922544 100644
--- a/app/javascript/mastodon/locales/id.json
+++ b/app/javascript/mastodon/locales/id.json
@@ -135,6 +135,7 @@
   "keyboard_shortcuts.mention": "to mention author",
   "keyboard_shortcuts.reply": "to reply",
   "keyboard_shortcuts.search": "untuk fokus mencari",
+  "keyboard_shortcuts.toggle_hidden": "to show/hide text behind CW",
   "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",
diff --git a/app/javascript/mastodon/locales/io.json b/app/javascript/mastodon/locales/io.json
index 9730bf934..ed45ee11e 100644
--- a/app/javascript/mastodon/locales/io.json
+++ b/app/javascript/mastodon/locales/io.json
@@ -135,6 +135,7 @@
   "keyboard_shortcuts.mention": "to mention author",
   "keyboard_shortcuts.reply": "to reply",
   "keyboard_shortcuts.search": "to focus search",
+  "keyboard_shortcuts.toggle_hidden": "to show/hide text behind CW",
   "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",
diff --git a/app/javascript/mastodon/locales/it.json b/app/javascript/mastodon/locales/it.json
index 5146d7ca2..a7ca62015 100644
--- a/app/javascript/mastodon/locales/it.json
+++ b/app/javascript/mastodon/locales/it.json
@@ -1,7 +1,7 @@
 {
   "account.block": "Blocca @{name}",
   "account.block_domain": "Hide everything from {domain}",
-  "account.blocked": "Blocked",
+  "account.blocked": "Bloccato",
   "account.direct": "Direct Message @{name}",
   "account.disclaimer_full": "Information below may reflect the user's profile incompletely.",
   "account.domain_blocked": "Domain hidden",
@@ -17,8 +17,8 @@
   "account.mute": "Silenzia @{name}",
   "account.mute_notifications": "Mute notifications from @{name}",
   "account.muted": "Muted",
-  "account.posts": "Posts",
-  "account.posts_with_replies": "Toots with replies",
+  "account.posts": "Toot",
+  "account.posts_with_replies": "Toot con risposte",
   "account.report": "Segnala @{name}",
   "account.requested": "In attesa di approvazione",
   "account.share": "Share @{name}'s profile",
@@ -105,7 +105,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.list": "Non c'è niente in questo elenco ancora. Quando i membri di questo elenco postano nuovi stati, questi appariranno qui.",
   "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",
@@ -135,6 +135,7 @@
   "keyboard_shortcuts.mention": "to mention author",
   "keyboard_shortcuts.reply": "to reply",
   "keyboard_shortcuts.search": "to focus search",
+  "keyboard_shortcuts.toggle_hidden": "to show/hide text behind CW",
   "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",
diff --git a/app/javascript/mastodon/locales/ja.json b/app/javascript/mastodon/locales/ja.json
index abd18742a..dbb4562de 100644
--- a/app/javascript/mastodon/locales/ja.json
+++ b/app/javascript/mastodon/locales/ja.json
@@ -29,8 +29,8 @@
   "account.unmute": "@{name}さんのミュートを解除",
   "account.unmute_notifications": "@{name}さんからの通知を受け取るようにする",
   "account.view_full_profile": "全ての情報を見る",
-  "alert.unexpected.message": "不明なエラーが発生しました",
-  "alert.unexpected.title": "エラー",
+  "alert.unexpected.message": "不明なエラーが発生しました。",
+  "alert.unexpected.title": "エラー!",
   "boost_modal.combo": "次からは{combo}を押せばスキップできます",
   "bundle_column_error.body": "コンポーネントの読み込み中に問題が発生しました。",
   "bundle_column_error.retry": "再試行",
@@ -104,7 +104,7 @@
   "emoji_button.symbols": "記号",
   "emoji_button.travel": "旅行と場所",
   "empty_column.community": "ローカルタイムラインはまだ使われていません。何か書いてみましょう!",
-  "empty_column.direct": "あなたはまだダイレクトメッセージを受け取っていません。あなたが送ったり受け取ったりすると、ここに表示されます。",
+  "empty_column.direct": "ダイレクトメッセージはまだありません。ダイレクトメッセージをやりとりすると、ここに表示されます。",
   "empty_column.hashtag": "このハッシュタグはまだ使われていません。",
   "empty_column.home": "まだ誰もフォローしていません。{public}を見に行くか、検索を使って他のユーザーを見つけましょう。",
   "empty_column.home.public_timeline": "連合タイムライン",
@@ -138,6 +138,7 @@
   "keyboard_shortcuts.mention": "メンション",
   "keyboard_shortcuts.reply": "返信",
   "keyboard_shortcuts.search": "検索欄に移動",
+  "keyboard_shortcuts.toggle_hidden": "CWで隠れた文を見る/隠す",
   "keyboard_shortcuts.toot": "新規トゥート",
   "keyboard_shortcuts.unfocus": "トゥート入力欄・検索欄から離れる",
   "keyboard_shortcuts.up": "カラム内一つ上に移動",
@@ -159,7 +160,7 @@
   "mute_modal.hide_notifications": "このユーザーからの通知を隠しますか?",
   "navigation_bar.blocks": "ブロックしたユーザー",
   "navigation_bar.community_timeline": "ローカルタイムライン",
-  "navigation_bar.direct": "Direct messages",
+  "navigation_bar.direct": "ダイレクトメッセージ",
   "navigation_bar.domain_blocks": "非表示にしたドメイン",
   "navigation_bar.edit_profile": "プロフィールを編集",
   "navigation_bar.favourites": "お気に入り",
@@ -245,7 +246,7 @@
   "search_results.total": "{count, number}件の結果",
   "standalone.public_title": "今こんな話をしています...",
   "status.block": "@{name}さんをブロック",
-  "status.cancel_reblog_private": "Unboost",
+  "status.cancel_reblog_private": "ブースト解除",
   "status.cannot_reblog": "この投稿はブーストできません",
   "status.delete": "削除",
   "status.direct": "@{name}さんにダイレクトメッセージ",
@@ -261,7 +262,7 @@
   "status.pin": "プロフィールに固定表示",
   "status.pinned": "固定されたトゥート",
   "status.reblog": "ブースト",
-  "status.reblog_private": "Boost to original audience",
+  "status.reblog_private": "ブースト",
   "status.reblogged_by": "{name}さんがブースト",
   "status.reply": "返信",
   "status.replyAll": "全員に返信",
diff --git a/app/javascript/mastodon/locales/ko.json b/app/javascript/mastodon/locales/ko.json
index 92367dc95..2a2734673 100644
--- a/app/javascript/mastodon/locales/ko.json
+++ b/app/javascript/mastodon/locales/ko.json
@@ -2,7 +2,7 @@
   "account.block": "@{name}을 차단",
   "account.block_domain": "{domain} 전체를 숨김",
   "account.blocked": "차단 됨",
-  "account.direct": "Direct message @{name}",
+  "account.direct": "@{name}으로부터의 다이렉트 메시지",
   "account.disclaimer_full": "여기 있는 정보는 유저의 프로파일을 정확히 반영하지 못 할 수도 있습니다.",
   "account.domain_blocked": "도메인 숨겨짐",
   "account.edit_profile": "프로필 편집",
@@ -12,7 +12,7 @@
   "account.follows_you": "날 팔로우합니다",
   "account.hide_reblogs": "@{name}의 부스트를 숨기기",
   "account.media": "미디어",
-  "account.mention": "답장",
+  "account.mention": "@{name}에게 글쓰기",
   "account.moved_to": "{name}는 계정을 이동했습니다:",
   "account.mute": "@{name} 뮤트",
   "account.mute_notifications": "@{name}의 알림을 뮤트",
@@ -135,6 +135,7 @@
   "keyboard_shortcuts.mention": "멘션",
   "keyboard_shortcuts.reply": "답장",
   "keyboard_shortcuts.search": "검색창에 포커스",
+  "keyboard_shortcuts.toggle_hidden": "to show/hide text behind CW",
   "keyboard_shortcuts.toot": "새 툿 작성",
   "keyboard_shortcuts.unfocus": "작성창에서 포커스 해제",
   "keyboard_shortcuts.up": "리스트에서 위로 이동",
diff --git a/app/javascript/mastodon/locales/nl.json b/app/javascript/mastodon/locales/nl.json
index c18ddbd01..adc1d19a7 100644
--- a/app/javascript/mastodon/locales/nl.json
+++ b/app/javascript/mastodon/locales/nl.json
@@ -18,7 +18,7 @@
   "account.mute_notifications": "Negeer meldingen van @{name}",
   "account.muted": "Genegeerd",
   "account.posts": "Toots",
-  "account.posts_with_replies": "Toots met reacties",
+  "account.posts_with_replies": "Toots en reacties",
   "account.report": "Rapporteer @{name}",
   "account.requested": "Wacht op goedkeuring. Klik om het volgverzoek te annuleren",
   "account.share": "Profiel van @{name} delen",
@@ -29,8 +29,8 @@
   "account.unmute": "@{name} niet meer negeren",
   "account.unmute_notifications": "@{name} meldingen niet meer negeren",
   "account.view_full_profile": "Volledig profiel tonen",
-  "alert.unexpected.message": "An unexpected error occurred.",
-  "alert.unexpected.title": "Oops!",
+  "alert.unexpected.message": "Er deed zich een onverwachte fout voor",
+  "alert.unexpected.title": "Oeps!",
   "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.",
   "bundle_column_error.retry": "Opnieuw proberen",
@@ -40,8 +40,8 @@
   "bundle_modal_error.retry": "Opnieuw proberen",
   "column.blocks": "Geblokkeerde gebruikers",
   "column.community": "Lokale tijdlijn",
-  "column.direct": "Direct messages",
-  "column.domain_blocks": "Hidden domains",
+  "column.direct": "Directe berichten",
+  "column.domain_blocks": "Verborgen domeinen",
   "column.favourites": "Favorieten",
   "column.follow_requests": "Volgverzoeken",
   "column.home": "Start",
@@ -59,7 +59,7 @@
   "column_header.unpin": "Losmaken",
   "column_subheading.navigation": "Navigatie",
   "column_subheading.settings": "Instellingen",
-  "compose_form.direct_message_warning": "This toot will only be visible to all the mentioned users.",
+  "compose_form.direct_message_warning": "Deze toot zal alleen zichtbaar zijn voor alle vermelde gebruikers.",
   "compose_form.hashtag_warning": "Deze toot valt niet onder een hashtag te bekijken, omdat deze niet op openbare tijdlijnen wordt getoond. Alleen openbare toots kunnen via hashtags gevonden worden.",
   "compose_form.lock_disclaimer": "Jouw account is niet {locked}. Iedereen kan jou volgen en toots zien die je alleen aan volgers hebt gericht.",
   "compose_form.lock_disclaimer.lock": "besloten",
@@ -101,7 +101,7 @@
   "emoji_button.symbols": "Symbolen",
   "emoji_button.travel": "Reizen en plekken",
   "empty_column.community": "De lokale tijdlijn is nog leeg. Toot iets in het openbaar om de bal aan het rollen te krijgen!",
-  "empty_column.direct": "You don't have any direct messages yet. When you send or receive one, it will show up here.",
+  "empty_column.direct": "Je hebt nog geen directe berichten. Wanneer je er een verzend of ontvangt, zijn deze hier te zien.",
   "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",
@@ -127,7 +127,7 @@
   "keyboard_shortcuts.compose": "om het tekstvak voor toots te focussen",
   "keyboard_shortcuts.description": "Omschrijving",
   "keyboard_shortcuts.down": "om naar beneden door de lijst te bewegen",
-  "keyboard_shortcuts.enter": "to open status",
+  "keyboard_shortcuts.enter": "om toot volledig te tonen",
   "keyboard_shortcuts.favourite": "om als favoriet te markeren",
   "keyboard_shortcuts.heading": "Sneltoetsen",
   "keyboard_shortcuts.hotkey": "Sneltoets",
@@ -135,6 +135,7 @@
   "keyboard_shortcuts.mention": "om de auteur te vermelden",
   "keyboard_shortcuts.reply": "om te reageren",
   "keyboard_shortcuts.search": "om het zoekvak te focussen",
+  "keyboard_shortcuts.toggle_hidden": "om tekst achter een waarschuwing (CW) te tonen/verbergen",
   "keyboard_shortcuts.toot": "om een nieuwe toot te starten",
   "keyboard_shortcuts.unfocus": "om het tekst- en zoekvak te ontfocussen",
   "keyboard_shortcuts.up": "om omhoog te bewegen in de lijst",
@@ -156,8 +157,8 @@
   "mute_modal.hide_notifications": "Verberg meldingen van deze persoon?",
   "navigation_bar.blocks": "Geblokkeerde gebruikers",
   "navigation_bar.community_timeline": "Lokale tijdlijn",
-  "navigation_bar.direct": "Direct messages",
-  "navigation_bar.domain_blocks": "Hidden domains",
+  "navigation_bar.direct": "Directe berichten",
+  "navigation_bar.domain_blocks": "Verborgen domeinen",
   "navigation_bar.edit_profile": "Profiel bewerken",
   "navigation_bar.favourites": "Favorieten",
   "navigation_bar.follow_requests": "Volgverzoeken",
@@ -241,10 +242,10 @@
   "search_results.total": "{count, number} {count, plural, one {resultaat} other {resultaten}}",
   "standalone.public_title": "Een kijkje binnenin...",
   "status.block": "Blokkeer @{name}",
-  "status.cancel_reblog_private": "Unboost",
+  "status.cancel_reblog_private": "Niet meer boosten",
   "status.cannot_reblog": "Deze toot kan niet geboost worden",
   "status.delete": "Verwijderen",
-  "status.direct": "Direct message @{name}",
+  "status.direct": "Directe toot @{name}",
   "status.embed": "Embed",
   "status.favourite": "Favoriet",
   "status.load_more": "Meer laden",
@@ -257,7 +258,7 @@
   "status.pin": "Aan profielpagina vastmaken",
   "status.pinned": "Vastgemaakte toot",
   "status.reblog": "Boost",
-  "status.reblog_private": "Boost to original audience",
+  "status.reblog_private": "Boost naar oorspronkelijke ontvangers",
   "status.reblogged_by": "{name} boostte",
   "status.reply": "Reageren",
   "status.replyAll": "Reageer op iedereen",
@@ -275,7 +276,7 @@
   "tabs_bar.home": "Start",
   "tabs_bar.local_timeline": "Lokaal",
   "tabs_bar.notifications": "Meldingen",
-  "tabs_bar.search": "Search",
+  "tabs_bar.search": "Zoeken",
   "ui.beforeunload": "Je concept zal verloren gaan als je Mastodon verlaat.",
   "upload_area.title": "Hierin slepen om te uploaden",
   "upload_button.label": "Media toevoegen",
diff --git a/app/javascript/mastodon/locales/no.json b/app/javascript/mastodon/locales/no.json
index 282a72acb..0ee6d0722 100644
--- a/app/javascript/mastodon/locales/no.json
+++ b/app/javascript/mastodon/locales/no.json
@@ -135,6 +135,7 @@
   "keyboard_shortcuts.mention": "å nevne forfatter",
   "keyboard_shortcuts.reply": "for å svare",
   "keyboard_shortcuts.search": "å fokusere søk",
+  "keyboard_shortcuts.toggle_hidden": "to show/hide text behind CW",
   "keyboard_shortcuts.toot": "å starte en helt ny tut",
   "keyboard_shortcuts.unfocus": "å ufokusere komponerings-/søkefeltet",
   "keyboard_shortcuts.up": "å flytte opp i listen",
diff --git a/app/javascript/mastodon/locales/oc.json b/app/javascript/mastodon/locales/oc.json
index 7170aefb8..d4836e9fe 100644
--- a/app/javascript/mastodon/locales/oc.json
+++ b/app/javascript/mastodon/locales/oc.json
@@ -18,7 +18,7 @@
   "account.mute_notifications": "Rescondre las notificacions de @{name}",
   "account.muted": "Mes en silenci",
   "account.posts": "Tuts",
-  "account.posts_with_replies": "Tuts amb responsas",
+  "account.posts_with_replies": "Tuts e responsas",
   "account.report": "Senhalar @{name}",
   "account.requested": "Invitacion mandada. Clicatz per anullar",
   "account.share": "Partejar lo perfil a @{name}",
@@ -29,8 +29,8 @@
   "account.unmute": "Quitar de rescondre @{name}",
   "account.unmute_notifications": "Mostrar las notificacions de @{name}",
   "account.view_full_profile": "Veire lo perfil complèt",
-  "alert.unexpected.message": "An unexpected error occurred.",
-  "alert.unexpected.title": "Oops!",
+  "alert.unexpected.message": "Una error s’es producha.",
+  "alert.unexpected.title": "Ops !",
   "boost_modal.combo": "Podètz botar {combo} per passar aquò lo còp que ven",
   "bundle_column_error.body": "Quicòm a fach mèuca pendent lo cargament d’aqueste compausant.",
   "bundle_column_error.retry": "Tornar ensajar",
@@ -40,8 +40,8 @@
   "bundle_modal_error.retry": "Tornar ensajar",
   "column.blocks": "Personas blocadas",
   "column.community": "Flux public local",
-  "column.direct": "Direct messages",
-  "column.domain_blocks": "Hidden domains",
+  "column.direct": "Messatges dirèctes",
+  "column.domain_blocks": "Domenis blocats",
   "column.favourites": "Favorits",
   "column.follow_requests": "Demandas d’abonament",
   "column.home": "Acuèlh",
@@ -59,7 +59,7 @@
   "column_header.unpin": "Despenjar",
   "column_subheading.navigation": "Navigacion",
   "column_subheading.settings": "Paramètres",
-  "compose_form.direct_message_warning": "This toot will only be visible to all the mentioned users.",
+  "compose_form.direct_message_warning": "Aqueste tut serà pas que visibile pel monde mencionat.",
   "compose_form.hashtag_warning": "Aqueste tut serà pas ligat a cap etiqueta estant qu’es pas listat. Òm pas cercar que los tuts publics per etiqueta.",
   "compose_form.lock_disclaimer": "Vòstre compte es pas {locked}. Tot lo mond pòt vos sègre e veire los estatuts reservats als seguidors.",
   "compose_form.lock_disclaimer.lock": "clavat",
@@ -73,13 +73,13 @@
   "compose_form.spoiler_placeholder": "Escrivètz l’avertiment aquí",
   "confirmation_modal.cancel": "Anullar",
   "confirmations.block.confirm": "Blocar",
-  "confirmations.block.message": "Sètz segur de voler blocar {name} ?",
+  "confirmations.block.message": "Volètz vertadièrament blocar {name} ?",
   "confirmations.delete.confirm": "Escafar",
-  "confirmations.delete.message": "Sètz segur de voler escafar l’estatut ?",
+  "confirmations.delete.message": "Volètz vertadièrament escafar l’estatut ?",
   "confirmations.delete_list.confirm": "Suprimir",
-  "confirmations.delete_list.message": "Sètz segur de voler suprimir aquesta lista per totjorn ?",
+  "confirmations.delete_list.message": "Volètz vertadièrament suprimir aquesta lista per totjorn ?",
   "confirmations.domain_block.confirm": "Amagar tot lo domeni",
-  "confirmations.domain_block.message": "Sètz segur segur de voler blocar complètament {domain} ? De còps cal pas que blocar o rescondre unas personas solament.",
+  "confirmations.domain_block.message": "Volètz vertadièrament blocar complètament {domain} ? De còps cal pas que blocar o rescondre unas personas solament.",
   "confirmations.mute.confirm": "Rescondre",
   "confirmations.mute.message": "Sètz segur de voler rescondre {name} ?",
   "confirmations.unfollow.confirm": "Quitar de sègre",
@@ -101,7 +101,7 @@
   "emoji_button.symbols": "Simbòls",
   "emoji_button.travel": "Viatges & lòcs",
   "empty_column.community": "Lo flux public local es void. Escrivètz quicòm per lo garnir !",
-  "empty_column.direct": "You don't have any direct messages yet. When you send or receive one, it will show up here.",
+  "empty_column.direct": "Avètz pas encara de messatges. Quand ne mandatz un o que ne recebètz un, serà mostrat aquí.",
   "empty_column.hashtag": "I a pas encara de contengut ligat a aquesta etiqueta.",
   "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",
@@ -135,6 +135,7 @@
   "keyboard_shortcuts.mention": "mencionar l’autor",
   "keyboard_shortcuts.reply": "respondre",
   "keyboard_shortcuts.search": "anar a la recèrca",
+  "keyboard_shortcuts.toggle_hidden": "mostrar/amagar lo tèxte dels avertiments",
   "keyboard_shortcuts.toot": "començar un estatut tot novèl",
   "keyboard_shortcuts.unfocus": "quitar lo camp tèxte/de recèrca",
   "keyboard_shortcuts.up": "far montar dins la lista",
@@ -156,7 +157,7 @@
   "mute_modal.hide_notifications": "Rescondre las notificacions d’aquesta persona ?",
   "navigation_bar.blocks": "Personas blocadas",
   "navigation_bar.community_timeline": "Flux public local",
-  "navigation_bar.direct": "Direct messages",
+  "navigation_bar.direct": "Messatges dirèctes",
   "navigation_bar.domain_blocks": "Hidden domains",
   "navigation_bar.edit_profile": "Modificar lo perfil",
   "navigation_bar.favourites": "Favorits",
@@ -216,7 +217,7 @@
   "privacy.unlisted.short": "Pas-listat",
   "regeneration_indicator.label": "Cargament…",
   "regeneration_indicator.sublabel": "Sèm a preparar vòstre flux d’acuèlh !",
-  "relative_time.days": "fa {number} d",
+  "relative_time.days": "fa {number}d",
   "relative_time.hours": "fa {number}h",
   "relative_time.just_now": "ara",
   "relative_time.minutes": "fa {number} min",
@@ -235,16 +236,16 @@
   "search_popout.tips.status": "estatut",
   "search_popout.tips.text": "Lo tèxt brut tòrna escais, noms d’utilizaire e etiquetas correspondents",
   "search_popout.tips.user": "utilizaire",
-  "search_results.accounts": "Monde",
+  "search_results.accounts": "Gents",
   "search_results.hashtags": "Etiquetas",
   "search_results.statuses": "Tuts",
   "search_results.total": "{count, number} {count, plural, one {resultat} other {resultats}}",
   "standalone.public_title": "Una ulhada dedins…",
   "status.block": "Blocar @{name}",
-  "status.cancel_reblog_private": "Unboost",
+  "status.cancel_reblog_private": "Quitar de partejar",
   "status.cannot_reblog": "Aqueste estatut pòt pas èsser partejat",
   "status.delete": "Escafar",
-  "status.direct": "Direct message @{name}",
+  "status.direct": "Messatge per @{name}",
   "status.embed": "Embarcar",
   "status.favourite": "Apondre als favorits",
   "status.load_more": "Cargar mai",
@@ -257,7 +258,7 @@
   "status.pin": "Penjar al perfil",
   "status.pinned": "Tut penjat",
   "status.reblog": "Partejar",
-  "status.reblog_private": "Boost to original audience",
+  "status.reblog_private": "Partejar al l’audiéncia d’origina",
   "status.reblogged_by": "{name} a partejat",
   "status.reply": "Respondre",
   "status.replyAll": "Respondre a la conversacion",
@@ -275,7 +276,7 @@
   "tabs_bar.home": "Acuèlh",
   "tabs_bar.local_timeline": "Flux public local",
   "tabs_bar.notifications": "Notificacions",
-  "tabs_bar.search": "Search",
+  "tabs_bar.search": "Recèrcas",
   "ui.beforeunload": "Vòstre brolhon serà perdut se quitatz Mastodon.",
   "upload_area.title": "Lisatz e depausatz per mandar",
   "upload_button.label": "Ajustar un mèdia",
diff --git a/app/javascript/mastodon/locales/pl.json b/app/javascript/mastodon/locales/pl.json
index 08aea797d..6d6db7c82 100644
--- a/app/javascript/mastodon/locales/pl.json
+++ b/app/javascript/mastodon/locales/pl.json
@@ -106,8 +106,8 @@
   "empty_column.community": "Lokalna oś czasu jest pusta. Napisz coś publicznie, aby zagaić!",
   "empty_column.direct": "Nie masz żadnych wiadomości bezpośrednich. Jeżeli wyślesz lub otrzymasz jakąś, będzie tu widoczna.",
   "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.home": "Nie śledzisz nikogo. Odwiedź globalną oś czasu lub użyj wyszukiwarki, aby znaleźć interesujące Cię profile.",
+  "empty_column.home.public_timeline": "globalna oś czasu",
   "empty_column.list": "Nie ma nic na tej liście. Kiedy członkowie listy dodadzą nowe wpisy, pojawia się one tutaj.",
   "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ć",
@@ -173,7 +173,7 @@
   "navigation_bar.mutes": "Wyciszeni użytkownicy",
   "navigation_bar.pins": "Przypięte wpisy",
   "navigation_bar.preferences": "Preferencje",
-  "navigation_bar.public_timeline": "Oś czasu federacji",
+  "navigation_bar.public_timeline": "Globalna oś czasu",
   "notification.favourite": "{name} dodał Twój wpis do ulubionych",
   "notification.follow": "{name} zaczął Cię śledzić",
   "notification.mention": "{name} wspomniał o tobie",
@@ -191,7 +191,7 @@
   "notifications.column_settings.sound": "Odtwarzaj dźwięk",
   "onboarding.done": "Gotowe",
   "onboarding.next": "Dalej",
-  "onboarding.page_five.public_timelines": "Lokalna oś czasu zawiera wszystkie publiczne wpisy z {domain}. Federalna oś czasu wyświetla publiczne wpisy śledzonych przez członków {domain}. Są to publiczne osie czasu – najlepszy sposób na poznanie nowych osób.",
+  "onboarding.page_five.public_timelines": "Lokalna oś czasu zawiera wszystkie publiczne wpisy z {domain}. Globalna oś czasu wyświetla publiczne wpisy śledzonych przez członków {domain}. Są to publiczne osie czasu – najlepszy sposób na poznanie nowych osób.",
   "onboarding.page_four.home": "Główna oś czasu wyświetla publiczne wpisy.",
   "onboarding.page_four.notifications": "Kolumna powiadomień wyświetla, gdy ktoś dokonuje interakcji z tobą.",
   "onboarding.page_one.federation": "Mastodon jest siecią niezależnych serwerów połączonych w jeden portal społecznościowy. Nazywamy te serwery instancjami.",
@@ -251,7 +251,7 @@
   "status.delete": "Usuń",
   "status.direct": "Wyślij wiadomość bezpośrednią do @{name}",
   "status.embed": "Osadź",
-  "status.favourite": "Ulubione",
+  "status.favourite": "Dodaj do ulubionych",
   "status.load_more": "Załaduj więcej",
   "status.media_hidden": "Zawartość multimedialna ukryta",
   "status.mention": "Wspomnij o @{name}",
diff --git a/app/javascript/mastodon/locales/pt-BR.json b/app/javascript/mastodon/locales/pt-BR.json
index c604476c7..7f8690f91 100644
--- a/app/javascript/mastodon/locales/pt-BR.json
+++ b/app/javascript/mastodon/locales/pt-BR.json
@@ -29,7 +29,7 @@
   "account.unmute": "Não silenciar @{name}",
   "account.unmute_notifications": "Retirar silêncio das notificações vindas de @{name}",
   "account.view_full_profile": "Ver perfil completo",
-  "alert.unexpected.message": "An unexpected error occurred.",
+  "alert.unexpected.message": "Um erro inesperado ocorreu.",
   "alert.unexpected.title": "Oops!",
   "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.",
@@ -40,8 +40,8 @@
   "bundle_modal_error.retry": "Tente novamente",
   "column.blocks": "Usuários bloqueados",
   "column.community": "Local",
-  "column.direct": "Direct messages",
-  "column.domain_blocks": "Hidden domains",
+  "column.direct": "Mensagens diretas",
+  "column.domain_blocks": "Domínios escondidos",
   "column.favourites": "Favoritos",
   "column.follow_requests": "Seguidores pendentes",
   "column.home": "Página inicial",
@@ -59,7 +59,7 @@
   "column_header.unpin": "Desafixar",
   "column_subheading.navigation": "Navegação",
   "column_subheading.settings": "Configurações",
-  "compose_form.direct_message_warning": "This toot will only be visible to all the mentioned users.",
+  "compose_form.direct_message_warning": "Este toot só será visível a todos os usuários mencionados.",
   "compose_form.hashtag_warning": "Esse toot não será listado em nenhuma hashtag por ser não listado. Somente toots públicos podem ser pesquisados por hashtag.",
   "compose_form.lock_disclaimer": "A sua conta não está {locked}. Qualquer pessoa pode te seguir e visualizar postagens direcionadas a apenas seguidores.",
   "compose_form.lock_disclaimer.lock": "trancada",
@@ -101,7 +101,7 @@
   "emoji_button.symbols": "Símbolos",
   "emoji_button.travel": "Viagens & Lugares",
   "empty_column.community": "A timeline local está vazia. Escreva algo publicamente para começar!",
-  "empty_column.direct": "You don't have any direct messages yet. When you send or receive one, it will show up here.",
+  "empty_column.direct": "Você não tem nenhuma mensagem direta ainda. Quando você enviar ou receber uma, as mensagens aparecerão por aqui.",
   "empty_column.hashtag": "Ainda não há qualquer conteúdo com essa hashtag.",
   "empty_column.home": "Você ainda não segue usuário algum. Visite a timeline {public} ou use o buscador para procurar e conhecer outros usuários.",
   "empty_column.home.public_timeline": "global",
@@ -135,6 +135,7 @@
   "keyboard_shortcuts.mention": "para mencionar o autor",
   "keyboard_shortcuts.reply": "para responder",
   "keyboard_shortcuts.search": "para focar a pesquisa",
+  "keyboard_shortcuts.toggle_hidden": "mostrar/esconder o texto com aviso de conteúdo",
   "keyboard_shortcuts.toot": "para compor um novo toot",
   "keyboard_shortcuts.unfocus": "para remover o foco da área de composição/pesquisa",
   "keyboard_shortcuts.up": "para mover para cima na lista",
@@ -156,8 +157,8 @@
   "mute_modal.hide_notifications": "Esconder notificações deste usuário?",
   "navigation_bar.blocks": "Usuários bloqueados",
   "navigation_bar.community_timeline": "Local",
-  "navigation_bar.direct": "Direct messages",
-  "navigation_bar.domain_blocks": "Hidden domains",
+  "navigation_bar.direct": "Mensagens diretas",
+  "navigation_bar.domain_blocks": "Domínios escondidos",
   "navigation_bar.edit_profile": "Editar perfil",
   "navigation_bar.favourites": "Favoritos",
   "navigation_bar.follow_requests": "Seguidores pendentes",
@@ -241,10 +242,10 @@
   "search_results.total": "{count, number} {count, plural, one {resultado} other {resultados}}",
   "standalone.public_title": "Dê uma espiada...",
   "status.block": "Block @{name}",
-  "status.cancel_reblog_private": "Unboost",
+  "status.cancel_reblog_private": "Retirar o compartilhamento",
   "status.cannot_reblog": "Esta postagem não pode ser compartilhada",
   "status.delete": "Excluir",
-  "status.direct": "Direct message @{name}",
+  "status.direct": "Enviar mensagem direta à @{name}",
   "status.embed": "Incorporar",
   "status.favourite": "Adicionar aos favoritos",
   "status.load_more": "Carregar mais",
@@ -257,7 +258,7 @@
   "status.pin": "Fixar no perfil",
   "status.pinned": "Toot fixado",
   "status.reblog": "Compartilhar",
-  "status.reblog_private": "Boost to original audience",
+  "status.reblog_private": "Compartilhar com a audiência original",
   "status.reblogged_by": "{name} compartilhou",
   "status.reply": "Responder",
   "status.replyAll": "Responder à sequência",
@@ -275,7 +276,7 @@
   "tabs_bar.home": "Página inicial",
   "tabs_bar.local_timeline": "Local",
   "tabs_bar.notifications": "Notificações",
-  "tabs_bar.search": "Search",
+  "tabs_bar.search": "Buscar",
   "ui.beforeunload": "Seu rascunho será perdido se você sair do Mastodon.",
   "upload_area.title": "Arraste e solte para enviar",
   "upload_button.label": "Adicionar mídia",
diff --git a/app/javascript/mastodon/locales/pt.json b/app/javascript/mastodon/locales/pt.json
index 826785aad..ce816dc41 100644
--- a/app/javascript/mastodon/locales/pt.json
+++ b/app/javascript/mastodon/locales/pt.json
@@ -135,6 +135,7 @@
   "keyboard_shortcuts.mention": "para mencionar o autor",
   "keyboard_shortcuts.reply": "para responder",
   "keyboard_shortcuts.search": "para focar na pesquisa",
+  "keyboard_shortcuts.toggle_hidden": "to show/hide text behind CW",
   "keyboard_shortcuts.toot": "para compor um novo post",
   "keyboard_shortcuts.unfocus": "para remover o foco da área de publicação/pesquisa",
   "keyboard_shortcuts.up": "para mover para cima na lista",
diff --git a/app/javascript/mastodon/locales/ru.json b/app/javascript/mastodon/locales/ru.json
index bb3cc1794..8eeebaf73 100644
--- a/app/javascript/mastodon/locales/ru.json
+++ b/app/javascript/mastodon/locales/ru.json
@@ -135,6 +135,7 @@
   "keyboard_shortcuts.mention": "упомянуть автора поста",
   "keyboard_shortcuts.reply": "ответить",
   "keyboard_shortcuts.search": "перейти к поиску",
+  "keyboard_shortcuts.toggle_hidden": "to show/hide text behind CW",
   "keyboard_shortcuts.toot": "начать писать новый пост",
   "keyboard_shortcuts.unfocus": "убрать фокус с поля ввода/поиска",
   "keyboard_shortcuts.up": "вверх по списку",
diff --git a/app/javascript/mastodon/locales/sk.json b/app/javascript/mastodon/locales/sk.json
index 58274fd2d..e5e826c96 100644
--- a/app/javascript/mastodon/locales/sk.json
+++ b/app/javascript/mastodon/locales/sk.json
@@ -2,7 +2,7 @@
   "account.block": "Blokovať @{name}",
   "account.block_domain": "Ukryť všetko z {domain}",
   "account.blocked": "Blokovaný/á",
-  "account.direct": "Direct message @{name}",
+  "account.direct": "Súkromná správa pre @{name}",
   "account.disclaimer_full": "Inofrmácie nižšie nemusia byť úplným odrazom uživateľovho účtu.",
   "account.domain_blocked": "Doména ukrytá",
   "account.edit_profile": "Upraviť profil",
@@ -29,7 +29,7 @@
   "account.unmute": "Prestať ignorovať @{name}",
   "account.unmute_notifications": "Odtĺmiť notifikácie od @{name}",
   "account.view_full_profile": "Pozri celý profil",
-  "alert.unexpected.message": "An unexpected error occurred.",
+  "alert.unexpected.message": "Vyskytla sa neočakávaná chyba.",
   "alert.unexpected.title": "Oops!",
   "boost_modal.combo": "Nabudúce môžete kliknúť {combo} aby ste preskočili",
   "bundle_column_error.body": "Nastala chyba pri načítaní tohto komponentu.",
@@ -40,8 +40,8 @@
   "bundle_modal_error.retry": "Skúsiť znova",
   "column.blocks": "Blokovaní užívatelia",
   "column.community": "Lokálna časová os",
-  "column.direct": "Direct messages",
-  "column.domain_blocks": "Hidden domains",
+  "column.direct": "Súkromné správy",
+  "column.domain_blocks": "Skryté domény",
   "column.favourites": "Obľúbené",
   "column.follow_requests": "Žiadosti o sledovanie",
   "column.home": "Domov",
@@ -59,7 +59,7 @@
   "column_header.unpin": "Odopnúť",
   "column_subheading.navigation": "Navigácia",
   "column_subheading.settings": "Nastavenia",
-  "compose_form.direct_message_warning": "This toot will only be visible to all the mentioned users.",
+  "compose_form.direct_message_warning": "Tento príspevok bude videný výhradne iba spomenutými užívateľmi.",
   "compose_form.hashtag_warning": "Tento toot nebude zobrazený pod žiadným haštagom lebo nieje listovaný. Iba verejné tooty môžu byť nájdené podľa haštagu.",
   "compose_form.lock_disclaimer": "Váš účet nie je zamknutý. Ktokoľvek ťa môže nasledovať a vidieť tvoje správy pre sledujúcich.",
   "compose_form.lock_disclaimer.lock": "zamknutý",
@@ -101,7 +101,7 @@
   "emoji_button.symbols": "Symboly",
   "emoji_button.travel": "Cestovanie a miesta",
   "empty_column.community": "Lokálna časová os je prázdna. Napíšte niečo, aby sa to tu začalo hýbať!",
-  "empty_column.direct": "You don't have any direct messages yet. When you send or receive one, it will show up here.",
+  "empty_column.direct": "Ešte nemáš žiadne súkromné správy. Keď nejakú pošleš, alebo dostaneš, ukáže sa tu.",
   "empty_column.hashtag": "Pod týmto hashtagom sa ešte nič nenachádza.",
   "empty_column.home": "Vaša lokálna osa je zatiaľ prázdna! Pre začiatok pozrite {public} alebo použite vyhľadávanie a nájdite tak ostatných používateľov.",
   "empty_column.home.public_timeline": "verejná časová os",
@@ -135,6 +135,7 @@
   "keyboard_shortcuts.mention": "spomenúť autora",
   "keyboard_shortcuts.reply": "odpovedať",
   "keyboard_shortcuts.search": "zamerať sa na vyhľadávanie",
+  "keyboard_shortcuts.toggle_hidden": "ukáž/skry text za CW",
   "keyboard_shortcuts.toot": "začať úplne novú hlášku",
   "keyboard_shortcuts.unfocus": "nesústrediť sa na písaciu plochu, alebo hľadanie",
   "keyboard_shortcuts.up": "posunúť sa vyššie v zozname",
@@ -156,8 +157,8 @@
   "mute_modal.hide_notifications": "Skryť notifikácie od tohoto užívateľa?",
   "navigation_bar.blocks": "Blokovaní užívatelia",
   "navigation_bar.community_timeline": "Lokálna časová os",
-  "navigation_bar.direct": "Direct messages",
-  "navigation_bar.domain_blocks": "Hidden domains",
+  "navigation_bar.direct": "Súkromné správy",
+  "navigation_bar.domain_blocks": "Skryté domény",
   "navigation_bar.edit_profile": "Upraviť profil",
   "navigation_bar.favourites": "Obľúbené",
   "navigation_bar.follow_requests": "Žiadosti o sledovanie",
@@ -244,7 +245,7 @@
   "status.cancel_reblog_private": "Unboost",
   "status.cannot_reblog": "Tento príspevok nemôže byť re-tootnutý",
   "status.delete": "Zmazať",
-  "status.direct": "Direct message @{name}",
+  "status.direct": "Súkromná správa @{name}",
   "status.embed": "Vložiť",
   "status.favourite": "Páči sa mi",
   "status.load_more": "Zobraz viac",
@@ -275,7 +276,7 @@
   "tabs_bar.home": "Domov",
   "tabs_bar.local_timeline": "Lokálna",
   "tabs_bar.notifications": "Notifikácie",
-  "tabs_bar.search": "Search",
+  "tabs_bar.search": "Hľadaj",
   "ui.beforeunload": "Čo máte rozpísané sa stratí, ak opustíte Mastodon.",
   "upload_area.title": "Ťahaj a pusti pre nahratie",
   "upload_button.label": "Pridať médiá",
diff --git a/app/javascript/mastodon/locales/sr-Latn.json b/app/javascript/mastodon/locales/sr-Latn.json
index e4d07edd1..b1ea0d179 100644
--- a/app/javascript/mastodon/locales/sr-Latn.json
+++ b/app/javascript/mastodon/locales/sr-Latn.json
@@ -135,6 +135,7 @@
   "keyboard_shortcuts.mention": "da pomenete autora",
   "keyboard_shortcuts.reply": "da odgovorite",
   "keyboard_shortcuts.search": "da se prebacite na pretragu",
+  "keyboard_shortcuts.toggle_hidden": "to show/hide text behind CW",
   "keyboard_shortcuts.toot": "da započnete skroz novi tut",
   "keyboard_shortcuts.unfocus": "da ne budete više na pretrazi/pravljenju novog tuta",
   "keyboard_shortcuts.up": "da se pomerite na gore u listi",
diff --git a/app/javascript/mastodon/locales/sr.json b/app/javascript/mastodon/locales/sr.json
index 60c781e9d..aa978675f 100644
--- a/app/javascript/mastodon/locales/sr.json
+++ b/app/javascript/mastodon/locales/sr.json
@@ -135,6 +135,7 @@
   "keyboard_shortcuts.mention": "да поменете аутора",
   "keyboard_shortcuts.reply": "да одговорите",
   "keyboard_shortcuts.search": "да се пребаците на претрагу",
+  "keyboard_shortcuts.toggle_hidden": "to show/hide text behind CW",
   "keyboard_shortcuts.toot": "да започнете скроз нови тут",
   "keyboard_shortcuts.unfocus": "да не будете више на претрази/прављењу новог тута",
   "keyboard_shortcuts.up": "да се померите на горе у листи",
diff --git a/app/javascript/mastodon/locales/sv.json b/app/javascript/mastodon/locales/sv.json
index 8fa6992f1..4efe88a7e 100644
--- a/app/javascript/mastodon/locales/sv.json
+++ b/app/javascript/mastodon/locales/sv.json
@@ -18,7 +18,7 @@
   "account.mute_notifications": "Stäng av notifieringar från @{name}",
   "account.muted": "Nertystad",
   "account.posts": "Inlägg",
-  "account.posts_with_replies": "Toots med svar",
+  "account.posts_with_replies": "Toots och svar",
   "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",
@@ -29,7 +29,7 @@
   "account.unmute": "Ta bort tystad @{name}",
   "account.unmute_notifications": "Återaktivera notifikationer från @{name}",
   "account.view_full_profile": "Visa hela profilen",
-  "alert.unexpected.message": "An unexpected error occurred.",
+  "alert.unexpected.message": "Ett oväntat fel uppstod.",
   "alert.unexpected.title": "Oops!",
   "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.",
@@ -40,8 +40,8 @@
   "bundle_modal_error.retry": "Försök igen",
   "column.blocks": "Blockerade användare",
   "column.community": "Lokal tidslinje",
-  "column.direct": "Direct messages",
-  "column.domain_blocks": "Hidden domains",
+  "column.direct": "Direktmeddelande",
+  "column.domain_blocks": "Dolda domäner",
   "column.favourites": "Favoriter",
   "column.follow_requests": "Följ förfrågningar",
   "column.home": "Hem",
@@ -59,7 +59,7 @@
   "column_header.unpin": "Ångra fäst",
   "column_subheading.navigation": "Navigation",
   "column_subheading.settings": "Inställningar",
-  "compose_form.direct_message_warning": "This toot will only be visible to all the mentioned users.",
+  "compose_form.direct_message_warning": "Denna toot kommer endast vara synlig för nämnda användare.",
   "compose_form.hashtag_warning": "Denna toot kommer inte att listas under någon hashtag eftersom den är onoterad. Endast offentliga toots kan sökas med hashtag.",
   "compose_form.lock_disclaimer": "Ditt konto är inte {locked}. Vemsomhelst kan följa dig och även se dina inlägg skrivna för endast dina följare.",
   "compose_form.lock_disclaimer.lock": "låst",
@@ -101,7 +101,7 @@
   "emoji_button.symbols": "Symboler",
   "emoji_button.travel": "Resor & Platser",
   "empty_column.community": "Den lokala tidslinjen är tom. Skriv något offentligt för att få bollen att rulla!",
-  "empty_column.direct": "You don't have any direct messages yet. When you send or receive one, it will show up here.",
+  "empty_column.direct": "Du har inga direktmeddelanden än. När du skickar eller tar emot kommer den att dyka upp här.",
   "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.public_timeline": "den publika tidslinjen",
@@ -113,7 +113,7 @@
   "getting_started.appsshort": "Appar",
   "getting_started.faq": "FAQ",
   "getting_started.heading": "Kom igång",
-  "getting_started.open_source_notice": "Mastodon är programvara med öppen källkod. Du kan bidra eller rapportera problem på GitHub på {github}.",
+  "getting_started.open_source_notice": "Mastodon är programvara med öppen källkod. Du kan bidra eller rapportera problem via GitHub på {github}.",
   "getting_started.userguide": "Användarguide",
   "home.column_settings.advanced": "Avancerad",
   "home.column_settings.basic": "Grundläggande",
@@ -135,6 +135,7 @@
   "keyboard_shortcuts.mention": "att nämna författaren",
   "keyboard_shortcuts.reply": "att svara",
   "keyboard_shortcuts.search": "att fokusera sökfältet",
+  "keyboard_shortcuts.toggle_hidden": "att visa/gömma text bakom CW",
   "keyboard_shortcuts.toot": "att börja en helt ny toot",
   "keyboard_shortcuts.unfocus": "att avfokusera komponera text fält / sökfält",
   "keyboard_shortcuts.up": "att flytta upp i listan",
@@ -156,8 +157,8 @@
   "mute_modal.hide_notifications": "Dölj notifikationer från denna användare?",
   "navigation_bar.blocks": "Blockerade användare",
   "navigation_bar.community_timeline": "Lokal tidslinje",
-  "navigation_bar.direct": "Direct messages",
-  "navigation_bar.domain_blocks": "Hidden domains",
+  "navigation_bar.direct": "Direktmeddelanden",
+  "navigation_bar.domain_blocks": "Dolda domäner",
   "navigation_bar.edit_profile": "Redigera profil",
   "navigation_bar.favourites": "Favoriter",
   "navigation_bar.follow_requests": "Följförfrågningar",
@@ -205,7 +206,7 @@
   "onboarding.page_three.search": "Använd sökfältet för att hitta personer och titta på hashtags, till exempel {illustration} och {introductions}. För att leta efter en person som inte befinner sig i detta fall använd deras fulla handhavande.",
   "onboarding.page_two.compose": "Skriv inlägg från skrivkolumnen. Du kan ladda upp bilder, ändra integritetsinställningar och lägga till varningar med ikonerna nedan.",
   "onboarding.skip": "Hoppa över",
-  "privacy.change": "Justera status sekretess",
+  "privacy.change": "Justera sekretess",
   "privacy.direct.long": "Skicka endast till nämnda användare",
   "privacy.direct.short": "Direkt",
   "privacy.private.long": "Skicka endast till följare",
@@ -241,10 +242,10 @@
   "search_results.total": "{count, number} {count, plural, ett {result} andra {results}}",
   "standalone.public_title": "En titt inuti...",
   "status.block": "Block @{name}",
-  "status.cancel_reblog_private": "Unboost",
+  "status.cancel_reblog_private": "Ta bort knuff",
   "status.cannot_reblog": "Detta inlägg kan inte knuffas",
   "status.delete": "Ta bort",
-  "status.direct": "Direct message @{name}",
+  "status.direct": "Direktmeddela @{name}",
   "status.embed": "Bädda in",
   "status.favourite": "Favorit",
   "status.load_more": "Ladda fler",
@@ -257,7 +258,7 @@
   "status.pin": "Fäst i profil",
   "status.pinned": "Fäst toot",
   "status.reblog": "Knuff",
-  "status.reblog_private": "Boost to original audience",
+  "status.reblog_private": "Knuffa till de ursprungliga åhörarna",
   "status.reblogged_by": "{name} knuffade",
   "status.reply": "Svara",
   "status.replyAll": "Svara på tråden",
@@ -275,7 +276,7 @@
   "tabs_bar.home": "Hem",
   "tabs_bar.local_timeline": "Lokal",
   "tabs_bar.notifications": "Meddelanden",
-  "tabs_bar.search": "Search",
+  "tabs_bar.search": "Sök",
   "ui.beforeunload": "Ditt utkast kommer att förloras om du lämnar Mastodon.",
   "upload_area.title": "Dra & släpp för att ladda upp",
   "upload_button.label": "Lägg till media",
diff --git a/app/javascript/mastodon/locales/te.json b/app/javascript/mastodon/locales/te.json
new file mode 100644
index 000000000..a56720fee
--- /dev/null
+++ b/app/javascript/mastodon/locales/te.json
@@ -0,0 +1,296 @@
+{
+  "account.block": "Block @{name}",
+  "account.block_domain": "Hide everything from {domain}",
+  "account.blocked": "Blocked",
+  "account.direct": "Direct message @{name}",
+  "account.disclaimer_full": "Information below may reflect the user's profile incompletely.",
+  "account.domain_blocked": "Domain hidden",
+  "account.edit_profile": "Edit profile",
+  "account.follow": "Follow",
+  "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.muted": "Muted",
+  "account.posts": "Toots",
+  "account.posts_with_replies": "Toots and replies",
+  "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",
+  "alert.unexpected.message": "An unexpected error occurred.",
+  "alert.unexpected.title": "Oops!",
+  "boost_modal.combo": "You can press {combo} to skip this next time",
+  "bundle_column_error.body": "Something went wrong while loading this component.",
+  "bundle_column_error.retry": "Try again",
+  "bundle_column_error.title": "Network error",
+  "bundle_modal_error.close": "Close",
+  "bundle_modal_error.message": "Something went wrong while loading this component.",
+  "bundle_modal_error.retry": "Try again",
+  "column.blocks": "Blocked users",
+  "column.community": "Local timeline",
+  "column.direct": "Direct messages",
+  "column.domain_blocks": "Hidden domains",
+  "column.favourites": "Favourites",
+  "column.follow_requests": "Follow requests",
+  "column.home": "Home",
+  "column.lists": "Lists",
+  "column.mutes": "Muted users",
+  "column.notifications": "Notifications",
+  "column.pins": "Pinned toot",
+  "column.public": "Federated timeline",
+  "column_back_button.label": "Back",
+  "column_header.hide_settings": "Hide settings",
+  "column_header.moveLeft_settings": "Move column to the left",
+  "column_header.moveRight_settings": "Move column to the right",
+  "column_header.pin": "Pin",
+  "column_header.show_settings": "Show settings",
+  "column_header.unpin": "Unpin",
+  "column_subheading.navigation": "Navigation",
+  "column_subheading.settings": "Settings",
+  "compose_form.direct_message_warning": "This toot will only be visible to all the mentioned users.",
+  "compose_form.hashtag_warning": "This toot won't be listed under any hashtag as it is unlisted. Only public toots can be searched by hashtag.",
+  "compose_form.lock_disclaimer": "Your account is not {locked}. Anyone can follow you to view your follower-only posts.",
+  "compose_form.lock_disclaimer.lock": "locked",
+  "compose_form.placeholder": "What is on your mind?",
+  "compose_form.publish": "Toot",
+  "compose_form.publish_loud": "{publish}!",
+  "compose_form.sensitive.marked": "Media is marked as sensitive",
+  "compose_form.sensitive.unmarked": "Media is not marked as sensitive",
+  "compose_form.spoiler.marked": "Text is hidden behind warning",
+  "compose_form.spoiler.unmarked": "Text is not hidden",
+  "compose_form.spoiler_placeholder": "Write your warning here",
+  "confirmation_modal.cancel": "Cancel",
+  "confirmations.block.confirm": "Block",
+  "confirmations.block.message": "Are you sure you want to block {name}?",
+  "confirmations.delete.confirm": "Delete",
+  "confirmations.delete.message": "Are you sure you want to delete this status?",
+  "confirmations.delete_list.confirm": "Delete",
+  "confirmations.delete_list.message": "Are you sure you want to permanently delete this list?",
+  "confirmations.domain_block.confirm": "Hide entire domain",
+  "confirmations.domain_block.message": "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.",
+  "confirmations.mute.confirm": "Mute",
+  "confirmations.mute.message": "Are you sure you want to mute {name}?",
+  "confirmations.unfollow.confirm": "Unfollow",
+  "confirmations.unfollow.message": "Are you sure you want to unfollow {name}?",
+  "embed.instructions": "Embed this status on your website by copying the code below.",
+  "embed.preview": "Here is what it will look like:",
+  "emoji_button.activity": "Activity",
+  "emoji_button.custom": "Custom",
+  "emoji_button.flags": "Flags",
+  "emoji_button.food": "Food & Drink",
+  "emoji_button.label": "Insert emoji",
+  "emoji_button.nature": "Nature",
+  "emoji_button.not_found": "No emojos!! (╯°□°)╯︵ ┻━┻",
+  "emoji_button.objects": "Objects",
+  "emoji_button.people": "People",
+  "emoji_button.recent": "Frequently used",
+  "emoji_button.search": "Search...",
+  "emoji_button.search_results": "Search results",
+  "emoji_button.symbols": "Symbols",
+  "emoji_button.travel": "Travel & Places",
+  "empty_column.community": "The local timeline is empty. Write something publicly to get the ball rolling!",
+  "empty_column.direct": "You don't have any direct messages yet. When you send or receive one, it will show up here.",
+  "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. When members of this list post new statuses, they will appear here.",
+  "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",
+  "follow_request.reject": "Reject",
+  "getting_started.appsshort": "Apps",
+  "getting_started.faq": "FAQ",
+  "getting_started.heading": "Getting started",
+  "getting_started.open_source_notice": "Mastodon is open source software. You can contribute or report issues on GitHub at {github}.",
+  "getting_started.userguide": "User Guide",
+  "home.column_settings.advanced": "Advanced",
+  "home.column_settings.basic": "Basic",
+  "home.column_settings.filter_regex": "Filter out by regular expressions",
+  "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.toggle_hidden": "to show/hide text behind CW",
+  "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",
+  "lists.account.add": "Add to list",
+  "lists.account.remove": "Remove from list",
+  "lists.delete": "Delete list",
+  "lists.edit": "Edit list",
+  "lists.new.create": "Add list",
+  "lists.new.title_placeholder": "New list title",
+  "lists.search": "Search among people you follow",
+  "lists.subheading": "Your lists",
+  "loading_indicator.label": "Loading...",
+  "media_gallery.toggle_visible": "Toggle visibility",
+  "missing_indicator.label": "Not found",
+  "missing_indicator.sublabel": "This resource could not be found",
+  "mute_modal.hide_notifications": "Hide notifications from this user?",
+  "navigation_bar.blocks": "Blocked users",
+  "navigation_bar.community_timeline": "Local timeline",
+  "navigation_bar.direct": "Direct messages",
+  "navigation_bar.domain_blocks": "Hidden domains",
+  "navigation_bar.edit_profile": "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.lists": "Lists",
+  "navigation_bar.logout": "Logout",
+  "navigation_bar.mutes": "Muted users",
+  "navigation_bar.pins": "Pinned toots",
+  "navigation_bar.preferences": "Preferences",
+  "navigation_bar.public_timeline": "Federated timeline",
+  "notification.favourite": "{name} favourited your status",
+  "notification.follow": "{name} followed you",
+  "notification.mention": "{name} mentioned you",
+  "notification.reblog": "{name} boosted your status",
+  "notifications.clear": "Clear notifications",
+  "notifications.clear_confirmation": "Are you sure you want to permanently clear all your notifications?",
+  "notifications.column_settings.alert": "Desktop notifications",
+  "notifications.column_settings.favourite": "Favourites:",
+  "notifications.column_settings.follow": "New followers:",
+  "notifications.column_settings.mention": "Mentions:",
+  "notifications.column_settings.push": "Push notifications",
+  "notifications.column_settings.push_meta": "This device",
+  "notifications.column_settings.reblog": "Boosts:",
+  "notifications.column_settings.show": "Show in column",
+  "notifications.column_settings.sound": "Play sound",
+  "onboarding.done": "Done",
+  "onboarding.next": "Next",
+  "onboarding.page_five.public_timelines": "The local timeline shows public posts from everyone on {domain}. The federated timeline shows public posts from everyone who people on {domain} follow. These are the Public Timelines, a great way to discover new people.",
+  "onboarding.page_four.home": "The home timeline shows posts from people you follow.",
+  "onboarding.page_four.notifications": "The notifications column shows when someone interacts with you.",
+  "onboarding.page_one.federation": "Mastodon is a network of independent servers joining up to make one larger social network. We call these servers instances.",
+  "onboarding.page_one.full_handle": "Your full handle",
+  "onboarding.page_one.handle_hint": "This is what you would tell your friends to search for.",
+  "onboarding.page_one.welcome": "Welcome to Mastodon!",
+  "onboarding.page_six.admin": "Your instance's admin is {admin}.",
+  "onboarding.page_six.almost_done": "Almost done...",
+  "onboarding.page_six.appetoot": "Bon Appetoot!",
+  "onboarding.page_six.apps_available": "There are {apps} available for iOS, Android and other platforms.",
+  "onboarding.page_six.github": "Mastodon is free open-source software. You can report bugs, request features, or contribute to the code on {github}.",
+  "onboarding.page_six.guidelines": "community guidelines",
+  "onboarding.page_six.read_guidelines": "Please read {domain}'s {guidelines}!",
+  "onboarding.page_six.various_app": "mobile apps",
+  "onboarding.page_three.profile": "Edit your profile to change your avatar, bio, and display name. There, you will also find other preferences.",
+  "onboarding.page_three.search": "Use the search bar to find people and look at hashtags, such as {illustration} and {introductions}. To look for a person who is not on this instance, use their full handle.",
+  "onboarding.page_two.compose": "Write posts from the compose column. You can upload images, change privacy settings, and add content warnings with the icons below.",
+  "onboarding.skip": "Skip",
+  "privacy.change": "Adjust status privacy",
+  "privacy.direct.long": "Post to mentioned users only",
+  "privacy.direct.short": "Direct",
+  "privacy.private.long": "Post to followers only",
+  "privacy.private.short": "Followers-only",
+  "privacy.public.long": "Post to public timelines",
+  "privacy.public.short": "Public",
+  "privacy.unlisted.long": "Do not show in public timelines",
+  "privacy.unlisted.short": "Unlisted",
+  "regeneration_indicator.label": "Loading…",
+  "regeneration_indicator.sublabel": "Your home feed is being prepared!",
+  "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": "Cancel",
+  "report.forward": "Forward to {target}",
+  "report.forward_hint": "The account is from another server. Send an anonymized copy of the report there as well?",
+  "report.hint": "The report will be sent to your instance moderators. You can provide an explanation of why you are reporting this account below:",
+  "report.placeholder": "Additional comments",
+  "report.submit": "Submit",
+  "report.target": "Report {target}",
+  "search.placeholder": "Search",
+  "search_popout.search_format": "Advanced search format",
+  "search_popout.tips.full_text": "Simple text returns statuses you have written, favourited, boosted, or have been mentioned in, as well as matching usernames, display names, and hashtags.",
+  "search_popout.tips.hashtag": "hashtag",
+  "search_popout.tips.status": "status",
+  "search_popout.tips.text": "Simple text returns matching display names, usernames and hashtags",
+  "search_popout.tips.user": "user",
+  "search_results.accounts": "People",
+  "search_results.hashtags": "Hashtags",
+  "search_results.statuses": "Toots",
+  "search_results.total": "{count, number} {count, plural, one {result} other {results}}",
+  "standalone.public_title": "A look inside...",
+  "status.block": "Block @{name}",
+  "status.cancel_reblog_private": "Unboost",
+  "status.cannot_reblog": "This post cannot be boosted",
+  "status.delete": "Delete",
+  "status.direct": "Direct message @{name}",
+  "status.embed": "Embed",
+  "status.favourite": "Favourite",
+  "status.load_more": "Load more",
+  "status.media_hidden": "Media hidden",
+  "status.mention": "Mention @{name}",
+  "status.more": "More",
+  "status.mute": "Mute @{name}",
+  "status.mute_conversation": "Mute conversation",
+  "status.open": "Expand this status",
+  "status.pin": "Pin on profile",
+  "status.pinned": "Pinned toot",
+  "status.reblog": "Boost",
+  "status.reblog_private": "Boost to original audience",
+  "status.reblogged_by": "{name} boosted",
+  "status.reply": "Reply",
+  "status.replyAll": "Reply to thread",
+  "status.report": "Report @{name}",
+  "status.sensitive_toggle": "Click to view",
+  "status.sensitive_warning": "Sensitive content",
+  "status.share": "Share",
+  "status.show_less": "Show less",
+  "status.show_less_all": "Show less for all",
+  "status.show_more": "Show more",
+  "status.show_more_all": "Show more for all",
+  "status.unmute_conversation": "Unmute conversation",
+  "status.unpin": "Unpin from profile",
+  "tabs_bar.federated_timeline": "Federated",
+  "tabs_bar.home": "Home",
+  "tabs_bar.local_timeline": "Local",
+  "tabs_bar.notifications": "Notifications",
+  "tabs_bar.search": "Search",
+  "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",
+  "upload_form.focus": "Crop",
+  "upload_form.undo": "Undo",
+  "upload_progress.label": "Uploading...",
+  "video.close": "Close video",
+  "video.exit_fullscreen": "Exit full screen",
+  "video.expand": "Expand video",
+  "video.fullscreen": "Full screen",
+  "video.hide": "Hide video",
+  "video.mute": "Mute sound",
+  "video.pause": "Pause",
+  "video.play": "Play",
+  "video.unmute": "Unmute sound"
+}
diff --git a/app/javascript/mastodon/locales/th.json b/app/javascript/mastodon/locales/th.json
index 3b91c0d2c..82b44fe30 100644
--- a/app/javascript/mastodon/locales/th.json
+++ b/app/javascript/mastodon/locales/th.json
@@ -135,6 +135,7 @@
   "keyboard_shortcuts.mention": "to mention author",
   "keyboard_shortcuts.reply": "to reply",
   "keyboard_shortcuts.search": "to focus search",
+  "keyboard_shortcuts.toggle_hidden": "to show/hide text behind CW",
   "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",
diff --git a/app/javascript/mastodon/locales/tr.json b/app/javascript/mastodon/locales/tr.json
index cdf6f46a3..056fbfe8f 100644
--- a/app/javascript/mastodon/locales/tr.json
+++ b/app/javascript/mastodon/locales/tr.json
@@ -135,6 +135,7 @@
   "keyboard_shortcuts.mention": "to mention author",
   "keyboard_shortcuts.reply": "to reply",
   "keyboard_shortcuts.search": "to focus search",
+  "keyboard_shortcuts.toggle_hidden": "to show/hide text behind CW",
   "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",
diff --git a/app/javascript/mastodon/locales/uk.json b/app/javascript/mastodon/locales/uk.json
index 261e5795e..1a7b58789 100644
--- a/app/javascript/mastodon/locales/uk.json
+++ b/app/javascript/mastodon/locales/uk.json
@@ -135,6 +135,7 @@
   "keyboard_shortcuts.mention": "to mention author",
   "keyboard_shortcuts.reply": "to reply",
   "keyboard_shortcuts.search": "to focus search",
+  "keyboard_shortcuts.toggle_hidden": "to show/hide text behind CW",
   "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",
diff --git a/app/javascript/mastodon/locales/whitelist_el.json b/app/javascript/mastodon/locales/whitelist_el.json
new file mode 100644
index 000000000..0d4f101c7
--- /dev/null
+++ b/app/javascript/mastodon/locales/whitelist_el.json
@@ -0,0 +1,2 @@
+[
+]
diff --git a/app/javascript/mastodon/locales/whitelist_eu.json b/app/javascript/mastodon/locales/whitelist_eu.json
new file mode 100644
index 000000000..0d4f101c7
--- /dev/null
+++ b/app/javascript/mastodon/locales/whitelist_eu.json
@@ -0,0 +1,2 @@
+[
+]
diff --git a/app/javascript/mastodon/locales/whitelist_te.json b/app/javascript/mastodon/locales/whitelist_te.json
new file mode 100644
index 000000000..0d4f101c7
--- /dev/null
+++ b/app/javascript/mastodon/locales/whitelist_te.json
@@ -0,0 +1,2 @@
+[
+]
diff --git a/app/javascript/mastodon/locales/zh-CN.json b/app/javascript/mastodon/locales/zh-CN.json
index aba0bde83..a3a4de0af 100644
--- a/app/javascript/mastodon/locales/zh-CN.json
+++ b/app/javascript/mastodon/locales/zh-CN.json
@@ -135,6 +135,7 @@
   "keyboard_shortcuts.mention": "提及嘟文作者",
   "keyboard_shortcuts.reply": "回复嘟文",
   "keyboard_shortcuts.search": "选择搜索框",
+  "keyboard_shortcuts.toggle_hidden": "to show/hide text behind CW",
   "keyboard_shortcuts.toot": "发送新嘟文",
   "keyboard_shortcuts.unfocus": "取消输入",
   "keyboard_shortcuts.up": "在列表中让光标上移",
diff --git a/app/javascript/mastodon/locales/zh-HK.json b/app/javascript/mastodon/locales/zh-HK.json
index b5ebd20fc..7719e08a6 100644
--- a/app/javascript/mastodon/locales/zh-HK.json
+++ b/app/javascript/mastodon/locales/zh-HK.json
@@ -135,6 +135,7 @@
   "keyboard_shortcuts.mention": "提及作者",
   "keyboard_shortcuts.reply": "回覆",
   "keyboard_shortcuts.search": "把標示移動到搜索",
+  "keyboard_shortcuts.toggle_hidden": "to show/hide text behind CW",
   "keyboard_shortcuts.toot": "新的推文",
   "keyboard_shortcuts.unfocus": "把標示移離文字輸入和搜索",
   "keyboard_shortcuts.up": "在列表往上移動",
diff --git a/app/javascript/mastodon/locales/zh-TW.json b/app/javascript/mastodon/locales/zh-TW.json
index 28d634600..84ff25e03 100644
--- a/app/javascript/mastodon/locales/zh-TW.json
+++ b/app/javascript/mastodon/locales/zh-TW.json
@@ -135,6 +135,7 @@
   "keyboard_shortcuts.mention": "到提到的作者",
   "keyboard_shortcuts.reply": "到回應",
   "keyboard_shortcuts.search": "to focus search",
+  "keyboard_shortcuts.toggle_hidden": "to show/hide text behind CW",
   "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",
diff --git a/app/javascript/mastodon/reducers/notifications.js b/app/javascript/mastodon/reducers/notifications.js
index da9b8c420..84d4fc698 100644
--- a/app/javascript/mastodon/reducers/notifications.js
+++ b/app/javascript/mastodon/reducers/notifications.js
@@ -105,7 +105,7 @@ export default function notifications(state = initialState, action) {
     return expandNormalizedNotifications(state, action.notifications, action.next);
   case ACCOUNT_BLOCK_SUCCESS:
   case ACCOUNT_MUTE_SUCCESS:
-    return filterNotifications(state, action.relationship);
+    return action.relationship.muting_notifications ? filterNotifications(state, action.relationship) : state;
   case NOTIFICATIONS_CLEAR:
     return state.set('items', ImmutableList()).set('hasMore', false);
   case TIMELINE_DELETE:
diff --git a/app/javascript/mastodon/reducers/timelines.js b/app/javascript/mastodon/reducers/timelines.js
index ad897bcc9..dd675d78f 100644
--- a/app/javascript/mastodon/reducers/timelines.js
+++ b/app/javascript/mastodon/reducers/timelines.js
@@ -34,7 +34,7 @@ const expandNormalizedTimeline = (state, timeline, statuses, next, isPartial) =>
       mMap.update('items', ImmutableList(), oldIds => {
         const newIds = statuses.map(status => status.get('id'));
         const lastIndex = oldIds.findLastIndex(id => id !== null && compareId(id, newIds.last()) >= 0) + 1;
-        const firstIndex = oldIds.take(lastIndex).findLastIndex(id => id !== null && compareId(id, newIds.first()) >= 0);
+        const firstIndex = oldIds.take(lastIndex).findLastIndex(id => id !== null && compareId(id, newIds.first()) > 0);
 
         if (firstIndex < 0) {
           return (isPartial ? newIds.unshift(null) : newIds).concat(oldIds.skip(lastIndex));
diff --git a/app/javascript/mastodon/utils/__tests__/base64-test.js b/app/javascript/mastodon/utils/__tests__/base64-test.js
new file mode 100644
index 000000000..1b3260faa
--- /dev/null
+++ b/app/javascript/mastodon/utils/__tests__/base64-test.js
@@ -0,0 +1,10 @@
+import * as base64 from '../base64';
+
+describe('base64', () => {
+  describe('decode', () => {
+    it('returns a uint8 array', () => {
+      const arr = base64.decode('dGVzdA==');
+      expect(arr).toEqual(new Uint8Array([116, 101, 115, 116]));
+    });
+  });
+});
diff --git a/app/javascript/mastodon/utils/base64.js b/app/javascript/mastodon/utils/base64.js
new file mode 100644
index 000000000..8226e2c54
--- /dev/null
+++ b/app/javascript/mastodon/utils/base64.js
@@ -0,0 +1,10 @@
+export const decode = base64 => {
+  const rawData = window.atob(base64);
+  const outputArray = new Uint8Array(rawData.length);
+
+  for (let i = 0; i < rawData.length; ++i) {
+    outputArray[i] = rawData.charCodeAt(i);
+  }
+
+  return outputArray;
+};
diff --git a/app/javascript/mastodon/utils/resize_image.js b/app/javascript/mastodon/utils/resize_image.js
new file mode 100644
index 000000000..6442eda38
--- /dev/null
+++ b/app/javascript/mastodon/utils/resize_image.js
@@ -0,0 +1,66 @@
+const MAX_IMAGE_DIMENSION = 1280;
+
+const getImageUrl = inputFile => new Promise((resolve, reject) => {
+  if (window.URL && URL.createObjectURL) {
+    try {
+      resolve(URL.createObjectURL(inputFile));
+    } catch (error) {
+      reject(error);
+    }
+    return;
+  }
+
+  const reader = new FileReader();
+  reader.onerror = (...args) => reject(...args);
+  reader.onload  = ({ target }) => resolve(target.result);
+
+  reader.readAsDataURL(inputFile);
+});
+
+const loadImage = inputFile => new Promise((resolve, reject) => {
+  getImageUrl(inputFile).then(url => {
+    const img = new Image();
+
+    img.onerror = (...args) => reject(...args);
+    img.onload  = () => resolve(img);
+
+    img.src = url;
+  }).catch(reject);
+});
+
+export default inputFile => new Promise((resolve, reject) => {
+  if (!inputFile.type.match(/image.*/) || inputFile.type === 'image/gif') {
+    resolve(inputFile);
+    return;
+  }
+
+  loadImage(inputFile).then(img => {
+    const canvas = document.createElement('canvas');
+    const { width, height } = img;
+
+    let newWidth, newHeight;
+
+    if (width < MAX_IMAGE_DIMENSION && height < MAX_IMAGE_DIMENSION) {
+      resolve(inputFile);
+      return;
+    }
+
+    if (width > height) {
+      newHeight = height * MAX_IMAGE_DIMENSION / width;
+      newWidth  = MAX_IMAGE_DIMENSION;
+    } else if (height > width) {
+      newWidth  = width * MAX_IMAGE_DIMENSION / height;
+      newHeight = MAX_IMAGE_DIMENSION;
+    } else {
+      newWidth  = MAX_IMAGE_DIMENSION;
+      newHeight = MAX_IMAGE_DIMENSION;
+    }
+
+    canvas.width  = newWidth;
+    canvas.height = newHeight;
+
+    canvas.getContext('2d').drawImage(img, 0, 0, newWidth, newHeight);
+
+    canvas.toBlob(resolve, inputFile.type);
+  }).catch(reject);
+});
diff --git a/app/javascript/styles/contrast.scss b/app/javascript/styles/contrast.scss
new file mode 100644
index 000000000..5b43aecbe
--- /dev/null
+++ b/app/javascript/styles/contrast.scss
@@ -0,0 +1,3 @@
+@import 'contrast/variables';
+@import 'application';
+@import 'contrast/diff';
diff --git a/app/javascript/styles/contrast/diff.scss b/app/javascript/styles/contrast/diff.scss
new file mode 100644
index 000000000..eee9ecc3e
--- /dev/null
+++ b/app/javascript/styles/contrast/diff.scss
@@ -0,0 +1,14 @@
+// components.scss
+.compose-form {
+  .compose-form__modifiers {
+    .compose-form__upload {
+      &-description {
+        input {
+          &::placeholder {
+            opacity: 1.0;
+          }
+        }
+      }
+    }
+  }
+}
diff --git a/app/javascript/styles/contrast/variables.scss b/app/javascript/styles/contrast/variables.scss
new file mode 100644
index 000000000..f6cadf029
--- /dev/null
+++ b/app/javascript/styles/contrast/variables.scss
@@ -0,0 +1,24 @@
+// Dependent colors
+$black: #000000;
+
+$classic-base-color: #282c37;
+$classic-primary-color: #9baec8;
+$classic-secondary-color: #d9e1e8;
+$classic-highlight-color: #2b90d9;
+
+$ui-base-color: $classic-base-color !default;
+$ui-primary-color: $classic-primary-color !default;
+$ui-secondary-color: $classic-secondary-color !default;
+
+// Differences
+$ui-highlight-color: #2b5fd9;
+
+$darker-text-color: lighten($ui-primary-color, 20%) !default;
+$dark-text-color: lighten($ui-primary-color, 12%) !default;
+$secondary-text-color: lighten($ui-secondary-color, 6%) !default;
+$highlight-text-color: $classic-highlight-color !default;
+$action-button-color: #8d9ac2;
+
+$inverted-text-color: $black !default;
+$lighter-text-color: darken($ui-base-color,6%) !default;
+$light-text-color: darken($ui-primary-color, 40%) !default;
diff --git a/app/javascript/styles/mastodon/about.scss b/app/javascript/styles/mastodon/about.scss
index 0a09a38d2..c9c0e3081 100644
--- a/app/javascript/styles/mastodon/about.scss
+++ b/app/javascript/styles/mastodon/about.scss
@@ -225,7 +225,7 @@ $small-breakpoint: 960px;
     font-family: inherit;
     font-size: inherit;
     line-height: inherit;
-    color: transparentize($darker-text-color, 0.1);
+    color: lighten($darker-text-color, 10%);
   }
 
   h1 {
@@ -234,14 +234,14 @@ $small-breakpoint: 960px;
     line-height: 30px;
     font-weight: 500;
     margin-bottom: 20px;
-    color: $primary-text-color;
+    color: $secondary-text-color;
 
     small {
       font-family: 'mastodon-font-sans-serif', sans-serif;
       display: block;
       font-size: 18px;
       font-weight: 400;
-      color: opacify($darker-text-color, 0.1);
+      color: lighten($darker-text-color, 10%);
     }
   }
 
@@ -251,7 +251,7 @@ $small-breakpoint: 960px;
     line-height: 26px;
     font-weight: 500;
     margin-bottom: 20px;
-    color: $primary-text-color;
+    color: $secondary-text-color;
   }
 
   h3 {
@@ -260,7 +260,7 @@ $small-breakpoint: 960px;
     line-height: 24px;
     font-weight: 500;
     margin-bottom: 20px;
-    color: $primary-text-color;
+    color: $secondary-text-color;
   }
 
   h4 {
@@ -269,7 +269,7 @@ $small-breakpoint: 960px;
     line-height: 24px;
     font-weight: 500;
     margin-bottom: 20px;
-    color: $primary-text-color;
+    color: $secondary-text-color;
   }
 
   h5 {
@@ -278,7 +278,7 @@ $small-breakpoint: 960px;
     line-height: 24px;
     font-weight: 500;
     margin-bottom: 20px;
-    color: $primary-text-color;
+    color: $secondary-text-color;
   }
 
   h6 {
@@ -287,7 +287,7 @@ $small-breakpoint: 960px;
     line-height: 24px;
     font-weight: 500;
     margin-bottom: 20px;
-    color: $primary-text-color;
+    color: $secondary-text-color;
   }
 
   ul,
@@ -405,7 +405,7 @@ $small-breakpoint: 960px;
         font-size: 14px;
 
         &:hover {
-          color: $darker-text-color;
+          color: $secondary-text-color;
         }
       }
 
@@ -517,7 +517,7 @@ $small-breakpoint: 960px;
 
       span {
         &:last-child {
-          color: $darker-text-color;
+          color: $secondary-text-color;
         }
       }
 
@@ -559,7 +559,7 @@ $small-breakpoint: 960px;
         a,
         span {
           font-weight: 400;
-          color: opacify($darker-text-color, 0.1);
+          color: darken($darker-text-color, 10%);
         }
 
         a {
@@ -775,7 +775,7 @@ $small-breakpoint: 960px;
     }
 
     p a {
-      color: $darker-text-color;
+      color: $secondary-text-color;
     }
 
     h1 {
@@ -787,7 +787,7 @@ $small-breakpoint: 960px;
         color: $darker-text-color;
 
         span {
-          color: $darker-text-color;
+          color: $secondary-text-color;
         }
       }
     }
@@ -896,7 +896,7 @@ $small-breakpoint: 960px;
       }
 
       a {
-        color: $darker-text-color;
+        color: $secondary-text-color;
         text-decoration: none;
       }
     }
@@ -980,7 +980,7 @@ $small-breakpoint: 960px;
   .footer-links {
     padding-bottom: 50px;
     text-align: right;
-    color: $darker-text-color;
+    color: $dark-text-color;
 
     p {
       font-size: 14px;
@@ -995,7 +995,7 @@ $small-breakpoint: 960px;
   &__footer {
     margin-top: 10px;
     text-align: center;
-    color: $darker-text-color;
+    color: $dark-text-color;
 
     p {
       font-size: 14px;
diff --git a/app/javascript/styles/mastodon/accounts.scss b/app/javascript/styles/mastodon/accounts.scss
index f9af6f288..c2d0de4b9 100644
--- a/app/javascript/styles/mastodon/accounts.scss
+++ b/app/javascript/styles/mastodon/accounts.scss
@@ -178,7 +178,7 @@
     font-size: 14px;
     line-height: 18px;
     padding: 0 15px;
-    color: $darker-text-color;
+    color: $secondary-text-color;
   }
 
   @media screen and (max-width: 480px) {
@@ -256,7 +256,7 @@
   .current {
     background: $simple-background-color;
     border-radius: 100px;
-    color: $lighter-text-color;
+    color: $inverted-text-color;
     cursor: default;
     margin: 0 10px;
   }
@@ -268,7 +268,7 @@
   .older,
   .newer {
     text-transform: uppercase;
-    color: $primary-text-color;
+    color: $secondary-text-color;
   }
 
   .older {
@@ -293,7 +293,7 @@
 
   .disabled {
     cursor: default;
-    color: opacify($lighter-text-color, 0.1);
+    color: lighten($inverted-text-color, 10%);
   }
 
   @media screen and (max-width: 700px) {
@@ -332,7 +332,7 @@
     width: 335px;
     background: $simple-background-color;
     border-radius: 4px;
-    color: $lighter-text-color;
+    color: $inverted-text-color;
     margin: 0 5px 10px;
     position: relative;
 
@@ -344,7 +344,7 @@
       overflow: hidden;
       height: 100px;
       border-radius: 4px 4px 0 0;
-      background-color: opacify($lighter-text-color, 0.04);
+      background-color: lighten($inverted-text-color, 4%);
       background-size: cover;
       background-position: center;
       position: relative;
@@ -422,7 +422,7 @@
     .account__header__content {
       padding: 10px 15px;
       padding-top: 15px;
-      color: transparentize($lighter-text-color, 0.1);
+      color: $lighter-text-color;
       word-wrap: break-word;
       overflow: hidden;
       text-overflow: ellipsis;
@@ -434,7 +434,7 @@
 .nothing-here {
   width: 100%;
   display: block;
-  color: $lighter-text-color;
+  color: $light-text-color;
   font-size: 14px;
   font-weight: 500;
   text-align: center;
@@ -493,7 +493,7 @@
 
       span {
         font-size: 14px;
-        color: $inverted-text-color;
+        color: $light-text-color;
       }
     }
 
@@ -508,7 +508,7 @@
 
   .account__header__content {
     font-size: 14px;
-    color: $darker-text-color;
+    color: $inverted-text-color;
   }
 }
 
@@ -586,7 +586,7 @@
     font-weight: 500;
     text-align: center;
     width: 94px;
-    color: opacify($darker-text-color, 0.1);
+    color: $secondary-text-color;
     background: rgba(darken($ui-base-color, 8%), 0.5);
   }
 
diff --git a/app/javascript/styles/mastodon/admin.scss b/app/javascript/styles/mastodon/admin.scss
index 348f72078..a6cc8b62b 100644
--- a/app/javascript/styles/mastodon/admin.scss
+++ b/app/javascript/styles/mastodon/admin.scss
@@ -90,7 +90,7 @@
     padding-left: 25px;
 
     h2 {
-      color: $primary-text-color;
+      color: $secondary-text-color;
       font-size: 24px;
       line-height: 28px;
       font-weight: 400;
@@ -98,7 +98,7 @@
     }
 
     h3 {
-      color: $primary-text-color;
+      color: $secondary-text-color;
       font-size: 20px;
       line-height: 28px;
       font-weight: 400;
@@ -109,7 +109,7 @@
       text-transform: uppercase;
       font-size: 13px;
       font-weight: 500;
-      color: $primary-text-color;
+      color: $darker-text-color;
       padding-bottom: 8px;
       margin-bottom: 8px;
       border-bottom: 1px solid lighten($ui-base-color, 8%);
@@ -117,7 +117,7 @@
 
     h6 {
       font-size: 16px;
-      color: $primary-text-color;
+      color: $secondary-text-color;
       line-height: 28px;
       font-weight: 400;
     }
@@ -125,7 +125,7 @@
     & > p {
       font-size: 14px;
       line-height: 18px;
-      color: $darker-text-color;
+      color: $secondary-text-color;
       margin-bottom: 20px;
 
       strong {
@@ -141,14 +141,15 @@
     }
 
     hr {
-      margin: 20px 0;
+      width: 100%;
+      height: 0;
       border: 0;
-      background: transparent;
-      border-bottom: 1px solid $ui-base-color;
+      border-bottom: 1px solid rgba($ui-base-lighter-color, .6);
+      margin: 20px 0;
 
-      &.section-break {
-        margin: 30px 0;
-        border-bottom: 2px solid $ui-base-lighter-color;
+      &.spacer {
+        height: 1px;
+        border: 0;
       }
     }
 
@@ -291,7 +292,7 @@
     font-weight: 500;
     font-size: 14px;
     line-height: 18px;
-    color: $primary-text-color;
+    color: $secondary-text-color;
 
     @each $lang in $cjk-langs {
       &:lang(#{$lang}) {
@@ -335,34 +336,8 @@
   }
 }
 
-.report-note__comment {
-  margin-bottom: 20px;
-}
-
-.report-note__form {
-  margin-bottom: 20px;
-
-  .report-note__textarea {
-    box-sizing: border-box;
-    border: 0;
-    padding: 7px 4px;
-    margin-bottom: 10px;
-    font-size: 16px;
-    color: $inverted-text-color;
-    display: block;
-    width: 100%;
-    outline: 0;
-    font-family: inherit;
-    resize: vertical;
-  }
-
-  .report-note__buttons {
-    text-align: right;
-  }
-
-  .report-note__button {
-    margin: 0 0 5px 5px;
-  }
+.simple_form.new_report_note {
+  max-width: 100%;
 }
 
 .batch-form-box {
@@ -390,13 +365,6 @@
   }
 }
 
-.batch-checkbox,
-.batch-checkbox-all {
-  display: flex;
-  align-items: center;
-  margin-right: 5px;
-}
-
 .back-link {
   margin-bottom: 10px;
   font-size: 14px;
@@ -416,7 +384,7 @@
 }
 
 .log-entry {
-  margin-bottom: 8px;
+  margin-bottom: 20px;
   line-height: 20px;
 
   &__header {
@@ -452,7 +420,7 @@
   }
 
   &__timestamp {
-    color: $darker-text-color;
+    color: $dark-text-color;
   }
 
   &__extras {
@@ -469,7 +437,7 @@
   &__icon {
     font-size: 28px;
     margin-right: 10px;
-    color: $darker-text-color;
+    color: $dark-text-color;
   }
 
   &__icon__overlay {
@@ -496,7 +464,7 @@
   a,
   .username,
   .target {
-    color: $primary-text-color;
+    color: $secondary-text-color;
     text-decoration: none;
     font-weight: 500;
   }
@@ -506,7 +474,7 @@
   }
 
   .diff-neutral {
-    color: $darker-text-color;
+    color: $secondary-text-color;
   }
 
   .diff-new {
@@ -514,9 +482,12 @@
   }
 }
 
+a.name-tag,
 .name-tag {
   display: flex;
   align-items: center;
+  text-decoration: none;
+  color: $secondary-text-color;
 
   .avatar {
     display: block;
@@ -528,4 +499,52 @@
   .username {
     font-weight: 500;
   }
+
+  &.suspended {
+    .username {
+      text-decoration: line-through;
+      color: lighten($error-red, 12%);
+    }
+
+    .avatar {
+      filter: grayscale(100%);
+      opacity: 0.8;
+    }
+  }
+}
+
+.speech-bubble {
+  margin-bottom: 20px;
+  border-left: 4px solid $ui-highlight-color;
+
+  &.positive {
+    border-left-color: $success-green;
+  }
+
+  &.negative {
+    border-left-color: lighten($error-red, 12%);
+  }
+
+  &__bubble {
+    padding: 16px;
+    padding-left: 14px;
+    font-size: 15px;
+    line-height: 20px;
+    border-radius: 4px 4px 4px 0;
+    position: relative;
+    font-weight: 500;
+
+    a {
+      color: $darker-text-color;
+    }
+  }
+
+  &__owner {
+    padding: 8px;
+    padding-left: 12px;
+  }
+
+  time {
+    color: $dark-text-color;
+  }
 }
diff --git a/app/javascript/styles/mastodon/compact_header.scss b/app/javascript/styles/mastodon/compact_header.scss
index 83ac7a8d0..4980ab5f1 100644
--- a/app/javascript/styles/mastodon/compact_header.scss
+++ b/app/javascript/styles/mastodon/compact_header.scss
@@ -2,7 +2,7 @@
   h1 {
     font-size: 24px;
     line-height: 28px;
-    color: $primary-text-color;
+    color: $darker-text-color;
     font-weight: 500;
     margin-bottom: 20px;
     padding: 0 10px;
@@ -20,7 +20,7 @@
 
     small {
       font-weight: 400;
-      color: $darker-text-color;
+      color: $secondary-text-color;
     }
 
     img {
diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss
index f0fde6666..a982585c3 100644
--- a/app/javascript/styles/mastodon/components.scss
+++ b/app/javascript/styles/mastodon/components.scss
@@ -31,7 +31,7 @@
   &:active,
   &:focus,
   &:hover {
-    background-color: lighten($ui-highlight-color, 4%);
+    background-color: lighten($ui-highlight-color, 10%);
     transition: all 200ms ease-out;
   }
 
@@ -83,7 +83,7 @@
   }
 
   &.button-secondary {
-    color: $ui-primary-color;
+    color: $darker-text-color;
     background: transparent;
     padding: 3px 15px;
     border: 1px solid $ui-primary-color;
@@ -92,7 +92,7 @@
     &:focus,
     &:hover {
       border-color: lighten($ui-primary-color, 4%);
-      color: lighten($ui-primary-color, 4%);
+      color: lighten($darker-text-color, 4%);
     }
   }
 
@@ -149,18 +149,18 @@
     &:hover,
     &:active,
     &:focus {
-      color: transparentize($lighter-text-color, 0.07);
+      color: darken($lighter-text-color, 7%);
     }
 
     &.disabled {
-      color: opacify($lighter-text-color, 0.07);
+      color: lighten($lighter-text-color, 7%);
     }
 
     &.active {
       color: $highlight-text-color;
 
       &.disabled {
-        color: opacify($lighter-text-color, 0.13);
+        color: lighten($highlight-text-color, 13%);
       }
     }
   }
@@ -193,12 +193,12 @@
   &:hover,
   &:active,
   &:focus {
-    color: opacify($lighter-text-color, 0.07);
+    color: darken($lighter-text-color, 7%);
     transition: color 200ms ease-out;
   }
 
   &.disabled {
-    color: transparentize($lighter-text-color, 0.2);
+    color: lighten($lighter-text-color, 20%);
     cursor: default;
   }
 
@@ -349,7 +349,7 @@
     box-shadow: 4px 4px 6px rgba($base-shadow-color, 0.4);
     background: $ui-secondary-color;
     border-radius: 0 0 4px 4px;
-    color: $lighter-text-color;
+    color: $inverted-text-color;
     font-size: 14px;
     padding: 6px;
 
@@ -457,7 +457,7 @@
 
         input {
           background: transparent;
-          color: $primary-text-color;
+          color: $secondary-text-color;
           border: 0;
           padding: 0;
           margin: 0;
@@ -471,8 +471,8 @@
           }
 
           &::placeholder {
-            opacity: 0.54;
-            color: $darker-text-color;
+            opacity: 0.75;
+            color: $secondary-text-color;
           }
         }
 
@@ -556,7 +556,6 @@
 }
 
 .emojione {
-  display: inline-block;
   font-size: inherit;
   vertical-align: middle;
   object-fit: contain;
@@ -588,7 +587,7 @@
 }
 
 .reply-indicator__display-name {
-  color: $lighter-text-color;
+  color: $inverted-text-color;
   display: block;
   max-width: 100%;
   line-height: 24px;
@@ -643,14 +642,14 @@
   }
 
   a {
-    color: $ui-secondary-color;
+    color: $secondary-text-color;
     text-decoration: none;
 
     &:hover {
       text-decoration: underline;
 
       .fa {
-        color: lighten($action-button-color, 7%);
+        color: lighten($dark-text-color, 7%);
       }
     }
 
@@ -665,7 +664,7 @@
     }
 
     .fa {
-      color: $action-button-color;
+      color: $dark-text-color;
     }
   }
 
@@ -702,7 +701,7 @@
   border-radius: 2px;
   background: transparent;
   border: 0;
-  color: $lighter-text-color;
+  color: $inverted-text-color;
   font-weight: 700;
   font-size: 11px;
   padding: 0 6px;
@@ -769,7 +768,7 @@
 
   &.light {
     .status__relative-time {
-      color: $lighter-text-color;
+      color: $light-text-color;
     }
 
     .status__display-name {
@@ -782,7 +781,7 @@
       }
 
       span {
-        color: $lighter-text-color;
+        color: $light-text-color;
       }
     }
 
@@ -816,13 +815,13 @@
 }
 
 .status__relative-time {
-  color: $darker-text-color;
+  color: $dark-text-color;
   float: right;
   font-size: 14px;
 }
 
 .status__display-name {
-  color: $darker-text-color;
+  color: $dark-text-color;
 }
 
 .status__info .status__display-name {
@@ -873,14 +872,14 @@
 
 .status__prepend {
   margin-left: 68px;
-  color: $darker-text-color;
+  color: $dark-text-color;
   padding: 8px 0;
   padding-bottom: 2px;
   font-size: 14px;
   position: relative;
 
   .status__display-name strong {
-    color: $darker-text-color;
+    color: $dark-text-color;
   }
 
   > span {
@@ -942,7 +941,7 @@
 
 .detailed-status__meta {
   margin-top: 15px;
-  color: $darker-text-color;
+  color: $dark-text-color;
   font-size: 14px;
   line-height: 18px;
 }
@@ -1006,6 +1005,15 @@
   padding: 10px;
   border-bottom: 1px solid lighten($ui-base-color, 8%);
 
+  &.compact {
+    padding: 0;
+    border-bottom: 0;
+
+    .account__avatar-wrapper {
+      margin-left: 0;
+    }
+  }
+
   .account__display-name {
     flex: 1 1 auto;
     display: block;
@@ -1029,7 +1037,6 @@
 .account__avatar {
   @include avatar-radius();
   position: relative;
-  cursor: pointer;
 
   &-inline {
     display: inline-block;
@@ -1038,6 +1045,10 @@
   }
 }
 
+a .account__avatar {
+  cursor: pointer;
+}
+
 .account__avatar-overlay {
   @include avatar-size(48px);
 
@@ -1079,7 +1090,7 @@
     }
 
     .account__header__username {
-      color: $darker-text-color;
+      color: $secondary-text-color;
     }
   }
 
@@ -1089,7 +1100,7 @@
   }
 
   .account__header__content {
-    color: $darker-text-color;
+    color: $secondary-text-color;
   }
 
   .account__header__display-name {
@@ -1117,7 +1128,7 @@
 .account__disclaimer {
   padding: 10px;
   border-top: 1px solid lighten($ui-base-color, 8%);
-  color: $darker-text-color;
+  color: $dark-text-color;
 
   strong {
     font-weight: 500;
@@ -1286,7 +1297,7 @@
 .status__display-name,
 .reply-indicator__display-name,
 .detailed-status__display-name,
-.account__display-name {
+a.account__display-name {
   &:hover strong {
     text-decoration: underline;
   }
@@ -1304,7 +1315,7 @@
 }
 
 .detailed-status__display-name {
-  color: $darker-text-color;
+  color: $secondary-text-color;
   display: block;
   line-height: 24px;
   margin-bottom: 15px;
@@ -1339,11 +1350,11 @@
 .muted {
   .status__content p,
   .status__content a {
-    color: $darker-text-color;
+    color: $dark-text-color;
   }
 
   .status__display-name strong {
-    color: $darker-text-color;
+    color: $dark-text-color;
   }
 
   .status__avatar {
@@ -1351,11 +1362,11 @@
   }
 
   a.status__content__spoiler-link {
-    background: $darker-text-color;
-    color: lighten($ui-base-color, 4%);
+    background: $ui-base-lighter-color;
+    color: $inverted-text-color;
 
     &:hover {
-      background: transparentize($darker-text-color, 0.07);
+      background: lighten($ui-base-lighter-color, 7%);
       text-decoration: none;
     }
   }
@@ -1366,7 +1377,7 @@
   padding: 8px 0;
   padding-bottom: 0;
   cursor: default;
-  color: $ui-primary-color;
+  color: $darker-text-color;
   font-size: 15px;
   position: relative;
 
@@ -1477,7 +1488,7 @@
   color: $darker-text-color;
 
   strong {
-    color: $primary-text-color;
+    color: $secondary-text-color;
   }
 
   a {
@@ -1591,7 +1602,7 @@
     &:hover,
     &:active {
       background: $ui-highlight-color;
-      color: $primary-text-color;
+      color: $secondary-text-color;
       outline: 0;
     }
   }
@@ -1644,7 +1655,7 @@
 
     &:hover {
       background: $ui-highlight-color;
-      color: $primary-text-color;
+      color: $secondary-text-color;
     }
   }
 }
@@ -1656,7 +1667,7 @@
 .static-content {
   padding: 10px;
   padding-top: 20px;
-  color: $darker-text-color;
+  color: $dark-text-color;
 
   h1 {
     font-size: 16px;
@@ -1743,7 +1754,7 @@
   display: block;
   flex: 1 1 auto;
   padding: 15px 5px 13px;
-  color: $ui-primary-color;
+  color: $darker-text-color;
   text-decoration: none;
   text-align: center;
   font-size: 16px;
@@ -2155,7 +2166,7 @@
 
 .column-subheading {
   background: $ui-base-color;
-  color: $darker-text-color;
+  color: $dark-text-color;
   padding: 8px 20px;
   font-size: 12px;
   font-weight: 500;
@@ -2178,11 +2189,11 @@
   flex: 1 0 auto;
 
   p {
-    color: $darker-text-color;
+    color: $secondary-text-color;
   }
 
   a {
-    color: opacify($darker-text-color, 0.07);
+    color: $dark-text-color;
   }
 }
 
@@ -2263,7 +2274,7 @@
   font-size: 14px;
   border: 1px solid lighten($ui-base-color, 8%);
   border-radius: 4px;
-  color: $darker-text-color;
+  color: $dark-text-color;
   margin-top: 14px;
   text-decoration: none;
   overflow: hidden;
@@ -2343,7 +2354,7 @@ a.status-card {
   display: block;
   font-weight: 500;
   margin-bottom: 5px;
-  color: $ui-primary-color;
+  color: $darker-text-color;
   overflow: hidden;
   text-overflow: ellipsis;
   white-space: nowrap;
@@ -2357,7 +2368,7 @@ a.status-card {
 }
 
 .status-card__description {
-  color: $ui-primary-color;
+  color: $darker-text-color;
 }
 
 .status-card__host {
@@ -2401,7 +2412,7 @@ a.status-card {
 
 .load-more {
   display: block;
-  color: $darker-text-color;
+  color: $dark-text-color;
   background-color: transparent;
   border: 0;
   font-size: inherit;
@@ -2425,7 +2436,7 @@ a.status-card {
   text-align: center;
   font-size: 16px;
   font-weight: 500;
-  color: opacify($darker-text-color, 0.07);
+  color: $dark-text-color;
   background: $ui-base-color;
   cursor: default;
   display: flex;
@@ -2465,7 +2476,7 @@ a.status-card {
     strong {
       display: block;
       margin-bottom: 10px;
-      color: $darker-text-color;
+      color: $dark-text-color;
     }
 
     span {
@@ -2553,13 +2564,13 @@ a.status-card {
 .column-header__button {
   background: lighten($ui-base-color, 4%);
   border: 0;
-  color: $ui-primary-color;
+  color: $darker-text-color;
   cursor: pointer;
   font-size: 16px;
   padding: 0 15px;
 
   &:hover {
-    color: lighten($ui-primary-color, 7%);
+    color: lighten($darker-text-color, 7%);
   }
 
   &.active {
@@ -2640,7 +2651,7 @@ a.status-card {
 }
 
 .loading-indicator {
-  color: $darker-text-color;
+  color: $dark-text-color;
   font-size: 12px;
   font-weight: 400;
   text-transform: uppercase;
@@ -2737,7 +2748,7 @@ a.status-card {
   &:active,
   &:focus {
     padding: 0;
-    color: transparentize($darker-text-color, 0.07);
+    color: lighten($darker-text-color, 8%);
   }
 }
 
@@ -2861,7 +2872,7 @@ a.status-card {
 
 .empty-column-indicator,
 .error-column {
-  color: $darker-text-color;
+  color: $dark-text-color;
   background: $ui-base-color;
   text-align: center;
   padding: 20px;
@@ -3063,7 +3074,7 @@ a.status-card {
   display: flex;
   align-items: center;
   justify-content: center;
-  color: $primary-text-color;
+  color: $secondary-text-color;
   font-size: 18px;
   font-weight: 500;
   border: 2px dashed $ui-base-lighter-color;
@@ -3161,7 +3172,7 @@ a.status-card {
 }
 
 .privacy-dropdown__option {
-  color: $lighter-text-color;
+  color: $inverted-text-color;
   padding: 10px;
   cursor: pointer;
   display: flex;
@@ -3283,7 +3294,7 @@ a.status-card {
     font-size: 18px;
     width: 18px;
     height: 18px;
-    color: $ui-secondary-color;
+    color: $secondary-text-color;
     cursor: default;
     pointer-events: none;
 
@@ -3319,7 +3330,7 @@ a.status-card {
 }
 
 .search-results__header {
-  color: $darker-text-color;
+  color: $dark-text-color;
   background: lighten($ui-base-color, 2%);
   border-bottom: 1px solid darken($ui-base-color, 4%);
   padding: 15px 10px;
@@ -3367,13 +3378,13 @@ a.status-card {
 .search-results__hashtag {
   display: block;
   padding: 10px;
-  color: darken($primary-text-color, 4%);
+  color: $secondary-text-color;
   text-decoration: none;
 
   &:hover,
   &:active,
   &:focus {
-    color: $primary-text-color;
+    color: lighten($secondary-text-color, 4%);
     text-decoration: underline;
   }
 }
@@ -3638,7 +3649,7 @@ a.status-card {
     &:hover,
     &:focus,
     &:active {
-      color: transparentize($lighter-text-color, 0.04);
+      color: darken($lighter-text-color, 4%);
       background-color: darken($ui-secondary-color, 16%);
     }
 
@@ -3732,7 +3743,7 @@ a.status-card {
     strong {
       font-weight: 500;
       background: $ui-base-color;
-      color: $primary-text-color;
+      color: $secondary-text-color;
       border-radius: 4px;
       font-size: 14px;
       padding: 3px 6px;
@@ -3792,7 +3803,7 @@ a.status-card {
 
   &__case {
     background: $ui-base-color;
-    color: $primary-text-color;
+    color: $secondary-text-color;
     font-weight: 500;
     padding: 10px;
     border-radius: 4px;
@@ -3809,7 +3820,7 @@ a.status-card {
 
   .figure {
     background: darken($ui-base-color, 8%);
-    color: $darker-text-color;
+    color: $secondary-text-color;
     margin-bottom: 20px;
     border-radius: 4px;
     padding: 10px;
@@ -3921,7 +3932,7 @@ a.status-card {
   }
 
   .status__content__spoiler-link {
-    color: lighten($ui-secondary-color, 8%);
+    color: lighten($secondary-text-color, 8%);
   }
 }
 
@@ -4026,6 +4037,10 @@ a.status-card {
   overflow-y: auto;
   overflow-x: hidden;
 
+  .status__content a {
+    color: $highlight-text-color;
+  }
+
   @media screen and (max-width: 480px) {
     max-height: 10vh;
   }
@@ -4151,7 +4166,7 @@ a.status-card {
     &:hover,
     &:focus,
     &:active {
-      color: transparentize($lighter-text-color, 0.04);
+      color: darken($lighter-text-color, 4%);
     }
   }
 }
@@ -4232,7 +4247,7 @@ a.status-card {
 
   &__icon {
     flex: 0 0 auto;
-    color: $darker-text-color;
+    color: $dark-text-color;
     padding: 8px 18px;
     cursor: default;
     border-right: 1px solid lighten($ui-base-color, 8%);
@@ -4262,7 +4277,7 @@ a.status-card {
 
     a {
       text-decoration: none;
-      color: $darker-text-color;
+      color: $dark-text-color;
       font-weight: 500;
 
       &:hover {
@@ -4281,7 +4296,7 @@ a.status-card {
     }
 
     .fa {
-      color: $darker-text-color;
+      color: $dark-text-color;
     }
   }
 }
@@ -4317,7 +4332,7 @@ a.status-card {
   cursor: zoom-in;
   display: block;
   text-decoration: none;
-  color: $ui-secondary-color;
+  color: $secondary-text-color;
   line-height: 0;
 
   &,
@@ -4431,6 +4446,8 @@ a.status-card {
     video {
       max-width: 100% !important;
       max-height: 100% !important;
+      width: 100% !important;
+      height: 100% !important;
     }
   }
 
@@ -4488,7 +4505,7 @@ a.status-card {
       &:hover,
       &:active,
       &:focus {
-        color: transparentize($darker-text-color, 0.07);
+        color: lighten($darker-text-color, 7%);
       }
     }
 
@@ -4693,7 +4710,7 @@ a.status-card {
     &:active,
     &:focus {
       outline: 0;
-      color: transparentize($darker-text-color, 0.07);
+      color: $secondary-text-color;
 
       &::before {
         content: "";
@@ -4733,7 +4750,7 @@ a.status-card {
     position: relative;
 
     &.active {
-      color: transparentize($darker-text-color, 0.07);
+      color: $secondary-text-color;
 
       &::before,
       &::after {
@@ -4768,12 +4785,12 @@ a.status-card {
   padding: 10px 14px;
   padding-bottom: 14px;
   margin-top: 10px;
-  color: $lighter-text-color;
+  color: $light-text-color;
   box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4);
 
   h4 {
     text-transform: uppercase;
-    color: $lighter-text-color;
+    color: $light-text-color;
     font-size: 13px;
     font-weight: 500;
     margin-bottom: 10px;
@@ -4805,7 +4822,7 @@ noscript {
   div {
     font-size: 14px;
     margin: 30px auto;
-    color: $primary-text-color;
+    color: $secondary-text-color;
     max-width: 400px;
 
     a {
@@ -4958,7 +4975,7 @@ noscript {
   &__message {
     position: relative;
     margin-left: 58px;
-    color: $darker-text-color;
+    color: $dark-text-color;
     padding: 8px 0;
     padding-top: 0;
     padding-bottom: 4px;
diff --git a/app/javascript/styles/mastodon/containers.scss b/app/javascript/styles/mastodon/containers.scss
index 8df2902d2..9d5ab66a4 100644
--- a/app/javascript/styles/mastodon/containers.scss
+++ b/app/javascript/styles/mastodon/containers.scss
@@ -100,7 +100,7 @@
 
   .name {
     flex: 1 1 auto;
-    color: $darker-text-color;
+    color: $secondary-text-color;
     width: calc(100% - 88px);
 
     .username {
diff --git a/app/javascript/styles/mastodon/emoji_picker.scss b/app/javascript/styles/mastodon/emoji_picker.scss
index 3620a6f54..cf9547586 100644
--- a/app/javascript/styles/mastodon/emoji_picker.scss
+++ b/app/javascript/styles/mastodon/emoji_picker.scss
@@ -50,7 +50,7 @@
   cursor: pointer;
 
   &:hover {
-    color: opacify($lighter-text-color, 0.04);
+    color: darken($lighter-text-color, 4%);
   }
 }
 
@@ -184,7 +184,7 @@
   font-size: 14px;
   text-align: center;
   padding-top: 70px;
-  color: $lighter-text-color;
+  color: $light-text-color;
 
   .emoji-mart-category-label {
     display: none;
diff --git a/app/javascript/styles/mastodon/forms.scss b/app/javascript/styles/mastodon/forms.scss
index 3a3b4c326..f97890187 100644
--- a/app/javascript/styles/mastodon/forms.scss
+++ b/app/javascript/styles/mastodon/forms.scss
@@ -248,7 +248,7 @@ code {
     }
 
     &:required:valid {
-      border-bottom-color: lighten($error-red, 12%);
+      border-bottom-color: $valid-value-color;
     }
 
     &:active,
@@ -266,7 +266,7 @@ code {
     input[type=text],
     input[type=email],
     input[type=password] {
-      border-bottom-color: lighten($error-red, 12%);
+      border-bottom-color: $valid-value-color;
     }
 
     .error {
@@ -356,7 +356,7 @@ code {
       padding: 7px 4px;
       padding-bottom: 9px;
       font-size: 16px;
-      color: $darker-text-color;
+      color: $dark-text-color;
       font-family: inherit;
       pointer-events: none;
       cursor: default;
@@ -446,7 +446,7 @@ code {
   }
 
   strong {
-    color: $primary-text-color;
+    color: $secondary-text-color;
     font-weight: 500;
 
     @each $lang in $cjk-langs {
@@ -483,7 +483,7 @@ code {
 
 .qr-alternative {
   margin-bottom: 20px;
-  color: $darker-text-color;
+  color: $secondary-text-color;
   flex: 150px;
 
   samp {
diff --git a/app/javascript/styles/mastodon/landing_strip.scss b/app/javascript/styles/mastodon/landing_strip.scss
index 651c06ced..86614b89b 100644
--- a/app/javascript/styles/mastodon/landing_strip.scss
+++ b/app/javascript/styles/mastodon/landing_strip.scss
@@ -45,7 +45,7 @@
   padding: 14px;
   border-radius: 4px;
   background: rgba(darken($ui-base-color, 7%), 0.8);
-  color: $darker-text-color;
+  color: $secondary-text-color;
   font-weight: 400;
   margin-bottom: 20px;
 
diff --git a/app/javascript/styles/mastodon/stream_entries.scss b/app/javascript/styles/mastodon/stream_entries.scss
index c39163ba8..281cbaf83 100644
--- a/app/javascript/styles/mastodon/stream_entries.scss
+++ b/app/javascript/styles/mastodon/stream_entries.scss
@@ -93,7 +93,7 @@
       display: block;
       max-width: 100%;
       padding-right: 25px;
-      color: $lighter-text-color;
+      color: $inverted-text-color;
     }
 
     .status__avatar {
@@ -134,7 +134,7 @@
 
       span {
         font-size: 14px;
-        color: $inverted-text-color;
+        color: $light-text-color;
       }
     }
 
@@ -191,7 +191,7 @@
 
         span {
           font-size: 14px;
-          color: $lighter-text-color;
+          color: $light-text-color;
         }
       }
     }
@@ -225,7 +225,7 @@
 
     .detailed-status__meta {
       margin-top: 15px;
-      color: $lighter-text-color;
+      color: $light-text-color;
       font-size: 14px;
       line-height: 18px;
 
@@ -270,7 +270,7 @@
     padding-left: (48px + 14px * 2);
     padding-bottom: 0;
     margin-bottom: -4px;
-    color: $lighter-text-color;
+    color: $light-text-color;
     font-size: 14px;
     position: relative;
 
@@ -280,7 +280,7 @@
     }
 
     .status__display-name.muted strong {
-      color: $lighter-text-color;
+      color: $light-text-color;
     }
   }
 
@@ -293,7 +293,7 @@
   }
 
   .more {
-    color: $classic-primary-color;
+    color: $darker-text-color;
     display: block;
     padding: 14px;
     text-align: center;
diff --git a/app/javascript/styles/mastodon/tables.scss b/app/javascript/styles/mastodon/tables.scss
index c12d84f1c..fa876e603 100644
--- a/app/javascript/styles/mastodon/tables.scss
+++ b/app/javascript/styles/mastodon/tables.scss
@@ -11,6 +11,7 @@
     vertical-align: top;
     border-top: 1px solid $ui-base-color;
     text-align: left;
+    background: darken($ui-base-color, 4%);
   }
 
   & > thead > tr > th {
@@ -48,9 +49,38 @@
     }
   }
 
-  &.inline-table > tbody > tr:nth-child(odd) > td,
-  &.inline-table > tbody > tr:nth-child(odd) > th {
-    background: transparent;
+  &.inline-table {
+    & > tbody > tr:nth-child(odd) {
+      & > td,
+      & > th {
+        background: transparent;
+      }
+    }
+
+    & > tbody > tr:first-child {
+      & > td,
+      & > th {
+        border-top: 0;
+      }
+    }
+  }
+
+  &.batch-table {
+    & > thead > tr > th {
+      background: $ui-base-color;
+      border-top: 1px solid darken($ui-base-color, 8%);
+      border-bottom: 1px solid darken($ui-base-color, 8%);
+
+      &:first-child {
+        border-radius: 4px 0 0;
+        border-left: 1px solid darken($ui-base-color, 8%);
+      }
+
+      &:last-child {
+        border-radius: 0 4px 0 0;
+        border-right: 1px solid darken($ui-base-color, 8%);
+      }
+    }
   }
 }
 
@@ -63,6 +93,13 @@ samp {
   font-family: 'mastodon-font-monospace', monospace;
 }
 
+button.table-action-link {
+  background: transparent;
+  border: 0;
+  font: inherit;
+}
+
+button.table-action-link,
 a.table-action-link {
   text-decoration: none;
   display: inline-block;
@@ -79,4 +116,77 @@ a.table-action-link {
     font-weight: 400;
     margin-right: 5px;
   }
+
+  &:first-child {
+    padding-left: 0;
+  }
+}
+
+.batch-table {
+  &__toolbar,
+  &__row {
+    display: flex;
+
+    &__select {
+      box-sizing: border-box;
+      padding: 8px 16px;
+      cursor: pointer;
+      min-height: 100%;
+
+      input {
+        margin-top: 8px;
+      }
+    }
+
+    &__actions,
+    &__content {
+      padding: 8px 0;
+      padding-right: 16px;
+      flex: 1 1 auto;
+    }
+  }
+
+  &__toolbar {
+    border: 1px solid darken($ui-base-color, 8%);
+    background: $ui-base-color;
+    border-radius: 4px 0 0;
+    height: 47px;
+    align-items: center;
+
+    &__actions {
+      text-align: right;
+      padding-right: 16px - 5px;
+    }
+  }
+
+  &__row {
+    border: 1px solid darken($ui-base-color, 8%);
+    border-top: 0;
+    background: darken($ui-base-color, 4%);
+
+    &:hover {
+      background: darken($ui-base-color, 2%);
+    }
+
+    &:nth-child(even) {
+      background: $ui-base-color;
+
+      &:hover {
+        background: lighten($ui-base-color, 2%);
+      }
+    }
+
+    &__content {
+      padding-top: 12px;
+      padding-bottom: 16px;
+    }
+  }
+
+  .status__content {
+    padding-top: 0;
+
+    strong {
+      font-weight: 700;
+    }
+  }
 }
diff --git a/app/javascript/styles/mastodon/variables.scss b/app/javascript/styles/mastodon/variables.scss
index dc4e72a2e..cbefe35b4 100644
--- a/app/javascript/styles/mastodon/variables.scss
+++ b/app/javascript/styles/mastodon/variables.scss
@@ -17,12 +17,6 @@ $base-shadow-color: $black !default;
 $base-overlay-background: $black !default;
 $base-border-color: $white !default;
 $simple-background-color: $white !default;
-$primary-text-color: $white !default;
-$darker-text-color: rgba($primary-text-color, 0.7) !default;
-$highlight-text-color: $classic-highlight-color !default;
-$inverted-text-color: $black !default;
-$lighter-text-color: rgba($inverted-text-color, 0.7) !default;
-$action-button-color: #8d9ac2;
 $valid-value-color: $success-green !default;
 $error-value-color: $error-red !default;
 
@@ -31,7 +25,19 @@ $ui-base-color: $classic-base-color !default;                  // Darkest
 $ui-base-lighter-color: lighten($ui-base-color, 26%) !default; // Lighter darkest
 $ui-primary-color: $classic-primary-color !default;            // Lighter
 $ui-secondary-color: $classic-secondary-color !default;        // Lightest
-$ui-highlight-color: #2b5fd9;
+$ui-highlight-color: $classic-highlight-color !default;
+
+// Variables for texts
+$primary-text-color: $white !default;
+$darker-text-color: $ui-primary-color !default;
+$dark-text-color: $ui-base-lighter-color !default;
+$secondary-text-color: $ui-secondary-color !default;
+$highlight-text-color: $ui-highlight-color !default;
+$action-button-color: $ui-base-lighter-color !default;
+// For texts on inverted backgrounds
+$inverted-text-color: $ui-base-color !default;
+$lighter-text-color: $ui-base-lighter-color !default;
+$light-text-color: $ui-primary-color !default;
 
 // Language codes that uses CJK fonts
 $cjk-langs: ja, ko, zh-CN, zh-HK, zh-TW;
diff --git a/app/lib/activitypub/activity.rb b/app/lib/activitypub/activity.rb
index 9b00f0f52..5b97a6208 100644
--- a/app/lib/activitypub/activity.rb
+++ b/app/lib/activitypub/activity.rb
@@ -80,7 +80,7 @@ class ActivityPub::Activity
 
     # Only continue if the status is supposed to have
     # arrived in real-time
-    return unless @options[:override_timestamps] || status.within_realtime_window?
+    return unless status.within_realtime_window?
 
     distribute_to_followers(status)
   end
diff --git a/app/lib/activitypub/activity/announce.rb b/app/lib/activitypub/activity/announce.rb
index c8a358195..8840a450c 100644
--- a/app/lib/activitypub/activity/announce.rb
+++ b/app/lib/activitypub/activity/announce.rb
@@ -15,7 +15,7 @@ class ActivityPub::Activity::Announce < ActivityPub::Activity
       account: @account,
       reblog: original_status,
       uri: @json['id'],
-      created_at: @options[:override_timestamps] ? nil : @json['published'],
+      created_at: @json['published'],
       visibility: original_status.visibility
     )
 
diff --git a/app/lib/activitypub/activity/create.rb b/app/lib/activitypub/activity/create.rb
index 45c0e91cb..edee2691f 100644
--- a/app/lib/activitypub/activity/create.rb
+++ b/app/lib/activitypub/activity/create.rb
@@ -47,7 +47,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
       text: text_from_content || '',
       language: detected_language,
       spoiler_text: @object['summary'] || '',
-      created_at: @options[:override_timestamps] ? nil : @object['published'],
+      created_at: @object['published'],
       reply: @object['inReplyTo'].present?,
       sensitive: @object['sensitive'] || false,
       visibility: visibility_from_audience,
@@ -61,12 +61,11 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
     return if @object['tag'].nil?
 
     as_array(@object['tag']).each do |tag|
-      case tag['type']
-      when 'Hashtag'
+      if equals_or_includes?(tag['type'], 'Hashtag')
         process_hashtag tag, status
-      when 'Mention'
+      elsif equals_or_includes?(tag['type'], 'Mention')
         process_mention tag, status
-      when 'Emoji'
+      elsif equals_or_includes?(tag['type'], 'Emoji')
         process_emoji tag, status
       end
     end
@@ -235,11 +234,11 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
   end
 
   def supported_object_type?
-    SUPPORTED_TYPES.include?(@object['type'])
+    equals_or_includes_any?(@object['type'], SUPPORTED_TYPES)
   end
 
   def converted_object_type?
-    CONVERTED_TYPES.include?(@object['type'])
+    equals_or_includes_any?(@object['type'], CONVERTED_TYPES)
   end
 
   def skip_download?
diff --git a/app/lib/activitypub/activity/update.rb b/app/lib/activitypub/activity/update.rb
index 0134b4015..aa5907f03 100644
--- a/app/lib/activitypub/activity/update.rb
+++ b/app/lib/activitypub/activity/update.rb
@@ -1,11 +1,10 @@
 # frozen_string_literal: true
 
 class ActivityPub::Activity::Update < ActivityPub::Activity
+  SUPPORTED_TYPES = %w(Application Group Organization Person Service).freeze
+
   def perform
-    case @object['type']
-    when 'Person'
-      update_account
-    end
+    update_account if equals_or_includes_any?(@object['type'], SUPPORTED_TYPES)
   end
 
   private
diff --git a/app/lib/entity_cache.rb b/app/lib/entity_cache.rb
new file mode 100644
index 000000000..2aa37389c
--- /dev/null
+++ b/app/lib/entity_cache.rb
@@ -0,0 +1,34 @@
+# frozen_string_literal: true
+
+require 'singleton'
+
+class EntityCache
+  include Singleton
+
+  MAX_EXPIRATION = 7.days.freeze
+
+  def mention(username, domain)
+    Rails.cache.fetch(to_key(:mention, username, domain), expires_in: MAX_EXPIRATION) { Account.select(:username, :domain, :url).find_remote(username, domain) }
+  end
+
+  def emoji(shortcodes, domain)
+    shortcodes   = [shortcodes] unless shortcodes.is_a?(Array)
+    cached       = Rails.cache.read_multi(*shortcodes.map { |shortcode| to_key(:emoji, shortcode, domain) })
+    uncached_ids = []
+
+    shortcodes.each do |shortcode|
+      uncached_ids << shortcode unless cached.key?(to_key(:emoji, shortcode, domain))
+    end
+
+    unless uncached_ids.empty?
+      uncached = CustomEmoji.where(shortcode: shortcodes, domain: domain, disabled: false).map { |item| [item.shortcode, item] }.to_h
+      uncached.each_value { |item| Rails.cache.write(to_key(:emoji, item.shortcode, domain), item, expires_in: MAX_EXPIRATION) }
+    end
+
+    shortcodes.map { |shortcode| cached[to_key(:emoji, shortcode, domain)] || uncached[shortcode] }.compact
+  end
+
+  def to_key(type, *ids)
+    "#{type}:#{ids.compact.map(&:downcase).join(':')}"
+  end
+end
diff --git a/app/lib/exceptions.rb b/app/lib/exceptions.rb
index e88e98eae..01346bfe5 100644
--- a/app/lib/exceptions.rb
+++ b/app/lib/exceptions.rb
@@ -6,6 +6,7 @@ module Mastodon
   class ValidationError < Error; end
   class HostValidationError < ValidationError; end
   class LengthValidationError < ValidationError; end
+  class DimensionsValidationError < ValidationError; end
   class RaceConditionError < Error; end
 
   class UnexpectedResponseError < Error
diff --git a/app/lib/feed_manager.rb b/app/lib/feed_manager.rb
index 700fd61c4..3a2dcac68 100644
--- a/app/lib/feed_manager.rb
+++ b/app/lib/feed_manager.rb
@@ -145,10 +145,14 @@ class FeedManager
     redis.exists("subscribed:#{timeline_id}")
   end
 
+  def blocks_or_mutes?(receiver_id, account_ids, context)
+    Block.where(account_id: receiver_id, target_account_id: account_ids).any? ||
+      (context == :home ? Mute.where(account_id: receiver_id, target_account_id: account_ids).any? : Mute.where(account_id: receiver_id, target_account_id: account_ids, hide_notifications: true).any?)
+  end
+
   def filter_from_home?(status, receiver_id)
     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, receiver_id)
 
     check_for_mutes = [status.account_id]
@@ -158,9 +162,10 @@ class FeedManager
     return true if Mute.where(account_id: receiver_id, target_account_id: check_for_mutes).any?
 
     check_for_blocks = status.mentions.pluck(:account_id)
+    check_for_blocks.concat([status.account_id])
     check_for_blocks.concat([status.reblog.account_id]) if status.reblog?
 
-    return true if Block.where(account_id: receiver_id, target_account_id: check_for_blocks).any?
+    return true if blocks_or_mutes?(receiver_id, check_for_blocks, :home)
 
     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
@@ -184,11 +189,13 @@ class FeedManager
   def filter_from_mentions?(status, receiver_id)
     return true if receiver_id == status.account_id
 
-    check_for_blocks = [status.account_id]
-    check_for_blocks.concat(status.mentions.pluck(:account_id))
+    # This filter is called from NotifyService, but already after the sender of
+    # the notification has been checked for mute/block. Therefore, it's not
+    # necessary to check the author of the toot for mute/block again
+    check_for_blocks = status.mentions.pluck(:account_id)
     check_for_blocks.concat([status.in_reply_to_account]) if status.reply? && !status.in_reply_to_account_id.nil?
 
-    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   = blocks_or_mutes?(receiver_id, check_for_blocks, :mentions)                                                         # Filter if it's from someone I blocked, in reply to someone I blocked, or mentioning someone I blocked (or muted)
     should_filter ||= (status.account.silenced? && !Follow.where(account_id: receiver_id, target_account_id: status.account_id).exists?) # of if the account is silenced and I'm not following them
     should_filter ||= keyword_filter?(status, receiver_id)                                                                               # or if the mention contains a muted keyword
 
diff --git a/app/lib/formatter.rb b/app/lib/formatter.rb
index 4124f1660..050c651ee 100644
--- a/app/lib/formatter.rb
+++ b/app/lib/formatter.rb
@@ -52,12 +52,8 @@ class Formatter
   end
 
   def simplified_format(account, **options)
-    html = if account.local?
-             linkify(account.note)
-           else
-             reformat(account.note)
-           end
-    html = encode_custom_emojis(html, CustomEmoji.from_text(account.note, account.domain)) if options[:custom_emojify]
+    html = account.local? ? linkify(account.note) : reformat(account.note)
+    html = encode_custom_emojis(html, account.emojis) if options[:custom_emojify]
     html.html_safe # rubocop:disable Rails/OutputSafety
   end
 
@@ -211,7 +207,7 @@ class Formatter
     username, domain = acct.split('@')
 
     domain  = nil if TagManager.instance.local_domain?(domain)
-    account = Account.find_remote(username, domain)
+    account = EntityCache.instance.mention(username, domain)
 
     account ? mention_html(account) : "@#{acct}"
   end
diff --git a/app/lib/ostatus/activity/creation.rb b/app/lib/ostatus/activity/creation.rb
index 6235127b2..a24a0093c 100644
--- a/app/lib/ostatus/activity/creation.rb
+++ b/app/lib/ostatus/activity/creation.rb
@@ -39,7 +39,7 @@ class OStatus::Activity::Creation < OStatus::Activity::Base
         reblog: cached_reblog,
         text: content,
         spoiler_text: content_warning,
-        created_at: @options[:override_timestamps] ? nil : published,
+        created_at: published,
         reply: thread?,
         language: content_language,
         visibility: visibility_scope,
@@ -61,7 +61,7 @@ class OStatus::Activity::Creation < OStatus::Activity::Base
     Rails.logger.debug "Queuing remote status #{status.id} (#{id}) for distribution"
 
     LinkCrawlWorker.perform_async(status.id) unless status.spoiler_text?
-    DistributionWorker.perform_async(status.id) if @options[:override_timestamps] || status.within_realtime_window?
+    DistributionWorker.perform_async(status.id) if status.within_realtime_window?
 
     status
   end
diff --git a/app/lib/ostatus/atom_serializer.rb b/app/lib/ostatus/atom_serializer.rb
index 055b4649c..7c66f2066 100644
--- a/app/lib/ostatus/atom_serializer.rb
+++ b/app/lib/ostatus/atom_serializer.rb
@@ -364,8 +364,6 @@ class OStatus::AtomSerializer
       append_element(entry, 'category', nil, term: tag.name)
     end
 
-    append_element(entry, 'category', nil, term: 'nsfw') if status.sensitive?
-
     status.media_attachments.each do |media|
       append_element(entry, 'link', nil, rel: :enclosure, type: media.file_content_type, length: media.file_file_size, href: full_asset_url(media.file.url(:original, false)))
     end
diff --git a/app/lib/provider_discovery.rb b/app/lib/provider_discovery.rb
deleted file mode 100644
index 3bec7211b..000000000
--- a/app/lib/provider_discovery.rb
+++ /dev/null
@@ -1,47 +0,0 @@
-# frozen_string_literal: true
-
-class ProviderDiscovery < OEmbed::ProviderDiscovery
-  class << self
-    def get(url, **options)
-      provider = discover_provider(url, options)
-
-      options.delete(:html)
-
-      provider.get(url, options)
-    end
-
-    def discover_provider(url, **options)
-      format = options[:format]
-
-      html = if options[:html]
-               Nokogiri::HTML(options[:html])
-             else
-               Request.new(:get, url).perform do |res|
-                 raise OEmbed::NotFound, url if res.code != 200 || res.mime_type != 'text/html'
-                 Nokogiri::HTML(res.body_with_limit)
-               end
-             end
-
-      if format.nil? || format == :json
-        provider_endpoint ||= html.at_xpath('//link[@type="application/json+oembed"]')&.attribute('href')&.value
-        format ||= :json if provider_endpoint
-      end
-
-      if format.nil? || format == :xml
-        provider_endpoint ||= html.at_xpath('//link[@type="text/xml+oembed"]')&.attribute('href')&.value
-        format ||= :xml if provider_endpoint
-      end
-
-      raise OEmbed::NotFound, url if provider_endpoint.nil?
-      begin
-        provider_endpoint = Addressable::URI.parse(provider_endpoint)
-        provider_endpoint.query = nil
-        provider_endpoint = provider_endpoint.to_s
-      rescue Addressable::URI::InvalidURIError
-        raise OEmbed::NotFound, url
-      end
-
-      OEmbed::Provider.new(provider_endpoint, format)
-    end
-  end
-end
diff --git a/app/lib/request.rb b/app/lib/request.rb
index dca93a6e9..00f94dacf 100644
--- a/app/lib/request.rb
+++ b/app/lib/request.rb
@@ -9,11 +9,15 @@ class Request
   include RoutingHelper
 
   def initialize(verb, url, **options)
+    raise ArgumentError if url.blank?
+
     @verb    = verb
     @url     = Addressable::URI.parse(url).normalize
-    @options = options.merge(socket_class: Socket)
+    @options = options.merge(use_proxy? ? Rails.configuration.x.http_client_proxy : { socket_class: Socket })
     @headers = {}
 
+    raise Mastodon::HostValidationError, 'Instance does not support hidden service connections' if block_hidden_service?
+
     set_common_headers!
     set_digest! if options.key?(:body)
   end
@@ -99,6 +103,14 @@ class Request
     @http_client ||= HTTP.timeout(:per_operation, timeout).follow(max_hops: 2)
   end
 
+  def use_proxy?
+    Rails.configuration.x.http_client_proxy.present?
+  end
+
+  def block_hidden_service?
+    !Rails.configuration.x.access_to_hidden_service && /\.(onion|i2p)$/.match(@url.host)
+  end
+
   module ClientLimit
     def body_with_limit(limit = 1.megabyte)
       raise Mastodon::LengthValidationError if content_length.present? && content_length > limit
@@ -129,6 +141,7 @@ class Request
   class Socket < TCPSocket
     class << self
       def open(host, *args)
+        return super host, *args if thru_hidden_service? host
         outer_e = nil
         Addrinfo.foreach(host, nil, nil, :SOCK_STREAM) do |address|
           begin
@@ -142,6 +155,10 @@ class Request
       end
 
       alias new open
+
+      def thru_hidden_service?(host)
+        Rails.configuration.x.hidden_service_via_transparent_proxy && /\.(onion|i2p)$/.match(host)
+      end
     end
   end
 
diff --git a/app/lib/rss_builder.rb b/app/lib/rss_builder.rb
new file mode 100644
index 000000000..63ddba2e8
--- /dev/null
+++ b/app/lib/rss_builder.rb
@@ -0,0 +1,130 @@
+# frozen_string_literal: true
+
+class RSSBuilder
+  class ItemBuilder
+    def initialize
+      @item = Ox::Element.new('item')
+    end
+
+    def title(str)
+      @item << (Ox::Element.new('title') << str)
+
+      self
+    end
+
+    def link(str)
+      @item << Ox::Element.new('guid').tap do |guid|
+        guid['isPermalink'] = 'true'
+        guid << str
+      end
+
+      @item << (Ox::Element.new('link') << str)
+
+      self
+    end
+
+    def pub_date(date)
+      @item << (Ox::Element.new('pubDate') << date.to_formatted_s(:rfc822))
+
+      self
+    end
+
+    def description(str)
+      @item << (Ox::Element.new('description') << str)
+
+      self
+    end
+
+    def enclosure(url, type, size)
+      @item << Ox::Element.new('enclosure').tap do |enclosure|
+        enclosure['url']    = url
+        enclosure['length'] = size
+        enclosure['type']   = type
+      end
+
+      self
+    end
+
+    def to_element
+      @item
+    end
+  end
+
+  def initialize
+    @document = Ox::Document.new(version: '1.0')
+    @channel  = Ox::Element.new('channel')
+
+    @document << (rss << @channel)
+  end
+
+  def title(str)
+    @channel << (Ox::Element.new('title') << str)
+
+    self
+  end
+
+  def link(str)
+    @channel << (Ox::Element.new('link') << str)
+
+    self
+  end
+
+  def image(str)
+    @channel << Ox::Element.new('image').tap do |image|
+      image << (Ox::Element.new('url') << str)
+      image << (Ox::Element.new('title') << '')
+      image << (Ox::Element.new('link') << '')
+    end
+
+    @channel << (Ox::Element.new('webfeeds:icon') << str)
+
+    self
+  end
+
+  def cover(str)
+    @channel << Ox::Element.new('webfeeds:cover').tap do |cover|
+      cover['image'] = str
+    end
+
+    self
+  end
+
+  def logo(str)
+    @channel << (Ox::Element.new('webfeeds:logo') << str)
+
+    self
+  end
+
+  def accent_color(str)
+    @channel << (Ox::Element.new('webfeeds:accentColor') << str)
+
+    self
+  end
+
+  def description(str)
+    @channel << (Ox::Element.new('description') << str)
+
+    self
+  end
+
+  def item
+    @channel << ItemBuilder.new.tap do |item|
+      yield item
+    end.to_element
+
+    self
+  end
+
+  def to_xml
+    ('<?xml version="1.0" encoding="UTF-8"?>' + Ox.dump(@document, effort: :tolerant)).force_encoding('UTF-8')
+  end
+
+  private
+
+  def rss
+    Ox::Element.new('rss').tap do |rss|
+      rss['version']        = '2.0'
+      rss['xmlns:webfeeds'] = 'http://webfeeds.org/rss/1.0'
+    end
+  end
+end
diff --git a/app/lib/status_filter.rb b/app/lib/status_filter.rb
index 41d4381e5..b6c80b801 100644
--- a/app/lib/status_filter.rb
+++ b/app/lib/status_filter.rb
@@ -3,9 +3,10 @@
 class StatusFilter
   attr_reader :status, :account
 
-  def initialize(status, account)
-    @status = status
-    @account = account
+  def initialize(status, account, preloaded_relations = {})
+    @status              = status
+    @account             = account
+    @preloaded_relations = preloaded_relations
   end
 
   def filtered?
@@ -24,15 +25,15 @@ class StatusFilter
   end
 
   def blocking_account?
-    account.blocking? status.account_id
+    @preloaded_relations[:blocking] ? @preloaded_relations[:blocking][status.account_id] : account.blocking?(status.account_id)
   end
 
   def blocking_domain?
-    account.domain_blocking? status.account_domain
+    @preloaded_relations[:domain_blocking_by_domain] ? @preloaded_relations[:domain_blocking_by_domain][status.account_domain] : account.domain_blocking?(status.account_domain)
   end
 
   def muting_account?
-    account.muting? status.account_id
+    @preloaded_relations[:muting] ? @preloaded_relations[:muting][status.account_id] : account.muting?(status.account_id)
   end
 
   def silenced_account?
@@ -44,7 +45,7 @@ class StatusFilter
   end
 
   def account_following_status_account?
-    account&.following? status.account_id
+    @preloaded_relations[:following] ? @preloaded_relations[:following][status.account_id] : account&.following?(status.account_id)
   end
 
   def blocked_by_policy?
@@ -52,6 +53,6 @@ class StatusFilter
   end
 
   def policy_allows_show?
-    StatusPolicy.new(account, status).show?
+    StatusPolicy.new(account, status, @preloaded_relations).show?
   end
 end
diff --git a/app/models/account.rb b/app/models/account.rb
index db2171102..c1ce1e99e 100644
--- a/app/models/account.rb
+++ b/app/models/account.rb
@@ -3,7 +3,7 @@
 #
 # Table name: accounts
 #
-#  id                      :integer          not null, primary key
+#  id                      :bigint(8)        not null, primary key
 #  username                :string           default(""), not null
 #  domain                  :string
 #  secret                  :string           default(""), not null
@@ -42,7 +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
+#  moved_to_account_id     :bigint(8)
 #  featured_collection_url :string
 #  fields                  :jsonb
 #
@@ -120,6 +120,7 @@ class Account < ApplicationRecord
   scope :partitioned, -> { order(Arel.sql('row_number() over (partition by domain)')) }
   scope :silenced, -> { where(silenced: true) }
   scope :suspended, -> { where(suspended: true) }
+  scope :without_suspended, -> { where(suspended: false) }
   scope :recent, -> { reorder(id: :desc) }
   scope :alphabetic, -> { order(domain: :asc, username: :asc) }
   scope :by_domain_accounts, -> { group(:domain).select(:domain, 'COUNT(*) AS accounts_count').order('accounts_count desc') }
@@ -275,6 +276,10 @@ class Account < ApplicationRecord
       @value   = attr['value']
       @errors  = {}
     end
+
+    def to_h
+      { name: @name, value: @value }
+    end
   end
 
   class << self
@@ -393,7 +398,7 @@ class Account < ApplicationRecord
   end
 
   def emojis
-    CustomEmoji.from_text(note, domain)
+    @emojis ||= CustomEmoji.from_text(note, domain)
   end
 
   before_create :generate_keys
@@ -408,9 +413,9 @@ class Account < ApplicationRecord
   end
 
   def generate_keys
-    return unless local?
+    return unless local? && !Rails.env.test?
 
-    keypair = OpenSSL::PKey::RSA.new(Rails.env.test? ? 512 : 2048)
+    keypair = OpenSSL::PKey::RSA.new(2048)
     self.private_key = keypair.to_pem
     self.public_key  = keypair.public_key.to_pem
   end
diff --git a/app/models/account_domain_block.rb b/app/models/account_domain_block.rb
index bc00b4f32..e352000c3 100644
--- a/app/models/account_domain_block.rb
+++ b/app/models/account_domain_block.rb
@@ -3,11 +3,11 @@
 #
 # Table name: account_domain_blocks
 #
-#  id         :integer          not null, primary key
+#  id         :bigint(8)        not null, primary key
 #  domain     :string
 #  created_at :datetime         not null
 #  updated_at :datetime         not null
-#  account_id :integer
+#  account_id :bigint(8)
 #
 
 class AccountDomainBlock < ApplicationRecord
diff --git a/app/models/account_moderation_note.rb b/app/models/account_moderation_note.rb
index 3ac9b1ac1..22e312bb2 100644
--- a/app/models/account_moderation_note.rb
+++ b/app/models/account_moderation_note.rb
@@ -3,10 +3,10 @@
 #
 # Table name: account_moderation_notes
 #
-#  id                :integer          not null, primary key
+#  id                :bigint(8)        not null, primary key
 #  content           :text             not null
-#  account_id        :integer          not null
-#  target_account_id :integer          not null
+#  account_id        :bigint(8)        not null
+#  target_account_id :bigint(8)        not null
 #  created_at        :datetime         not null
 #  updated_at        :datetime         not null
 #
diff --git a/app/models/admin/action_log.rb b/app/models/admin/action_log.rb
index 81f278e07..1d1db1b7a 100644
--- a/app/models/admin/action_log.rb
+++ b/app/models/admin/action_log.rb
@@ -3,11 +3,11 @@
 #
 # Table name: admin_action_logs
 #
-#  id               :integer          not null, primary key
-#  account_id       :integer
+#  id               :bigint(8)        not null, primary key
+#  account_id       :bigint(8)
 #  action           :string           default(""), not null
 #  target_type      :string
-#  target_id        :integer
+#  target_id        :bigint(8)
 #  recorded_changes :text             default(""), not null
 #  created_at       :datetime         not null
 #  updated_at       :datetime         not null
diff --git a/app/models/backup.rb b/app/models/backup.rb
index 5a7e6a14d..c2651313b 100644
--- a/app/models/backup.rb
+++ b/app/models/backup.rb
@@ -3,8 +3,8 @@
 #
 # Table name: backups
 #
-#  id                :integer          not null, primary key
-#  user_id           :integer
+#  id                :bigint(8)        not null, primary key
+#  user_id           :bigint(8)
 #  dump_file_name    :string
 #  dump_content_type :string
 #  dump_file_size    :integer
diff --git a/app/models/block.rb b/app/models/block.rb
index d6ecabd3b..df4a6bbac 100644
--- a/app/models/block.rb
+++ b/app/models/block.rb
@@ -3,11 +3,11 @@
 #
 # Table name: blocks
 #
-#  id                :integer          not null, primary key
+#  id                :bigint(8)        not null, primary key
 #  created_at        :datetime         not null
 #  updated_at        :datetime         not null
-#  account_id        :integer          not null
-#  target_account_id :integer          not null
+#  account_id        :bigint(8)        not null
+#  target_account_id :bigint(8)        not null
 #
 
 class Block < ApplicationRecord
diff --git a/app/models/concerns/account_interactions.rb b/app/models/concerns/account_interactions.rb
index 3830ba9b0..20fc74ba6 100644
--- a/app/models/concerns/account_interactions.rb
+++ b/app/models/concerns/account_interactions.rb
@@ -20,6 +20,10 @@ module AccountInteractions
       follow_mapping(Block.where(target_account_id: target_account_ids, account_id: account_id), :target_account_id)
     end
 
+    def blocked_by_map(target_account_ids, account_id)
+      follow_mapping(Block.where(account_id: target_account_ids, target_account_id: account_id), :account_id)
+    end
+
     def muting_map(target_account_ids, account_id)
       Mute.where(target_account_id: target_account_ids, account_id: account_id).each_with_object({}) do |mute, mapping|
         mapping[mute.target_account_id] = {
@@ -38,8 +42,12 @@ module AccountInteractions
 
     def domain_blocking_map(target_account_ids, account_id)
       accounts_map    = Account.where(id: target_account_ids).select('id, domain').map { |a| [a.id, a.domain] }.to_h
-      blocked_domains = AccountDomainBlock.where(account_id: account_id, domain: accounts_map.values).pluck(:domain)
-      accounts_map.map { |id, domain| [id, blocked_domains.include?(domain)] }.to_h
+      blocked_domains = domain_blocking_map_by_domain(accounts_map.values.compact, account_id)
+      accounts_map.map { |id, domain| [id, blocked_domains[domain]] }.to_h
+    end
+
+    def domain_blocking_map_by_domain(target_domains, account_id)
+      follow_mapping(AccountDomainBlock.where(account_id: account_id, domain: target_domains), :domain)
     end
 
     private
@@ -93,6 +101,7 @@ module AccountInteractions
     if mute.hide_notifications? != notifications
       mute.update!(hide_notifications: notifications)
     end
+    mute
   end
 
   def mute_conversation!(conversation)
diff --git a/app/models/concerns/attachmentable.rb b/app/models/concerns/attachmentable.rb
index 90ce88463..6f8489b89 100644
--- a/app/models/concerns/attachmentable.rb
+++ b/app/models/concerns/attachmentable.rb
@@ -1,10 +1,15 @@
 # frozen_string_literal: true
 
+require 'mime/types'
+
 module Attachmentable
   extend ActiveSupport::Concern
 
+  MAX_MATRIX_LIMIT = 16_777_216 # 4096x4096px or approx. 16MB
+
   included do
     before_post_process :set_file_extensions
+    before_post_process :check_image_dimensions
   end
 
   private
@@ -12,10 +17,31 @@ module Attachmentable
   def set_file_extensions
     self.class.attachment_definitions.each_key do |attachment_name|
       attachment = send(attachment_name)
+
       next if attachment.blank?
-      extension = Paperclip::Interpolations.content_type_extension(attachment, :original)
-      basename  = Paperclip::Interpolations.basename(attachment, :original)
-      attachment.instance_write :file_name, [basename, extension].delete_if(&:blank?).join('.')
+
+      attachment.instance_write :file_name, [Paperclip::Interpolations.basename(attachment, :original), appropriate_extension(attachment)].delete_if(&:blank?).join('.')
+    end
+  end
+
+  def check_image_dimensions
+    self.class.attachment_definitions.each_key do |attachment_name|
+      attachment = send(attachment_name)
+
+      next if attachment.blank? || !attachment.content_type.match?(/image.*/) || attachment.queued_for_write[:original].blank?
+
+      width, height = FastImage.size(attachment.queued_for_write[:original].path)
+
+      raise Mastodon::DimensionsValidationError, "#{width}x#{height} images are not supported" if width.present? && height.present? && (width * height >= MAX_MATRIX_LIMIT)
     end
   end
+
+  def appropriate_extension(attachment)
+    mime_type = MIME::Types[attachment.content_type]
+
+    extensions_for_mime_type = mime_type.empty? ? [] : mime_type.first.extensions
+    original_extension       = Paperclip::Interpolations.extension(attachment, :original)
+
+    extensions_for_mime_type.include?(original_extension) ? original_extension : extensions_for_mime_type.first
+  end
 end
diff --git a/app/models/concerns/cacheable.rb b/app/models/concerns/cacheable.rb
index 51451d260..d7524cdfd 100644
--- a/app/models/concerns/cacheable.rb
+++ b/app/models/concerns/cacheable.rb
@@ -3,14 +3,19 @@
 module Cacheable
   extend ActiveSupport::Concern
 
-  class_methods do
+  module ClassMethods
+    @cache_associated = []
+
     def cache_associated(*associations)
       @cache_associated = associations
     end
-  end
 
-  included do
-    scope :with_includes, -> { includes(@cache_associated) }
-    scope :cache_ids, -> { select(:id, :updated_at) }
+    def with_includes
+      includes(@cache_associated)
+    end
+
+    def cache_ids
+      select(:id, :updated_at)
+    end
   end
 end
diff --git a/app/models/concerns/remotable.rb b/app/models/concerns/remotable.rb
index 3b8c507c3..7f1ef5191 100644
--- a/app/models/concerns/remotable.rb
+++ b/app/models/concerns/remotable.rb
@@ -38,7 +38,7 @@ module Remotable
 
             self[attribute_name] = url if has_attribute?(attribute_name)
           end
-        rescue HTTP::TimeoutError, HTTP::ConnectionError, OpenSSL::SSL::SSLError, Paperclip::Errors::NotIdentifiedByImageMagickError, Addressable::URI::InvalidURIError, Mastodon::HostValidationError => e
+        rescue HTTP::TimeoutError, HTTP::ConnectionError, OpenSSL::SSL::SSLError, Paperclip::Errors::NotIdentifiedByImageMagickError, Addressable::URI::InvalidURIError, Mastodon::HostValidationError, Mastodon::LengthValidationError => e
           Rails.logger.debug "Error fetching remote #{attachment_name}: #{e}"
           nil
         end
diff --git a/app/models/concerns/status_threading_concern.rb b/app/models/concerns/status_threading_concern.rb
index fffc095ee..8e817be00 100644
--- a/app/models/concerns/status_threading_concern.rb
+++ b/app/models/concerns/status_threading_concern.rb
@@ -7,8 +7,8 @@ module StatusThreadingConcern
     find_statuses_from_tree_path(ancestor_ids(limit), account)
   end
 
-  def descendants(account = nil)
-    find_statuses_from_tree_path(descendant_ids, account)
+  def descendants(limit, account = nil, max_child_id = nil, since_child_id = nil, depth = nil)
+    find_statuses_from_tree_path(descendant_ids(limit, max_child_id, since_child_id, depth), account)
   end
 
   private
@@ -46,34 +46,46 @@ module StatusThreadingConcern
     SQL
   end
 
-  def descendant_ids
-    descendant_statuses.pluck(:id)
+  def descendant_ids(limit, max_child_id, since_child_id, depth)
+    descendant_statuses(limit, max_child_id, since_child_id, depth).pluck(:id)
   end
 
-  def descendant_statuses
-    Status.find_by_sql([<<-SQL.squish, id: id])
+  def descendant_statuses(limit, max_child_id, since_child_id, depth)
+    Status.find_by_sql([<<-SQL.squish, id: id, limit: limit, max_child_id: max_child_id, since_child_id: since_child_id, depth: depth])
       WITH RECURSIVE search_tree(id, path)
       AS (
         SELECT id, ARRAY[id]
         FROM statuses
-        WHERE in_reply_to_id = :id
+        WHERE in_reply_to_id = :id AND COALESCE(id < :max_child_id, TRUE) AND COALESCE(id > :since_child_id, TRUE)
         UNION ALL
         SELECT statuses.id, path || statuses.id
         FROM search_tree
         JOIN statuses ON statuses.in_reply_to_id = search_tree.id
-        WHERE NOT statuses.id = ANY(path)
+        WHERE COALESCE(array_length(path, 1) < :depth, TRUE) AND NOT statuses.id = ANY(path)
       )
       SELECT id
       FROM search_tree
       ORDER BY path
+      LIMIT :limit
     SQL
   end
 
   def find_statuses_from_tree_path(ids, account)
-    statuses = statuses_with_accounts(ids).to_a
+    statuses    = statuses_with_accounts(ids).to_a
+    account_ids = statuses.map(&:account_id).uniq
+    domains     = statuses.map(&:account_domain).compact.uniq
+
+    relations = if account.present?
+                  {
+                    blocking: Account.blocking_map(account_ids, account.id),
+                    blocked_by: Account.blocked_by_map(account_ids, account.id),
+                    muting: Account.muting_map(account_ids, account.id),
+                    following: Account.following_map(account_ids, account.id),
+                    domain_blocking_by_domain: Account.domain_blocking_map_by_domain(domains, account.id),
+                  }
+                end
 
-    # FIXME: n+1 bonanza
-    statuses.reject! { |status| filter_from_context?(status, account) }
+    statuses.reject! { |status| filter_from_context?(status, account, relations) }
 
     # Order ancestors/descendants by tree path
     statuses.sort_by! { |status| ids.index(status.id) }
@@ -83,7 +95,7 @@ module StatusThreadingConcern
     Status.where(id: ids).includes(:account)
   end
 
-  def filter_from_context?(status, account)
-    StatusFilter.new(status, account).filtered?
+  def filter_from_context?(status, account, relations)
+    StatusFilter.new(status, account, relations).filtered?
   end
 end
diff --git a/app/models/conversation.rb b/app/models/conversation.rb
index 08c1ce945..4dfaea889 100644
--- a/app/models/conversation.rb
+++ b/app/models/conversation.rb
@@ -3,7 +3,7 @@
 #
 # Table name: conversations
 #
-#  id         :integer          not null, primary key
+#  id         :bigint(8)        not null, primary key
 #  uri        :string
 #  created_at :datetime         not null
 #  updated_at :datetime         not null
diff --git a/app/models/conversation_mute.rb b/app/models/conversation_mute.rb
index 272eb81af..52c1a33e0 100644
--- a/app/models/conversation_mute.rb
+++ b/app/models/conversation_mute.rb
@@ -3,9 +3,9 @@
 #
 # Table name: conversation_mutes
 #
-#  id              :integer          not null, primary key
-#  conversation_id :integer          not null
-#  account_id      :integer          not null
+#  id              :bigint(8)        not null, primary key
+#  conversation_id :bigint(8)        not null
+#  account_id      :bigint(8)        not null
 #
 
 class ConversationMute < ApplicationRecord
diff --git a/app/models/custom_emoji.rb b/app/models/custom_emoji.rb
index 1ec21d1a0..b99ed01f0 100644
--- a/app/models/custom_emoji.rb
+++ b/app/models/custom_emoji.rb
@@ -3,7 +3,7 @@
 #
 # Table name: custom_emojis
 #
-#  id                 :integer          not null, primary key
+#  id                 :bigint(8)        not null, primary key
 #  shortcode          :string           default(""), not null
 #  domain             :string
 #  image_file_name    :string
@@ -40,6 +40,10 @@ class CustomEmoji < ApplicationRecord
 
   remotable_attachment :image, LIMIT
 
+  include Attachmentable
+
+  after_commit :remove_entity_cache
+
   def local?
     domain.nil?
   end
@@ -56,11 +60,17 @@ class CustomEmoji < ApplicationRecord
 
       return [] if shortcodes.empty?
 
-      where(shortcode: shortcodes, domain: domain, disabled: false)
+      EntityCache.instance.emoji(shortcodes, domain)
     end
 
     def search(shortcode)
       where('"custom_emojis"."shortcode" ILIKE ?', "%#{shortcode}%")
     end
   end
+
+  private
+
+  def remove_entity_cache
+    Rails.cache.delete(EntityCache.instance.to_key(:emoji, shortcode, domain))
+  end
 end
diff --git a/app/models/domain_block.rb b/app/models/domain_block.rb
index aea8919af..93658793b 100644
--- a/app/models/domain_block.rb
+++ b/app/models/domain_block.rb
@@ -3,7 +3,7 @@
 #
 # Table name: domain_blocks
 #
-#  id           :integer          not null, primary key
+#  id           :bigint(8)        not null, primary key
 #  domain       :string           default(""), not null
 #  created_at   :datetime         not null
 #  updated_at   :datetime         not null
diff --git a/app/models/email_domain_block.rb b/app/models/email_domain_block.rb
index a104810d1..10490375b 100644
--- a/app/models/email_domain_block.rb
+++ b/app/models/email_domain_block.rb
@@ -3,7 +3,7 @@
 #
 # Table name: email_domain_blocks
 #
-#  id         :integer          not null, primary key
+#  id         :bigint(8)        not null, primary key
 #  domain     :string           default(""), not null
 #  created_at :datetime         not null
 #  updated_at :datetime         not null
diff --git a/app/models/favourite.rb b/app/models/favourite.rb
index fa1884b86..c998a67eb 100644
--- a/app/models/favourite.rb
+++ b/app/models/favourite.rb
@@ -3,11 +3,11 @@
 #
 # Table name: favourites
 #
-#  id         :integer          not null, primary key
+#  id         :bigint(8)        not null, primary key
 #  created_at :datetime         not null
 #  updated_at :datetime         not null
-#  account_id :integer          not null
-#  status_id  :integer          not null
+#  account_id :bigint(8)        not null
+#  status_id  :bigint(8)        not null
 #
 
 class Favourite < ApplicationRecord
diff --git a/app/models/follow.rb b/app/models/follow.rb
index 8e6fe537a..2ca42ff70 100644
--- a/app/models/follow.rb
+++ b/app/models/follow.rb
@@ -3,11 +3,11 @@
 #
 # Table name: follows
 #
-#  id                :integer          not null, primary key
+#  id                :bigint(8)        not null, primary key
 #  created_at        :datetime         not null
 #  updated_at        :datetime         not null
-#  account_id        :integer          not null
-#  target_account_id :integer          not null
+#  account_id        :bigint(8)        not null
+#  target_account_id :bigint(8)        not null
 #  show_reblogs      :boolean          default(TRUE), not null
 #
 
diff --git a/app/models/follow_request.rb b/app/models/follow_request.rb
index cde26ceed..d559a8f62 100644
--- a/app/models/follow_request.rb
+++ b/app/models/follow_request.rb
@@ -3,11 +3,11 @@
 #
 # Table name: follow_requests
 #
-#  id                :integer          not null, primary key
+#  id                :bigint(8)        not null, primary key
 #  created_at        :datetime         not null
 #  updated_at        :datetime         not null
-#  account_id        :integer          not null
-#  target_account_id :integer          not null
+#  account_id        :bigint(8)        not null
+#  target_account_id :bigint(8)        not null
 #  show_reblogs      :boolean          default(TRUE), not null
 #
 
diff --git a/app/models/import.rb b/app/models/import.rb
index fdb4c6b80..55e970b0d 100644
--- a/app/models/import.rb
+++ b/app/models/import.rb
@@ -3,7 +3,7 @@
 #
 # Table name: imports
 #
-#  id                :integer          not null, primary key
+#  id                :bigint(8)        not null, primary key
 #  type              :integer          not null
 #  approved          :boolean          default(FALSE), not null
 #  created_at        :datetime         not null
@@ -12,7 +12,7 @@
 #  data_content_type :string
 #  data_file_size    :integer
 #  data_updated_at   :datetime
-#  account_id        :integer          not null
+#  account_id        :bigint(8)        not null
 #
 
 class Import < ApplicationRecord
diff --git a/app/models/invite.rb b/app/models/invite.rb
index 4ba5432d2..2250e588e 100644
--- a/app/models/invite.rb
+++ b/app/models/invite.rb
@@ -3,8 +3,8 @@
 #
 # Table name: invites
 #
-#  id         :integer          not null, primary key
-#  user_id    :integer          not null
+#  id         :bigint(8)        not null, primary key
+#  user_id    :bigint(8)        not null
 #  code       :string           default(""), not null
 #  expires_at :datetime
 #  max_uses   :integer
diff --git a/app/models/list.rb b/app/models/list.rb
index a2ec7e84a..c9c94fca1 100644
--- a/app/models/list.rb
+++ b/app/models/list.rb
@@ -3,8 +3,8 @@
 #
 # Table name: lists
 #
-#  id         :integer          not null, primary key
-#  account_id :integer          not null
+#  id         :bigint(8)        not null, primary key
+#  account_id :bigint(8)        not null
 #  title      :string           default(""), not null
 #  created_at :datetime         not null
 #  updated_at :datetime         not null
diff --git a/app/models/list_account.rb b/app/models/list_account.rb
index da46cf032..87b498224 100644
--- a/app/models/list_account.rb
+++ b/app/models/list_account.rb
@@ -3,10 +3,10 @@
 #
 # Table name: list_accounts
 #
-#  id         :integer          not null, primary key
-#  list_id    :integer          not null
-#  account_id :integer          not null
-#  follow_id  :integer          not null
+#  id         :bigint(8)        not null, primary key
+#  list_id    :bigint(8)        not null
+#  account_id :bigint(8)        not null
+#  follow_id  :bigint(8)        not null
 #
 
 class ListAccount < ApplicationRecord
diff --git a/app/models/media_attachment.rb b/app/models/media_attachment.rb
index 3b16944ce..62abc876e 100644
--- a/app/models/media_attachment.rb
+++ b/app/models/media_attachment.rb
@@ -3,8 +3,8 @@
 #
 # Table name: media_attachments
 #
-#  id                :integer          not null, primary key
-#  status_id         :integer
+#  id                :bigint(8)        not null, primary key
+#  status_id         :bigint(8)
 #  file_file_name    :string
 #  file_content_type :string
 #  file_file_size    :integer
@@ -15,12 +15,10 @@
 #  shortcode         :string
 #  type              :integer          default("image"), not null
 #  file_meta         :json
-#  account_id        :integer
+#  account_id        :bigint(8)
 #  description       :text
 #
 
-require 'mime/types'
-
 class MediaAttachment < ApplicationRecord
   self.inheritance_column = nil
 
@@ -90,6 +88,8 @@ class MediaAttachment < ApplicationRecord
   validates_attachment_size :file, less_than: LIMIT
   remotable_attachment :file, LIMIT
 
+  include Attachmentable
+
   validates :account, presence: true
   validates :description, length: { maximum: 420 }, if: :local?
 
@@ -247,13 +247,4 @@ class MediaAttachment < ApplicationRecord
       bitrate: movie.bitrate,
     }
   end
-
-  def appropriate_extension
-    mime_type = MIME::Types[file.content_type]
-
-    extensions_for_mime_type = mime_type.empty? ? [] : mime_type.first.extensions
-    original_extension       = Paperclip::Interpolations.extension(file, :original)
-
-    extensions_for_mime_type.include?(original_extension) ? original_extension : extensions_for_mime_type.first
-  end
 end
diff --git a/app/models/mention.rb b/app/models/mention.rb
index f864bf8e1..8ab886b18 100644
--- a/app/models/mention.rb
+++ b/app/models/mention.rb
@@ -3,11 +3,11 @@
 #
 # Table name: mentions
 #
-#  id         :integer          not null, primary key
-#  status_id  :integer
+#  id         :bigint(8)        not null, primary key
+#  status_id  :bigint(8)
 #  created_at :datetime         not null
 #  updated_at :datetime         not null
-#  account_id :integer
+#  account_id :bigint(8)
 #
 
 class Mention < ApplicationRecord
diff --git a/app/models/mute.rb b/app/models/mute.rb
index ebb3818c7..639120f7d 100644
--- a/app/models/mute.rb
+++ b/app/models/mute.rb
@@ -3,12 +3,12 @@
 #
 # Table name: mutes
 #
-#  id                 :integer          not null, primary key
+#  id                 :bigint(8)        not null, primary key
 #  created_at         :datetime         not null
 #  updated_at         :datetime         not null
 #  hide_notifications :boolean          default(TRUE), not null
-#  account_id         :integer          not null
-#  target_account_id  :integer          not null
+#  account_id         :bigint(8)        not null
+#  target_account_id  :bigint(8)        not null
 #
 
 class Mute < ApplicationRecord
diff --git a/app/models/notification.rb b/app/models/notification.rb
index 0b0f01aa8..4f6ec8e8e 100644
--- a/app/models/notification.rb
+++ b/app/models/notification.rb
@@ -3,13 +3,13 @@
 #
 # Table name: notifications
 #
-#  id              :integer          not null, primary key
-#  activity_id     :integer          not null
+#  id              :bigint(8)        not null, primary key
+#  activity_id     :bigint(8)        not null
 #  activity_type   :string           not null
 #  created_at      :datetime         not null
 #  updated_at      :datetime         not null
-#  account_id      :integer          not null
-#  from_account_id :integer          not null
+#  account_id      :bigint(8)        not null
+#  from_account_id :bigint(8)        not null
 #
 
 class Notification < ApplicationRecord
diff --git a/app/models/preview_card.rb b/app/models/preview_card.rb
index 0c82f06ce..a792b352b 100644
--- a/app/models/preview_card.rb
+++ b/app/models/preview_card.rb
@@ -3,7 +3,7 @@
 #
 # Table name: preview_cards
 #
-#  id                 :integer          not null, primary key
+#  id                 :bigint(8)        not null, primary key
 #  url                :string           default(""), not null
 #  title              :string           default(""), not null
 #  description        :string           default(""), not null
@@ -34,7 +34,7 @@ class PreviewCard < ApplicationRecord
 
   has_and_belongs_to_many :statuses
 
-  has_attached_file :image, styles: { original: { geometry: '400x400>', file_geometry_parser: FastGeometryParser } }, convert_options: { all: '-quality 80 -strip' }
+  has_attached_file :image, styles: ->(f) { image_styles(f) }, convert_options: { all: '-quality 80 -strip' }
 
   include Attachmentable
 
@@ -52,6 +52,23 @@ class PreviewCard < ApplicationRecord
     save!
   end
 
+  class << self
+    private
+
+    def image_styles(f)
+      styles = {
+        original: {
+          geometry: '400x400>',
+          file_geometry_parser: FastGeometryParser,
+          convert_options: '-coalesce -strip',
+        },
+      }
+
+      styles[:original][:format] = 'jpg' if f.instance.image_content_type == 'image/gif'
+      styles
+    end
+  end
+
   private
 
   def extract_dimensions
diff --git a/app/models/report.rb b/app/models/report.rb
index 5b90c7bce..efe385b2d 100644
--- a/app/models/report.rb
+++ b/app/models/report.rb
@@ -3,16 +3,16 @@
 #
 # Table name: reports
 #
-#  id                         :integer          not null, primary key
-#  status_ids                 :integer          default([]), not null, is an Array
+#  id                         :bigint(8)        not null, primary key
+#  status_ids                 :bigint(8)        default([]), not null, is an Array
 #  comment                    :text             default(""), not null
 #  action_taken               :boolean          default(FALSE), not null
 #  created_at                 :datetime         not null
 #  updated_at                 :datetime         not null
-#  account_id                 :integer          not null
-#  action_taken_by_account_id :integer
-#  target_account_id          :integer          not null
-#  assigned_account_id        :integer
+#  account_id                 :bigint(8)        not null
+#  action_taken_by_account_id :bigint(8)
+#  target_account_id          :bigint(8)        not null
+#  assigned_account_id        :bigint(8)
 #
 
 class Report < ApplicationRecord
diff --git a/app/models/report_note.rb b/app/models/report_note.rb
index 6d9dec80a..54b416577 100644
--- a/app/models/report_note.rb
+++ b/app/models/report_note.rb
@@ -3,10 +3,10 @@
 #
 # Table name: report_notes
 #
-#  id         :integer          not null, primary key
+#  id         :bigint(8)        not null, primary key
 #  content    :text             not null
-#  report_id  :integer          not null
-#  account_id :integer          not null
+#  report_id  :bigint(8)        not null
+#  account_id :bigint(8)        not null
 #  created_at :datetime         not null
 #  updated_at :datetime         not null
 #
diff --git a/app/models/session_activation.rb b/app/models/session_activation.rb
index d364f03df..34d25c83d 100644
--- a/app/models/session_activation.rb
+++ b/app/models/session_activation.rb
@@ -3,15 +3,15 @@
 #
 # Table name: session_activations
 #
-#  id                       :integer          not null, primary key
+#  id                       :bigint(8)        not null, primary key
 #  session_id               :string           not null
 #  created_at               :datetime         not null
 #  updated_at               :datetime         not null
 #  user_agent               :string           default(""), not null
 #  ip                       :inet
-#  access_token_id          :integer
-#  user_id                  :integer          not null
-#  web_push_subscription_id :integer
+#  access_token_id          :bigint(8)
+#  user_id                  :bigint(8)        not null
+#  web_push_subscription_id :bigint(8)
 #
 
 class SessionActivation < ApplicationRecord
diff --git a/app/models/setting.rb b/app/models/setting.rb
index df93590ce..033d09fd5 100644
--- a/app/models/setting.rb
+++ b/app/models/setting.rb
@@ -3,13 +3,13 @@
 #
 # Table name: settings
 #
-#  id         :integer          not null, primary key
+#  id         :bigint(8)        not null, primary key
 #  var        :string           not null
 #  value      :text
 #  thing_type :string
 #  created_at :datetime
 #  updated_at :datetime
-#  thing_id   :integer
+#  thing_id   :bigint(8)
 #
 
 class Setting < RailsSettings::Base
diff --git a/app/models/site_upload.rb b/app/models/site_upload.rb
index 641128adf..14d683767 100644
--- a/app/models/site_upload.rb
+++ b/app/models/site_upload.rb
@@ -3,7 +3,7 @@
 #
 # Table name: site_uploads
 #
-#  id                :integer          not null, primary key
+#  id                :bigint(8)        not null, primary key
 #  var               :string           default(""), not null
 #  file_file_name    :string
 #  file_content_type :string
diff --git a/app/models/status.rb b/app/models/status.rb
index 952661169..44238ca6b 100644
--- a/app/models/status.rb
+++ b/app/models/status.rb
@@ -3,13 +3,13 @@
 #
 # Table name: statuses
 #
-#  id                     :integer          not null, primary key
+#  id                     :bigint(8)        not null, primary key
 #  uri                    :string
 #  text                   :text             default(""), not null
 #  created_at             :datetime         not null
 #  updated_at             :datetime         not null
-#  in_reply_to_id         :integer
-#  reblog_of_id           :integer
+#  in_reply_to_id         :bigint(8)
+#  reblog_of_id           :bigint(8)
 #  url                    :string
 #  sensitive              :boolean          default(FALSE), not null
 #  visibility             :integer          default("public"), not null
@@ -18,11 +18,11 @@
 #  favourites_count       :integer          default(0), not null
 #  reblogs_count          :integer          default(0), not null
 #  language               :string
-#  conversation_id        :integer
+#  conversation_id        :bigint(8)
 #  local                  :boolean
-#  account_id             :integer          not null
-#  application_id         :integer
-#  in_reply_to_account_id :integer
+#  account_id             :bigint(8)        not null
+#  application_id         :bigint(8)
+#  in_reply_to_account_id :bigint(8)
 #  local_only             :boolean
 #  full_status_text       :text             default(""), not null
 #
@@ -62,6 +62,7 @@ class Status < ApplicationRecord
   validates :uri, uniqueness: true, presence: true, unless: :local?
   validates :text, presence: true, unless: -> { with_media? || reblog? }
   validates_with StatusLengthValidator
+  validates_with DisallowedHashtagsValidator
   validates :reblog, uniqueness: { scope: :account }, if: :reblog?
 
   default_scope { recent }
@@ -164,7 +165,7 @@ class Status < ApplicationRecord
   end
 
   def emojis
-    CustomEmoji.from_text([spoiler_text, text].join(' '), account.domain)
+    @emojis ||= CustomEmoji.from_text([spoiler_text, text].join(' '), account.domain)
   end
 
   after_create_commit :store_uri, if: :local?
diff --git a/app/models/status_pin.rb b/app/models/status_pin.rb
index d3a98d8bd..afc76bded 100644
--- a/app/models/status_pin.rb
+++ b/app/models/status_pin.rb
@@ -3,9 +3,9 @@
 #
 # Table name: status_pins
 #
-#  id         :integer          not null, primary key
-#  account_id :integer          not null
-#  status_id  :integer          not null
+#  id         :bigint(8)        not null, primary key
+#  account_id :bigint(8)        not null
+#  status_id  :bigint(8)        not null
 #  created_at :datetime         not null
 #  updated_at :datetime         not null
 #
diff --git a/app/models/stream_entry.rb b/app/models/stream_entry.rb
index 36fe487dc..dd383eb81 100644
--- a/app/models/stream_entry.rb
+++ b/app/models/stream_entry.rb
@@ -3,13 +3,13 @@
 #
 # Table name: stream_entries
 #
-#  id            :integer          not null, primary key
-#  activity_id   :integer
+#  id            :bigint(8)        not null, primary key
+#  activity_id   :bigint(8)
 #  activity_type :string
 #  created_at    :datetime         not null
 #  updated_at    :datetime         not null
 #  hidden        :boolean          default(FALSE), not null
-#  account_id    :integer
+#  account_id    :bigint(8)
 #
 
 class StreamEntry < ApplicationRecord
diff --git a/app/models/subscription.rb b/app/models/subscription.rb
index ea1173160..79b81828d 100644
--- a/app/models/subscription.rb
+++ b/app/models/subscription.rb
@@ -3,7 +3,7 @@
 #
 # Table name: subscriptions
 #
-#  id                          :integer          not null, primary key
+#  id                          :bigint(8)        not null, primary key
 #  callback_url                :string           default(""), not null
 #  secret                      :string
 #  expires_at                  :datetime
@@ -12,7 +12,7 @@
 #  updated_at                  :datetime         not null
 #  last_successful_delivery_at :datetime
 #  domain                      :string
-#  account_id                  :integer          not null
+#  account_id                  :bigint(8)        not null
 #
 
 class Subscription < ApplicationRecord
diff --git a/app/models/tag.rb b/app/models/tag.rb
index 9fa9405d7..8b1b02412 100644
--- a/app/models/tag.rb
+++ b/app/models/tag.rb
@@ -3,7 +3,7 @@
 #
 # Table name: tags
 #
-#  id         :integer          not null, primary key
+#  id         :bigint(8)        not null, primary key
 #  name       :string           default(""), not null
 #  created_at :datetime         not null
 #  updated_at :datetime         not null
diff --git a/app/models/user.rb b/app/models/user.rb
index 803eb8a33..24beb77b2 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -3,7 +3,7 @@
 #
 # Table name: users
 #
-#  id                        :integer          not null, primary key
+#  id                        :bigint(8)        not null, primary key
 #  email                     :string           default(""), not null
 #  created_at                :datetime         not null
 #  updated_at                :datetime         not null
@@ -30,10 +30,10 @@
 #  last_emailed_at           :datetime
 #  otp_backup_codes          :string           is an Array
 #  filtered_languages        :string           default([]), not null, is an Array
-#  account_id                :integer          not null
+#  account_id                :bigint(8)        not null
 #  disabled                  :boolean          default(FALSE), not null
 #  moderator                 :boolean          default(FALSE), not null
-#  invite_id                 :integer
+#  invite_id                 :bigint(8)
 #  remember_token            :string
 #
 
diff --git a/app/models/web/push_subscription.rb b/app/models/web/push_subscription.rb
index 5aee92d27..1736106f7 100644
--- a/app/models/web/push_subscription.rb
+++ b/app/models/web/push_subscription.rb
@@ -3,7 +3,7 @@
 #
 # Table name: web_push_subscriptions
 #
-#  id         :integer          not null, primary key
+#  id         :bigint(8)        not null, primary key
 #  endpoint   :string           not null
 #  key_p256dh :string           not null
 #  key_auth   :string           not null
diff --git a/app/models/web/setting.rb b/app/models/web/setting.rb
index 0a5129d17..99588d26c 100644
--- a/app/models/web/setting.rb
+++ b/app/models/web/setting.rb
@@ -3,11 +3,11 @@
 #
 # Table name: web_settings
 #
-#  id         :integer          not null, primary key
+#  id         :bigint(8)        not null, primary key
 #  data       :json
 #  created_at :datetime         not null
 #  updated_at :datetime         not null
-#  user_id    :integer          not null
+#  user_id    :bigint(8)        not null
 #
 
 class Web::Setting < ApplicationRecord
diff --git a/app/policies/status_policy.rb b/app/policies/status_policy.rb
index 307876856..96cdee8c7 100644
--- a/app/policies/status_policy.rb
+++ b/app/policies/status_policy.rb
@@ -1,6 +1,12 @@
 # frozen_string_literal: true
 
 class StatusPolicy < ApplicationPolicy
+  def initialize(current_account, record, preloaded_relations = {})
+    super(current_account, record)
+
+    @preloaded_relations = preloaded_relations
+  end
+
   def index?
     staff?
   end
@@ -9,16 +15,20 @@ class StatusPolicy < ApplicationPolicy
     return false if local_only? && current_account.nil?
 
     if direct?
-      owned? || record.mentions.where(account: current_account).exists?
+      owned? || mention_exists?
     elsif private?
-      owned? || current_account&.following?(author) || record.mentions.where(account: current_account).exists?
+      owned? || following_author? || mention_exists?
     else
-      current_account.nil? || !author.blocking?(current_account)
+      current_account.nil? || !author_blocking?
     end
   end
 
   def reblog?
-    !direct? && (!private? || owned?) && show?
+    !direct? && (!private? || owned?) && show? && !blocking_author?
+  end
+
+  def favourite?
+    show? && !blocking_author?
   end
 
   def destroy?
@@ -45,6 +55,34 @@ class StatusPolicy < ApplicationPolicy
     record.private_visibility?
   end
 
+  def mention_exists?
+    return false if current_account.nil?
+
+    if record.mentions.loaded?
+      record.mentions.any? { |mention| mention.account_id == current_account.id }
+    else
+      record.mentions.where(account: current_account).exists?
+    end
+  end
+
+  def blocking_author?
+    return false if current_account.nil?
+
+    @preloaded_relations[:blocking] ? @preloaded_relations[:blocking][author.id] : current_account.blocking?(author)
+  end
+
+  def author_blocking?
+    return false if current_account.nil?
+
+    @preloaded_relations[:blocked_by] ? @preloaded_relations[:blocked_by][author.id] : author.blocking?(current_account)
+  end
+
+  def following_author?
+    return false if current_account.nil?
+
+    @preloaded_relations[:following] ? @preloaded_relations[:following][author.id] : current_account.following?(author)
+  end
+
   def author
     record.account
   end
diff --git a/app/serializers/rest/credential_account_serializer.rb b/app/serializers/rest/credential_account_serializer.rb
index 870d8b71f..56857cba8 100644
--- a/app/serializers/rest/credential_account_serializer.rb
+++ b/app/serializers/rest/credential_account_serializer.rb
@@ -5,10 +5,12 @@ class REST::CredentialAccountSerializer < REST::AccountSerializer
 
   def source
     user = object.user
+
     {
       privacy: user.setting_default_privacy,
       sensitive: user.setting_default_sensitive,
       note: object.note,
+      fields: object.fields.map(&:to_h),
     }
   end
 end
diff --git a/app/serializers/rss/account_serializer.rb b/app/serializers/rss/account_serializer.rb
new file mode 100644
index 000000000..bde360a41
--- /dev/null
+++ b/app/serializers/rss/account_serializer.rb
@@ -0,0 +1,39 @@
+# frozen_string_literal: true
+
+class RSS::AccountSerializer
+  include ActionView::Helpers::NumberHelper
+  include StreamEntriesHelper
+  include RoutingHelper
+
+  def render(account, statuses)
+    builder = RSSBuilder.new
+
+    builder.title("#{display_name(account)} (@#{account.local_username_and_domain})")
+           .description(account_description(account))
+           .link(TagManager.instance.url_for(account))
+           .logo(full_asset_url(asset_pack_path('logo.svg')))
+           .accent_color('2b90d9')
+
+    builder.image(full_asset_url(account.avatar.url(:original))) if account.avatar?
+    builder.cover(full_asset_url(account.header.url(:original))) if account.header?
+
+    statuses.each do |status|
+      builder.item do |item|
+        item.title(status.title)
+            .link(TagManager.instance.url_for(status))
+            .pub_date(status.created_at)
+            .description(status.spoiler_text.presence || Formatter.instance.format(status).to_str)
+
+        status.media_attachments.each do |media|
+          item.enclosure(full_asset_url(media.file.url(:original, false)), media.file.content_type, length: media.file.size)
+        end
+      end
+    end
+
+    builder.to_xml
+  end
+
+  def self.render(account, statuses)
+    new.render(account, statuses)
+  end
+end
diff --git a/app/serializers/rss/tag_serializer.rb b/app/serializers/rss/tag_serializer.rb
new file mode 100644
index 000000000..7680a8da5
--- /dev/null
+++ b/app/serializers/rss/tag_serializer.rb
@@ -0,0 +1,37 @@
+# frozen_string_literal: true
+
+class RSS::TagSerializer
+  include ActionView::Helpers::NumberHelper
+  include ActionView::Helpers::SanitizeHelper
+  include StreamEntriesHelper
+  include RoutingHelper
+
+  def render(tag, statuses)
+    builder = RSSBuilder.new
+
+    builder.title("##{tag.name}")
+           .description(strip_tags(I18n.t('about.about_hashtag_html', hashtag: tag.name)))
+           .link(tag_url(tag))
+           .logo(full_asset_url(asset_pack_path('logo.svg')))
+           .accent_color('2b90d9')
+
+    statuses.each do |status|
+      builder.item do |item|
+        item.title(status.title)
+            .link(TagManager.instance.url_for(status))
+            .pub_date(status.created_at)
+            .description(status.spoiler_text.presence || Formatter.instance.format(status).to_str)
+
+        status.media_attachments.each do |media|
+          item.enclosure(full_asset_url(media.file.url(:original, false)), media.file.content_type, length: media.file.size)
+        end
+      end
+    end
+
+    builder.to_xml
+  end
+
+  def self.render(tag, statuses)
+    new.render(tag, statuses)
+  end
+end
diff --git a/app/services/account_search_service.rb b/app/services/account_search_service.rb
index 3860a9cbd..7edbd9b47 100644
--- a/app/services/account_search_service.rb
+++ b/app/services/account_search_service.rb
@@ -65,9 +65,9 @@ class AccountSearchService < BaseService
   def exact_match
     @_exact_match ||= begin
       if domain_is_local?
-        search_from.find_local(query_username)
+        search_from.without_suspended.find_local(query_username)
       else
-        search_from.find_remote(query_username, query_domain)
+        search_from.without_suspended.find_remote(query_username, query_domain)
       end
     end
   end
diff --git a/app/services/activitypub/fetch_featured_collection_service.rb b/app/services/activitypub/fetch_featured_collection_service.rb
index 40714e980..6a137b520 100644
--- a/app/services/activitypub/fetch_featured_collection_service.rb
+++ b/app/services/activitypub/fetch_featured_collection_service.rb
@@ -4,6 +4,8 @@ class ActivityPub::FetchFeaturedCollectionService < BaseService
   include JsonLdHelper
 
   def call(account)
+    return if account.featured_collection_url.blank?
+
     @account = account
     @json    = fetch_resource(@account.featured_collection_url, true)
 
diff --git a/app/services/activitypub/fetch_remote_account_service.rb b/app/services/activitypub/fetch_remote_account_service.rb
index 5024853ca..867e70876 100644
--- a/app/services/activitypub/fetch_remote_account_service.rb
+++ b/app/services/activitypub/fetch_remote_account_service.rb
@@ -56,6 +56,6 @@ class ActivityPub::FetchRemoteAccountService < BaseService
   end
 
   def expected_type?
-    SUPPORTED_TYPES.include?(@json['type'])
+    equals_or_includes_any?(@json['type'], SUPPORTED_TYPES)
   end
 end
diff --git a/app/services/activitypub/fetch_remote_key_service.rb b/app/services/activitypub/fetch_remote_key_service.rb
index 41837d462..505baccd4 100644
--- a/app/services/activitypub/fetch_remote_key_service.rb
+++ b/app/services/activitypub/fetch_remote_key_service.rb
@@ -43,7 +43,7 @@ class ActivityPub::FetchRemoteKeyService < BaseService
   end
 
   def person?
-    ActivityPub::FetchRemoteAccountService::SUPPORTED_TYPES.include?(@json['type'])
+    equals_or_includes_any?(@json['type'], ActivityPub::FetchRemoteAccountService::SUPPORTED_TYPES)
   end
 
   def public_key?
@@ -55,6 +55,6 @@ class ActivityPub::FetchRemoteKeyService < BaseService
   end
 
   def confirmed_owner?
-    ActivityPub::FetchRemoteAccountService::SUPPORTED_TYPES.include?(@owner['type']) && value_or_id(@owner['publicKey']) == @json['id']
+    equals_or_includes_any?(@owner['type'], ActivityPub::FetchRemoteAccountService::SUPPORTED_TYPES) && value_or_id(@owner['publicKey']) == @json['id']
   end
 end
diff --git a/app/services/activitypub/fetch_remote_status_service.rb b/app/services/activitypub/fetch_remote_status_service.rb
index 503c175d8..930fbad1f 100644
--- a/app/services/activitypub/fetch_remote_status_service.rb
+++ b/app/services/activitypub/fetch_remote_status_service.rb
@@ -42,7 +42,7 @@ class ActivityPub::FetchRemoteStatusService < BaseService
   end
 
   def expected_type?
-    (ActivityPub::Activity::Create::SUPPORTED_TYPES + ActivityPub::Activity::Create::CONVERTED_TYPES).include? @json['type']
+    equals_or_includes_any?(@json['type'], ActivityPub::Activity::Create::SUPPORTED_TYPES + ActivityPub::Activity::Create::CONVERTED_TYPES)
   end
 
   def needs_update(actor)
diff --git a/app/services/activitypub/process_account_service.rb b/app/services/activitypub/process_account_service.rb
index da32f9615..f67ebb443 100644
--- a/app/services/activitypub/process_account_service.rb
+++ b/app/services/activitypub/process_account_service.rb
@@ -201,10 +201,7 @@ class ActivityPub::ProcessAccountService < BaseService
     return if @json['tag'].blank?
 
     as_array(@json['tag']).each do |tag|
-      case tag['type']
-      when 'Emoji'
-        process_emoji tag
-      end
+      process_emoji tag if equals_or_includes?(tag['type'], 'Emoji')
     end
   end
 
diff --git a/app/services/fan_out_on_write_service.rb b/app/services/fan_out_on_write_service.rb
index 0f77556dc..510b80c82 100644
--- a/app/services/fan_out_on_write_service.rb
+++ b/app/services/fan_out_on_write_service.rb
@@ -1,7 +1,5 @@
 # frozen_string_literal: true
 
-require 'sidekiq-bulk'
-
 class FanOutOnWriteService < BaseService
   # Push a status into home and mentions feeds
   # @param [Status] status
diff --git a/app/services/favourite_service.rb b/app/services/favourite_service.rb
index 44df3ed13..bc2d1547a 100644
--- a/app/services/favourite_service.rb
+++ b/app/services/favourite_service.rb
@@ -8,7 +8,7 @@ class FavouriteService < BaseService
   # @param [Status] status
   # @return [Favourite]
   def call(account, status)
-    authorize_with account, status, :show?
+    authorize_with account, status, :favourite?
 
     favourite = Favourite.find_by(account: account, status: status)
 
diff --git a/app/services/fetch_atom_service.rb b/app/services/fetch_atom_service.rb
index 0444baf74..550e75f33 100644
--- a/app/services/fetch_atom_service.rb
+++ b/app/services/fetch_atom_service.rb
@@ -42,7 +42,7 @@ class FetchAtomService < BaseService
     elsif ['application/activity+json', 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"'].include?(response.mime_type)
       body = response.body_with_limit
       json = body_to_json(body)
-      if supported_context?(json) && ActivityPub::FetchRemoteAccountService::SUPPORTED_TYPES.include?(json['type']) && json['inbox'].present?
+      if supported_context?(json) && equals_or_includes_any?(json['type'], ActivityPub::FetchRemoteAccountService::SUPPORTED_TYPES) && json['inbox'].present?
         [json['id'], { prefetched_body: body, id: true }, :activitypub]
       elsif supported_context?(json) && expected_type?(json)
         [json['id'], { prefetched_body: body, id: true }, :activitypub]
@@ -62,7 +62,7 @@ class FetchAtomService < BaseService
   end
 
   def expected_type?(json)
-    (ActivityPub::Activity::Create::SUPPORTED_TYPES + ActivityPub::Activity::Create::CONVERTED_TYPES).include? json['type']
+    equals_or_includes_any?(json['type'], ActivityPub::Activity::Create::SUPPORTED_TYPES + ActivityPub::Activity::Create::CONVERTED_TYPES)
   end
 
   def process_html(response)
diff --git a/app/services/fetch_link_card_service.rb b/app/services/fetch_link_card_service.rb
index d5920a417..77d4aa538 100644
--- a/app/services/fetch_link_card_service.rb
+++ b/app/services/fetch_link_card_service.rb
@@ -85,42 +85,40 @@ class FetchLinkCardService < BaseService
   end
 
   def attempt_oembed
-    embed = OEmbed::Providers.get(@url, html: @html)
+    embed = FetchOEmbedService.new.call(@url, html: @html)
 
-    return false unless embed.respond_to?(:type)
+    return false if embed.nil?
 
-    @card.type          = embed.type
-    @card.title         = embed.respond_to?(:title)         ? embed.title         : ''
-    @card.author_name   = embed.respond_to?(:author_name)   ? embed.author_name   : ''
-    @card.author_url    = embed.respond_to?(:author_url)    ? embed.author_url    : ''
-    @card.provider_name = embed.respond_to?(:provider_name) ? embed.provider_name : ''
-    @card.provider_url  = embed.respond_to?(:provider_url)  ? embed.provider_url  : ''
+    @card.type          = embed[:type]
+    @card.title         = embed[:title]         || ''
+    @card.author_name   = embed[:author_name]   || ''
+    @card.author_url    = embed[:author_url]    || ''
+    @card.provider_name = embed[:provider_name] || ''
+    @card.provider_url  = embed[:provider_url]  || ''
     @card.width         = 0
     @card.height        = 0
 
     case @card.type
     when 'link'
-      @card.image_remote_url = embed.thumbnail_url if embed.respond_to?(:thumbnail_url)
+      @card.image_remote_url = embed[:thumbnail_url] if embed[:thumbnail_url].present?
     when 'photo'
-      return false unless embed.respond_to?(:url)
+      return false if embed[:url].blank?
 
-      @card.embed_url        = embed.url
-      @card.image_remote_url = embed.url
-      @card.width            = embed.width.presence  || 0
-      @card.height           = embed.height.presence || 0
+      @card.embed_url        = embed[:url]
+      @card.image_remote_url = embed[:url]
+      @card.width            = embed[:width].presence  || 0
+      @card.height           = embed[:height].presence || 0
     when 'video'
-      @card.width            = embed.width.presence  || 0
-      @card.height           = embed.height.presence || 0
-      @card.html             = Formatter.instance.sanitize(embed.html, Sanitize::Config::MASTODON_OEMBED)
-      @card.image_remote_url = embed.thumbnail_url if embed.respond_to?(:thumbnail_url)
+      @card.width            = embed[:width].presence  || 0
+      @card.height           = embed[:height].presence || 0
+      @card.html             = Formatter.instance.sanitize(embed[:html], Sanitize::Config::MASTODON_OEMBED)
+      @card.image_remote_url = embed[:thumbnail_url] if embed[:thumbnail_url].present?
     when 'rich'
       # Most providers rely on <script> tags, which is a no-no
       return false
     end
 
     @card.save_with_optional_image!
-  rescue OEmbed::NotFound
-    false
   end
 
   def attempt_opengraph
diff --git a/app/services/fetch_oembed_service.rb b/app/services/fetch_oembed_service.rb
new file mode 100644
index 000000000..998228517
--- /dev/null
+++ b/app/services/fetch_oembed_service.rb
@@ -0,0 +1,71 @@
+# frozen_string_literal: true
+
+class FetchOEmbedService
+  attr_reader :url, :options, :format, :endpoint_url
+
+  def call(url, options = {})
+    @url     = url
+    @options = options
+
+    discover_endpoint!
+    fetch!
+  end
+
+  private
+
+  def discover_endpoint!
+    return if html.nil?
+
+    @format = @options[:format]
+    page    = Nokogiri::HTML(html)
+
+    if @format.nil? || @format == :json
+      @endpoint_url ||= page.at_xpath('//link[@type="application/json+oembed"]')&.attribute('href')&.value
+      @format       ||= :json if @endpoint_url
+    end
+
+    if @format.nil? || @format == :xml
+      @endpoint_url ||= page.at_xpath('//link[@type="text/xml+oembed"]')&.attribute('href')&.value
+      @format       ||= :xml if @endpoint_url
+    end
+
+    return if @endpoint_url.blank?
+
+    @endpoint_url = Addressable::URI.parse(@endpoint_url).to_s
+  rescue Addressable::URI::InvalidURIError
+    @endpoint_url = nil
+  end
+
+  def fetch!
+    return if @endpoint_url.blank?
+
+    body = Request.new(:get, @endpoint_url).perform do |res|
+      res.code != 200 ? nil : res.body_with_limit
+    end
+
+    validate(parse_for_format(body)) unless body.nil?
+  rescue Oj::ParseError, Ox::ParseError
+    nil
+  end
+
+  def parse_for_format(body)
+    case @format
+    when :json
+      Oj.load(body, mode: :strict)&.with_indifferent_access
+    when :xml
+      Ox.load(body, mode: :hash_no_attrs)&.with_indifferent_access&.dig(:oembed)
+    end
+  end
+
+  def validate(oembed)
+    oembed if oembed[:version] == '1.0' && oembed[:type].present?
+  end
+
+  def html
+    return @html if defined?(@html)
+
+    @html = @options[:html] || Request.new(:get, @url).perform do |res|
+      res.code != 200 || res.mime_type != 'text/html' ? nil : res.body_with_limit
+    end
+  end
+end
diff --git a/app/services/mute_service.rb b/app/services/mute_service.rb
index 547b2efa1..c6122a152 100644
--- a/app/services/mute_service.rb
+++ b/app/services/mute_service.rb
@@ -3,8 +3,13 @@
 class MuteService < BaseService
   def call(account, target_account, notifications: nil)
     return if account.id == target_account.id
+
     mute = account.mute!(target_account, notifications: notifications)
-    BlockWorker.perform_async(account.id, target_account.id)
+    if mute.hide_notifications?
+      BlockWorker.perform_async(account.id, target_account.id)
+    else
+      FeedManager.instance.clear_from_timeline(account, target_account)
+    end
     mute
   end
 end
diff --git a/app/services/process_hashtags_service.rb b/app/services/process_hashtags_service.rb
index 990e01a4b..5b45c865f 100644
--- a/app/services/process_hashtags_service.rb
+++ b/app/services/process_hashtags_service.rb
@@ -7,7 +7,5 @@ class ProcessHashtagsService < BaseService
     tags.map { |str| str.mb_chars.downcase }.uniq(&:to_s).each do |tag|
       status.tags << Tag.where(name: tag).first_or_initialize(name: tag)
     end
-
-    status.update(sensitive: true) if tags.include?('nsfw')
   end
 end
diff --git a/app/services/process_mentions_service.rb b/app/services/process_mentions_service.rb
index dc8df4a9a..2ed6698cf 100644
--- a/app/services/process_mentions_service.rb
+++ b/app/services/process_mentions_service.rb
@@ -10,55 +10,61 @@ class ProcessMentionsService < BaseService
   def call(status)
     return unless status.local?
 
+    @status  = status
+    mentions = []
+
     status.text = status.text.gsub(Account::MENTION_RE) do |match|
-      username, domain  = $1.split('@')
+      username, domain  = Regexp.last_match(1).split('@')
       mentioned_account = Account.find_remote(username, domain)
 
-      if mention_undeliverable?(status, mentioned_account)
+      if mention_undeliverable?(mentioned_account)
         begin
-          mentioned_account = resolve_account_service.call($1)
+          mentioned_account = resolve_account_service.call(Regexp.last_match(1))
         rescue Goldfinger::Error, HTTP::Error, OpenSSL::SSL::SSLError, Mastodon::UnexpectedResponseError
           mentioned_account = nil
         end
       end
 
-      next match if mention_undeliverable?(status, mentioned_account)
+      next match if mention_undeliverable?(mentioned_account)
+
+      mentions << mentioned_account.mentions.where(status: status).first_or_create(status: status)
 
-      mentioned_account.mentions.where(status: status).first_or_create(status: status)
       "@#{mentioned_account.acct}"
     end
 
     status.save!
 
-    status.mentions.includes(:account).each do |mention|
-      create_notification(status, mention)
-    end
+    mentions.each { |mention| create_notification(mention) }
   end
 
   private
 
-  def mention_undeliverable?(status, mentioned_account)
-    mentioned_account.nil? || (!mentioned_account.local? && mentioned_account.ostatus? && status.stream_entry.hidden?)
+  def mention_undeliverable?(mentioned_account)
+    mentioned_account.nil? || (!mentioned_account.local? && mentioned_account.ostatus? && @status.stream_entry.hidden?)
   end
 
-  def create_notification(status, mention)
+  def create_notification(mention)
     mentioned_account = mention.account
 
     if mentioned_account.local?
-      NotifyService.new.call(mentioned_account, mention)
-    elsif mentioned_account.ostatus? && !status.stream_entry.hidden?
-      NotificationWorker.perform_async(stream_entry_to_xml(status.stream_entry), status.account_id, mentioned_account.id)
+      LocalNotificationWorker.perform_async(mention.id)
+    elsif mentioned_account.ostatus? && !@status.stream_entry.hidden?
+      NotificationWorker.perform_async(ostatus_xml, @status.account_id, mentioned_account.id)
     elsif mentioned_account.activitypub?
-      ActivityPub::DeliveryWorker.perform_async(build_json(mention.status), mention.status.account_id, mentioned_account.inbox_url)
+      ActivityPub::DeliveryWorker.perform_async(activitypub_json, mention.status.account_id, mentioned_account.inbox_url)
     end
   end
 
-  def build_json(status)
-    Oj.dump(ActivityPub::LinkedDataSignature.new(ActiveModelSerializers::SerializableResource.new(
-      status,
+  def ostatus_xml
+    @ostatus_xml ||= stream_entry_to_xml(@status.stream_entry)
+  end
+
+  def activitypub_json
+    @activitypub_json ||= Oj.dump(ActivityPub::LinkedDataSignature.new(ActiveModelSerializers::SerializableResource.new(
+      @status,
       serializer: ActivityPub::ActivitySerializer,
       adapter: ActivityPub::Adapter
-    ).as_json).sign!(status.account))
+    ).as_json).sign!(@status.account))
   end
 
   def resolve_account_service
diff --git a/app/services/resolve_account_service.rb b/app/services/resolve_account_service.rb
index 8cba88f01..de8d1151d 100644
--- a/app/services/resolve_account_service.rb
+++ b/app/services/resolve_account_service.rb
@@ -189,7 +189,7 @@ class ResolveAccountService < BaseService
     return @actor_json if defined?(@actor_json)
 
     json        = fetch_resource(actor_url, false)
-    @actor_json = supported_context?(json) && ActivityPub::FetchRemoteAccountService::SUPPORTED_TYPES.include?(json['type']) ? json : nil
+    @actor_json = supported_context?(json) && equals_or_includes_any?(json['type'], ActivityPub::FetchRemoteAccountService::SUPPORTED_TYPES) ? json : nil
   end
 
   def atom
diff --git a/app/services/resolve_url_service.rb b/app/services/resolve_url_service.rb
index c19b568cb..a068c1ed8 100644
--- a/app/services/resolve_url_service.rb
+++ b/app/services/resolve_url_service.rb
@@ -16,10 +16,9 @@ class ResolveURLService < BaseService
   private
 
   def process_url
-    case type
-    when 'Application', 'Group', 'Organization', 'Person', 'Service'
+    if equals_or_includes_any?(type, %w(Application Group Organization Person Service))
       FetchRemoteAccountService.new.call(atom_url, body, protocol)
-    when 'Note', 'Article', 'Image', 'Video'
+    elsif equals_or_includes_any?(type, %w(Note Article Image Video))
       FetchRemoteStatusService.new.call(atom_url, body, protocol)
     end
   end
diff --git a/app/validators/disallowed_hashtags_validator.rb b/app/validators/disallowed_hashtags_validator.rb
new file mode 100644
index 000000000..22c027b0f
--- /dev/null
+++ b/app/validators/disallowed_hashtags_validator.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+class DisallowedHashtagsValidator < ActiveModel::Validator
+  def validate(status)
+    return unless status.local? && !status.reblog?
+
+    tags = Extractor.extract_hashtags(status.text)
+    tags.keep_if { |tag| disallowed_hashtags.include? tag.downcase }
+
+    status.errors.add(:text, I18n.t('statuses.disallowed_hashtags', tags: tags.join(', '), count: tags.size)) unless tags.empty?
+  end
+
+  private
+
+  def disallowed_hashtags
+    return @disallowed_hashtags if @disallowed_hashtags
+
+    @disallowed_hashtags = Setting.disallowed_hashtags.nil? ? [] : Setting.disallowed_hashtags
+    @disallowed_hashtags = @disallowed_hashtags.split(' ') if @disallowed_hashtags.is_a? String
+    @disallowed_hashtags = @disallowed_hashtags.map(&:downcase)
+  end
+end
diff --git a/app/views/admin/action_logs/_action_log.html.haml b/app/views/admin/action_logs/_action_log.html.haml
index ec90961cb..f059814bd 100644
--- a/app/views/admin/action_logs/_action_log.html.haml
+++ b/app/views/admin/action_logs/_action_log.html.haml
@@ -1,4 +1,4 @@
-%li.log-entry
+.log-entry
   .log-entry__header
     .log-entry__avatar
       = image_tag action_log.account.avatar.url(:original), alt: '', width: 40, height: 40, class: 'avatar'
diff --git a/app/views/admin/action_logs/index.html.haml b/app/views/admin/action_logs/index.html.haml
index bb6d7b5d7..a4d3871a9 100644
--- a/app/views/admin/action_logs/index.html.haml
+++ b/app/views/admin/action_logs/index.html.haml
@@ -1,7 +1,6 @@
 - content_for :page_title do
   = t('admin.action_logs.title')
 
-%ul
-  = render @action_logs
+= render @action_logs
 
 = paginate @action_logs
diff --git a/app/views/admin/report_notes/_report_note.html.haml b/app/views/admin/report_notes/_report_note.html.haml
index 1f621e0d3..d34dc3d15 100644
--- a/app/views/admin/report_notes/_report_note.html.haml
+++ b/app/views/admin/report_notes/_report_note.html.haml
@@ -1,9 +1,7 @@
-%li
-  %h4
-    = report_note.account.acct
-    %div{ style: 'float: right' }
-      %time.formatted{ datetime: report_note.created_at.iso8601, title: l(report_note.created_at) }
-        = l report_note.created_at
-      = table_link_to 'trash', t('admin.reports.notes.delete'), admin_report_note_path(report_note), method: :delete if can?(:destroy, report_note)
-  %div{ class: 'report-note__comment' }
+.speech-bubble
+  .speech-bubble__bubble
     = simple_format(h(report_note.content))
+  .speech-bubble__owner
+    = admin_account_link_to report_note.account
+    %time.formatted{ datetime: report_note.created_at.iso8601 }= l report_note.created_at
+    = table_link_to 'trash', t('admin.reports.notes.delete'), admin_report_note_path(report_note), method: :delete if can?(:destroy, report_note)
diff --git a/app/views/admin/reports/_account.html.haml b/app/views/admin/reports/_account.html.haml
new file mode 100644
index 000000000..22b7a0861
--- /dev/null
+++ b/app/views/admin/reports/_account.html.haml
@@ -0,0 +1,19 @@
+- size ||= 36
+
+.account.compact
+  .account__wrapper
+    - if account.nil?
+      .account__display-name
+        .account__avatar-wrapper
+          .account__avatar{ style: "background-image: url(#{full_asset_url('avatars/original/missing.png', skip_pipeline: true)}); width: #{size}px; height: #{size}px; background-size: #{size}px #{size}px" }
+        %span.display-name
+          %strong= t 'about.contact_missing'
+          %span.display-name__account= t 'about.contact_unavailable'
+    - else
+      = link_to TagManager.instance.url_for(account), class: 'account__display-name' do
+        .account__avatar-wrapper
+          .account__avatar{ style: "background-image: url(#{account.avatar.url}); width: #{size}px; height: #{size}px; background-size: #{size}px #{size}px" }
+        %span.display-name
+          %bdi
+            %strong.display-name__html.emojify= display_name(account)
+          %span.display-name__account @#{account.acct}
diff --git a/app/views/admin/reports/_account_details.html.haml b/app/views/admin/reports/_account_details.html.haml
deleted file mode 100644
index a8af39bef..000000000
--- a/app/views/admin/reports/_account_details.html.haml
+++ /dev/null
@@ -1,20 +0,0 @@
-.table-wrapper
-  %table.table
-    %tbody
-      %tr
-        %td= t('admin.reports.account.created_reports')
-        %td= link_to pluralize(account.reports.count, t('admin.reports.account.report')), admin_reports_path(account_id: account.id)
-      %tr
-        %td= t('admin.reports.account.targeted_reports')
-        %td= link_to pluralize(account.targeted_reports.count, t('admin.reports.account.report')), admin_reports_path(target_account_id: account.id)
-      %tr
-        %td= t('admin.reports.account.moderation_notes')
-        %td= link_to pluralize(account.targeted_moderation_notes.count, t('admin.reports.account.note')), admin_reports_path(target_account_id: account.id)
-      - if account.silenced? || account.suspended?
-        %tr
-          %td= t('admin.reports.account.moderation.title')
-          %td
-            - if account.silenced?
-              %p= t('admin.reports.account.moderation.silenced')
-            - if account.suspended?
-              %p= t('admin.reports.account.moderation.suspended')
diff --git a/app/views/admin/reports/_action_log.html.haml b/app/views/admin/reports/_action_log.html.haml
new file mode 100644
index 000000000..024078eb9
--- /dev/null
+++ b/app/views/admin/reports/_action_log.html.haml
@@ -0,0 +1,6 @@
+.speech-bubble.positive
+  .speech-bubble__bubble
+    = 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
+  .speech-bubble__owner
+    = admin_account_link_to(action_log.account)
+    %time.formatted{ datetime: action_log.created_at.iso8601 }= l action_log.created_at
diff --git a/app/views/admin/reports/_report.html.haml b/app/views/admin/reports/_report.html.haml
index 84db00ad5..d6c881955 100644
--- a/app/views/admin/reports/_report.html.haml
+++ b/app/views/admin/reports/_report.html.haml
@@ -2,9 +2,9 @@
   %td.id
     = "##{report.id}"
   %td.target
-    = link_to report.target_account.acct, admin_account_path(report.target_account.id)
+    = admin_account_link_to report.target_account
   %td.reporter
-    = link_to report.account.acct, admin_account_path(report.account.id)
+    = admin_account_link_to report.account
   %td
     %div{ title: report.comment }
       = truncate(report.comment, length: 30, separator: ' ')
@@ -21,6 +21,6 @@
     - if report.assigned_account.nil?
       \-
     - else
-      = link_to report.assigned_account.acct, admin_account_path(report.assigned_account.id)
+      = admin_account_link_to report.assigned_account
   %td
     = table_link_to 'circle', t('admin.reports.view'), admin_report_path(report)
diff --git a/app/views/admin/reports/_status.html.haml b/app/views/admin/reports/_status.html.haml
new file mode 100644
index 000000000..137609539
--- /dev/null
+++ b/app/views/admin/reports/_status.html.haml
@@ -0,0 +1,28 @@
+.batch-table__row
+  %label.batch-table__row__select.batch-checkbox
+    = f.check_box :status_ids, { multiple: true, include_hidden: false }, status.id
+  .batch-table__row__content
+    .status__content><
+      - unless status.spoiler_text.blank?
+        %p><
+          %strong= Formatter.instance.format_spoiler(status)
+
+      = Formatter.instance.format(status)
+
+    - unless status.media_attachments.empty?
+      - if status.media_attachments.first.video?
+        - video = status.media_attachments.first
+        = react_component :video, src: video.file.url(:original), preview: video.file.url(:small), sensitive: status.sensitive? && !current_account&.user&.setting_display_sensitive_media, width: 610, height: 343, inline: true
+      - else
+        = react_component :media_gallery, height: 343, sensitive: status.sensitive? && !current_account&.user&.setting_display_sensitive_media, 'autoPlayGif': current_account&.user&.setting_auto_play_gif, media: status.media_attachments.map { |a| ActiveModelSerializers::SerializableResource.new(a, serializer: REST::MediaAttachmentSerializer).as_json }
+
+    .detailed-status__meta
+      = link_to TagManager.instance.url_for(status), class: 'detailed-status__datetime', target: stream_link_target, rel: 'noopener' do
+        %time.formatted{ datetime: status.created_at.iso8601, title: l(status.created_at) }= l(status.created_at)
+      ·
+      = fa_visibility_icon(status)
+      = t("statuses.visibilities.#{status.visibility}")
+      - if status.sensitive?
+        ·
+        = fa_icon('eye-slash fw')
+        = t('stream_entries.sensitive_content')
diff --git a/app/views/admin/reports/index.html.haml b/app/views/admin/reports/index.html.haml
index c3baaf6be..44a531f2c 100644
--- a/app/views/admin/reports/index.html.haml
+++ b/app/views/admin/reports/index.html.haml
@@ -8,20 +8,17 @@
       %li= filter_link_to t('admin.reports.unresolved'), resolved: nil
       %li= filter_link_to t('admin.reports.resolved'), resolved: '1'
 
-= form_tag do
-
-  .table-wrapper
-    %table.table
-      %thead
-        %tr
-          -# %th
-          %th= t('admin.reports.id')
-          %th= t('admin.reports.target')
-          %th= t('admin.reports.reported_by')
-          %th= t('admin.reports.report_contents')
-          %th= t('admin.reports.assigned')
-          %th
-      %tbody
-        = render @reports
+.table-wrapper
+  %table.table
+    %thead
+      %tr
+        %th= t('admin.reports.id')
+        %th= t('admin.reports.target')
+        %th= t('admin.reports.reported_by')
+        %th= t('admin.reports.report_contents')
+        %th= t('admin.reports.assigned')
+        %th
+    %tbody
+      = render @reports
 
 = paginate @reports
diff --git a/app/views/admin/reports/show.html.haml b/app/views/admin/reports/show.html.haml
index 60a8cab8e..cbfbdcfa9 100644
--- a/app/views/admin/reports/show.html.haml
+++ b/app/views/admin/reports/show.html.haml
@@ -11,16 +11,28 @@
   - else
     = link_to t('admin.reports.mark_as_unresolved'), admin_report_path(@report, outcome: 'reopen'), method: :put, class: 'button'
 
+%hr.spacer
+
 .table-wrapper
   %table.table.inline-table
     %tbody
       %tr
+        %th= t('admin.reports.reported_account')
+        %td= admin_account_link_to @report.target_account
+        %td= table_link_to 'flag', pluralize(@report.target_account.targeted_reports.count, t('admin.reports.account.report')), admin_reports_path(target_account_id: @report.target_account.id)
+        %td= table_link_to 'file', pluralize(@report.target_account.targeted_moderation_notes.count, t('admin.reports.account.note')), admin_reports_path(target_account_id: @report.target_account.id)
+      %tr
+        %th= t('admin.reports.reported_by')
+        %td= admin_account_link_to @report.account
+        %td= table_link_to 'flag', pluralize(@report.account.targeted_reports.count, t('admin.reports.account.report')), admin_reports_path(target_account_id: @report.account.id)
+        %td= table_link_to 'file', pluralize(@report.account.targeted_moderation_notes.count, t('admin.reports.account.note')), admin_reports_path(target_account_id: @report.account.id)
+      %tr
         %th= t('admin.reports.created_at')
-        %td{colspan: 2}
+        %td{ colspan: 3 }
           %time.formatted{ datetime: @report.created_at.iso8601 }
       %tr
         %th= t('admin.reports.updated_at')
-        %td{colspan: 2}
+        %td{ colspan: 3 }
           %time.formatted{ datetime: @report.updated_at.iso8601 }
       %tr
         %th= t('admin.reports.status')
@@ -29,14 +41,14 @@
             = t('admin.reports.resolved')
           - else
             = t('admin.reports.unresolved')
-        %td{style: "text-align: right; overflow: hidden;"}
+        %td{ colspan: 2 }
           - if @report.action_taken?
             = table_link_to 'envelope-open', t('admin.reports.reopen'), admin_report_path(@report, outcome: 'reopen'), method: :put
       - if !@report.action_taken_by_account.nil?
         %tr
           %th= t('admin.reports.action_taken_by')
-          %td{colspan: 2}
-            = @report.action_taken_by_account.acct
+          %td{ colspan: 3 }
+            = admin_account_link_to @report.action_taken_by_account
       - else
         %tr
           %th= t('admin.reports.assigned')
@@ -44,78 +56,55 @@
             - if @report.assigned_account.nil?
               \-
             - else
-              = link_to @report.assigned_account.acct, admin_account_path(@report.assigned_account.id)
-          %td{style: "text-align: right"}
+              = admin_account_link_to @report.assigned_account
+          %td
             - if @report.assigned_account != current_user.account
               = table_link_to 'user', t('admin.reports.assign_to_self'), admin_report_path(@report, outcome: 'assign_to_self'), method: :put
+          %td
             - if !@report.assigned_account.nil?
               = table_link_to 'trash', t('admin.reports.unassign'), admin_report_path(@report, outcome: 'unassign'), method: :put
 
-%hr{ class: "section-break"}/
-
-.report-accounts
-  .report-accounts__item
-    %h3= t('admin.reports.reported_account')
-    = render 'authorize_follows/card', account: @report.target_account, admin: true
-    = render 'admin/reports/account_details', account: @report.target_account
-  .report-accounts__item
-    %h3= t('admin.reports.reported_by')
-    = render 'authorize_follows/card', account: @report.account, admin: true
-    = render 'admin/reports/account_details', account: @report.account
-
-%h3= t('admin.reports.comment.label')
+%hr.spacer
 
-= simple_format(@report.comment.presence || t('admin.reports.comment.none'))
+.speech-bubble
+  .speech-bubble__bubble= simple_format(@report.comment.presence || t('admin.reports.comment.none'))
+  .speech-bubble__owner
+    = admin_account_link_to @report.account
+    %time.formatted{ datetime: @report.created_at.iso8601 }
 
 - unless @report.statuses.empty?
-  %hr/
-
-  %h3= t('admin.reports.statuses')
+  %hr.spacer/
 
   = form_for(@form, url: admin_report_reported_statuses_path(@report.id)) do |f|
-    .batch-form-box
-      .batch-checkbox-all
-        = check_box_tag :batch_checkbox_all, nil, false
-      = f.select :action, Form::StatusBatch::ACTION_TYPE.map{|action| [t("admin.statuses.batch.#{action}"), action]}
-      = f.submit t('admin.statuses.execute'), data: { confirm: t('admin.reports.are_you_sure') }, class: 'button'
-      .media-spoiler-toggle-buttons
-        .media-spoiler-show-button.button= t('admin.statuses.media.show')
-        .media-spoiler-hide-button.button= t('admin.statuses.media.hide')
-    - @report.statuses.each do |status|
-      .report-status{ data: { id: status.id } }
-        .batch-checkbox
-          = f.check_box :status_ids, { multiple: true, include_hidden: false }, status.id
-        .activity-stream.activity-stream-headless
-          .entry= render 'stream_entries/simple_status', status: status
-        .report-status__actions
-          - unless status.media_attachments.empty?
-            = link_to admin_report_reported_status_path(@report, status, status: { sensitive: !status.sensitive }), method: :put, class: 'icon-button nsfw-button', title: t("admin.reports.nsfw.#{!status.sensitive}") do
-              = fa_icon status.sensitive? ? 'eye' : 'eye-slash'
-          = link_to admin_report_reported_status_path(@report, status), method: :delete, class: 'icon-button trash-button', title: t('admin.reports.delete'), data: { confirm: t('admin.reports.are_you_sure') }, remote: true do
-            = fa_icon 'trash'
-
-%hr{ class: "section-break"}/
+    .batch-table
+      .batch-table__toolbar
+        %label.batch-table__toolbar__select.batch-checkbox-all
+          = check_box_tag :batch_checkbox_all, nil, false
+        .batch-table__toolbar__actions
+          = f.button safe_join([fa_icon('eye-slash'), t('admin.statuses.batch.nsfw_on')]), name: :nsfw_on, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') }
+          = f.button safe_join([fa_icon('eye'), t('admin.statuses.batch.nsfw_off')]), name: :nsfw_off, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') }
+          = f.button safe_join([fa_icon('trash'), t('admin.statuses.batch.delete')]), name: :delete, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') }
+      .batch-table__body
+        = render partial: 'admin/reports/status', collection: @report.statuses, locals: { f: f }
 
-%h3= t('admin.reports.notes.label')
+%hr.spacer/
 
-- if @report_notes.length > 0
-  %ul
-    = render @report_notes
+- @report_notes.each do |item|
+  - if item.is_a?(Admin::ActionLog)
+    = render partial: 'action_log', locals: { action_log: item }
+  - elsif item.is_a?(ReportNote)
+    = render item
 
-%h4= t('admin.reports.notes.new_label')
-= form_for @report_note, url: admin_report_notes_path, html: { class: 'report-note__form' } do |f|
+= simple_form_for @report_note, url: admin_report_notes_path do |f|
   = render 'shared/error_messages', object: @report_note
-  = f.text_area :content, placeholder: t('admin.reports.notes.placeholder'), rows: 6, class: 'report-note__textarea'
-  = f.hidden_field :report_id
-  %div{ class: 'report-note__buttons' }
-    - if @report.unresolved?
-      = f.submit t('admin.reports.notes.create_and_resolve'), name: :create_and_resolve, class: 'button report-note__button'
-    - else
-      = f.submit t('admin.reports.notes.create_and_unresolve'), name: :create_and_unresolve, class: 'button report-note__button'
-    = f.submit t('admin.reports.notes.create'), class: 'button report-note__button'
+  = f.input :report_id, as: :hidden
 
-- if @report_history.length > 0
-  %h3= t('admin.reports.history')
+  .field-group
+    = f.input :content, placeholder: t('admin.reports.notes.placeholder'), rows: 6
 
-  %ul
-    = render @report_history
+  .actions
+    - if @report.unresolved?
+      = f.button :button, t('admin.reports.notes.create_and_resolve'), name: :create_and_resolve, type: :submit
+    - else
+      = f.button :button, t('admin.reports.notes.create_and_unresolve'), name: :create_and_unresolve, type: :submit
+    = f.button :button, t('admin.reports.notes.create'), type: :submit
diff --git a/app/views/home/index.html.haml b/app/views/home/index.html.haml
index e8a81656c..789de47d1 100644
--- a/app/views/home/index.html.haml
+++ b/app/views/home/index.html.haml
@@ -1,4 +1,9 @@
 - content_for :header_tags do
+  = preload_link_tag asset_pack_path('features/getting_started.js'), crossorigin: 'anonymous'
+  = preload_link_tag asset_pack_path('features/compose.js'), crossorigin: 'anonymous'
+  = preload_link_tag asset_pack_path('features/home_timeline.js'), crossorigin: 'anonymous'
+  = preload_link_tag asset_pack_path('features/notifications.js'), crossorigin: 'anonymous'
+
   %meta{name: 'applicationServerKey', content: Rails.configuration.x.vapid_public_key}
   %script#initial-state{ type: 'application/json' }!= json_escape(@initial_state_json)
 
diff --git a/app/views/stream_entries/_detailed_status.html.haml b/app/views/stream_entries/_detailed_status.html.haml
index e1122d5a2..afc66d148 100644
--- a/app/views/stream_entries/_detailed_status.html.haml
+++ b/app/views/stream_entries/_detailed_status.html.haml
@@ -22,11 +22,11 @@
   - if !status.media_attachments.empty?
     - if status.media_attachments.first.video?
       - video = status.media_attachments.first
-      %div{ data: { component: 'Video', props: Oj.dump(src: video.file.url(:original), preview: video.file.url(:small), sensitive: status.sensitive? && !current_account&.user&.setting_display_sensitive_media, width: 670, height: 380, detailed: true, inline: true) }}
+      = react_component :video, src: video.file.url(:original), preview: video.file.url(:small), sensitive: status.sensitive? && !current_account&.user&.setting_display_sensitive_media, width: 670, height: 380, detailed: true, inline: true
     - else
-      %div{ data: { component: 'MediaGallery', props: Oj.dump(height: 380, sensitive: status.sensitive? && !current_account&.user&.setting_display_sensitive_media, standalone: true, 'autoPlayGif': current_account&.user&.setting_auto_play_gif, 'reduceMotion': current_account&.user&.setting_reduce_motion, media: status.media_attachments.map { |a| ActiveModelSerializers::SerializableResource.new(a, serializer: REST::MediaAttachmentSerializer).as_json }) }}
+      = react_component :media_gallery, height: 380, sensitive: status.sensitive? && !current_account&.user&.setting_display_sensitive_media, standalone: true, 'autoPlayGif': current_account&.user&.setting_auto_play_gif, 'reduceMotion': current_account&.user&.setting_reduce_motion, media: status.media_attachments.map { |a| ActiveModelSerializers::SerializableResource.new(a, serializer: REST::MediaAttachmentSerializer).as_json }
   - elsif status.preview_cards.first
-    %div{ data: { component: 'Card', props: Oj.dump('maxDescription': 160, card: ActiveModelSerializers::SerializableResource.new(status.preview_cards.first, serializer: REST::PreviewCardSerializer).as_json) }}
+    = react_component :card, 'maxDescription': 160, card: ActiveModelSerializers::SerializableResource.new(status.preview_cards.first, serializer: REST::PreviewCardSerializer).as_json
 
   .detailed-status__meta
     %data.dt-published{ value: status.created_at.to_time.iso8601 }
diff --git a/app/views/stream_entries/_more.html.haml b/app/views/stream_entries/_more.html.haml
new file mode 100644
index 000000000..9b1dfe4a7
--- /dev/null
+++ b/app/views/stream_entries/_more.html.haml
@@ -0,0 +1,2 @@
+= link_to url, class: 'more light'  do
+  = t('statuses.show_more')
diff --git a/app/views/stream_entries/_simple_status.html.haml b/app/views/stream_entries/_simple_status.html.haml
index 2ad1f5120..cc2b6abe8 100644
--- a/app/views/stream_entries/_simple_status.html.haml
+++ b/app/views/stream_entries/_simple_status.html.haml
@@ -20,9 +20,10 @@
         %a.status__content__spoiler-link{ href: '#' }= t('statuses.show_more')
     .e-content{ lang: status.language, style: "display: #{status.spoiler_text? ? 'none' : 'block'}; direction: #{rtl_status?(status) ? 'rtl' : 'ltr'}" }<
       = Formatter.instance.format(status, custom_emojify: true)
-      - unless status.media_attachments.empty?
-        - if status.media_attachments.first.video?
-          - video = status.media_attachments.first
-          %div{ data: { component: 'Video', props: Oj.dump(src: video.file.url(:original), preview: video.file.url(:small), sensitive: status.sensitive? && !current_account&.user&.setting_display_sensitive_media, width: 610, height: 343) }}
-        - else
-          %div{ data: { component: 'MediaGallery', props: Oj.dump(height: 343, sensitive: status.sensitive? && !current_account&.user&.setting_display_sensitive_media, 'autoPlayGif': current_account&.user&.setting_auto_play_gif, media: status.media_attachments.map { |a| ActiveModelSerializers::SerializableResource.new(a, serializer: REST::MediaAttachmentSerializer).as_json }) }}
+
+  - unless status.media_attachments.empty?
+    - if status.media_attachments.first.video?
+      - video = status.media_attachments.first
+      = react_component :video, src: video.file.url(:original), preview: video.file.url(:small), sensitive: status.sensitive? && !current_account&.user&.setting_display_sensitive_media, width: 610, height: 343, inline: true
+    - else
+      = react_component :media_gallery, height: 343, sensitive: status.sensitive? && !current_account&.user&.setting_display_sensitive_media, 'autoPlayGif': current_account&.user&.setting_auto_play_gif, media: status.media_attachments.map { |a| ActiveModelSerializers::SerializableResource.new(a, serializer: REST::MediaAttachmentSerializer).as_json }
diff --git a/app/views/stream_entries/_status.html.haml b/app/views/stream_entries/_status.html.haml
index 2d0dafcb7..9764bc74d 100644
--- a/app/views/stream_entries/_status.html.haml
+++ b/app/views/stream_entries/_status.html.haml
@@ -5,19 +5,19 @@
   is_successor    ||= false
   direct_reply_id ||= false
   parent_id       ||= false
-  is_direct_parent = direct_reply_id == status.id
-  is_direct_child  = parent_id == status.in_reply_to_id
-  centered ||= include_threads && !is_predecessor && !is_successor
-  h_class       = microformats_h_class(status, is_predecessor, is_successor, include_threads)
-  style_classes = style_classes(status, is_predecessor, is_successor, include_threads)
-  mf_classes    = microformats_classes(status, is_direct_parent, is_direct_child)
-  entry_classes = h_class + ' ' + mf_classes + ' ' + style_classes
+  is_direct_parent  = direct_reply_id == status.id
+  is_direct_child   = parent_id == status.in_reply_to_id
+  centered        ||= include_threads && !is_predecessor && !is_successor
+  h_class           = microformats_h_class(status, is_predecessor, is_successor, include_threads)
+  style_classes     = style_classes(status, is_predecessor, is_successor, include_threads)
+  mf_classes        = microformats_classes(status, is_direct_parent, is_direct_child)
+  entry_classes     = h_class + ' ' + mf_classes + ' ' + style_classes
 
 - if status.reply? && include_threads
   - if @next_ancestor
     .entry{ class: entry_classes }
-      = link_to short_account_status_url(@next_ancestor.account.username, @next_ancestor), class: 'more light'  do
-        = t('statuses.show_more')
+      = render 'stream_entries/more', url: TagManager.instance.url_for(@next_ancestor)
+
   = render partial: 'stream_entries/status', collection: @ancestors, as: :status, locals: { is_predecessor: true, direct_reply_id: status.in_reply_to_id }
 
 .entry{ class: entry_classes }
@@ -40,4 +40,15 @@
   = render (centered ? 'stream_entries/detailed_status' : 'stream_entries/simple_status'), status: status.proper
 
 - if include_threads
-  = render partial: 'stream_entries/status', collection: @descendants, as: :status, locals: { is_successor: true, parent_id: status.id }
+  - if @since_descendant_thread_id
+    .entry{ class: entry_classes }
+      = render 'stream_entries/more', url: short_account_status_url(status.account.username, status, max_descendant_thread_id: @since_descendant_thread_id + 1)
+  - @descendant_threads.each do |thread|
+    = render partial: 'stream_entries/status', collection: thread[:statuses], as: :status, locals: { is_successor: true, parent_id: status.id }
+
+    - if thread[:next_status]
+      .entry{ class: entry_classes }
+        = render 'stream_entries/more', url: TagManager.instance.url_for(thread[:next_status])
+  - if @next_descendant_thread
+    .entry{ class: entry_classes }
+      = render 'stream_entries/more', url: short_account_status_url(status.account.username, status, since_descendant_thread_id: @max_descendant_thread_id - 1)
diff --git a/app/views/well_known/host_meta/show.xml.ruby b/app/views/well_known/host_meta/show.xml.ruby
index 07d026471..0a6bdc322 100644
--- a/app/views/well_known/host_meta/show.xml.ruby
+++ b/app/views/well_known/host_meta/show.xml.ruby
@@ -1,5 +1,13 @@
-Nokogiri::XML::Builder.new do |xml|
-  xml.XRD(xmlns: 'http://docs.oasis-open.org/ns/xri/xrd-1.0') do
-    xml.Link(rel: 'lrdd', type: 'application/xrd+xml', template: @webfinger_template)
+doc = Ox::Document.new(version: '1.0')
+
+doc << Ox::Element.new('XRD').tap do |xrd|
+  xrd['xmlns'] = 'http://docs.oasis-open.org/ns/xri/xrd-1.0'
+
+  xrd << Ox::Element.new('Link').tap do |link|
+    link['rel']      = 'lrdd'
+    link['type']     = 'application/xrd+xml'
+    link['template'] = @webfinger_template
   end
-end.to_xml
+end
+
+('<?xml version="1.0" encoding="UTF-8"?>' + Ox.dump(doc, effort: :tolerant)).force_encoding('UTF-8')
diff --git a/app/views/well_known/webfinger/show.xml.ruby b/app/views/well_known/webfinger/show.xml.ruby
index 0c7289d6a..4352a24e9 100644
--- a/app/views/well_known/webfinger/show.xml.ruby
+++ b/app/views/well_known/webfinger/show.xml.ruby
@@ -1,13 +1,44 @@
-Nokogiri::XML::Builder.new do |xml|
-  xml.XRD(xmlns: 'http://docs.oasis-open.org/ns/xri/xrd-1.0') do
-    xml.Subject @account.to_webfinger_s
-    xml.Alias short_account_url(@account)
-    xml.Alias account_url(@account)
-    xml.Link(rel: 'http://webfinger.net/rel/profile-page', type: 'text/html', href: short_account_url(@account))
-    xml.Link(rel: 'http://schemas.google.com/g/2010#updates-from', type: 'application/atom+xml', href: account_url(@account, format: 'atom'))
-    xml.Link(rel: 'self', type: 'application/activity+json', href: account_url(@account))
-    xml.Link(rel: 'salmon', href: api_salmon_url(@account.id))
-    xml.Link(rel: 'magic-public-key', href: "data:application/magic-public-key,#{@account.magic_key}")
-    xml.Link(rel: 'http://ostatus.org/schema/1.0/subscribe', template: "#{authorize_follow_url}?acct={uri}")
-  end
-end.to_xml
+doc = Ox::Document.new(version: '1.0')
+
+doc << Ox::Element.new('XRD').tap do |xrd|
+  xrd['xmlns'] = 'http://docs.oasis-open.org/ns/xri/xrd-1.0'
+
+  xrd << (Ox::Element.new('Subject') << @account.to_webfinger_s)
+  xrd << (Ox::Element.new('Alias') << short_account_url(@account))
+  xrd << (Ox::Element.new('Alias') << account_url(@account))
+
+  xrd << Ox::Element.new('Link').tap do |link|
+    link['rel']      = 'http://webfinger.net/rel/profile-page'
+    link['type']     = 'text/html'
+    link['href']     = short_account_url(@account)
+  end
+
+  xrd << Ox::Element.new('Link').tap do |link|
+    link['rel']      = 'http://schemas.google.com/g/2010#updates-from'
+    link['type']     = 'application/atom+xml'
+    link['href']     = account_url(@account, format: 'atom')
+  end
+
+  xrd << Ox::Element.new('Link').tap do |link|
+    link['rel']      = 'self'
+    link['type']     = 'application/activity+json'
+    link['href']     = account_url(@account)
+  end
+
+  xrd << Ox::Element.new('Link').tap do |link|
+    link['rel']      = 'salmon'
+    link['href']     = api_salmon_url(@account.id)
+  end
+
+  xrd << Ox::Element.new('Link').tap do |link|
+    link['rel']      = 'magic-public-key'
+    link['href']     = "data:application/magic-public-key,#{@account.magic_key}"
+  end
+
+  xrd << Ox::Element.new('Link').tap do |link|
+    link['rel']      = 'http://ostatus.org/schema/1.0/subscribe'
+    link['template'] = "#{authorize_follow_url}?acct={uri}"
+  end
+end
+
+('<?xml version="1.0" encoding="UTF-8"?>' + Ox.dump(doc, effort: :tolerant)).force_encoding('UTF-8')
diff --git a/app/workers/activitypub/processing_worker.rb b/app/workers/activitypub/processing_worker.rb
index 0e2e0eddd..bb9adf64b 100644
--- a/app/workers/activitypub/processing_worker.rb
+++ b/app/workers/activitypub/processing_worker.rb
@@ -6,6 +6,6 @@ class ActivityPub::ProcessingWorker
   sidekiq_options backtrace: true
 
   def perform(account_id, body)
-    ActivityPub::ProcessCollectionService.new.call(body, Account.find(account_id), override_timestamps: true)
+    ActivityPub::ProcessCollectionService.new.call(body, Account.find(account_id))
   end
 end
diff --git a/app/workers/local_notification_worker.rb b/app/workers/local_notification_worker.rb
new file mode 100644
index 000000000..748270563
--- /dev/null
+++ b/app/workers/local_notification_worker.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+class LocalNotificationWorker
+  include Sidekiq::Worker
+
+  def perform(mention_id)
+    mention = Mention.find(mention_id)
+    NotifyService.new.call(mention.account, mention)
+  rescue ActiveRecord::RecordNotFound
+    true
+  end
+end
diff --git a/app/workers/processing_worker.rb b/app/workers/processing_worker.rb
index 978c3aba2..5df404bcc 100644
--- a/app/workers/processing_worker.rb
+++ b/app/workers/processing_worker.rb
@@ -6,6 +6,6 @@ class ProcessingWorker
   sidekiq_options backtrace: true
 
   def perform(account_id, body)
-    ProcessFeedService.new.call(body, Account.find(account_id), override_timestamps: true)
+    ProcessFeedService.new.call(body, Account.find(account_id))
   end
 end
diff --git a/app/workers/scheduler/backup_cleanup_scheduler.rb b/app/workers/scheduler/backup_cleanup_scheduler.rb
index 7a9d4f894..5ab16c057 100644
--- a/app/workers/scheduler/backup_cleanup_scheduler.rb
+++ b/app/workers/scheduler/backup_cleanup_scheduler.rb
@@ -1,5 +1,4 @@
 # frozen_string_literal: true
-require 'sidekiq-scheduler'
 
 class Scheduler::BackupCleanupScheduler
   include Sidekiq::Worker
diff --git a/app/workers/scheduler/doorkeeper_cleanup_scheduler.rb b/app/workers/scheduler/doorkeeper_cleanup_scheduler.rb
index 6488798cd..bab4ae886 100644
--- a/app/workers/scheduler/doorkeeper_cleanup_scheduler.rb
+++ b/app/workers/scheduler/doorkeeper_cleanup_scheduler.rb
@@ -1,5 +1,4 @@
 # frozen_string_literal: true
-require 'sidekiq-scheduler'
 
 class Scheduler::DoorkeeperCleanupScheduler
   include Sidekiq::Worker
diff --git a/app/workers/scheduler/email_scheduler.rb b/app/workers/scheduler/email_scheduler.rb
index 24d0c0ebe..36866061b 100644
--- a/app/workers/scheduler/email_scheduler.rb
+++ b/app/workers/scheduler/email_scheduler.rb
@@ -1,5 +1,4 @@
 # frozen_string_literal: true
-require 'sidekiq-scheduler'
 
 class Scheduler::EmailScheduler
   include Sidekiq::Worker
diff --git a/app/workers/scheduler/feed_cleanup_scheduler.rb b/app/workers/scheduler/feed_cleanup_scheduler.rb
index 23fa7672b..42cf14128 100644
--- a/app/workers/scheduler/feed_cleanup_scheduler.rb
+++ b/app/workers/scheduler/feed_cleanup_scheduler.rb
@@ -1,5 +1,4 @@
 # frozen_string_literal: true
-require 'sidekiq-scheduler'
 
 class Scheduler::FeedCleanupScheduler
   include Sidekiq::Worker
diff --git a/app/workers/scheduler/ip_cleanup_scheduler.rb b/app/workers/scheduler/ip_cleanup_scheduler.rb
index a33ca031e..613a5e336 100644
--- a/app/workers/scheduler/ip_cleanup_scheduler.rb
+++ b/app/workers/scheduler/ip_cleanup_scheduler.rb
@@ -1,5 +1,4 @@
 # frozen_string_literal: true
-require 'sidekiq-scheduler'
 
 class Scheduler::IpCleanupScheduler
   include Sidekiq::Worker
diff --git a/app/workers/scheduler/media_cleanup_scheduler.rb b/app/workers/scheduler/media_cleanup_scheduler.rb
index ce32ce314..c35686fcb 100644
--- a/app/workers/scheduler/media_cleanup_scheduler.rb
+++ b/app/workers/scheduler/media_cleanup_scheduler.rb
@@ -1,5 +1,4 @@
 # frozen_string_literal: true
-require 'sidekiq-scheduler'
 
 class Scheduler::MediaCleanupScheduler
   include Sidekiq::Worker
diff --git a/app/workers/scheduler/subscriptions_cleanup_scheduler.rb b/app/workers/scheduler/subscriptions_cleanup_scheduler.rb
index 3b9211e81..af2ae3120 100644
--- a/app/workers/scheduler/subscriptions_cleanup_scheduler.rb
+++ b/app/workers/scheduler/subscriptions_cleanup_scheduler.rb
@@ -1,7 +1,5 @@
 # frozen_string_literal: true
 
-require 'sidekiq-scheduler'
-
 class Scheduler::SubscriptionsCleanupScheduler
   include Sidekiq::Worker
 
diff --git a/app/workers/scheduler/subscriptions_scheduler.rb b/app/workers/scheduler/subscriptions_scheduler.rb
index 469a3d2a6..dc16e85c2 100644
--- a/app/workers/scheduler/subscriptions_scheduler.rb
+++ b/app/workers/scheduler/subscriptions_scheduler.rb
@@ -1,8 +1,5 @@
 # frozen_string_literal: true
 
-require 'sidekiq-scheduler'
-require 'sidekiq-bulk'
-
 class Scheduler::SubscriptionsScheduler
   include Sidekiq::Worker
 
diff --git a/app/workers/scheduler/user_cleanup_scheduler.rb b/app/workers/scheduler/user_cleanup_scheduler.rb
index a8f8fbd83..245536cea 100644
--- a/app/workers/scheduler/user_cleanup_scheduler.rb
+++ b/app/workers/scheduler/user_cleanup_scheduler.rb
@@ -1,5 +1,4 @@
 # frozen_string_literal: true
-require 'sidekiq-scheduler'
 
 class Scheduler::UserCleanupScheduler
   include Sidekiq::Worker
diff --git a/app/workers/soft_block_domain_followers_worker.rb b/app/workers/soft_block_domain_followers_worker.rb
index ce76683c5..85445c7fb 100644
--- a/app/workers/soft_block_domain_followers_worker.rb
+++ b/app/workers/soft_block_domain_followers_worker.rb
@@ -1,7 +1,5 @@
 # frozen_string_literal: true
 
-require 'sidekiq-bulk'
-
 class SoftBlockDomainFollowersWorker
   include Sidekiq::Worker