about summary refs log tree commit diff
path: root/app
diff options
context:
space:
mode:
authorStarfall <admin@plural.cafe>2020-06-19 13:14:45 -0500
committerStarfall <admin@plural.cafe>2020-06-19 13:14:45 -0500
commit5668836f56cddf3257f38a2483c1d42cacbad3a8 (patch)
treec241a44562f79ccecaf215eb572c22d559b08dd1 /app
parent76f79dd29909d39f1d36ef48e6892223d59e29d7 (diff)
parentc7da2cc5a1821e589e7241aec21d8bb426722c2a (diff)
Merge branch 'glitch' into main
Diffstat (limited to 'app')
-rw-r--r--app/controllers/accounts_controller.rb8
-rw-r--r--app/controllers/activitypub/claims_controller.rb21
-rw-r--r--app/controllers/activitypub/collections_controller.rb48
-rw-r--r--app/controllers/admin/custom_emojis_controller.rb2
-rw-r--r--app/controllers/api/v1/crypto/deliveries_controller.rb30
-rw-r--r--app/controllers/api/v1/crypto/encrypted_messages_controller.rb59
-rw-r--r--app/controllers/api/v1/crypto/keys/claims_controller.rb25
-rw-r--r--app/controllers/api/v1/crypto/keys/counts_controller.rb17
-rw-r--r--app/controllers/api/v1/crypto/keys/queries_controller.rb26
-rw-r--r--app/controllers/api/v1/crypto/keys/uploads_controller.rb29
-rw-r--r--app/controllers/auth/sessions_controller.rb52
-rw-r--r--app/controllers/concerns/sign_in_token_authentication_concern.rb50
-rw-r--r--app/controllers/concerns/two_factor_authentication_concern.rb48
-rw-r--r--app/controllers/settings/migration/redirects_controller.rb2
-rw-r--r--app/controllers/statuses_controller.rb2
-rw-r--r--app/controllers/tags_controller.rb4
-rw-r--r--app/helpers/application_helper.rb5
-rw-r--r--app/helpers/webfinger_helper.rb19
-rw-r--r--app/javascript/flavours/glitch/actions/importer/normalizer.js2
-rw-r--r--app/javascript/flavours/glitch/components/autosuggest_textarea.js2
-rw-r--r--app/javascript/flavours/glitch/components/scrollable_list.js5
-rw-r--r--app/javascript/flavours/glitch/components/status.js1
-rw-r--r--app/javascript/flavours/glitch/components/timeline_hint.js18
-rw-r--r--app/javascript/flavours/glitch/features/account_timeline/index.js28
-rw-r--r--app/javascript/flavours/glitch/features/audio/index.js1
-rw-r--r--app/javascript/flavours/glitch/features/emoji_picker/index.js7
-rw-r--r--app/javascript/flavours/glitch/features/followers/index.js26
-rw-r--r--app/javascript/flavours/glitch/features/following/index.js26
-rw-r--r--app/javascript/flavours/glitch/features/keyboard_shortcuts/index.js4
-rw-r--r--app/javascript/flavours/glitch/features/status/components/card.js85
-rw-r--r--app/javascript/flavours/glitch/features/status/components/detailed_status.js2
-rw-r--r--app/javascript/flavours/glitch/features/ui/index.js11
-rw-r--r--app/javascript/flavours/glitch/styles/accessibility.scss10
-rw-r--r--app/javascript/flavours/glitch/styles/components/columns.scss21
-rw-r--r--app/javascript/flavours/glitch/styles/components/index.scss25
-rw-r--r--app/javascript/flavours/glitch/styles/components/status.scss26
-rw-r--r--app/javascript/flavours/glitch/styles/forms.scss2
-rw-r--r--app/javascript/flavours/glitch/styles/mastodon-light/variables.scss2
-rw-r--r--app/javascript/flavours/glitch/util/emoji/index.js16
-rw-r--r--app/javascript/mastodon/actions/importer/normalizer.js2
-rw-r--r--app/javascript/mastodon/components/autosuggest_textarea.js2
-rw-r--r--app/javascript/mastodon/components/modal_root.js2
-rw-r--r--app/javascript/mastodon/components/scrollable_list.js5
-rw-r--r--app/javascript/mastodon/components/status.js1
-rw-r--r--app/javascript/mastodon/components/timeline_hint.js18
-rw-r--r--app/javascript/mastodon/features/account_timeline/index.js28
-rw-r--r--app/javascript/mastodon/features/audio/index.js1
-rw-r--r--app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js7
-rw-r--r--app/javascript/mastodon/features/emoji/__tests__/emoji-test.js12
-rw-r--r--app/javascript/mastodon/features/emoji/emoji.js16
-rw-r--r--app/javascript/mastodon/features/followers/index.js28
-rw-r--r--app/javascript/mastodon/features/following/index.js28
-rw-r--r--app/javascript/mastodon/features/keyboard_shortcuts/index.js4
-rw-r--r--app/javascript/mastodon/features/status/components/card.js85
-rw-r--r--app/javascript/mastodon/features/status/components/detailed_status.js2
-rw-r--r--app/javascript/mastodon/features/ui/components/compose_panel.js38
-rw-r--r--app/javascript/mastodon/features/ui/index.js12
-rw-r--r--app/javascript/mastodon/locales/ar.json6
-rw-r--r--app/javascript/mastodon/locales/ast.json6
-rw-r--r--app/javascript/mastodon/locales/bg.json6
-rw-r--r--app/javascript/mastodon/locales/bn.json6
-rw-r--r--app/javascript/mastodon/locales/br.json6
-rw-r--r--app/javascript/mastodon/locales/ca.json6
-rw-r--r--app/javascript/mastodon/locales/co.json6
-rw-r--r--app/javascript/mastodon/locales/cs.json6
-rw-r--r--app/javascript/mastodon/locales/cy.json6
-rw-r--r--app/javascript/mastodon/locales/da.json6
-rw-r--r--app/javascript/mastodon/locales/de.json6
-rw-r--r--app/javascript/mastodon/locales/defaultMessages.json40
-rw-r--r--app/javascript/mastodon/locales/el.json6
-rw-r--r--app/javascript/mastodon/locales/en.json34
-rw-r--r--app/javascript/mastodon/locales/eo.json6
-rw-r--r--app/javascript/mastodon/locales/es-AR.json6
-rw-r--r--app/javascript/mastodon/locales/es.json6
-rw-r--r--app/javascript/mastodon/locales/et.json6
-rw-r--r--app/javascript/mastodon/locales/eu.json6
-rw-r--r--app/javascript/mastodon/locales/fa.json6
-rw-r--r--app/javascript/mastodon/locales/fi.json6
-rw-r--r--app/javascript/mastodon/locales/fr.json6
-rw-r--r--app/javascript/mastodon/locales/ga.json6
-rw-r--r--app/javascript/mastodon/locales/gl.json6
-rw-r--r--app/javascript/mastodon/locales/he.json6
-rw-r--r--app/javascript/mastodon/locales/hi.json6
-rw-r--r--app/javascript/mastodon/locales/hr.json6
-rw-r--r--app/javascript/mastodon/locales/hu.json6
-rw-r--r--app/javascript/mastodon/locales/hy.json6
-rw-r--r--app/javascript/mastodon/locales/id.json6
-rw-r--r--app/javascript/mastodon/locales/io.json6
-rw-r--r--app/javascript/mastodon/locales/is.json6
-rw-r--r--app/javascript/mastodon/locales/it.json6
-rw-r--r--app/javascript/mastodon/locales/ja.json6
-rw-r--r--app/javascript/mastodon/locales/ka.json6
-rw-r--r--app/javascript/mastodon/locales/kab.json6
-rw-r--r--app/javascript/mastodon/locales/kk.json6
-rw-r--r--app/javascript/mastodon/locales/kn.json6
-rw-r--r--app/javascript/mastodon/locales/ko.json6
-rw-r--r--app/javascript/mastodon/locales/lt.json6
-rw-r--r--app/javascript/mastodon/locales/lv.json6
-rw-r--r--app/javascript/mastodon/locales/mk.json6
-rw-r--r--app/javascript/mastodon/locales/ml.json6
-rw-r--r--app/javascript/mastodon/locales/mr.json6
-rw-r--r--app/javascript/mastodon/locales/ms.json6
-rw-r--r--app/javascript/mastodon/locales/nl.json6
-rw-r--r--app/javascript/mastodon/locales/nn.json6
-rw-r--r--app/javascript/mastodon/locales/no.json6
-rw-r--r--app/javascript/mastodon/locales/oc.json6
-rw-r--r--app/javascript/mastodon/locales/pl.json6
-rw-r--r--app/javascript/mastodon/locales/pt-BR.json6
-rw-r--r--app/javascript/mastodon/locales/pt-PT.json6
-rw-r--r--app/javascript/mastodon/locales/ro.json6
-rw-r--r--app/javascript/mastodon/locales/ru.json6
-rw-r--r--app/javascript/mastodon/locales/sc.json6
-rw-r--r--app/javascript/mastodon/locales/sk.json6
-rw-r--r--app/javascript/mastodon/locales/sl.json6
-rw-r--r--app/javascript/mastodon/locales/sq.json6
-rw-r--r--app/javascript/mastodon/locales/sr-Latn.json6
-rw-r--r--app/javascript/mastodon/locales/sr.json6
-rw-r--r--app/javascript/mastodon/locales/sv.json6
-rw-r--r--app/javascript/mastodon/locales/ta.json6
-rw-r--r--app/javascript/mastodon/locales/te.json6
-rw-r--r--app/javascript/mastodon/locales/th.json6
-rw-r--r--app/javascript/mastodon/locales/tr.json6
-rw-r--r--app/javascript/mastodon/locales/uk.json6
-rw-r--r--app/javascript/mastodon/locales/ur.json6
-rw-r--r--app/javascript/mastodon/locales/vi.json6
-rw-r--r--app/javascript/mastodon/locales/zh-CN.json6
-rw-r--r--app/javascript/mastodon/locales/zh-HK.json6
-rw-r--r--app/javascript/mastodon/locales/zh-TW.json6
-rw-r--r--app/javascript/styles/mastodon-light/variables.scss2
-rw-r--r--app/javascript/styles/mastodon/accessibility.scss11
-rw-r--r--app/javascript/styles/mastodon/components.scss51
-rw-r--r--app/javascript/styles/mastodon/forms.scss2
-rw-r--r--app/lib/activitypub/activity/create.rb50
-rw-r--r--app/lib/activitypub/adapter.rb1
-rw-r--r--app/lib/feed_manager.rb11
-rw-r--r--app/lib/inline_renderer.rb2
-rw-r--r--app/mailers/user_mailer.rb17
-rw-r--r--app/models/account.rb1
-rw-r--r--app/models/concerns/account_associations.rb1
-rw-r--r--app/models/concerns/domain_normalizable.rb2
-rw-r--r--app/models/device.rb35
-rw-r--r--app/models/domain_block.rb4
-rw-r--r--app/models/encrypted_message.rb50
-rw-r--r--app/models/message_franking.rb19
-rw-r--r--app/models/one_time_key.rb21
-rw-r--r--app/models/preview_card.rb9
-rw-r--r--app/models/system_key.rb41
-rw-r--r--app/models/user.rb28
-rw-r--r--app/presenters/activitypub/activity_presenter.rb41
-rw-r--r--app/presenters/initial_state_presenter.rb2
-rw-r--r--app/serializers/activitypub/activity_serializer.rb54
-rw-r--r--app/serializers/activitypub/actor_serializer.rb9
-rw-r--r--app/serializers/activitypub/collection_serializer.rb24
-rw-r--r--app/serializers/activitypub/device_serializer.rb52
-rw-r--r--app/serializers/activitypub/encrypted_message_serializer.rb61
-rw-r--r--app/serializers/activitypub/one_time_key_serializer.rb35
-rw-r--r--app/serializers/activitypub/outbox_serializer.rb11
-rw-r--r--app/serializers/activitypub/undo_announce_serializer.rb6
-rw-r--r--app/serializers/initial_state_serializer.rb2
-rw-r--r--app/serializers/rest/encrypted_message_serializer.rb19
-rw-r--r--app/serializers/rest/keys/claim_result_serializer.rb9
-rw-r--r--app/serializers/rest/keys/device_serializer.rb6
-rw-r--r--app/serializers/rest/keys/query_result_serializer.rb11
-rw-r--r--app/serializers/rest/preview_card_serializer.rb2
-rw-r--r--app/services/activitypub/process_account_service.rb1
-rw-r--r--app/services/backup_service.rb2
-rw-r--r--app/services/block_domain_service.rb53
-rw-r--r--app/services/clear_domain_media_service.rb70
-rw-r--r--app/services/concerns/payloadable.rb3
-rw-r--r--app/services/deliver_to_device_service.rb78
-rw-r--r--app/services/import_service.rb4
-rw-r--r--app/services/keys/claim_service.rb77
-rw-r--r--app/services/keys/query_service.rb75
-rw-r--r--app/services/process_mentions_service.rb2
-rw-r--r--app/services/reblog_service.rb2
-rw-r--r--app/services/resolve_account_service.rb2
-rw-r--r--app/validators/ed25519_key_validator.rb19
-rw-r--r--app/validators/ed25519_signature_validator.rb29
-rw-r--r--app/views/about/more.html.haml3
-rw-r--r--app/views/admin/custom_emojis/index.html.haml10
-rw-r--r--app/views/admin/instances/index.html.haml12
-rw-r--r--app/views/auth/sessions/sign_in_token.html.haml14
-rw-r--r--app/views/statuses/_detailed_status.html.haml2
-rw-r--r--app/views/statuses/_simple_status.html.haml2
-rw-r--r--app/views/user_mailer/sign_in_token.html.haml105
-rw-r--r--app/views/user_mailer/sign_in_token.text.erb17
-rw-r--r--app/workers/activitypub/distribution_worker.rb2
-rw-r--r--app/workers/activitypub/reply_distribution_worker.rb2
-rw-r--r--app/workers/domain_block_worker.rb7
-rw-r--r--app/workers/domain_clear_media_worker.rb14
-rw-r--r--app/workers/import/relationship_worker.rb21
-rw-r--r--app/workers/push_conversation_worker.rb3
-rw-r--r--app/workers/push_encrypted_message_worker.rb16
-rw-r--r--app/workers/scheduler/doorkeeper_cleanup_scheduler.rb1
194 files changed, 2597 insertions, 299 deletions
diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb
index e3d8c1061..b8bca580f 100644
--- a/app/controllers/accounts_controller.rb
+++ b/app/controllers/accounts_controller.rb
@@ -1,7 +1,8 @@
 # frozen_string_literal: true
 
 class AccountsController < ApplicationController
-  PAGE_SIZE = 20
+  PAGE_SIZE     = 20
+  PAGE_SIZE_MAX = 200
 
   include AccountControllerConcern
   include SignatureAuthentication
@@ -27,7 +28,7 @@ class AccountsController < ApplicationController
           return
         end
 
-        @pinned_statuses = cache_collection(@account.pinned_statuses, Status) if show_pinned_statuses?
+        @pinned_statuses = cache_collection(@account.pinned_statuses.not_local_only, Status) if show_pinned_statuses?
         @statuses        = filtered_status_page
         @statuses        = cache_collection(@statuses, Status)
         @rss_url         = rss_url
@@ -41,7 +42,8 @@ class AccountsController < ApplicationController
       format.rss do
         expires_in 1.minute, public: true
 
-        @statuses = filtered_statuses.without_reblogs.limit(PAGE_SIZE)
+        limit     = params[:limit].present? ? [params[:limit].to_i, PAGE_SIZE_MAX].min : PAGE_SIZE
+        @statuses = filtered_statuses.without_reblogs.limit(limit)
         @statuses = cache_collection(@statuses, Status)
         render xml: RSS::AccountSerializer.render(@account, @statuses, params[:tag])
       end
diff --git a/app/controllers/activitypub/claims_controller.rb b/app/controllers/activitypub/claims_controller.rb
new file mode 100644
index 000000000..08ad952df
--- /dev/null
+++ b/app/controllers/activitypub/claims_controller.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+class ActivityPub::ClaimsController < ActivityPub::BaseController
+  include SignatureVerification
+  include AccountOwnedConcern
+
+  skip_before_action :authenticate_user!
+
+  before_action :require_signature!
+  before_action :set_claim_result
+
+  def create
+    render json: @claim_result, serializer: ActivityPub::OneTimeKeySerializer
+  end
+
+  private
+
+  def set_claim_result
+    @claim_result = ::Keys::ClaimService.new.call(@account.id, params[:id])
+  end
+end
diff --git a/app/controllers/activitypub/collections_controller.rb b/app/controllers/activitypub/collections_controller.rb
index c1e7aa550..e62fba748 100644
--- a/app/controllers/activitypub/collections_controller.rb
+++ b/app/controllers/activitypub/collections_controller.rb
@@ -5,8 +5,9 @@ class ActivityPub::CollectionsController < ActivityPub::BaseController
   include AccountOwnedConcern
 
   before_action :require_signature!, if: :authorized_fetch_mode?
+  before_action :set_items
   before_action :set_size
-  before_action :set_statuses
+  before_action :set_type
   before_action :set_cache_headers
 
   def show
@@ -16,40 +17,53 @@ class ActivityPub::CollectionsController < ActivityPub::BaseController
 
   private
 
-  def set_statuses
-    @statuses = scope_for_collection
-    @statuses = cache_collection(@statuses, Status)
+  def set_items
+    case params[:id]
+    when 'featured'
+      @items = begin
+        # Because in public fetch mode we cache the response, there would be no
+        # benefit from performing the check below, since a blocked account or domain
+        # would likely be served the cache from the reverse proxy anyway
+
+        if authorized_fetch_mode? && !signed_request_account.nil? && (@account.blocking?(signed_request_account) || (!signed_request_account.domain.nil? && @account.domain_blocking?(signed_request_account.domain)))
+          []
+        else
+          cache_collection(@account.pinned_statuses.not_local_only, Status)
+        end
+      end
+    when 'devices'
+      @items = @account.devices
+    else
+      not_found
+    end
   end
 
   def set_size
     case params[:id]
-    when 'featured'
-      @size = @account.pinned_statuses.count
+    when 'featured', 'devices'
+      @size = @items.size
     else
       not_found
     end
   end
 
-  def scope_for_collection
+  def set_type
     case params[:id]
     when 'featured'
-      # Because in public fetch mode we cache the response, there would be no
-      # benefit from performing the check below, since a blocked account or domain
-      # would likely be served the cache from the reverse proxy anyway
-      if authorized_fetch_mode? && !signed_request_account.nil? && (@account.blocking?(signed_request_account) || (!signed_request_account.domain.nil? && @account.domain_blocking?(signed_request_account.domain)))
-        Status.none
-      else
-        @account.pinned_statuses
-      end
+      @type = :ordered
+    when 'devices'
+      @type = :unordered
+    else
+      not_found
     end
   end
 
   def collection_presenter
     ActivityPub::CollectionPresenter.new(
       id: account_collection_url(@account, params[:id]),
-      type: :ordered,
+      type: @type,
       size: @size,
-      items: @statuses
+      items: @items
     )
   end
 end
diff --git a/app/controllers/admin/custom_emojis_controller.rb b/app/controllers/admin/custom_emojis_controller.rb
index efa8f2950..71efb543e 100644
--- a/app/controllers/admin/custom_emojis_controller.rb
+++ b/app/controllers/admin/custom_emojis_controller.rb
@@ -33,6 +33,8 @@ module Admin
       @form.save
     rescue ActionController::ParameterMissing
       flash[:alert] = I18n.t('admin.accounts.no_account_selected')
+    rescue Mastodon::NotPermittedError
+      flash[:alert] = I18n.t('admin.custom_emojis.not_permitted')
     ensure
       redirect_to admin_custom_emojis_path(filter_params)
     end
diff --git a/app/controllers/api/v1/crypto/deliveries_controller.rb b/app/controllers/api/v1/crypto/deliveries_controller.rb
new file mode 100644
index 000000000..aa9df6e03
--- /dev/null
+++ b/app/controllers/api/v1/crypto/deliveries_controller.rb
@@ -0,0 +1,30 @@
+# frozen_string_literal: true
+
+class Api::V1::Crypto::DeliveriesController < Api::BaseController
+  before_action -> { doorkeeper_authorize! :crypto }
+  before_action :require_user!
+  before_action :set_current_device
+
+  def create
+    devices.each do |device_params|
+      DeliverToDeviceService.new.call(current_account, @current_device, device_params)
+    end
+
+    render_empty
+  end
+
+  private
+
+  def set_current_device
+    @current_device = Device.find_by!(access_token: doorkeeper_token)
+  end
+
+  def resource_params
+    params.require(:device)
+    params.permit(device: [:account_id, :device_id, :type, :body, :hmac])
+  end
+
+  def devices
+    Array(resource_params[:device])
+  end
+end
diff --git a/app/controllers/api/v1/crypto/encrypted_messages_controller.rb b/app/controllers/api/v1/crypto/encrypted_messages_controller.rb
new file mode 100644
index 000000000..c764915e5
--- /dev/null
+++ b/app/controllers/api/v1/crypto/encrypted_messages_controller.rb
@@ -0,0 +1,59 @@
+# frozen_string_literal: true
+
+class Api::V1::Crypto::EncryptedMessagesController < Api::BaseController
+  LIMIT = 80
+
+  before_action -> { doorkeeper_authorize! :crypto }
+  before_action :require_user!
+  before_action :set_current_device
+
+  before_action :set_encrypted_messages,    only: :index
+  after_action  :insert_pagination_headers, only: :index
+
+  def index
+    render json: @encrypted_messages, each_serializer: REST::EncryptedMessageSerializer
+  end
+
+  def clear
+    @current_device.encrypted_messages.up_to(params[:up_to_id]).delete_all
+    render_empty
+  end
+
+  private
+
+  def set_current_device
+    @current_device = Device.find_by!(access_token: doorkeeper_token)
+  end
+
+  def set_encrypted_messages
+    @encrypted_messages = @current_device.encrypted_messages.paginate_by_id(limit_param(LIMIT), params_slice(:max_id, :since_id, :min_id))
+  end
+
+  def insert_pagination_headers
+    set_pagination_headers(next_path, prev_path)
+  end
+
+  def next_path
+    api_v1_crypto_encrypted_messages_url pagination_params(max_id: pagination_max_id) if records_continue?
+  end
+
+  def prev_path
+    api_v1_crypto_encrypted_messages_url pagination_params(min_id: pagination_since_id) unless @encrypted_messages.empty?
+  end
+
+  def pagination_max_id
+    @encrypted_messages.last.id
+  end
+
+  def pagination_since_id
+    @encrypted_messages.first.id
+  end
+
+  def records_continue?
+    @encrypted_messages.size == limit_param(LIMIT)
+  end
+
+  def pagination_params(core_params)
+    params.slice(:limit).permit(:limit).merge(core_params)
+  end
+end
diff --git a/app/controllers/api/v1/crypto/keys/claims_controller.rb b/app/controllers/api/v1/crypto/keys/claims_controller.rb
new file mode 100644
index 000000000..34b21a380
--- /dev/null
+++ b/app/controllers/api/v1/crypto/keys/claims_controller.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+class Api::V1::Crypto::Keys::ClaimsController < Api::BaseController
+  before_action -> { doorkeeper_authorize! :crypto }
+  before_action :require_user!
+  before_action :set_claim_results
+
+  def create
+    render json: @claim_results, each_serializer: REST::Keys::ClaimResultSerializer
+  end
+
+  private
+
+  def set_claim_results
+    @claim_results = devices.map { |device_params| ::Keys::ClaimService.new.call(current_account, device_params[:account_id], device_params[:device_id]) }.compact
+  end
+
+  def resource_params
+    params.permit(device: [:account_id, :device_id])
+  end
+
+  def devices
+    Array(resource_params[:device])
+  end
+end
diff --git a/app/controllers/api/v1/crypto/keys/counts_controller.rb b/app/controllers/api/v1/crypto/keys/counts_controller.rb
new file mode 100644
index 000000000..ffd7151b7
--- /dev/null
+++ b/app/controllers/api/v1/crypto/keys/counts_controller.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+class Api::V1::Crypto::Keys::CountsController < Api::BaseController
+  before_action -> { doorkeeper_authorize! :crypto }
+  before_action :require_user!
+  before_action :set_current_device
+
+  def show
+    render json: { one_time_keys: @current_device.one_time_keys.count }
+  end
+
+  private
+
+  def set_current_device
+    @current_device = Device.find_by!(access_token: doorkeeper_token)
+  end
+end
diff --git a/app/controllers/api/v1/crypto/keys/queries_controller.rb b/app/controllers/api/v1/crypto/keys/queries_controller.rb
new file mode 100644
index 000000000..0851d797d
--- /dev/null
+++ b/app/controllers/api/v1/crypto/keys/queries_controller.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+class Api::V1::Crypto::Keys::QueriesController < Api::BaseController
+  before_action -> { doorkeeper_authorize! :crypto }
+  before_action :require_user!
+  before_action :set_accounts
+  before_action :set_query_results
+
+  def create
+    render json: @query_results, each_serializer: REST::Keys::QueryResultSerializer
+  end
+
+  private
+
+  def set_accounts
+    @accounts = Account.where(id: account_ids).includes(:devices)
+  end
+
+  def set_query_results
+    @query_results = @accounts.map { |account| ::Keys::QueryService.new.call(account) }.compact
+  end
+
+  def account_ids
+    Array(params[:id]).map(&:to_i)
+  end
+end
diff --git a/app/controllers/api/v1/crypto/keys/uploads_controller.rb b/app/controllers/api/v1/crypto/keys/uploads_controller.rb
new file mode 100644
index 000000000..fc4abf63b
--- /dev/null
+++ b/app/controllers/api/v1/crypto/keys/uploads_controller.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+class Api::V1::Crypto::Keys::UploadsController < Api::BaseController
+  before_action -> { doorkeeper_authorize! :crypto }
+  before_action :require_user!
+
+  def create
+    device = Device.find_or_initialize_by(access_token: doorkeeper_token)
+
+    device.transaction do
+      device.account = current_account
+      device.update!(resource_params[:device])
+
+      if resource_params[:one_time_keys].present? && resource_params[:one_time_keys].is_a?(Enumerable)
+        resource_params[:one_time_keys].each do |one_time_key_params|
+          device.one_time_keys.create!(one_time_key_params)
+        end
+      end
+    end
+
+    render json: device, serializer: REST::Keys::DeviceSerializer
+  end
+
+  private
+
+  def resource_params
+    params.permit(device: [:device_id, :name, :fingerprint_key, :identity_key], one_time_keys: [:key_id, :key, :signature])
+  end
+end
diff --git a/app/controllers/auth/sessions_controller.rb b/app/controllers/auth/sessions_controller.rb
index c36561b86..c54f6643a 100644
--- a/app/controllers/auth/sessions_controller.rb
+++ b/app/controllers/auth/sessions_controller.rb
@@ -9,7 +9,9 @@ class Auth::SessionsController < Devise::SessionsController
   skip_before_action :require_functional!
 
   prepend_before_action :set_pack
-  prepend_before_action :authenticate_with_two_factor, if: :two_factor_enabled?, only: [:create]
+
+  include TwoFactorAuthenticationConcern
+  include SignInTokenAuthenticationConcern
 
   before_action :set_instance_presenter, only: [:new]
   before_action :set_body_classes
@@ -40,8 +42,8 @@ class Auth::SessionsController < Devise::SessionsController
   protected
 
   def find_user
-    if session[:otp_user_id]
-      User.find(session[:otp_user_id])
+    if session[:attempt_user_id]
+      User.find(session[:attempt_user_id])
     else
       user   = User.authenticate_with_ldap(user_params) if Devise.ldap_authentication
       user ||= User.authenticate_with_pam(user_params) if Devise.pam_authentication
@@ -50,7 +52,7 @@ class Auth::SessionsController < Devise::SessionsController
   end
 
   def user_params
-    params.require(:user).permit(:email, :password, :otp_attempt)
+    params.require(:user).permit(:email, :password, :otp_attempt, :sign_in_token_attempt)
   end
 
   def after_sign_in_path_for(resource)
@@ -71,48 +73,6 @@ class Auth::SessionsController < Devise::SessionsController
     super
   end
 
-  def two_factor_enabled?
-    find_user&.otp_required_for_login?
-  end
-
-  def valid_otp_attempt?(user)
-    user.validate_and_consume_otp!(user_params[:otp_attempt]) ||
-      user.invalidate_otp_backup_code!(user_params[:otp_attempt])
-  rescue OpenSSL::Cipher::CipherError
-    false
-  end
-
-  def authenticate_with_two_factor
-    user = self.resource = find_user
-
-    if user_params[:otp_attempt].present? && session[:otp_user_id]
-      authenticate_with_two_factor_via_otp(user)
-    elsif user.present? && (user.encrypted_password.blank? || user.valid_password?(user_params[:password]))
-      # If encrypted_password is blank, we got the user from LDAP or PAM,
-      # so credentials are already valid
-
-      prompt_for_two_factor(user)
-    end
-  end
-
-  def authenticate_with_two_factor_via_otp(user)
-    if valid_otp_attempt?(user)
-      session.delete(:otp_user_id)
-      remember_me(user)
-      sign_in(user)
-    else
-      flash.now[:alert] = I18n.t('users.invalid_otp_token')
-      prompt_for_two_factor(user)
-    end
-  end
-
-  def prompt_for_two_factor(user)
-    session[:otp_user_id] = user.id
-    use_pack 'auth'
-    @body_classes = 'lighter'
-    render :two_factor
-  end
-
   def require_no_authentication
     super
     # Delete flash message that isn't entirely useful and may be confusing in
diff --git a/app/controllers/concerns/sign_in_token_authentication_concern.rb b/app/controllers/concerns/sign_in_token_authentication_concern.rb
new file mode 100644
index 000000000..88c009b19
--- /dev/null
+++ b/app/controllers/concerns/sign_in_token_authentication_concern.rb
@@ -0,0 +1,50 @@
+# frozen_string_literal: true
+
+module SignInTokenAuthenticationConcern
+  extend ActiveSupport::Concern
+
+  included do
+    prepend_before_action :authenticate_with_sign_in_token, if: :sign_in_token_required?, only: [:create]
+  end
+
+  def sign_in_token_required?
+    find_user&.suspicious_sign_in?(request.remote_ip)
+  end
+
+  def valid_sign_in_token_attempt?(user)
+    Devise.secure_compare(user.sign_in_token, user_params[:sign_in_token_attempt])
+  end
+
+  def authenticate_with_sign_in_token
+    user = self.resource = find_user
+
+    if user_params[:sign_in_token_attempt].present? && session[:attempt_user_id]
+      authenticate_with_sign_in_token_attempt(user)
+    elsif user.present? && user.external_or_valid_password?(user_params[:password])
+      prompt_for_sign_in_token(user)
+    end
+  end
+
+  def authenticate_with_sign_in_token_attempt(user)
+    if valid_sign_in_token_attempt?(user)
+      session.delete(:attempt_user_id)
+      remember_me(user)
+      sign_in(user)
+    else
+      flash.now[:alert] = I18n.t('users.invalid_sign_in_token')
+      prompt_for_sign_in_token(user)
+    end
+  end
+
+  def prompt_for_sign_in_token(user)
+    if user.sign_in_token_expired?
+      user.generate_sign_in_token && user.save
+      UserMailer.sign_in_token(user, request.remote_ip, request.user_agent, Time.now.utc.to_s).deliver_later!
+    end
+
+    session[:attempt_user_id] = user.id
+    use_pack 'auth'
+    @body_classes = 'lighter'
+    render :sign_in_token
+  end
+end
diff --git a/app/controllers/concerns/two_factor_authentication_concern.rb b/app/controllers/concerns/two_factor_authentication_concern.rb
new file mode 100644
index 000000000..0d9f87455
--- /dev/null
+++ b/app/controllers/concerns/two_factor_authentication_concern.rb
@@ -0,0 +1,48 @@
+# frozen_string_literal: true
+
+module TwoFactorAuthenticationConcern
+  extend ActiveSupport::Concern
+
+  included do
+    prepend_before_action :authenticate_with_two_factor, if: :two_factor_enabled?, only: [:create]
+  end
+
+  def two_factor_enabled?
+    find_user&.otp_required_for_login?
+  end
+
+  def valid_otp_attempt?(user)
+    user.validate_and_consume_otp!(user_params[:otp_attempt]) ||
+      user.invalidate_otp_backup_code!(user_params[:otp_attempt])
+  rescue OpenSSL::Cipher::CipherError
+    false
+  end
+
+  def authenticate_with_two_factor
+    user = self.resource = find_user
+
+    if user_params[:otp_attempt].present? && session[:attempt_user_id]
+      authenticate_with_two_factor_attempt(user)
+    elsif user.present? && user.external_or_valid_password?(user_params[:password])
+      prompt_for_two_factor(user)
+    end
+  end
+
+  def authenticate_with_two_factor_attempt(user)
+    if valid_otp_attempt?(user)
+      session.delete(:attempt_user_id)
+      remember_me(user)
+      sign_in(user)
+    else
+      flash.now[:alert] = I18n.t('users.invalid_otp_token')
+      prompt_for_two_factor(user)
+    end
+  end
+
+  def prompt_for_two_factor(user)
+    session[:attempt_user_id] = user.id
+    use_pack 'auth'
+    @body_classes = 'lighter'
+    render :two_factor
+  end
+end
diff --git a/app/controllers/settings/migration/redirects_controller.rb b/app/controllers/settings/migration/redirects_controller.rb
index 6e5b72ffb..97193ade0 100644
--- a/app/controllers/settings/migration/redirects_controller.rb
+++ b/app/controllers/settings/migration/redirects_controller.rb
@@ -18,7 +18,7 @@ class Settings::Migration::RedirectsController < Settings::BaseController
     if @redirect.valid_with_challenge?(current_user)
       current_account.update!(moved_to_account: @redirect.target_account)
       ActivityPub::UpdateDistributionWorker.perform_async(current_account.id)
-      redirect_to settings_migration_path, notice: I18n.t('migrations.moved_msg', acct: current_account.moved_to_account.acct)
+      redirect_to settings_migration_path, notice: I18n.t('migrations.redirected_msg', acct: current_account.moved_to_account.acct)
     else
       render :new
     end
diff --git a/app/controllers/statuses_controller.rb b/app/controllers/statuses_controller.rb
index a1b7f4320..b0abad984 100644
--- a/app/controllers/statuses_controller.rb
+++ b/app/controllers/statuses_controller.rb
@@ -44,7 +44,7 @@ class StatusesController < ApplicationController
 
   def activity
     expires_in 3.minutes, public: @status.distributable? && public_fetch_mode?
-    render_with_cache json: @status, content_type: 'application/activity+json', serializer: ActivityPub::ActivitySerializer, adapter: ActivityPub::Adapter
+    render_with_cache json: ActivityPub::ActivityPresenter.from_status(@status), content_type: 'application/activity+json', serializer: ActivityPub::ActivitySerializer, adapter: ActivityPub::Adapter
   end
 
   def embed
diff --git a/app/controllers/tags_controller.rb b/app/controllers/tags_controller.rb
index 3d12c9eaf..2363cb31b 100644
--- a/app/controllers/tags_controller.rb
+++ b/app/controllers/tags_controller.rb
@@ -3,7 +3,8 @@
 class TagsController < ApplicationController
   include SignatureVerification
 
-  PAGE_SIZE = 20
+  PAGE_SIZE     = 20
+  PAGE_SIZE_MAX = 200
 
   layout 'public'
 
@@ -26,6 +27,7 @@ class TagsController < ApplicationController
       format.rss do
         expires_in 0, public: true
 
+        limit     = params[:limit].present? ? [params[:limit].to_i, PAGE_SIZE_MAX].min : PAGE_SIZE
         @statuses = HashtagQueryService.new.call(@tag, filter_params, nil, @local).limit(PAGE_SIZE)
         @statuses = cache_collection(@statuses, Status)
 
diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb
index 40f914f1e..2f11ccb6f 100644
--- a/app/helpers/application_helper.rb
+++ b/app/helpers/application_helper.rb
@@ -137,6 +137,11 @@ module ApplicationHelper
       text: [params[:title], params[:text], params[:url]].compact.join(' '),
     }
 
+    permit_visibilities = %w(public unlisted private direct)
+    default_privacy     = current_account&.user&.setting_default_privacy
+    permit_visibilities.shift(permit_visibilities.index(default_privacy) + 1) if default_privacy.present?
+    state_params[:visibility] = params[:visibility] if permit_visibilities.include? params[:visibility]
+
     if user_signed_in?
       state_params[:settings]          = state_params[:settings].merge(Web::Setting.find_by(user: current_user)&.data || {})
       state_params[:push_subscription] = current_account.user.web_push_subscription(current_session)
diff --git a/app/helpers/webfinger_helper.rb b/app/helpers/webfinger_helper.rb
index 70c493210..ab7ca4698 100644
--- a/app/helpers/webfinger_helper.rb
+++ b/app/helpers/webfinger_helper.rb
@@ -1,5 +1,16 @@
 # frozen_string_literal: true
 
+# Monkey-patch on monkey-patch.
+# Because it conflicts with the request.rb patch.
+class HTTP::Timeout::PerOperationOriginal < HTTP::Timeout::PerOperation
+  def connect(socket_class, host, port, nodelay = false)
+    ::Timeout.timeout(@connect_timeout, HTTP::TimeoutError) do
+      @socket = socket_class.open(host, port)
+      @socket.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_NODELAY, 1) if nodelay
+    end
+  end
+end
+
 module WebfingerHelper
   def webfinger!(uri)
     hidden_service_uri = /\.(onion|i2p)(:\d+)?$/.match(uri)
@@ -12,6 +23,14 @@ module WebfingerHelper
       headers: {
         'User-Agent': Mastodon::Version.user_agent,
       },
+
+      timeout_class: HTTP::Timeout::PerOperationOriginal,
+
+      timeout_options: {
+        write_timeout: 10,
+        connect_timeout: 5,
+        read_timeout: 10,
+      },
     }
 
     Goldfinger::Client.new(uri, opts.merge(Rails.configuration.x.http_client_proxy)).finger
diff --git a/app/javascript/flavours/glitch/actions/importer/normalizer.js b/app/javascript/flavours/glitch/actions/importer/normalizer.js
index 52ad17779..05955963c 100644
--- a/app/javascript/flavours/glitch/actions/importer/normalizer.js
+++ b/app/javascript/flavours/glitch/actions/importer/normalizer.js
@@ -12,7 +12,7 @@ const makeEmojiMap = record => record.emojis.reduce((obj, emoji) => {
 
 export function searchTextFromRawStatus (status) {
   const spoilerText   = status.spoiler_text || '';
-  const searchContent = ([spoilerText, status.content].concat((status.poll && status.poll.options) ? status.poll.options.map(option => option.title) : [])).join('\n\n').replace(/<br\s*\/?>/g, '\n').replace(/<\/p><p>/g, '\n\n');
+  const searchContent = ([spoilerText, status.content].concat((status.poll && status.poll.options) ? status.poll.options.map(option => option.title) : [])).concat(status.media_attachments.map(att => att.description)).join('\n\n').replace(/<br\s*\/?>/g, '\n').replace(/<\/p><p>/g, '\n\n');
   return domParser.parseFromString(searchContent, 'text/html').documentElement.textContent;
 }
 
diff --git a/app/javascript/flavours/glitch/components/autosuggest_textarea.js b/app/javascript/flavours/glitch/components/autosuggest_textarea.js
index ec2fbbe4b..1ce2f42b4 100644
--- a/app/javascript/flavours/glitch/components/autosuggest_textarea.js
+++ b/app/javascript/flavours/glitch/components/autosuggest_textarea.js
@@ -208,7 +208,7 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
             <span style={{ display: 'none' }}>{placeholder}</span>
 
             <Textarea
-              inputRef={this.setTextarea}
+              ref={this.setTextarea}
               className='autosuggest-textarea__textarea'
               disabled={disabled}
               placeholder={placeholder}
diff --git a/app/javascript/flavours/glitch/components/scrollable_list.js b/app/javascript/flavours/glitch/components/scrollable_list.js
index c022290a4..fae0a7393 100644
--- a/app/javascript/flavours/glitch/components/scrollable_list.js
+++ b/app/javascript/flavours/glitch/components/scrollable_list.js
@@ -32,6 +32,7 @@ export default class ScrollableList extends PureComponent {
     hasMore: PropTypes.bool,
     numPending: PropTypes.number,
     prepend: PropTypes.node,
+    append: PropTypes.node,
     alwaysPrepend: PropTypes.bool,
     emptyMessage: PropTypes.node,
     children: PropTypes.node,
@@ -272,7 +273,7 @@ export default class ScrollableList extends PureComponent {
   }
 
   render () {
-    const { children, scrollKey, trackScroll, shouldUpdateScroll, showLoading, isLoading, hasMore, numPending, prepend, alwaysPrepend, emptyMessage, onLoadMore } = this.props;
+    const { children, scrollKey, trackScroll, shouldUpdateScroll, showLoading, isLoading, hasMore, numPending, prepend, alwaysPrepend, append, emptyMessage, onLoadMore } = this.props;
     const { fullscreen } = this.state;
     const childrenCount = React.Children.count(children);
 
@@ -319,6 +320,8 @@ export default class ScrollableList extends PureComponent {
             ))}
 
             {loadMore}
+
+            {!hasMore && append}
           </div>
         </div>
       );
diff --git a/app/javascript/flavours/glitch/components/status.js b/app/javascript/flavours/glitch/components/status.js
index 91bc06b3c..e036c0da7 100644
--- a/app/javascript/flavours/glitch/components/status.js
+++ b/app/javascript/flavours/glitch/components/status.js
@@ -656,6 +656,7 @@ class Status extends ImmutablePureComponent {
           compact
           cacheWidth={this.props.cacheMediaWidth}
           defaultWidth={this.props.cachedMediaWidth}
+          sensitive={status.get('sensitive')}
         />
       );
       mediaIcon = 'link';
diff --git a/app/javascript/flavours/glitch/components/timeline_hint.js b/app/javascript/flavours/glitch/components/timeline_hint.js
new file mode 100644
index 000000000..fb55a62cc
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/timeline_hint.js
@@ -0,0 +1,18 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { FormattedMessage } from 'react-intl';
+
+const TimelineHint = ({ resource, url }) => (
+  <div className='timeline-hint'>
+    <strong><FormattedMessage id='timeline_hint.remote_resource_not_displayed' defaultMessage='{resource} from other servers are not displayed.' values={{ resource }} /></strong>
+    <br />
+    <a href={url} target='_blank'><FormattedMessage id='account.browse_more_on_origin_server' defaultMessage='Browse more on the original profile' /></a>
+  </div>
+);
+
+TimelineHint.propTypes = {
+  resource: PropTypes.node.isRequired,
+  url: PropTypes.string.isRequired,
+};
+
+export default TimelineHint;
diff --git a/app/javascript/flavours/glitch/features/account_timeline/index.js b/app/javascript/flavours/glitch/features/account_timeline/index.js
index f25c82a00..a8e8aa7a8 100644
--- a/app/javascript/flavours/glitch/features/account_timeline/index.js
+++ b/app/javascript/flavours/glitch/features/account_timeline/index.js
@@ -15,11 +15,14 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
 import { FormattedMessage } from 'react-intl';
 import { fetchAccountIdentityProofs } from '../../actions/identity_proofs';
 import MissingIndicator from 'flavours/glitch/components/missing_indicator';
+import TimelineHint from 'flavours/glitch/components/timeline_hint';
 
 const mapStateToProps = (state, { params: { accountId }, withReplies = false }) => {
   const path = withReplies ? `${accountId}:with_replies` : accountId;
 
   return {
+    remote: !!state.getIn(['accounts', accountId, 'acct']) !== state.getIn(['accounts', accountId, 'username']),
+    remoteUrl: state.getIn(['accounts', accountId, 'url']),
     isAccount: !!state.getIn(['accounts', accountId]),
     statusIds: state.getIn(['timelines', `account:${path}`, 'items'], ImmutableList()),
     featuredStatusIds: withReplies ? ImmutableList() : state.getIn(['timelines', `account:${accountId}:pinned`, 'items'], ImmutableList()),
@@ -28,6 +31,14 @@ const mapStateToProps = (state, { params: { accountId }, withReplies = false })
   };
 };
 
+const RemoteHint = ({ url }) => (
+  <TimelineHint url={url} resource={<FormattedMessage id='timeline_hint.resources.statuses' defaultMessage='Older toots' />} />
+);
+
+RemoteHint.propTypes = {
+  url: PropTypes.string.isRequired,
+};
+
 export default @connect(mapStateToProps)
 class AccountTimeline extends ImmutablePureComponent {
 
@@ -40,6 +51,8 @@ class AccountTimeline extends ImmutablePureComponent {
     hasMore: PropTypes.bool,
     withReplies: PropTypes.bool,
     isAccount: PropTypes.bool,
+    remote: PropTypes.bool,
+    remoteUrl: PropTypes.string,
     multiColumn: PropTypes.bool,
   };
 
@@ -78,7 +91,7 @@ class AccountTimeline extends ImmutablePureComponent {
   }
 
   render () {
-    const { statusIds, featuredStatusIds, isLoading, hasMore, isAccount, multiColumn } = this.props;
+    const { statusIds, featuredStatusIds, isLoading, hasMore, isAccount, multiColumn, remote, remoteUrl } = this.props;
 
     if (!isAccount) {
       return (
@@ -97,6 +110,16 @@ class AccountTimeline extends ImmutablePureComponent {
       );
     }
 
+    let emptyMessage;
+
+    if (remote && statusIds.isEmpty()) {
+      emptyMessage = <RemoteHint url={remoteUrl} />;
+    } else {
+      emptyMessage = <FormattedMessage id='empty_column.account_timeline' defaultMessage='No toots here!' />;
+    }
+
+    const remoteMessage = remote ? <RemoteHint url={remoteUrl} /> : null;
+
     return (
       <Column ref={this.setRef} name='account'>
         <ProfileColumnHeader onClick={this.handleHeaderClick} multiColumn={multiColumn} />
@@ -104,13 +127,14 @@ class AccountTimeline extends ImmutablePureComponent {
         <StatusList
           prepend={<HeaderContainer accountId={this.props.params.accountId} />}
           alwaysPrepend
+          append={remoteMessage}
           scrollKey='account_timeline'
           statusIds={statusIds}
           featuredStatusIds={featuredStatusIds}
           isLoading={isLoading}
           hasMore={hasMore}
           onLoadMore={this.handleLoadMore}
-          emptyMessage={<FormattedMessage id='empty_column.account_timeline' defaultMessage='No toots here!' />}
+          emptyMessage={emptyMessage}
           bindToDocument={!multiColumn}
           timelineId='account'
         />
diff --git a/app/javascript/flavours/glitch/features/audio/index.js b/app/javascript/flavours/glitch/features/audio/index.js
index 49e91227f..ba3534492 100644
--- a/app/javascript/flavours/glitch/features/audio/index.js
+++ b/app/javascript/flavours/glitch/features/audio/index.js
@@ -125,6 +125,7 @@ class Audio extends React.PureComponent {
         this.wavesurfer.createPeakCache();
         this.wavesurfer.load(this.props.src);
         this.wavesurfer.toggleInteraction();
+        this.wavesurfer.setVolume(this.state.volume);
         this.loaded = true;
       }
 
diff --git a/app/javascript/flavours/glitch/features/emoji_picker/index.js b/app/javascript/flavours/glitch/features/emoji_picker/index.js
index 3717fcd82..14e5cb94a 100644
--- a/app/javascript/flavours/glitch/features/emoji_picker/index.js
+++ b/app/javascript/flavours/glitch/features/emoji_picker/index.js
@@ -279,12 +279,13 @@ class EmojiPickerMenu extends React.PureComponent {
     };
   }
 
-  handleClick = emoji => {
+  handleClick = (emoji, event) => {
     if (!emoji.native) {
       emoji.native = emoji.colons;
     }
-
-    this.props.onClose();
+    if (!event.ctrlKey) {
+      this.props.onClose();
+    }
     this.props.onPick(emoji);
   }
 
diff --git a/app/javascript/flavours/glitch/features/followers/index.js b/app/javascript/flavours/glitch/features/followers/index.js
index bf41f3b98..8ae46be94 100644
--- a/app/javascript/flavours/glitch/features/followers/index.js
+++ b/app/javascript/flavours/glitch/features/followers/index.js
@@ -17,14 +17,25 @@ import HeaderContainer from 'flavours/glitch/features/account_timeline/container
 import ImmutablePureComponent from 'react-immutable-pure-component';
 import MissingIndicator from 'flavours/glitch/components/missing_indicator';
 import ScrollableList from 'flavours/glitch/components/scrollable_list';
+import TimelineHint from 'flavours/glitch/components/timeline_hint';
 
 const mapStateToProps = (state, props) => ({
+  remote: !!state.getIn(['accounts', props.params.accountId, 'acct']) !== state.getIn(['accounts', props.params.accountId, 'username']),
+  remoteUrl: state.getIn(['accounts', props.params.accountId, 'url']),
   isAccount: !!state.getIn(['accounts', props.params.accountId]),
   accountIds: state.getIn(['user_lists', 'followers', props.params.accountId, 'items']),
   hasMore: !!state.getIn(['user_lists', 'followers', props.params.accountId, 'next']),
   isLoading: state.getIn(['user_lists', 'followers', props.params.accountId, 'isLoading'], true),
 });
 
+const RemoteHint = ({ url }) => (
+  <TimelineHint url={url} resource={<FormattedMessage id='timeline_hint.resources.followers' defaultMessage='Followers' />} />
+);
+
+RemoteHint.propTypes = {
+  url: PropTypes.string.isRequired,
+};
+
 export default @connect(mapStateToProps)
 class Followers extends ImmutablePureComponent {
 
@@ -35,6 +46,8 @@ class Followers extends ImmutablePureComponent {
     hasMore: PropTypes.bool,
     isLoading: PropTypes.bool,
     isAccount: PropTypes.bool,
+    remote: PropTypes.bool,
+    remoteUrl: PropTypes.string,
     multiColumn: PropTypes.bool,
   };
 
@@ -65,7 +78,7 @@ class Followers extends ImmutablePureComponent {
   }
 
   render () {
-    const { accountIds, hasMore, isAccount, multiColumn, isLoading } = this.props;
+    const { accountIds, hasMore, isAccount, multiColumn, isLoading, remote, remoteUrl } = this.props;
 
     if (!isAccount) {
       return (
@@ -83,7 +96,15 @@ class Followers extends ImmutablePureComponent {
       );
     }
 
-    const emptyMessage = <FormattedMessage id='account.followers.empty' defaultMessage='No one follows this user yet.' />;
+    let emptyMessage;
+
+    if (remote && accountIds.isEmpty()) {
+      emptyMessage = <RemoteHint url={remoteUrl} />;
+    } else {
+      emptyMessage = <FormattedMessage id='account.followers.empty' defaultMessage='No one follows this user yet.' />;
+    }
+
+    const remoteMessage = remote ? <RemoteHint url={remoteUrl} /> : null;
 
     return (
       <Column ref={this.setRef}>
@@ -96,6 +117,7 @@ class Followers extends ImmutablePureComponent {
           onLoadMore={this.handleLoadMore}
           prepend={<HeaderContainer accountId={this.props.params.accountId} hideTabs />}
           alwaysPrepend
+          append={remoteMessage}
           emptyMessage={emptyMessage}
           bindToDocument={!multiColumn}
         >
diff --git a/app/javascript/flavours/glitch/features/following/index.js b/app/javascript/flavours/glitch/features/following/index.js
index f090900cc..e06eaa8a6 100644
--- a/app/javascript/flavours/glitch/features/following/index.js
+++ b/app/javascript/flavours/glitch/features/following/index.js
@@ -17,14 +17,25 @@ import HeaderContainer from 'flavours/glitch/features/account_timeline/container
 import ImmutablePureComponent from 'react-immutable-pure-component';
 import MissingIndicator from 'flavours/glitch/components/missing_indicator';
 import ScrollableList from 'flavours/glitch/components/scrollable_list';
+import TimelineHint from 'flavours/glitch/components/timeline_hint';
 
 const mapStateToProps = (state, props) => ({
+  remote: !!state.getIn(['accounts', props.params.accountId, 'acct']) !== state.getIn(['accounts', props.params.accountId, 'username']),
+  remoteUrl: state.getIn(['accounts', props.params.accountId, 'url']),
   isAccount: !!state.getIn(['accounts', props.params.accountId]),
   accountIds: state.getIn(['user_lists', 'following', props.params.accountId, 'items']),
   hasMore: !!state.getIn(['user_lists', 'following', props.params.accountId, 'next']),
   isLoading: state.getIn(['user_lists', 'following', props.params.accountId, 'isLoading'], true),
 });
 
+const RemoteHint = ({ url }) => (
+  <TimelineHint url={url} resource={<FormattedMessage id='timeline_hint.resources.follows' defaultMessage='Follows' />} />
+);
+
+RemoteHint.propTypes = {
+  url: PropTypes.string.isRequired,
+};
+
 export default @connect(mapStateToProps)
 class Following extends ImmutablePureComponent {
 
@@ -35,6 +46,8 @@ class Following extends ImmutablePureComponent {
     hasMore: PropTypes.bool,
     isLoading: PropTypes.bool,
     isAccount: PropTypes.bool,
+    remote: PropTypes.bool,
+    remoteUrl: PropTypes.string,
     multiColumn: PropTypes.bool,
   };
 
@@ -65,7 +78,7 @@ class Following extends ImmutablePureComponent {
   }
 
   render () {
-    const { accountIds, hasMore, isAccount, multiColumn, isLoading } = this.props;
+    const { accountIds, hasMore, isAccount, multiColumn, isLoading, remote, remoteUrl } = this.props;
 
     if (!isAccount) {
       return (
@@ -83,7 +96,15 @@ class Following extends ImmutablePureComponent {
       );
     }
 
-    const emptyMessage = <FormattedMessage id='account.follows.empty' defaultMessage="This user doesn't follow anyone yet." />;
+    let emptyMessage;
+
+    if (remote && accountIds.isEmpty()) {
+      emptyMessage = <RemoteHint url={remoteUrl} />;
+    } else {
+      emptyMessage = <FormattedMessage id='account.follows.empty' defaultMessage="This user doesn't follow anyone yet." />;
+    }
+
+    const remoteMessage = remote ? <RemoteHint url={remoteUrl} /> : null;
 
     return (
       <Column ref={this.setRef}>
@@ -96,6 +117,7 @@ class Following extends ImmutablePureComponent {
           onLoadMore={this.handleLoadMore}
           prepend={<HeaderContainer accountId={this.props.params.accountId} hideTabs />}
           alwaysPrepend
+          append={remoteMessage}
           emptyMessage={emptyMessage}
           bindToDocument={!multiColumn}
         >
diff --git a/app/javascript/flavours/glitch/features/keyboard_shortcuts/index.js b/app/javascript/flavours/glitch/features/keyboard_shortcuts/index.js
index 0bb71e872..abc3f468f 100644
--- a/app/javascript/flavours/glitch/features/keyboard_shortcuts/index.js
+++ b/app/javascript/flavours/glitch/features/keyboard_shortcuts/index.js
@@ -106,6 +106,10 @@ class KeyboardShortcuts extends ImmutablePureComponent {
                 <td><FormattedMessage id='keyboard_shortcuts.toot' defaultMessage='to start a brand new toot' /></td>
               </tr>
               <tr>
+                <td><kbd>alt</kbd>+<kbd>x</kbd></td>
+                <td><FormattedMessage id='keyboard_shortcuts.spoilers' defaultMessage='to show/hide CW field' /></td>
+              </tr>
+              <tr>
                 <td><kbd>backspace</kbd></td>
                 <td><FormattedMessage id='keyboard_shortcuts.back' defaultMessage='to navigate back' /></td>
               </tr>
diff --git a/app/javascript/flavours/glitch/features/status/components/card.js b/app/javascript/flavours/glitch/features/status/components/card.js
index e3ee7dada..03867e03a 100644
--- a/app/javascript/flavours/glitch/features/status/components/card.js
+++ b/app/javascript/flavours/glitch/features/status/components/card.js
@@ -2,10 +2,14 @@ import React from 'react';
 import PropTypes from 'prop-types';
 import Immutable from 'immutable';
 import ImmutablePropTypes from 'react-immutable-proptypes';
+import { FormattedMessage } from 'react-intl';
 import punycode from 'punycode';
 import classnames from 'classnames';
 import { decode as decodeIDNA } from 'flavours/glitch/util/idna';
 import Icon from 'flavours/glitch/components/icon';
+import classNames from 'classnames';
+import { useBlurhash } from 'flavours/glitch/util/initial_state';
+import { decode } from 'blurhash';
 
 const getHostname = url => {
   const parser = document.createElement('a');
@@ -55,6 +59,7 @@ export default class Card extends React.PureComponent {
     compact: PropTypes.bool,
     defaultWidth: PropTypes.number,
     cacheWidth: PropTypes.func,
+    sensitive: PropTypes.bool,
   };
 
   static defaultProps = {
@@ -64,12 +69,44 @@ export default class Card extends React.PureComponent {
 
   state = {
     width: this.props.defaultWidth || 280,
+    previewLoaded: false,
     embedded: false,
+    revealed: !this.props.sensitive,
   };
 
   componentWillReceiveProps (nextProps) {
     if (!Immutable.is(this.props.card, nextProps.card)) {
-      this.setState({ embedded: false });
+      this.setState({ embedded: false, previewLoaded: false });
+    }
+    if (this.props.sensitive !== nextProps.sensitive) {
+      this.setState({ revealed: !nextProps.sensitive });
+    }
+  }
+
+  componentDidMount () {
+    if (this.props.card && this.props.card.get('blurhash')) {
+      this._decode();
+    }
+  }
+
+  componentDidUpdate (prevProps) {
+    const { card } = this.props;
+    if (card.get('blurhash') && (!prevProps.card || prevProps.card.get('blurhash') !== card.get('blurhash'))) {
+      this._decode();
+    }
+  }
+
+  _decode () {
+    if (!useBlurhash) return;
+
+    const hash   = this.props.card.get('blurhash');
+    const pixels = decode(hash, 32, 32);
+
+    if (pixels) {
+      const ctx       = this.canvas.getContext('2d');
+      const imageData = new ImageData(pixels, 32, 32);
+
+      ctx.putImageData(imageData, 0, 0);
     }
   }
 
@@ -111,6 +148,18 @@ export default class Card extends React.PureComponent {
     }
   }
 
+  setCanvasRef = c => {
+    this.canvas = c;
+  }
+
+  handleImageLoad = () => {
+    this.setState({ previewLoaded: true });
+  }
+
+  handleReveal = () => {
+    this.setState({ revealed: true });
+  }
+
   renderVideo () {
     const { card }  = this.props;
     const content   = { __html: addAutoPlay(card.get('html')) };
@@ -130,7 +179,7 @@ export default class Card extends React.PureComponent {
 
   render () {
     const { card, maxDescription, compact, defaultWidth } = this.props;
-    const { width, embedded } = this.state;
+    const { width, embedded, revealed } = this.state;
 
     if (card === null) {
       return null;
@@ -145,7 +194,7 @@ export default class Card extends React.PureComponent {
     const height      = (compact && !embedded) ? (width / (16 / 9)) : (width / ratio);
 
     const description = (
-      <div className='status-card__content'>
+      <div className={classNames('status-card__content', { 'status-card__content--blurred': !revealed })}>
         {title}
         {!(horizontal || compact) && <p className='status-card__description'>{trim(card.get('description') || '', maxDescription)}</p>}
         <span className='status-card__host'>{provider}</span>
@@ -153,7 +202,18 @@ export default class Card extends React.PureComponent {
     );
 
     let embed     = '';
-    let thumbnail = <div style={{ backgroundImage: `url(${card.get('image')})`, width: horizontal ? width : null, height: horizontal ? height : null }} className='status-card__image-image' />;
+    let canvas = <canvas width={32} height={32} ref={this.setCanvasRef} className={classNames('status-card__image-preview', { 'status-card__image-preview--hidden' : revealed && this.state.previewLoaded })} />;
+    let thumbnail = <img src={card.get('image')} alt='' style={{ width: horizontal ? width : null, height: horizontal ? height : null, visibility: revealed ? null : 'hidden' }} onLoad={this.handleImageLoad} className='status-card__image-image' />;
+    let spoilerButton = (
+      <button type='button' onClick={this.handleReveal} className='spoiler-button__overlay'>
+        <span className='spoiler-button__overlay__label'><FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' /></span>
+      </button>
+    );
+    spoilerButton = (
+      <div className={classNames('spoiler-button', { 'spoiler-button--minified': revealed })}>
+        {spoilerButton}
+      </div>
+    );
 
     if (interactive) {
       if (embedded) {
@@ -167,14 +227,18 @@ export default class Card extends React.PureComponent {
 
         embed = (
           <div className='status-card__image'>
+            {canvas}
             {thumbnail}
 
-            <div className='status-card__actions'>
-              <div>
-                <button onClick={this.handleEmbedClick}><Icon id={iconVariant} /></button>
-                {horizontal && <a href={card.get('url')} target='_blank' rel='noopener noreferrer'><Icon id='external-link' /></a>}
+            {revealed && (
+              <div className='status-card__actions'>
+                <div>
+                  <button onClick={this.handleEmbedClick}><Icon id={iconVariant} /></button>
+                  {horizontal && <a href={card.get('url')} target='_blank' rel='noopener noreferrer'><Icon id='external-link' /></a>}
+                </div>
               </div>
-            </div>
+            )}
+            {!revealed && spoilerButton}
           </div>
         );
       }
@@ -188,13 +252,16 @@ export default class Card extends React.PureComponent {
     } else if (card.get('image')) {
       embed = (
         <div className='status-card__image'>
+          {canvas}
           {thumbnail}
+          {!revealed && spoilerButton}
         </div>
       );
     } else {
       embed = (
         <div className='status-card__image'>
           <Icon id='file-text' />
+          {!revealed && spoilerButton}
         </div>
       );
     }
diff --git a/app/javascript/flavours/glitch/features/status/components/detailed_status.js b/app/javascript/flavours/glitch/features/status/components/detailed_status.js
index 17f22a8a2..4fbd65517 100644
--- a/app/javascript/flavours/glitch/features/status/components/detailed_status.js
+++ b/app/javascript/flavours/glitch/features/status/components/detailed_status.js
@@ -184,7 +184,7 @@ export default class DetailedStatus extends ImmutablePureComponent {
         mediaIcon = 'picture-o';
       }
     } else if (status.get('card')) {
-      media = <Card onOpenMedia={this.props.onOpenMedia} card={status.get('card')} />;
+      media = <Card sensitive={status.get('sensitive')} onOpenMedia={this.props.onOpenMedia} card={status.get('card')} />;
       mediaIcon = 'link';
     }
 
diff --git a/app/javascript/flavours/glitch/features/ui/index.js b/app/javascript/flavours/glitch/features/ui/index.js
index f8f6cff88..bf76c0e57 100644
--- a/app/javascript/flavours/glitch/features/ui/index.js
+++ b/app/javascript/flavours/glitch/features/ui/index.js
@@ -7,7 +7,7 @@ import { connect } from 'react-redux';
 import { Redirect, withRouter } from 'react-router-dom';
 import { isMobile } from 'flavours/glitch/util/is_mobile';
 import { debounce } from 'lodash';
-import { uploadCompose, resetCompose } from 'flavours/glitch/actions/compose';
+import { uploadCompose, resetCompose, changeComposeSpoilerness } from 'flavours/glitch/actions/compose';
 import { expandHomeTimeline } from 'flavours/glitch/actions/timelines';
 import { expandNotifications, notificationsSetVisibility } from 'flavours/glitch/actions/notifications';
 import { fetchFilters } from 'flavours/glitch/actions/filters';
@@ -81,6 +81,7 @@ const keyMap = {
   new: 'n',
   search: 's',
   forceNew: 'option+n',
+  toggleComposeSpoilers: 'option+x',
   focusColumn: ['1', '2', '3', '4', '5', '6', '7', '8', '9'],
   reply: 'r',
   favourite: 'f',
@@ -396,7 +397,7 @@ class UI extends React.Component {
 
   componentDidMount () {
     this.hotkeys.__mousetrap__.stopCallback = (e, element) => {
-      return ['TEXTAREA', 'SELECT', 'INPUT'].includes(element.tagName);
+      return ['TEXTAREA', 'SELECT', 'INPUT'].includes(element.tagName) && !e.altKey;
     };
   }
 
@@ -455,6 +456,11 @@ class UI extends React.Component {
     this.props.dispatch(resetCompose());
   }
 
+  handleHotkeyToggleComposeSpoilers = e => {
+    e.preventDefault();
+    this.props.dispatch(changeComposeSpoilerness());
+  }
+
   handleHotkeyFocusColumn = e => {
     const index  = (e.key * 1) + 1; // First child is drawer, skip that
     const column = this.node.querySelector(`.column:nth-child(${index})`);
@@ -569,6 +575,7 @@ class UI extends React.Component {
       new: this.handleHotkeyNew,
       search: this.handleHotkeySearch,
       forceNew: this.handleHotkeyForceNew,
+      toggleComposeSpoilers: this.handleHotkeyToggleComposeSpoilers,
       focusColumn: this.handleHotkeyFocusColumn,
       back: this.handleHotkeyBack,
       goToHome: this.handleHotkeyGoToHome,
diff --git a/app/javascript/flavours/glitch/styles/accessibility.scss b/app/javascript/flavours/glitch/styles/accessibility.scss
index 35e91da80..1a2de2f06 100644
--- a/app/javascript/flavours/glitch/styles/accessibility.scss
+++ b/app/javascript/flavours/glitch/styles/accessibility.scss
@@ -1,13 +1,13 @@
-$emojis-requiring-outlines: '8ball' 'ant' 'back' 'black_circle' 'black_heart' 'black_large_square' 'black_medium_small_square' 'black_medium_square' 'black_nib' 'black_small_square' 'bomb' 'bowling' 'bust_in_silhouette' 'busts_in_silhouette' 'camera' 'camera_with_flash' 'clubs' 'copyright' 'curly_loop' 'currency_exchange' 'dark_sunglasses' 'eight_pointed_black_star' 'electric_plug' 'end' 'female-guard' 'film_projector' 'fried_egg' 'gorilla' 'guardsman' 'heavy_check_mark' 'heavy_division_sign' 'heavy_dollar_sign' 'heavy_minus_sign' 'heavy_multiplication_x' 'heavy_plus_sign' 'hocho' 'hole' 'joystick' 'kaaba' 'lower_left_ballpoint_pen' 'lower_left_fountain_pen' 'male-guard' 'microphone' 'mortar_board' 'movie_camera' 'musical_score' 'on' 'registered' 'soon' 'spades' 'speaking_head_in_silhouette' 'spider' 'telephone_receiver' 'tm' 'top' 'tophat' 'turkey' 'vhs' 'video_camera' 'video_game' 'water_buffalo' 'waving_black_flag' 'wavy_dash' !default;
+$emojis-requiring-inversion: 'back' 'copyright' 'curly_loop' 'currency_exchange' 'end' 'heavy_check_mark' 'heavy_division_sign' 'heavy_dollar_sign' 'heavy_minus_sign' 'heavy_multiplication_x' 'heavy_plus_sign' 'on' 'registered' 'soon' 'spider' 'telephone_receiver' 'tm' 'top' 'wavy_dash' !default;
 
-%emoji-outline {
-  filter: drop-shadow(1px 1px 0 $primary-text-color) drop-shadow(-1px 1px 0 $primary-text-color) drop-shadow(1px -1px 0 $primary-text-color) drop-shadow(-1px -1px 0 $primary-text-color);
+%emoji-color-inversion {
+  filter: invert(1);
 }
 
 .emojione {
-  @each $emoji in $emojis-requiring-outlines {
+  @each $emoji in $emojis-requiring-inversion {
     &[title=':#{$emoji}:'] {
-      @extend %emoji-outline;
+      @extend %emoji-color-inversion;
     }
   }
 }
diff --git a/app/javascript/flavours/glitch/styles/components/columns.scss b/app/javascript/flavours/glitch/styles/components/columns.scss
index 3269638eb..6b657660a 100644
--- a/app/javascript/flavours/glitch/styles/components/columns.scss
+++ b/app/javascript/flavours/glitch/styles/components/columns.scss
@@ -363,8 +363,8 @@
     @extend .column-header__button;
     background: transparent;
     text-align: center;
-    padding: 10px 0;
-    white-space: pre-wrap;
+    padding: 10px 5px;
+    font-size: 14px;
   }
 
   b {
@@ -372,6 +372,23 @@
   }
 }
 
+
+.layout-single-column .column-header__notif-cleaning-buttons {
+  @media screen and (min-width: $no-gap-breakpoint) {
+    b, i {
+      margin-right: 5px;
+    }
+
+    br {
+      display: none;
+    }
+
+    button {
+      padding: 15px 5px;
+    }
+  }
+}
+
 // The notifs drawer with no padding to have more space for the buttons
 .column-header__collapsible-inner.nopad-drawer {
   padding: 0;
diff --git a/app/javascript/flavours/glitch/styles/components/index.scss b/app/javascript/flavours/glitch/styles/components/index.scss
index 50cea8b26..a37cef795 100644
--- a/app/javascript/flavours/glitch/styles/components/index.scss
+++ b/app/javascript/flavours/glitch/styles/components/index.scss
@@ -1093,6 +1093,31 @@
   border-bottom: 1px solid lighten($ui-base-color, 8%);
 }
 
+.timeline-hint {
+  text-align: center;
+  color: $darker-text-color;
+  padding: 15px;
+  box-sizing: border-box;
+  width: 100%;
+  cursor: default;
+
+  strong {
+    font-weight: 500;
+  }
+
+  a {
+    color: lighten($ui-highlight-color, 8%);
+    text-decoration: none;
+
+    &:hover,
+    &:focus,
+    &:active {
+      text-decoration: underline;
+      color: lighten($ui-highlight-color, 12%);
+    }
+  }
+}
+
 .missing-indicator {
   padding-top: 20px + 48px;
 
diff --git a/app/javascript/flavours/glitch/styles/components/status.scss b/app/javascript/flavours/glitch/styles/components/status.scss
index 50b7f2a72..28a4ce0ce 100644
--- a/app/javascript/flavours/glitch/styles/components/status.scss
+++ b/app/javascript/flavours/glitch/styles/components/status.scss
@@ -874,6 +874,11 @@ a.status-card {
   flex: 1 1 auto;
   overflow: hidden;
   padding: 14px 14px 14px 8px;
+
+  &--blurred {
+    filter: blur(2px);
+    pointer-events: none;
+  }
 }
 
 .status-card__description {
@@ -911,7 +916,8 @@ a.status-card {
     width: 100%;
   }
 
-  .status-card__image-image {
+  .status-card__image-image,
+  .status-card__image-preview {
     border-radius: 4px 4px 0 0;
   }
 
@@ -956,6 +962,24 @@ a.status-card.compact:hover {
   background-position: center center;
 }
 
+.status-card__image-preview {
+  border-radius: 4px 0 0 4px;
+  display: block;
+  margin: 0;
+  width: 100%;
+  height: 100%;
+  object-fit: fill;
+  position: absolute;
+  top: 0;
+  left: 0;
+  z-index: 0;
+  background: $base-overlay-background;
+
+  &--hidden {
+    display: none;
+  }
+}
+
 .attachment-list {
   display: flex;
   font-size: 14px;
diff --git a/app/javascript/flavours/glitch/styles/forms.scss b/app/javascript/flavours/glitch/styles/forms.scss
index 5de650f0a..6767c15f1 100644
--- a/app/javascript/flavours/glitch/styles/forms.scss
+++ b/app/javascript/flavours/glitch/styles/forms.scss
@@ -578,7 +578,7 @@ code {
 
   &.alert {
     border: 1px solid rgba($error-value-color, 0.5);
-    background: rgba($error-value-color, 0.25);
+    background: rgba($error-value-color, 0.1);
     color: $error-value-color;
   }
 
diff --git a/app/javascript/flavours/glitch/styles/mastodon-light/variables.scss b/app/javascript/flavours/glitch/styles/mastodon-light/variables.scss
index 312f5e314..7709d4535 100644
--- a/app/javascript/flavours/glitch/styles/mastodon-light/variables.scss
+++ b/app/javascript/flavours/glitch/styles/mastodon-light/variables.scss
@@ -37,4 +37,4 @@ $account-background-color: $white !default;
   @return hsl(hue($color), saturation($color), lightness($color) - $amount);
 }
 
-$emojis-requiring-outlines: 'alien' 'baseball' 'chains' 'chicken' 'cloud' 'crescent_moon' 'dash' 'dove_of_peace' 'eyes' 'first_quarter_moon' 'first_quarter_moon_with_face' 'fish_cake' 'full_moon' 'full_moon_with_face' 'ghost' 'goat' 'grey_exclamation' 'grey_question' 'ice_skate' 'last_quarter_moon' 'last_quarter_moon_with_face' 'lightning' 'loud_sound' 'moon' 'mute' 'page_with_curl' 'rain_cloud' 'ram' 'rice' 'rice_ball' 'rooster' 'sheep' 'skull' 'skull_and_crossbones' 'snow_cloud' 'sound' 'speaker' 'speech_balloon' 'thought_balloon' 'volleyball' 'waning_crescent_moon' 'waning_gibbous_moon' 'waving_white_flag' 'waxing_crescent_moon' 'white_circle' 'white_large_square' 'white_medium_small_square' 'white_medium_square' 'white_small_square' 'wind_blowing_face';
+$emojis-requiring-inversion: 'chains';
diff --git a/app/javascript/flavours/glitch/util/emoji/index.js b/app/javascript/flavours/glitch/util/emoji/index.js
index e1a244127..61f211c92 100644
--- a/app/javascript/flavours/glitch/util/emoji/index.js
+++ b/app/javascript/flavours/glitch/util/emoji/index.js
@@ -6,6 +6,20 @@ const trie = new Trie(Object.keys(unicodeMapping));
 
 const assetHost = process.env.CDN_HOST || '';
 
+// Convert to file names from emojis. (For different variation selector emojis)
+const emojiFilenames = (emojis) => {
+  return emojis.map(v => unicodeMapping[v].filename);
+};
+
+// Emoji requiring extra borders depending on theme
+const darkEmoji = emojiFilenames(['🎱', '🐜', '⚫', '🖤', '⬛', '◼️', '◾', '◼️', '✒️', '▪️', '💣', '🎳', '📷', '📸', '♣️', '🕶️', '✴️', '🔌', '💂‍♀️', '📽️', '🍳', '🦍', '💂', '🔪', '🕳️', '🕹️', '🕋', '🖊️', '🖋️', '💂‍♂️', '🎤', '🎓', '🎥', '🎼', '♠️', '🎩', '🦃', '📼', '📹', '🎮', '🐃', '🏴']);
+const lightEmoji = emojiFilenames(['👽', '⚾', '🐔', '☁️', '💨', '🕊️', '👀', '🍥', '👻', '🐐', '❕', '❔', '⛸️', '🌩️', '🔊', '🔇', '📃', '🌧️', '🐏', '🍚', '🍙', '🐓', '🐑', '💀', '☠️', '🌨️', '🔉', '🔈', '💬', '💭', '🏐', '🏳️', '⚪', '⬜', '◽', '◻️', '▫️']);
+
+const emojiFilename = (filename) => {
+  const borderedEmoji = (document.body && document.body.classList.contains('skin-mastodon-light')) ? lightEmoji : darkEmoji;
+  return borderedEmoji.includes(filename) ? (filename + '_border') : filename;
+};
+
 const emojify = (str, customEmojis = {}) => {
   const tagCharsWithoutEmojis = '<&';
   const tagCharsWithEmojis = Object.keys(customEmojis).length ? '<&:' : '<&';
@@ -60,7 +74,7 @@ const emojify = (str, customEmojis = {}) => {
     } else if (!useSystemEmojiFont) { // matched to unicode emoji
       const { filename, shortCode } = unicodeMapping[match];
       const title = shortCode ? `:${shortCode}:` : '';
-      replacement = `<img draggable="false" class="emojione" alt="${match}" title="${title}" src="${assetHost}/emoji/${filename}.svg" />`;
+      replacement = `<img draggable="false" class="emojione" alt="${match}" title="${title}" src="${assetHost}/emoji/${emojiFilename(filename)}.svg" />`;
       rend = i + match.length;
       // If the matched character was followed by VS15 (for selecting text presentation), skip it.
       if (str.codePointAt(rend) === 65038) {
diff --git a/app/javascript/mastodon/actions/importer/normalizer.js b/app/javascript/mastodon/actions/importer/normalizer.js
index f7cbe4c1c..dca44917a 100644
--- a/app/javascript/mastodon/actions/importer/normalizer.js
+++ b/app/javascript/mastodon/actions/importer/normalizer.js
@@ -12,7 +12,7 @@ const makeEmojiMap = record => record.emojis.reduce((obj, emoji) => {
 
 export function searchTextFromRawStatus (status) {
   const spoilerText   = status.spoiler_text || '';
-  const searchContent = ([spoilerText, status.content].concat((status.poll && status.poll.options) ? status.poll.options.map(option => option.title) : [])).join('\n\n').replace(/<br\s*\/?>/g, '\n').replace(/<\/p><p>/g, '\n\n');
+  const searchContent = ([spoilerText, status.content].concat((status.poll && status.poll.options) ? status.poll.options.map(option => option.title) : [])).concat(status.media_attachments.map(att => att.description)).join('\n\n').replace(/<br\s*\/?>/g, '\n').replace(/<\/p><p>/g, '\n\n');
   return domParser.parseFromString(searchContent, 'text/html').documentElement.textContent;
 }
 
diff --git a/app/javascript/mastodon/components/autosuggest_textarea.js b/app/javascript/mastodon/components/autosuggest_textarea.js
index ac2a6366a..58ec4f6eb 100644
--- a/app/javascript/mastodon/components/autosuggest_textarea.js
+++ b/app/javascript/mastodon/components/autosuggest_textarea.js
@@ -208,7 +208,7 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
             <span style={{ display: 'none' }}>{placeholder}</span>
 
             <Textarea
-              inputRef={this.setTextarea}
+              ref={this.setTextarea}
               className='autosuggest-textarea__textarea'
               disabled={disabled}
               placeholder={placeholder}
diff --git a/app/javascript/mastodon/components/modal_root.js b/app/javascript/mastodon/components/modal_root.js
index fa4e59192..6297b5e29 100644
--- a/app/javascript/mastodon/components/modal_root.js
+++ b/app/javascript/mastodon/components/modal_root.js
@@ -66,7 +66,7 @@ export default class ModalRoot extends React.PureComponent {
       // immediately selectable, we have to wait for observers to run, as
       // described in https://github.com/WICG/inert#performance-and-gotchas
       Promise.resolve().then(() => {
-        this.activeElement.focus();
+        this.activeElement.focus({ preventScroll: true });
         this.activeElement = null;
       }).catch((error) => {
         console.error(error);
diff --git a/app/javascript/mastodon/components/scrollable_list.js b/app/javascript/mastodon/components/scrollable_list.js
index 65ca43911..7eb0910c9 100644
--- a/app/javascript/mastodon/components/scrollable_list.js
+++ b/app/javascript/mastodon/components/scrollable_list.js
@@ -32,6 +32,7 @@ export default class ScrollableList extends PureComponent {
     hasMore: PropTypes.bool,
     numPending: PropTypes.number,
     prepend: PropTypes.node,
+    append: PropTypes.node,
     alwaysPrepend: PropTypes.bool,
     emptyMessage: PropTypes.node,
     children: PropTypes.node,
@@ -280,7 +281,7 @@ export default class ScrollableList extends PureComponent {
   }
 
   render () {
-    const { children, scrollKey, trackScroll, shouldUpdateScroll, showLoading, isLoading, hasMore, numPending, prepend, alwaysPrepend, emptyMessage, onLoadMore } = this.props;
+    const { children, scrollKey, trackScroll, shouldUpdateScroll, showLoading, isLoading, hasMore, numPending, prepend, alwaysPrepend, append, emptyMessage, onLoadMore } = this.props;
     const { fullscreen } = this.state;
     const childrenCount = React.Children.count(children);
 
@@ -327,6 +328,8 @@ export default class ScrollableList extends PureComponent {
             ))}
 
             {loadMore}
+
+            {!hasMore && append}
           </div>
         </div>
       );
diff --git a/app/javascript/mastodon/components/status.js b/app/javascript/mastodon/components/status.js
index 9e4442cef..f99ccd39a 100644
--- a/app/javascript/mastodon/components/status.js
+++ b/app/javascript/mastodon/components/status.js
@@ -401,6 +401,7 @@ class Status extends ImmutablePureComponent {
           compact
           cacheWidth={this.props.cacheMediaWidth}
           defaultWidth={this.props.cachedMediaWidth}
+          sensitive={status.get('sensitive')}
         />
       );
     }
diff --git a/app/javascript/mastodon/components/timeline_hint.js b/app/javascript/mastodon/components/timeline_hint.js
new file mode 100644
index 000000000..fb55a62cc
--- /dev/null
+++ b/app/javascript/mastodon/components/timeline_hint.js
@@ -0,0 +1,18 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { FormattedMessage } from 'react-intl';
+
+const TimelineHint = ({ resource, url }) => (
+  <div className='timeline-hint'>
+    <strong><FormattedMessage id='timeline_hint.remote_resource_not_displayed' defaultMessage='{resource} from other servers are not displayed.' values={{ resource }} /></strong>
+    <br />
+    <a href={url} target='_blank'><FormattedMessage id='account.browse_more_on_origin_server' defaultMessage='Browse more on the original profile' /></a>
+  </div>
+);
+
+TimelineHint.propTypes = {
+  resource: PropTypes.node.isRequired,
+  url: PropTypes.string.isRequired,
+};
+
+export default TimelineHint;
diff --git a/app/javascript/mastodon/features/account_timeline/index.js b/app/javascript/mastodon/features/account_timeline/index.js
index 37622d4c0..6740e8adb 100644
--- a/app/javascript/mastodon/features/account_timeline/index.js
+++ b/app/javascript/mastodon/features/account_timeline/index.js
@@ -14,6 +14,7 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
 import { FormattedMessage } from 'react-intl';
 import { fetchAccountIdentityProofs } from '../../actions/identity_proofs';
 import MissingIndicator from 'mastodon/components/missing_indicator';
+import TimelineHint from 'mastodon/components/timeline_hint';
 
 const emptyList = ImmutableList();
 
@@ -21,6 +22,8 @@ const mapStateToProps = (state, { params: { accountId }, withReplies = false })
   const path = withReplies ? `${accountId}:with_replies` : accountId;
 
   return {
+    remote: !!state.getIn(['accounts', accountId, 'acct']) !== state.getIn(['accounts', accountId, 'username']),
+    remoteUrl: state.getIn(['accounts', accountId, 'url']),
     isAccount: !!state.getIn(['accounts', accountId]),
     statusIds: state.getIn(['timelines', `account:${path}`, 'items'], emptyList),
     featuredStatusIds: withReplies ? ImmutableList() : state.getIn(['timelines', `account:${accountId}:pinned`, 'items'], emptyList),
@@ -30,6 +33,14 @@ const mapStateToProps = (state, { params: { accountId }, withReplies = false })
   };
 };
 
+const RemoteHint = ({ url }) => (
+  <TimelineHint url={url} resource={<FormattedMessage id='timeline_hint.resources.statuses' defaultMessage='Older toots' />} />
+);
+
+RemoteHint.propTypes = {
+  url: PropTypes.string.isRequired,
+};
+
 export default @connect(mapStateToProps)
 class AccountTimeline extends ImmutablePureComponent {
 
@@ -44,6 +55,8 @@ class AccountTimeline extends ImmutablePureComponent {
     withReplies: PropTypes.bool,
     blockedBy: PropTypes.bool,
     isAccount: PropTypes.bool,
+    remote: PropTypes.bool,
+    remoteUrl: PropTypes.string,
     multiColumn: PropTypes.bool,
   };
 
@@ -78,7 +91,7 @@ class AccountTimeline extends ImmutablePureComponent {
   }
 
   render () {
-    const { shouldUpdateScroll, statusIds, featuredStatusIds, isLoading, hasMore, blockedBy, isAccount, multiColumn } = this.props;
+    const { shouldUpdateScroll, statusIds, featuredStatusIds, isLoading, hasMore, blockedBy, isAccount, multiColumn, remote, remoteUrl } = this.props;
 
     if (!isAccount) {
       return (
@@ -97,7 +110,17 @@ class AccountTimeline extends ImmutablePureComponent {
       );
     }
 
-    const emptyMessage = blockedBy ? <FormattedMessage id='empty_column.account_unavailable' defaultMessage='Profile unavailable' /> : <FormattedMessage id='empty_column.account_timeline' defaultMessage='No toots here!' />;
+    let emptyMessage;
+
+    if (blockedBy) {
+      emptyMessage = <FormattedMessage id='empty_column.account_unavailable' defaultMessage='Profile unavailable' />;
+    } else if (remote && statusIds.isEmpty()) {
+      emptyMessage = <RemoteHint url={remoteUrl} />;
+    } else {
+      emptyMessage = <FormattedMessage id='empty_column.account_timeline' defaultMessage='No toots here!' />;
+    }
+
+    const remoteMessage = remote ? <RemoteHint url={remoteUrl} /> : null;
 
     return (
       <Column>
@@ -106,6 +129,7 @@ class AccountTimeline extends ImmutablePureComponent {
         <StatusList
           prepend={<HeaderContainer accountId={this.props.params.accountId} />}
           alwaysPrepend
+          append={remoteMessage}
           scrollKey='account_timeline'
           statusIds={blockedBy ? emptyList : statusIds}
           featuredStatusIds={featuredStatusIds}
diff --git a/app/javascript/mastodon/features/audio/index.js b/app/javascript/mastodon/features/audio/index.js
index 95c9c7751..baad1c0e5 100644
--- a/app/javascript/mastodon/features/audio/index.js
+++ b/app/javascript/mastodon/features/audio/index.js
@@ -128,6 +128,7 @@ class Audio extends React.PureComponent {
         this.wavesurfer.createPeakCache();
         this.wavesurfer.load(this.props.src);
         this.wavesurfer.toggleInteraction();
+        this.wavesurfer.setVolume(this.state.volume);
         this.loaded = true;
       }
 
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 582bb0d39..a6186010b 100644
--- a/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js
+++ b/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js
@@ -199,12 +199,13 @@ class EmojiPickerMenu extends React.PureComponent {
     };
   }
 
-  handleClick = emoji => {
+  handleClick = (emoji, event) => {
     if (!emoji.native) {
       emoji.native = emoji.colons;
     }
-
-    this.props.onClose();
+    if (!event.ctrlKey) {
+      this.props.onClose();
+    }
     this.props.onPick(emoji);
   }
 
diff --git a/app/javascript/mastodon/features/emoji/__tests__/emoji-test.js b/app/javascript/mastodon/features/emoji/__tests__/emoji-test.js
index c8425c4c6..07b3d8c53 100644
--- a/app/javascript/mastodon/features/emoji/__tests__/emoji-test.js
+++ b/app/javascript/mastodon/features/emoji/__tests__/emoji-test.js
@@ -76,7 +76,17 @@ describe('emoji', () => {
 
     it('skips the textual presentation VS15 character', () => {
       expect(emojify('✴︎')) // This is U+2734 EIGHT POINTED BLACK STAR then U+FE0E VARIATION SELECTOR-15
-        .toEqual('<img draggable="false" class="emojione" alt="✴" title=":eight_pointed_black_star:" src="/emoji/2734.svg" />');
+        .toEqual('<img draggable="false" class="emojione" alt="✴" title=":eight_pointed_black_star:" src="/emoji/2734_border.svg" />');
+    });
+
+    it('does an simple emoji properly', () => {
+      expect(emojify('♀♂'))
+        .toEqual('<img draggable="false" class="emojione" alt="♀" title=":female_sign:" src="/emoji/2640.svg" /><img draggable="false" class="emojione" alt="♂" title=":male_sign:" src="/emoji/2642.svg" />');
+    });
+
+    it('does an emoji containing ZWJ properly', () => {
+      expect(emojify('💂‍♀️💂‍♂️'))
+        .toEqual('<img draggable="false" class="emojione" alt="💂\u200D♀️" title=":female-guard:" src="/emoji/1f482-200d-2640-fe0f_border.svg" /><img draggable="false" class="emojione" alt="💂\u200D♂️" title=":male-guard:" src="/emoji/1f482-200d-2642-fe0f_border.svg" />');
     });
   });
 });
diff --git a/app/javascript/mastodon/features/emoji/emoji.js b/app/javascript/mastodon/features/emoji/emoji.js
index cd10e20b7..f7d3cfd08 100644
--- a/app/javascript/mastodon/features/emoji/emoji.js
+++ b/app/javascript/mastodon/features/emoji/emoji.js
@@ -6,6 +6,20 @@ const trie = new Trie(Object.keys(unicodeMapping));
 
 const assetHost = process.env.CDN_HOST || '';
 
+// Convert to file names from emojis. (For different variation selector emojis)
+const emojiFilenames = (emojis) => {
+  return emojis.map(v => unicodeMapping[v].filename);
+};
+
+// Emoji requiring extra borders depending on theme
+const darkEmoji = emojiFilenames(['🎱', '🐜', '⚫', '🖤', '⬛', '◼️', '◾', '◼️', '✒️', '▪️', '💣', '🎳', '📷', '📸', '♣️', '🕶️', '✴️', '🔌', '💂‍♀️', '📽️', '🍳', '🦍', '💂', '🔪', '🕳️', '🕹️', '🕋', '🖊️', '🖋️', '💂‍♂️', '🎤', '🎓', '🎥', '🎼', '♠️', '🎩', '🦃', '📼', '📹', '🎮', '🐃', '🏴']);
+const lightEmoji = emojiFilenames(['👽', '⚾', '🐔', '☁️', '💨', '🕊️', '👀', '🍥', '👻', '🐐', '❕', '❔', '⛸️', '🌩️', '🔊', '🔇', '📃', '🌧️', '🐏', '🍚', '🍙', '🐓', '🐑', '💀', '☠️', '🌨️', '🔉', '🔈', '💬', '💭', '🏐', '🏳️', '⚪', '⬜', '◽', '◻️', '▫️']);
+
+const emojiFilename = (filename) => {
+  const borderedEmoji = (document.body && document.body.classList.contains('theme-mastodon-light')) ? lightEmoji : darkEmoji;
+  return borderedEmoji.includes(filename) ? (filename + '_border') : filename;
+};
+
 const emojify = (str, customEmojis = {}) => {
   const tagCharsWithoutEmojis = '<&';
   const tagCharsWithEmojis = Object.keys(customEmojis).length ? '<&:' : '<&';
@@ -60,7 +74,7 @@ const emojify = (str, customEmojis = {}) => {
     } else { // matched to unicode emoji
       const { filename, shortCode } = unicodeMapping[match];
       const title = shortCode ? `:${shortCode}:` : '';
-      replacement = `<img draggable="false" class="emojione" alt="${match}" title="${title}" src="${assetHost}/emoji/${filename}.svg" />`;
+      replacement = `<img draggable="false" class="emojione" alt="${match}" title="${title}" src="${assetHost}/emoji/${emojiFilename(filename)}.svg" />`;
       rend = i + match.length;
       // If the matched character was followed by VS15 (for selecting text presentation), skip it.
       if (str.codePointAt(rend) === 65038) {
diff --git a/app/javascript/mastodon/features/followers/index.js b/app/javascript/mastodon/features/followers/index.js
index 302ccffdd..439161118 100644
--- a/app/javascript/mastodon/features/followers/index.js
+++ b/app/javascript/mastodon/features/followers/index.js
@@ -17,8 +17,11 @@ import HeaderContainer from '../account_timeline/containers/header_container';
 import ColumnBackButton from '../../components/column_back_button';
 import ScrollableList from '../../components/scrollable_list';
 import MissingIndicator from 'mastodon/components/missing_indicator';
+import TimelineHint from 'mastodon/components/timeline_hint';
 
 const mapStateToProps = (state, props) => ({
+  remote: !!state.getIn(['accounts', props.params.accountId, 'acct']) !== state.getIn(['accounts', props.params.accountId, 'username']),
+  remoteUrl: state.getIn(['accounts', props.params.accountId, 'url']),
   isAccount: !!state.getIn(['accounts', props.params.accountId]),
   accountIds: state.getIn(['user_lists', 'followers', props.params.accountId, 'items']),
   hasMore: !!state.getIn(['user_lists', 'followers', props.params.accountId, 'next']),
@@ -26,6 +29,14 @@ const mapStateToProps = (state, props) => ({
   blockedBy: state.getIn(['relationships', props.params.accountId, 'blocked_by'], false),
 });
 
+const RemoteHint = ({ url }) => (
+  <TimelineHint url={url} resource={<FormattedMessage id='timeline_hint.resources.followers' defaultMessage='Followers' />} />
+);
+
+RemoteHint.propTypes = {
+  url: PropTypes.string.isRequired,
+};
+
 export default @connect(mapStateToProps)
 class Followers extends ImmutablePureComponent {
 
@@ -38,6 +49,8 @@ class Followers extends ImmutablePureComponent {
     isLoading: PropTypes.bool,
     blockedBy: PropTypes.bool,
     isAccount: PropTypes.bool,
+    remote: PropTypes.bool,
+    remoteUrl: PropTypes.string,
     multiColumn: PropTypes.bool,
   };
 
@@ -60,7 +73,7 @@ class Followers extends ImmutablePureComponent {
   }, 300, { leading: true });
 
   render () {
-    const { shouldUpdateScroll, accountIds, hasMore, blockedBy, isAccount, multiColumn, isLoading } = this.props;
+    const { shouldUpdateScroll, accountIds, hasMore, blockedBy, isAccount, multiColumn, isLoading, remote, remoteUrl } = this.props;
 
     if (!isAccount) {
       return (
@@ -78,7 +91,17 @@ class Followers extends ImmutablePureComponent {
       );
     }
 
-    const emptyMessage = blockedBy ? <FormattedMessage id='empty_column.account_unavailable' defaultMessage='Profile unavailable' /> : <FormattedMessage id='account.followers.empty' defaultMessage='No one follows this user yet.' />;
+    let emptyMessage;
+
+    if (blockedBy) {
+      emptyMessage = <FormattedMessage id='empty_column.account_unavailable' defaultMessage='Profile unavailable' />;
+    } else if (remote && accountIds.isEmpty()) {
+      emptyMessage = <RemoteHint url={remoteUrl} />;
+    } else {
+      emptyMessage = <FormattedMessage id='account.followers.empty' defaultMessage='No one follows this user yet.' />;
+    }
+
+    const remoteMessage = remote ? <RemoteHint url={remoteUrl} /> : null;
 
     return (
       <Column>
@@ -92,6 +115,7 @@ class Followers extends ImmutablePureComponent {
           shouldUpdateScroll={shouldUpdateScroll}
           prepend={<HeaderContainer accountId={this.props.params.accountId} hideTabs />}
           alwaysPrepend
+          append={remoteMessage}
           emptyMessage={emptyMessage}
           bindToDocument={!multiColumn}
         >
diff --git a/app/javascript/mastodon/features/following/index.js b/app/javascript/mastodon/features/following/index.js
index 06311bbdc..2d2ed7be4 100644
--- a/app/javascript/mastodon/features/following/index.js
+++ b/app/javascript/mastodon/features/following/index.js
@@ -17,8 +17,11 @@ import HeaderContainer from '../account_timeline/containers/header_container';
 import ColumnBackButton from '../../components/column_back_button';
 import ScrollableList from '../../components/scrollable_list';
 import MissingIndicator from 'mastodon/components/missing_indicator';
+import TimelineHint from 'mastodon/components/timeline_hint';
 
 const mapStateToProps = (state, props) => ({
+  remote: !!state.getIn(['accounts', props.params.accountId, 'acct']) !== state.getIn(['accounts', props.params.accountId, 'username']),
+  remoteUrl: state.getIn(['accounts', props.params.accountId, 'url']),
   isAccount: !!state.getIn(['accounts', props.params.accountId]),
   accountIds: state.getIn(['user_lists', 'following', props.params.accountId, 'items']),
   hasMore: !!state.getIn(['user_lists', 'following', props.params.accountId, 'next']),
@@ -26,6 +29,14 @@ const mapStateToProps = (state, props) => ({
   blockedBy: state.getIn(['relationships', props.params.accountId, 'blocked_by'], false),
 });
 
+const RemoteHint = ({ url }) => (
+  <TimelineHint url={url} resource={<FormattedMessage id='timeline_hint.resources.follows' defaultMessage='Follows' />} />
+);
+
+RemoteHint.propTypes = {
+  url: PropTypes.string.isRequired,
+};
+
 export default @connect(mapStateToProps)
 class Following extends ImmutablePureComponent {
 
@@ -38,6 +49,8 @@ class Following extends ImmutablePureComponent {
     isLoading: PropTypes.bool,
     blockedBy: PropTypes.bool,
     isAccount: PropTypes.bool,
+    remote: PropTypes.bool,
+    remoteUrl: PropTypes.string,
     multiColumn: PropTypes.bool,
   };
 
@@ -60,7 +73,7 @@ class Following extends ImmutablePureComponent {
   }, 300, { leading: true });
 
   render () {
-    const { shouldUpdateScroll, accountIds, hasMore, blockedBy, isAccount, multiColumn, isLoading } = this.props;
+    const { shouldUpdateScroll, accountIds, hasMore, blockedBy, isAccount, multiColumn, isLoading, remote, remoteUrl } = this.props;
 
     if (!isAccount) {
       return (
@@ -78,7 +91,17 @@ class Following extends ImmutablePureComponent {
       );
     }
 
-    const emptyMessage = blockedBy ? <FormattedMessage id='empty_column.account_unavailable' defaultMessage='Profile unavailable' /> : <FormattedMessage id='account.follows.empty' defaultMessage="This user doesn't follow anyone yet." />;
+    let emptyMessage;
+
+    if (blockedBy) {
+      emptyMessage = <FormattedMessage id='empty_column.account_unavailable' defaultMessage='Profile unavailable' />;
+    } else if (remote && accountIds.isEmpty()) {
+      emptyMessage = <RemoteHint url={remoteUrl} />;
+    } else {
+      emptyMessage = <FormattedMessage id='account.follows.empty' defaultMessage="This user doesn't follow anyone yet." />;
+    }
+
+    const remoteMessage = remote ? <RemoteHint url={remoteUrl} /> : null;
 
     return (
       <Column>
@@ -92,6 +115,7 @@ class Following extends ImmutablePureComponent {
           shouldUpdateScroll={shouldUpdateScroll}
           prepend={<HeaderContainer accountId={this.props.params.accountId} hideTabs />}
           alwaysPrepend
+          append={remoteMessage}
           emptyMessage={emptyMessage}
           bindToDocument={!multiColumn}
         >
diff --git a/app/javascript/mastodon/features/keyboard_shortcuts/index.js b/app/javascript/mastodon/features/keyboard_shortcuts/index.js
index 666baf621..d278d2b26 100644
--- a/app/javascript/mastodon/features/keyboard_shortcuts/index.js
+++ b/app/javascript/mastodon/features/keyboard_shortcuts/index.js
@@ -89,6 +89,10 @@ class KeyboardShortcuts extends ImmutablePureComponent {
                 <td><FormattedMessage id='keyboard_shortcuts.toot' defaultMessage='to start a brand new toot' /></td>
               </tr>
               <tr>
+                <td><kbd>alt</kbd>+<kbd>x</kbd></td>
+                <td><FormattedMessage id='keyboard_shortcuts.spoilers' defaultMessage='to show/hide CW field' /></td>
+              </tr>
+              <tr>
                 <td><kbd>backspace</kbd></td>
                 <td><FormattedMessage id='keyboard_shortcuts.back' defaultMessage='to navigate back' /></td>
               </tr>
diff --git a/app/javascript/mastodon/features/status/components/card.js b/app/javascript/mastodon/features/status/components/card.js
index b8344a667..630e99f2c 100644
--- a/app/javascript/mastodon/features/status/components/card.js
+++ b/app/javascript/mastodon/features/status/components/card.js
@@ -2,9 +2,13 @@ import React from 'react';
 import PropTypes from 'prop-types';
 import Immutable from 'immutable';
 import ImmutablePropTypes from 'react-immutable-proptypes';
+import { FormattedMessage } from 'react-intl';
 import punycode from 'punycode';
 import classnames from 'classnames';
 import Icon from 'mastodon/components/icon';
+import classNames from 'classnames';
+import { useBlurhash } from 'mastodon/initial_state';
+import { decode } from 'blurhash';
 
 const IDNA_PREFIX = 'xn--';
 
@@ -63,6 +67,7 @@ export default class Card extends React.PureComponent {
     compact: PropTypes.bool,
     defaultWidth: PropTypes.number,
     cacheWidth: PropTypes.func,
+    sensitive: PropTypes.bool,
   };
 
   static defaultProps = {
@@ -72,12 +77,44 @@ export default class Card extends React.PureComponent {
 
   state = {
     width: this.props.defaultWidth || 280,
+    previewLoaded: false,
     embedded: false,
+    revealed: !this.props.sensitive,
   };
 
   componentWillReceiveProps (nextProps) {
     if (!Immutable.is(this.props.card, nextProps.card)) {
-      this.setState({ embedded: false });
+      this.setState({ embedded: false, previewLoaded: false });
+    }
+    if (this.props.sensitive !== nextProps.sensitive) {
+      this.setState({ revealed: !nextProps.sensitive });
+    }
+  }
+
+  componentDidMount () {
+    if (this.props.card && this.props.card.get('blurhash')) {
+      this._decode();
+    }
+  }
+
+  componentDidUpdate (prevProps) {
+    const { card } = this.props;
+    if (card.get('blurhash') && (!prevProps.card || prevProps.card.get('blurhash') !== card.get('blurhash'))) {
+      this._decode();
+    }
+  }
+
+  _decode () {
+    if (!useBlurhash) return;
+
+    const hash   = this.props.card.get('blurhash');
+    const pixels = decode(hash, 32, 32);
+
+    if (pixels) {
+      const ctx       = this.canvas.getContext('2d');
+      const imageData = new ImageData(pixels, 32, 32);
+
+      ctx.putImageData(imageData, 0, 0);
     }
   }
 
@@ -119,6 +156,18 @@ export default class Card extends React.PureComponent {
     }
   }
 
+  setCanvasRef = c => {
+    this.canvas = c;
+  }
+
+  handleImageLoad = () => {
+    this.setState({ previewLoaded: true });
+  }
+
+  handleReveal = () => {
+    this.setState({ revealed: true });
+  }
+
   renderVideo () {
     const { card }  = this.props;
     const content   = { __html: addAutoPlay(card.get('html')) };
@@ -138,7 +187,7 @@ export default class Card extends React.PureComponent {
 
   render () {
     const { card, maxDescription, compact } = this.props;
-    const { width, embedded } = this.state;
+    const { width, embedded, revealed } = this.state;
 
     if (card === null) {
       return null;
@@ -153,7 +202,7 @@ export default class Card extends React.PureComponent {
     const height      = (compact && !embedded) ? (width / (16 / 9)) : (width / ratio);
 
     const description = (
-      <div className='status-card__content'>
+      <div className={classNames('status-card__content', { 'status-card__content--blurred': !revealed })}>
         {title}
         {!(horizontal || compact) && <p className='status-card__description'>{trim(card.get('description') || '', maxDescription)}</p>}
         <span className='status-card__host'>{provider}</span>
@@ -161,7 +210,18 @@ export default class Card extends React.PureComponent {
     );
 
     let embed     = '';
-    let thumbnail = <div style={{ backgroundImage: `url(${card.get('image')})`, width: horizontal ? width : null, height: horizontal ? height : null }} className='status-card__image-image' />;
+    let canvas = <canvas width={32} height={32} ref={this.setCanvasRef} className={classNames('status-card__image-preview', { 'status-card__image-preview--hidden' : revealed && this.state.previewLoaded })} />;
+    let thumbnail = <img src={card.get('image')} alt='' style={{ width: horizontal ? width : null, height: horizontal ? height : null, visibility: revealed ? null : 'hidden' }} onLoad={this.handleImageLoad} className='status-card__image-image' />;
+    let spoilerButton = (
+      <button type='button' onClick={this.handleReveal} className='spoiler-button__overlay'>
+        <span className='spoiler-button__overlay__label'><FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' /></span>
+      </button>
+    );
+    spoilerButton = (
+      <div className={classNames('spoiler-button', { 'spoiler-button--minified': revealed })}>
+        {spoilerButton}
+      </div>
+    );
 
     if (interactive) {
       if (embedded) {
@@ -175,14 +235,18 @@ export default class Card extends React.PureComponent {
 
         embed = (
           <div className='status-card__image'>
+            {canvas}
             {thumbnail}
 
-            <div className='status-card__actions'>
-              <div>
-                <button onClick={this.handleEmbedClick}><Icon id={iconVariant} /></button>
-                {horizontal && <a href={card.get('url')} target='_blank' rel='noopener noreferrer'><Icon id='external-link' /></a>}
+            {revealed && (
+              <div className='status-card__actions'>
+                <div>
+                  <button onClick={this.handleEmbedClick}><Icon id={iconVariant} /></button>
+                  {horizontal && <a href={card.get('url')} target='_blank' rel='noopener noreferrer'><Icon id='external-link' /></a>}
+                </div>
               </div>
-            </div>
+            )}
+            {!revealed && spoilerButton}
           </div>
         );
       }
@@ -196,13 +260,16 @@ export default class Card extends React.PureComponent {
     } else if (card.get('image')) {
       embed = (
         <div className='status-card__image'>
+          {canvas}
           {thumbnail}
+          {!revealed && spoilerButton}
         </div>
       );
     } else {
       embed = (
         <div className='status-card__image'>
           <Icon id='file-text' />
+          {!revealed && spoilerButton}
         </div>
       );
     }
diff --git a/app/javascript/mastodon/features/status/components/detailed_status.js b/app/javascript/mastodon/features/status/components/detailed_status.js
index 4201b237e..2ac47677e 100644
--- a/app/javascript/mastodon/features/status/components/detailed_status.js
+++ b/app/javascript/mastodon/features/status/components/detailed_status.js
@@ -153,7 +153,7 @@ export default class DetailedStatus extends ImmutablePureComponent {
         );
       }
     } else if (status.get('spoiler_text').length === 0) {
-      media = <Card onOpenMedia={this.props.onOpenMedia} card={status.get('card', null)} />;
+      media = <Card sensitive={status.get('sensitive')} onOpenMedia={this.props.onOpenMedia} card={status.get('card', null)} />;
     }
 
     if (status.get('application')) {
diff --git a/app/javascript/mastodon/features/ui/components/compose_panel.js b/app/javascript/mastodon/features/ui/components/compose_panel.js
index c7821f473..3d0c48c7a 100644
--- a/app/javascript/mastodon/features/ui/components/compose_panel.js
+++ b/app/javascript/mastodon/features/ui/components/compose_panel.js
@@ -1,16 +1,36 @@
 import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
 import SearchContainer from 'mastodon/features/compose/containers/search_container';
 import ComposeFormContainer from 'mastodon/features/compose/containers/compose_form_container';
 import NavigationContainer from 'mastodon/features/compose/containers/navigation_container';
 import LinkFooter from './link_footer';
+import { changeComposing } from 'mastodon/actions/compose';
 
-const ComposePanel = () => (
-  <div className='compose-panel'>
-    <SearchContainer openInRoute />
-    <NavigationContainer />
-    <ComposeFormContainer singleColumn />
-    <LinkFooter withHotkeys />
-  </div>
-);
+export default @connect()
+class ComposePanel extends React.PureComponent {
 
-export default ComposePanel;
+  static propTypes = {
+    dispatch: PropTypes.func.isRequired,
+  };
+
+  onFocus = () => {
+    this.props.dispatch(changeComposing(true));
+  }
+
+  onBlur = () => {
+    this.props.dispatch(changeComposing(false));
+  }
+
+  render() {
+    return (
+      <div className='compose-panel' onFocus={this.onFocus}>
+        <SearchContainer openInRoute />
+        <NavigationContainer onClose={this.onBlur} />
+        <ComposeFormContainer singleColumn />
+        <LinkFooter withHotkeys />
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/mastodon/features/ui/index.js b/app/javascript/mastodon/features/ui/index.js
index 81ffad22e..553cb3365 100644
--- a/app/javascript/mastodon/features/ui/index.js
+++ b/app/javascript/mastodon/features/ui/index.js
@@ -10,7 +10,7 @@ import LoadingBarContainer from './containers/loading_bar_container';
 import ModalContainer from './containers/modal_container';
 import { isMobile } from '../../is_mobile';
 import { debounce } from 'lodash';
-import { uploadCompose, resetCompose } from '../../actions/compose';
+import { uploadCompose, resetCompose, changeComposeSpoilerness } from '../../actions/compose';
 import { expandHomeTimeline } from '../../actions/timelines';
 import { expandNotifications } from '../../actions/notifications';
 import { fetchFilters } from '../../actions/filters';
@@ -76,6 +76,7 @@ const keyMap = {
   new: 'n',
   search: 's',
   forceNew: 'option+n',
+  toggleComposeSpoilers: 'option+x',
   focusColumn: ['1', '2', '3', '4', '5', '6', '7', '8', '9'],
   reply: 'r',
   favourite: 'f',
@@ -254,6 +255,7 @@ class UI extends React.PureComponent {
     dispatch(synchronouslySubmitMarkers());
 
     if (isComposing && (hasComposingText || hasMediaAttachments)) {
+      e.preventDefault();
       // Setting returnValue to any string causes confirmation dialog.
       // Many browsers no longer display this text to users,
       // but we set user-friendly message for other browsers, e.g. Edge.
@@ -374,7 +376,7 @@ class UI extends React.PureComponent {
 
   componentDidMount () {
     this.hotkeys.__mousetrap__.stopCallback = (e, element) => {
-      return ['TEXTAREA', 'SELECT', 'INPUT'].includes(element.tagName);
+      return ['TEXTAREA', 'SELECT', 'INPUT'].includes(element.tagName) && !e.altKey;
     };
   }
 
@@ -419,6 +421,11 @@ class UI extends React.PureComponent {
     this.props.dispatch(resetCompose());
   }
 
+  handleHotkeyToggleComposeSpoilers = e => {
+    e.preventDefault();
+    this.props.dispatch(changeComposeSpoilerness());
+  }
+
   handleHotkeyFocusColumn = e => {
     const index  = (e.key * 1) + 1; // First child is drawer, skip that
     const column = this.node.querySelector(`.column:nth-child(${index})`);
@@ -514,6 +521,7 @@ class UI extends React.PureComponent {
       new: this.handleHotkeyNew,
       search: this.handleHotkeySearch,
       forceNew: this.handleHotkeyForceNew,
+      toggleComposeSpoilers: this.handleHotkeyToggleComposeSpoilers,
       focusColumn: this.handleHotkeyFocusColumn,
       back: this.handleHotkeyBack,
       goToHome: this.handleHotkeyGoToHome,
diff --git a/app/javascript/mastodon/locales/ar.json b/app/javascript/mastodon/locales/ar.json
index 70868e397..e1871b31a 100644
--- a/app/javascript/mastodon/locales/ar.json
+++ b/app/javascript/mastodon/locales/ar.json
@@ -5,6 +5,7 @@
   "account.block": "حظر @{name}",
   "account.block_domain": "إخفاء كل شيء قادم من اسم النطاق {domain}",
   "account.blocked": "محظور",
+  "account.browse_more_on_origin_server": "Browse more on the original profile",
   "account.cancel_follow_request": "إلغاء طلب المتابَعة",
   "account.direct": "رسالة خاصة إلى @{name}",
   "account.domain_blocked": "النطاق مخفي",
@@ -236,6 +237,7 @@
   "keyboard_shortcuts.reply": "للردّ",
   "keyboard_shortcuts.requests": "لفتح قائمة طلبات المتابعة",
   "keyboard_shortcuts.search": "للتركيز على البحث",
+  "keyboard_shortcuts.spoilers": "to show/hide CW field",
   "keyboard_shortcuts.start": "لفتح عمود \"هيا نبدأ\"",
   "keyboard_shortcuts.toggle_hidden": "لعرض أو إخفاء النص مِن وراء التحذير",
   "keyboard_shortcuts.toggle_sensitivity": "لعرض/إخفاء الوسائط",
@@ -412,6 +414,10 @@
   "time_remaining.minutes": "{number, plural, one {# دقيقة} other {# دقائق}} متبقية",
   "time_remaining.moments": "لحظات متبقية",
   "time_remaining.seconds": "{number, plural, one {# ثانية} other {# ثوانٍ}} متبقية",
+  "timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.",
+  "timeline_hint.resources.followers": "Followers",
+  "timeline_hint.resources.follows": "Follows",
+  "timeline_hint.resources.statuses": "Older toots",
   "trends.count_by_accounts": "{count} {rawCount, plural, zero {} one {شخص واحد} two {شخصين} few {أشخاص} many {أشخاص} other {أشخاص}} تتحدّث",
   "trends.trending_now": "المتداولة الآن",
   "ui.beforeunload": "سوف تفقد مسودتك إن تركت ماستدون.",
diff --git a/app/javascript/mastodon/locales/ast.json b/app/javascript/mastodon/locales/ast.json
index 6c53e33db..3989978a0 100644
--- a/app/javascript/mastodon/locales/ast.json
+++ b/app/javascript/mastodon/locales/ast.json
@@ -5,6 +5,7 @@
   "account.block": "Bloquiar a @{name}",
   "account.block_domain": "Anubrir tolo de {domain}",
   "account.blocked": "Blocked",
+  "account.browse_more_on_origin_server": "Browse more on the original profile",
   "account.cancel_follow_request": "Encaboxar la solicitú de siguimientu",
   "account.direct": "Unviar un mensaxe direutu a @{name}",
   "account.domain_blocked": "Dominiu anubríu",
@@ -236,6 +237,7 @@
   "keyboard_shortcuts.reply": "pa responder",
   "keyboard_shortcuts.requests": "p'abrir la llista de solicitúes de siguimientu",
   "keyboard_shortcuts.search": "pa enfocar la gueta",
+  "keyboard_shortcuts.spoilers": "to show/hide CW field",
   "keyboard_shortcuts.start": "p'abrir la columna «entamar»",
   "keyboard_shortcuts.toggle_hidden": "to show/hide text behind CW",
   "keyboard_shortcuts.toggle_sensitivity": "to show/hide media",
@@ -412,6 +414,10 @@
   "time_remaining.minutes": "{number, plural, one {# minutu restante} other {# minutos restantes}}",
   "time_remaining.moments": "Moments remaining",
   "time_remaining.seconds": "{number, plural, one {# segundu restante} other {# segundos restantes}}",
+  "timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.",
+  "timeline_hint.resources.followers": "Followers",
+  "timeline_hint.resources.follows": "Follows",
+  "timeline_hint.resources.statuses": "Older toots",
   "trends.count_by_accounts": "{count} {rawCount, plural, one {persona} other {persones}} falando",
   "trends.trending_now": "Trending now",
   "ui.beforeunload": "El borrador va perdese si coles de Mastodon.",
diff --git a/app/javascript/mastodon/locales/bg.json b/app/javascript/mastodon/locales/bg.json
index b571d8c0e..e8bf05b13 100644
--- a/app/javascript/mastodon/locales/bg.json
+++ b/app/javascript/mastodon/locales/bg.json
@@ -5,6 +5,7 @@
   "account.block": "Блокирай",
   "account.block_domain": "скрий всичко от (домейн)",
   "account.blocked": "Блокирани",
+  "account.browse_more_on_origin_server": "Browse more on the original profile",
   "account.cancel_follow_request": "Откажи искането за следване",
   "account.direct": "Direct Message @{name}",
   "account.domain_blocked": "Скрит домейн",
@@ -236,6 +237,7 @@
   "keyboard_shortcuts.reply": "to reply",
   "keyboard_shortcuts.requests": "to open follow requests list",
   "keyboard_shortcuts.search": "to focus search",
+  "keyboard_shortcuts.spoilers": "to show/hide CW field",
   "keyboard_shortcuts.start": "to open \"get started\" column",
   "keyboard_shortcuts.toggle_hidden": "to show/hide text behind CW",
   "keyboard_shortcuts.toggle_sensitivity": "to show/hide media",
@@ -412,6 +414,10 @@
   "time_remaining.minutes": "{number, plural, one {# minute} other {# minutes}} left",
   "time_remaining.moments": "Moments remaining",
   "time_remaining.seconds": "{number, plural, one {# second} other {# seconds}} left",
+  "timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.",
+  "timeline_hint.resources.followers": "Followers",
+  "timeline_hint.resources.follows": "Follows",
+  "timeline_hint.resources.statuses": "Older toots",
   "trends.count_by_accounts": "{count} {rawCount, plural, one {person} other {people}} talking",
   "trends.trending_now": "Trending now",
   "ui.beforeunload": "Your draft will be lost if you leave Mastodon.",
diff --git a/app/javascript/mastodon/locales/bn.json b/app/javascript/mastodon/locales/bn.json
index 20a64b7e3..7f27f58f2 100644
--- a/app/javascript/mastodon/locales/bn.json
+++ b/app/javascript/mastodon/locales/bn.json
@@ -5,6 +5,7 @@
   "account.block": "@{name} কে ব্লক করুন",
   "account.block_domain": "{domain} থেকে সব আড়াল করুন",
   "account.blocked": "অবরুদ্ধ",
+  "account.browse_more_on_origin_server": "Browse more on the original profile",
   "account.cancel_follow_request": "অনুসরণ অনুরোধ বাতিল করুন",
   "account.direct": "@{name} কে সরাসরি বার্তা",
   "account.domain_blocked": "ডোমেন গোপন করুন",
@@ -236,6 +237,7 @@
   "keyboard_shortcuts.reply": "মতামত দিতে",
   "keyboard_shortcuts.requests": "অনুসরণ অনুরোধের তালিকা দেখতে",
   "keyboard_shortcuts.search": "খোঁজার অংশে ফোকাস করতে",
+  "keyboard_shortcuts.spoilers": "to show/hide CW field",
   "keyboard_shortcuts.start": "\"প্রথম শুরুর\" কলাম বের করতে",
   "keyboard_shortcuts.toggle_hidden": "CW লেখা দেখতে বা লুকাতে",
   "keyboard_shortcuts.toggle_sensitivity": "ভিডিও/ছবি দেখতে বা বন্ধ করতে",
@@ -412,6 +414,10 @@
   "time_remaining.minutes": "{number, plural, one {# মিনিট} other {# মিনিট}} বাকি আছে",
   "time_remaining.moments": "সময় বাকি আছে",
   "time_remaining.seconds": "{number, plural, one {# second} other {# seconds}} বাকি আছে",
+  "timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.",
+  "timeline_hint.resources.followers": "Followers",
+  "timeline_hint.resources.follows": "Follows",
+  "timeline_hint.resources.statuses": "Older toots",
   "trends.count_by_accounts": "{count} {rawCount, plural, one {person} other {people}} কথা বলছে",
   "trends.trending_now": "বর্তমানে জনপ্রিয়",
   "ui.beforeunload": "যে পর্যন্ত এটা লেখা হয়েছে, মাস্টাডন থেকে চলে গেলে এটা মুছে যাবে।",
diff --git a/app/javascript/mastodon/locales/br.json b/app/javascript/mastodon/locales/br.json
index 5bf8cdb1c..ae7283573 100644
--- a/app/javascript/mastodon/locales/br.json
+++ b/app/javascript/mastodon/locales/br.json
@@ -5,6 +5,7 @@
   "account.block": "Berzañ @{name}",
   "account.block_domain": "Berzañ pep tra eus {domain}",
   "account.blocked": "Stanket",
+  "account.browse_more_on_origin_server": "Browse more on the original profile",
   "account.cancel_follow_request": "Nullañ ar bedadenn heuliañ",
   "account.direct": "Kas ur gemennadenn da @{name}",
   "account.domain_blocked": "Domani berzet",
@@ -236,6 +237,7 @@
   "keyboard_shortcuts.reply": "da respont",
   "keyboard_shortcuts.requests": "to open follow requests list",
   "keyboard_shortcuts.search": "to focus search",
+  "keyboard_shortcuts.spoilers": "to show/hide CW field",
   "keyboard_shortcuts.start": "to open \"get started\" column",
   "keyboard_shortcuts.toggle_hidden": "to show/hide text behind CW",
   "keyboard_shortcuts.toggle_sensitivity": "to show/hide media",
@@ -412,6 +414,10 @@
   "time_remaining.minutes": "{number, plural, one {# minute} other {# minutes}} left",
   "time_remaining.moments": "Moments remaining",
   "time_remaining.seconds": "{number, plural, one {# second} other {# seconds}} left",
+  "timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.",
+  "timeline_hint.resources.followers": "Followers",
+  "timeline_hint.resources.follows": "Follows",
+  "timeline_hint.resources.statuses": "Older toots",
   "trends.count_by_accounts": "{count} {rawCount, plural, one {person} other {people}} talking",
   "trends.trending_now": "Luskad ar mare",
   "ui.beforeunload": "Your draft will be lost if you leave Mastodon.",
diff --git a/app/javascript/mastodon/locales/ca.json b/app/javascript/mastodon/locales/ca.json
index 00965f153..d5e5ce565 100644
--- a/app/javascript/mastodon/locales/ca.json
+++ b/app/javascript/mastodon/locales/ca.json
@@ -5,6 +5,7 @@
   "account.block": "Bloqueja @{name}",
   "account.block_domain": "Amaga-ho tot de {domain}",
   "account.blocked": "Bloquejat",
+  "account.browse_more_on_origin_server": "Browse more on the original profile",
   "account.cancel_follow_request": "Anul·la la sol·licitud de seguiment",
   "account.direct": "Missatge directe @{name}",
   "account.domain_blocked": "Domini ocult",
@@ -236,6 +237,7 @@
   "keyboard_shortcuts.reply": "respondre",
   "keyboard_shortcuts.requests": "per a obrir la llista de sol·licituds de seguiment",
   "keyboard_shortcuts.search": "per a centrar la cerca",
+  "keyboard_shortcuts.spoilers": "to show/hide CW field",
   "keyboard_shortcuts.start": "per a obrir la columna \"Començar\"",
   "keyboard_shortcuts.toggle_hidden": "per a mostrar o amagar text sota CW",
   "keyboard_shortcuts.toggle_sensitivity": "per a mostrar o amagar contingut multimèdia",
@@ -412,6 +414,10 @@
   "time_remaining.minutes": "{number, plural, one {# minut} other {# minuts}} restants",
   "time_remaining.moments": "Moments restants",
   "time_remaining.seconds": "{number, plural, one {# segon} other {# segons}} restants",
+  "timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.",
+  "timeline_hint.resources.followers": "Followers",
+  "timeline_hint.resources.follows": "Follows",
+  "timeline_hint.resources.statuses": "Older toots",
   "trends.count_by_accounts": "{count} {rawCount, plural, one {persona} other {persones}} parlant-hi",
   "trends.trending_now": "Ara en tendència",
   "ui.beforeunload": "El teu esborrany es perdrà si surts de Mastodon.",
diff --git a/app/javascript/mastodon/locales/co.json b/app/javascript/mastodon/locales/co.json
index 2e8b8e1a4..76b4abd50 100644
--- a/app/javascript/mastodon/locales/co.json
+++ b/app/javascript/mastodon/locales/co.json
@@ -5,6 +5,7 @@
   "account.block": "Bluccà @{name}",
   "account.block_domain": "Piattà u duminiu {domain}",
   "account.blocked": "Bluccatu",
+  "account.browse_more_on_origin_server": "Browse more on the original profile",
   "account.cancel_follow_request": "Annullà a dumanda d'abbunamentu",
   "account.direct": "Missaghju direttu @{name}",
   "account.domain_blocked": "Duminiu piattatu",
@@ -236,6 +237,7 @@
   "keyboard_shortcuts.reply": "risponde",
   "keyboard_shortcuts.requests": "per apre a lista di dumande d'abbunamentu",
   "keyboard_shortcuts.search": "fucalizà nant'à l'area di circata",
+  "keyboard_shortcuts.spoilers": "to show/hide CW field",
   "keyboard_shortcuts.start": "per apre a culonna \"per principià\"",
   "keyboard_shortcuts.toggle_hidden": "vede/piattà u testu daretu à l'avertimentu CW",
   "keyboard_shortcuts.toggle_sensitivity": "vede/piattà i media",
@@ -412,6 +414,10 @@
   "time_remaining.minutes": "{number, plural, one {# minuta ferma} other {# minute fermanu}} left",
   "time_remaining.moments": "Ci fermanu qualchi mumentu",
   "time_remaining.seconds": "{number, plural, one {# siconda ferma} other {# siconde fermanu}}",
+  "timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.",
+  "timeline_hint.resources.followers": "Followers",
+  "timeline_hint.resources.follows": "Follows",
+  "timeline_hint.resources.statuses": "Older toots",
   "trends.count_by_accounts": "{count} {rawCount, plural, one {person} other {people}} parlanu",
   "trends.trending_now": "Tindenze d'avà",
   "ui.beforeunload": "A bruttacopia sarà persa s'ellu hè chjosu Mastodon.",
diff --git a/app/javascript/mastodon/locales/cs.json b/app/javascript/mastodon/locales/cs.json
index 3c51b7f9c..0b092a9c7 100644
--- a/app/javascript/mastodon/locales/cs.json
+++ b/app/javascript/mastodon/locales/cs.json
@@ -5,6 +5,7 @@
   "account.block": "Zablokovat uživatele @{name}",
   "account.block_domain": "Skrýt vše ze serveru {domain}",
   "account.blocked": "Blokováno",
+  "account.browse_more_on_origin_server": "Browse more on the original profile",
   "account.cancel_follow_request": "Zrušit žádost o sledování",
   "account.direct": "Poslat uživateli @{name} přímou zprávu",
   "account.domain_blocked": "Doména skryta",
@@ -236,6 +237,7 @@
   "keyboard_shortcuts.reply": "odpovědět",
   "keyboard_shortcuts.requests": "otevření seznamu požadavků o sledování",
   "keyboard_shortcuts.search": "zaměření na hledání",
+  "keyboard_shortcuts.spoilers": "to show/hide CW field",
   "keyboard_shortcuts.start": "otevření sloupce „začínáme“",
   "keyboard_shortcuts.toggle_hidden": "zobrazení/skrytí textu za varováním o obsahu",
   "keyboard_shortcuts.toggle_sensitivity": "zobrazení/skrytí médií",
@@ -412,6 +414,10 @@
   "time_remaining.minutes": "{number, plural, one {Zbývá # minuta} few {Zbývají # minuty} many {Zbývá # minut} other {Zbývá # minut}}",
   "time_remaining.moments": "Zbývá několik sekund",
   "time_remaining.seconds": "{number, plural, one {Zbývá # sekunda} few {Zbývají # sekundy} many {Zbývá # sekund} other {Zbývá # sekund}}",
+  "timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.",
+  "timeline_hint.resources.followers": "Followers",
+  "timeline_hint.resources.follows": "Follows",
+  "timeline_hint.resources.statuses": "Older toots",
   "trends.count_by_accounts": "{count} {rawCount, plural, one {člověk} few {lidé} many {lidí} other {lidí}} hovoří",
   "trends.trending_now": "Aktuální trendy",
   "ui.beforeunload": "Pokud Mastodon opustíte, váš koncept se ztratí.",
diff --git a/app/javascript/mastodon/locales/cy.json b/app/javascript/mastodon/locales/cy.json
index d8b8b56f4..0dbc91b8d 100644
--- a/app/javascript/mastodon/locales/cy.json
+++ b/app/javascript/mastodon/locales/cy.json
@@ -5,6 +5,7 @@
   "account.block": "Blocio @{name}",
   "account.block_domain": "Cuddio popeth rhag {domain}",
   "account.blocked": "Blociwyd",
+  "account.browse_more_on_origin_server": "Browse more on the original profile",
   "account.cancel_follow_request": "Canslo cais dilyn",
   "account.direct": "Neges breifat @{name}",
   "account.domain_blocked": "Parth wedi ei guddio",
@@ -236,6 +237,7 @@
   "keyboard_shortcuts.reply": "i ateb",
   "keyboard_shortcuts.requests": "i agor rhestr ceisiadau dilyn",
   "keyboard_shortcuts.search": "i ffocysu chwilio",
+  "keyboard_shortcuts.spoilers": "to show/hide CW field",
   "keyboard_shortcuts.start": "i agor colofn \"dechrau arni\"",
   "keyboard_shortcuts.toggle_hidden": "i ddangos/cuddio testun tu ôl i CW",
   "keyboard_shortcuts.toggle_sensitivity": "i ddangos/gyddio cyfryngau",
@@ -412,6 +414,10 @@
   "time_remaining.minutes": "{number, plural, one {# funud} other {# o funudau}} ar ôl",
   "time_remaining.moments": "Munudau ar ôl",
   "time_remaining.seconds": "{number, plural, one {# eiliad} other {# o eiliadau}} ar ôl",
+  "timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.",
+  "timeline_hint.resources.followers": "Followers",
+  "timeline_hint.resources.follows": "Follows",
+  "timeline_hint.resources.statuses": "Older toots",
   "trends.count_by_accounts": "{count} {rawCount, plural, one {person} other {people}} yn siarad",
   "trends.trending_now": "Yn tueddu nawr",
   "ui.beforeunload": "Mi fyddwch yn colli eich drafft os gadewch Mastodon.",
diff --git a/app/javascript/mastodon/locales/da.json b/app/javascript/mastodon/locales/da.json
index d93a73dee..5fb6ea4d7 100644
--- a/app/javascript/mastodon/locales/da.json
+++ b/app/javascript/mastodon/locales/da.json
@@ -5,6 +5,7 @@
   "account.block": "Bloker @{name}",
   "account.block_domain": "Skjul alt fra {domain}",
   "account.blocked": "Blokeret",
+  "account.browse_more_on_origin_server": "Browse more on the original profile",
   "account.cancel_follow_request": "Annullér følgeranmodning",
   "account.direct": "Send en direkte besked til @{name}",
   "account.domain_blocked": "Domænet er blevet skjult",
@@ -236,6 +237,7 @@
   "keyboard_shortcuts.reply": "for at svare",
   "keyboard_shortcuts.requests": "for at åbne listen over følgeranmodninger",
   "keyboard_shortcuts.search": "for at fokusere søgningen",
+  "keyboard_shortcuts.spoilers": "to show/hide CW field",
   "keyboard_shortcuts.start": "for at åbne \"kom igen\" kolonnen",
   "keyboard_shortcuts.toggle_hidden": "for at vise/skjule tekst bag CW",
   "keyboard_shortcuts.toggle_sensitivity": "for at vise/skjule medier",
@@ -412,6 +414,10 @@
   "time_remaining.minutes": "{number, plural, one {# minut} other {# minutter}} tilbage",
   "time_remaining.moments": "Få øjeblikke tilbage",
   "time_remaining.seconds": "{number, plural, one {# sekund} other {# sekunder}} tilbage",
+  "timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.",
+  "timeline_hint.resources.followers": "Followers",
+  "timeline_hint.resources.follows": "Follows",
+  "timeline_hint.resources.statuses": "Older toots",
   "trends.count_by_accounts": "{count} {rawCount, plural, one {person} other {personer}} snakker",
   "trends.trending_now": "Hot lige nu",
   "ui.beforeunload": "Din kladde vil gå tabt hvis du forlader Mastodon.",
diff --git a/app/javascript/mastodon/locales/de.json b/app/javascript/mastodon/locales/de.json
index 35bc3025b..5220772cd 100644
--- a/app/javascript/mastodon/locales/de.json
+++ b/app/javascript/mastodon/locales/de.json
@@ -5,6 +5,7 @@
   "account.block": "@{name} blockieren",
   "account.block_domain": "Alles von {domain} blockieren",
   "account.blocked": "Blockiert",
+  "account.browse_more_on_origin_server": "Browse more on the original profile",
   "account.cancel_follow_request": "Folgeanfrage abbrechen",
   "account.direct": "Direktnachricht an @{name}",
   "account.domain_blocked": "Domain versteckt",
@@ -236,6 +237,7 @@
   "keyboard_shortcuts.reply": "antworten",
   "keyboard_shortcuts.requests": "Liste der Folge-Anfragen öffnen",
   "keyboard_shortcuts.search": "Suche fokussieren",
+  "keyboard_shortcuts.spoilers": "to show/hide CW field",
   "keyboard_shortcuts.start": "\"Erste Schritte\"-Spalte öffnen",
   "keyboard_shortcuts.toggle_hidden": "Text hinter einer Inhaltswarnung verstecken/anzeigen",
   "keyboard_shortcuts.toggle_sensitivity": "Medien hinter einer Inhaltswarnung verstecken/anzeigen",
@@ -412,6 +414,10 @@
   "time_remaining.minutes": "{number, plural, one {# Minute} other {# Minuten}} verbleibend",
   "time_remaining.moments": "Schließt in Kürze",
   "time_remaining.seconds": "{number, plural, one {# Sekunde} other {# Sekunden}} verbleibend",
+  "timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.",
+  "timeline_hint.resources.followers": "Followers",
+  "timeline_hint.resources.follows": "Follows",
+  "timeline_hint.resources.statuses": "Older toots",
   "trends.count_by_accounts": "{count} {rawCount, plural, eine {Person} other {Personen}} reden darüber",
   "trends.trending_now": "In den Trends",
   "ui.beforeunload": "Dein Entwurf geht verloren, wenn du Mastodon verlässt.",
diff --git a/app/javascript/mastodon/locales/defaultMessages.json b/app/javascript/mastodon/locales/defaultMessages.json
index 7bd4a274c..1d280d710 100644
--- a/app/javascript/mastodon/locales/defaultMessages.json
+++ b/app/javascript/mastodon/locales/defaultMessages.json
@@ -510,6 +510,19 @@
   {
     "descriptors": [
       {
+        "defaultMessage": "{resource} from other servers are not displayed.",
+        "id": "timeline_hint.remote_resource_not_displayed"
+      },
+      {
+        "defaultMessage": "Browse more on the original profile",
+        "id": "account.browse_more_on_origin_server"
+      }
+    ],
+    "path": "app/javascript/mastodon/components/timeline_hint.json"
+  },
+  {
+    "descriptors": [
+      {
         "defaultMessage": "Unfollow",
         "id": "confirmations.unfollow.confirm"
       },
@@ -620,6 +633,10 @@
   {
     "descriptors": [
       {
+        "defaultMessage": "Older toots",
+        "id": "timeline_hint.resources.statuses"
+      },
+      {
         "defaultMessage": "Profile unavailable",
         "id": "empty_column.account_unavailable"
       },
@@ -1543,6 +1560,10 @@
   {
     "descriptors": [
       {
+        "defaultMessage": "Followers",
+        "id": "timeline_hint.resources.followers"
+      },
+      {
         "defaultMessage": "Profile unavailable",
         "id": "empty_column.account_unavailable"
       },
@@ -1556,6 +1577,10 @@
   {
     "descriptors": [
       {
+        "defaultMessage": "Follows",
+        "id": "timeline_hint.resources.follows"
+      },
+      {
         "defaultMessage": "Profile unavailable",
         "id": "empty_column.account_unavailable"
       },
@@ -1921,6 +1946,10 @@
         "id": "keyboard_shortcuts.toot"
       },
       {
+        "defaultMessage": "to show/hide CW field",
+        "id": "keyboard_shortcuts.spoilers"
+      },
+      {
         "defaultMessage": "to navigate back",
         "id": "keyboard_shortcuts.back"
       },
@@ -2430,6 +2459,15 @@
   {
     "descriptors": [
       {
+        "defaultMessage": "Sensitive content",
+        "id": "status.sensitive_warning"
+      }
+    ],
+    "path": "app/javascript/mastodon/features/status/components/card.json"
+  },
+  {
+    "descriptors": [
+      {
         "defaultMessage": "Delete",
         "id": "confirmations.delete.confirm"
       },
@@ -2982,4 +3020,4 @@
     ],
     "path": "app/javascript/mastodon/features/video/index.json"
   }
-]
+]
\ No newline at end of file
diff --git a/app/javascript/mastodon/locales/el.json b/app/javascript/mastodon/locales/el.json
index 202c91f90..31d289207 100644
--- a/app/javascript/mastodon/locales/el.json
+++ b/app/javascript/mastodon/locales/el.json
@@ -5,6 +5,7 @@
   "account.block": "Αποκλεισμός @{name}",
   "account.block_domain": "Απόκρυψη όλων από {domain}",
   "account.blocked": "Αποκλεισμένος/η",
+  "account.browse_more_on_origin_server": "Browse more on the original profile",
   "account.cancel_follow_request": "Ακύρωση αιτήματος παρακολούθησης",
   "account.direct": "Προσωπικό μήνυμα προς @{name}",
   "account.domain_blocked": "Κρυμμένος τομέας",
@@ -236,6 +237,7 @@
   "keyboard_shortcuts.reply": "απάντηση",
   "keyboard_shortcuts.requests": "άνοιγμα λίστας αιτημάτων παρακολούθησης",
   "keyboard_shortcuts.search": "εστίαση αναζήτησης",
+  "keyboard_shortcuts.spoilers": "to show/hide CW field",
   "keyboard_shortcuts.start": "άνοιγμα κολώνας \"Ξεκινώντας\"",
   "keyboard_shortcuts.toggle_hidden": "εμφάνιση/απόκρυψη κειμένου πίσω από την προειδοποίηση",
   "keyboard_shortcuts.toggle_sensitivity": "εμφάνιση/απόκρυψη πολυμέσων",
@@ -412,6 +414,10 @@
   "time_remaining.minutes": "απομένουν {number, plural, one {# λεπτό} other {# λεπτά}}",
   "time_remaining.moments": "Απομένουν στιγμές",
   "time_remaining.seconds": "απομένουν {number, plural, one {# δευτερόλεπτο} other {# δευτερόλεπτα}}",
+  "timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.",
+  "timeline_hint.resources.followers": "Followers",
+  "timeline_hint.resources.follows": "Follows",
+  "timeline_hint.resources.statuses": "Older toots",
   "trends.count_by_accounts": "{count} {rawCount, plural, one {άτομο μιλάει} other {άτομα μιλάνε}}",
   "trends.trending_now": "Δημοφιλή τώρα",
   "ui.beforeunload": "Το προσχέδιό σου θα χαθεί αν φύγεις από το Mastodon.",
diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json
index 225126e6f..1779f4713 100644
--- a/app/javascript/mastodon/locales/en.json
+++ b/app/javascript/mastodon/locales/en.json
@@ -5,6 +5,7 @@
   "account.block": "Block @{name}",
   "account.block_domain": "Block domain {domain}",
   "account.blocked": "Blocked",
+  "account.browse_more_on_origin_server": "Browse more on the original profile",
   "account.cancel_follow_request": "Cancel follow request",
   "account.direct": "Direct message @{name}",
   "account.domain_blocked": "Domain blocked",
@@ -106,7 +107,7 @@
   "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.message": "Are you sure you want to delete this toot?",
   "confirmations.delete_list.confirm": "Delete",
   "confirmations.delete_list.message": "Are you sure you want to permanently delete this list?",
   "confirmations.domain_block.confirm": "Block entire domain",
@@ -117,7 +118,7 @@
   "confirmations.mute.explanation": "This will hide posts from them and posts mentioning them, but it will still allow them to see your posts and follow you.",
   "confirmations.mute.message": "Are you sure you want to mute {name}?",
   "confirmations.redraft.confirm": "Delete & redraft",
-  "confirmations.redraft.message": "Are you sure you want to delete this status and re-draft it? Favourites and boosts will be lost, and replies to the original post will be orphaned.",
+  "confirmations.redraft.message": "Are you sure you want to delete this toot and re-draft it? Favourites and boosts will be lost, and replies to the original post will be orphaned.",
   "confirmations.reply.confirm": "Reply",
   "confirmations.reply.message": "Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?",
   "confirmations.unfollow.confirm": "Unfollow",
@@ -130,7 +131,7 @@
   "directory.local": "From {domain} only",
   "directory.new_arrivals": "New arrivals",
   "directory.recently_active": "Recently active",
-  "embed.instructions": "Embed this status on your website by copying the code below.",
+  "embed.instructions": "Embed this toot 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",
@@ -159,7 +160,7 @@
   "empty_column.hashtag": "There is nothing in this hashtag yet.",
   "empty_column.home": "Your home timeline is empty! Visit {public} or use search to get started and meet other users.",
   "empty_column.home.public_timeline": "the public timeline",
-  "empty_column.list": "There is nothing in this list yet. When members of this list post new statuses, they will appear here.",
+  "empty_column.list": "There is nothing in this list yet. When members of this list post new toots, they will appear here.",
   "empty_column.lists": "You don't have any lists yet. When you create one, it will show up here.",
   "empty_column.mutes": "You haven't muted any users yet.",
   "empty_column.notifications": "You don't have any notifications yet. Interact with others to start the conversation.",
@@ -216,12 +217,12 @@
   "keyboard_shortcuts.back": "to navigate back",
   "keyboard_shortcuts.blocked": "to open blocked users list",
   "keyboard_shortcuts.boost": "to boost",
-  "keyboard_shortcuts.column": "to focus a status in one of the columns",
+  "keyboard_shortcuts.column": "to focus a toot in one of the columns",
   "keyboard_shortcuts.compose": "to focus the compose textarea",
   "keyboard_shortcuts.description": "Description",
   "keyboard_shortcuts.direct": "to open direct messages column",
   "keyboard_shortcuts.down": "to move down in the list",
-  "keyboard_shortcuts.enter": "to open status",
+  "keyboard_shortcuts.enter": "to open toot",
   "keyboard_shortcuts.favourite": "to favourite",
   "keyboard_shortcuts.favourites": "to open favourites list",
   "keyboard_shortcuts.federated": "to open federated timeline",
@@ -240,6 +241,7 @@
   "keyboard_shortcuts.reply": "to reply",
   "keyboard_shortcuts.requests": "to open follow requests list",
   "keyboard_shortcuts.search": "to focus search",
+  "keyboard_shortcuts.spoilers": "to show/hide CW field",
   "keyboard_shortcuts.start": "to open \"get started\" column",
   "keyboard_shortcuts.toggle_hidden": "to show/hide text behind CW",
   "keyboard_shortcuts.toggle_sensitivity": "to show/hide media",
@@ -289,13 +291,13 @@
   "navigation_bar.preferences": "Preferences",
   "navigation_bar.public_timeline": "Federated timeline",
   "navigation_bar.security": "Security",
-  "notification.favourite": "{name} favourited your status",
+  "notification.favourite": "{name} favourited your toot",
   "notification.follow": "{name} followed you",
   "notification.follow_request": "{name} has requested to follow you",
   "notification.mention": "{name} mentioned you",
   "notification.own_poll": "Your poll has ended",
   "notification.poll": "A poll you have voted in has ended",
-  "notification.reblog": "{name} boosted your status",
+  "notification.reblog": "{name} boosted your toot",
   "notifications.clear": "Clear notifications",
   "notifications.clear_confirmation": "Are you sure you want to permanently clear all your notifications?",
   "notifications.column_settings.alert": "Desktop notifications",
@@ -326,7 +328,7 @@
   "poll.voted": "You voted for this answer",
   "poll_button.add_poll": "Add a poll",
   "poll_button.remove_poll": "Remove poll",
-  "privacy.change": "Adjust status privacy",
+  "privacy.change": "Adjust toot privacy",
   "privacy.direct.long": "Visible for mentioned users only",
   "privacy.direct.short": "Direct",
   "privacy.private.long": "Visible for followers only",
@@ -353,9 +355,9 @@
   "report.target": "Reporting {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.full_text": "Simple text returns toots 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.status": "toot",
   "search_popout.tips.text": "Simple text returns matching display names, usernames and hashtags",
   "search_popout.tips.user": "user",
   "search_results.accounts": "People",
@@ -364,12 +366,12 @@
   "search_results.statuses_fts_disabled": "Searching toots by their content is not enabled on this Mastodon server.",
   "search_results.total": "{count, number} {count, plural, one {result} other {results}}",
   "status.admin_account": "Open moderation interface for @{name}",
-  "status.admin_status": "Open this status in the moderation interface",
+  "status.admin_status": "Open this toot in the moderation interface",
   "status.block": "Block @{name}",
   "status.bookmark": "Bookmark",
   "status.cancel_reblog_private": "Unboost",
   "status.cannot_reblog": "This post cannot be boosted",
-  "status.copy": "Copy link to status",
+  "status.copy": "Copy link to toot",
   "status.delete": "Delete",
   "status.detailed_status": "Detailed conversation view",
   "status.direct": "Direct message @{name}",
@@ -382,7 +384,7 @@
   "status.more": "More",
   "status.mute": "Mute @{name}",
   "status.mute_conversation": "Mute conversation",
-  "status.open": "Expand this status",
+  "status.open": "Expand this toot",
   "status.pin": "Pin on profile",
   "status.pinned": "Pinned toot",
   "status.read_more": "Read more",
@@ -417,6 +419,10 @@
   "time_remaining.minutes": "{number, plural, one {# minute} other {# minutes}} left",
   "time_remaining.moments": "Moments remaining",
   "time_remaining.seconds": "{number, plural, one {# second} other {# seconds}} left",
+  "timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.",
+  "timeline_hint.resources.followers": "Followers",
+  "timeline_hint.resources.follows": "Follows",
+  "timeline_hint.resources.statuses": "Older toots",
   "trends.count_by_accounts": "{count} {rawCount, plural, one {person} other {people}} talking",
   "trends.trending_now": "Trending now",
   "ui.beforeunload": "Your draft will be lost if you leave Mastodon.",
diff --git a/app/javascript/mastodon/locales/eo.json b/app/javascript/mastodon/locales/eo.json
index e476538be..fff7508ae 100644
--- a/app/javascript/mastodon/locales/eo.json
+++ b/app/javascript/mastodon/locales/eo.json
@@ -5,6 +5,7 @@
   "account.block": "Bloki @{name}",
   "account.block_domain": "Kaŝi ĉion de {domain}",
   "account.blocked": "Blokita",
+  "account.browse_more_on_origin_server": "Browse more on the original profile",
   "account.cancel_follow_request": "Nuligi peton de sekvado",
   "account.direct": "Rekte mesaĝi @{name}",
   "account.domain_blocked": "Domajno kaŝita",
@@ -236,6 +237,7 @@
   "keyboard_shortcuts.reply": "respondi",
   "keyboard_shortcuts.requests": "malfermi la liston de petoj de sekvado",
   "keyboard_shortcuts.search": "enfokusigi la serĉilon",
+  "keyboard_shortcuts.spoilers": "to show/hide CW field",
   "keyboard_shortcuts.start": "malfermi la kolumnon «por komenci»",
   "keyboard_shortcuts.toggle_hidden": "montri/kaŝi tekston malantaŭ enhava averto",
   "keyboard_shortcuts.toggle_sensitivity": "montri/kaŝi aŭdovidaĵojn",
@@ -412,6 +414,10 @@
   "time_remaining.minutes": "{number, plural, one {# minuto} other {# minutoj}} restas",
   "time_remaining.moments": "Momenteto restas",
   "time_remaining.seconds": "{number, plural, one {# sekundo} other {# sekundoj}} restas",
+  "timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.",
+  "timeline_hint.resources.followers": "Followers",
+  "timeline_hint.resources.follows": "Follows",
+  "timeline_hint.resources.statuses": "Older toots",
   "trends.count_by_accounts": "{count} {rawCount, plural, one {persono} other {personoj}} parolas",
   "trends.trending_now": "Nunaj furoraĵoj",
   "ui.beforeunload": "Via malneto perdiĝos se vi eliras de Mastodon.",
diff --git a/app/javascript/mastodon/locales/es-AR.json b/app/javascript/mastodon/locales/es-AR.json
index 15ef9afa9..a5ccea076 100644
--- a/app/javascript/mastodon/locales/es-AR.json
+++ b/app/javascript/mastodon/locales/es-AR.json
@@ -5,6 +5,7 @@
   "account.block": "Bloquear a @{name}",
   "account.block_domain": "Ocultar todo de {domain}",
   "account.blocked": "Bloqueado",
+  "account.browse_more_on_origin_server": "Browse more on the original profile",
   "account.cancel_follow_request": "Cancelar la solicitud de seguimiento",
   "account.direct": "Mensaje directo a @{name}",
   "account.domain_blocked": "Dominio oculto",
@@ -236,6 +237,7 @@
   "keyboard_shortcuts.reply": "para responder",
   "keyboard_shortcuts.requests": "para abrir la lista de solicitudes de seguimiento",
   "keyboard_shortcuts.search": "para enfocar la búsqueda",
+  "keyboard_shortcuts.spoilers": "to show/hide CW field",
   "keyboard_shortcuts.start": "para abrir la columna \"Introducción\"",
   "keyboard_shortcuts.toggle_hidden": "para mostrar/ocultar el texto detrás de la advertencia de contenido",
   "keyboard_shortcuts.toggle_sensitivity": "para mostrar/ocultar los medios",
@@ -412,6 +414,10 @@
   "time_remaining.minutes": "{number, plural,one {queda # minuto} other {quedan # minutos}}",
   "time_remaining.moments": "Momentos restantes",
   "time_remaining.seconds": "{number, plural,one {queda # segundo} other {quedan # segundos}}",
+  "timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.",
+  "timeline_hint.resources.followers": "Followers",
+  "timeline_hint.resources.follows": "Follows",
+  "timeline_hint.resources.statuses": "Older toots",
   "trends.count_by_accounts": "{count} {rawCount, plural, one {persona} other {personas}} hablando",
   "trends.trending_now": "Tendencia ahora",
   "ui.beforeunload": "Tu borrador se perderá si abandonás Mastodon.",
diff --git a/app/javascript/mastodon/locales/es.json b/app/javascript/mastodon/locales/es.json
index e8767cd35..a3a7a110d 100644
--- a/app/javascript/mastodon/locales/es.json
+++ b/app/javascript/mastodon/locales/es.json
@@ -5,6 +5,7 @@
   "account.block": "Bloquear a @{name}",
   "account.block_domain": "Ocultar todo de {domain}",
   "account.blocked": "Bloqueado",
+  "account.browse_more_on_origin_server": "Browse more on the original profile",
   "account.cancel_follow_request": "Cancelar la solicitud de seguimiento",
   "account.direct": "Mensaje directo a @{name}",
   "account.domain_blocked": "Dominio oculto",
@@ -236,6 +237,7 @@
   "keyboard_shortcuts.reply": "para responder",
   "keyboard_shortcuts.requests": "abrir la lista de peticiones de seguidores",
   "keyboard_shortcuts.search": "para poner el foco en la búsqueda",
+  "keyboard_shortcuts.spoilers": "to show/hide CW field",
   "keyboard_shortcuts.start": "abrir la columna \"comenzar\"",
   "keyboard_shortcuts.toggle_hidden": "mostrar/ocultar texto tras aviso de contenido (CW)",
   "keyboard_shortcuts.toggle_sensitivity": "mostrar/ocultar medios",
@@ -412,6 +414,10 @@
   "time_remaining.minutes": "{number, plural, one {# minuto restante} other {# minutos restantes}}",
   "time_remaining.moments": "Momentos restantes",
   "time_remaining.seconds": "{number, plural, one {# segundo restante} other {# segundos restantes}}",
+  "timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.",
+  "timeline_hint.resources.followers": "Followers",
+  "timeline_hint.resources.follows": "Follows",
+  "timeline_hint.resources.statuses": "Older toots",
   "trends.count_by_accounts": "{count} {rawCount, plural, one {persona} other {personas}} hablando",
   "trends.trending_now": "Tendencia ahora",
   "ui.beforeunload": "Tu borrador se perderá si sales de Mastodon.",
diff --git a/app/javascript/mastodon/locales/et.json b/app/javascript/mastodon/locales/et.json
index be3ee148b..bedba181d 100644
--- a/app/javascript/mastodon/locales/et.json
+++ b/app/javascript/mastodon/locales/et.json
@@ -5,6 +5,7 @@
   "account.block": "Blokeeri @{name}",
   "account.block_domain": "Peida kõik domeenist {domain}",
   "account.blocked": "Blokeeritud",
+  "account.browse_more_on_origin_server": "Browse more on the original profile",
   "account.cancel_follow_request": "Tühista jälgimistaotlus",
   "account.direct": "Otsesõnum @{name}",
   "account.domain_blocked": "Domeen peidetud",
@@ -236,6 +237,7 @@
   "keyboard_shortcuts.reply": "vastamiseks",
   "keyboard_shortcuts.requests": "avamaks jälgimistaotluste nimistut",
   "keyboard_shortcuts.search": "otsingu fokuseerimiseks",
+  "keyboard_shortcuts.spoilers": "to show/hide CW field",
   "keyboard_shortcuts.start": "avamaks \"Alusta\" tulpa",
   "keyboard_shortcuts.toggle_hidden": "näitamaks/peitmaks teksti CW taga",
   "keyboard_shortcuts.toggle_sensitivity": "et peita/näidata meediat",
@@ -412,6 +414,10 @@
   "time_remaining.minutes": "{number, plural, one {# minut} other {# minutit}} left",
   "time_remaining.moments": "Hetked jäänud",
   "time_remaining.seconds": "{number, plural, one {# sekund} other {# sekundit}} left",
+  "timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.",
+  "timeline_hint.resources.followers": "Followers",
+  "timeline_hint.resources.follows": "Follows",
+  "timeline_hint.resources.statuses": "Older toots",
   "trends.count_by_accounts": "{count} {rawCount, plural, one {inimene} other {inimesed}} talking",
   "trends.trending_now": "Praegu populaarne",
   "ui.beforeunload": "Teie mustand läheb kaotsi, kui lahkute Mastodonist.",
diff --git a/app/javascript/mastodon/locales/eu.json b/app/javascript/mastodon/locales/eu.json
index 7f777eeaf..f9bd3e090 100644
--- a/app/javascript/mastodon/locales/eu.json
+++ b/app/javascript/mastodon/locales/eu.json
@@ -5,6 +5,7 @@
   "account.block": "Blokeatu @{name}",
   "account.block_domain": "Ezkutatu {domain} domeinuko guztia",
   "account.blocked": "Blokeatuta",
+  "account.browse_more_on_origin_server": "Browse more on the original profile",
   "account.cancel_follow_request": "Ezeztatu jarraitzeko eskaria",
   "account.direct": "Mezu zuzena @{name}(r)i",
   "account.domain_blocked": "Ezkutatutako domeinua",
@@ -236,6 +237,7 @@
   "keyboard_shortcuts.reply": "erantzutea",
   "keyboard_shortcuts.requests": "jarraitzeko eskarien zerrenda irekitzeko",
   "keyboard_shortcuts.search": "bilaketan fokua jartzea",
+  "keyboard_shortcuts.spoilers": "to show/hide CW field",
   "keyboard_shortcuts.start": "\"Menua\" zutabea irekitzeko",
   "keyboard_shortcuts.toggle_hidden": "testua erakustea/ezkutatzea abisu baten atzean",
   "keyboard_shortcuts.toggle_sensitivity": "multimedia erakutsi/ezkutatzeko",
@@ -412,6 +414,10 @@
   "time_remaining.minutes": "{number, plural, one {minutu #} other {# minutu}} amaitzeko",
   "time_remaining.moments": "Amaitzekotan",
   "time_remaining.seconds": "{number, plural, one {segundo #} other {# segundo}} amaitzeko",
+  "timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.",
+  "timeline_hint.resources.followers": "Followers",
+  "timeline_hint.resources.follows": "Follows",
+  "timeline_hint.resources.statuses": "Older toots",
   "trends.count_by_accounts": "{count} {rawCount, plural, one {pertsona} other {pertsona}} hitz egiten",
   "trends.trending_now": "Joera orain",
   "ui.beforeunload": "Zure zirriborroa galduko da Mastodon uzten baduzu.",
diff --git a/app/javascript/mastodon/locales/fa.json b/app/javascript/mastodon/locales/fa.json
index 7eca45e42..714e8a30d 100644
--- a/app/javascript/mastodon/locales/fa.json
+++ b/app/javascript/mastodon/locales/fa.json
@@ -5,6 +5,7 @@
   "account.block": "مسدودسازی @{name}",
   "account.block_domain": "نهفتن همه چیز از {domain}",
   "account.blocked": "مسدود",
+  "account.browse_more_on_origin_server": "Browse more on the original profile",
   "account.cancel_follow_request": "لغو درخواست پیگیری",
   "account.direct": "پیام خصوصی به @{name}",
   "account.domain_blocked": "دامنهٔ نهفته",
@@ -236,6 +237,7 @@
   "keyboard_shortcuts.reply": "برای پاسخ",
   "keyboard_shortcuts.requests": "برای گشودن فهرست درخواست‌های پیگیری",
   "keyboard_shortcuts.search": "برای تمرکز روی جستجو",
+  "keyboard_shortcuts.spoilers": "to show/hide CW field",
   "keyboard_shortcuts.start": "برای گشودن ستون «آغاز کنید»",
   "keyboard_shortcuts.toggle_hidden": "برای نمایش/نهفتن نوشتهٔ پشت هشدار محتوا",
   "keyboard_shortcuts.toggle_sensitivity": "برای نمایش/نهفتن رسانه",
@@ -412,6 +414,10 @@
   "time_remaining.minutes": "{number, plural, one {# دقیقه} other {# دقیقه}} باقی مانده",
   "time_remaining.moments": "زمان باقی‌مانده",
   "time_remaining.seconds": "{number, plural, one {# ثانیه} other {# ثانیه}} باقی مانده",
+  "timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.",
+  "timeline_hint.resources.followers": "Followers",
+  "timeline_hint.resources.follows": "Follows",
+  "timeline_hint.resources.statuses": "Older toots",
   "trends.count_by_accounts": "{count} {rawCount, plural, one {نفر نوشته است} other {نفر نوشته‌اند}}",
   "trends.trending_now": "پرطرفدار",
   "ui.beforeunload": "اگر از ماستودون خارج شوید پیش‌نویس شما از دست خواهد رفت.",
diff --git a/app/javascript/mastodon/locales/fi.json b/app/javascript/mastodon/locales/fi.json
index 3b7b4e909..3ff7a3cb1 100644
--- a/app/javascript/mastodon/locales/fi.json
+++ b/app/javascript/mastodon/locales/fi.json
@@ -5,6 +5,7 @@
   "account.block": "Estä @{name}",
   "account.block_domain": "Piilota kaikki sisältö verkkotunnuksesta {domain}",
   "account.blocked": "Estetty",
+  "account.browse_more_on_origin_server": "Browse more on the original profile",
   "account.cancel_follow_request": "Peruuta seurauspyyntö",
   "account.direct": "Viesti käyttäjälle @{name}",
   "account.domain_blocked": "Verkko-osoite piilotettu",
@@ -236,6 +237,7 @@
   "keyboard_shortcuts.reply": "vastaa",
   "keyboard_shortcuts.requests": "avaa lista seurauspyynnöistä",
   "keyboard_shortcuts.search": "siirry hakukenttään",
+  "keyboard_shortcuts.spoilers": "to show/hide CW field",
   "keyboard_shortcuts.start": "avaa \"Aloitus\" -sarake",
   "keyboard_shortcuts.toggle_hidden": "näytä/piilota sisältövaroituksella merkitty teksti",
   "keyboard_shortcuts.toggle_sensitivity": "näytä/piilota media",
@@ -412,6 +414,10 @@
   "time_remaining.minutes": "{number, plural, one {# minuutti} other {# minuuttia}} jäljellä",
   "time_remaining.moments": "Hetki jäljellä",
   "time_remaining.seconds": "{number, plural, one {# sekunti} other {# sekuntia}} jäljellä",
+  "timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.",
+  "timeline_hint.resources.followers": "Followers",
+  "timeline_hint.resources.follows": "Follows",
+  "timeline_hint.resources.statuses": "Older toots",
   "trends.count_by_accounts": "{count} {rawCount, plural, one {henkilö} other {henkilöä}} keskustelee",
   "trends.trending_now": "Suosittua nyt",
   "ui.beforeunload": "Luonnos häviää, jos poistut Mastodonista.",
diff --git a/app/javascript/mastodon/locales/fr.json b/app/javascript/mastodon/locales/fr.json
index 5e03386f6..fe98a8e97 100644
--- a/app/javascript/mastodon/locales/fr.json
+++ b/app/javascript/mastodon/locales/fr.json
@@ -5,6 +5,7 @@
   "account.block": "Bloquer @{name}",
   "account.block_domain": "Bloquer le domaine {domain}",
   "account.blocked": "Bloqué·e",
+  "account.browse_more_on_origin_server": "Browse more on the original profile",
   "account.cancel_follow_request": "Annuler la demande de suivi",
   "account.direct": "Envoyer un message direct à @{name}",
   "account.domain_blocked": "Domaine bloqué",
@@ -236,6 +237,7 @@
   "keyboard_shortcuts.reply": "répondre",
   "keyboard_shortcuts.requests": "ouvrir la liste de demandes d’abonnement",
   "keyboard_shortcuts.search": "cibler la zone de recherche",
+  "keyboard_shortcuts.spoilers": "to show/hide CW field",
   "keyboard_shortcuts.start": "ouvrir la colonne « Pour commencer »",
   "keyboard_shortcuts.toggle_hidden": "déplier/replier le texte derrière un CW",
   "keyboard_shortcuts.toggle_sensitivity": "afficher/cacher les médias",
@@ -412,6 +414,10 @@
   "time_remaining.minutes": "{number, plural, one {# minute} other {# minutes}} restantes",
   "time_remaining.moments": "Encore quelques instants",
   "time_remaining.seconds": "{number, plural, one {# second} other {# seconds}} restantes",
+  "timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.",
+  "timeline_hint.resources.followers": "Followers",
+  "timeline_hint.resources.follows": "Follows",
+  "timeline_hint.resources.statuses": "Older toots",
   "trends.count_by_accounts": "{count} {rawCount, plural, one {personne discute} other {personnes discutent}}",
   "trends.trending_now": "Tendance en ce moment",
   "ui.beforeunload": "Votre brouillon sera perdu si vous quittez Mastodon.",
diff --git a/app/javascript/mastodon/locales/ga.json b/app/javascript/mastodon/locales/ga.json
index 81a1fbae0..19054f716 100644
--- a/app/javascript/mastodon/locales/ga.json
+++ b/app/javascript/mastodon/locales/ga.json
@@ -5,6 +5,7 @@
   "account.block": "Block @{name}",
   "account.block_domain": "Hide everything from {domain}",
   "account.blocked": "Blocked",
+  "account.browse_more_on_origin_server": "Browse more on the original profile",
   "account.cancel_follow_request": "Cancel follow request",
   "account.direct": "Direct message @{name}",
   "account.domain_blocked": "Domain hidden",
@@ -236,6 +237,7 @@
   "keyboard_shortcuts.reply": "to reply",
   "keyboard_shortcuts.requests": "to open follow requests list",
   "keyboard_shortcuts.search": "to focus search",
+  "keyboard_shortcuts.spoilers": "to show/hide CW field",
   "keyboard_shortcuts.start": "to open \"get started\" column",
   "keyboard_shortcuts.toggle_hidden": "to show/hide text behind CW",
   "keyboard_shortcuts.toggle_sensitivity": "to show/hide media",
@@ -412,6 +414,10 @@
   "time_remaining.minutes": "{number, plural, one {# minute} other {# minutes}} left",
   "time_remaining.moments": "Moments remaining",
   "time_remaining.seconds": "{number, plural, one {# second} other {# seconds}} left",
+  "timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.",
+  "timeline_hint.resources.followers": "Followers",
+  "timeline_hint.resources.follows": "Follows",
+  "timeline_hint.resources.statuses": "Older toots",
   "trends.count_by_accounts": "{count} {rawCount, plural, one {person} other {people}} talking",
   "trends.trending_now": "Trending now",
   "ui.beforeunload": "Your draft will be lost if you leave Mastodon.",
diff --git a/app/javascript/mastodon/locales/gl.json b/app/javascript/mastodon/locales/gl.json
index 73a3b744e..c3140182c 100644
--- a/app/javascript/mastodon/locales/gl.json
+++ b/app/javascript/mastodon/locales/gl.json
@@ -5,6 +5,7 @@
   "account.block": "Bloquear @{name}",
   "account.block_domain": "Agochar todo de {domain}",
   "account.blocked": "Bloqueada",
+  "account.browse_more_on_origin_server": "Browse more on the original profile",
   "account.cancel_follow_request": "Desbotar solicitude de seguimento",
   "account.direct": "Mensaxe directa @{name}",
   "account.domain_blocked": "Dominio agochado",
@@ -236,6 +237,7 @@
   "keyboard_shortcuts.reply": "para responder",
   "keyboard_shortcuts.requests": "para abrir a listaxe das peticións de seguimento",
   "keyboard_shortcuts.search": "para destacar a procura",
+  "keyboard_shortcuts.spoilers": "to show/hide CW field",
   "keyboard_shortcuts.start": "para abrir a columna dos \"primeiros pasos\"",
   "keyboard_shortcuts.toggle_hidden": "para amosar/agochar texto detrás do aviso de contido (AC)",
   "keyboard_shortcuts.toggle_sensitivity": "para amosar/agochar contido multimedia",
@@ -412,6 +414,10 @@
   "time_remaining.minutes": "{number, plural, one {# minuto} other {# minutos}} restantes",
   "time_remaining.moments": "Momentos restantes",
   "time_remaining.seconds": "{number, plural, one {# segundo} other {# segundos}} restantes",
+  "timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.",
+  "timeline_hint.resources.followers": "Followers",
+  "timeline_hint.resources.follows": "Follows",
+  "timeline_hint.resources.statuses": "Older toots",
   "trends.count_by_accounts": "{count} {rawCount, plural, one {persoa} other {persoas}} falando",
   "trends.trending_now": "Tendencias actuais",
   "ui.beforeunload": "O borrador perderase se saes de Mastodon.",
diff --git a/app/javascript/mastodon/locales/he.json b/app/javascript/mastodon/locales/he.json
index 02fddcc72..4b65cd967 100644
--- a/app/javascript/mastodon/locales/he.json
+++ b/app/javascript/mastodon/locales/he.json
@@ -5,6 +5,7 @@
   "account.block": "חסימת @{name}",
   "account.block_domain": "להסתיר הכל מהקהילה {domain}",
   "account.blocked": "חסום",
+  "account.browse_more_on_origin_server": "Browse more on the original profile",
   "account.cancel_follow_request": "בטל בקשת מעקב",
   "account.direct": "Direct Message @{name}",
   "account.domain_blocked": "הדומיין חסוי",
@@ -236,6 +237,7 @@
   "keyboard_shortcuts.reply": "לענות",
   "keyboard_shortcuts.requests": "to open follow requests list",
   "keyboard_shortcuts.search": "להתמקד בחלון החיפוש",
+  "keyboard_shortcuts.spoilers": "to show/hide CW field",
   "keyboard_shortcuts.start": "to open \"get started\" column",
   "keyboard_shortcuts.toggle_hidden": "to show/hide text behind CW",
   "keyboard_shortcuts.toggle_sensitivity": "to show/hide media",
@@ -412,6 +414,10 @@
   "time_remaining.minutes": "{number, plural, one {# minute} other {# minutes}} left",
   "time_remaining.moments": "Moments remaining",
   "time_remaining.seconds": "{number, plural, one {# second} other {# seconds}} left",
+  "timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.",
+  "timeline_hint.resources.followers": "Followers",
+  "timeline_hint.resources.follows": "Follows",
+  "timeline_hint.resources.statuses": "Older toots",
   "trends.count_by_accounts": "{count} {rawCount, plural, one {person} other {people}} talking",
   "trends.trending_now": "Trending now",
   "ui.beforeunload": "הטיוטא תאבד אם תעזבו את מסטודון.",
diff --git a/app/javascript/mastodon/locales/hi.json b/app/javascript/mastodon/locales/hi.json
index 438c84610..e26b607bb 100644
--- a/app/javascript/mastodon/locales/hi.json
+++ b/app/javascript/mastodon/locales/hi.json
@@ -5,6 +5,7 @@
   "account.block": "@{name} को ब्लॉक करें",
   "account.block_domain": "{domain} के सारी चीज़े छुपाएं",
   "account.blocked": "ब्लॉक",
+  "account.browse_more_on_origin_server": "Browse more on the original profile",
   "account.cancel_follow_request": "फ़ॉलो रिक्वेस्ट रद्द करें",
   "account.direct": "प्रत्यक्ष संदेश @{name}",
   "account.domain_blocked": "छिपा हुआ डोमेन",
@@ -236,6 +237,7 @@
   "keyboard_shortcuts.reply": "जवाब के लिए",
   "keyboard_shortcuts.requests": "फॉलो रिक्वेस्ट लिस्ट खोलने के लिए",
   "keyboard_shortcuts.search": "गहरी खोज के लिए",
+  "keyboard_shortcuts.spoilers": "to show/hide CW field",
   "keyboard_shortcuts.start": "to open \"get started\" column",
   "keyboard_shortcuts.toggle_hidden": "to show/hide text behind CW",
   "keyboard_shortcuts.toggle_sensitivity": "to show/hide media",
@@ -412,6 +414,10 @@
   "time_remaining.minutes": "{number, plural, one {# minute} other {# minutes}} left",
   "time_remaining.moments": "Moments remaining",
   "time_remaining.seconds": "{number, plural, one {# second} other {# seconds}} left",
+  "timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.",
+  "timeline_hint.resources.followers": "Followers",
+  "timeline_hint.resources.follows": "Follows",
+  "timeline_hint.resources.statuses": "Older toots",
   "trends.count_by_accounts": "{count} {rawCount, plural, one {person} other {people}} talking",
   "trends.trending_now": "Trending now",
   "ui.beforeunload": "Your draft will be lost if you leave Mastodon.",
diff --git a/app/javascript/mastodon/locales/hr.json b/app/javascript/mastodon/locales/hr.json
index 10daf96f3..f12450d7b 100644
--- a/app/javascript/mastodon/locales/hr.json
+++ b/app/javascript/mastodon/locales/hr.json
@@ -5,6 +5,7 @@
   "account.block": "Blokiraj @{name}",
   "account.block_domain": "Sakrij sve sa {domain}",
   "account.blocked": "Blocked",
+  "account.browse_more_on_origin_server": "Browse more on the original profile",
   "account.cancel_follow_request": "Cancel follow request",
   "account.direct": "Direct Message @{name}",
   "account.domain_blocked": "Domain hidden",
@@ -236,6 +237,7 @@
   "keyboard_shortcuts.reply": "to reply",
   "keyboard_shortcuts.requests": "to open follow requests list",
   "keyboard_shortcuts.search": "to focus search",
+  "keyboard_shortcuts.spoilers": "to show/hide CW field",
   "keyboard_shortcuts.start": "to open \"get started\" column",
   "keyboard_shortcuts.toggle_hidden": "to show/hide text behind CW",
   "keyboard_shortcuts.toggle_sensitivity": "to show/hide media",
@@ -412,6 +414,10 @@
   "time_remaining.minutes": "{number, plural, one {# minute} other {# minutes}} left",
   "time_remaining.moments": "Moments remaining",
   "time_remaining.seconds": "{number, plural, one {# second} other {# seconds}} left",
+  "timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.",
+  "timeline_hint.resources.followers": "Followers",
+  "timeline_hint.resources.follows": "Follows",
+  "timeline_hint.resources.statuses": "Older toots",
   "trends.count_by_accounts": "{count} {rawCount, plural, one {person} other {people}} talking",
   "trends.trending_now": "Trending now",
   "ui.beforeunload": "Your draft will be lost if you leave Mastodon.",
diff --git a/app/javascript/mastodon/locales/hu.json b/app/javascript/mastodon/locales/hu.json
index 241f074a6..eb824f4ae 100644
--- a/app/javascript/mastodon/locales/hu.json
+++ b/app/javascript/mastodon/locales/hu.json
@@ -5,6 +5,7 @@
   "account.block": "@{name} letiltása",
   "account.block_domain": "Minden elrejtése innen: {domain}",
   "account.blocked": "Letiltva",
+  "account.browse_more_on_origin_server": "Browse more on the original profile",
   "account.cancel_follow_request": "Követési kérelem törlése",
   "account.direct": "Közvetlen üzenet @{name} számára",
   "account.domain_blocked": "Rejtett domain",
@@ -236,6 +237,7 @@
   "keyboard_shortcuts.reply": "válasz",
   "keyboard_shortcuts.requests": "követési kérések listájának megnyitása",
   "keyboard_shortcuts.search": "fókuszálás a keresőre",
+  "keyboard_shortcuts.spoilers": "to show/hide CW field",
   "keyboard_shortcuts.start": "\"Első lépések\" megnyitása",
   "keyboard_shortcuts.toggle_hidden": "tartalmi figyelmeztetéssel ellátott szöveg mutatása/elrejtése",
   "keyboard_shortcuts.toggle_sensitivity": "média mutatása/elrejtése",
@@ -412,6 +414,10 @@
   "time_remaining.minutes": "{number, plural, one {# perc} other {# perc}} van hátra",
   "time_remaining.moments": "Pillanatok vannak hátra",
   "time_remaining.seconds": "{number, plural, one {# másodperc} other {# másodperc}} van hátra",
+  "timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.",
+  "timeline_hint.resources.followers": "Followers",
+  "timeline_hint.resources.follows": "Follows",
+  "timeline_hint.resources.statuses": "Older toots",
   "trends.count_by_accounts": "{count} {rawCount, plural, one {résztvevő} other {résztvevő}} beszélget",
   "trends.trending_now": "Most felkapott",
   "ui.beforeunload": "A piszkozatod el fog veszni, ha elhagyod a Mastodont.",
diff --git a/app/javascript/mastodon/locales/hy.json b/app/javascript/mastodon/locales/hy.json
index 1cdccd601..5520f7041 100644
--- a/app/javascript/mastodon/locales/hy.json
+++ b/app/javascript/mastodon/locales/hy.json
@@ -5,6 +5,7 @@
   "account.block": "Արգելափակել @{name}֊ին",
   "account.block_domain": "Թաքցնել ամէնը հետեւեալ տիրոյթից՝ {domain}",
   "account.blocked": "Արգելափակուած է",
+  "account.browse_more_on_origin_server": "Browse more on the original profile",
   "account.cancel_follow_request": "չեղարկել հետեւելու հայցը",
   "account.direct": "Նամակ գրել @{name} -ին",
   "account.domain_blocked": "Տիրոյթը արգելափակուած է",
@@ -236,6 +237,7 @@
   "keyboard_shortcuts.reply": "պատասխանելու համար",
   "keyboard_shortcuts.requests": "հետեւելու հայցերի ցանկը դիտելու համար",
   "keyboard_shortcuts.search": "որոնման դաշտին սեւեռվելու համար",
+  "keyboard_shortcuts.spoilers": "to show/hide CW field",
   "keyboard_shortcuts.start": "«սկսել» սիւնակը բացելու համար",
   "keyboard_shortcuts.toggle_hidden": "CW֊ի ետեւի տեքստը ցուցադրել֊թաքցնելու համար",
   "keyboard_shortcuts.toggle_sensitivity": "մեդիան ցուցադրել֊թաքցնելու համար",
@@ -412,6 +414,10 @@
   "time_remaining.minutes": "{number, plural, one {# րոպե} other {# րոպե}} անց",
   "time_remaining.moments": "Մնացել է մի քանի վարկյան",
   "time_remaining.seconds": "{number, plural, one {# վայրկյան} other {# վայրկյան}} անց",
+  "timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.",
+  "timeline_hint.resources.followers": "Followers",
+  "timeline_hint.resources.follows": "Follows",
+  "timeline_hint.resources.statuses": "Older toots",
   "trends.count_by_accounts": "{count} {rawCount, plural, one {հոգի} other {հոգի}} խոսում է սրա մասին",
   "trends.trending_now": "Այժմ արդիական",
   "ui.beforeunload": "Քո սեւագիրը կկորի, եթե լքես Մաստոդոնը։",
diff --git a/app/javascript/mastodon/locales/id.json b/app/javascript/mastodon/locales/id.json
index 09f5fdee7..df3775cf9 100644
--- a/app/javascript/mastodon/locales/id.json
+++ b/app/javascript/mastodon/locales/id.json
@@ -5,6 +5,7 @@
   "account.block": "Blokir @{name}",
   "account.block_domain": "Sembunyikan segalanya dari {domain}",
   "account.blocked": "Terblokir",
+  "account.browse_more_on_origin_server": "Browse more on the original profile",
   "account.cancel_follow_request": "Batalkan permintaan ikuti",
   "account.direct": "Direct Message @{name}",
   "account.domain_blocked": "Domain disembunyikan",
@@ -236,6 +237,7 @@
   "keyboard_shortcuts.reply": "balas",
   "keyboard_shortcuts.requests": "buka daftar permintaan ikuti",
   "keyboard_shortcuts.search": "untuk fokus mencari",
+  "keyboard_shortcuts.spoilers": "to show/hide CW field",
   "keyboard_shortcuts.start": "buka kolom \"memulai\"",
   "keyboard_shortcuts.toggle_hidden": "tampilkan/sembunyikan teks di belakang CW",
   "keyboard_shortcuts.toggle_sensitivity": "tampilkan/sembunyikan media",
@@ -412,6 +414,10 @@
   "time_remaining.minutes": "{number, plural, other {# menit}} tersisa",
   "time_remaining.moments": "Momen tersisa",
   "time_remaining.seconds": "{number, plural, other {# detik}} tersisa",
+  "timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.",
+  "timeline_hint.resources.followers": "Followers",
+  "timeline_hint.resources.follows": "Follows",
+  "timeline_hint.resources.statuses": "Older toots",
   "trends.count_by_accounts": "{count} {rawCount, plural, other {orang}} berbicara",
   "trends.trending_now": "Sedang tren sekarang",
   "ui.beforeunload": "Naskah anda akan hilang jika anda keluar dari Mastodon.",
diff --git a/app/javascript/mastodon/locales/io.json b/app/javascript/mastodon/locales/io.json
index 696ef694f..a7bc54e85 100644
--- a/app/javascript/mastodon/locales/io.json
+++ b/app/javascript/mastodon/locales/io.json
@@ -5,6 +5,7 @@
   "account.block": "Blokusar @{name}",
   "account.block_domain": "Hide everything from {domain}",
   "account.blocked": "Blocked",
+  "account.browse_more_on_origin_server": "Browse more on the original profile",
   "account.cancel_follow_request": "Cancel follow request",
   "account.direct": "Direct Message @{name}",
   "account.domain_blocked": "Domain hidden",
@@ -236,6 +237,7 @@
   "keyboard_shortcuts.reply": "to reply",
   "keyboard_shortcuts.requests": "to open follow requests list",
   "keyboard_shortcuts.search": "to focus search",
+  "keyboard_shortcuts.spoilers": "to show/hide CW field",
   "keyboard_shortcuts.start": "to open \"get started\" column",
   "keyboard_shortcuts.toggle_hidden": "to show/hide text behind CW",
   "keyboard_shortcuts.toggle_sensitivity": "to show/hide media",
@@ -412,6 +414,10 @@
   "time_remaining.minutes": "{number, plural, one {# minute} other {# minutes}} left",
   "time_remaining.moments": "Moments remaining",
   "time_remaining.seconds": "{number, plural, one {# second} other {# seconds}} left",
+  "timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.",
+  "timeline_hint.resources.followers": "Followers",
+  "timeline_hint.resources.follows": "Follows",
+  "timeline_hint.resources.statuses": "Older toots",
   "trends.count_by_accounts": "{count} {rawCount, plural, one {person} other {people}} talking",
   "trends.trending_now": "Trending now",
   "ui.beforeunload": "Your draft will be lost if you leave Mastodon.",
diff --git a/app/javascript/mastodon/locales/is.json b/app/javascript/mastodon/locales/is.json
index 5c5c9edad..ada0a37af 100644
--- a/app/javascript/mastodon/locales/is.json
+++ b/app/javascript/mastodon/locales/is.json
@@ -5,6 +5,7 @@
   "account.block": "Útiloka @{name}",
   "account.block_domain": "Fela allt frá {domain}",
   "account.blocked": "Útilokaður",
+  "account.browse_more_on_origin_server": "Browse more on the original profile",
   "account.cancel_follow_request": "Hætta við beiðni um að fylgjast með",
   "account.direct": "Bein skilaboð til @{name}",
   "account.domain_blocked": "Lén falið",
@@ -236,6 +237,7 @@
   "keyboard_shortcuts.reply": "að svara",
   "keyboard_shortcuts.requests": "að opna lista yfir fylgjendabeiðnir",
   "keyboard_shortcuts.search": "að setja virkni í leit",
+  "keyboard_shortcuts.spoilers": "to show/hide CW field",
   "keyboard_shortcuts.start": "að opna \"komast í gang\" dálk",
   "keyboard_shortcuts.toggle_hidden": "að birta/fela texta á bak við aðvörun vegna efnis",
   "keyboard_shortcuts.toggle_sensitivity": "að birta/fela myndir",
@@ -412,6 +414,10 @@
   "time_remaining.minutes": "{number, plural, one {# mínúta} other {# mínútur}} eftir",
   "time_remaining.moments": "Tími eftir",
   "time_remaining.seconds": "{number, plural, one {# sekúnda} other {# sekúndur}} eftir",
+  "timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.",
+  "timeline_hint.resources.followers": "Followers",
+  "timeline_hint.resources.follows": "Follows",
+  "timeline_hint.resources.statuses": "Older toots",
   "trends.count_by_accounts": "{count} {rawCount, plural, one {aðili} other {aðilar}} að tala",
   "trends.trending_now": "Í umræðunni núna",
   "ui.beforeunload": "Drögin tapast ef þú ferð út úr Mastodon.",
diff --git a/app/javascript/mastodon/locales/it.json b/app/javascript/mastodon/locales/it.json
index 5ae34551d..1fab8f17c 100644
--- a/app/javascript/mastodon/locales/it.json
+++ b/app/javascript/mastodon/locales/it.json
@@ -5,6 +5,7 @@
   "account.block": "Blocca @{name}",
   "account.block_domain": "Nascondi tutto da {domain}",
   "account.blocked": "Bloccato",
+  "account.browse_more_on_origin_server": "Browse more on the original profile",
   "account.cancel_follow_request": "Annulla richiesta di seguire",
   "account.direct": "Invia messaggio privato a @{name}",
   "account.domain_blocked": "Dominio nascosto",
@@ -236,6 +237,7 @@
   "keyboard_shortcuts.reply": "per rispondere",
   "keyboard_shortcuts.requests": "per aprire l'elenco delle richieste di seguirti",
   "keyboard_shortcuts.search": "per spostare il focus sulla ricerca",
+  "keyboard_shortcuts.spoilers": "to show/hide CW field",
   "keyboard_shortcuts.start": "per aprire la colonna \"Come iniziare\"",
   "keyboard_shortcuts.toggle_hidden": "per mostrare/nascondere il testo dei CW",
   "keyboard_shortcuts.toggle_sensitivity": "mostrare/nascondere media",
@@ -412,6 +414,10 @@
   "time_remaining.minutes": "{number, plural, one {# minuto} other {# minuti}} left",
   "time_remaining.moments": "Restano pochi istanti",
   "time_remaining.seconds": "{number, plural, one {# secondo} other {# secondi}} left",
+  "timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.",
+  "timeline_hint.resources.followers": "Followers",
+  "timeline_hint.resources.follows": "Follows",
+  "timeline_hint.resources.statuses": "Older toots",
   "trends.count_by_accounts": "{count} {rawCount, plural, one {persona ne sta} other {persone ne stanno}} parlando",
   "trends.trending_now": "Di tendenza ora",
   "ui.beforeunload": "La bozza andrà persa se esci da Mastodon.",
diff --git a/app/javascript/mastodon/locales/ja.json b/app/javascript/mastodon/locales/ja.json
index 75a215145..a519b819b 100644
--- a/app/javascript/mastodon/locales/ja.json
+++ b/app/javascript/mastodon/locales/ja.json
@@ -5,6 +5,7 @@
   "account.block": "@{name}さんをブロック",
   "account.block_domain": "{domain}全体をブロック",
   "account.blocked": "ブロック済み",
+  "account.browse_more_on_origin_server": "Browse more on the original profile",
   "account.cancel_follow_request": "フォローリクエストを取り消す",
   "account.direct": "@{name}さんにダイレクトメッセージ",
   "account.domain_blocked": "ドメインブロック中",
@@ -240,6 +241,7 @@
   "keyboard_shortcuts.reply": "返信",
   "keyboard_shortcuts.requests": "フォローリクエストのリストを開く",
   "keyboard_shortcuts.search": "検索欄に移動",
+  "keyboard_shortcuts.spoilers": "to show/hide CW field",
   "keyboard_shortcuts.start": "\"スタート\" カラムを開く",
   "keyboard_shortcuts.toggle_hidden": "CWで隠れた文を見る/隠す",
   "keyboard_shortcuts.toggle_sensitivity": "非表示のメディアを見る/隠す",
@@ -417,6 +419,10 @@
   "time_remaining.minutes": "残り{number}分",
   "time_remaining.moments": "まもなく終了",
   "time_remaining.seconds": "残り{number}秒",
+  "timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.",
+  "timeline_hint.resources.followers": "Followers",
+  "timeline_hint.resources.follows": "Follows",
+  "timeline_hint.resources.statuses": "Older toots",
   "trends.count_by_accounts": "{count}人がトゥート",
   "trends.trending_now": "トレンドタグ",
   "ui.beforeunload": "Mastodonから離れると送信前の投稿は失われます。",
diff --git a/app/javascript/mastodon/locales/ka.json b/app/javascript/mastodon/locales/ka.json
index 2c487a9b6..d7913dd2a 100644
--- a/app/javascript/mastodon/locales/ka.json
+++ b/app/javascript/mastodon/locales/ka.json
@@ -5,6 +5,7 @@
   "account.block": "დაბლოკე @{name}",
   "account.block_domain": "დაიმალოს ყველაფერი დომენიდან {domain}",
   "account.blocked": "დაიბლოკა",
+  "account.browse_more_on_origin_server": "Browse more on the original profile",
   "account.cancel_follow_request": "Cancel follow request",
   "account.direct": "პირდაპირი წერილი @{name}-ს",
   "account.domain_blocked": "დომენი დამალულია",
@@ -236,6 +237,7 @@
   "keyboard_shortcuts.reply": "პასუხისთვის",
   "keyboard_shortcuts.requests": "to open follow requests list",
   "keyboard_shortcuts.search": "ძიებაზე ფოკუსირებისთვის",
+  "keyboard_shortcuts.spoilers": "to show/hide CW field",
   "keyboard_shortcuts.start": "to open \"get started\" column",
   "keyboard_shortcuts.toggle_hidden": "გაფრთხილების უკან ტექსტის გამოსაჩენად/დასამალვად",
   "keyboard_shortcuts.toggle_sensitivity": "to show/hide media",
@@ -412,6 +414,10 @@
   "time_remaining.minutes": "{number, plural, one {# minute} other {# minutes}} left",
   "time_remaining.moments": "Moments remaining",
   "time_remaining.seconds": "{number, plural, one {# second} other {# seconds}} left",
+  "timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.",
+  "timeline_hint.resources.followers": "Followers",
+  "timeline_hint.resources.follows": "Follows",
+  "timeline_hint.resources.statuses": "Older toots",
   "trends.count_by_accounts": "{count} {rawCount, plural, one {person} other {people}} საუბრობს",
   "trends.trending_now": "Trending now",
   "ui.beforeunload": "თქვენი დრაფტი გაუქმდება თუ დატოვებთ მასტოდონს.",
diff --git a/app/javascript/mastodon/locales/kab.json b/app/javascript/mastodon/locales/kab.json
index abf6cbbca..9237f486b 100644
--- a/app/javascript/mastodon/locales/kab.json
+++ b/app/javascript/mastodon/locales/kab.json
@@ -5,6 +5,7 @@
   "account.block": "Seḥbes @{name}",
   "account.block_domain": "Ffer kra i d-yekkan seg {domain}",
   "account.blocked": "Yettuseḥbes",
+  "account.browse_more_on_origin_server": "Browse more on the original profile",
   "account.cancel_follow_request": "Sefsex asuter n uḍfaṛ",
   "account.direct": "Izen usrid i @{name}",
   "account.domain_blocked": "Taɣult yeffren",
@@ -236,6 +237,7 @@
   "keyboard_shortcuts.reply": "i tririt",
   "keyboard_shortcuts.requests": "akken ad d-teldiḍ umuγ n yisuturen n teḍfeṛt",
   "keyboard_shortcuts.search": "to focus search",
+  "keyboard_shortcuts.spoilers": "to show/hide CW field",
   "keyboard_shortcuts.start": "akken ad d-teldiḍ ajgu n \"bdu\"",
   "keyboard_shortcuts.toggle_hidden": "to show/hide text behind CW",
   "keyboard_shortcuts.toggle_sensitivity": "i teskent/tuffra n yimidyaten",
@@ -412,6 +414,10 @@
   "time_remaining.minutes": "Mazal {number, plural, one {# n tesdat} other {# n tesdatin}}",
   "time_remaining.moments": "Moments remaining",
   "time_remaining.seconds": "Mazal {number, plural, one {# n tasint} other {# n tsinin}} id yugran",
+  "timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.",
+  "timeline_hint.resources.followers": "Followers",
+  "timeline_hint.resources.follows": "Follows",
+  "timeline_hint.resources.statuses": "Older toots",
   "trends.count_by_accounts": "{count} {rawCount, plural, one {n umdan} other {n yemdanen}} i yettmeslayen",
   "trends.trending_now": "Trending now",
   "ui.beforeunload": "Arewway-ik·im ad iruḥ ma yella tefeɣ-d deg Maṣṭudun.",
diff --git a/app/javascript/mastodon/locales/kk.json b/app/javascript/mastodon/locales/kk.json
index f1782424c..48e6e414d 100644
--- a/app/javascript/mastodon/locales/kk.json
+++ b/app/javascript/mastodon/locales/kk.json
@@ -5,6 +5,7 @@
   "account.block": "Бұғаттау @{name}",
   "account.block_domain": "Домендегі барлығын бұғатта {domain}",
   "account.blocked": "Бұғатталды",
+  "account.browse_more_on_origin_server": "Browse more on the original profile",
   "account.cancel_follow_request": "Жазылуға сұранымды қайтару",
   "account.direct": "Жеке хат @{name}",
   "account.domain_blocked": "Домен жабық",
@@ -236,6 +237,7 @@
   "keyboard_shortcuts.reply": "жауап жазу",
   "keyboard_shortcuts.requests": "жазылу сұранымдарын қарау",
   "keyboard_shortcuts.search": "іздеу",
+  "keyboard_shortcuts.spoilers": "to show/hide CW field",
   "keyboard_shortcuts.start": "бастапқы бағанға бару",
   "keyboard_shortcuts.toggle_hidden": "жабық мәтінді CW ашу/жабу",
   "keyboard_shortcuts.toggle_sensitivity": "көрсет/жап",
@@ -412,6 +414,10 @@
   "time_remaining.minutes": "{number, plural, one {# минут} other {# минут}}",
   "time_remaining.moments": "Қалған уақыт",
   "time_remaining.seconds": "{number, plural, one {# секунд} other {# секунд}}",
+  "timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.",
+  "timeline_hint.resources.followers": "Followers",
+  "timeline_hint.resources.follows": "Follows",
+  "timeline_hint.resources.statuses": "Older toots",
   "trends.count_by_accounts": "{count} {rawCount, plural, one {person} other {people}} жазған екен",
   "trends.trending_now": "Тренд тақырыптар",
   "ui.beforeunload": "Mastodon желісінен шықсаңыз, нобайыңыз сақталмайды.",
diff --git a/app/javascript/mastodon/locales/kn.json b/app/javascript/mastodon/locales/kn.json
index d0757efbf..33fec4a4c 100644
--- a/app/javascript/mastodon/locales/kn.json
+++ b/app/javascript/mastodon/locales/kn.json
@@ -5,6 +5,7 @@
   "account.block": "Block @{name}",
   "account.block_domain": "Hide everything from {domain}",
   "account.blocked": "Blocked",
+  "account.browse_more_on_origin_server": "Browse more on the original profile",
   "account.cancel_follow_request": "Cancel follow request",
   "account.direct": "Direct message @{name}",
   "account.domain_blocked": "Domain hidden",
@@ -236,6 +237,7 @@
   "keyboard_shortcuts.reply": "to reply",
   "keyboard_shortcuts.requests": "to open follow requests list",
   "keyboard_shortcuts.search": "to focus search",
+  "keyboard_shortcuts.spoilers": "to show/hide CW field",
   "keyboard_shortcuts.start": "to open \"get started\" column",
   "keyboard_shortcuts.toggle_hidden": "to show/hide text behind CW",
   "keyboard_shortcuts.toggle_sensitivity": "to show/hide media",
@@ -412,6 +414,10 @@
   "time_remaining.minutes": "{number, plural, one {# minute} other {# minutes}} left",
   "time_remaining.moments": "Moments remaining",
   "time_remaining.seconds": "{number, plural, one {# second} other {# seconds}} left",
+  "timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.",
+  "timeline_hint.resources.followers": "Followers",
+  "timeline_hint.resources.follows": "Follows",
+  "timeline_hint.resources.statuses": "Older toots",
   "trends.count_by_accounts": "{count} {rawCount, plural, one {person} other {people}} talking",
   "trends.trending_now": "Trending now",
   "ui.beforeunload": "Your draft will be lost if you leave Mastodon.",
diff --git a/app/javascript/mastodon/locales/ko.json b/app/javascript/mastodon/locales/ko.json
index 54a24376c..c8e470b38 100644
--- a/app/javascript/mastodon/locales/ko.json
+++ b/app/javascript/mastodon/locales/ko.json
@@ -5,6 +5,7 @@
   "account.block": "@{name}을 차단",
   "account.block_domain": "{domain} 전체를 숨김",
   "account.blocked": "차단됨",
+  "account.browse_more_on_origin_server": "Browse more on the original profile",
   "account.cancel_follow_request": "팔로우 요청 취소",
   "account.direct": "@{name}의 다이렉트 메시지",
   "account.domain_blocked": "도메인 숨겨짐",
@@ -236,6 +237,7 @@
   "keyboard_shortcuts.reply": "답장",
   "keyboard_shortcuts.requests": "팔로우 요청 리스트 열기",
   "keyboard_shortcuts.search": "검색창에 포커스",
+  "keyboard_shortcuts.spoilers": "to show/hide CW field",
   "keyboard_shortcuts.start": "\"시작하기\" 컬럼 열기",
   "keyboard_shortcuts.toggle_hidden": "CW로 가려진 텍스트를 표시/비표시",
   "keyboard_shortcuts.toggle_sensitivity": "이미지 보이기/숨기기",
@@ -412,6 +414,10 @@
   "time_remaining.minutes": "{number} 분 남음",
   "time_remaining.moments": "남은 시간",
   "time_remaining.seconds": "{number} 초 남음",
+  "timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.",
+  "timeline_hint.resources.followers": "Followers",
+  "timeline_hint.resources.follows": "Follows",
+  "timeline_hint.resources.statuses": "Older toots",
   "trends.count_by_accounts": "{count} 명의 사람들이 말하고 있습니다",
   "trends.trending_now": "지금 유행중",
   "ui.beforeunload": "지금 나가면 저장되지 않은 항목을 잃게 됩니다.",
diff --git a/app/javascript/mastodon/locales/lt.json b/app/javascript/mastodon/locales/lt.json
index d0757efbf..33fec4a4c 100644
--- a/app/javascript/mastodon/locales/lt.json
+++ b/app/javascript/mastodon/locales/lt.json
@@ -5,6 +5,7 @@
   "account.block": "Block @{name}",
   "account.block_domain": "Hide everything from {domain}",
   "account.blocked": "Blocked",
+  "account.browse_more_on_origin_server": "Browse more on the original profile",
   "account.cancel_follow_request": "Cancel follow request",
   "account.direct": "Direct message @{name}",
   "account.domain_blocked": "Domain hidden",
@@ -236,6 +237,7 @@
   "keyboard_shortcuts.reply": "to reply",
   "keyboard_shortcuts.requests": "to open follow requests list",
   "keyboard_shortcuts.search": "to focus search",
+  "keyboard_shortcuts.spoilers": "to show/hide CW field",
   "keyboard_shortcuts.start": "to open \"get started\" column",
   "keyboard_shortcuts.toggle_hidden": "to show/hide text behind CW",
   "keyboard_shortcuts.toggle_sensitivity": "to show/hide media",
@@ -412,6 +414,10 @@
   "time_remaining.minutes": "{number, plural, one {# minute} other {# minutes}} left",
   "time_remaining.moments": "Moments remaining",
   "time_remaining.seconds": "{number, plural, one {# second} other {# seconds}} left",
+  "timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.",
+  "timeline_hint.resources.followers": "Followers",
+  "timeline_hint.resources.follows": "Follows",
+  "timeline_hint.resources.statuses": "Older toots",
   "trends.count_by_accounts": "{count} {rawCount, plural, one {person} other {people}} talking",
   "trends.trending_now": "Trending now",
   "ui.beforeunload": "Your draft will be lost if you leave Mastodon.",
diff --git a/app/javascript/mastodon/locales/lv.json b/app/javascript/mastodon/locales/lv.json
index 241d7c080..d4288f96b 100644
--- a/app/javascript/mastodon/locales/lv.json
+++ b/app/javascript/mastodon/locales/lv.json
@@ -5,6 +5,7 @@
   "account.block": "Bloķēt @{name}",
   "account.block_domain": "Slēpt visu no {domain}",
   "account.blocked": "Bloķēts",
+  "account.browse_more_on_origin_server": "Browse more on the original profile",
   "account.cancel_follow_request": "Cancel follow request",
   "account.direct": "Privātā ziņa @{name}",
   "account.domain_blocked": "Domēns ir paslēpts",
@@ -236,6 +237,7 @@
   "keyboard_shortcuts.reply": "to reply",
   "keyboard_shortcuts.requests": "to open follow requests list",
   "keyboard_shortcuts.search": "to focus search",
+  "keyboard_shortcuts.spoilers": "to show/hide CW field",
   "keyboard_shortcuts.start": "to open \"get started\" column",
   "keyboard_shortcuts.toggle_hidden": "to show/hide text behind CW",
   "keyboard_shortcuts.toggle_sensitivity": "to show/hide media",
@@ -412,6 +414,10 @@
   "time_remaining.minutes": "{number, plural, one {# minute} other {# minutes}} left",
   "time_remaining.moments": "Moments remaining",
   "time_remaining.seconds": "{number, plural, one {# second} other {# seconds}} left",
+  "timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.",
+  "timeline_hint.resources.followers": "Followers",
+  "timeline_hint.resources.follows": "Follows",
+  "timeline_hint.resources.statuses": "Older toots",
   "trends.count_by_accounts": "{count} {rawCount, plural, one {person} other {people}} talking",
   "trends.trending_now": "Trending now",
   "ui.beforeunload": "Your draft will be lost if you leave Mastodon.",
diff --git a/app/javascript/mastodon/locales/mk.json b/app/javascript/mastodon/locales/mk.json
index bc5f91264..61202ec19 100644
--- a/app/javascript/mastodon/locales/mk.json
+++ b/app/javascript/mastodon/locales/mk.json
@@ -5,6 +5,7 @@
   "account.block": "Блокирај @{name}",
   "account.block_domain": "Сокријај се од {domain}",
   "account.blocked": "Блокиран",
+  "account.browse_more_on_origin_server": "Browse more on the original profile",
   "account.cancel_follow_request": "Одкажи барање за следење",
   "account.direct": "Директна порана @{name}",
   "account.domain_blocked": "Скриен домен",
@@ -236,6 +237,7 @@
   "keyboard_shortcuts.reply": "одговори",
   "keyboard_shortcuts.requests": "to open follow requests list",
   "keyboard_shortcuts.search": "to focus search",
+  "keyboard_shortcuts.spoilers": "to show/hide CW field",
   "keyboard_shortcuts.start": "to open \"get started\" column",
   "keyboard_shortcuts.toggle_hidden": "to show/hide text behind CW",
   "keyboard_shortcuts.toggle_sensitivity": "to show/hide media",
@@ -412,6 +414,10 @@
   "time_remaining.minutes": "{number, plural, one {# минута} other {# минути}} {number, plural, one {остана} other {останаа}}",
   "time_remaining.moments": "Уште некои мига",
   "time_remaining.seconds": "{number, plural, one {# секунда} other {# секунди}} {number, plural, one {остана} other {останаа}}",
+  "timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.",
+  "timeline_hint.resources.followers": "Followers",
+  "timeline_hint.resources.follows": "Follows",
+  "timeline_hint.resources.statuses": "Older toots",
   "trends.count_by_accounts": "{count} {rawCount, plural, one {person} other {people}} talking",
   "trends.trending_now": "Trending now",
   "ui.beforeunload": "Your draft will be lost if you leave Mastodon.",
diff --git a/app/javascript/mastodon/locales/ml.json b/app/javascript/mastodon/locales/ml.json
index 788200c87..7b74c10ee 100644
--- a/app/javascript/mastodon/locales/ml.json
+++ b/app/javascript/mastodon/locales/ml.json
@@ -5,6 +5,7 @@
   "account.block": "@{name} നെ ബ്ലോക്ക് ചെയ്യുക",
   "account.block_domain": "{domain} ൽ നിന്നുള്ള എല്ലാം മറയ്കുക",
   "account.blocked": "തടഞ്ഞു",
+  "account.browse_more_on_origin_server": "Browse more on the original profile",
   "account.cancel_follow_request": "പിന്തുടരാനുള്ള അപേക്ഷ നിരസിക്കുക",
   "account.direct": "@{name} ന് നേരിട്ട് മെസേജ് അയക്കുക",
   "account.domain_blocked": "മേഖല മറയ്ക്കപ്പെട്ടിരിക്കുന്നു",
@@ -236,6 +237,7 @@
   "keyboard_shortcuts.reply": "to reply",
   "keyboard_shortcuts.requests": "to open follow requests list",
   "keyboard_shortcuts.search": "to focus search",
+  "keyboard_shortcuts.spoilers": "to show/hide CW field",
   "keyboard_shortcuts.start": "to open \"get started\" column",
   "keyboard_shortcuts.toggle_hidden": "to show/hide text behind CW",
   "keyboard_shortcuts.toggle_sensitivity": "to show/hide media",
@@ -412,6 +414,10 @@
   "time_remaining.minutes": "{number, plural, one {# minute} other {# minutes}} left",
   "time_remaining.moments": "Moments remaining",
   "time_remaining.seconds": "{number, plural, one {# second} other {# seconds}} left",
+  "timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.",
+  "timeline_hint.resources.followers": "Followers",
+  "timeline_hint.resources.follows": "Follows",
+  "timeline_hint.resources.statuses": "Older toots",
   "trends.count_by_accounts": "{count} {rawCount, plural, one {person} other {people}} talking",
   "trends.trending_now": "Trending now",
   "ui.beforeunload": "Your draft will be lost if you leave Mastodon.",
diff --git a/app/javascript/mastodon/locales/mr.json b/app/javascript/mastodon/locales/mr.json
index f213c2942..46fd5acc5 100644
--- a/app/javascript/mastodon/locales/mr.json
+++ b/app/javascript/mastodon/locales/mr.json
@@ -5,6 +5,7 @@
   "account.block": "@{name} यांना ब्लॉक करा",
   "account.block_domain": "{domain} पासून सर्व लपवा",
   "account.blocked": "ब्लॉक केले आहे",
+  "account.browse_more_on_origin_server": "Browse more on the original profile",
   "account.cancel_follow_request": "अनुयायी होण्याची विनंती रद्द करा",
   "account.direct": "थेट संदेश @{name}",
   "account.domain_blocked": "Domain hidden",
@@ -236,6 +237,7 @@
   "keyboard_shortcuts.reply": "to reply",
   "keyboard_shortcuts.requests": "to open follow requests list",
   "keyboard_shortcuts.search": "to focus search",
+  "keyboard_shortcuts.spoilers": "to show/hide CW field",
   "keyboard_shortcuts.start": "to open \"get started\" column",
   "keyboard_shortcuts.toggle_hidden": "to show/hide text behind CW",
   "keyboard_shortcuts.toggle_sensitivity": "to show/hide media",
@@ -412,6 +414,10 @@
   "time_remaining.minutes": "{number, plural, one {# minute} other {# minutes}} left",
   "time_remaining.moments": "Moments remaining",
   "time_remaining.seconds": "{number, plural, one {# second} other {# seconds}} left",
+  "timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.",
+  "timeline_hint.resources.followers": "Followers",
+  "timeline_hint.resources.follows": "Follows",
+  "timeline_hint.resources.statuses": "Older toots",
   "trends.count_by_accounts": "{count} {rawCount, plural, one {person} other {people}} talking",
   "trends.trending_now": "Trending now",
   "ui.beforeunload": "Your draft will be lost if you leave Mastodon.",
diff --git a/app/javascript/mastodon/locales/ms.json b/app/javascript/mastodon/locales/ms.json
index a5b4d199a..9a9fc975a 100644
--- a/app/javascript/mastodon/locales/ms.json
+++ b/app/javascript/mastodon/locales/ms.json
@@ -5,6 +5,7 @@
   "account.block": "Block @{name}",
   "account.block_domain": "Hide everything from {domain}",
   "account.blocked": "Blocked",
+  "account.browse_more_on_origin_server": "Browse more on the original profile",
   "account.cancel_follow_request": "Cancel follow request",
   "account.direct": "Direct message @{name}",
   "account.domain_blocked": "Domain hidden",
@@ -236,6 +237,7 @@
   "keyboard_shortcuts.reply": "to reply",
   "keyboard_shortcuts.requests": "to open follow requests list",
   "keyboard_shortcuts.search": "to focus search",
+  "keyboard_shortcuts.spoilers": "to show/hide CW field",
   "keyboard_shortcuts.start": "to open \"get started\" column",
   "keyboard_shortcuts.toggle_hidden": "to show/hide text behind CW",
   "keyboard_shortcuts.toggle_sensitivity": "to show/hide media",
@@ -412,6 +414,10 @@
   "time_remaining.minutes": "{number, plural, one {# minute} other {# minutes}} left",
   "time_remaining.moments": "Moments remaining",
   "time_remaining.seconds": "{number, plural, one {# second} other {# seconds}} left",
+  "timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.",
+  "timeline_hint.resources.followers": "Followers",
+  "timeline_hint.resources.follows": "Follows",
+  "timeline_hint.resources.statuses": "Older toots",
   "trends.count_by_accounts": "{count} {rawCount, plural, one {person} other {people}} talking",
   "trends.trending_now": "Trending now",
   "ui.beforeunload": "Your draft will be lost if you leave Mastodon.",
diff --git a/app/javascript/mastodon/locales/nl.json b/app/javascript/mastodon/locales/nl.json
index 6cdd02c02..ca5be5164 100644
--- a/app/javascript/mastodon/locales/nl.json
+++ b/app/javascript/mastodon/locales/nl.json
@@ -5,6 +5,7 @@
   "account.block": "@{name} blokkeren",
   "account.block_domain": "Alles van {domain} verbergen",
   "account.blocked": "Geblokkeerd",
+  "account.browse_more_on_origin_server": "Browse more on the original profile",
   "account.cancel_follow_request": "Volgverzoek annuleren",
   "account.direct": "@{name} een direct bericht sturen",
   "account.domain_blocked": "Domein verborgen",
@@ -236,6 +237,7 @@
   "keyboard_shortcuts.reply": "om te reageren",
   "keyboard_shortcuts.requests": "om jouw volgverzoeken te tonen",
   "keyboard_shortcuts.search": "om het zoekvak te focussen",
+  "keyboard_shortcuts.spoilers": "to show/hide CW field",
   "keyboard_shortcuts.start": "om de \"Aan de slag\"-kolom te tonen",
   "keyboard_shortcuts.toggle_hidden": "om tekst achter een waarschuwing (CW) te tonen/verbergen",
   "keyboard_shortcuts.toggle_sensitivity": "om media te tonen/verbergen",
@@ -412,6 +414,10 @@
   "time_remaining.minutes": "{number, plural, one {# minuut} other {# minuten}} te gaan",
   "time_remaining.moments": "Nog enkele ogenblikken resterend",
   "time_remaining.seconds": "{number, plural, one {# seconde} other {# seconden}} te gaan",
+  "timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.",
+  "timeline_hint.resources.followers": "Followers",
+  "timeline_hint.resources.follows": "Follows",
+  "timeline_hint.resources.statuses": "Older toots",
   "trends.count_by_accounts": "{count} {rawCount, plural, one {persoon praat} other {mensen praten}} hierover",
   "trends.trending_now": "Trends",
   "ui.beforeunload": "Je concept zal verloren gaan als je Mastodon verlaat.",
diff --git a/app/javascript/mastodon/locales/nn.json b/app/javascript/mastodon/locales/nn.json
index 0c1fd8bbf..dee0cd836 100644
--- a/app/javascript/mastodon/locales/nn.json
+++ b/app/javascript/mastodon/locales/nn.json
@@ -5,6 +5,7 @@
   "account.block": "Blokker @{name}",
   "account.block_domain": "Skjul alt frå {domain}",
   "account.blocked": "Blokkert",
+  "account.browse_more_on_origin_server": "Browse more on the original profile",
   "account.cancel_follow_request": "Fjern fylgjeførespurnad",
   "account.direct": "Send melding til @{name}",
   "account.domain_blocked": "Domenet er gøymt",
@@ -236,6 +237,7 @@
   "keyboard_shortcuts.reply": "for å svara",
   "keyboard_shortcuts.requests": "for å opna lista med fylgjeførespurnader",
   "keyboard_shortcuts.search": "for å fokusera søket",
+  "keyboard_shortcuts.spoilers": "to show/hide CW field",
   "keyboard_shortcuts.start": "for å opna \"kom i gang\"-feltet",
   "keyboard_shortcuts.toggle_hidden": "for å visa/gøyma tekst bak innhaldsvarsel",
   "keyboard_shortcuts.toggle_sensitivity": "for å visa/gøyma media",
@@ -412,6 +414,10 @@
   "time_remaining.minutes": "{number, plural, one {# minutt} other {# minutt}} igjen",
   "time_remaining.moments": "Kort tid igjen",
   "time_remaining.seconds": "{number, plural, one {# sekund} other {# sekund}} igjen",
+  "timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.",
+  "timeline_hint.resources.followers": "Followers",
+  "timeline_hint.resources.follows": "Follows",
+  "timeline_hint.resources.statuses": "Older toots",
   "trends.count_by_accounts": "{count} {rawCount, plural, one {person} other {folk}} snakkar",
   "trends.trending_now": "Populært no",
   "ui.beforeunload": "Kladden din forsvinn om du forlèt Mastodon no.",
diff --git a/app/javascript/mastodon/locales/no.json b/app/javascript/mastodon/locales/no.json
index f7d47f7f7..71cfdfe14 100644
--- a/app/javascript/mastodon/locales/no.json
+++ b/app/javascript/mastodon/locales/no.json
@@ -5,6 +5,7 @@
   "account.block": "Blokkér @{name}",
   "account.block_domain": "Skjul alt fra {domain}",
   "account.blocked": "Blokkert",
+  "account.browse_more_on_origin_server": "Browse more on the original profile",
   "account.cancel_follow_request": "Avbryt følge forespørsel",
   "account.direct": "Direct Message @{name}",
   "account.domain_blocked": "Domenet skjult",
@@ -236,6 +237,7 @@
   "keyboard_shortcuts.reply": "for å svare",
   "keyboard_shortcuts.requests": "åpne følgingsforespørselslisten",
   "keyboard_shortcuts.search": "å fokusere søk",
+  "keyboard_shortcuts.spoilers": "to show/hide CW field",
   "keyboard_shortcuts.start": "åpne «Sett i gang»-kolonnen",
   "keyboard_shortcuts.toggle_hidden": "å vise/skjule tekst bak en innholdsadvarsel",
   "keyboard_shortcuts.toggle_sensitivity": "å vise/skjule media",
@@ -412,6 +414,10 @@
   "time_remaining.minutes": "{number, plural, one {# minutt} other {# minutter}} igjen",
   "time_remaining.moments": "Gjenværende øyeblikk",
   "time_remaining.seconds": "{number, plural, one {# sekund} other {# sekunder}} igjen",
+  "timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.",
+  "timeline_hint.resources.followers": "Followers",
+  "timeline_hint.resources.follows": "Follows",
+  "timeline_hint.resources.statuses": "Older toots",
   "trends.count_by_accounts": "{count} {rawCount, plural, one {person} other {personer}} snakker om det",
   "trends.trending_now": "Trender nå",
   "ui.beforeunload": "Din kladd vil bli forkastet om du forlater Mastodon.",
diff --git a/app/javascript/mastodon/locales/oc.json b/app/javascript/mastodon/locales/oc.json
index 5dc7bf61c..370a73475 100644
--- a/app/javascript/mastodon/locales/oc.json
+++ b/app/javascript/mastodon/locales/oc.json
@@ -5,6 +5,7 @@
   "account.block": "Blocar @{name}",
   "account.block_domain": "Tot amagar del domeni {domain}",
   "account.blocked": "Blocat",
+  "account.browse_more_on_origin_server": "Browse more on the original profile",
   "account.cancel_follow_request": "Anullar la demanda de seguiment",
   "account.direct": "Escriure un MP a @{name}",
   "account.domain_blocked": "Domeni amagat",
@@ -236,6 +237,7 @@
   "keyboard_shortcuts.reply": "respondre",
   "keyboard_shortcuts.requests": "dorbir la lista de demanda d’abonament",
   "keyboard_shortcuts.search": "anar a la recèrca",
+  "keyboard_shortcuts.spoilers": "to show/hide CW field",
   "keyboard_shortcuts.start": "dobrir la colomna « Per començar »",
   "keyboard_shortcuts.toggle_hidden": "mostrar/amagar lo tèxte dels avertiments",
   "keyboard_shortcuts.toggle_sensitivity": "mostrar/rescondre los mèdias",
@@ -412,6 +414,10 @@
   "time_remaining.minutes": "demòra{number, plural, one { # minuta} other {n # minutas}}",
   "time_remaining.moments": "Moments restants",
   "time_remaining.seconds": "demòra{number, plural, one { # segonda} other {n # segondas}}",
+  "timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.",
+  "timeline_hint.resources.followers": "Followers",
+  "timeline_hint.resources.follows": "Follows",
+  "timeline_hint.resources.statuses": "Older toots",
   "trends.count_by_accounts": "{count} {rawCount, plural, one {person} ne charra other {people}} ne charran",
   "trends.trending_now": "Tendéncia del moment",
   "ui.beforeunload": "Vòstre brolhon serà perdut se quitatz Mastodon.",
diff --git a/app/javascript/mastodon/locales/pl.json b/app/javascript/mastodon/locales/pl.json
index afb4e29e6..bbfe4cce3 100644
--- a/app/javascript/mastodon/locales/pl.json
+++ b/app/javascript/mastodon/locales/pl.json
@@ -5,6 +5,7 @@
   "account.block": "Blokuj @{name}",
   "account.block_domain": "Blokuj wszystko z {domain}",
   "account.blocked": "Zablokowany(-a)",
+  "account.browse_more_on_origin_server": "Browse more on the original profile",
   "account.cancel_follow_request": "Zrezygnuj z prośby o możliwość śledzenia",
   "account.direct": "Wyślij wiadomość bezpośrednią do @{name}",
   "account.domain_blocked": "Ukryto domenę",
@@ -240,6 +241,7 @@
   "keyboard_shortcuts.reply": "aby odpowiedzieć",
   "keyboard_shortcuts.requests": "aby przejść do listy próśb o możliwość śledzenia",
   "keyboard_shortcuts.search": "aby przejść do pola wyszukiwania",
+  "keyboard_shortcuts.spoilers": "to show/hide CW field",
   "keyboard_shortcuts.start": "aby otworzyć kolumnę „Rozpocznij”",
   "keyboard_shortcuts.toggle_hidden": "aby wyświetlić lub ukryć wpis spod CW",
   "keyboard_shortcuts.toggle_sensitivity": "by pokazać/ukryć multimedia",
@@ -417,6 +419,10 @@
   "time_remaining.minutes": "{number, plural, one {Pozostała # minuta} few {Pozostały # minuty} many {Pozostało # minut} other {Pozostało # minut}}",
   "time_remaining.moments": "Pozostała chwila",
   "time_remaining.seconds": "{number, plural, one {Pozostała # sekunda} few {Pozostały # sekundy} many {Pozostało # sekund} other {Pozostało # sekund}}",
+  "timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.",
+  "timeline_hint.resources.followers": "Followers",
+  "timeline_hint.resources.follows": "Follows",
+  "timeline_hint.resources.statuses": "Older toots",
   "trends.count_by_accounts": "{count} {rawCount, plural, one {osoba rozmawia} few {osoby rozmawiają} other {osób rozmawia}} o tym",
   "trends.trending_now": "Popularne teraz",
   "ui.beforeunload": "Utracisz tworzony wpis, jeżeli opuścisz Mastodona.",
diff --git a/app/javascript/mastodon/locales/pt-BR.json b/app/javascript/mastodon/locales/pt-BR.json
index 4eb61279a..334e0e8c6 100644
--- a/app/javascript/mastodon/locales/pt-BR.json
+++ b/app/javascript/mastodon/locales/pt-BR.json
@@ -5,6 +5,7 @@
   "account.block": "Bloquear @{name}",
   "account.block_domain": "Bloquear domínio {domain}",
   "account.blocked": "Bloqueado",
+  "account.browse_more_on_origin_server": "Browse more on the original profile",
   "account.cancel_follow_request": "Cancelar solicitação para seguir",
   "account.direct": "Enviar toot direto para @{name}",
   "account.domain_blocked": "Domínio bloqueado",
@@ -236,6 +237,7 @@
   "keyboard_shortcuts.reply": "para responder",
   "keyboard_shortcuts.requests": "para abrir lista de pedidos para seguir",
   "keyboard_shortcuts.search": "para focar pesquisa",
+  "keyboard_shortcuts.spoilers": "to show/hide CW field",
   "keyboard_shortcuts.start": "para abrir coluna \"primeiros passos\"",
   "keyboard_shortcuts.toggle_hidden": "mostrar/ocultar o toot com Aviso de Conteúdo",
   "keyboard_shortcuts.toggle_sensitivity": "mostrar/ocultar mídia",
@@ -412,6 +414,10 @@
   "time_remaining.minutes": "{number, plural, one {# minuto restante} other {# minutos restantes}}",
   "time_remaining.moments": "Momentos faltantes",
   "time_remaining.seconds": "{number, plural, one {# segundo restante} other {# segundos restantes}}",
+  "timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.",
+  "timeline_hint.resources.followers": "Followers",
+  "timeline_hint.resources.follows": "Follows",
+  "timeline_hint.resources.statuses": "Older toots",
   "trends.count_by_accounts": "{count} {rawCount, plural, one {pessoa} other {pessoas}} falando",
   "trends.trending_now": "Em alta no momento",
   "ui.beforeunload": "Seu rascunho vai ser perdido se você sair do Mastodon.",
diff --git a/app/javascript/mastodon/locales/pt-PT.json b/app/javascript/mastodon/locales/pt-PT.json
index 41433a43f..a888f7a58 100644
--- a/app/javascript/mastodon/locales/pt-PT.json
+++ b/app/javascript/mastodon/locales/pt-PT.json
@@ -5,6 +5,7 @@
   "account.block": "Bloquear @{name}",
   "account.block_domain": "Esconder tudo do domínio {domain}",
   "account.blocked": "Bloqueado",
+  "account.browse_more_on_origin_server": "Browse more on the original profile",
   "account.cancel_follow_request": "Cancelar pedido de seguidor",
   "account.direct": "Mensagem directa @{name}",
   "account.domain_blocked": "Domínio escondido",
@@ -236,6 +237,7 @@
   "keyboard_shortcuts.reply": "para responder",
   "keyboard_shortcuts.requests": "para abrir a lista dos pedidos de seguidor",
   "keyboard_shortcuts.search": "para focar na pesquisa",
+  "keyboard_shortcuts.spoilers": "to show/hide CW field",
   "keyboard_shortcuts.start": "para abrir a coluna dos \"primeiros passos\"",
   "keyboard_shortcuts.toggle_hidden": "para mostrar/esconder texto atrás de CW",
   "keyboard_shortcuts.toggle_sensitivity": "mostrar/ocultar media",
@@ -412,6 +414,10 @@
   "time_remaining.minutes": "{número, plural, um {# minute} outro {# minutes}} faltam",
   "time_remaining.moments": "Momentos restantes",
   "time_remaining.seconds": "{número, plural, um {# second} outro {# seconds}} faltam",
+  "timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.",
+  "timeline_hint.resources.followers": "Followers",
+  "timeline_hint.resources.follows": "Follows",
+  "timeline_hint.resources.statuses": "Older toots",
   "trends.count_by_accounts": "{count} {rawCount, plural, uma {person} outra {people}} a falar",
   "trends.trending_now": "Tendências atuais",
   "ui.beforeunload": "O teu rascunho será perdido se abandonares o Mastodon.",
diff --git a/app/javascript/mastodon/locales/ro.json b/app/javascript/mastodon/locales/ro.json
index f7143a2e2..1a998e8fb 100644
--- a/app/javascript/mastodon/locales/ro.json
+++ b/app/javascript/mastodon/locales/ro.json
@@ -5,6 +5,7 @@
   "account.block": "Blocați @{name}",
   "account.block_domain": "Blocați domeniul {domain}",
   "account.blocked": "Blocat",
+  "account.browse_more_on_origin_server": "Browse more on the original profile",
   "account.cancel_follow_request": "Anulați cererea de urmărire",
   "account.direct": "Mesaj direct @{name}",
   "account.domain_blocked": "Domeniu blocat",
@@ -236,6 +237,7 @@
   "keyboard_shortcuts.reply": "să răspundă",
   "keyboard_shortcuts.requests": "să deschidă lista cu cereri de urmărire",
   "keyboard_shortcuts.search": "să focalizeze căutarea",
+  "keyboard_shortcuts.spoilers": "to show/hide CW field",
   "keyboard_shortcuts.start": "să deschidă coloana \"Începere\"",
   "keyboard_shortcuts.toggle_hidden": "să arate/ascundă textul în spatele CW",
   "keyboard_shortcuts.toggle_sensitivity": "pentru a afișa/ascunde media",
@@ -412,6 +414,10 @@
   "time_remaining.minutes": "{number, plural, one {# minut} other {# minute}} rămase",
   "time_remaining.moments": "Momente rămase",
   "time_remaining.seconds": "{number, plural, one {# secundă} other {# secunde}} rămase",
+  "timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.",
+  "timeline_hint.resources.followers": "Followers",
+  "timeline_hint.resources.follows": "Follows",
+  "timeline_hint.resources.statuses": "Older toots",
   "trends.count_by_accounts": "{count} {rawCount, plural, one {persoană} other {persoane}} vorbește/ecs",
   "trends.trending_now": "În tendință acum",
   "ui.beforeunload": "Postarea se va pierde dacă părăsești pagina.",
diff --git a/app/javascript/mastodon/locales/ru.json b/app/javascript/mastodon/locales/ru.json
index fa81bdc36..ed1518d02 100644
--- a/app/javascript/mastodon/locales/ru.json
+++ b/app/javascript/mastodon/locales/ru.json
@@ -5,6 +5,7 @@
   "account.block": "Заблокировать @{name}",
   "account.block_domain": "Заблокировать {domain}",
   "account.blocked": "Заблокирован(а)",
+  "account.browse_more_on_origin_server": "Browse more on the original profile",
   "account.cancel_follow_request": "Отменить запрос",
   "account.direct": "Написать @{name}",
   "account.domain_blocked": "Домен скрыт",
@@ -236,6 +237,7 @@
   "keyboard_shortcuts.reply": "ответить",
   "keyboard_shortcuts.requests": "перейти к запросам на подписку",
   "keyboard_shortcuts.search": "перейти к поиску",
+  "keyboard_shortcuts.spoilers": "to show/hide CW field",
   "keyboard_shortcuts.start": "перейти к разделу \"добро пожаловать\"",
   "keyboard_shortcuts.toggle_hidden": "показать/скрыть текст за предупреждением",
   "keyboard_shortcuts.toggle_sensitivity": "показать/скрыть медиафайлы",
@@ -412,6 +414,10 @@
   "time_remaining.minutes": "{number, plural, one {осталась # минута} few {осталось # минуты} many {осталось # минут} other {осталось # минут}}",
   "time_remaining.moments": "остались считанные мгновения",
   "time_remaining.seconds": "{number, plural, one {# секунда} many {# секунд} other {# секунды}}",
+  "timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.",
+  "timeline_hint.resources.followers": "Followers",
+  "timeline_hint.resources.follows": "Follows",
+  "timeline_hint.resources.statuses": "Older toots",
   "trends.count_by_accounts": "{count} {rawCount, plural, one {человек говорит} few {человека говорят} other {человек говорят}} про это",
   "trends.trending_now": "Самое актуальное",
   "ui.beforeunload": "Ваш черновик будет утерян, если вы покинете Mastodon.",
diff --git a/app/javascript/mastodon/locales/sc.json b/app/javascript/mastodon/locales/sc.json
index db2bb0dac..dadc34cde 100644
--- a/app/javascript/mastodon/locales/sc.json
+++ b/app/javascript/mastodon/locales/sc.json
@@ -5,6 +5,7 @@
   "account.block": "Bloca @{name}",
   "account.block_domain": "Bloca domìniu{domain}",
   "account.blocked": "Blocadu",
+  "account.browse_more_on_origin_server": "Browse more on the original profile",
   "account.cancel_follow_request": "Annulla rechesta de sighidura",
   "account.direct": "Messàgiu deretu a @{name}",
   "account.domain_blocked": "Domìniu blocadu",
@@ -236,6 +237,7 @@
   "keyboard_shortcuts.reply": "pro rispòndere",
   "keyboard_shortcuts.requests": "pro abèrrere sa lista de rechestas de sighidura",
   "keyboard_shortcuts.search": "pro atzentrare sa chirca",
+  "keyboard_shortcuts.spoilers": "to show/hide CW field",
   "keyboard_shortcuts.start": "pro abèrrere sa colunna \"Cumintza\"",
   "keyboard_shortcuts.toggle_hidden": "pro ammustrare o cuare testu de is CW",
   "keyboard_shortcuts.toggle_sensitivity": "pro ammustrare o cuare mèdias",
@@ -412,6 +414,10 @@
   "time_remaining.minutes": "{number, plural, one {abarrat # minutu} other {abarrant # minutos}}",
   "time_remaining.moments": "Abarrant pagu momentos",
   "time_remaining.seconds": "{number, plural, one {abarrat # segundu} other {abarrant # segundos}}",
+  "timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.",
+  "timeline_hint.resources.followers": "Followers",
+  "timeline_hint.resources.follows": "Follows",
+  "timeline_hint.resources.statuses": "Older toots",
   "trends.count_by_accounts": "{count} {rawCount, plural, one {persone} other {persones}} nde sunt chistionende",
   "trends.trending_now": "Est tendèntzia immoe",
   "ui.beforeunload": "S'abbotzu tuo at a èssere pèrdidu si essis dae Mastodon.",
diff --git a/app/javascript/mastodon/locales/sk.json b/app/javascript/mastodon/locales/sk.json
index ec34bb29e..d37a7db6f 100644
--- a/app/javascript/mastodon/locales/sk.json
+++ b/app/javascript/mastodon/locales/sk.json
@@ -5,6 +5,7 @@
   "account.block": "Blokuj @{name}",
   "account.block_domain": "Ukry všetko z {domain}",
   "account.blocked": "Blokovaný/á",
+  "account.browse_more_on_origin_server": "Browse more on the original profile",
   "account.cancel_follow_request": "Zruš žiadosť o sledovanie",
   "account.direct": "Priama správa pre @{name}",
   "account.domain_blocked": "Doména ukrytá",
@@ -236,6 +237,7 @@
   "keyboard_shortcuts.reply": "odpovedať",
   "keyboard_shortcuts.requests": "otvor zoznam žiadostí o sledovanie",
   "keyboard_shortcuts.search": "zameraj sa na vyhľadávanie",
+  "keyboard_shortcuts.spoilers": "to show/hide CW field",
   "keyboard_shortcuts.start": "otvor panel ''začíname''",
   "keyboard_shortcuts.toggle_hidden": "ukáž/skry text za CW",
   "keyboard_shortcuts.toggle_sensitivity": "pre zobrazenie/skrytie médií",
@@ -412,6 +414,10 @@
   "time_remaining.minutes": "Ostáva {number, plural, one {# minúta} few {# minút} many {# minút} other {# minúty}}",
   "time_remaining.moments": "Ostáva už iba chviľka",
   "time_remaining.seconds": "Ostáva {number, plural, one {# sekunda} few {# sekúnd} many {# sekúnd} other {# sekúnd}}",
+  "timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.",
+  "timeline_hint.resources.followers": "Followers",
+  "timeline_hint.resources.follows": "Follows",
+  "timeline_hint.resources.statuses": "Older toots",
   "trends.count_by_accounts": "{count} {rawCount, plural, one {človek spomína} other {ľudí spomína}}",
   "trends.trending_now": "Teraz populárne",
   "ui.beforeunload": "Čo máš rozpísané sa stratí, ak opustíš Mastodon.",
diff --git a/app/javascript/mastodon/locales/sl.json b/app/javascript/mastodon/locales/sl.json
index 3d8bcc571..e969b9ad2 100644
--- a/app/javascript/mastodon/locales/sl.json
+++ b/app/javascript/mastodon/locales/sl.json
@@ -5,6 +5,7 @@
   "account.block": "Blokiraj @{name}",
   "account.block_domain": "Skrij vse iz {domain}",
   "account.blocked": "Blokirano",
+  "account.browse_more_on_origin_server": "Browse more on the original profile",
   "account.cancel_follow_request": "Cancel follow request",
   "account.direct": "Neposredno sporočilo @{name}",
   "account.domain_blocked": "Skrita domena",
@@ -236,6 +237,7 @@
   "keyboard_shortcuts.reply": "odgovori",
   "keyboard_shortcuts.requests": "odpri seznam s prošnjami za sledenje",
   "keyboard_shortcuts.search": "fokusiraj na iskanje",
+  "keyboard_shortcuts.spoilers": "to show/hide CW field",
   "keyboard_shortcuts.start": "odpri stolpec \"začni\"",
   "keyboard_shortcuts.toggle_hidden": "prikaži/skrij besedilo za CW",
   "keyboard_shortcuts.toggle_sensitivity": "prikaži/skrij medije",
@@ -412,6 +414,10 @@
   "time_remaining.minutes": "{number, plural, one {# minuta} other {# minut}} je ostalo",
   "time_remaining.moments": "Preostali trenutki",
   "time_remaining.seconds": "{number, plural, one {# sekunda} other {# sekund}} je ostalo",
+  "timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.",
+  "timeline_hint.resources.followers": "Followers",
+  "timeline_hint.resources.follows": "Follows",
+  "timeline_hint.resources.statuses": "Older toots",
   "trends.count_by_accounts": "{count} {rawCount, plural, one {oseba} other {ljudi}} govori",
   "trends.trending_now": "Trending now",
   "ui.beforeunload": "Vaš osnutek bo izgubljen, če zapustite Mastodona.",
diff --git a/app/javascript/mastodon/locales/sq.json b/app/javascript/mastodon/locales/sq.json
index 8626b9ce7..2729c0266 100644
--- a/app/javascript/mastodon/locales/sq.json
+++ b/app/javascript/mastodon/locales/sq.json
@@ -5,6 +5,7 @@
   "account.block": "Blloko @{name}",
   "account.block_domain": "Fshih gjithçka prej {domain}",
   "account.blocked": "E bllokuar",
+  "account.browse_more_on_origin_server": "Browse more on the original profile",
   "account.cancel_follow_request": "Anulo kërkesën e ndjekjes",
   "account.direct": "Mesazh i drejtpërdrejt për @{name}",
   "account.domain_blocked": "Përkatësi e fshehur",
@@ -236,6 +237,7 @@
   "keyboard_shortcuts.reply": "për t’u përgjigjur",
   "keyboard_shortcuts.requests": "për hapje liste kërkesash për ndjekje",
   "keyboard_shortcuts.search": "për kalim fokusi te kërkimi",
+  "keyboard_shortcuts.spoilers": "to show/hide CW field",
   "keyboard_shortcuts.start": "për hapjen e shtyllës \"fillojani\"",
   "keyboard_shortcuts.toggle_hidden": "për shfaqje/fshehje teksti pas CW",
   "keyboard_shortcuts.toggle_sensitivity": "për të shfaqur/të fshehur media",
@@ -412,6 +414,10 @@
   "time_remaining.minutes": "{number, plural,one {# minutë}other {# minuta}} mbetur",
   "time_remaining.moments": "Momente të mbetura",
   "time_remaining.seconds": "{number, plural,one {# sekond}other {# sekonda}} mbetur",
+  "timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.",
+  "timeline_hint.resources.followers": "Followers",
+  "timeline_hint.resources.follows": "Follows",
+  "timeline_hint.resources.statuses": "Older toots",
   "trends.count_by_accounts": "{count} {rawCount, plural, një {person} {people} të tjerë} po flasin",
   "trends.trending_now": "Në trend",
   "ui.beforeunload": "Skica juaj do të humbë nëse dilni nga Mastodon-i.",
diff --git a/app/javascript/mastodon/locales/sr-Latn.json b/app/javascript/mastodon/locales/sr-Latn.json
index d1b4ee7ec..6e0bd4d1e 100644
--- a/app/javascript/mastodon/locales/sr-Latn.json
+++ b/app/javascript/mastodon/locales/sr-Latn.json
@@ -5,6 +5,7 @@
   "account.block": "Blokiraj korisnika @{name}",
   "account.block_domain": "Sakrij sve sa domena {domain}",
   "account.blocked": "Blocked",
+  "account.browse_more_on_origin_server": "Browse more on the original profile",
   "account.cancel_follow_request": "Cancel follow request",
   "account.direct": "Direct Message @{name}",
   "account.domain_blocked": "Domain hidden",
@@ -236,6 +237,7 @@
   "keyboard_shortcuts.reply": "da odgovorite",
   "keyboard_shortcuts.requests": "to open follow requests list",
   "keyboard_shortcuts.search": "da se prebacite na pretragu",
+  "keyboard_shortcuts.spoilers": "to show/hide CW field",
   "keyboard_shortcuts.start": "to open \"get started\" column",
   "keyboard_shortcuts.toggle_hidden": "to show/hide text behind CW",
   "keyboard_shortcuts.toggle_sensitivity": "to show/hide media",
@@ -412,6 +414,10 @@
   "time_remaining.minutes": "{number, plural, one {# minute} other {# minutes}} left",
   "time_remaining.moments": "Moments remaining",
   "time_remaining.seconds": "{number, plural, one {# second} other {# seconds}} left",
+  "timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.",
+  "timeline_hint.resources.followers": "Followers",
+  "timeline_hint.resources.follows": "Follows",
+  "timeline_hint.resources.statuses": "Older toots",
   "trends.count_by_accounts": "{count} {rawCount, plural, one {person} other {people}} talking",
   "trends.trending_now": "Trending now",
   "ui.beforeunload": "Ako napustite Mastodont, izgubićete napisani nacrt.",
diff --git a/app/javascript/mastodon/locales/sr.json b/app/javascript/mastodon/locales/sr.json
index 277ef356f..356ff85bc 100644
--- a/app/javascript/mastodon/locales/sr.json
+++ b/app/javascript/mastodon/locales/sr.json
@@ -5,6 +5,7 @@
   "account.block": "Блокирај @{name}",
   "account.block_domain": "Сакриј све са домена {domain}",
   "account.blocked": "Блокиран",
+  "account.browse_more_on_origin_server": "Browse more on the original profile",
   "account.cancel_follow_request": "Поништи захтеве за праћење",
   "account.direct": "Директна порука @{name}",
   "account.domain_blocked": "Домен сакривен",
@@ -236,6 +237,7 @@
   "keyboard_shortcuts.reply": "да одговорите",
   "keyboard_shortcuts.requests": "да отворите листу примљених захтева за праћење",
   "keyboard_shortcuts.search": "да се пребаците на претрагу",
+  "keyboard_shortcuts.spoilers": "to show/hide CW field",
   "keyboard_shortcuts.start": "да отворите колону \"почнимо\"",
   "keyboard_shortcuts.toggle_hidden": "да прикажете/сакријте текст иза CW-а",
   "keyboard_shortcuts.toggle_sensitivity": "to show/hide media",
@@ -412,6 +414,10 @@
   "time_remaining.minutes": "{number, plural, one {# minute} other {# minutes}} left",
   "time_remaining.moments": "Moments remaining",
   "time_remaining.seconds": "{number, plural, one {# second} other {# seconds}} left",
+  "timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.",
+  "timeline_hint.resources.followers": "Followers",
+  "timeline_hint.resources.follows": "Follows",
+  "timeline_hint.resources.statuses": "Older toots",
   "trends.count_by_accounts": "{count} {rawCount, plural, one {човек} other {људи}} прича",
   "trends.trending_now": "Trending now",
   "ui.beforeunload": "Ако напустите Мастодонт, изгубићете написани нацрт.",
diff --git a/app/javascript/mastodon/locales/sv.json b/app/javascript/mastodon/locales/sv.json
index c83ede5bc..11b72db36 100644
--- a/app/javascript/mastodon/locales/sv.json
+++ b/app/javascript/mastodon/locales/sv.json
@@ -5,6 +5,7 @@
   "account.block": "Blockera @{name}",
   "account.block_domain": "Dölj allt från {domain}",
   "account.blocked": "Blockerad",
+  "account.browse_more_on_origin_server": "Browse more on the original profile",
   "account.cancel_follow_request": "Avbryt följarförfrågan",
   "account.direct": "Skicka ett direktmeddelande till @{name}",
   "account.domain_blocked": "Domän dold",
@@ -236,6 +237,7 @@
   "keyboard_shortcuts.reply": "för att svara",
   "keyboard_shortcuts.requests": "för att öppna Följförfrågningar",
   "keyboard_shortcuts.search": "för att fokusera sökfältet",
+  "keyboard_shortcuts.spoilers": "to show/hide CW field",
   "keyboard_shortcuts.start": "för att öppna \"Kom igång\"-kolumnen",
   "keyboard_shortcuts.toggle_hidden": "för att visa/gömma text bakom CW",
   "keyboard_shortcuts.toggle_sensitivity": "för att visa/gömma media",
@@ -412,6 +414,10 @@
   "time_remaining.minutes": "{minutes, plural, one {1 minut} other {# minuter}} kvar",
   "time_remaining.moments": "Återstående tillfällen",
   "time_remaining.seconds": "{hours, plural, one {# sekund} other {# sekunder}} kvar",
+  "timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.",
+  "timeline_hint.resources.followers": "Followers",
+  "timeline_hint.resources.follows": "Follows",
+  "timeline_hint.resources.statuses": "Older toots",
   "trends.count_by_accounts": "{count} {rawCount, plural, en {person} andra {people}} pratar",
   "trends.trending_now": "Trendar nu",
   "ui.beforeunload": "Ditt utkast kommer att förloras om du lämnar Mastodon.",
diff --git a/app/javascript/mastodon/locales/ta.json b/app/javascript/mastodon/locales/ta.json
index 22d24639f..117fa0839 100644
--- a/app/javascript/mastodon/locales/ta.json
+++ b/app/javascript/mastodon/locales/ta.json
@@ -5,6 +5,7 @@
   "account.block": "@{name} -ஐத் தடு",
   "account.block_domain": "{domain} யில் இருந்து வரும் எல்லாவற்றையும் மறை",
   "account.blocked": "முடக்கப்பட்டது",
+  "account.browse_more_on_origin_server": "Browse more on the original profile",
   "account.cancel_follow_request": "பின்தொடரும் கோரிக்கையை நிராகரி",
   "account.direct": "நேரடி செய்தி @{name}",
   "account.domain_blocked": "மறைக்கப்பட்டத் தளங்கள்",
@@ -236,6 +237,7 @@
   "keyboard_shortcuts.reply": "பதிலளிக்க",
   "keyboard_shortcuts.requests": "கோரிக்கைகள் பட்டியலைத் திறக்க",
   "keyboard_shortcuts.search": "தேடல் கவனம் செலுத்த",
+  "keyboard_shortcuts.spoilers": "to show/hide CW field",
   "keyboard_shortcuts.start": "'தொடங்குவதற்கு' நெடுவரிசை திறக்க",
   "keyboard_shortcuts.toggle_hidden": "CW க்கு பின்னால் உரையை மறைக்க / மறைக்க",
   "keyboard_shortcuts.toggle_sensitivity": "படிமங்களைக் காட்ட/மறைக்க",
@@ -412,6 +414,10 @@
   "time_remaining.minutes": "{number, plural, one {# minute} மற்ற {# minutes}} left",
   "time_remaining.moments": "தருணங்கள் மீதமுள்ளன",
   "time_remaining.seconds": "{number, plural, one {# second} மற்ற {# seconds}} left",
+  "timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.",
+  "timeline_hint.resources.followers": "Followers",
+  "timeline_hint.resources.follows": "Follows",
+  "timeline_hint.resources.statuses": "Older toots",
   "trends.count_by_accounts": "{count} {rawCount, plural, one {person} மற்ற {people}} உரையாடு",
   "trends.trending_now": "இப்போது செல்திசையில் இருப்பவை",
   "ui.beforeunload": "நீங்கள் வெளியே சென்றால் உங்கள் வரைவு இழக்கப்படும் மஸ்தோடோன்.",
diff --git a/app/javascript/mastodon/locales/te.json b/app/javascript/mastodon/locales/te.json
index 031d1fad7..bd94f8498 100644
--- a/app/javascript/mastodon/locales/te.json
+++ b/app/javascript/mastodon/locales/te.json
@@ -5,6 +5,7 @@
   "account.block": "@{name} ను బ్లాక్ చేయి",
   "account.block_domain": "{domain} నుంచి అన్నీ దాచిపెట్టు",
   "account.blocked": "బ్లాక్ అయినవి",
+  "account.browse_more_on_origin_server": "Browse more on the original profile",
   "account.cancel_follow_request": "Cancel follow request",
   "account.direct": "@{name}కు నేరుగా సందేశం పంపు",
   "account.domain_blocked": "డొమైన్ దాచిపెట్టబడినది",
@@ -236,6 +237,7 @@
   "keyboard_shortcuts.reply": "ప్రత్యుత్తరం ఇవ్వడానికి",
   "keyboard_shortcuts.requests": "ఫాలో రిక్వెస్ట్ల జాబితాను తెరవడానికి",
   "keyboard_shortcuts.search": "శోధనపై దృష్టి పెట్టండి",
+  "keyboard_shortcuts.spoilers": "to show/hide CW field",
   "keyboard_shortcuts.start": "\"ఇక్కడ ప్రారంభించండి\" నిలువు వరుసను తెరవడానికి",
   "keyboard_shortcuts.toggle_hidden": "CW వెనుక ఉన్న పాఠ్యాన్ని చూపడానికి / దాచడానికి",
   "keyboard_shortcuts.toggle_sensitivity": "to show/hide media",
@@ -412,6 +414,10 @@
   "time_remaining.minutes": "{number, plural, one {# minute} other {# minutes}} left",
   "time_remaining.moments": "కొన్ని క్షణాలు మాత్రమే మిగిలి ఉన్నాయి",
   "time_remaining.seconds": "{number, plural, one {# second} other {# seconds}} left",
+  "timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.",
+  "timeline_hint.resources.followers": "Followers",
+  "timeline_hint.resources.follows": "Follows",
+  "timeline_hint.resources.statuses": "Older toots",
   "trends.count_by_accounts": "{count} {rawCount, plural, one {person} other {people}} మాట్లాడుతున్నారు",
   "trends.trending_now": "Trending now",
   "ui.beforeunload": "మీరు మాస్టొడొన్ను వదిలివేస్తే మీ డ్రాఫ్ట్లు పోతాయి.",
diff --git a/app/javascript/mastodon/locales/th.json b/app/javascript/mastodon/locales/th.json
index 8ba698e4e..a95fc55b9 100644
--- a/app/javascript/mastodon/locales/th.json
+++ b/app/javascript/mastodon/locales/th.json
@@ -5,6 +5,7 @@
   "account.block": "ปิดกั้น @{name}",
   "account.block_domain": "ปิดกั้นโดเมน {domain}",
   "account.blocked": "ปิดกั้นอยู่",
+  "account.browse_more_on_origin_server": "Browse more on the original profile",
   "account.cancel_follow_request": "ยกเลิกคำขอติดตาม",
   "account.direct": "ส่งข้อความโดยตรงถึง @{name}",
   "account.domain_blocked": "ปิดกั้นโดเมนอยู่",
@@ -236,6 +237,7 @@
   "keyboard_shortcuts.reply": "เพื่อตอบกลับ",
   "keyboard_shortcuts.requests": "เพื่อเปิดรายการคำขอติดตาม",
   "keyboard_shortcuts.search": "เพื่อโฟกัสการค้นหา",
+  "keyboard_shortcuts.spoilers": "to show/hide CW field",
   "keyboard_shortcuts.start": "เพื่อเปิดคอลัมน์ \"เริ่มต้นใช้งาน\"",
   "keyboard_shortcuts.toggle_hidden": "เพื่อแสดง/ซ่อนข้อความที่อยู่หลังคำเตือนเนื้อหา",
   "keyboard_shortcuts.toggle_sensitivity": "เพื่อแสดง/ซ่อนสื่อ",
@@ -412,6 +414,10 @@
   "time_remaining.minutes": "เหลืออีก {number, plural, other {# นาที}}",
   "time_remaining.moments": "ช่วงเวลาที่เหลือ",
   "time_remaining.seconds": "เหลืออีก {number, plural, other {# วินาที}}",
+  "timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.",
+  "timeline_hint.resources.followers": "Followers",
+  "timeline_hint.resources.follows": "Follows",
+  "timeline_hint.resources.statuses": "Older toots",
   "trends.count_by_accounts": "{count} {rawCount, plural, other {คน}}กำลังพูดคุย",
   "trends.trending_now": "กำลังนิยม",
   "ui.beforeunload": "แบบร่างของคุณจะหายไปหากคุณออกจาก Mastodon",
diff --git a/app/javascript/mastodon/locales/tr.json b/app/javascript/mastodon/locales/tr.json
index c6c1c8d8c..17d8a54ff 100644
--- a/app/javascript/mastodon/locales/tr.json
+++ b/app/javascript/mastodon/locales/tr.json
@@ -5,6 +5,7 @@
   "account.block": "@{name} adlı kişiyi engelle",
   "account.block_domain": "{domain} alanından her şeyi gizle",
   "account.blocked": "Engellenmiş",
+  "account.browse_more_on_origin_server": "Browse more on the original profile",
   "account.cancel_follow_request": "Takip isteğini iptal et",
   "account.direct": "Mesaj gönder @{name}",
   "account.domain_blocked": "Alan adı gizlendi",
@@ -236,6 +237,7 @@
   "keyboard_shortcuts.reply": "cevaplamak için",
   "keyboard_shortcuts.requests": "takip istekleri listesini açmak için",
   "keyboard_shortcuts.search": "aramaya odaklanmak için",
+  "keyboard_shortcuts.spoilers": "to show/hide CW field",
   "keyboard_shortcuts.start": "\"başlayın\" sütununu açmak için",
   "keyboard_shortcuts.toggle_hidden": "CW'den önceki yazıyı göstermek/gizlemek için",
   "keyboard_shortcuts.toggle_sensitivity": "medyayı göstermek/gizlemek için",
@@ -412,6 +414,10 @@
   "time_remaining.minutes": "{number, plural, one {# dakika} other {# dakika}} kaldı",
   "time_remaining.moments": "Sadece birkaç dakika kaldı",
   "time_remaining.seconds": "{number, plural, one {# saniye} other {# saniye}} kaldı",
+  "timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.",
+  "timeline_hint.resources.followers": "Followers",
+  "timeline_hint.resources.follows": "Follows",
+  "timeline_hint.resources.statuses": "Older toots",
   "trends.count_by_accounts": "{count} {rawCount, plural, one {kişi} other {kişi}} konuşuyor",
   "trends.trending_now": "Şu an popüler",
   "ui.beforeunload": "Mastodon'dan ayrılırsanız taslağınız kaybolacak.",
diff --git a/app/javascript/mastodon/locales/uk.json b/app/javascript/mastodon/locales/uk.json
index 2bf264395..9735f2b83 100644
--- a/app/javascript/mastodon/locales/uk.json
+++ b/app/javascript/mastodon/locales/uk.json
@@ -5,6 +5,7 @@
   "account.block": "Заблокувати @{name}",
   "account.block_domain": "Заглушити {domain}",
   "account.blocked": "Заблоковані",
+  "account.browse_more_on_origin_server": "Browse more on the original profile",
   "account.cancel_follow_request": "Скасувати запит на підписку",
   "account.direct": "Пряме повідомлення @{name}",
   "account.domain_blocked": "Домен приховано",
@@ -236,6 +237,7 @@
   "keyboard_shortcuts.reply": "відповісти",
   "keyboard_shortcuts.requests": "відкрити список бажаючих підписатися",
   "keyboard_shortcuts.search": "сфокусуватися на пошуку",
+  "keyboard_shortcuts.spoilers": "to show/hide CW field",
   "keyboard_shortcuts.start": "відкрити колонку \"Початок\"",
   "keyboard_shortcuts.toggle_hidden": "показати/приховати текст під попередженням",
   "keyboard_shortcuts.toggle_sensitivity": "показати/приховати медіа",
@@ -412,6 +414,10 @@
   "time_remaining.minutes": "{number, plural, one {# хвилина} few {# хвилини} other {# хвилин}}",
   "time_remaining.moments": "Залишилось секунд",
   "time_remaining.seconds": "{number, plural, one {# секунда} few {# секунди} other {# секунд}}",
+  "timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.",
+  "timeline_hint.resources.followers": "Followers",
+  "timeline_hint.resources.follows": "Follows",
+  "timeline_hint.resources.statuses": "Older toots",
   "trends.count_by_accounts": "{count} {rawCount, plural, one {людина} few {людини} many {людей} other {людей}} обговорюють це",
   "trends.trending_now": "Актуальні",
   "ui.beforeunload": "Вашу чернетку буде втрачено, якщо ви покинете Mastodon.",
diff --git a/app/javascript/mastodon/locales/ur.json b/app/javascript/mastodon/locales/ur.json
index b4989afb7..e3639d477 100644
--- a/app/javascript/mastodon/locales/ur.json
+++ b/app/javascript/mastodon/locales/ur.json
@@ -5,6 +5,7 @@
   "account.block": "مسدود @{name}",
   "account.block_domain": "{domain} سے سب چھپائیں",
   "account.blocked": "مسدود کردہ",
+  "account.browse_more_on_origin_server": "Browse more on the original profile",
   "account.cancel_follow_request": "درخواستِ پیروی منسوخ کریں",
   "account.direct": "راست پیغام @{name}",
   "account.domain_blocked": "پوشیدہ ڈومین",
@@ -236,6 +237,7 @@
   "keyboard_shortcuts.reply": "to reply",
   "keyboard_shortcuts.requests": "to open follow requests list",
   "keyboard_shortcuts.search": "to focus search",
+  "keyboard_shortcuts.spoilers": "to show/hide CW field",
   "keyboard_shortcuts.start": "to open \"get started\" column",
   "keyboard_shortcuts.toggle_hidden": "to show/hide text behind CW",
   "keyboard_shortcuts.toggle_sensitivity": "to show/hide media",
@@ -412,6 +414,10 @@
   "time_remaining.minutes": "{number, plural, one {# minute} other {# minutes}} left",
   "time_remaining.moments": "Moments remaining",
   "time_remaining.seconds": "{number, plural, one {# second} other {# seconds}} left",
+  "timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.",
+  "timeline_hint.resources.followers": "Followers",
+  "timeline_hint.resources.follows": "Follows",
+  "timeline_hint.resources.statuses": "Older toots",
   "trends.count_by_accounts": "{count} {rawCount, plural, one {person} other {people}} talking",
   "trends.trending_now": "Trending now",
   "ui.beforeunload": "Your draft will be lost if you leave Mastodon.",
diff --git a/app/javascript/mastodon/locales/vi.json b/app/javascript/mastodon/locales/vi.json
index 126fbc561..2d16526c4 100644
--- a/app/javascript/mastodon/locales/vi.json
+++ b/app/javascript/mastodon/locales/vi.json
@@ -5,6 +5,7 @@
   "account.block": "Chặn @{name}",
   "account.block_domain": "Ẩn mọi thứ từ {domain}",
   "account.blocked": "Đã chặn",
+  "account.browse_more_on_origin_server": "Browse more on the original profile",
   "account.cancel_follow_request": "Hủy theo yêu cầu",
   "account.direct": "Nhắn riêng @{name}",
   "account.domain_blocked": "Miền đã ẩn",
@@ -236,6 +237,7 @@
   "keyboard_shortcuts.reply": "để trả lời",
   "keyboard_shortcuts.requests": "để mở danh sách các yêu cầu",
   "keyboard_shortcuts.search": "để vào ô tìm kiếm",
+  "keyboard_shortcuts.spoilers": "to show/hide CW field",
   "keyboard_shortcuts.start": "để mở cột \"Bắt đầu\"",
   "keyboard_shortcuts.toggle_hidden": "để ẩn/hiện đằng sau văn bản CW",
   "keyboard_shortcuts.toggle_sensitivity": "để hiển thị / ẩn media",
@@ -412,6 +414,10 @@
   "time_remaining.minutes": "{number, plural, other {}} left",
   "time_remaining.moments": "Còn lại",
   "time_remaining.seconds": "{number, plural, other {}} left",
+  "timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.",
+  "timeline_hint.resources.followers": "Followers",
+  "timeline_hint.resources.follows": "Follows",
+  "timeline_hint.resources.statuses": "Older toots",
   "trends.count_by_accounts": "{Count} {rawCount, số nhiều, một {người} khác {người}} nói chuyện",
   "trends.trending_now": "Đang là xu hướng",
   "ui.beforeunload": "Bản nháp của bạn sẽ bị mất nếu bạn rời của Mastodon.",
diff --git a/app/javascript/mastodon/locales/zh-CN.json b/app/javascript/mastodon/locales/zh-CN.json
index ccd69815d..0069b90ae 100644
--- a/app/javascript/mastodon/locales/zh-CN.json
+++ b/app/javascript/mastodon/locales/zh-CN.json
@@ -5,6 +5,7 @@
   "account.block": "屏蔽 @{name}",
   "account.block_domain": "隐藏来自 {domain} 的内容",
   "account.blocked": "已屏蔽",
+  "account.browse_more_on_origin_server": "Browse more on the original profile",
   "account.cancel_follow_request": "取消关注请求",
   "account.direct": "发送私信给 @{name}",
   "account.domain_blocked": "网站已屏蔽",
@@ -236,6 +237,7 @@
   "keyboard_shortcuts.reply": "回复嘟文",
   "keyboard_shortcuts.requests": "打开关注请求列表",
   "keyboard_shortcuts.search": "选择搜索框",
+  "keyboard_shortcuts.spoilers": "to show/hide CW field",
   "keyboard_shortcuts.start": "打开“开始使用”栏",
   "keyboard_shortcuts.toggle_hidden": "显示或隐藏被折叠的正文",
   "keyboard_shortcuts.toggle_sensitivity": "显示/隐藏媒体",
@@ -412,6 +414,10 @@
   "time_remaining.minutes": "剩余 {number, plural, one {# 分钟} other {# 分钟}}",
   "time_remaining.moments": "即将结束",
   "time_remaining.seconds": "剩余 {number, plural, one {# 秒} other {# 秒}}",
+  "timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.",
+  "timeline_hint.resources.followers": "Followers",
+  "timeline_hint.resources.follows": "Follows",
+  "timeline_hint.resources.statuses": "Older toots",
   "trends.count_by_accounts": "{count} 人正在讨论",
   "trends.trending_now": "现在流行",
   "ui.beforeunload": "如果你现在离开 Mastodon,你的草稿内容将会丢失。",
diff --git a/app/javascript/mastodon/locales/zh-HK.json b/app/javascript/mastodon/locales/zh-HK.json
index 1ae125ba0..f3212049b 100644
--- a/app/javascript/mastodon/locales/zh-HK.json
+++ b/app/javascript/mastodon/locales/zh-HK.json
@@ -5,6 +5,7 @@
   "account.block": "封鎖 @{name}",
   "account.block_domain": "隱藏來自 {domain} 的一切文章",
   "account.blocked": "封鎖",
+  "account.browse_more_on_origin_server": "Browse more on the original profile",
   "account.cancel_follow_request": "取消關注請求",
   "account.direct": "私訊 @{name}",
   "account.domain_blocked": "服務站被隱藏",
@@ -236,6 +237,7 @@
   "keyboard_shortcuts.reply": "回覆",
   "keyboard_shortcuts.requests": "開啟關注請求名單",
   "keyboard_shortcuts.search": "把標示移動到搜索",
+  "keyboard_shortcuts.spoilers": "to show/hide CW field",
   "keyboard_shortcuts.start": "開啟「開始使用」欄位",
   "keyboard_shortcuts.toggle_hidden": "顯示或隱藏被標為敏感的文字",
   "keyboard_shortcuts.toggle_sensitivity": "顯示 / 隱藏媒體",
@@ -412,6 +414,10 @@
   "time_remaining.minutes": "剩餘{number, plural, one {# 分鐘} other {# 分鐘}}",
   "time_remaining.moments": "剩餘時間",
   "time_remaining.seconds": "剩餘 {number, plural, one {# 秒} other {# 秒}}",
+  "timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.",
+  "timeline_hint.resources.followers": "Followers",
+  "timeline_hint.resources.follows": "Follows",
+  "timeline_hint.resources.statuses": "Older toots",
   "trends.count_by_accounts": "{count} 位用戶在討論",
   "trends.trending_now": "目前趨勢",
   "ui.beforeunload": "如果你現在離開 Mastodon,你的草稿內容將會被丟棄。",
diff --git a/app/javascript/mastodon/locales/zh-TW.json b/app/javascript/mastodon/locales/zh-TW.json
index f4862cf86..62ef113ed 100644
--- a/app/javascript/mastodon/locales/zh-TW.json
+++ b/app/javascript/mastodon/locales/zh-TW.json
@@ -5,6 +5,7 @@
   "account.block": "封鎖 @{name}",
   "account.block_domain": "隱藏來自 {domain} 的所有內容",
   "account.blocked": "已封鎖",
+  "account.browse_more_on_origin_server": "Browse more on the original profile",
   "account.cancel_follow_request": "取消關注請求",
   "account.direct": "傳私訊給 @{name}",
   "account.domain_blocked": "已隱藏網域",
@@ -236,6 +237,7 @@
   "keyboard_shortcuts.reply": "回覆",
   "keyboard_shortcuts.requests": "開啟關注請求名單",
   "keyboard_shortcuts.search": "將焦點移至搜尋框",
+  "keyboard_shortcuts.spoilers": "to show/hide CW field",
   "keyboard_shortcuts.start": "開啟「開始使用」欄位",
   "keyboard_shortcuts.toggle_hidden": "顯示/隱藏在內容警告之後的正文",
   "keyboard_shortcuts.toggle_sensitivity": "顯示 / 隱藏媒體",
@@ -412,6 +414,10 @@
   "time_remaining.minutes": "剩餘{number, plural, one {# 分鐘} other {# 分鐘}}",
   "time_remaining.moments": "剩餘時間",
   "time_remaining.seconds": "剩餘 {number, plural, one {# 秒} other {# 秒}}",
+  "timeline_hint.remote_resource_not_displayed": "{resource} from other servers are not displayed.",
+  "timeline_hint.resources.followers": "Followers",
+  "timeline_hint.resources.follows": "Follows",
+  "timeline_hint.resources.statuses": "Older toots",
   "trends.count_by_accounts": "{count} 位使用者在討論",
   "trends.trending_now": "目前趨勢",
   "ui.beforeunload": "如果離開 Mastodon,你的草稿將會不見。",
diff --git a/app/javascript/styles/mastodon-light/variables.scss b/app/javascript/styles/mastodon-light/variables.scss
index c68944528..bc039ff03 100644
--- a/app/javascript/styles/mastodon-light/variables.scss
+++ b/app/javascript/styles/mastodon-light/variables.scss
@@ -39,3 +39,5 @@ $account-background-color: $white !default;
 @function lighten($color, $amount) {
   @return hsl(hue($color), saturation($color), lightness($color) - $amount);
 }
+
+$emojis-requiring-inversion: 'chains';
diff --git a/app/javascript/styles/mastodon/accessibility.scss b/app/javascript/styles/mastodon/accessibility.scss
index d33806c84..c5bcb5941 100644
--- a/app/javascript/styles/mastodon/accessibility.scss
+++ b/app/javascript/styles/mastodon/accessibility.scss
@@ -1,14 +1,13 @@
-$black-emojis: '8ball' 'ant' 'back' 'black_circle' 'black_heart' 'black_large_square' 'black_medium_small_square' 'black_medium_square' 'black_nib' 'black_small_square' 'bomb' 'bowling' 'bust_in_silhouette' 'busts_in_silhouette' 'camera' 'camera_with_flash' 'clubs' 'copyright' 'curly_loop' 'currency_exchange' 'dark_sunglasses' 'eight_pointed_black_star' 'electric_plug' 'end' 'female-guard' 'film_projector' 'fried_egg' 'gorilla' 'guardsman' 'heavy_check_mark' 'heavy_division_sign' 'heavy_dollar_sign' 'heavy_minus_sign' 'heavy_multiplication_x' 'heavy_plus_sign' 'hocho' 'hole' 'joystick' 'kaaba' 'lower_left_ballpoint_pen' 'lower_left_fountain_pen' 'male-guard' 'microphone' 'mortar_board' 'movie_camera' 'musical_score' 'on' 'registered' 'soon' 'spades' 'speaking_head_in_silhouette' 'spider' 'telephone_receiver' 'tm' 'top' 'tophat' 'turkey' 'vhs' 'video_camera' 'video_game' 'water_buffalo' 'waving_black_flag' 'wavy_dash';
+$emojis-requiring-inversion: 'back' 'copyright' 'curly_loop' 'currency_exchange' 'end' 'heavy_check_mark' 'heavy_division_sign' 'heavy_dollar_sign' 'heavy_minus_sign' 'heavy_multiplication_x' 'heavy_plus_sign' 'on' 'registered' 'soon' 'spider' 'telephone_receiver' 'tm' 'top' 'wavy_dash' !default;
 
-%white-emoji-outline {
-  filter: drop-shadow(1px 1px 0 $white) drop-shadow(-1px 1px 0 $white) drop-shadow(1px -1px 0 $white) drop-shadow(-1px -1px 0 $white);
-  transform: scale(.71);
+%emoji-color-inversion {
+  filter: invert(1);
 }
 
 .emojione {
-  @each $emoji in $black-emojis {
+  @each $emoji in $emojis-requiring-inversion {
     &[title=':#{$emoji}:'] {
-      @extend %white-emoji-outline;
+      @extend %emoji-color-inversion;
     }
   }
 }
diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss
index 64f97c648..acbd21e8b 100644
--- a/app/javascript/styles/mastodon/components.scss
+++ b/app/javascript/styles/mastodon/components.scss
@@ -3097,6 +3097,11 @@ a.status-card {
   flex: 1 1 auto;
   overflow: hidden;
   padding: 14px 14px 14px 8px;
+
+  &--blurred {
+    filter: blur(2px);
+    pointer-events: none;
+  }
 }
 
 .status-card__description {
@@ -3134,7 +3139,8 @@ a.status-card {
     width: 100%;
   }
 
-  .status-card__image-image {
+  .status-card__image-image,
+  .status-card__image-preview {
     border-radius: 4px 4px 0 0;
   }
 
@@ -3179,6 +3185,24 @@ a.status-card.compact:hover {
   background-position: center center;
 }
 
+.status-card__image-preview {
+  border-radius: 4px 0 0 4px;
+  display: block;
+  margin: 0;
+  width: 100%;
+  height: 100%;
+  object-fit: fill;
+  position: absolute;
+  top: 0;
+  left: 0;
+  z-index: 0;
+  background: $base-overlay-background;
+
+  &--hidden {
+    display: none;
+  }
+}
+
 .load-more {
   display: block;
   color: $dark-text-color;
@@ -3203,6 +3227,31 @@ a.status-card.compact:hover {
   border-bottom: 1px solid lighten($ui-base-color, 8%);
 }
 
+.timeline-hint {
+  text-align: center;
+  color: $darker-text-color;
+  padding: 15px;
+  box-sizing: border-box;
+  width: 100%;
+  cursor: default;
+
+  strong {
+    font-weight: 500;
+  }
+
+  a {
+    color: lighten($ui-highlight-color, 8%);
+    text-decoration: none;
+
+    &:hover,
+    &:focus,
+    &:active {
+      text-decoration: underline;
+      color: lighten($ui-highlight-color, 12%);
+    }
+  }
+}
+
 .regeneration-indicator {
   text-align: center;
   font-size: 16px;
diff --git a/app/javascript/styles/mastodon/forms.scss b/app/javascript/styles/mastodon/forms.scss
index 0e5b00e8f..7a0b2f9a3 100644
--- a/app/javascript/styles/mastodon/forms.scss
+++ b/app/javascript/styles/mastodon/forms.scss
@@ -587,7 +587,7 @@ code {
 
   &.alert {
     border: 1px solid rgba($error-value-color, 0.5);
-    background: rgba($error-value-color, 0.25);
+    background: rgba($error-value-color, 0.1);
     color: $error-value-color;
   }
 
diff --git a/app/lib/activitypub/activity/create.rb b/app/lib/activitypub/activity/create.rb
index 572b8087e..3509a6c40 100644
--- a/app/lib/activitypub/activity/create.rb
+++ b/app/lib/activitypub/activity/create.rb
@@ -2,6 +2,45 @@
 
 class ActivityPub::Activity::Create < ActivityPub::Activity
   def perform
+    case @object['type']
+    when 'EncryptedMessage'
+      create_encrypted_message
+    else
+      create_status
+    end
+  end
+
+  private
+
+  def create_encrypted_message
+    return reject_payload! if invalid_origin?(@object['id']) || @options[:delivered_to_account_id].blank?
+
+    target_account = Account.find(@options[:delivered_to_account_id])
+    target_device  = target_account.devices.find_by(device_id: @object.dig('to', 'deviceId'))
+
+    return if target_device.nil?
+
+    target_device.encrypted_messages.create!(
+      from_account: @account,
+      from_device_id: @object.dig('attributedTo', 'deviceId'),
+      type: @object['messageType'],
+      body: @object['cipherText'],
+      digest: @object.dig('digest', 'digestValue'),
+      message_franking: message_franking.to_token
+    )
+  end
+
+  def message_franking
+    MessageFranking.new(
+      hmac: @object.dig('digest', 'digestValue'),
+      original_franking: @object['messageFranking'],
+      source_account_id: @account.id,
+      target_account_id: @options[:delivered_to_account_id],
+      timestamp: Time.now.utc
+    )
+  end
+
+  def create_status
     return reject_payload! if unsupported_object_type? || invalid_origin?(@object['id']) || Tombstone.exists?(uri: @object['id']) || !related_to_local_activity?
 
     RedisLock.acquire(lock_options) do |lock|
@@ -23,8 +62,6 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
     @status
   end
 
-  private
-
   def audience_to
     @object['to'] || @json['to']
   end
@@ -262,6 +299,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
   def poll_vote!
     poll = replied_to_status.preloadable_poll
     already_voted = true
+
     RedisLock.acquire(poll_lock_options) do |lock|
       if lock.acquired?
         already_voted = poll.votes.where(account: @account).exists?
@@ -270,20 +308,24 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
         raise Mastodon::RaceConditionError
       end
     end
+
     increment_voters_count! unless already_voted
     ActivityPub::DistributePollUpdateWorker.perform_in(3.minutes, replied_to_status.id) unless replied_to_status.preloadable_poll.hide_totals?
   end
 
   def resolve_thread(status)
     return unless status.reply? && status.thread.nil? && Request.valid_url?(in_reply_to_uri)
+
     ThreadResolveWorker.perform_async(status.id, in_reply_to_uri)
   end
 
   def fetch_replies(status)
     collection = @object['replies']
     return if collection.nil?
+
     replies = ActivityPub::FetchRepliesService.new.call(status, collection, false)
     return unless replies.nil?
+
     uri = value_or_id(collection)
     ActivityPub::FetchRepliesWorker.perform_async(status.id, uri) unless uri.nil?
   end
@@ -291,6 +333,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
   def conversation_from_uri(uri)
     return nil if uri.nil?
     return Conversation.find_by(id: OStatus::TagManager.instance.unique_tag_to_local_id(uri, 'Conversation')) if OStatus::TagManager.instance.local_id?(uri)
+
     begin
       Conversation.find_or_create_by!(uri: uri)
     rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotUnique
@@ -404,6 +447,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
 
   def skip_download?
     return @skip_download if defined?(@skip_download)
+
     @skip_download ||= DomainBlock.reject_media?(@account.domain)
   end
 
@@ -436,11 +480,13 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
 
   def forward_for_reply
     return unless @json['signature'].present? && reply_to_local?
+
     ActivityPub::RawDistributionWorker.perform_async(Oj.dump(@json), replied_to_status.account_id, [@account.preferred_inbox_url])
   end
 
   def increment_voters_count!
     poll = replied_to_status.preloadable_poll
+
     unless poll.voters_count.nil?
       poll.voters_count = poll.voters_count + 1
       poll.save
diff --git a/app/lib/activitypub/adapter.rb b/app/lib/activitypub/adapter.rb
index 78138fb73..634ed29fa 100644
--- a/app/lib/activitypub/adapter.rb
+++ b/app/lib/activitypub/adapter.rb
@@ -22,6 +22,7 @@ class ActivityPub::Adapter < ActiveModelSerializers::Adapter::Base
     blurhash: { 'toot' => 'http://joinmastodon.org/ns#', 'blurhash' => 'toot:blurhash' },
     discoverable: { 'toot' => 'http://joinmastodon.org/ns#', 'discoverable' => 'toot:discoverable' },
     voters_count: { 'toot' => 'http://joinmastodon.org/ns#', 'votersCount' => 'toot:votersCount' },
+    olm: { 'toot' => 'http://joinmastodon.org/ns#', 'Device' => 'toot:Device', 'Ed25519Signature' => 'toot:Ed25519Signature', 'Ed25519Key' => 'toot:Ed25519Key', 'Curve25519Key' => 'toot:Curve25519Key', 'EncryptedMessage' => 'toot:EncryptedMessage', 'publicKeyBase64' => 'toot:publicKeyBase64', 'deviceId' => 'toot:deviceId', 'claim' => { '@type' => '@id', '@id' => 'toot:claim' }, 'fingerprintKey' => { '@type' => '@id', '@id' => 'toot:fingerprintKey' }, 'identityKey' => { '@type' => '@id', '@id' => 'toot:identityKey' }, 'devices' => { '@type' => '@id', '@id' => 'toot:devices' }, 'messageFranking' => 'toot:messageFranking', 'messageType' => 'toot:messageType', 'cipherText' => 'toot:cipherText' },
   }.freeze
 
   def self.default_key_transform
diff --git a/app/lib/feed_manager.rb b/app/lib/feed_manager.rb
index 3ce182809..8b3198df7 100644
--- a/app/lib/feed_manager.rb
+++ b/app/lib/feed_manager.rb
@@ -287,9 +287,14 @@ class FeedManager
     combined_regex = active_filters.reduce { |memo, obj| Regexp.union(memo, obj) }
     status         = status.reblog if status.reblog?
 
-    !combined_regex.match(Formatter.instance.plaintext(status)).nil? ||
-      (status.spoiler_text.present? && !combined_regex.match(status.spoiler_text).nil?) ||
-      (status.preloadable_poll && !combined_regex.match(status.preloadable_poll.options.join("\n\n")).nil?)
+    combined_text = [
+      Formatter.instance.plaintext(status),
+      status.spoiler_text,
+      status.preloadable_poll ? status.preloadable_poll.options.join("\n\n") : nil,
+      status.media_attachments.map(&:description).join("\n\n"),
+    ].compact.join("\n\n")
+
+    !combined_regex.match(combined_text).nil?
   end
 
   # Adds a status to an account's feed, returning true if a status was
diff --git a/app/lib/inline_renderer.rb b/app/lib/inline_renderer.rb
index 27e334a4d..b70814748 100644
--- a/app/lib/inline_renderer.rb
+++ b/app/lib/inline_renderer.rb
@@ -19,6 +19,8 @@ class InlineRenderer
       serializer = REST::AnnouncementSerializer
     when :reaction
       serializer = REST::ReactionSerializer
+    when :encrypted_message
+      serializer = REST::EncryptedMessageSerializer
     else
       return
     end
diff --git a/app/mailers/user_mailer.rb b/app/mailers/user_mailer.rb
index 88a11f761..2cd58e60a 100644
--- a/app/mailers/user_mailer.rb
+++ b/app/mailers/user_mailer.rb
@@ -126,4 +126,21 @@ class UserMailer < Devise::Mailer
            reply_to: Setting.site_contact_email
     end
   end
+
+  def sign_in_token(user, remote_ip, user_agent, timestamp)
+    @resource   = user
+    @instance   = Rails.configuration.x.local_domain
+    @remote_ip  = remote_ip
+    @user_agent = user_agent
+    @detection  = Browser.new(user_agent)
+    @timestamp  = timestamp.to_time.utc
+
+    return if @resource.disabled?
+
+    I18n.with_locale(@resource.locale || I18n.default_locale) do
+      mail to: @resource.email,
+           subject: I18n.t('user_mailer.sign_in_token.subject'),
+           reply_to: Setting.site_contact_email
+    end
+  end
 end
diff --git a/app/models/account.rb b/app/models/account.rb
index 5038d4768..0b3c48543 100644
--- a/app/models/account.rb
+++ b/app/models/account.rb
@@ -49,6 +49,7 @@
 #  hide_collections              :boolean
 #  avatar_storage_schema_version :integer
 #  header_storage_schema_version :integer
+#  devices_url                   :string
 #
 
 class Account < ApplicationRecord
diff --git a/app/models/concerns/account_associations.rb b/app/models/concerns/account_associations.rb
index 499edbf4e..cca3a17fa 100644
--- a/app/models/concerns/account_associations.rb
+++ b/app/models/concerns/account_associations.rb
@@ -9,6 +9,7 @@ module AccountAssociations
 
     # Identity proofs
     has_many :identity_proofs, class_name: 'AccountIdentityProof', dependent: :destroy, inverse_of: :account
+    has_many :devices, dependent: :destroy, inverse_of: :account
 
     # Timelines
     has_many :statuses, inverse_of: :account, dependent: :destroy
diff --git a/app/models/concerns/domain_normalizable.rb b/app/models/concerns/domain_normalizable.rb
index c00b3142f..fb84058fc 100644
--- a/app/models/concerns/domain_normalizable.rb
+++ b/app/models/concerns/domain_normalizable.rb
@@ -4,7 +4,7 @@ module DomainNormalizable
   extend ActiveSupport::Concern
 
   included do
-    before_save :normalize_domain
+    before_validation :normalize_domain
   end
 
   private
diff --git a/app/models/device.rb b/app/models/device.rb
new file mode 100644
index 000000000..97d0d2774
--- /dev/null
+++ b/app/models/device.rb
@@ -0,0 +1,35 @@
+# frozen_string_literal: true
+# == Schema Information
+#
+# Table name: devices
+#
+#  id              :bigint(8)        not null, primary key
+#  access_token_id :bigint(8)
+#  account_id      :bigint(8)
+#  device_id       :string           default(""), not null
+#  name            :string           default(""), not null
+#  fingerprint_key :text             default(""), not null
+#  identity_key    :text             default(""), not null
+#  created_at      :datetime         not null
+#  updated_at      :datetime         not null
+#
+
+class Device < ApplicationRecord
+  belongs_to :access_token, class_name: 'Doorkeeper::AccessToken'
+  belongs_to :account
+
+  has_many :one_time_keys, dependent: :destroy, inverse_of: :device
+  has_many :encrypted_messages, dependent: :destroy, inverse_of: :device
+
+  validates :name, :fingerprint_key, :identity_key, presence: true
+  validates :fingerprint_key, :identity_key, ed25519_key: true
+
+  before_save :invalidate_associations, if: -> { device_id_changed? || fingerprint_key_changed? || identity_key_changed? }
+
+  private
+
+  def invalidate_associations
+    one_time_keys.destroy_all
+    encrypted_messages.destroy_all
+  end
+end
diff --git a/app/models/domain_block.rb b/app/models/domain_block.rb
index f0a5bd296..2b18e01fa 100644
--- a/app/models/domain_block.rb
+++ b/app/models/domain_block.rb
@@ -50,11 +50,13 @@ class DomainBlock < ApplicationRecord
     def rule_for(domain)
       return if domain.blank?
 
-      uri      = Addressable::URI.new.tap { |u| u.host = domain.gsub(/[\/]/, '') }
+      uri      = Addressable::URI.new.tap { |u| u.host = domain.strip.gsub(/[\/]/, '') }
       segments = uri.normalized_host.split('.')
       variants = segments.map.with_index { |_, i| segments[i..-1].join('.') }
 
       where(domain: variants).order(Arel.sql('char_length(domain) desc')).first
+    rescue Addressable::URI::InvalidURIError, IDN::Idna::IdnaError
+      nil
     end
   end
 
diff --git a/app/models/encrypted_message.rb b/app/models/encrypted_message.rb
new file mode 100644
index 000000000..5e0aba434
--- /dev/null
+++ b/app/models/encrypted_message.rb
@@ -0,0 +1,50 @@
+# frozen_string_literal: true
+# == Schema Information
+#
+# Table name: encrypted_messages
+#
+#  id               :bigint(8)        not null, primary key
+#  device_id        :bigint(8)
+#  from_account_id  :bigint(8)
+#  from_device_id   :string           default(""), not null
+#  type             :integer          default(0), not null
+#  body             :text             default(""), not null
+#  digest           :text             default(""), not null
+#  message_franking :text             default(""), not null
+#  created_at       :datetime         not null
+#  updated_at       :datetime         not null
+#
+
+class EncryptedMessage < ApplicationRecord
+  self.inheritance_column = nil
+
+  include Paginable
+
+  scope :up_to, ->(id) { where(arel_table[:id].lteq(id)) }
+
+  belongs_to :device
+  belongs_to :from_account, class_name: 'Account'
+
+  around_create Mastodon::Snowflake::Callbacks
+
+  after_commit :push_to_streaming_api
+
+  private
+
+  def push_to_streaming_api
+    Rails.logger.info(streaming_channel)
+    Rails.logger.info(subscribed_to_timeline?)
+
+    return if destroyed? || !subscribed_to_timeline?
+
+    PushEncryptedMessageWorker.perform_async(id)
+  end
+
+  def subscribed_to_timeline?
+    Redis.current.exists("subscribed:#{streaming_channel}")
+  end
+
+  def streaming_channel
+    "timeline:#{device.account_id}:#{device.device_id}"
+  end
+end
diff --git a/app/models/message_franking.rb b/app/models/message_franking.rb
new file mode 100644
index 000000000..c72bd1cca
--- /dev/null
+++ b/app/models/message_franking.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+class MessageFranking
+  attr_reader :hmac, :source_account_id, :target_account_id,
+              :timestamp, :original_franking
+
+  def initialize(attributes = {})
+    @hmac              = attributes[:hmac]
+    @source_account_id = attributes[:source_account_id]
+    @target_account_id = attributes[:target_account_id]
+    @timestamp         = attributes[:timestamp]
+    @original_franking = attributes[:original_franking]
+  end
+
+  def to_token
+    crypt = ActiveSupport::MessageEncryptor.new(SystemKey.current_key, serializer: Oj)
+    crypt.encrypt_and_sign(self)
+  end
+end
diff --git a/app/models/one_time_key.rb b/app/models/one_time_key.rb
new file mode 100644
index 000000000..8ada34824
--- /dev/null
+++ b/app/models/one_time_key.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+# == Schema Information
+#
+# Table name: one_time_keys
+#
+#  id         :bigint(8)        not null, primary key
+#  device_id  :bigint(8)
+#  key_id     :string           default(""), not null
+#  key        :text             default(""), not null
+#  signature  :text             default(""), not null
+#  created_at :datetime         not null
+#  updated_at :datetime         not null
+#
+
+class OneTimeKey < ApplicationRecord
+  belongs_to :device
+
+  validates :key_id, :key, :signature, presence: true
+  validates :key, ed25519_key: true
+  validates :signature, ed25519_signature: { message: :key, verify_key: ->(one_time_key) { one_time_key.device.fingerprint_key } }
+end
diff --git a/app/models/preview_card.rb b/app/models/preview_card.rb
index 2802f4667..235928260 100644
--- a/app/models/preview_card.rb
+++ b/app/models/preview_card.rb
@@ -23,19 +23,25 @@
 #  updated_at                   :datetime         not null
 #  embed_url                    :string           default(""), not null
 #  image_storage_schema_version :integer
+#  blurhash                     :string
 #
 
 class PreviewCard < ApplicationRecord
   IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif'].freeze
   LIMIT = 1.megabytes
 
+  BLURHASH_OPTIONS = {
+    x_comp: 4,
+    y_comp: 4,
+  }.freeze
+
   self.inheritance_column = false
 
   enum type: [:link, :photo, :video, :rich]
 
   has_and_belongs_to_many :statuses
 
-  has_attached_file :image, styles: ->(f) { image_styles(f) }, convert_options: { all: '-quality 80 -strip' }
+  has_attached_file :image, processors: [:thumbnail, :blurhash_transcoder], styles: ->(f) { image_styles(f) }, convert_options: { all: '-quality 80 -strip' }
 
   include Attachmentable
 
@@ -72,6 +78,7 @@ class PreviewCard < ApplicationRecord
           geometry: '400x400>',
           file_geometry_parser: FastGeometryParser,
           convert_options: '-coalesce -strip',
+          blurhash: BLURHASH_OPTIONS,
         },
       }
 
diff --git a/app/models/system_key.rb b/app/models/system_key.rb
new file mode 100644
index 000000000..f17db7c2d
--- /dev/null
+++ b/app/models/system_key.rb
@@ -0,0 +1,41 @@
+# frozen_string_literal: true
+
+# == Schema Information
+#
+# Table name: system_keys
+#
+#  id         :bigint(8)        not null, primary key
+#  key        :binary
+#  created_at :datetime         not null
+#  updated_at :datetime         not null
+#
+class SystemKey < ApplicationRecord
+  ROTATION_PERIOD = 1.week.freeze
+
+  before_validation :set_key
+
+  scope :expired, ->(now = Time.now.utc) { where(arel_table[:created_at].lt(now - ROTATION_PERIOD * 3)) }
+
+  class << self
+    def current_key
+      previous_key = order(id: :asc).last
+
+      if previous_key && previous_key.created_at >= ROTATION_PERIOD.ago
+        previous_key.key
+      else
+        create.key
+      end
+    end
+  end
+
+  private
+
+  def set_key
+    return if key.present?
+
+    cipher = OpenSSL::Cipher.new('AES-256-GCM')
+    cipher.encrypt
+
+    self.key = cipher.random_key
+  end
+end
diff --git a/app/models/user.rb b/app/models/user.rb
index c8dbd2fd3..a05d98d88 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -38,6 +38,8 @@
 #  chosen_languages          :string           is an Array
 #  created_by_application_id :bigint(8)
 #  approved                  :boolean          default(TRUE), not null
+#  sign_in_token             :string
+#  sign_in_token_sent_at     :datetime
 #
 
 class User < ApplicationRecord
@@ -114,7 +116,7 @@ class User < ApplicationRecord
            :default_content_type, :system_emoji_font,
            to: :settings, prefix: :setting, allow_nil: false
 
-  attr_reader :invite_code
+  attr_reader :invite_code, :sign_in_token_attempt
   attr_writer :external
 
   def confirmed?
@@ -168,6 +170,10 @@ class User < ApplicationRecord
     true
   end
 
+  def suspicious_sign_in?(ip)
+    !otp_required_for_login? && current_sign_in_at.present? && current_sign_in_at < 2.weeks.ago && !recent_ip?(ip)
+  end
+
   def functional?
     confirmed? && approved? && !disabled? && !account.suspended?
   end
@@ -270,6 +276,13 @@ class User < ApplicationRecord
     super
   end
 
+  def external_or_valid_password?(compare_password)
+    # If encrypted_password is blank, we got the user from LDAP or PAM,
+    # so credentials are already valid
+
+    encrypted_password.blank? || valid_password?(compare_password)
+  end
+
   def send_reset_password_instructions
     return false if encrypted_password.blank?
 
@@ -305,6 +318,15 @@ class User < ApplicationRecord
     end
   end
 
+  def sign_in_token_expired?
+    sign_in_token_sent_at.nil? || sign_in_token_sent_at < 5.minutes.ago
+  end
+
+  def generate_sign_in_token
+    self.sign_in_token         = Devise.friendly_token(6)
+    self.sign_in_token_sent_at = Time.now.utc
+  end
+
   protected
 
   def send_devise_notification(notification, *args)
@@ -321,6 +343,10 @@ class User < ApplicationRecord
 
   private
 
+  def recent_ip?(ip)
+    recent_ips.any? { |(_, recent_ip)| recent_ip == ip }
+  end
+
   def send_pending_devise_notifications
     pending_devise_notifications.each do |notification, args|
       render_and_send_devise_message(notification, *args)
diff --git a/app/presenters/activitypub/activity_presenter.rb b/app/presenters/activitypub/activity_presenter.rb
new file mode 100644
index 000000000..5d174767f
--- /dev/null
+++ b/app/presenters/activitypub/activity_presenter.rb
@@ -0,0 +1,41 @@
+# frozen_string_literal: true
+
+class ActivityPub::ActivityPresenter < ActiveModelSerializers::Model
+  attributes :id, :type, :actor, :published, :to, :cc, :virtual_object
+
+  class << self
+    def from_status(status)
+      new.tap do |presenter|
+        presenter.id        = ActivityPub::TagManager.instance.activity_uri_for(status)
+        presenter.type      = status.reblog? ? 'Announce' : 'Create'
+        presenter.actor     = ActivityPub::TagManager.instance.uri_for(status.account)
+        presenter.published = status.created_at
+        presenter.to        = ActivityPub::TagManager.instance.to(status)
+        presenter.cc        = ActivityPub::TagManager.instance.cc(status)
+
+        presenter.virtual_object = begin
+          if status.reblog?
+            if status.account == status.proper.account && status.proper.private_visibility? && status.local?
+              status.proper
+            else
+              ActivityPub::TagManager.instance.uri_for(status.proper)
+            end
+          else
+            status.proper
+          end
+        end
+      end
+    end
+
+    def from_encrypted_message(encrypted_message)
+      new.tap do |presenter|
+        presenter.id = ActivityPub::TagManager.instance.generate_uri_for(nil)
+        presenter.type = 'Create'
+        presenter.actor = ActivityPub::TagManager.instance.uri_for(encrypted_message.source_account)
+        presenter.published = Time.now.utc
+        presenter.to = ActivityPub::TagManager.instance.uri_for(encrypted_message.target_account)
+        presenter.virtual_object = encrypted_message
+      end
+    end
+  end
+end
diff --git a/app/presenters/initial_state_presenter.rb b/app/presenters/initial_state_presenter.rb
index 70c496be8..06482935c 100644
--- a/app/presenters/initial_state_presenter.rb
+++ b/app/presenters/initial_state_presenter.rb
@@ -2,5 +2,5 @@
 
 class InitialStatePresenter < ActiveModelSerializers::Model
   attributes :settings, :push_subscription, :token,
-             :current_account, :admin, :text
+             :current_account, :admin, :text, :visibility
 end
diff --git a/app/serializers/activitypub/activity_serializer.rb b/app/serializers/activitypub/activity_serializer.rb
index d0edad786..5bdf53f03 100644
--- a/app/serializers/activitypub/activity_serializer.rb
+++ b/app/serializers/activitypub/activity_serializer.rb
@@ -1,52 +1,22 @@
 # frozen_string_literal: true
 
 class ActivityPub::ActivitySerializer < ActivityPub::Serializer
-  attributes :id, :type, :actor, :published, :to, :cc
-
-  has_one :proper, key: :object, serializer: ActivityPub::NoteSerializer, if: :serialize_object?
-
-  attribute :proper_uri, key: :object, unless: :serialize_object?
-  attribute :atom_uri, if: :announce?
-
-  def id
-    ActivityPub::TagManager.instance.activity_uri_for(object)
+  def self.serializer_for(model, options)
+    case model.class.name
+    when 'Status'
+      ActivityPub::NoteSerializer
+    when 'DeliverToDeviceService::EncryptedMessage'
+      ActivityPub::EncryptedMessageSerializer
+    else
+      super
+    end
   end
 
-  def type
-    announce? ? 'Announce' : 'Create'
-  end
+  attributes :id, :type, :actor, :published, :to, :cc
 
-  def actor
-    ActivityPub::TagManager.instance.uri_for(object.account)
-  end
+  has_one :virtual_object, key: :object
 
   def published
-    object.created_at.iso8601
-  end
-
-  def to
-    ActivityPub::TagManager.instance.to(object)
-  end
-
-  def cc
-    ActivityPub::TagManager.instance.cc(object)
-  end
-
-  def proper_uri
-    ActivityPub::TagManager.instance.uri_for(object.proper)
-  end
-
-  def atom_uri
-    OStatus::TagManager.instance.uri_for(object)
-  end
-
-  def announce?
-    object.reblog?
-  end
-
-  def serialize_object?
-    return true unless announce?
-    # Serialize private self-boosts of local toots
-    object.account == object.proper.account && object.proper.private_visibility? && object.local?
+    object.published.iso8601
   end
 end
diff --git a/app/serializers/activitypub/actor_serializer.rb b/app/serializers/activitypub/actor_serializer.rb
index aa64936a7..627d4446b 100644
--- a/app/serializers/activitypub/actor_serializer.rb
+++ b/app/serializers/activitypub/actor_serializer.rb
@@ -7,7 +7,7 @@ class ActivityPub::ActorSerializer < ActivityPub::Serializer
 
   context_extensions :manually_approves_followers, :featured, :also_known_as,
                      :moved_to, :property_value, :identity_proof,
-                     :discoverable
+                     :discoverable, :olm
 
   attributes :id, :type, :following, :followers,
              :inbox, :outbox, :featured,
@@ -20,6 +20,7 @@ class ActivityPub::ActorSerializer < ActivityPub::Serializer
   has_many :virtual_tags, key: :tag
   has_many :virtual_attachments, key: :attachment
 
+  attribute :devices, unless: :instance_actor?
   attribute :moved_to, if: :moved?
   attribute :also_known_as, if: :also_known_as?
 
@@ -38,7 +39,7 @@ class ActivityPub::ActorSerializer < ActivityPub::Serializer
   has_one :icon,  serializer: ActivityPub::ImageSerializer, if: :avatar_exists?
   has_one :image, serializer: ActivityPub::ImageSerializer, if: :header_exists?
 
-  delegate :moved?, to: :object
+  delegate :moved?, :instance_actor?, to: :object
 
   def id
     object.instance_actor? ? instance_actor_url : account_url(object)
@@ -68,6 +69,10 @@ class ActivityPub::ActorSerializer < ActivityPub::Serializer
     object.instance_actor? ? instance_actor_inbox_url : account_inbox_url(object)
   end
 
+  def devices
+    account_collection_url(object, :devices)
+  end
+
   def outbox
     account_outbox_url(object)
   end
diff --git a/app/serializers/activitypub/collection_serializer.rb b/app/serializers/activitypub/collection_serializer.rb
index da1ba735f..ea7af5433 100644
--- a/app/serializers/activitypub/collection_serializer.rb
+++ b/app/serializers/activitypub/collection_serializer.rb
@@ -1,10 +1,28 @@
 # frozen_string_literal: true
 
 class ActivityPub::CollectionSerializer < ActivityPub::Serializer
+  class StringSerializer < ActiveModel::Serializer
+    # Despite the name, it does not return a hash, but the same can be said of
+    # the ActiveModel::Serializer::CollectionSerializer class which handles
+    # arrays.
+    def serializable_hash(*_args)
+      object
+    end
+  end
+
   def self.serializer_for(model, options)
-    return ActivityPub::NoteSerializer if model.class.name == 'Status'
-    return ActivityPub::CollectionSerializer if model.class.name == 'ActivityPub::CollectionPresenter'
-    super
+    case model.class.name
+    when 'Status'
+      ActivityPub::NoteSerializer
+    when 'Device'
+      ActivityPub::DeviceSerializer
+    when 'ActivityPub::CollectionPresenter'
+      ActivityPub::CollectionSerializer
+    when 'String'
+      StringSerializer
+    else
+      super
+    end
   end
 
   attribute :id, if: -> { object.id.present? }
diff --git a/app/serializers/activitypub/device_serializer.rb b/app/serializers/activitypub/device_serializer.rb
new file mode 100644
index 000000000..5f0fdc8af
--- /dev/null
+++ b/app/serializers/activitypub/device_serializer.rb
@@ -0,0 +1,52 @@
+# frozen_string_literal: true
+
+class ActivityPub::DeviceSerializer < ActivityPub::Serializer
+  context_extensions :olm
+
+  include RoutingHelper
+
+  class FingerprintKeySerializer < ActivityPub::Serializer
+    attributes :type, :public_key_base64
+
+    def type
+      'Ed25519Key'
+    end
+
+    def public_key_base64
+      object.fingerprint_key
+    end
+  end
+
+  class IdentityKeySerializer < ActivityPub::Serializer
+    attributes :type, :public_key_base64
+
+    def type
+      'Curve25519Key'
+    end
+
+    def public_key_base64
+      object.identity_key
+    end
+  end
+
+  attributes :device_id, :type, :name, :claim
+
+  has_one :fingerprint_key, serializer: FingerprintKeySerializer
+  has_one :identity_key, serializer: IdentityKeySerializer
+
+  def type
+    'Device'
+  end
+
+  def claim
+    account_claim_url(object.account, id: object.device_id)
+  end
+
+  def fingerprint_key
+    object
+  end
+
+  def identity_key
+    object
+  end
+end
diff --git a/app/serializers/activitypub/encrypted_message_serializer.rb b/app/serializers/activitypub/encrypted_message_serializer.rb
new file mode 100644
index 000000000..3c525d23e
--- /dev/null
+++ b/app/serializers/activitypub/encrypted_message_serializer.rb
@@ -0,0 +1,61 @@
+# frozen_string_literal: true
+
+class ActivityPub::EncryptedMessageSerializer < ActivityPub::Serializer
+  context :security
+
+  context_extensions :olm
+
+  class DeviceSerializer < ActivityPub::Serializer
+    attributes :type, :device_id
+
+    def type
+      'Device'
+    end
+
+    def device_id
+      object
+    end
+  end
+
+  class DigestSerializer < ActivityPub::Serializer
+    attributes :type, :digest_algorithm, :digest_value
+
+    def type
+      'Digest'
+    end
+
+    def digest_algorithm
+      'http://www.w3.org/2000/09/xmldsig#hmac-sha256'
+    end
+
+    def digest_value
+      object
+    end
+  end
+
+  attributes :type, :message_type, :cipher_text, :message_franking
+
+  has_one :attributed_to, serializer: DeviceSerializer
+  has_one :to, serializer: DeviceSerializer
+  has_one :digest, serializer: DigestSerializer
+
+  def type
+    'EncryptedMessage'
+  end
+
+  def attributed_to
+    object.source_device.device_id
+  end
+
+  def to
+    object.target_device_id
+  end
+
+  def message_type
+    object.type
+  end
+
+  def cipher_text
+    object.body
+  end
+end
diff --git a/app/serializers/activitypub/one_time_key_serializer.rb b/app/serializers/activitypub/one_time_key_serializer.rb
new file mode 100644
index 000000000..5932eb5b5
--- /dev/null
+++ b/app/serializers/activitypub/one_time_key_serializer.rb
@@ -0,0 +1,35 @@
+# frozen_string_literal: true
+
+class ActivityPub::OneTimeKeySerializer < ActivityPub::Serializer
+  context :security
+
+  context_extensions :olm
+
+  class SignatureSerializer < ActivityPub::Serializer
+    attributes :type, :signature_value
+
+    def type
+      'Ed25519Signature'
+    end
+
+    def signature_value
+      object.signature
+    end
+  end
+
+  attributes :key_id, :type, :public_key_base64
+
+  has_one :signature, serializer: SignatureSerializer
+
+  def type
+    'Curve25519Key'
+  end
+
+  def public_key_base64
+    object.key
+  end
+
+  def signature
+    object
+  end
+end
diff --git a/app/serializers/activitypub/outbox_serializer.rb b/app/serializers/activitypub/outbox_serializer.rb
index 48fbad0fd..4f4f950a5 100644
--- a/app/serializers/activitypub/outbox_serializer.rb
+++ b/app/serializers/activitypub/outbox_serializer.rb
@@ -2,7 +2,14 @@
 
 class ActivityPub::OutboxSerializer < ActivityPub::CollectionSerializer
   def self.serializer_for(model, options)
-    return ActivityPub::ActivitySerializer if model.is_a?(Status)
-    super
+    if model.class.name == 'ActivityPub::ActivityPresenter'
+      ActivityPub::ActivitySerializer
+    else
+      super
+    end
+  end
+
+  def items
+    object.items.map { |status| ActivityPub::ActivityPresenter.from_status(status) }
   end
 end
diff --git a/app/serializers/activitypub/undo_announce_serializer.rb b/app/serializers/activitypub/undo_announce_serializer.rb
index 6758af679..a925efc18 100644
--- a/app/serializers/activitypub/undo_announce_serializer.rb
+++ b/app/serializers/activitypub/undo_announce_serializer.rb
@@ -3,7 +3,7 @@
 class ActivityPub::UndoAnnounceSerializer < ActivityPub::Serializer
   attributes :id, :type, :actor, :to
 
-  has_one :object, serializer: ActivityPub::ActivitySerializer
+  has_one :virtual_object, key: :object, serializer: ActivityPub::ActivitySerializer
 
   def id
     [ActivityPub::TagManager.instance.uri_for(object.account), '#announces/', object.id, '/undo'].join
@@ -20,4 +20,8 @@ class ActivityPub::UndoAnnounceSerializer < ActivityPub::Serializer
   def to
     [ActivityPub::TagManager::COLLECTIONS[:public]]
   end
+
+  def virtual_object
+    ActivityPub::ActivityPresenter.from_status(object)
+  end
 end
diff --git a/app/serializers/initial_state_serializer.rb b/app/serializers/initial_state_serializer.rb
index e5e88c200..c520c9bcb 100644
--- a/app/serializers/initial_state_serializer.rb
+++ b/app/serializers/initial_state_serializer.rb
@@ -72,7 +72,7 @@ class InitialStateSerializer < ActiveModel::Serializer
 
     if object.current_account
       store[:me]                = object.current_account.id.to_s
-      store[:default_privacy]   = object.current_account.user.setting_default_privacy
+      store[:default_privacy]   = object.visibility || object.current_account.user.setting_default_privacy
       store[:default_sensitive] = object.current_account.user.setting_default_sensitive
     end
 
diff --git a/app/serializers/rest/encrypted_message_serializer.rb b/app/serializers/rest/encrypted_message_serializer.rb
new file mode 100644
index 000000000..80c26d060
--- /dev/null
+++ b/app/serializers/rest/encrypted_message_serializer.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+class REST::EncryptedMessageSerializer < ActiveModel::Serializer
+  attributes :id, :account_id, :device_id,
+             :type, :body, :digest, :message_franking,
+             :created_at
+
+  def id
+    object.id.to_s
+  end
+
+  def account_id
+    object.from_account_id.to_s
+  end
+
+  def device_id
+    object.from_device_id
+  end
+end
diff --git a/app/serializers/rest/keys/claim_result_serializer.rb b/app/serializers/rest/keys/claim_result_serializer.rb
new file mode 100644
index 000000000..145044f55
--- /dev/null
+++ b/app/serializers/rest/keys/claim_result_serializer.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+class REST::Keys::ClaimResultSerializer < ActiveModel::Serializer
+  attributes :account_id, :device_id, :key_id, :key, :signature
+
+  def account_id
+    object.account.id.to_s
+  end
+end
diff --git a/app/serializers/rest/keys/device_serializer.rb b/app/serializers/rest/keys/device_serializer.rb
new file mode 100644
index 000000000..f9b821b79
--- /dev/null
+++ b/app/serializers/rest/keys/device_serializer.rb
@@ -0,0 +1,6 @@
+# frozen_string_literal: true
+
+class REST::Keys::DeviceSerializer < ActiveModel::Serializer
+  attributes :device_id, :name, :identity_key,
+             :fingerprint_key
+end
diff --git a/app/serializers/rest/keys/query_result_serializer.rb b/app/serializers/rest/keys/query_result_serializer.rb
new file mode 100644
index 000000000..8f8bdde28
--- /dev/null
+++ b/app/serializers/rest/keys/query_result_serializer.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+class REST::Keys::QueryResultSerializer < ActiveModel::Serializer
+  attributes :account_id
+
+  has_many :devices, serializer: REST::Keys::DeviceSerializer
+
+  def account_id
+    object.account.id.to_s
+  end
+end
diff --git a/app/serializers/rest/preview_card_serializer.rb b/app/serializers/rest/preview_card_serializer.rb
index 2df9d07a7..66ff47d22 100644
--- a/app/serializers/rest/preview_card_serializer.rb
+++ b/app/serializers/rest/preview_card_serializer.rb
@@ -6,7 +6,7 @@ class REST::PreviewCardSerializer < ActiveModel::Serializer
   attributes :url, :title, :description, :type,
              :author_name, :author_url, :provider_name,
              :provider_url, :html, :width, :height,
-             :image, :embed_url
+             :image, :embed_url, :blurhash
 
   def image
     object.image? ? full_asset_url(object.image.url(:original)) : nil
diff --git a/app/services/activitypub/process_account_service.rb b/app/services/activitypub/process_account_service.rb
index 7b4c53d50..f4276cece 100644
--- a/app/services/activitypub/process_account_service.rb
+++ b/app/services/activitypub/process_account_service.rb
@@ -76,6 +76,7 @@ class ActivityPub::ProcessAccountService < BaseService
     @account.shared_inbox_url        = (@json['endpoints'].is_a?(Hash) ? @json['endpoints']['sharedInbox'] : @json['sharedInbox']) || ''
     @account.followers_url           = @json['followers'] || ''
     @account.featured_collection_url = @json['featured'] || ''
+    @account.devices_url             = @json['devices'] || ''
     @account.url                     = url || @uri
     @account.uri                     = @uri
     @account.display_name            = @json['name'] || ''
diff --git a/app/services/backup_service.rb b/app/services/backup_service.rb
index 989fd6784..749c84736 100644
--- a/app/services/backup_service.rb
+++ b/app/services/backup_service.rb
@@ -22,7 +22,7 @@ class BackupService < BaseService
 
     account.statuses.with_includes.reorder(nil).find_in_batches do |statuses|
       statuses.each do |status|
-        item = serialize_payload(status, ActivityPub::ActivitySerializer, signer: @account, allow_local_only: true)
+        item = serialize_payload(ActivityPub::ActivityPresenter.from_status(status), ActivityPub::ActivitySerializer, signer: @account, allow_local_only: true)
         item.delete(:'@context')
 
         unless item[:type] == 'Announce' || item[:object][:attachment].blank?
diff --git a/app/services/block_domain_service.rb b/app/services/block_domain_service.rb
index 9f0860674..dc23ef8d8 100644
--- a/app/services/block_domain_service.rb
+++ b/app/services/block_domain_service.rb
@@ -26,59 +26,20 @@ class BlockDomainService < BaseService
       suspend_accounts!
     end
 
-    clear_media! if domain_block.reject_media?
-  end
-
-  def invalidate_association_caches!
-    # Normally, associated models of a status are immutable (except for accounts)
-    # so they are aggressively cached. After updating the media attachments to no
-    # longer point to a local file, we need to clear the cache to make those
-    # changes appear in the API and UI
-    @affected_status_ids.each { |id| Rails.cache.delete_matched("statuses/#{id}-*") }
+    DomainClearMediaWorker.perform_async(domain_block.id) if domain_block.reject_media?
   end
 
   def silence_accounts!
     blocked_domain_accounts.without_silenced.in_batches.update_all(silenced_at: @domain_block.created_at)
   end
 
-  def clear_media!
-    @affected_status_ids = []
-
-    clear_account_images!
-    clear_account_attachments!
-    clear_emojos!
-
-    invalidate_association_caches!
-  end
-
   def suspend_accounts!
-    blocked_domain_accounts.without_suspended.reorder(nil).find_each do |account|
+    blocked_domain_accounts.without_suspended.in_batches.update_all(suspended_at: @domain_block.created_at)
+    blocked_domain_accounts.where(suspended_at: @domain_block.created_at).reorder(nil).find_each do |account|
       SuspendAccountService.new.call(account, reserve_username: true, suspended_at: @domain_block.created_at)
     end
   end
 
-  def clear_account_images!
-    blocked_domain_accounts.reorder(nil).find_each do |account|
-      account.avatar.destroy if account.avatar.exists?
-      account.header.destroy if account.header.exists?
-      account.save
-    end
-  end
-
-  def clear_account_attachments!
-    media_from_blocked_domain.reorder(nil).find_each do |attachment|
-      @affected_status_ids << attachment.status_id if attachment.status_id.present?
-
-      attachment.file.destroy if attachment.file.exists?
-      attachment.type = :unknown
-      attachment.save
-    end
-  end
-
-  def clear_emojos!
-    emojis_from_blocked_domains.destroy_all
-  end
-
   def blocked_domain
     domain_block.domain
   end
@@ -86,12 +47,4 @@ class BlockDomainService < BaseService
   def blocked_domain_accounts
     Account.by_domain_and_subdomains(blocked_domain)
   end
-
-  def media_from_blocked_domain
-    MediaAttachment.joins(:account).merge(blocked_domain_accounts).reorder(nil)
-  end
-
-  def emojis_from_blocked_domains
-    CustomEmoji.by_domain_and_subdomains(blocked_domain)
-  end
 end
diff --git a/app/services/clear_domain_media_service.rb b/app/services/clear_domain_media_service.rb
new file mode 100644
index 000000000..704cfb71a
--- /dev/null
+++ b/app/services/clear_domain_media_service.rb
@@ -0,0 +1,70 @@
+# frozen_string_literal: true
+
+class ClearDomainMediaService < BaseService
+  attr_reader :domain_block
+
+  def call(domain_block)
+    @domain_block = domain_block
+    clear_media! if domain_block.reject_media?
+  end
+
+  private
+
+  def invalidate_association_caches!
+    # Normally, associated models of a status are immutable (except for accounts)
+    # so they are aggressively cached. After updating the media attachments to no
+    # longer point to a local file, we need to clear the cache to make those
+    # changes appear in the API and UI
+    @affected_status_ids.each { |id| Rails.cache.delete_matched("statuses/#{id}-*") }
+  end
+
+  def clear_media!
+    @affected_status_ids = []
+
+    begin
+      clear_account_images!
+      clear_account_attachments!
+      clear_emojos!
+    ensure
+      invalidate_association_caches!
+    end
+  end
+
+  def clear_account_images!
+    blocked_domain_accounts.reorder(nil).find_each do |account|
+      account.avatar.destroy if account.avatar&.exists?
+      account.header.destroy if account.header&.exists?
+      account.save
+    end
+  end
+
+  def clear_account_attachments!
+    media_from_blocked_domain.reorder(nil).find_each do |attachment|
+      @affected_status_ids << attachment.status_id if attachment.status_id.present?
+
+      attachment.file.destroy if attachment.file&.exists?
+      attachment.type = :unknown
+      attachment.save
+    end
+  end
+
+  def clear_emojos!
+    emojis_from_blocked_domains.destroy_all
+  end
+
+  def blocked_domain
+    domain_block.domain
+  end
+
+  def blocked_domain_accounts
+    Account.by_domain_and_subdomains(blocked_domain)
+  end
+
+  def media_from_blocked_domain
+    MediaAttachment.joins(:account).merge(blocked_domain_accounts).reorder(nil)
+  end
+
+  def emojis_from_blocked_domains
+    CustomEmoji.by_domain_and_subdomains(blocked_domain)
+  end
+end
diff --git a/app/services/concerns/payloadable.rb b/app/services/concerns/payloadable.rb
index 7f9f21c4b..3e45570c3 100644
--- a/app/services/concerns/payloadable.rb
+++ b/app/services/concerns/payloadable.rb
@@ -5,8 +5,9 @@ module Payloadable
     signer    = options.delete(:signer)
     sign_with = options.delete(:sign_with)
     payload   = ActiveModelSerializers::SerializableResource.new(record, options.merge(serializer: serializer, adapter: ActivityPub::Adapter)).as_json
+    object    = record.respond_to?(:virtual_object) ? record.virtual_object : record
 
-    if (record.respond_to?(:sign?) && record.sign?) && signer && signing_enabled?
+    if (object.respond_to?(:sign?) && object.sign?) && signer && signing_enabled?
       ActivityPub::LinkedDataSignature.new(payload).sign!(signer, sign_with: sign_with)
     else
       payload
diff --git a/app/services/deliver_to_device_service.rb b/app/services/deliver_to_device_service.rb
new file mode 100644
index 000000000..71711945c
--- /dev/null
+++ b/app/services/deliver_to_device_service.rb
@@ -0,0 +1,78 @@
+# frozen_string_literal: true
+
+class DeliverToDeviceService < BaseService
+  include Payloadable
+
+  class EncryptedMessage < ActiveModelSerializers::Model
+    attributes :source_account, :target_account, :source_device,
+               :target_device_id, :type, :body, :digest,
+               :message_franking
+  end
+
+  def call(source_account, source_device, options = {})
+    @source_account   = source_account
+    @source_device    = source_device
+    @target_account   = Account.find(options[:account_id])
+    @target_device_id = options[:device_id]
+    @body             = options[:body]
+    @type             = options[:type]
+    @hmac             = options[:hmac]
+
+    set_message_franking!
+
+    if @target_account.local?
+      deliver_to_local!
+    else
+      deliver_to_remote!
+    end
+  end
+
+  private
+
+  def set_message_franking!
+    @message_franking = message_franking.to_token
+  end
+
+  def deliver_to_local!
+    target_device = @target_account.devices.find_by!(device_id: @target_device_id)
+
+    target_device.encrypted_messages.create!(
+      from_account: @source_account,
+      from_device_id: @source_device.device_id,
+      type: @type,
+      body: @body,
+      digest: @hmac,
+      message_franking: @message_franking
+    )
+  end
+
+  def deliver_to_remote!
+    ActivityPub::DeliveryWorker.perform_async(
+      Oj.dump(serialize_payload(ActivityPub::ActivityPresenter.from_encrypted_message(encrypted_message), ActivityPub::ActivitySerializer)),
+      @source_account.id,
+      @target_account.inbox_url
+    )
+  end
+
+  def message_franking
+    MessageFranking.new(
+      source_account_id: @source_account.id,
+      target_account_id: @target_account.id,
+      hmac: @hmac,
+      timestamp: Time.now.utc
+    )
+  end
+
+  def encrypted_message
+    EncryptedMessage.new(
+      source_account: @source_account,
+      target_account: @target_account,
+      source_device: @source_device,
+      target_device_id: @target_device_id,
+      type: @type,
+      body: @body,
+      digest: @hmac,
+      message_franking: @message_franking
+    )
+  end
+end
diff --git a/app/services/import_service.rb b/app/services/import_service.rb
index c0d741d57..4cad93767 100644
--- a/app/services/import_service.rb
+++ b/app/services/import_service.rb
@@ -81,7 +81,9 @@ class ImportService < BaseService
       end
     end
 
-    Import::RelationshipWorker.push_bulk(items) do |acct, extra|
+    head_items = items.uniq { |acct, _| acct.split('@')[1] }
+    tail_items = items - head_items
+    Import::RelationshipWorker.push_bulk(head_items + tail_items) do |acct, extra|
       [@account.id, acct, action, extra]
     end
   end
diff --git a/app/services/keys/claim_service.rb b/app/services/keys/claim_service.rb
new file mode 100644
index 000000000..672119130
--- /dev/null
+++ b/app/services/keys/claim_service.rb
@@ -0,0 +1,77 @@
+# frozen_string_literal: true
+
+class Keys::ClaimService < BaseService
+  HEADERS = { 'Content-Type' => 'application/activity+json' }.freeze
+
+  class Result < ActiveModelSerializers::Model
+    attributes :account, :device_id, :key_id,
+               :key, :signature
+
+    def initialize(account, device_id, key_attributes = {})
+      @account   = account
+      @device_id = device_id
+      @key_id    = key_attributes[:key_id]
+      @key       = key_attributes[:key]
+      @signature = key_attributes[:signature]
+    end
+  end
+
+  def call(source_account, target_account_id, device_id)
+    @source_account = source_account
+    @target_account = Account.find(target_account_id)
+    @device_id      = device_id
+
+    if @target_account.local?
+      claim_local_key!
+    else
+      claim_remote_key!
+    end
+  rescue ActiveRecord::RecordNotFound
+    nil
+  end
+
+  private
+
+  def claim_local_key!
+    device = @target_account.devices.find_by(device_id: @device_id)
+    key    = nil
+
+    ApplicationRecord.transaction do
+      key = device.one_time_keys.order(Arel.sql('random()')).first!
+      key.destroy!
+    end
+
+    @result = Result.new(@target_account, @device_id, key)
+  end
+
+  def claim_remote_key!
+    query_result = QueryService.new.call(@target_account)
+    device       = query_result.find(@device_id)
+
+    return unless device.present? && device.valid_claim_url?
+
+    json = fetch_resource_with_post(device.claim_url)
+
+    return unless json.present? && json['publicKeyBase64'].present?
+
+    @result = Result.new(@target_account, @device_id, key_id: json['id'], key: json['publicKeyBase64'], signature: json.dig('signature', 'signatureValue'))
+  rescue HTTP::Error, OpenSSL::SSL::SSLError, Mastodon::Error => e
+    Rails.logger.debug "Claiming one-time key for #{@target_account.acct}:#{@device_id} failed: #{e}"
+    nil
+  end
+
+  def fetch_resource_with_post(uri)
+    build_post_request(uri).perform do |response|
+      raise Mastodon::UnexpectedResponseError, response unless response_successful?(response) || response_error_unsalvageable?(response)
+
+      body_to_json(response.body_with_limit) if response.code == 200
+    end
+  end
+
+  def build_post_request(uri)
+    Request.new(:post, uri).tap do |request|
+      request.on_behalf_of(@source_account, :uri)
+      request.add_headers(HEADERS)
+    end
+  end
+end
diff --git a/app/services/keys/query_service.rb b/app/services/keys/query_service.rb
new file mode 100644
index 000000000..286fbd834
--- /dev/null
+++ b/app/services/keys/query_service.rb
@@ -0,0 +1,75 @@
+# frozen_string_literal: true
+
+class Keys::QueryService < BaseService
+  include JsonLdHelper
+
+  class Result < ActiveModelSerializers::Model
+    attributes :account, :devices
+
+    def initialize(account, devices)
+      @account = account
+      @devices = devices || []
+    end
+
+    def find(device_id)
+      @devices.find { |device| device.device_id == device_id }
+    end
+  end
+
+  class Device < ActiveModelSerializers::Model
+    attributes :device_id, :name, :identity_key, :fingerprint_key
+
+    def initialize(attributes = {})
+      @device_id       = attributes[:device_id]
+      @name            = attributes[:name]
+      @identity_key    = attributes[:identity_key]
+      @fingerprint_key = attributes[:fingerprint_key]
+      @claim_url       = attributes[:claim_url]
+    end
+
+    def valid_claim_url?
+      return false if @claim_url.blank?
+
+      begin
+        parsed_url = Addressable::URI.parse(@claim_url).normalize
+      rescue Addressable::URI::InvalidURIError
+        return false
+      end
+
+      %w(http https).include?(parsed_url.scheme) && parsed_url.host.present?
+    end
+  end
+
+  def call(account)
+    @account = account
+
+    if @account.local?
+      query_local_devices!
+    else
+      query_remote_devices!
+    end
+
+    Result.new(@account, @devices)
+  end
+
+  private
+
+  def query_local_devices!
+    @devices = @account.devices.map { |device| Device.new(device) }
+  end
+
+  def query_remote_devices!
+    return if @account.devices_url.blank?
+
+    json = fetch_resource(@account.devices_url)
+
+    return if json['items'].blank?
+
+    @devices = json['items'].map do |device|
+      Device.new(device_id: device['id'], name: device['name'], identity_key: device.dig('identityKey', 'publicKeyBase64'), fingerprint_key: device.dig('fingerprintKey', 'publicKeyBase64'), claim_url: device['claim'])
+    end
+  rescue HTTP::Error, OpenSSL::SSL::SSLError, Mastodon::Error => e
+    Rails.logger.debug "Querying devices for #{@account.acct} failed: #{e}"
+    nil
+  end
+end
diff --git a/app/services/process_mentions_service.rb b/app/services/process_mentions_service.rb
index 3c257451c..65a3f64b8 100644
--- a/app/services/process_mentions_service.rb
+++ b/app/services/process_mentions_service.rb
@@ -65,7 +65,7 @@ class ProcessMentionsService < BaseService
 
   def activitypub_json
     return @activitypub_json if defined?(@activitypub_json)
-    @activitypub_json = Oj.dump(serialize_payload(@status, ActivityPub::ActivitySerializer, signer: @status.account))
+    @activitypub_json = Oj.dump(serialize_payload(ActivityPub::ActivityPresenter.from_status(@status), ActivityPub::ActivitySerializer, signer: @status.account))
   end
 
   def resolve_account_service
diff --git a/app/services/reblog_service.rb b/app/services/reblog_service.rb
index 0a46509f8..6cecb5ac4 100644
--- a/app/services/reblog_service.rb
+++ b/app/services/reblog_service.rb
@@ -60,6 +60,6 @@ class ReblogService < BaseService
   end
 
   def build_json(reblog)
-    Oj.dump(serialize_payload(reblog, ActivityPub::ActivitySerializer, signer: reblog.account))
+    Oj.dump(serialize_payload(ActivityPub::ActivityPresenter.from_status(reblog), ActivityPub::ActivitySerializer, signer: reblog.account))
   end
 end
diff --git a/app/services/resolve_account_service.rb b/app/services/resolve_account_service.rb
index 17ace100c..ba77552c6 100644
--- a/app/services/resolve_account_service.rb
+++ b/app/services/resolve_account_service.rb
@@ -112,6 +112,8 @@ class ResolveAccountService < BaseService
   end
 
   def webfinger_update_due?
+    return false if @options[:check_delivery_availability] && !DeliveryFailureTracker.available?(@domain)
+
     @account.nil? || ((!@options[:skip_webfinger] || @account.ostatus?) && @account.possibly_stale?)
   end
 
diff --git a/app/validators/ed25519_key_validator.rb b/app/validators/ed25519_key_validator.rb
new file mode 100644
index 000000000..00a448d5a
--- /dev/null
+++ b/app/validators/ed25519_key_validator.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+class Ed25519KeyValidator < ActiveModel::EachValidator
+  def validate_each(record, attribute, value)
+    return if value.blank?
+
+    key = Base64.decode64(value)
+
+    record.errors[attribute] << I18n.t('crypto.errors.invalid_key') unless verified?(key)
+  end
+
+  private
+
+  def verified?(key)
+    Ed25519.validate_key_bytes(key)
+  rescue ArgumentError
+    false
+  end
+end
diff --git a/app/validators/ed25519_signature_validator.rb b/app/validators/ed25519_signature_validator.rb
new file mode 100644
index 000000000..77a21b837
--- /dev/null
+++ b/app/validators/ed25519_signature_validator.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+class Ed25519SignatureValidator < ActiveModel::EachValidator
+  def validate_each(record, attribute, value)
+    return if value.blank?
+
+    verify_key = Ed25519::VerifyKey.new(Base64.decode64(option_to_value(record, :verify_key)))
+    signature  = Base64.decode64(value)
+    message    = option_to_value(record, :message)
+
+    record.errors[attribute] << I18n.t('crypto.errors.invalid_signature') unless verified?(verify_key, signature, message)
+  end
+
+  private
+
+  def verified?(verify_key, signature, message)
+    verify_key.verify(signature, message)
+  rescue Ed25519::VerifyError, ArgumentError
+    false
+  end
+
+  def option_to_value(record, key)
+    if options[key].is_a?(Proc)
+      options[key].call(record)
+    else
+      record.public_send(options[key])
+    end
+  end
+end
diff --git a/app/views/about/more.html.haml b/app/views/about/more.html.haml
index 03be3f423..0a12ab8d6 100644
--- a/app/views/about/more.html.haml
+++ b/app/views/about/more.html.haml
@@ -55,12 +55,15 @@
             %p= t('about.unavailable_content_html')
 
             - if (blocks = @blocks.select(&:reject_media?)) && !blocks.empty?
+              %h3= t('about.unavailable_content_description.rejecting_media_title')
               %p= t('about.unavailable_content_description.rejecting_media')
               = render partial: 'domain_blocks', locals: { domain_blocks: blocks }
             - if (blocks = @blocks.select(&:silence?)) && !blocks.empty?
+              %h3= t('about.unavailable_content_description.silenced_title')
               %p= t('about.unavailable_content_description.silenced')
               = render partial: 'domain_blocks', locals: { domain_blocks: blocks }
             - if (blocks = @blocks.select(&:suspend?)) && !blocks.empty?
+              %h3= t('about.unavailable_content_description.suspended_title')
               %p= t('about.unavailable_content_description.suspended')
               = render partial: 'domain_blocks', locals: { domain_blocks: blocks }
 
diff --git a/app/views/admin/custom_emojis/index.html.haml b/app/views/admin/custom_emojis/index.html.haml
index d3705a36e..45cb7bee0 100644
--- a/app/views/admin/custom_emojis/index.html.haml
+++ b/app/views/admin/custom_emojis/index.html.haml
@@ -1,8 +1,9 @@
 - content_for :page_title do
   = t('admin.custom_emojis.title')
 
-- content_for :heading_actions do
-  = link_to t('admin.custom_emojis.upload'), new_admin_custom_emoji_path, class: 'button'
+- if can?(:create, :custom_emoji)
+  - content_for :heading_actions do
+    = link_to t('admin.custom_emojis.upload'), new_admin_custom_emoji_path, class: 'button'
 
 .filters
   .filter-subset
@@ -55,9 +56,10 @@
 
         = f.button safe_join([fa_icon('power-off'), t('admin.custom_emojis.disable')]), name: :disable, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') }
 
-        = f.button safe_join([fa_icon('times'), t('admin.custom_emojis.delete')]), name: :delete, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') }
+        - if can?(:destroy, :custom_emoji)
+          = f.button safe_join([fa_icon('times'), t('admin.custom_emojis.delete')]), name: :delete, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') }
 
-        - unless params[:local] == '1'
+        - if can?(:copy, :custom_emoji) && params[:local] != '1'
           = f.button safe_join([fa_icon('copy'), t('admin.custom_emojis.copy')]), name: :copy, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') }
 
     - if params[:local] == '1'
diff --git a/app/views/admin/instances/index.html.haml b/app/views/admin/instances/index.html.haml
index bd67eb4fc..a73b8dc92 100644
--- a/app/views/admin/instances/index.html.haml
+++ b/app/views/admin/instances/index.html.haml
@@ -1,6 +1,12 @@
 - content_for :page_title do
   = t('admin.instances.title')
 
+- content_for :heading_actions do
+  - if whitelist_mode?
+    = link_to t('admin.domain_allows.add_new'), new_admin_domain_allow_path, class: 'button'
+  - else
+    = link_to t('admin.domain_blocks.add_new'), new_admin_domain_block_path, class: 'button'
+
 .filters
   .filter-subset
     %strong= t('admin.instances.moderation.title')
@@ -10,12 +16,6 @@
       - unless whitelist_mode?
         %li= filter_link_to t('admin.instances.moderation.limited'), limited: '1'
 
-  %div.special-action-button
-    - if whitelist_mode?
-      = link_to t('admin.domain_allows.add_new'), new_admin_domain_allow_path, class: 'button'
-    - else
-      = link_to t('admin.domain_blocks.add_new'), new_admin_domain_block_path, class: 'button'
-
 - unless whitelist_mode?
   = form_tag admin_instances_url, method: 'GET', class: 'simple_form' do
     .fields-group
diff --git a/app/views/auth/sessions/sign_in_token.html.haml b/app/views/auth/sessions/sign_in_token.html.haml
new file mode 100644
index 000000000..8923203cd
--- /dev/null
+++ b/app/views/auth/sessions/sign_in_token.html.haml
@@ -0,0 +1,14 @@
+- content_for :page_title do
+  = t('auth.login')
+
+= simple_form_for(resource, as: resource_name, url: session_path(resource_name), method: :post) do |f|
+  %p.hint.otp-hint= t('users.suspicious_sign_in_confirmation')
+
+  .fields-group
+    = f.input :sign_in_token_attempt, type: :number, wrapper: :with_label, label: t('simple_form.labels.defaults.sign_in_token_attempt'), input_html: { 'aria-label' => t('simple_form.labels.defaults.sign_in_token_attempt'), :autocomplete => 'off' }, autofocus: true
+
+  .actions
+    = f.button :button, t('auth.login'), type: :submit
+
+  - if Setting.site_contact_email.present?
+    %p.hint.subtle-hint= t('users.generic_access_help_html', email: mail_to(Setting.site_contact_email, nil))
diff --git a/app/views/statuses/_detailed_status.html.haml b/app/views/statuses/_detailed_status.html.haml
index 33b81c748..8e409846a 100644
--- a/app/views/statuses/_detailed_status.html.haml
+++ b/app/views/statuses/_detailed_status.html.haml
@@ -39,7 +39,7 @@
       = react_component :media_gallery, height: 380, sensitive: status.sensitive?, standalone: true, autoplay: autoplay, media: status.media_attachments.map { |a| ActiveModelSerializers::SerializableResource.new(a, serializer: REST::MediaAttachmentSerializer).as_json } do
         = render partial: 'statuses/attachment_list', locals: { attachments: status.media_attachments }
   - elsif status.preview_card
-    = react_component :card, 'maxDescription': 160, card: ActiveModelSerializers::SerializableResource.new(status.preview_card, serializer: REST::PreviewCardSerializer).as_json
+    = react_component :card, sensitive: status.sensitive?, 'maxDescription': 160, card: ActiveModelSerializers::SerializableResource.new(status.preview_card, serializer: REST::PreviewCardSerializer).as_json
 
   .detailed-status__meta
     %data.dt-published{ value: status.created_at.to_time.iso8601 }
diff --git a/app/views/statuses/_simple_status.html.haml b/app/views/statuses/_simple_status.html.haml
index b7a2b7116..7a0262c9d 100644
--- a/app/views/statuses/_simple_status.html.haml
+++ b/app/views/statuses/_simple_status.html.haml
@@ -43,7 +43,7 @@
       = react_component :media_gallery, height: 343, sensitive: status.sensitive?, autoplay: autoplay, media: status.media_attachments.map { |a| ActiveModelSerializers::SerializableResource.new(a, serializer: REST::MediaAttachmentSerializer).as_json } do
         = render partial: 'statuses/attachment_list', locals: { attachments: status.media_attachments }
   - elsif status.preview_card
-    = react_component :card, 'maxDescription': 160, card: ActiveModelSerializers::SerializableResource.new(status.preview_card, serializer: REST::PreviewCardSerializer).as_json
+    = react_component :card, sensitive: status.sensitive?, 'maxDescription': 160, card: ActiveModelSerializers::SerializableResource.new(status.preview_card, serializer: REST::PreviewCardSerializer).as_json
 
   - if !status.in_reply_to_id.nil? && status.in_reply_to_account_id == status.account.id
     = link_to ActivityPub::TagManager.instance.url_for(status), class: 'status__content__read-more-button', target: stream_link_target, rel: 'noopener noreferrer' do
diff --git a/app/views/user_mailer/sign_in_token.html.haml b/app/views/user_mailer/sign_in_token.html.haml
new file mode 100644
index 000000000..826b34e7c
--- /dev/null
+++ b/app/views/user_mailer/sign_in_token.html.haml
@@ -0,0 +1,105 @@
+%table.email-table{ cellspacing: 0, cellpadding: 0 }
+  %tbody
+    %tr
+      %td.email-body
+        .email-container
+          %table.content-section{ cellspacing: 0, cellpadding: 0 }
+            %tbody
+              %tr
+                %td.content-cell.hero
+                  .email-row
+                    .col-6
+                      %table.column{ cellspacing: 0, cellpadding: 0 }
+                        %tbody
+                          %tr
+                            %td.column-cell.text-center.padded
+                              %table.hero-icon.alert-icon{ align: 'center', cellspacing: 0, cellpadding: 0 }
+                                %tbody
+                                  %tr
+                                    %td
+                                      = image_tag full_pack_url('media/images/mailer/icon_email.png'), alt: ''
+
+                              %h1= t 'user_mailer.sign_in_token.title'
+                              %p.lead= t 'user_mailer.sign_in_token.explanation'
+
+%table.email-table{ cellspacing: 0, cellpadding: 0 }
+  %tbody
+    %tr
+      %td.email-body
+        .email-container
+          %table.content-section{ cellspacing: 0, cellpadding: 0 }
+            %tbody
+              %tr
+                %td.content-cell.content-start
+                  %table.column{ cellspacing: 0, cellpadding: 0 }
+                    %tbody
+                      %tr
+                        %td.column-cell.input-cell
+                          %table.input{ align: 'center', cellspacing: 0, cellpadding: 0 }
+                            %tbody
+                              %tr
+                                %td= @resource.sign_in_token
+
+%table.email-table{ cellspacing: 0, cellpadding: 0 }
+  %tbody
+    %tr
+      %td.email-body
+        .email-container
+          %table.content-section{ cellspacing: 0, cellpadding: 0 }
+            %tbody
+              %tr
+                %td.content-cell
+                  .email-row
+                    .col-6
+                      %table.column{ cellspacing: 0, cellpadding: 0 }
+                        %tbody
+                          %tr
+                            %td.column-cell.text-center
+                              %p= t 'user_mailer.sign_in_token.details'
+                          %tr
+                            %td.column-cell.text-center
+                              %p
+                                %strong= "#{t('sessions.ip')}:"
+                                = @remote_ip
+                                %br/
+                                %strong= "#{t('sessions.browser')}:"
+                                %span{ title: @user_agent }= t 'sessions.description', browser: t("sessions.browsers.#{@detection.id}", default: "#{@detection.id}"), platform: t("sessions.platforms.#{@detection.platform.id}", default: "#{@detection.platform.id}")
+                                %br/
+                                = l(@timestamp)
+
+%table.email-table{ cellspacing: 0, cellpadding: 0 }
+  %tbody
+    %tr
+      %td.email-body
+        .email-container
+          %table.content-section{ cellspacing: 0, cellpadding: 0 }
+            %tbody
+              %tr
+                %td.content-cell
+                  .email-row
+                    .col-6
+                      %table.column{ cellspacing: 0, cellpadding: 0 }
+                        %tbody
+                          %tr
+                            %td.column-cell.text-center
+                              %p= t 'user_mailer.sign_in_token.further_actions'
+
+%table.email-table{ cellspacing: 0, cellpadding: 0 }
+  %tbody
+    %tr
+      %td.email-body
+        .email-container
+          %table.content-section{ cellspacing: 0, cellpadding: 0 }
+            %tbody
+              %tr
+                %td.content-cell
+                  %table.column{ cellspacing: 0, cellpadding: 0 }
+                    %tbody
+                      %tr
+                        %td.column-cell.button-cell
+                          %table.button{ align: 'center', cellspacing: 0, cellpadding: 0 }
+                            %tbody
+                              %tr
+                                %td.button-primary
+                                  = link_to edit_user_registration_url do
+                                    %span= t 'settings.account_settings'
diff --git a/app/views/user_mailer/sign_in_token.text.erb b/app/views/user_mailer/sign_in_token.text.erb
new file mode 100644
index 000000000..2539ddaf6
--- /dev/null
+++ b/app/views/user_mailer/sign_in_token.text.erb
@@ -0,0 +1,17 @@
+<%= t 'user_mailer.sign_in_token.title' %>
+
+===
+
+<%= t 'user_mailer.sign_in_token.explanation' %>
+
+=> <%= @resource.sign_in_token %>
+
+<%= t 'user_mailer.sign_in_token.details' %>
+
+<%= t('sessions.ip') %>: <%= @remote_ip %>
+<%= t('sessions.browser') %>: <%= t('sessions.description', browser: t("sessions.browsers.#{@detection.id}", default: "#{@detection.id}"), platform: t("sessions.platforms.#{@detection.platform.id}", default: "#{@detection.platform.id}")) %>
+<%= l(@timestamp) %>
+
+<%= t 'user_mailer.sign_in_token.further_actions' %>
+
+=> <%= edit_user_registration_url %>
diff --git a/app/workers/activitypub/distribution_worker.rb b/app/workers/activitypub/distribution_worker.rb
index 11b6a6111..e4997ba0e 100644
--- a/app/workers/activitypub/distribution_worker.rb
+++ b/app/workers/activitypub/distribution_worker.rb
@@ -43,7 +43,7 @@ class ActivityPub::DistributionWorker
   end
 
   def payload
-    @payload ||= Oj.dump(serialize_payload(@status, ActivityPub::ActivitySerializer, signer: @account))
+    @payload ||= Oj.dump(serialize_payload(ActivityPub::ActivityPresenter.from_status(@status), ActivityPub::ActivitySerializer, signer: @account))
   end
 
   def relay!
diff --git a/app/workers/activitypub/reply_distribution_worker.rb b/app/workers/activitypub/reply_distribution_worker.rb
index 1ff8a657e..d4d0148ac 100644
--- a/app/workers/activitypub/reply_distribution_worker.rb
+++ b/app/workers/activitypub/reply_distribution_worker.rb
@@ -29,6 +29,6 @@ class ActivityPub::ReplyDistributionWorker
   end
 
   def payload
-    @payload ||= Oj.dump(serialize_payload(@status, ActivityPub::ActivitySerializer, signer: @status.account))
+    @payload ||= Oj.dump(serialize_payload(ActivityPub::ActivityPresenter.from_status(@status), ActivityPub::ActivitySerializer, signer: @status.account))
   end
 end
diff --git a/app/workers/domain_block_worker.rb b/app/workers/domain_block_worker.rb
index 35518d6b5..3c601cd83 100644
--- a/app/workers/domain_block_worker.rb
+++ b/app/workers/domain_block_worker.rb
@@ -4,8 +4,9 @@ class DomainBlockWorker
   include Sidekiq::Worker
 
   def perform(domain_block_id, update = false)
-    BlockDomainService.new.call(DomainBlock.find(domain_block_id), update)
-  rescue ActiveRecord::RecordNotFound
-    true
+    domain_block = DomainBlock.find_by(id: domain_block_id)
+    return true if domain_block.nil?
+
+    BlockDomainService.new.call(domain_block, update)
   end
 end
diff --git a/app/workers/domain_clear_media_worker.rb b/app/workers/domain_clear_media_worker.rb
new file mode 100644
index 000000000..971934a08
--- /dev/null
+++ b/app/workers/domain_clear_media_worker.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+class DomainClearMediaWorker
+  include Sidekiq::Worker
+
+  sidekiq_options queue: 'pull'
+
+  def perform(domain_block_id)
+    domain_block = DomainBlock.find_by(id: domain_block_id)
+    return true if domain_block.nil?
+
+    ClearDomainMediaService.new.call(domain_block)
+  end
+end
diff --git a/app/workers/import/relationship_worker.rb b/app/workers/import/relationship_worker.rb
index 616da6da9..4a455f3ae 100644
--- a/app/workers/import/relationship_worker.rb
+++ b/app/workers/import/relationship_worker.rb
@@ -7,7 +7,8 @@ class Import::RelationshipWorker
 
   def perform(account_id, target_account_uri, relationship, options = {})
     from_account   = Account.find(account_id)
-    target_account = ResolveAccountService.new.call(target_account_uri)
+    target_domain  = domain(target_account_uri)
+    target_account = stoplight_wrap_request(target_domain) { ResolveAccountService.new.call(target_account_uri, { check_delivery_availability: true }) }
     options.symbolize_keys!
 
     return if target_account.nil?
@@ -29,4 +30,22 @@ class Import::RelationshipWorker
   rescue ActiveRecord::RecordNotFound
     true
   end
+
+  def domain(uri)
+    domain = uri.is_a?(Account) ? uri.domain : uri.split('@')[1]
+    TagManager.instance.local_domain?(domain) ? nil : TagManager.instance.normalize_domain(domain)
+  end
+
+  def stoplight_wrap_request(domain, &block)
+    if domain.present?
+      Stoplight("source:#{domain}", &block)
+        .with_fallback { nil }
+        .with_threshold(1)
+        .with_cool_off_time(5.minutes.seconds)
+        .with_error_handler { |error, handle| error.is_a?(HTTP::Error) || error.is_a?(OpenSSL::SSL::SSLError) ? handle.call(error) : raise(error) }
+        .run
+    else
+      block.call
+    end
+  end
 end
diff --git a/app/workers/push_conversation_worker.rb b/app/workers/push_conversation_worker.rb
index 16f538215..aa858f715 100644
--- a/app/workers/push_conversation_worker.rb
+++ b/app/workers/push_conversation_worker.rb
@@ -2,13 +2,14 @@
 
 class PushConversationWorker
   include Sidekiq::Worker
+  include Redisable
 
   def perform(conversation_account_id)
     conversation = AccountConversation.find(conversation_account_id)
     message      = InlineRenderer.render(conversation, conversation.account, :conversation)
     timeline_id  = "timeline:direct:#{conversation.account_id}"
 
-    Redis.current.publish(timeline_id, Oj.dump(event: :conversation, payload: message, queued_at: (Time.now.to_f * 1000.0).to_i))
+    redis.publish(timeline_id, Oj.dump(event: :conversation, payload: message, queued_at: (Time.now.to_f * 1000.0).to_i))
   rescue ActiveRecord::RecordNotFound
     true
   end
diff --git a/app/workers/push_encrypted_message_worker.rb b/app/workers/push_encrypted_message_worker.rb
new file mode 100644
index 000000000..031230172
--- /dev/null
+++ b/app/workers/push_encrypted_message_worker.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+class PushEncryptedMessageWorker
+  include Sidekiq::Worker
+  include Redisable
+
+  def perform(encrypted_message_id)
+    encrypted_message = EncryptedMessage.find(encrypted_message_id)
+    message           = InlineRenderer.render(encrypted_message, nil, :encrypted_message)
+    timeline_id       = "timeline:#{encrypted_message.device.account_id}:#{encrypted_message.device.device_id}"
+
+    redis.publish(timeline_id, Oj.dump(event: :encrypted_message, payload: message, queued_at: (Time.now.to_f * 1000.0).to_i))
+  rescue ActiveRecord::RecordNotFound
+    true
+  end
+end
diff --git a/app/workers/scheduler/doorkeeper_cleanup_scheduler.rb b/app/workers/scheduler/doorkeeper_cleanup_scheduler.rb
index 94788a85b..bb9dd49ca 100644
--- a/app/workers/scheduler/doorkeeper_cleanup_scheduler.rb
+++ b/app/workers/scheduler/doorkeeper_cleanup_scheduler.rb
@@ -8,5 +8,6 @@ class Scheduler::DoorkeeperCleanupScheduler
   def perform
     Doorkeeper::AccessToken.where('revoked_at IS NOT NULL').where('revoked_at < NOW()').delete_all
     Doorkeeper::AccessGrant.where('revoked_at IS NOT NULL').where('revoked_at < NOW()').delete_all
+    SystemKey.expired.delete_all
   end
 end