about summary refs log tree commit diff
path: root/app
diff options
context:
space:
mode:
authorkibigo! <marrus-sh@users.noreply.github.com>2017-10-11 10:43:10 -0700
committerkibigo! <marrus-sh@users.noreply.github.com>2017-10-11 10:43:10 -0700
commit8d6b9ba4946b5b159af0fbd130637a226a286796 (patch)
tree9def26711682d29338cfa1b081822029a01669eb /app
parentf0a2a6c875e9294f0ea1d4c6bc90529e41a2dc37 (diff)
parent476e79b8e340c9103352a0799e102e4aca1a5593 (diff)
Merge upstream 2.0ish #165
Diffstat (limited to 'app')
-rw-r--r--app/controllers/accounts_controller.rb5
-rw-r--r--app/controllers/activitypub/inboxes_controller.rb5
-rw-r--r--app/controllers/admin/account_moderation_notes_controller.rb31
-rw-r--r--app/controllers/admin/accounts_controller.rb5
-rw-r--r--app/controllers/admin/custom_emojis_controller.rb41
-rw-r--r--app/controllers/admin/email_domain_blocks_controller.rb40
-rw-r--r--app/controllers/api/salmon_controller.rb6
-rw-r--r--app/controllers/api/v1/accounts/relationships_controller.rb5
-rw-r--r--app/controllers/api/v1/apps/credentials_controller.rb11
-rw-r--r--app/controllers/api/v1/apps_controller.rb2
-rw-r--r--app/controllers/api/v1/blocks_controller.rb26
-rw-r--r--app/controllers/api/v1/custom_emojis_controller.rb2
-rw-r--r--app/controllers/api/v1/media_controller.rb10
-rw-r--r--app/controllers/auth/registrations_controller.rb5
-rw-r--r--app/controllers/auth/sessions_controller.rb5
-rw-r--r--app/controllers/concerns/signature_verification.rb20
-rw-r--r--app/controllers/emojis_controller.rb22
-rw-r--r--app/controllers/follower_accounts_controller.rb5
-rw-r--r--app/controllers/following_accounts_controller.rb5
-rw-r--r--app/controllers/manifests_controller.rb8
-rw-r--r--app/controllers/settings/follower_domains_controller.rb2
-rw-r--r--app/controllers/settings/notifications_controller.rb32
-rw-r--r--app/controllers/statuses_controller.rb10
-rw-r--r--app/controllers/tags_controller.rb35
-rw-r--r--app/helpers/admin/account_moderation_notes_helper.rb4
-rw-r--r--app/helpers/jsonld_helper.rb13
-rw-r--r--app/javascript/mastodon/actions/compose.js84
-rw-r--r--app/javascript/mastodon/actions/emojis.js14
-rw-r--r--app/javascript/mastodon/actions/settings.js18
-rw-r--r--app/javascript/mastodon/actions/timelines.js20
-rw-r--r--app/javascript/mastodon/components/autosuggest_emoji.js11
-rw-r--r--app/javascript/mastodon/components/autosuggest_textarea.js14
-rw-r--r--app/javascript/mastodon/components/column_header.js4
-rw-r--r--app/javascript/mastodon/components/dropdown_menu.js5
-rw-r--r--app/javascript/mastodon/components/extended_video_player.js14
-rw-r--r--app/javascript/mastodon/components/intersection_observer_article.js37
-rw-r--r--app/javascript/mastodon/components/media_gallery.js44
-rw-r--r--app/javascript/mastodon/components/relative_timestamp.js113
-rw-r--r--app/javascript/mastodon/components/scrollable_list.js39
-rw-r--r--app/javascript/mastodon/components/status.js116
-rw-r--r--app/javascript/mastodon/components/status_content.js4
-rw-r--r--app/javascript/mastodon/components/status_list.js31
-rw-r--r--app/javascript/mastodon/components/video_player.js207
-rw-r--r--app/javascript/mastodon/containers/mastodon.js5
-rw-r--r--app/javascript/mastodon/containers/timeline_container.js14
-rw-r--r--app/javascript/mastodon/emoji.js72
-rw-r--r--app/javascript/mastodon/emojione_light.js38
-rw-r--r--app/javascript/mastodon/features/account/components/action_bar.js2
-rw-r--r--app/javascript/mastodon/features/compose/components/compose_form.js5
-rw-r--r--app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js100
-rw-r--r--app/javascript/mastodon/features/compose/components/privacy_dropdown.js155
-rw-r--r--app/javascript/mastodon/features/compose/components/search.js55
-rw-r--r--app/javascript/mastodon/features/compose/components/search_results.js2
-rw-r--r--app/javascript/mastodon/features/compose/components/upload.js96
-rw-r--r--app/javascript/mastodon/features/compose/components/upload_form.js44
-rw-r--r--app/javascript/mastodon/features/compose/components/warning.js12
-rw-r--r--app/javascript/mastodon/features/compose/containers/emoji_picker_dropdown_container.js79
-rw-r--r--app/javascript/mastodon/features/compose/containers/upload_container.js21
-rw-r--r--app/javascript/mastodon/features/compose/containers/upload_form_container.js13
-rw-r--r--app/javascript/mastodon/features/compose/index.js2
-rw-r--r--app/javascript/mastodon/features/emoji/emoji.js77
-rw-r--r--app/javascript/mastodon/features/emoji/emoji_compressed.js92
-rw-r--r--app/javascript/mastodon/features/emoji/emoji_map.json (renamed from app/javascript/mastodon/emoji_map.json)0
-rw-r--r--app/javascript/mastodon/features/emoji/emoji_mart_data_light.js41
-rw-r--r--app/javascript/mastodon/features/emoji/emoji_mart_search_light.js157
-rw-r--r--app/javascript/mastodon/features/emoji/emoji_picker.js7
-rw-r--r--app/javascript/mastodon/features/emoji/emoji_unicode_mapping_light.js35
-rw-r--r--app/javascript/mastodon/features/emoji/emoji_utils.js258
-rw-r--r--app/javascript/mastodon/features/emoji/unicode_to_filename.js26
-rw-r--r--app/javascript/mastodon/features/emoji/unicode_to_unified_name.js17
-rw-r--r--app/javascript/mastodon/features/home_timeline/index.js2
-rw-r--r--app/javascript/mastodon/features/notifications/components/notification.js115
-rw-r--r--app/javascript/mastodon/features/notifications/containers/notification_container.js9
-rw-r--r--app/javascript/mastodon/features/notifications/index.js28
-rw-r--r--app/javascript/mastodon/features/standalone/hashtag_timeline/index.js70
-rw-r--r--app/javascript/mastodon/features/status/components/card.js19
-rw-r--r--app/javascript/mastodon/features/status/components/detailed_status.js2
-rw-r--r--app/javascript/mastodon/features/status/index.js153
-rw-r--r--app/javascript/mastodon/features/ui/components/column_link.js2
-rw-r--r--app/javascript/mastodon/features/ui/components/media_modal.js31
-rw-r--r--app/javascript/mastodon/features/ui/components/modal_root.js65
-rw-r--r--app/javascript/mastodon/features/ui/components/tabs_bar.js2
-rw-r--r--app/javascript/mastodon/features/ui/components/video_modal.js1
-rw-r--r--app/javascript/mastodon/features/ui/index.js227
-rw-r--r--app/javascript/mastodon/features/ui/util/async-components.js8
-rw-r--r--app/javascript/mastodon/features/ui/util/fullscreen.js46
-rw-r--r--app/javascript/mastodon/features/ui/util/react_router_helpers.js3
-rw-r--r--app/javascript/mastodon/features/video/index.js46
-rw-r--r--app/javascript/mastodon/locales/ar.json106
-rw-r--r--app/javascript/mastodon/locales/bg.json12
-rw-r--r--app/javascript/mastodon/locales/ca.json12
-rw-r--r--app/javascript/mastodon/locales/de.json146
-rw-r--r--app/javascript/mastodon/locales/defaultMessages.json94
-rw-r--r--app/javascript/mastodon/locales/en.json12
-rw-r--r--app/javascript/mastodon/locales/eo.json12
-rw-r--r--app/javascript/mastodon/locales/es.json12
-rw-r--r--app/javascript/mastodon/locales/fa.json12
-rw-r--r--app/javascript/mastodon/locales/fi.json12
-rw-r--r--app/javascript/mastodon/locales/fr.json18
-rw-r--r--app/javascript/mastodon/locales/he.json12
-rw-r--r--app/javascript/mastodon/locales/hr.json12
-rw-r--r--app/javascript/mastodon/locales/hu.json12
-rw-r--r--app/javascript/mastodon/locales/id.json12
-rw-r--r--app/javascript/mastodon/locales/io.json12
-rw-r--r--app/javascript/mastodon/locales/it.json12
-rw-r--r--app/javascript/mastodon/locales/ja.json25
-rw-r--r--app/javascript/mastodon/locales/ko.json12
-rw-r--r--app/javascript/mastodon/locales/nl.json12
-rw-r--r--app/javascript/mastodon/locales/no.json12
-rw-r--r--app/javascript/mastodon/locales/oc.json12
-rw-r--r--app/javascript/mastodon/locales/pl.json22
-rw-r--r--app/javascript/mastodon/locales/pt-BR.json12
-rw-r--r--app/javascript/mastodon/locales/pt.json12
-rw-r--r--app/javascript/mastodon/locales/ru.json12
-rw-r--r--app/javascript/mastodon/locales/th.json12
-rw-r--r--app/javascript/mastodon/locales/tr.json12
-rw-r--r--app/javascript/mastodon/locales/uk.json12
-rw-r--r--app/javascript/mastodon/locales/zh-CN.json12
-rw-r--r--app/javascript/mastodon/locales/zh-HK.json12
-rw-r--r--app/javascript/mastodon/locales/zh-TW.json12
-rw-r--r--app/javascript/mastodon/performance.js4
-rw-r--r--app/javascript/mastodon/reducers/accounts.js2
-rw-r--r--app/javascript/mastodon/reducers/compose.js21
-rw-r--r--app/javascript/mastodon/reducers/contexts.js28
-rw-r--r--app/javascript/mastodon/reducers/custom_emojis.js6
-rw-r--r--app/javascript/mastodon/reducers/notifications.js6
-rw-r--r--app/javascript/mastodon/reducers/settings.js29
-rw-r--r--app/javascript/mastodon/reducers/statuses.js9
-rw-r--r--app/javascript/packs/about.js6
-rw-r--r--app/javascript/packs/public.js2
-rw-r--r--app/javascript/styles/about.scss91
-rw-r--r--app/javascript/styles/accounts.scss17
-rw-r--r--app/javascript/styles/basics.scss26
-rw-r--r--app/javascript/styles/components.scss256
-rw-r--r--app/javascript/styles/forms.scss12
-rw-r--r--app/javascript/styles/rtl.scss18
-rw-r--r--app/lib/activitypub/activity.rb7
-rw-r--r--app/lib/activitypub/activity/announce.rb5
-rw-r--r--app/lib/activitypub/activity/create.rb18
-rw-r--r--app/lib/activitypub/linked_data_signature.rb2
-rw-r--r--app/lib/activitypub/tag_manager.rb2
-rw-r--r--app/lib/delivery_failure_tracker.rb56
-rw-r--r--app/lib/feed_manager.rb130
-rw-r--r--app/lib/formatter.rb4
-rw-r--r--app/lib/ostatus/activity/base.rb5
-rw-r--r--app/lib/ostatus/activity/creation.rb11
-rw-r--r--app/lib/ostatus/activity/general.rb2
-rw-r--r--app/lib/request.rb4
-rw-r--r--app/lib/user_settings_decorator.rb26
-rw-r--r--app/mailers/user_mailer.rb2
-rw-r--r--app/models/account.rb16
-rw-r--r--app/models/account_domain_block.rb4
-rw-r--r--app/models/account_filter.rb2
-rw-r--r--app/models/account_moderation_note.rb21
-rw-r--r--app/models/block.rb6
-rw-r--r--app/models/conversation_mute.rb4
-rw-r--r--app/models/custom_emoji.rb19
-rw-r--r--app/models/custom_emoji_filter.rb34
-rw-r--r--app/models/domain_block.rb2
-rw-r--r--app/models/email_domain_block.rb17
-rw-r--r--app/models/favourite.rb6
-rw-r--r--app/models/feed.rb2
-rw-r--r--app/models/follow.rb6
-rw-r--r--app/models/follow_request.rb6
-rw-r--r--app/models/import.rb4
-rw-r--r--app/models/media_attachment.rb7
-rw-r--r--app/models/mention.rb4
-rw-r--r--app/models/mute.rb6
-rw-r--r--app/models/report.rb6
-rw-r--r--app/models/setting.rb4
-rw-r--r--app/models/status.rb2
-rw-r--r--app/models/stream_entry.rb5
-rw-r--r--app/models/subscription.rb4
-rw-r--r--app/models/web/setting.rb4
-rw-r--r--app/serializers/activitypub/activity_serializer.rb9
-rw-r--r--app/serializers/activitypub/actor_serializer.rb18
-rw-r--r--app/serializers/activitypub/emoji_serializer.rb29
-rw-r--r--app/serializers/activitypub/image_serializer.rb19
-rw-r--r--app/serializers/activitypub/note_serializer.rb23
-rw-r--r--app/serializers/manifest_serializer.rb52
-rw-r--r--app/serializers/rest/application_serializer.rb4
-rw-r--r--app/serializers/rest/custom_emoji_serializer.rb6
-rw-r--r--app/serializers/rest/media_attachment_serializer.rb7
-rw-r--r--app/services/activitypub/fetch_remote_account_service.rb18
-rw-r--r--app/services/activitypub/fetch_remote_key_service.rb25
-rw-r--r--app/services/activitypub/fetch_remote_status_service.rb37
-rw-r--r--app/services/activitypub/process_account_service.rb6
-rw-r--r--app/services/activitypub/process_collection_service.rb5
-rw-r--r--app/services/batched_remove_status_service.rb37
-rw-r--r--app/services/fetch_atom_service.rb13
-rw-r--r--app/services/fetch_link_card_service.rb3
-rw-r--r--app/services/fetch_remote_account_service.rb14
-rw-r--r--app/services/fetch_remote_resource_service.rb2
-rw-r--r--app/services/fetch_remote_status_service.rb16
-rw-r--r--app/services/mute_service.rb3
-rw-r--r--app/services/precompute_feed_service.rb38
-rw-r--r--app/services/process_feed_service.rb6
-rw-r--r--app/services/remove_status_service.rb8
-rw-r--r--app/services/resolve_remote_account_service.rb4
-rw-r--r--app/services/send_interaction_service.rb2
-rw-r--r--app/services/subscribe_service.rb2
-rw-r--r--app/services/unsubscribe_service.rb2
-rw-r--r--app/validators/blacklisted_email_validator.rb1
-rw-r--r--app/views/about/more.html.haml2
-rw-r--r--app/views/about/show.html.haml4
-rw-r--r--app/views/admin/account_moderation_notes/_account_moderation_note.html.haml10
-rw-r--r--app/views/admin/accounts/show.html.haml22
-rw-r--r--app/views/admin/custom_emojis/_custom_emoji.html.haml13
-rw-r--r--app/views/admin/custom_emojis/index.html.haml20
-rw-r--r--app/views/admin/email_domain_blocks/_email_domain_block.html.haml5
-rw-r--r--app/views/admin/email_domain_blocks/index.html.haml14
-rw-r--r--app/views/admin/email_domain_blocks/new.html.haml10
-rw-r--r--app/views/auth/registrations/new.html.haml3
-rw-r--r--app/views/auth/sessions/new.html.haml3
-rw-r--r--app/views/layouts/error.html.haml4
-rw-r--r--app/views/manifests/show.json.rabl11
-rw-r--r--app/views/settings/notifications/show.html.haml25
-rw-r--r--app/views/settings/preferences/show.html.haml45
-rw-r--r--app/views/shared/_og.html.haml (renamed from app/views/about/_og.html.haml)0
-rw-r--r--app/views/stream_entries/_og_image.html.haml2
-rw-r--r--app/views/stream_entries/show.html.haml2
-rw-r--r--app/views/tags/_og.html.haml6
-rw-r--r--app/views/tags/show.html.haml47
-rw-r--r--app/views/user_mailer/confirmation_instructions.en.html.erb7
-rw-r--r--app/views/user_mailer/confirmation_instructions.ko.html.erb13
-rw-r--r--app/views/user_mailer/confirmation_instructions.ko.text.erb10
-rw-r--r--app/workers/activitypub/delivery_worker.rb11
-rw-r--r--app/workers/activitypub/processing_worker.rb2
-rw-r--r--app/workers/import/relationship_worker.rb25
-rw-r--r--app/workers/import_worker.rb56
-rw-r--r--app/workers/link_crawl_worker.rb2
-rw-r--r--app/workers/processing_worker.rb2
-rw-r--r--app/workers/pubsubhubbub/delivery_worker.rb2
233 files changed, 4272 insertions, 1620 deletions
diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb
index 26ab6636b..75915b337 100644
--- a/app/controllers/accounts_controller.rb
+++ b/app/controllers/accounts_controller.rb
@@ -26,7 +26,10 @@ class AccountsController < ApplicationController
       end
 
       format.json do
-        render json: @account, serializer: ActivityPub::ActorSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json'
+        render json: @account,
+               serializer: ActivityPub::ActorSerializer,
+               adapter: ActivityPub::Adapter,
+               content_type: 'application/activity+json'
       end
     end
   end
diff --git a/app/controllers/activitypub/inboxes_controller.rb b/app/controllers/activitypub/inboxes_controller.rb
index b37910b36..76553a162 100644
--- a/app/controllers/activitypub/inboxes_controller.rb
+++ b/app/controllers/activitypub/inboxes_controller.rb
@@ -9,9 +9,9 @@ class ActivityPub::InboxesController < Api::BaseController
     if signed_request_account
       upgrade_account
       process_payload
-      head 201
-    else
       head 202
+    else
+      [signature_verification_failure_reason, 401]
     end
   end
 
@@ -32,6 +32,7 @@ class ActivityPub::InboxesController < Api::BaseController
     end
 
     Pubsubhubbub::UnsubscribeWorker.perform_async(signed_request_account.id) if signed_request_account.subscribed?
+    DeliveryFailureTracker.track_inverse_success!(signed_request_account)
   end
 
   def process_payload
diff --git a/app/controllers/admin/account_moderation_notes_controller.rb b/app/controllers/admin/account_moderation_notes_controller.rb
new file mode 100644
index 000000000..414a875d0
--- /dev/null
+++ b/app/controllers/admin/account_moderation_notes_controller.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+class Admin::AccountModerationNotesController < Admin::BaseController
+  def create
+    @account_moderation_note = current_account.account_moderation_notes.new(resource_params)
+    if @account_moderation_note.save
+      @target_account = @account_moderation_note.target_account
+      redirect_to admin_account_path(@target_account.id), notice: I18n.t('admin.account_moderation_notes.created_msg')
+    else
+      @account = @account_moderation_note.target_account
+      @moderation_notes = @account.targeted_moderation_notes.latest
+      render template: 'admin/accounts/show'
+    end
+  end
+
+  def destroy
+    @account_moderation_note = AccountModerationNote.find(params[:id])
+    @target_account = @account_moderation_note.target_account
+    @account_moderation_note.destroy
+    redirect_to admin_account_path(@target_account.id), notice: I18n.t('admin.account_moderation_notes.destroyed_msg')
+  end
+
+  private
+
+  def resource_params
+    params.require(:account_moderation_note).permit(
+      :content,
+      :target_account_id
+    )
+  end
+end
diff --git a/app/controllers/admin/accounts_controller.rb b/app/controllers/admin/accounts_controller.rb
index 54c659e1b..ffa4dc850 100644
--- a/app/controllers/admin/accounts_controller.rb
+++ b/app/controllers/admin/accounts_controller.rb
@@ -9,7 +9,10 @@ module Admin
       @accounts = filtered_accounts.page(params[:page])
     end
 
-    def show; end
+    def show
+      @account_moderation_note = current_account.account_moderation_notes.new(target_account: @account)
+      @moderation_notes = @account.targeted_moderation_notes.latest
+    end
 
     def subscribe
       Pubsubhubbub::SubscribeWorker.perform_async(@account.id)
diff --git a/app/controllers/admin/custom_emojis_controller.rb b/app/controllers/admin/custom_emojis_controller.rb
index d70514d9a..ca81f3255 100644
--- a/app/controllers/admin/custom_emojis_controller.rb
+++ b/app/controllers/admin/custom_emojis_controller.rb
@@ -2,8 +2,10 @@
 
 module Admin
   class CustomEmojisController < BaseController
+    before_action :set_custom_emoji, except: [:index, :new, :create]
+
     def index
-      @custom_emojis = CustomEmoji.local
+      @custom_emojis = filtered_custom_emojis.page(params[:page])
     end
 
     def new
@@ -21,14 +23,49 @@ module Admin
     end
 
     def destroy
-      CustomEmoji.find(params[:id]).destroy
+      @custom_emoji.destroy
       redirect_to admin_custom_emojis_path, notice: I18n.t('admin.custom_emojis.destroyed_msg')
     end
 
+    def copy
+      emoji = CustomEmoji.new(domain: nil, shortcode: @custom_emoji.shortcode, image: @custom_emoji.image)
+
+      if emoji.save
+        redirect_to admin_custom_emojis_path, notice: I18n.t('admin.custom_emojis.copied_msg')
+      else
+        redirect_to admin_custom_emojis_path, alert: I18n.t('admin.custom_emojis.copy_failed_msg')
+      end
+    end
+
+    def enable
+      @custom_emoji.update!(disabled: false)
+      redirect_to admin_custom_emojis_path, notice: I18n.t('admin.custom_emojis.enabled_msg')
+    end
+
+    def disable
+      @custom_emoji.update!(disabled: true)
+      redirect_to admin_custom_emojis_path, notice: I18n.t('admin.custom_emojis.disabled_msg')
+    end
+
     private
 
+    def set_custom_emoji
+      @custom_emoji = CustomEmoji.find(params[:id])
+    end
+
     def resource_params
       params.require(:custom_emoji).permit(:shortcode, :image)
     end
+
+    def filtered_custom_emojis
+      CustomEmojiFilter.new(filter_params).results
+    end
+
+    def filter_params
+      params.permit(
+        :local,
+        :remote
+      )
+    end
   end
 end
diff --git a/app/controllers/admin/email_domain_blocks_controller.rb b/app/controllers/admin/email_domain_blocks_controller.rb
new file mode 100644
index 000000000..09275d5dc
--- /dev/null
+++ b/app/controllers/admin/email_domain_blocks_controller.rb
@@ -0,0 +1,40 @@
+# frozen_string_literal: true
+
+module Admin
+  class EmailDomainBlocksController < BaseController
+    before_action :set_email_domain_block, only: [:show, :destroy]
+
+    def index
+      @email_domain_blocks = EmailDomainBlock.page(params[:page])
+    end
+
+    def new
+      @email_domain_block = EmailDomainBlock.new
+    end
+
+    def create
+      @email_domain_block = EmailDomainBlock.new(resource_params)
+
+      if @email_domain_block.save
+        redirect_to admin_email_domain_blocks_path, notice: I18n.t('admin.email_domain_blocks.created_msg')
+      else
+        render :new
+      end
+    end
+
+    def destroy
+      @email_domain_block.destroy
+      redirect_to admin_email_domain_blocks_path, notice: I18n.t('admin.email_domain_blocks.destroyed_msg')
+    end
+
+    private
+
+    def set_email_domain_block
+      @email_domain_block = EmailDomainBlock.find(params[:id])
+    end
+
+    def resource_params
+      params.require(:email_domain_block).permit(:domain)
+    end
+  end
+end
diff --git a/app/controllers/api/salmon_controller.rb b/app/controllers/api/salmon_controller.rb
index e9e700b18..143e9d3cd 100644
--- a/app/controllers/api/salmon_controller.rb
+++ b/app/controllers/api/salmon_controller.rb
@@ -7,9 +7,11 @@ class Api::SalmonController < Api::BaseController
   def update
     if verify_payload?
       process_salmon
-      head 201
-    else
       head 202
+    elsif payload.present?
+      [signature_verification_failure_reason, 401]
+    else
+      head 400
     end
   end
 
diff --git a/app/controllers/api/v1/accounts/relationships_controller.rb b/app/controllers/api/v1/accounts/relationships_controller.rb
index a88cf2021..91a942d75 100644
--- a/app/controllers/api/v1/accounts/relationships_controller.rb
+++ b/app/controllers/api/v1/accounts/relationships_controller.rb
@@ -7,7 +7,10 @@ class Api::V1::Accounts::RelationshipsController < Api::BaseController
   respond_to :json
 
   def index
-    @accounts = Account.where(id: account_ids).select('id')
+    accounts = Account.where(id: account_ids).select('id')
+    # .where doesn't guarantee that our results are in the same order
+    # we requested them, so return the "right" order to the requestor.
+    @accounts = accounts.index_by(&:id).values_at(*account_ids)
     render json: @accounts, each_serializer: REST::RelationshipSerializer, relationships: relationships
   end
 
diff --git a/app/controllers/api/v1/apps/credentials_controller.rb b/app/controllers/api/v1/apps/credentials_controller.rb
new file mode 100644
index 000000000..e469c7d21
--- /dev/null
+++ b/app/controllers/api/v1/apps/credentials_controller.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+class Api::V1::Apps::CredentialsController < Api::BaseController
+  before_action -> { doorkeeper_authorize! :read }
+
+  respond_to :json
+
+  def show
+    render json: doorkeeper_token.application, serializer: REST::StatusSerializer::ApplicationSerializer
+  end
+end
diff --git a/app/controllers/api/v1/apps_controller.rb b/app/controllers/api/v1/apps_controller.rb
index 44a27b20a..e9f7a7291 100644
--- a/app/controllers/api/v1/apps_controller.rb
+++ b/app/controllers/api/v1/apps_controller.rb
@@ -1,8 +1,6 @@
 # frozen_string_literal: true
 
 class Api::V1::AppsController < Api::BaseController
-  respond_to :json
-
   def create
     @app = Doorkeeper::Application.create!(application_options)
     render json: @app, serializer: REST::ApplicationSerializer
diff --git a/app/controllers/api/v1/blocks_controller.rb b/app/controllers/api/v1/blocks_controller.rb
index a412e4341..3a6690766 100644
--- a/app/controllers/api/v1/blocks_controller.rb
+++ b/app/controllers/api/v1/blocks_controller.rb
@@ -15,19 +15,17 @@ class Api::V1::BlocksController < Api::BaseController
   private
 
   def load_accounts
-    default_accounts.merge(paginated_blocks).to_a
-  end
-
-  def default_accounts
-    Account.includes(:blocked_by).references(:blocked_by)
+    paginated_blocks.map(&:target_account)
   end
 
   def paginated_blocks
-    Block.where(account: current_account).paginate_by_max_id(
-      limit_param(DEFAULT_ACCOUNTS_LIMIT),
-      params[:max_id],
-      params[:since_id]
-    )
+    @paginated_blocks ||= Block.eager_load(:target_account)
+                               .where(account: current_account)
+                               .paginate_by_max_id(
+                                 limit_param(DEFAULT_ACCOUNTS_LIMIT),
+                                 params[:max_id],
+                                 params[:since_id]
+                               )
   end
 
   def insert_pagination_headers
@@ -41,21 +39,21 @@ class Api::V1::BlocksController < Api::BaseController
   end
 
   def prev_path
-    unless @accounts.empty?
+    unless paginated_blocks.empty?
       api_v1_blocks_url pagination_params(since_id: pagination_since_id)
     end
   end
 
   def pagination_max_id
-    @accounts.last.blocked_by_ids.last
+    paginated_blocks.last.id
   end
 
   def pagination_since_id
-    @accounts.first.blocked_by_ids.first
+    paginated_blocks.first.id
   end
 
   def records_continue?
-    @accounts.size == limit_param(DEFAULT_ACCOUNTS_LIMIT)
+    paginated_blocks.size == limit_param(DEFAULT_ACCOUNTS_LIMIT)
   end
 
   def pagination_params(core_params)
diff --git a/app/controllers/api/v1/custom_emojis_controller.rb b/app/controllers/api/v1/custom_emojis_controller.rb
index 4dd77fb55..f8cd64455 100644
--- a/app/controllers/api/v1/custom_emojis_controller.rb
+++ b/app/controllers/api/v1/custom_emojis_controller.rb
@@ -4,6 +4,6 @@ class Api::V1::CustomEmojisController < Api::BaseController
   respond_to :json
 
   def index
-    render json: CustomEmoji.local, each_serializer: REST::CustomEmojiSerializer
+    render json: CustomEmoji.local.where(disabled: false), each_serializer: REST::CustomEmojiSerializer
   end
 end
diff --git a/app/controllers/api/v1/media_controller.rb b/app/controllers/api/v1/media_controller.rb
index 8a1992fca..9f330f0df 100644
--- a/app/controllers/api/v1/media_controller.rb
+++ b/app/controllers/api/v1/media_controller.rb
@@ -10,7 +10,7 @@ class Api::V1::MediaController < Api::BaseController
   respond_to :json
 
   def create
-    @media = current_account.media_attachments.create!(file: media_params[:file])
+    @media = current_account.media_attachments.create!(media_params)
     render json: @media, serializer: REST::MediaAttachmentSerializer
   rescue Paperclip::Errors::NotIdentifiedByImageMagickError
     render json: file_type_error, status: 422
@@ -18,10 +18,16 @@ class Api::V1::MediaController < Api::BaseController
     render json: processing_error, status: 500
   end
 
+  def update
+    @media = current_account.media_attachments.where(status_id: nil).find(params[:id])
+    @media.update!(media_params)
+    render json: @media, serializer: REST::MediaAttachmentSerializer
+  end
+
   private
 
   def media_params
-    params.permit(:file)
+    params.permit(:file, :description)
   end
 
   def file_type_error
diff --git a/app/controllers/auth/registrations_controller.rb b/app/controllers/auth/registrations_controller.rb
index 60ace04d7..aac3c31ff 100644
--- a/app/controllers/auth/registrations_controller.rb
+++ b/app/controllers/auth/registrations_controller.rb
@@ -6,6 +6,7 @@ class Auth::RegistrationsController < Devise::RegistrationsController
   before_action :check_enabled_registrations, only: [:new, :create]
   before_action :configure_sign_up_params, only: [:create]
   before_action :set_sessions, only: [:edit, :update]
+  before_action :set_instance_presenter, only: [:new, :update]
 
   def destroy
     not_found
@@ -39,6 +40,10 @@ class Auth::RegistrationsController < Devise::RegistrationsController
 
   private
 
+  def set_instance_presenter
+    @instance_presenter = InstancePresenter.new
+  end
+
   def determine_layout
     %w(edit update).include?(action_name) ? 'admin' : 'auth'
   end
diff --git a/app/controllers/auth/sessions_controller.rb b/app/controllers/auth/sessions_controller.rb
index bc3bd2f4b..463a183e4 100644
--- a/app/controllers/auth/sessions_controller.rb
+++ b/app/controllers/auth/sessions_controller.rb
@@ -8,6 +8,7 @@ class Auth::SessionsController < Devise::SessionsController
   skip_before_action :require_no_authentication, only: [:create]
   skip_before_action :check_suspension, only: [:destroy]
   prepend_before_action :authenticate_with_two_factor, if: :two_factor_enabled?, only: [:create]
+  before_action :set_instance_presenter, only: [:new]
 
   def create
     super do |resource|
@@ -84,6 +85,10 @@ class Auth::SessionsController < Devise::SessionsController
 
   private
 
+  def set_instance_presenter
+    @instance_presenter = InstancePresenter.new
+  end
+
   def home_paths(resource)
     paths = [about_path]
     if single_user_mode? && resource.is_a?(User)
diff --git a/app/controllers/concerns/signature_verification.rb b/app/controllers/concerns/signature_verification.rb
index 4211283ed..2baafb5bf 100644
--- a/app/controllers/concerns/signature_verification.rb
+++ b/app/controllers/concerns/signature_verification.rb
@@ -9,10 +9,15 @@ module SignatureVerification
     request.headers['Signature'].present?
   end
 
+  def signature_verification_failure_reason
+    return @signature_verification_failure_reason if defined?(@signature_verification_failure_reason)
+  end
+
   def signed_request_account
     return @signed_request_account if defined?(@signed_request_account)
 
     unless signed_request?
+      @signature_verification_failure_reason = 'Request not signed'
       @signed_request_account = nil
       return
     end
@@ -27,6 +32,7 @@ module SignatureVerification
     end
 
     if incompatible_signature?(signature_params)
+      @signature_verification_failure_reason = 'Incompatible request signature'
       @signed_request_account = nil
       return
     end
@@ -34,6 +40,7 @@ module SignatureVerification
     account = account_from_key_id(signature_params['keyId'])
 
     if account.nil?
+      @signature_verification_failure_reason = "Public key not found for key #{signature_params['keyId']}"
       @signed_request_account = nil
       return
     end
@@ -44,7 +51,18 @@ module SignatureVerification
     if account.keypair.public_key.verify(OpenSSL::Digest::SHA256.new, signature, compare_signed_string)
       @signed_request_account = account
       @signed_request_account
+    elsif account.possibly_stale?
+      account = account.refresh!
+
+      if account.keypair.public_key.verify(OpenSSL::Digest::SHA256.new, signature, compare_signed_string)
+        @signed_request_account = account
+        @signed_request_account
+      else
+        @signed_verification_failure_reason = "Verification failed for #{account.username}@#{account.domain} #{account.uri}"
+        @signed_request_account = nil
+      end
     else
+      @signed_verification_failure_reason = "Verification failed for #{account.username}@#{account.domain} #{account.uri}"
       @signed_request_account = nil
     end
   end
@@ -99,7 +117,7 @@ module SignatureVerification
       ResolveRemoteAccountService.new.call(key_id.gsub(/\Aacct:/, ''))
     elsif !ActivityPub::TagManager.instance.local_uri?(key_id)
       account   = ActivityPub::TagManager.instance.uri_to_resource(key_id, Account)
-      account ||= ActivityPub::FetchRemoteKeyService.new.call(key_id)
+      account ||= ActivityPub::FetchRemoteKeyService.new.call(key_id, id: false)
       account
     end
   end
diff --git a/app/controllers/emojis_controller.rb b/app/controllers/emojis_controller.rb
new file mode 100644
index 000000000..a82b9340b
--- /dev/null
+++ b/app/controllers/emojis_controller.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+class EmojisController < ApplicationController
+  before_action :set_emoji
+
+  def show
+    respond_to do |format|
+      format.json do
+        render json: @emoji,
+               serializer: ActivityPub::EmojiSerializer,
+               adapter: ActivityPub::Adapter,
+               content_type: 'application/activity+json'
+      end
+    end
+  end
+
+  private
+
+  def set_emoji
+    @emoji = CustomEmoji.local.find(params[:id])
+  end
+end
diff --git a/app/controllers/follower_accounts_controller.rb b/app/controllers/follower_accounts_controller.rb
index 8eb4d2822..399e79665 100644
--- a/app/controllers/follower_accounts_controller.rb
+++ b/app/controllers/follower_accounts_controller.rb
@@ -10,7 +10,10 @@ class FollowerAccountsController < ApplicationController
       format.html
 
       format.json do
-        render json: collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json'
+        render json: collection_presenter,
+               serializer: ActivityPub::CollectionSerializer,
+               adapter: ActivityPub::Adapter,
+               content_type: 'application/activity+json'
       end
     end
   end
diff --git a/app/controllers/following_accounts_controller.rb b/app/controllers/following_accounts_controller.rb
index 1ca6f0fe7..1e73d4bd4 100644
--- a/app/controllers/following_accounts_controller.rb
+++ b/app/controllers/following_accounts_controller.rb
@@ -10,7 +10,10 @@ class FollowingAccountsController < ApplicationController
       format.html
 
       format.json do
-        render json: collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json'
+        render json: collection_presenter,
+               serializer: ActivityPub::CollectionSerializer,
+               adapter: ActivityPub::Adapter,
+               content_type: 'application/activity+json'
       end
     end
   end
diff --git a/app/controllers/manifests_controller.rb b/app/controllers/manifests_controller.rb
index 832e1eb6f..ac267c229 100644
--- a/app/controllers/manifests_controller.rb
+++ b/app/controllers/manifests_controller.rb
@@ -1,11 +1,7 @@
 # frozen_string_literal: true
 
 class ManifestsController < ApplicationController
-  before_action :set_instance_presenter
-
-  def show; end
-
-  def set_instance_presenter
-    @instance_presenter = InstancePresenter.new
+  def show
+    render json: InstancePresenter.new, serializer: ManifestSerializer
   end
 end
diff --git a/app/controllers/settings/follower_domains_controller.rb b/app/controllers/settings/follower_domains_controller.rb
index 90b48887f..9968504e5 100644
--- a/app/controllers/settings/follower_domains_controller.rb
+++ b/app/controllers/settings/follower_domains_controller.rb
@@ -9,7 +9,7 @@ class Settings::FollowerDomainsController < ApplicationController
 
   def show
     @account = current_account
-    @domains = current_account.followers.reorder(nil).group('accounts.domain').select('accounts.domain, count(accounts.id) as accounts_from_domain').page(params[:page]).per(10)
+    @domains = current_account.followers.reorder('MIN(follows.id) DESC').group('accounts.domain').select('accounts.domain, count(accounts.id) as accounts_from_domain').page(params[:page]).per(10)
   end
 
   def update
diff --git a/app/controllers/settings/notifications_controller.rb b/app/controllers/settings/notifications_controller.rb
new file mode 100644
index 000000000..09839f16e
--- /dev/null
+++ b/app/controllers/settings/notifications_controller.rb
@@ -0,0 +1,32 @@
+# frozen_string_literal: true
+
+class Settings::NotificationsController < ApplicationController
+  layout 'admin'
+
+  before_action :authenticate_user!
+
+  def show; end
+
+  def update
+    user_settings.update(user_settings_params.to_h)
+
+    if current_user.save
+      redirect_to settings_notifications_path, notice: I18n.t('generic.changes_saved_msg')
+    else
+      render :show
+    end
+  end
+
+  private
+
+  def user_settings
+    UserSettingsDecorator.new(current_user)
+  end
+
+  def user_settings_params
+    params.require(:user).permit(
+      notification_emails: %i(follow follow_request reblog favourite mention digest),
+      interactions: %i(must_be_follower must_be_following)
+    )
+  end
+end
diff --git a/app/controllers/statuses_controller.rb b/app/controllers/statuses_controller.rb
index 65206ea96..e8a360fb5 100644
--- a/app/controllers/statuses_controller.rb
+++ b/app/controllers/statuses_controller.rb
@@ -21,13 +21,19 @@ class StatusesController < ApplicationController
       end
 
       format.json do
-        render json: @status, serializer: ActivityPub::NoteSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json'
+        render json: @status,
+               serializer: ActivityPub::NoteSerializer,
+               adapter: ActivityPub::Adapter,
+               content_type: 'application/activity+json'
       end
     end
   end
 
   def activity
-    render json: @status, serializer: ActivityPub::ActivitySerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json'
+    render json: @status,
+           serializer: ActivityPub::ActivitySerializer,
+           adapter: ActivityPub::Adapter,
+           content_type: 'application/activity+json'
   end
 
   def embed
diff --git a/app/controllers/tags_controller.rb b/app/controllers/tags_controller.rb
index 3001b2ee3..9f3090e37 100644
--- a/app/controllers/tags_controller.rb
+++ b/app/controllers/tags_controller.rb
@@ -1,24 +1,40 @@
 # frozen_string_literal: true
 
 class TagsController < ApplicationController
-  layout 'public'
+  before_action :set_body_classes
+  before_action :set_instance_presenter
 
   def show
-    @tag      = Tag.find_by!(name: params[:id].downcase)
-    @statuses = Status.as_tag_timeline(@tag, current_account, params[:local]).paginate_by_max_id(20, params[:max_id])
-    @statuses = cache_collection(@statuses, Status)
+    @tag = Tag.find_by!(name: params[:id].downcase)
 
     respond_to do |format|
-      format.html
+      format.html do
+        serializable_resource = ActiveModelSerializers::SerializableResource.new(InitialStatePresenter.new(initial_state_params), serializer: InitialStateSerializer)
+        @initial_state_json   = serializable_resource.to_json
+      end
 
       format.json do
-        render json: collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json'
+        @statuses = Status.as_tag_timeline(@tag, current_account, params[:local]).paginate_by_max_id(20, params[:max_id])
+        @statuses = cache_collection(@statuses, Status)
+
+        render json: collection_presenter,
+               serializer: ActivityPub::CollectionSerializer,
+               adapter: ActivityPub::Adapter,
+               content_type: 'application/activity+json'
       end
     end
   end
 
   private
 
+  def set_body_classes
+    @body_classes = 'tag-body'
+  end
+
+  def set_instance_presenter
+    @instance_presenter = InstancePresenter.new
+  end
+
   def collection_presenter
     ActivityPub::CollectionPresenter.new(
       id: tag_url(@tag),
@@ -27,4 +43,11 @@ class TagsController < ApplicationController
       items: @statuses.map { |s| ActivityPub::TagManager.instance.uri_for(s) }
     )
   end
+
+  def initial_state_params
+    {
+      settings: {},
+      token: current_session&.token,
+    }
+  end
 end
diff --git a/app/helpers/admin/account_moderation_notes_helper.rb b/app/helpers/admin/account_moderation_notes_helper.rb
new file mode 100644
index 000000000..b17c52264
--- /dev/null
+++ b/app/helpers/admin/account_moderation_notes_helper.rb
@@ -0,0 +1,4 @@
+# frozen_string_literal: true
+
+module Admin::AccountModerationNotesHelper
+end
diff --git a/app/helpers/jsonld_helper.rb b/app/helpers/jsonld_helper.rb
index d82a07332..c23a2e095 100644
--- a/app/helpers/jsonld_helper.rb
+++ b/app/helpers/jsonld_helper.rb
@@ -22,7 +22,18 @@ module JsonLdHelper
     graph.dump(:normalize)
   end
 
-  def fetch_resource(uri)
+  def fetch_resource(uri, id)
+    unless id
+      json = fetch_resource_without_id_validation(uri)
+      return unless json
+      uri = json['id']
+    end
+
+    json = fetch_resource_without_id_validation(uri)
+    json.present? && json['id'] == uri ? json : nil
+  end
+
+  def fetch_resource_without_id_validation(uri)
     response = build_request(uri).perform
     return if response.code != 200
     body_to_json(response.to_s)
diff --git a/app/javascript/mastodon/actions/compose.js b/app/javascript/mastodon/actions/compose.js
index 20cb09f58..8d035e82f 100644
--- a/app/javascript/mastodon/actions/compose.js
+++ b/app/javascript/mastodon/actions/compose.js
@@ -1,5 +1,7 @@
 import api from '../api';
-import { emojiIndex } from 'emoji-mart';
+import { throttle } from 'lodash';
+import { search as emojiSearch } from '../features/emoji/emoji_mart_search_light';
+import { useEmoji } from './emojis';
 
 import {
   updateTimeline,
@@ -15,6 +17,7 @@ export const COMPOSE_SUBMIT_FAIL     = 'COMPOSE_SUBMIT_FAIL';
 export const COMPOSE_REPLY           = 'COMPOSE_REPLY';
 export const COMPOSE_REPLY_CANCEL    = 'COMPOSE_REPLY_CANCEL';
 export const COMPOSE_MENTION         = 'COMPOSE_MENTION';
+export const COMPOSE_RESET           = 'COMPOSE_RESET';
 export const COMPOSE_UPLOAD_REQUEST  = 'COMPOSE_UPLOAD_REQUEST';
 export const COMPOSE_UPLOAD_SUCCESS  = 'COMPOSE_UPLOAD_SUCCESS';
 export const COMPOSE_UPLOAD_FAIL     = 'COMPOSE_UPLOAD_FAIL';
@@ -38,6 +41,10 @@ export const COMPOSE_COMPOSING_CHANGE = 'COMPOSE_COMPOSING_CHANGE';
 
 export const COMPOSE_EMOJI_INSERT = 'COMPOSE_EMOJI_INSERT';
 
+export const COMPOSE_UPLOAD_CHANGE_REQUEST     = 'COMPOSE_UPLOAD_UPDATE_REQUEST';
+export const COMPOSE_UPLOAD_CHANGE_SUCCESS     = 'COMPOSE_UPLOAD_UPDATE_SUCCESS';
+export const COMPOSE_UPLOAD_CHANGE_FAIL        = 'COMPOSE_UPLOAD_UPDATE_FAIL';
+
 export function changeCompose(text) {
   return {
     type: COMPOSE_CHANGE,
@@ -64,6 +71,12 @@ export function cancelReplyCompose() {
   };
 };
 
+export function resetCompose() {
+  return {
+    type: COMPOSE_RESET,
+  };
+};
+
 export function mentionCompose(account, router) {
   return (dispatch, getState) => {
     dispatch({
@@ -168,6 +181,40 @@ export function uploadCompose(files) {
   };
 };
 
+export function changeUploadCompose(id, description) {
+  return (dispatch, getState) => {
+    dispatch(changeUploadComposeRequest());
+
+    api(getState).put(`/api/v1/media/${id}`, { description }).then(response => {
+      dispatch(changeUploadComposeSuccess(response.data));
+    }).catch(error => {
+      dispatch(changeUploadComposeFail(id, error));
+    });
+  };
+};
+
+export function changeUploadComposeRequest() {
+  return {
+    type: COMPOSE_UPLOAD_CHANGE_REQUEST,
+    skipLoading: true,
+  };
+};
+export function changeUploadComposeSuccess(media) {
+  return {
+    type: COMPOSE_UPLOAD_CHANGE_SUCCESS,
+    media: media,
+    skipLoading: true,
+  };
+};
+
+export function changeUploadComposeFail(error) {
+  return {
+    type: COMPOSE_UPLOAD_CHANGE_FAIL,
+    error: error,
+    skipLoading: true,
+  };
+};
+
 export function uploadComposeRequest() {
   return {
     type: COMPOSE_UPLOAD_REQUEST,
@@ -212,23 +259,30 @@ export function clearComposeSuggestions() {
   };
 };
 
+const fetchComposeSuggestionsAccounts = throttle((dispatch, getState, token) => {
+  api(getState).get('/api/v1/accounts/search', {
+    params: {
+      q: token.slice(1),
+      resolve: false,
+      limit: 4,
+    },
+  }).then(response => {
+    dispatch(readyComposeSuggestionsAccounts(token, response.data));
+  });
+}, 200, { leading: true, trailing: true });
+
+const fetchComposeSuggestionsEmojis = (dispatch, getState, token) => {
+  const results = emojiSearch(token.replace(':', ''), { maxResults: 5 });
+  dispatch(readyComposeSuggestionsEmojis(token, results));
+};
+
 export function fetchComposeSuggestions(token) {
   return (dispatch, getState) => {
     if (token[0] === ':') {
-      const results = emojiIndex.search(token.replace(':', ''), { maxResults: 3 });
-      dispatch(readyComposeSuggestionsEmojis(token, results));
-      return;
+      fetchComposeSuggestionsEmojis(dispatch, getState, token);
+    } else {
+      fetchComposeSuggestionsAccounts(dispatch, getState, token);
     }
-
-    api(getState).get('/api/v1/accounts/search', {
-      params: {
-        q: token.slice(1),
-        resolve: false,
-        limit: 4,
-      },
-    }).then(response => {
-      dispatch(readyComposeSuggestionsAccounts(token, response.data));
-    });
   };
 };
 
@@ -255,6 +309,8 @@ export function selectComposeSuggestion(position, token, suggestion) {
     if (typeof suggestion === 'object' && suggestion.id) {
       completion    = suggestion.native || suggestion.colons;
       startPosition = position - 1;
+
+      dispatch(useEmoji(suggestion));
     } else {
       completion    = getState().getIn(['accounts', suggestion, 'acct']);
       startPosition = position;
diff --git a/app/javascript/mastodon/actions/emojis.js b/app/javascript/mastodon/actions/emojis.js
new file mode 100644
index 000000000..7cd9d4b7b
--- /dev/null
+++ b/app/javascript/mastodon/actions/emojis.js
@@ -0,0 +1,14 @@
+import { saveSettings } from './settings';
+
+export const EMOJI_USE = 'EMOJI_USE';
+
+export function useEmoji(emoji) {
+  return dispatch => {
+    dispatch({
+      type: EMOJI_USE,
+      emoji,
+    });
+
+    dispatch(saveSettings());
+  };
+};
diff --git a/app/javascript/mastodon/actions/settings.js b/app/javascript/mastodon/actions/settings.js
index f9d304c96..79adca18c 100644
--- a/app/javascript/mastodon/actions/settings.js
+++ b/app/javascript/mastodon/actions/settings.js
@@ -1,6 +1,8 @@
 import axios from 'axios';
+import { debounce } from 'lodash';
 
 export const SETTING_CHANGE = 'SETTING_CHANGE';
+export const SETTING_SAVE   = 'SETTING_SAVE';
 
 export function changeSetting(key, value) {
   return dispatch => {
@@ -14,10 +16,16 @@ export function changeSetting(key, value) {
   };
 };
 
+const debouncedSave = debounce((dispatch, getState) => {
+  if (getState().getIn(['settings', 'saved'])) {
+    return;
+  }
+
+  const data = getState().get('settings').filter((_, key) => key !== 'saved').toJS();
+
+  axios.put('/api/web/settings', { data }).then(() => dispatch({ type: SETTING_SAVE }));
+}, 5000, { trailing: true });
+
 export function saveSettings() {
-  return (_, getState) => {
-    axios.put('/api/web/settings', {
-      data: getState().get('settings').toJS(),
-    });
-  };
+  return (dispatch, getState) => debouncedSave(dispatch, getState);
 };
diff --git a/app/javascript/mastodon/actions/timelines.js b/app/javascript/mastodon/actions/timelines.js
index 5c0cd93c7..09abe2702 100644
--- a/app/javascript/mastodon/actions/timelines.js
+++ b/app/javascript/mastodon/actions/timelines.js
@@ -17,6 +17,8 @@ export const TIMELINE_SCROLL_TOP = 'TIMELINE_SCROLL_TOP';
 export const TIMELINE_CONNECT    = 'TIMELINE_CONNECT';
 export const TIMELINE_DISCONNECT = 'TIMELINE_DISCONNECT';
 
+export const TIMELINE_CONTEXT_UPDATE = 'CONTEXT_UPDATE';
+
 export function refreshTimelineSuccess(timeline, statuses, skipLoading, next) {
   return {
     type: TIMELINE_REFRESH_SUCCESS,
@@ -30,6 +32,16 @@ export function refreshTimelineSuccess(timeline, statuses, skipLoading, next) {
 export function updateTimeline(timeline, status) {
   return (dispatch, getState) => {
     const references = status.reblog ? getState().get('statuses').filter((item, itemId) => (itemId === status.reblog.id || item.get('reblog') === status.reblog.id)).map((_, itemId) => itemId) : [];
+    const parents = [];
+
+    if (status.in_reply_to_id) {
+      let parent = getState().getIn(['statuses', status.in_reply_to_id]);
+
+      while (parent && parent.get('in_reply_to_id')) {
+        parents.push(parent.get('id'));
+        parent = getState().getIn(['statuses', parent.get('in_reply_to_id')]);
+      }
+    }
 
     dispatch({
       type: TIMELINE_UPDATE,
@@ -37,6 +49,14 @@ export function updateTimeline(timeline, status) {
       status,
       references,
     });
+
+    if (parents.length > 0) {
+      dispatch({
+        type: TIMELINE_CONTEXT_UPDATE,
+        status,
+        references: parents,
+      });
+    }
   };
 };
 
diff --git a/app/javascript/mastodon/components/autosuggest_emoji.js b/app/javascript/mastodon/components/autosuggest_emoji.js
index e2866e8e4..ce4383a60 100644
--- a/app/javascript/mastodon/components/autosuggest_emoji.js
+++ b/app/javascript/mastodon/components/autosuggest_emoji.js
@@ -1,6 +1,6 @@
 import React from 'react';
 import PropTypes from 'prop-types';
-import { unicodeMapping } from '../emojione_light';
+import unicodeMapping from '../features/emoji/emoji_unicode_mapping_light';
 
 const assetHost = process.env.CDN_HOST || '';
 
@@ -17,8 +17,13 @@ export default class AutosuggestEmoji extends React.PureComponent {
     if (emoji.custom) {
       url = emoji.imageUrl;
     } else {
-      const [ filename ] = unicodeMapping[emoji.native];
-      url = `${assetHost}/emoji/${filename}.svg`;
+      const mapping = unicodeMapping[emoji.native] || unicodeMapping[emoji.native.replace(/\uFE0F$/, '')];
+
+      if (!mapping) {
+        return null;
+      }
+
+      url = `${assetHost}/emoji/${mapping.filename}.svg`;
     }
 
     return (
diff --git a/app/javascript/mastodon/components/autosuggest_textarea.js b/app/javascript/mastodon/components/autosuggest_textarea.js
index 6f725885d..14a8d4c38 100644
--- a/app/javascript/mastodon/components/autosuggest_textarea.js
+++ b/app/javascript/mastodon/components/autosuggest_textarea.js
@@ -125,6 +125,16 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
     this.props.onKeyDown(e);
   }
 
+  onKeyUp = e => {
+    if (e.key === 'Escape' && this.state.suggestionsHidden) {
+      document.querySelector('.ui').parentElement.focus();
+    }
+
+    if (this.props.onKeyUp) {
+      this.props.onKeyUp(e);
+    }
+  }
+
   onBlur = () => {
     this.setState({ suggestionsHidden: true });
   }
@@ -173,7 +183,7 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
   }
 
   render () {
-    const { value, suggestions, disabled, placeholder, onKeyUp, autoFocus } = this.props;
+    const { value, suggestions, disabled, placeholder, autoFocus } = this.props;
     const { suggestionsHidden } = this.state;
     const style = { direction: 'ltr' };
 
@@ -195,7 +205,7 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
             value={value}
             onChange={this.onChange}
             onKeyDown={this.onKeyDown}
-            onKeyUp={onKeyUp}
+            onKeyUp={this.onKeyUp}
             onBlur={this.onBlur}
             onPaste={this.onPaste}
             style={style}
diff --git a/app/javascript/mastodon/components/column_header.js b/app/javascript/mastodon/components/column_header.js
index e0042b055..c47296a51 100644
--- a/app/javascript/mastodon/components/column_header.js
+++ b/app/javascript/mastodon/components/column_header.js
@@ -173,7 +173,7 @@ export default class ColumnHeader extends React.PureComponent {
 
     return (
       <div className={wrapperClassName}>
-        <h1 tabIndex={focusable && '0'} role='button' className={buttonClassName} aria-label={title} onClick={this.handleTitleClick}>
+        <h1 tabIndex={focusable ? 0 : null} role='button' className={buttonClassName} aria-label={title} onClick={this.handleTitleClick}>
           <i className={`fa fa-fw fa-${icon} column-header__icon`} />
           {title}
           <div className='column-header__buttons'>
@@ -200,7 +200,7 @@ export default class ColumnHeader extends React.PureComponent {
           </div>
         ) : null}
 
-        <div className={collapsibleClassName} tabIndex={collapsed && -1} onTransitionEnd={this.handleTransitionEnd}>
+        <div className={collapsibleClassName} tabIndex={collapsed ? -1 : null} onTransitionEnd={this.handleTransitionEnd}>
           <div className='column-header__collapsible-inner'>
             {(!collapsed || animating) && collapsedContent}
           </div>
diff --git a/app/javascript/mastodon/components/dropdown_menu.js b/app/javascript/mastodon/components/dropdown_menu.js
index c0fbcab6d..73ad46bb7 100644
--- a/app/javascript/mastodon/components/dropdown_menu.js
+++ b/app/javascript/mastodon/components/dropdown_menu.js
@@ -2,8 +2,9 @@ import React from 'react';
 import PropTypes from 'prop-types';
 import ImmutablePropTypes from 'react-immutable-proptypes';
 import IconButton from './icon_button';
-import { Overlay } from 'react-overlays';
-import { Motion, spring } from 'react-motion';
+import Overlay from 'react-overlays/lib/Overlay';
+import Motion from 'react-motion/lib/Motion';
+import spring from 'react-motion/lib/spring';
 import detectPassiveEvents from 'detect-passive-events';
 
 const listenerOptions = detectPassiveEvents.hasSupport ? { passive: true } : false;
diff --git a/app/javascript/mastodon/components/extended_video_player.js b/app/javascript/mastodon/components/extended_video_player.js
index 5ab5e9e58..f8bd067e8 100644
--- a/app/javascript/mastodon/components/extended_video_player.js
+++ b/app/javascript/mastodon/components/extended_video_player.js
@@ -5,6 +5,7 @@ export default class ExtendedVideoPlayer extends React.PureComponent {
 
   static propTypes = {
     src: PropTypes.string.isRequired,
+    alt: PropTypes.string,
     width: PropTypes.number,
     height: PropTypes.number,
     time: PropTypes.number,
@@ -31,15 +32,20 @@ export default class ExtendedVideoPlayer extends React.PureComponent {
   }
 
   render () {
+    const { src, muted, controls, alt } = this.props;
+
     return (
       <div className='extended-video-player'>
         <video
           ref={this.setRef}
-          src={this.props.src}
+          src={src}
           autoPlay
-          muted={this.props.muted}
-          controls={this.props.controls}
-          loop={!this.props.controls}
+          role='button'
+          tabIndex='0'
+          aria-label={alt}
+          muted={muted}
+          controls={controls}
+          loop={!controls}
         />
       </div>
     );
diff --git a/app/javascript/mastodon/components/intersection_observer_article.js b/app/javascript/mastodon/components/intersection_observer_article.js
index 575743350..e2ce9ec96 100644
--- a/app/javascript/mastodon/components/intersection_observer_article.js
+++ b/app/javascript/mastodon/components/intersection_observer_article.js
@@ -58,26 +58,31 @@ export default class IntersectionObserverArticle extends React.Component {
   }
 
   handleIntersection = (entry) => {
-    const { onHeightChange, saveHeightKey, id } = this.props;
+    this.entry = entry;
 
-    if (this.node && this.node.children.length !== 0) {
-      // save the height of the fully-rendered element
-      this.height = getRectFromEntry(entry).height;
+    scheduleIdleTask(this.calculateHeight);
+    this.setState(this.updateStateAfterIntersection);
+  }
 
-      if (onHeightChange && saveHeightKey) {
-        onHeightChange(saveHeightKey, id, this.height);
-      }
+  updateStateAfterIntersection = (prevState) => {
+    if (prevState.isIntersecting && !this.entry.isIntersecting) {
+      scheduleIdleTask(this.hideIfNotIntersecting);
     }
+    return {
+      isIntersecting: this.entry.isIntersecting,
+      isHidden: false,
+    };
+  }
 
-    this.setState((prevState) => {
-      if (prevState.isIntersecting && !entry.isIntersecting) {
-        scheduleIdleTask(this.hideIfNotIntersecting);
-      }
-      return {
-        isIntersecting: entry.isIntersecting,
-        isHidden: false,
-      };
-    });
+  calculateHeight = () => {
+    const { onHeightChange, saveHeightKey, id } = this.props;
+    // save the height of the fully-rendered element (this is expensive
+    // on Chrome, where we need to fall back to getBoundingClientRect)
+    this.height = getRectFromEntry(this.entry).height;
+
+    if (onHeightChange && saveHeightKey) {
+      onHeightChange(saveHeightKey, id, this.height);
+    }
   }
 
   hideIfNotIntersecting = () => {
diff --git a/app/javascript/mastodon/components/media_gallery.js b/app/javascript/mastodon/components/media_gallery.js
index 8bc1427d9..83cf8b871 100644
--- a/app/javascript/mastodon/components/media_gallery.js
+++ b/app/javascript/mastodon/components/media_gallery.js
@@ -9,7 +9,6 @@ import IconButton from './icon_button';
 import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
 import { isIOS } from '../is_mobile';
 import classNames from 'classnames';
-import sizeMe from 'react-sizeme';
 
 const messages = defineMessages({
   toggle_visible: { id: 'media_gallery.toggle_visible', defaultMessage: 'Toggle visibility' },
@@ -139,7 +138,7 @@ class Item extends React.PureComponent {
           onClick={this.handleClick}
           target='_blank'
         >
-          <img src={previewUrl} srcSet={srcSet} sizes={sizes} alt='' />
+          <img src={previewUrl} srcSet={srcSet} sizes={sizes} alt={attachment.get('description')} title={attachment.get('description')} />
         </a>
       );
     } else if (attachment.get('type') === 'gifv') {
@@ -149,6 +148,7 @@ class Item extends React.PureComponent {
         <div className={classNames('media-gallery__gifv', { autoplay: autoPlay })}>
           <video
             className='media-gallery__item-gifv-thumbnail'
+            aria-label={attachment.get('description')}
             role='application'
             src={attachment.get('url')}
             onClick={this.handleClick}
@@ -174,7 +174,6 @@ class Item extends React.PureComponent {
 }
 
 @injectIntl
-@sizeMe({})
 export default class MediaGallery extends React.PureComponent {
 
   static propTypes = {
@@ -211,21 +210,42 @@ export default class MediaGallery extends React.PureComponent {
     this.props.onOpenMedia(this.props.media, index);
   }
 
+  handleRef = (node) => {
+    if (node && this.isStandaloneEligible()) {
+      // offsetWidth triggers a layout, so only calculate when we need to
+      this.setState({
+        width: node.offsetWidth,
+      });
+    }
+  }
+
+  isStandaloneEligible() {
+    const { media, standalone } = this.props;
+    return standalone && media.size === 1 && media.getIn([0, 'meta', 'small', 'aspect']);
+  }
+
   render () {
-    const { media, intl, sensitive, height, standalone, size } = this.props;
+    const { media, intl, sensitive, height } = this.props;
+    const { width, visible } = this.state;
 
     let children;
 
-    const standaloneEligible = standalone && size.width && media.size === 1 && media.getIn([0, 'meta', 'small', 'aspect']);
     const style = {};
 
-    if (standaloneEligible) {
-      style.height = size.width / media.getIn([0, 'meta', 'small', 'aspect']);
+    if (this.isStandaloneEligible()) {
+      if (!visible && width) {
+        // only need to forcibly set the height in "sensitive" mode
+        style.height = width / this.props.media.getIn([0, 'meta', 'small', 'aspect']);
+      } else {
+        // layout automatically, using image's natural aspect ratio
+        style.height = '';
+      }
     } else {
+      // crop the image
       style.height = height;
     }
 
-    if (!this.state.visible) {
+    if (!visible) {
       let warning;
 
       if (sensitive) {
@@ -235,7 +255,7 @@ export default class MediaGallery extends React.PureComponent {
       }
 
       children = (
-        <button className='media-spoiler' onClick={this.handleOpen} style={style}>
+        <button className='media-spoiler' onClick={this.handleOpen} style={style} ref={this.handleRef}>
           <span className='media-spoiler__warning'>{warning}</span>
           <span className='media-spoiler__trigger'><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span>
         </button>
@@ -243,7 +263,7 @@ export default class MediaGallery extends React.PureComponent {
     } else {
       const size = media.take(4).size;
 
-      if (standaloneEligible) {
+      if (this.isStandaloneEligible()) {
         children = <Item standalone onClick={this.handleClick} attachment={media.get(0)} autoPlayGif={this.props.autoPlayGif} />;
       } else {
         children = media.take(4).map((attachment, i) => <Item key={attachment.get('id')} onClick={this.handleClick} attachment={attachment} autoPlayGif={this.props.autoPlayGif} index={i} size={size} />);
@@ -252,8 +272,8 @@ export default class MediaGallery extends React.PureComponent {
 
     return (
       <div className='media-gallery' style={style}>
-        <div className={classNames('spoiler-button', { 'spoiler-button--visible': this.state.visible })}>
-          <IconButton title={intl.formatMessage(messages.toggle_visible)} icon={this.state.visible ? 'eye' : 'eye-slash'} overlay onClick={this.handleOpen} />
+        <div className={classNames('spoiler-button', { 'spoiler-button--visible': visible })}>
+          <IconButton title={intl.formatMessage(messages.toggle_visible)} icon={visible ? 'eye' : 'eye-slash'} overlay onClick={this.handleOpen} />
         </div>
 
         {children}
diff --git a/app/javascript/mastodon/components/relative_timestamp.js b/app/javascript/mastodon/components/relative_timestamp.js
index 2717d2326..534d83fac 100644
--- a/app/javascript/mastodon/components/relative_timestamp.js
+++ b/app/javascript/mastodon/components/relative_timestamp.js
@@ -1,7 +1,15 @@
 import React from 'react';
-import { injectIntl, FormattedRelative } from 'react-intl';
+import { injectIntl, defineMessages } from 'react-intl';
 import PropTypes from 'prop-types';
 
+const messages = defineMessages({
+  just_now: { id: 'relative_time.just_now', defaultMessage: 'now' },
+  seconds: { id: 'relative_time.seconds', defaultMessage: '{number}s' },
+  minutes: { id: 'relative_time.minutes', defaultMessage: '{number}m' },
+  hours: { id: 'relative_time.hours', defaultMessage: '{number}h' },
+  days: { id: 'relative_time.days', defaultMessage: '{number}d' },
+});
+
 const dateFormatOptions = {
   hour12: false,
   year: 'numeric',
@@ -11,6 +19,47 @@ const dateFormatOptions = {
   minute: '2-digit',
 };
 
+const shortDateFormatOptions = {
+  month: 'numeric',
+  day: 'numeric',
+};
+
+const SECOND = 1000;
+const MINUTE = 1000 * 60;
+const HOUR   = 1000 * 60 * 60;
+const DAY    = 1000 * 60 * 60 * 24;
+
+const MAX_DELAY = 2147483647;
+
+const selectUnits = delta => {
+  const absDelta = Math.abs(delta);
+
+  if (absDelta < MINUTE) {
+    return 'second';
+  } else if (absDelta < HOUR) {
+    return 'minute';
+  } else if (absDelta < DAY) {
+    return 'hour';
+  }
+
+  return 'day';
+};
+
+const getUnitDelay = units => {
+  switch (units) {
+  case 'second':
+    return SECOND;
+  case 'minute':
+    return MINUTE;
+  case 'hour':
+    return HOUR;
+  case 'day':
+    return DAY;
+  default:
+    return MAX_DELAY;
+  }
+};
+
 @injectIntl
 export default class RelativeTimestamp extends React.Component {
 
@@ -19,20 +68,74 @@ export default class RelativeTimestamp extends React.Component {
     timestamp: PropTypes.string.isRequired,
   };
 
-  shouldComponentUpdate (nextProps) {
+  state = {
+    now: this.props.intl.now(),
+  };
+
+  shouldComponentUpdate (nextProps, nextState) {
     // As of right now the locale doesn't change without a new page load,
     // but we might as well check in case that ever changes.
     return this.props.timestamp !== nextProps.timestamp ||
-      this.props.intl.locale !== nextProps.intl.locale;
+      this.props.intl.locale !== nextProps.intl.locale ||
+      this.state.now !== nextState.now;
+  }
+
+  componentWillReceiveProps (nextProps) {
+    if (this.props.timestamp !== nextProps.timestamp) {
+      this.setState({ now: this.props.intl.now() });
+    }
+  }
+
+  componentDidMount () {
+    this._scheduleNextUpdate(this.props, this.state);
+  }
+
+  componentWillUpdate (nextProps, nextState) {
+    this._scheduleNextUpdate(nextProps, nextState);
+  }
+
+  _scheduleNextUpdate (props, state) {
+    clearTimeout(this._timer);
+
+    const { timestamp }  = props;
+    const delta          = (new Date(timestamp)).getTime() - state.now;
+    const unitDelay      = getUnitDelay(selectUnits(delta));
+    const unitRemainder  = Math.abs(delta % unitDelay);
+    const updateInterval = 1000 * 10;
+    const delay          = delta < 0 ? Math.max(updateInterval, unitDelay - unitRemainder) : Math.max(updateInterval, unitRemainder);
+
+    this._timer = setTimeout(() => {
+      this.setState({ now: this.props.intl.now() });
+    }, delay);
   }
 
   render () {
     const { timestamp, intl } = this.props;
-    const date = new Date(timestamp);
+
+    const date  = new Date(timestamp);
+    const delta = this.state.now - date.getTime();
+
+    let relativeTime;
+
+    if (delta < 10 * SECOND) {
+      relativeTime = intl.formatMessage(messages.just_now);
+    } else if (delta < 3 * DAY) {
+      if (delta < MINUTE) {
+        relativeTime = intl.formatMessage(messages.seconds, { number: Math.floor(delta / SECOND) });
+      } else if (delta < HOUR) {
+        relativeTime = intl.formatMessage(messages.minutes, { number: Math.floor(delta / MINUTE) });
+      } else if (delta < DAY) {
+        relativeTime = intl.formatMessage(messages.hours, { number: Math.floor(delta / HOUR) });
+      } else {
+        relativeTime = intl.formatMessage(messages.days, { number: Math.floor(delta / DAY) });
+      }
+    } else {
+      relativeTime = intl.formatDate(date, shortDateFormatOptions);
+    }
 
     return (
       <time dateTime={timestamp} title={intl.formatDate(date, dateFormatOptions)}>
-        <FormattedRelative value={date} />
+        {relativeTime}
       </time>
     );
   }
diff --git a/app/javascript/mastodon/components/scrollable_list.js b/app/javascript/mastodon/components/scrollable_list.js
index ff0540e5d..ab9d48510 100644
--- a/app/javascript/mastodon/components/scrollable_list.js
+++ b/app/javascript/mastodon/components/scrollable_list.js
@@ -6,6 +6,8 @@ import LoadMore from './load_more';
 import IntersectionObserverWrapper from '../features/ui/util/intersection_observer_wrapper';
 import { throttle } from 'lodash';
 import { List as ImmutableList } from 'immutable';
+import classNames from 'classnames';
+import { attachFullscreenListener, detachFullscreenListener, isFullscreen } from '../features/ui/util/fullscreen';
 
 export default class ScrollableList extends PureComponent {
 
@@ -66,6 +68,7 @@ export default class ScrollableList extends PureComponent {
   componentDidMount () {
     this.attachScrollListener();
     this.attachIntersectionObserver();
+    attachFullscreenListener(this.onFullScreenChange);
 
     // Handle initial scroll posiiton
     this.handleScroll();
@@ -92,6 +95,11 @@ export default class ScrollableList extends PureComponent {
   componentWillUnmount () {
     this.detachScrollListener();
     this.detachIntersectionObserver();
+    detachFullscreenListener(this.onFullScreenChange);
+  }
+
+  onFullScreenChange = () => {
+    this.setState({ fullscreen: isFullscreen() });
   }
 
   attachIntersectionObserver () {
@@ -137,34 +145,9 @@ export default class ScrollableList extends PureComponent {
     return this._lastMouseMove !== null && ((new Date()) - this._lastMouseMove < 600);
   }
 
-  handleKeyDown = (e) => {
-    if (['PageDown', 'PageUp'].includes(e.key) || (e.ctrlKey && ['End', 'Home'].includes(e.key))) {
-      const article = (() => {
-        switch (e.key) {
-        case 'PageDown':
-          return e.target.nodeName === 'ARTICLE' && e.target.nextElementSibling;
-        case 'PageUp':
-          return e.target.nodeName === 'ARTICLE' && e.target.previousElementSibling;
-        case 'End':
-          return this.node.querySelector('[role="feed"] > article:last-of-type');
-        case 'Home':
-          return this.node.querySelector('[role="feed"] > article:first-of-type');
-        default:
-          return null;
-        }
-      })();
-
-
-      if (article) {
-        e.preventDefault();
-        article.focus();
-        article.scrollIntoView();
-      }
-    }
-  }
-
   render () {
     const { children, scrollKey, trackScroll, shouldUpdateScroll, isLoading, hasMore, prepend, emptyMessage } = this.props;
+    const { fullscreen } = this.state;
     const childrenCount = React.Children.count(children);
 
     const loadMore     = (hasMore && childrenCount > 0) ? <LoadMore visible={!isLoading} onClick={this.handleLoadMore} /> : null;
@@ -172,8 +155,8 @@ export default class ScrollableList extends PureComponent {
 
     if (isLoading || childrenCount > 0 || !emptyMessage) {
       scrollableArea = (
-        <div className='scrollable' ref={this.setRef} onMouseMove={this.handleMouseMove} onMouseLeave={this.handleMouseLeave}>
-          <div role='feed' className='item-list' onKeyDown={this.handleKeyDown}>
+        <div className={classNames('scrollable', { fullscreen })} ref={this.setRef} onMouseMove={this.handleMouseMove} onMouseLeave={this.handleMouseLeave}>
+          <div role='feed' className='item-list'>
             {prepend}
 
             {React.Children.map(this.props.children, (child, index) => (
diff --git a/app/javascript/mastodon/components/status.js b/app/javascript/mastodon/components/status.js
index 9e65db85c..b9be20033 100644
--- a/app/javascript/mastodon/components/status.js
+++ b/app/javascript/mastodon/components/status.js
@@ -13,6 +13,8 @@ import StatusActionBar from './status_action_bar';
 import { FormattedMessage } from 'react-intl';
 import ImmutablePureComponent from 'react-immutable-pure-component';
 import { MediaGallery, Video } from '../features/ui/util/async-components';
+import { HotKeys } from 'react-hotkeys';
+import classNames from 'classnames';
 
 // We use the component (and not the container) since we do not want
 // to use the progress bar to show download progress
@@ -42,6 +44,8 @@ export default class Status extends ImmutablePureComponent {
     autoPlayGif: PropTypes.bool,
     muted: PropTypes.bool,
     hidden: PropTypes.bool,
+    onMoveUp: PropTypes.func,
+    onMoveDown: PropTypes.func,
   };
 
   state = {
@@ -92,16 +96,62 @@ export default class Status extends ImmutablePureComponent {
   }
 
   handleOpenVideo = startTime => {
-    this.props.onOpenVideo(this.props.status.getIn(['media_attachments', 0]), startTime);
+    this.props.onOpenVideo(this._properStatus().getIn(['media_attachments', 0]), startTime);
+  }
+
+  handleHotkeyReply = e => {
+    e.preventDefault();
+    this.props.onReply(this._properStatus(), this.context.router.history);
+  }
+
+  handleHotkeyFavourite = () => {
+    this.props.onFavourite(this._properStatus());
+  }
+
+  handleHotkeyBoost = e => {
+    this.props.onReblog(this._properStatus(), e);
+  }
+
+  handleHotkeyMention = e => {
+    e.preventDefault();
+    this.props.onMention(this._properStatus().get('account'), this.context.router.history);
+  }
+
+  handleHotkeyOpen = () => {
+    this.context.router.history.push(`/statuses/${this._properStatus().get('id')}`);
+  }
+
+  handleHotkeyOpenProfile = () => {
+    this.context.router.history.push(`/accounts/${this._properStatus().getIn(['account', 'id'])}`);
+  }
+
+  handleHotkeyMoveUp = () => {
+    this.props.onMoveUp(this.props.status.get('id'));
+  }
+
+  handleHotkeyMoveDown = () => {
+    this.props.onMoveDown(this.props.status.get('id'));
+  }
+
+  _properStatus () {
+    const { status } = this.props;
+
+    if (status.get('reblog', null) !== null && typeof status.get('reblog') === 'object') {
+      return status.get('reblog');
+    } else {
+      return status;
+    }
   }
 
   render () {
     let media = null;
-    let statusAvatar;
+    let statusAvatar, prepend;
 
-    const { status, account, hidden, ...other } = this.props;
+    const { hidden }     = this.props;
     const { isExpanded } = this.state;
 
+    let { status, account, ...other } = this.props;
+
     if (status === null) {
       return null;
     }
@@ -118,16 +168,15 @@ export default class Status extends ImmutablePureComponent {
     if (status.get('reblog', null) !== null && typeof status.get('reblog') === 'object') {
       const display_name_html = { __html: status.getIn(['account', 'display_name_html']) };
 
-      return (
-        <div className='status__wrapper' data-id={status.get('id')} >
-          <div className='status__prepend'>
-            <div className='status__prepend-icon-wrapper'><i className='fa fa-fw fa-retweet status__prepend-icon' /></div>
-            <FormattedMessage id='status.reblogged_by' defaultMessage='{name} boosted' values={{ name: <a onClick={this.handleAccountClick} data-id={status.getIn(['account', 'id'])} href={status.getIn(['account', 'url'])} className='status__display-name muted'><strong dangerouslySetInnerHTML={display_name_html} /></a> }} />
-          </div>
-
-          <Status {...other} status={status.get('reblog')} account={status.get('account')} />
+      prepend = (
+        <div className='status__prepend'>
+          <div className='status__prepend-icon-wrapper'><i className='fa fa-fw fa-retweet status__prepend-icon' /></div>
+          <FormattedMessage id='status.reblogged_by' defaultMessage='{name} boosted' values={{ name: <a onClick={this.handleAccountClick} data-id={status.getIn(['account', 'id'])} href={status.getIn(['account', 'url'])} className='status__display-name muted'><strong dangerouslySetInnerHTML={display_name_html} /></a> }} />
         </div>
       );
+
+      account = status.get('account');
+      status  = status.get('reblog');
     }
 
     if (status.get('media_attachments').size > 0 && !this.props.muted) {
@@ -163,26 +212,43 @@ export default class Status extends ImmutablePureComponent {
       statusAvatar = <AvatarOverlay account={status.get('account')} friend={account} />;
     }
 
+    const handlers = this.props.muted ? {} : {
+      reply: this.handleHotkeyReply,
+      favourite: this.handleHotkeyFavourite,
+      boost: this.handleHotkeyBoost,
+      mention: this.handleHotkeyMention,
+      open: this.handleHotkeyOpen,
+      openProfile: this.handleHotkeyOpenProfile,
+      moveUp: this.handleHotkeyMoveUp,
+      moveDown: this.handleHotkeyMoveDown,
+    };
+
     return (
-      <div className={`status ${this.props.muted ? 'muted' : ''} status-${status.get('visibility')}`} data-id={status.get('id')}>
-        <div className='status__info'>
-          <a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener'><RelativeTimestamp timestamp={status.get('created_at')} /></a>
+      <HotKeys handlers={handlers}>
+        <div className={classNames('status__wrapper', `status__wrapper-${status.get('visibility')}`, { focusable: !this.props.muted })} tabIndex={this.props.muted ? null : 0}>
+          {prepend}
 
-          <a onClick={this.handleAccountClick} target='_blank' data-id={status.getIn(['account', 'id'])} href={status.getIn(['account', 'url'])} className='status__display-name'>
-            <div className='status__avatar'>
-              {statusAvatar}
-            </div>
+          <div className={classNames('status', `status-${status.get('visibility')}`, { muted: this.props.muted })} data-id={status.get('id')}>
+            <div className='status__info'>
+              <a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener'><RelativeTimestamp timestamp={status.get('created_at')} /></a>
 
-            <DisplayName account={status.get('account')} />
-          </a>
-        </div>
+              <a onClick={this.handleAccountClick} target='_blank' data-id={status.getIn(['account', 'id'])} href={status.getIn(['account', 'url'])} title={status.getIn(['account', 'acct'])} className='status__display-name'>
+                <div className='status__avatar'>
+                  {statusAvatar}
+                </div>
 
-        <StatusContent status={status} onClick={this.handleClick} expanded={isExpanded} onExpandedToggle={this.handleExpandedToggle} />
+                <DisplayName account={status.get('account')} />
+              </a>
+            </div>
+
+            <StatusContent status={status} onClick={this.handleClick} expanded={isExpanded} onExpandedToggle={this.handleExpandedToggle} />
 
-        {media}
+            {media}
 
-        <StatusActionBar {...this.props} />
-      </div>
+            <StatusActionBar status={status} account={account} {...other} />
+          </div>
+        </div>
+      </HotKeys>
     );
   }
 
diff --git a/app/javascript/mastodon/components/status_content.js b/app/javascript/mastodon/components/status_content.js
index d1381f176..8ad60b9d6 100644
--- a/app/javascript/mastodon/components/status_content.js
+++ b/app/javascript/mastodon/components/status_content.js
@@ -147,7 +147,7 @@ export default class StatusContent extends React.PureComponent {
       }
 
       return (
-        <div className={classNames} ref={this.setRef} tabIndex='0' aria-label={status.get('search_index')} onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp}>
+        <div className={classNames} ref={this.setRef} tabIndex='0' onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp}>
           <p style={{ marginBottom: hidden && status.get('mentions').isEmpty() ? '0px' : null }}>
             <span dangerouslySetInnerHTML={spoilerContent} />
             {' '}
@@ -164,7 +164,6 @@ export default class StatusContent extends React.PureComponent {
         <div
           ref={this.setRef}
           tabIndex='0'
-          aria-label={status.get('search_index')}
           className={classNames}
           style={directionStyle}
           onMouseDown={this.handleMouseDown}
@@ -176,7 +175,6 @@ export default class StatusContent extends React.PureComponent {
       return (
         <div
           tabIndex='0'
-          aria-label={status.get('search_index')}
           ref={this.setRef}
           className='status__content'
           style={directionStyle}
diff --git a/app/javascript/mastodon/components/status_list.js b/app/javascript/mastodon/components/status_list.js
index 9026ebb0c..214955591 100644
--- a/app/javascript/mastodon/components/status_list.js
+++ b/app/javascript/mastodon/components/status_list.js
@@ -25,18 +25,45 @@ export default class StatusList extends ImmutablePureComponent {
     trackScroll: true,
   };
 
+  handleMoveUp = id => {
+    const elementIndex = this.props.statusIds.indexOf(id) - 1;
+    this._selectChild(elementIndex);
+  }
+
+  handleMoveDown = id => {
+    const elementIndex = this.props.statusIds.indexOf(id) + 1;
+    this._selectChild(elementIndex);
+  }
+
+  _selectChild (index) {
+    const element = this.node.node.querySelector(`article:nth-of-type(${index + 1}) .focusable`);
+
+    if (element) {
+      element.focus();
+    }
+  }
+
+  setRef = c => {
+    this.node = c;
+  }
+
   render () {
     const { statusIds, ...other } = this.props;
     const { isLoading } = other;
 
     const scrollableContent = (isLoading || statusIds.size > 0) ? (
       statusIds.map((statusId) => (
-        <StatusContainer key={statusId} id={statusId} />
+        <StatusContainer
+          key={statusId}
+          id={statusId}
+          onMoveUp={this.handleMoveUp}
+          onMoveDown={this.handleMoveDown}
+        />
       ))
     ) : null;
 
     return (
-      <ScrollableList {...other}>
+      <ScrollableList {...other} ref={this.setRef}>
         {scrollableContent}
       </ScrollableList>
     );
diff --git a/app/javascript/mastodon/components/video_player.js b/app/javascript/mastodon/components/video_player.js
deleted file mode 100644
index 26914f113..000000000
--- a/app/javascript/mastodon/components/video_player.js
+++ /dev/null
@@ -1,207 +0,0 @@
-//  THIS FILE EXISTS FOR UPSTREAM COMPATIBILITY & SHOULDN'T BE USED !!
-//  SEE INSTEAD : glitch/components/status/player
-
-import React from 'react';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import PropTypes from 'prop-types';
-import IconButton from './icon_button';
-import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
-import { isIOS } from '../is_mobile';
-
-const messages = defineMessages({
-  toggle_sound: { id: 'video_player.toggle_sound', defaultMessage: 'Toggle sound' },
-  toggle_visible: { id: 'video_player.toggle_visible', defaultMessage: 'Toggle visibility' },
-  expand_video: { id: 'video_player.expand', defaultMessage: 'Expand video' },
-});
-
-@injectIntl
-export default class VideoPlayer extends React.PureComponent {
-
-  static contextTypes = {
-    router: PropTypes.object,
-  };
-
-  static propTypes = {
-    media: ImmutablePropTypes.map.isRequired,
-    width: PropTypes.number,
-    height: PropTypes.number,
-    sensitive: PropTypes.bool,
-    intl: PropTypes.object.isRequired,
-    autoplay: PropTypes.bool,
-    onOpenVideo: PropTypes.func.isRequired,
-  };
-
-  static defaultProps = {
-    width: 239,
-    height: 110,
-  };
-
-  state = {
-    visible: !this.props.sensitive,
-    preview: true,
-    muted: true,
-    hasAudio: true,
-    videoError: false,
-  };
-
-  handleClick = () => {
-    this.setState({ muted: !this.state.muted });
-  }
-
-  handleVideoClick = (e) => {
-    e.stopPropagation();
-
-    const node = this.video;
-
-    if (node.paused) {
-      node.play();
-    } else {
-      node.pause();
-    }
-  }
-
-  handleOpen = () => {
-    this.setState({ preview: !this.state.preview });
-  }
-
-  handleVisibility = () => {
-    this.setState({
-      visible: !this.state.visible,
-      preview: true,
-    });
-  }
-
-  handleExpand = () => {
-    this.video.pause();
-    this.props.onOpenVideo(this.props.media, this.video.currentTime);
-  }
-
-  setRef = (c) => {
-    this.video = c;
-  }
-
-  handleLoadedData = () => {
-    if (('WebkitAppearance' in document.documentElement.style && this.video.audioTracks.length === 0) || this.video.mozHasAudio === false) {
-      this.setState({ hasAudio: false });
-    }
-  }
-
-  handleVideoError = () => {
-    this.setState({ videoError: true });
-  }
-
-  componentDidMount () {
-    if (!this.video) {
-      return;
-    }
-
-    this.video.addEventListener('loadeddata', this.handleLoadedData);
-    this.video.addEventListener('error', this.handleVideoError);
-  }
-
-  componentDidUpdate () {
-    if (!this.video) {
-      return;
-    }
-
-    this.video.addEventListener('loadeddata', this.handleLoadedData);
-    this.video.addEventListener('error', this.handleVideoError);
-  }
-
-  componentWillUnmount () {
-    if (!this.video) {
-      return;
-    }
-
-    this.video.removeEventListener('loadeddata', this.handleLoadedData);
-    this.video.removeEventListener('error', this.handleVideoError);
-  }
-
-  render () {
-    const { media, intl, width, height, sensitive, autoplay } = this.props;
-
-    let spoilerButton = (
-      <div className={`status__video-player-spoiler ${this.state.visible ? 'status__video-player-spoiler--visible' : ''}`}>
-        <IconButton overlay title={intl.formatMessage(messages.toggle_visible)} icon={this.state.visible ? 'eye' : 'eye-slash'} onClick={this.handleVisibility} />
-      </div>
-    );
-
-    let expandButton = '';
-
-    if (this.context.router) {
-      expandButton = (
-        <div className='status__video-player-expand'>
-          <IconButton overlay title={intl.formatMessage(messages.expand_video)} icon='expand' onClick={this.handleExpand} />
-        </div>
-      );
-    }
-
-    let muteButton = '';
-
-    if (this.state.hasAudio) {
-      muteButton = (
-        <div className='status__video-player-mute'>
-          <IconButton overlay title={intl.formatMessage(messages.toggle_sound)} icon={this.state.muted ? 'volume-off' : 'volume-up'} onClick={this.handleClick} />
-        </div>
-      );
-    }
-
-    if (!this.state.visible) {
-      if (sensitive) {
-        return (
-          <button style={{ width: `${width}px`, height: `${height}px`, marginTop: '8px' }} className='media-spoiler' onClick={this.handleVisibility}>
-            {spoilerButton}
-            <span className='media-spoiler__warning'><FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' /></span>
-            <span className='media-spoiler__trigger'><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span>
-          </button>
-        );
-      } else {
-        return (
-          <button style={{ width: `${width}px`, height: `${height}px`, marginTop: '8px' }} className='media-spoiler' onClick={this.handleVisibility}>
-            {spoilerButton}
-            <span className='media-spoiler__warning'><FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' /></span>
-            <span className='media-spoiler__trigger'><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span>
-          </button>
-        );
-      }
-    }
-
-    if (this.state.preview && !autoplay) {
-      return (
-        <button className='media-spoiler-video' style={{ width: `${width}px`, height: `${height}px`, backgroundImage: `url(${media.get('preview_url')})` }} onClick={this.handleOpen}>
-          {spoilerButton}
-          <div className='media-spoiler-video-play-icon'><i className='fa fa-play' /></div>
-        </button>
-      );
-    }
-
-    if (this.state.videoError) {
-      return (
-        <div style={{ width: `${width}px`, height: `${height}px` }} className='video-error-cover' >
-          <span className='media-spoiler__warning'><FormattedMessage id='video_player.video_error' defaultMessage='Video could not be played' /></span>
-        </div>
-      );
-    }
-
-    return (
-      <div className='status__video-player' style={{ width: `${width}px`, height: `${height}px` }}>
-        {spoilerButton}
-        {muteButton}
-        {expandButton}
-
-        <video
-          className='status__video-player-video'
-          role='button'
-          tabIndex='0'
-          ref={this.setRef}
-          src={media.get('url')}
-          autoPlay={!isIOS()}
-          loop
-          muted={this.state.muted}
-          onClick={this.handleVideoClick}
-        />
-      </div>
-    );
-  }
-
-}
diff --git a/app/javascript/mastodon/containers/mastodon.js b/app/javascript/mastodon/containers/mastodon.js
index db2a5f269..6beffca1c 100644
--- a/app/javascript/mastodon/containers/mastodon.js
+++ b/app/javascript/mastodon/containers/mastodon.js
@@ -3,9 +3,8 @@ import { Provider } from 'react-redux';
 import PropTypes from 'prop-types';
 import configureStore from '../store/configureStore';
 import { showOnboardingOnce } from '../actions/onboarding';
-import BrowserRouter from 'react-router-dom/BrowserRouter';
-import Route from 'react-router-dom/Route';
-import ScrollContext from 'react-router-scroll/lib/ScrollBehaviorContext';
+import { BrowserRouter, Route } from 'react-router-dom';
+import { ScrollContext } from 'react-router-scroll';
 import UI from '../features/ui';
 import { hydrateStore } from '../actions/store';
 import { connectUserStream } from '../actions/streaming';
diff --git a/app/javascript/mastodon/containers/timeline_container.js b/app/javascript/mastodon/containers/timeline_container.js
index 6b545ef09..4be037955 100644
--- a/app/javascript/mastodon/containers/timeline_container.js
+++ b/app/javascript/mastodon/containers/timeline_container.js
@@ -6,6 +6,7 @@ import { hydrateStore } from '../actions/store';
 import { IntlProvider, addLocaleData } from 'react-intl';
 import { getLocale } from '../locales';
 import PublicTimeline from '../features/standalone/public_timeline';
+import HashtagTimeline from '../features/standalone/hashtag_timeline';
 
 const { localeData, messages } = getLocale();
 addLocaleData(localeData);
@@ -22,15 +23,24 @@ export default class TimelineContainer extends React.PureComponent {
 
   static propTypes = {
     locale: PropTypes.string.isRequired,
+    hashtag: PropTypes.string,
   };
 
   render () {
-    const { locale } = this.props;
+    const { locale, hashtag } = this.props;
+
+    let timeline;
+
+    if (hashtag) {
+      timeline = <HashtagTimeline hashtag={hashtag} />;
+    } else {
+      timeline = <PublicTimeline />;
+    }
 
     return (
       <IntlProvider locale={locale} messages={messages}>
         <Provider store={store}>
-          <PublicTimeline />
+          {timeline}
         </Provider>
       </IntlProvider>
     );
diff --git a/app/javascript/mastodon/emoji.js b/app/javascript/mastodon/emoji.js
deleted file mode 100644
index d75f6f598..000000000
--- a/app/javascript/mastodon/emoji.js
+++ /dev/null
@@ -1,72 +0,0 @@
-import { unicodeMapping } from './emojione_light';
-import Trie from 'substring-trie';
-
-const trie = new Trie(Object.keys(unicodeMapping));
-
-const assetHost = process.env.CDN_HOST || '';
-
-const emojify = (str, customEmojis = {}) => {
-  let rtn = '';
-  for (;;) {
-    let match, i = 0, tag;
-    while (i < str.length && (tag = '<&'.indexOf(str[i])) === -1 && str[i] !== ':' && !(match = trie.search(str.slice(i)))) {
-      i += str.codePointAt(i) < 65536 ? 1 : 2;
-    }
-    if (i === str.length)
-      break;
-    else if (tag >= 0) {
-      const tagend = str.indexOf('>;'[tag], i + 1) + 1;
-      if (!tagend)
-        break;
-      rtn += str.slice(0, tagend);
-      str = str.slice(tagend);
-    } else if (str[i] === ':') {
-      try {
-        // if replacing :shortname: succeed, exit this block with "continue"
-        const closeColon = str.indexOf(':', i + 1) + 1;
-        if (!closeColon) throw null; // no pair of ':'
-        const lt = str.indexOf('<', i + 1);
-        if (!(lt === -1 || lt >= closeColon)) throw null; // tag appeared before closing ':'
-        const shortname = str.slice(i, closeColon);
-        if (shortname in customEmojis) {
-          rtn += str.slice(0, i) + `<img draggable="false" class="emojione" alt="${shortname}" title="${shortname}" src="${customEmojis[shortname]}" />`;
-          str = str.slice(closeColon);
-          continue;
-        }
-      } catch (e) {}
-      // replacing :shortname: failed
-      rtn += str.slice(0, i + 1);
-      str = str.slice(i + 1);
-    } else {
-      const [filename, shortCode] = unicodeMapping[match];
-      rtn += str.slice(0, i) + `<img draggable="false" class="emojione" alt="${match}" title=":${shortCode}:" src="${assetHost}/emoji/${filename}.svg" />`;
-      str = str.slice(i + match.length);
-    }
-  }
-  return rtn + str;
-};
-
-export default emojify;
-
-export const buildCustomEmojis = customEmojis => {
-  const emojis = [];
-
-  customEmojis.forEach(emoji => {
-    const shortcode = emoji.get('shortcode');
-    const url       = emoji.get('url');
-    const name      = shortcode.replace(':', '');
-
-    emojis.push({
-      id: name,
-      name,
-      short_names: [name],
-      text: '',
-      emoticons: [],
-      keywords: [name],
-      imageUrl: url,
-      custom: true,
-    });
-  });
-
-  return emojis;
-};
diff --git a/app/javascript/mastodon/emojione_light.js b/app/javascript/mastodon/emojione_light.js
deleted file mode 100644
index 2296497b0..000000000
--- a/app/javascript/mastodon/emojione_light.js
+++ /dev/null
@@ -1,38 +0,0 @@
-// @preval
-// http://www.unicode.org/Public/emoji/5.0/emoji-test.txt
-
-const emojis         = require('./emoji_map.json');
-const { emojiIndex } = require('emoji-mart');
-const excluded       = ['®', '©', '™'];
-const skins          = ['🏻', '🏼', '🏽', '🏾', '🏿'];
-const shortcodeMap   = {};
-
-Object.keys(emojiIndex.emojis).forEach(key => {
-  shortcodeMap[emojiIndex.emojis[key].native] = emojiIndex.emojis[key].id;
-});
-
-const stripModifiers = unicode => {
-  skins.forEach(tone => {
-    unicode = unicode.replace(tone, '');
-  });
-
-  return unicode;
-};
-
-Object.keys(emojis).forEach(key => {
-  if (excluded.includes(key)) {
-    delete emojis[key];
-    return;
-  }
-
-  const normalizedKey = stripModifiers(key);
-  let shortcode       = shortcodeMap[normalizedKey];
-
-  if (!shortcode) {
-    shortcode = shortcodeMap[normalizedKey + '\uFE0F'];
-  }
-
-  emojis[key] = [emojis[key], shortcode];
-});
-
-module.exports.unicodeMapping = emojis;
diff --git a/app/javascript/mastodon/features/account/components/action_bar.js b/app/javascript/mastodon/features/account/components/action_bar.js
index 9e8fea69d..2819ae252 100644
--- a/app/javascript/mastodon/features/account/components/action_bar.js
+++ b/app/javascript/mastodon/features/account/components/action_bar.js
@@ -2,7 +2,7 @@ import React from 'react';
 import ImmutablePropTypes from 'react-immutable-proptypes';
 import PropTypes from 'prop-types';
 import DropdownMenuContainer from '../../../containers/dropdown_menu_container';
-import Link from 'react-router-dom/Link';
+import { Link } from 'react-router-dom';
 import { defineMessages, injectIntl, FormattedMessage, FormattedNumber } from 'react-intl';
 
 const messages = defineMessages({
diff --git a/app/javascript/mastodon/features/compose/components/compose_form.js b/app/javascript/mastodon/features/compose/components/compose_form.js
index b85105c53..097dccfb4 100644
--- a/app/javascript/mastodon/features/compose/components/compose_form.js
+++ b/app/javascript/mastodon/features/compose/components/compose_form.js
@@ -5,7 +5,6 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
 import PropTypes from 'prop-types';
 import ReplyIndicatorContainer from '../containers/reply_indicator_container';
 import AutosuggestTextarea from '../../../components/autosuggest_textarea';
-import { debounce } from 'lodash';
 import UploadButtonContainer from '../containers/upload_button_container';
 import { defineMessages, injectIntl } from 'react-intl';
 import Collapsable from '../../../components/collapsable';
@@ -94,9 +93,9 @@ export default class ComposeForm extends ImmutablePureComponent {
     this.props.onClearSuggestions();
   }
 
-  onSuggestionsFetchRequested = debounce((token) => {
+  onSuggestionsFetchRequested = (token) => {
     this.props.onFetchSuggestions(token);
-  }, 500, { trailing: true })
+  }
 
   onSuggestionSelected = (tokenStart, token, value) => {
     this._restoreCaret = null;
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 621cc21ce..dffa04ff0 100644
--- a/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js
+++ b/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js
@@ -1,11 +1,12 @@
 import React from 'react';
 import PropTypes from 'prop-types';
 import { defineMessages, injectIntl } from 'react-intl';
-import { Picker, Emoji } from 'emoji-mart';
-import { Overlay } from 'react-overlays';
+import { EmojiPicker as EmojiPickerAsync } from '../../ui/util/async-components';
+import Overlay from 'react-overlays/lib/Overlay';
 import classNames from 'classnames';
 import ImmutablePropTypes from 'react-immutable-proptypes';
 import detectPassiveEvents from 'detect-passive-events';
+import { buildCustomEmojis } from '../../emoji/emoji';
 
 const messages = defineMessages({
   emoji: { id: 'emoji_button.label', defaultMessage: 'Insert emoji' },
@@ -25,9 +26,24 @@ const messages = defineMessages({
 });
 
 const assetHost = process.env.CDN_HOST || '';
+let EmojiPicker, Emoji; // load asynchronously
+
 const backgroundImageFn = () => `${assetHost}/emoji/sheet.png`;
 const listenerOptions = detectPassiveEvents.hasSupport ? { passive: true } : false;
 
+const categoriesSort = [
+  'recent',
+  'custom',
+  'people',
+  'nature',
+  'foods',
+  'activity',
+  'places',
+  'objects',
+  'symbols',
+  'flags',
+];
+
 class ModifierPickerMenu extends React.PureComponent {
 
   static propTypes = {
@@ -36,9 +52,8 @@ class ModifierPickerMenu extends React.PureComponent {
     onClose: PropTypes.func.isRequired,
   };
 
-  handleClick = (e) => {
-    const modifier = [].slice.call(e.currentTarget.parentNode.children).indexOf(e.target) + 1;
-    this.props.onSelect(modifier);
+  handleClick = e => {
+    this.props.onSelect(e.currentTarget.getAttribute('data-index') * 1);
   }
 
   componentWillReceiveProps (nextProps) {
@@ -78,12 +93,12 @@ class ModifierPickerMenu extends React.PureComponent {
 
     return (
       <div className='emoji-picker-dropdown__modifiers__menu' style={{ display: active ? 'block' : 'none' }} ref={this.setRef}>
-        <button onClick={this.handleClick}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={1} backgroundImageFn={backgroundImageFn} /></button>
-        <button onClick={this.handleClick}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={2} backgroundImageFn={backgroundImageFn} /></button>
-        <button onClick={this.handleClick}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={3} backgroundImageFn={backgroundImageFn} /></button>
-        <button onClick={this.handleClick}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={4} backgroundImageFn={backgroundImageFn} /></button>
-        <button onClick={this.handleClick}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={5} backgroundImageFn={backgroundImageFn} /></button>
-        <button onClick={this.handleClick}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={6} backgroundImageFn={backgroundImageFn} /></button>
+        <button onClick={this.handleClick} data-index={1}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={1} backgroundImageFn={backgroundImageFn} /></button>
+        <button onClick={this.handleClick} data-index={2}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={2} backgroundImageFn={backgroundImageFn} /></button>
+        <button onClick={this.handleClick} data-index={3}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={3} backgroundImageFn={backgroundImageFn} /></button>
+        <button onClick={this.handleClick} data-index={4}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={4} backgroundImageFn={backgroundImageFn} /></button>
+        <button onClick={this.handleClick} data-index={5}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={5} backgroundImageFn={backgroundImageFn} /></button>
+        <button onClick={this.handleClick} data-index={6}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={6} backgroundImageFn={backgroundImageFn} /></button>
       </div>
     );
   }
@@ -131,6 +146,8 @@ class EmojiPickerMenu extends React.PureComponent {
 
   static propTypes = {
     custom_emojis: ImmutablePropTypes.list,
+    frequentlyUsedEmojis: PropTypes.arrayOf(PropTypes.string),
+    loading: PropTypes.bool,
     onClose: PropTypes.func.isRequired,
     onPick: PropTypes.func.isRequired,
     style: PropTypes.object,
@@ -138,16 +155,20 @@ class EmojiPickerMenu extends React.PureComponent {
     arrowOffsetLeft: PropTypes.string,
     arrowOffsetTop: PropTypes.string,
     intl: PropTypes.object.isRequired,
+    skinTone: PropTypes.number.isRequired,
+    onSkinTone: PropTypes.func.isRequired,
+    autoPlay: PropTypes.bool,
   };
 
   static defaultProps = {
     style: {},
+    loading: true,
     placement: 'bottom',
+    frequentlyUsedEmojis: [],
   };
 
   state = {
     modifierOpen: false,
-    modifier: 1,
   };
 
   handleDocumentClick = e => {
@@ -210,35 +231,43 @@ class EmojiPickerMenu extends React.PureComponent {
   }
 
   handleModifierChange = modifier => {
-    if (modifier !== this.state.modifier) {
-      this.setState({ modifier });
-    }
+    this.props.onSkinTone(modifier);
   }
 
   render () {
-    const { style, intl } = this.props;
+    const { loading, style, intl, custom_emojis, autoPlay, skinTone, frequentlyUsedEmojis } = this.props;
+
+    if (loading) {
+      return <div style={{ width: 299 }} />;
+    }
+
     const title = intl.formatMessage(messages.emoji);
-    const { modifierOpen, modifier } = this.state;
+    const { modifierOpen } = this.state;
 
     return (
       <div className={classNames('emoji-picker-dropdown__menu', { selecting: modifierOpen })} style={style} ref={this.setRef}>
-        <Picker
+        <EmojiPicker
           perLine={8}
           emojiSize={22}
           sheetSize={32}
+          custom={buildCustomEmojis(custom_emojis, autoPlay)}
           color=''
           emoji=''
           set='twitter'
           title={title}
           i18n={this.getI18n()}
           onClick={this.handleClick}
-          skin={modifier}
+          include={categoriesSort}
+          recent={frequentlyUsedEmojis}
+          skin={skinTone}
+          showPreview={false}
           backgroundImageFn={backgroundImageFn}
+          emojiTooltip
         />
 
         <ModifierPicker
           active={modifierOpen}
-          modifier={modifier}
+          modifier={skinTone}
           onOpen={this.handleModifierOpen}
           onClose={this.handleModifierClose}
           onChange={this.handleModifierChange}
@@ -254,12 +283,17 @@ export default class EmojiPickerDropdown extends React.PureComponent {
 
   static propTypes = {
     custom_emojis: ImmutablePropTypes.list,
+    frequentlyUsedEmojis: PropTypes.arrayOf(PropTypes.string),
+    autoPlay: PropTypes.bool,
     intl: PropTypes.object.isRequired,
     onPickEmoji: PropTypes.func.isRequired,
+    onSkinTone: PropTypes.func.isRequired,
+    skinTone: PropTypes.number.isRequired,
   };
 
   state = {
     active: false,
+    loading: false,
   };
 
   setRef = (c) => {
@@ -268,6 +302,19 @@ export default class EmojiPickerDropdown extends React.PureComponent {
 
   onShowDropdown = () => {
     this.setState({ active: true });
+
+    if (!EmojiPicker) {
+      this.setState({ loading: true });
+
+      EmojiPickerAsync().then(EmojiMart => {
+        EmojiPicker = EmojiMart.Picker;
+        Emoji       = EmojiMart.Emoji;
+
+        this.setState({ loading: false });
+      }).catch(() => {
+        this.setState({ loading: false });
+      });
+    }
   }
 
   onHideDropdown = () => {
@@ -275,7 +322,7 @@ export default class EmojiPickerDropdown extends React.PureComponent {
   }
 
   onToggle = (e) => {
-    if (!e.key || e.key === 'Enter') {
+    if (!this.state.loading && (!e.key || e.key === 'Enter')) {
       if (this.state.active) {
         this.onHideDropdown();
       } else {
@@ -299,15 +346,15 @@ export default class EmojiPickerDropdown extends React.PureComponent {
   }
 
   render () {
-    const { intl, onPickEmoji } = this.props;
+    const { intl, onPickEmoji, autoPlay, onSkinTone, skinTone, frequentlyUsedEmojis } = this.props;
     const title = intl.formatMessage(messages.emoji);
-    const { active } = this.state;
+    const { active, loading } = this.state;
 
     return (
       <div className='emoji-picker-dropdown' onKeyDown={this.handleKeyDown}>
         <div ref={this.setTargetRef} className='emoji-button' title={title} aria-label={title} aria-expanded={active} role='button' onClick={this.onToggle} onKeyDown={this.onToggle} tabIndex={0}>
           <img
-            className='emojione'
+            className={classNames('emojione', { 'pulse-loading': active && loading })}
             alt='🙂'
             src={`${assetHost}/emoji/1f602.svg`}
           />
@@ -316,8 +363,13 @@ export default class EmojiPickerDropdown extends React.PureComponent {
         <Overlay show={active} placement='bottom' target={this.findTarget}>
           <EmojiPickerMenu
             custom_emojis={this.props.custom_emojis}
+            loading={loading}
             onClose={this.onHideDropdown}
             onPick={onPickEmoji}
+            autoPlay={autoPlay}
+            onSkinTone={onSkinTone}
+            skinTone={skinTone}
+            frequentlyUsedEmojis={frequentlyUsedEmojis}
           />
         </Overlay>
       </div>
diff --git a/app/javascript/mastodon/features/compose/components/privacy_dropdown.js b/app/javascript/mastodon/features/compose/components/privacy_dropdown.js
index 0474dfb4e..e38ed38c1 100644
--- a/app/javascript/mastodon/features/compose/components/privacy_dropdown.js
+++ b/app/javascript/mastodon/features/compose/components/privacy_dropdown.js
@@ -2,7 +2,11 @@ import React from 'react';
 import PropTypes from 'prop-types';
 import { injectIntl, defineMessages } from 'react-intl';
 import IconButton from '../../../components/icon_button';
+import Overlay from 'react-overlays/lib/Overlay';
+import Motion from 'react-motion/lib/Motion';
+import spring from 'react-motion/lib/spring';
 import detectPassiveEvents from 'detect-passive-events';
+import classNames from 'classnames';
 
 const messages = defineMessages({
   public_short: { id: 'privacy.public.short', defaultMessage: 'Public' },
@@ -16,10 +20,77 @@ const messages = defineMessages({
   change_privacy: { id: 'privacy.change', defaultMessage: 'Adjust status privacy' },
 });
 
-const iconStyle = {
-  height: null,
-  lineHeight: '27px',
-};
+const listenerOptions = detectPassiveEvents.hasSupport ? { passive: true } : false;
+
+class PrivacyDropdownMenu extends React.PureComponent {
+
+  static propTypes = {
+    style: PropTypes.object,
+    items: PropTypes.array.isRequired,
+    value: PropTypes.string.isRequired,
+    onClose: PropTypes.func.isRequired,
+    onChange: PropTypes.func.isRequired,
+  };
+
+  handleDocumentClick = e => {
+    if (this.node && !this.node.contains(e.target)) {
+      this.props.onClose();
+    }
+  }
+
+  handleClick = e => {
+    if (e.key === 'Escape') {
+      this.props.onClose();
+    } else if (!e.key || e.key === 'Enter') {
+      const value = e.currentTarget.getAttribute('data-index');
+
+      e.preventDefault();
+
+      this.props.onClose();
+      this.props.onChange(value);
+    }
+  }
+
+  componentDidMount () {
+    document.addEventListener('click', this.handleDocumentClick, false);
+    document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);
+  }
+
+  componentWillUnmount () {
+    document.removeEventListener('click', this.handleDocumentClick, false);
+    document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions);
+  }
+
+  setRef = c => {
+    this.node = c;
+  }
+
+  render () {
+    const { style, items, value } = this.props;
+
+    return (
+      <Motion defaultStyle={{ opacity: 0, scaleX: 0.85, scaleY: 0.75 }} style={{ opacity: spring(1, { damping: 35, stiffness: 400 }), scaleX: spring(1, { damping: 35, stiffness: 400 }), scaleY: spring(1, { damping: 35, stiffness: 400 }) }}>
+        {({ opacity, scaleX, scaleY }) => (
+          <div className='privacy-dropdown__dropdown' style={{ ...style, opacity: opacity, transform: `scale(${scaleX}, ${scaleY})` }} ref={this.setRef}>
+            {items.map(item =>
+              <div role='button' tabIndex='0' key={item.value} data-index={item.value} onKeyDown={this.handleClick} onClick={this.handleClick} className={classNames('privacy-dropdown__option', { active: item.value === value })}>
+                <div className='privacy-dropdown__option__icon'>
+                  <i className={`fa fa-fw fa-${item.icon}`} />
+                </div>
+
+                <div className='privacy-dropdown__option__content'>
+                  <strong>{item.text}</strong>
+                  {item.meta}
+                </div>
+              </div>
+            )}
+          </div>
+        )}
+      </Motion>
+    );
+  }
+
+}
 
 @injectIntl
 export default class PrivacyDropdown extends React.PureComponent {
@@ -55,26 +126,30 @@ export default class PrivacyDropdown extends React.PureComponent {
 
   handleModalActionClick = (e) => {
     e.preventDefault();
+
     const { value } = this.options[e.currentTarget.getAttribute('data-index')];
+
     this.props.onModalClose();
     this.props.onChange(value);
   }
 
-  handleClick = (e) => {
-    if (e.key === 'Escape') {
-      this.setState({ open: false });
-    } else if (!e.key || e.key === 'Enter') {
-      const value = e.currentTarget.getAttribute('data-index');
-      e.preventDefault();
-      this.setState({ open: false });
-      this.props.onChange(value);
+  handleKeyDown = e => {
+    switch(e.key) {
+    case 'Enter':
+      this.handleToggle();
+      break;
+    case 'Escape':
+      this.handleClose();
+      break;
     }
   }
 
-  onGlobalClick = (e) => {
-    if (e.target !== this.node && !this.node.contains(e.target) && this.state.open) {
-      this.setState({ open: false });
-    }
+  handleClose = () => {
+    this.setState({ open: false });
+  }
+
+  handleChange = value => {
+    this.props.onChange(value);
   }
 
   componentWillMount () {
@@ -88,20 +163,6 @@ export default class PrivacyDropdown extends React.PureComponent {
     ];
   }
 
-  componentDidMount () {
-    window.addEventListener('click', this.onGlobalClick);
-    window.addEventListener('touchstart', this.onGlobalClick, detectPassiveEvents.hasSupport ? { passive: true } : false);
-  }
-
-  componentWillUnmount () {
-    window.removeEventListener('click', this.onGlobalClick);
-    window.removeEventListener('touchstart', this.onGlobalClick, detectPassiveEvents.hasSupport ? { passive: true } : false);
-  }
-
-  setRef = (c) => {
-    this.node = c;
-  }
-
   render () {
     const { value, intl } = this.props;
     const { open } = this.state;
@@ -109,19 +170,29 @@ export default class PrivacyDropdown extends React.PureComponent {
     const valueOption = this.options.find(item => item.value === value);
 
     return (
-      <div ref={this.setRef} className={`privacy-dropdown ${open ? 'active' : ''}`}>
-        <div className='privacy-dropdown__value'><IconButton className='privacy-dropdown__value-icon' icon={valueOption.icon} title={intl.formatMessage(messages.change_privacy)} size={18} expanded={open} active={open} inverted onClick={this.handleToggle} style={iconStyle} /></div>
-        <div className='privacy-dropdown__dropdown'>
-          {open && this.options.map(item =>
-            <div role='button' tabIndex='0' key={item.value} data-index={item.value} onKeyDown={this.handleClick} onClick={this.handleClick} className={`privacy-dropdown__option ${item.value === value ? 'active' : ''}`}>
-              <div className='privacy-dropdown__option__icon'><i className={`fa fa-fw fa-${item.icon}`} /></div>
-              <div className='privacy-dropdown__option__content'>
-                <strong>{item.text}</strong>
-                {item.meta}
-              </div>
-            </div>
-          )}
+      <div className={classNames('privacy-dropdown', { active: open })} onKeyDown={this.handleKeyDown}>
+        <div className={classNames('privacy-dropdown__value', { active: this.options.indexOf(valueOption) === 0 })}>
+          <IconButton
+            className='privacy-dropdown__value-icon'
+            icon={valueOption.icon}
+            title={intl.formatMessage(messages.change_privacy)}
+            size={18}
+            expanded={open}
+            active={open}
+            inverted
+            onClick={this.handleToggle}
+            style={{ height: null, lineHeight: '27px' }}
+          />
         </div>
+
+        <Overlay show={open} placement='bottom' target={this}>
+          <PrivacyDropdownMenu
+            items={this.options}
+            value={value}
+            onClose={this.handleClose}
+            onChange={this.handleChange}
+          />
+        </Overlay>
       </div>
     );
   }
diff --git a/app/javascript/mastodon/features/compose/components/search.js b/app/javascript/mastodon/features/compose/components/search.js
index 85ef767ab..f57d54618 100644
--- a/app/javascript/mastodon/features/compose/components/search.js
+++ b/app/javascript/mastodon/features/compose/components/search.js
@@ -1,11 +1,47 @@
 import React from 'react';
 import PropTypes from 'prop-types';
-import { defineMessages, injectIntl } from 'react-intl';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import Overlay from 'react-overlays/lib/Overlay';
+import Motion from 'react-motion/lib/Motion';
+import spring from 'react-motion/lib/spring';
 
 const messages = defineMessages({
   placeholder: { id: 'search.placeholder', defaultMessage: 'Search' },
 });
 
+class SearchPopout extends React.PureComponent {
+
+  static propTypes = {
+    style: PropTypes.object,
+  };
+
+  render () {
+    const { style } = this.props;
+
+    return (
+      <div style={{ ...style, position: 'absolute', width: 285 }}>
+        <Motion defaultStyle={{ opacity: 0, scaleX: 0.85, scaleY: 0.75 }} style={{ opacity: spring(1, { damping: 35, stiffness: 400 }), scaleX: spring(1, { damping: 35, stiffness: 400 }), scaleY: spring(1, { damping: 35, stiffness: 400 }) }}>
+          {({ opacity, scaleX, scaleY }) => (
+            <div className='search-popout' style={{ opacity: opacity, transform: `scale(${scaleX}, ${scaleY})` }}>
+              <h4><FormattedMessage id='search_popout.search_format' defaultMessage='Advanced search format' /></h4>
+
+              <ul>
+                <li><em>#example</em> <FormattedMessage id='search_popout.tips.hashtag' defaultMessage='hashtag' /></li>
+                <li><em>@username@domain</em> <FormattedMessage id='search_popout.tips.user' defaultMessage='user' /></li>
+                <li><em>URL</em> <FormattedMessage id='search_popout.tips.user' defaultMessage='user' /></li>
+                <li><em>URL</em> <FormattedMessage id='search_popout.tips.status' defaultMessage='status' /></li>
+              </ul>
+
+              <FormattedMessage id='search_popout.tips.text' defaultMessage='Simple text returns matching display names, usernames and hashtags' />
+            </div>
+          )}
+        </Motion>
+      </div>
+    );
+  }
+
+}
+
 @injectIntl
 export default class Search extends React.PureComponent {
 
@@ -19,6 +55,10 @@ export default class Search extends React.PureComponent {
     intl: PropTypes.object.isRequired,
   };
 
+  state = {
+    expanded: false,
+  };
+
   handleChange = (e) => {
     this.props.onChange(e.target.value);
   }
@@ -35,6 +75,8 @@ export default class Search extends React.PureComponent {
     if (e.key === 'Enter') {
       e.preventDefault();
       this.props.onSubmit();
+    } else if (e.key === 'Escape') {
+      document.querySelector('.ui').parentElement.focus();
     }
   }
 
@@ -43,11 +85,17 @@ export default class Search extends React.PureComponent {
   }
 
   handleFocus = () => {
+    this.setState({ expanded: true });
     this.props.onShow();
   }
 
+  handleBlur = () => {
+    this.setState({ expanded: false });
+  }
+
   render () {
     const { intl, value, submitted } = this.props;
+    const { expanded } = this.state;
     const hasValue = value.length > 0 || submitted;
 
     return (
@@ -62,6 +110,7 @@ export default class Search extends React.PureComponent {
             onChange={this.handleChange}
             onKeyUp={this.handleKeyDown}
             onFocus={this.handleFocus}
+            onBlur={this.handleBlur}
           />
         </label>
 
@@ -69,6 +118,10 @@ export default class Search extends React.PureComponent {
           <i className={`fa fa-search ${hasValue ? '' : 'active'}`} />
           <i aria-label={intl.formatMessage(messages.placeholder)} className={`fa fa-times-circle ${hasValue ? 'active' : ''}`} />
         </div>
+
+        <Overlay show={expanded && !hasValue} placement='bottom' target={this}>
+          <SearchPopout />
+        </Overlay>
       </div>
     );
   }
diff --git a/app/javascript/mastodon/features/compose/components/search_results.js b/app/javascript/mastodon/features/compose/components/search_results.js
index cae4ca412..a3e68643f 100644
--- a/app/javascript/mastodon/features/compose/components/search_results.js
+++ b/app/javascript/mastodon/features/compose/components/search_results.js
@@ -3,7 +3,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
 import { FormattedMessage } from 'react-intl';
 import AccountContainer from '../../../containers/account_container';
 import StatusContainer from '../../../../glitch/components/status/container';
-import Link from 'react-router-dom/Link';
+import { Link } from 'react-router-dom';
 import ImmutablePureComponent from 'react-immutable-pure-component';
 
 export default class SearchResults extends ImmutablePureComponent {
diff --git a/app/javascript/mastodon/features/compose/components/upload.js b/app/javascript/mastodon/features/compose/components/upload.js
new file mode 100644
index 000000000..cd9e08360
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/components/upload.js
@@ -0,0 +1,96 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import IconButton from '../../../components/icon_button';
+import Motion from 'react-motion/lib/Motion';
+import spring from 'react-motion/lib/spring';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import { defineMessages, injectIntl } from 'react-intl';
+import classNames from 'classnames';
+
+const messages = defineMessages({
+  undo: { id: 'upload_form.undo', defaultMessage: 'Undo' },
+  description: { id: 'upload_form.description', defaultMessage: 'Describe for the visually impaired' },
+});
+
+@injectIntl
+export default class Upload extends ImmutablePureComponent {
+
+  static propTypes = {
+    media: ImmutablePropTypes.map.isRequired,
+    intl: PropTypes.object.isRequired,
+    onUndo: PropTypes.func.isRequired,
+    onDescriptionChange: PropTypes.func.isRequired,
+  };
+
+  state = {
+    hovered: false,
+    focused: false,
+    dirtyDescription: null,
+  };
+
+  handleUndoClick = () => {
+    this.props.onUndo(this.props.media.get('id'));
+  }
+
+  handleInputChange = e => {
+    this.setState({ dirtyDescription: e.target.value });
+  }
+
+  handleMouseEnter = () => {
+    this.setState({ hovered: true });
+  }
+
+  handleMouseLeave = () => {
+    this.setState({ hovered: false });
+  }
+
+  handleInputFocus = () => {
+    this.setState({ focused: true });
+  }
+
+  handleInputBlur = () => {
+    const { dirtyDescription } = this.state;
+
+    this.setState({ focused: false, dirtyDescription: null });
+
+    if (dirtyDescription !== null) {
+      this.props.onDescriptionChange(this.props.media.get('id'), dirtyDescription);
+    }
+  }
+
+  render () {
+    const { intl, media } = this.props;
+    const active          = this.state.hovered || this.state.focused;
+    const description     = this.state.dirtyDescription || media.get('description') || '';
+
+    return (
+      <div className='compose-form__upload' onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}>
+        <Motion defaultStyle={{ scale: 0.8 }} style={{ scale: spring(1, { stiffness: 180, damping: 12 }) }}>
+          {({ scale }) => (
+            <div className='compose-form__upload-thumbnail' style={{ transform: `translateZ(0) scale(${scale})`, backgroundImage: `url(${media.get('preview_url')})` }}>
+              <IconButton icon='times' title={intl.formatMessage(messages.undo)} size={36} onClick={this.handleUndoClick} />
+
+              <div className={classNames('compose-form__upload-description', { active })}>
+                <label>
+                  <span style={{ display: 'none' }}>{intl.formatMessage(messages.description)}</span>
+
+                  <input
+                    placeholder={intl.formatMessage(messages.description)}
+                    type='text'
+                    value={description}
+                    maxLength={420}
+                    onFocus={this.handleInputFocus}
+                    onChange={this.handleInputChange}
+                    onBlur={this.handleInputBlur}
+                  />
+                </label>
+              </div>
+            </div>
+          )}
+        </Motion>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/mastodon/features/compose/components/upload_form.js b/app/javascript/mastodon/features/compose/components/upload_form.js
index cf2d2658a..b7f112205 100644
--- a/app/javascript/mastodon/features/compose/components/upload_form.js
+++ b/app/javascript/mastodon/features/compose/components/upload_form.js
@@ -1,49 +1,27 @@
 import React from 'react';
 import ImmutablePropTypes from 'react-immutable-proptypes';
-import PropTypes from 'prop-types';
-import IconButton from '../../../components/icon_button';
-import { defineMessages, injectIntl } from 'react-intl';
 import UploadProgressContainer from '../containers/upload_progress_container';
-import Motion from 'react-motion/lib/Motion';
-import spring from 'react-motion/lib/spring';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import UploadContainer from '../containers/upload_container';
 
-const messages = defineMessages({
-  undo: { id: 'upload_form.undo', defaultMessage: 'Undo' },
-});
-
-@injectIntl
-export default class UploadForm extends React.PureComponent {
+export default class UploadForm extends ImmutablePureComponent {
 
   static propTypes = {
-    media: ImmutablePropTypes.list.isRequired,
-    onRemoveFile: PropTypes.func.isRequired,
-    intl: PropTypes.object.isRequired,
+    mediaIds: ImmutablePropTypes.list.isRequired,
   };
 
-  onRemoveFile = (e) => {
-    const id = e.currentTarget.parentElement.getAttribute('data-id');
-    this.props.onRemoveFile(id);
-  }
-
   render () {
-    const { intl, media } = this.props;
-
-    const uploads = media.map(attachment =>
-      <div className='compose-form__upload' key={attachment.get('id')}>
-        <Motion defaultStyle={{ scale: 0.8 }} style={{ scale: spring(1, { stiffness: 180, damping: 12 }) }}>
-          {({ scale }) =>
-            <div className='compose-form__upload-thumbnail' data-id={attachment.get('id')} style={{ transform: `translateZ(0) scale(${scale})`, backgroundImage: `url(${attachment.get('preview_url')})` }}>
-              <IconButton icon='times' title={intl.formatMessage(messages.undo)} size={36} onClick={this.onRemoveFile} />
-            </div>
-          }
-        </Motion>
-      </div>
-    );
+    const { mediaIds } = this.props;
 
     return (
       <div className='compose-form__upload-wrapper'>
         <UploadProgressContainer />
-        <div className='compose-form__uploads-wrapper'>{uploads}</div>
+
+        <div className='compose-form__uploads-wrapper'>
+          {mediaIds.map(id => (
+            <UploadContainer id={id} key={id} />
+          ))}
+        </div>
       </div>
     );
   }
diff --git a/app/javascript/mastodon/features/compose/components/warning.js b/app/javascript/mastodon/features/compose/components/warning.js
index 75f36b840..a0814e984 100644
--- a/app/javascript/mastodon/features/compose/components/warning.js
+++ b/app/javascript/mastodon/features/compose/components/warning.js
@@ -1,5 +1,7 @@
 import React from 'react';
 import PropTypes from 'prop-types';
+import Motion from 'react-motion/lib/Motion';
+import spring from 'react-motion/lib/spring';
 
 export default class Warning extends React.PureComponent {
 
@@ -11,9 +13,13 @@ export default class Warning extends React.PureComponent {
     const { message } = this.props;
 
     return (
-      <div className='compose-form__warning'>
-        {message}
-      </div>
+      <Motion defaultStyle={{ opacity: 0, scaleX: 0.85, scaleY: 0.75 }} style={{ opacity: spring(1, { damping: 35, stiffness: 400 }), scaleX: spring(1, { damping: 35, stiffness: 400 }), scaleY: spring(1, { damping: 35, stiffness: 400 }) }}>
+        {({ opacity, scaleX, scaleY }) => (
+          <div className='compose-form__warning' style={{ opacity: opacity, transform: `scale(${scaleX}, ${scaleY})` }}>
+            {message}
+          </div>
+        )}
+      </Motion>
     );
   }
 
diff --git a/app/javascript/mastodon/features/compose/containers/emoji_picker_dropdown_container.js b/app/javascript/mastodon/features/compose/containers/emoji_picker_dropdown_container.js
index 7a8026bbc..71944128c 100644
--- a/app/javascript/mastodon/features/compose/containers/emoji_picker_dropdown_container.js
+++ b/app/javascript/mastodon/features/compose/containers/emoji_picker_dropdown_container.js
@@ -1,8 +1,83 @@
 import { connect } from 'react-redux';
 import EmojiPickerDropdown from '../components/emoji_picker_dropdown';
+import { changeSetting } from '../../../actions/settings';
+import { createSelector } from 'reselect';
+import { Map as ImmutableMap } from 'immutable';
+import { useEmoji } from '../../../actions/emojis';
+
+const perLine = 8;
+const lines   = 2;
+
+const DEFAULTS = [
+  '+1',
+  'grinning',
+  'kissing_heart',
+  'heart_eyes',
+  'laughing',
+  'stuck_out_tongue_winking_eye',
+  'sweat_smile',
+  'joy',
+  'yum',
+  'disappointed',
+  'thinking_face',
+  'weary',
+  'sob',
+  'sunglasses',
+  'heart',
+  'ok_hand',
+];
+
+const getFrequentlyUsedEmojis = createSelector([
+  state => state.getIn(['settings', 'frequentlyUsedEmojis'], ImmutableMap()),
+], emojiCounters => {
+  let emojis = emojiCounters
+    .keySeq()
+    .sort((a, b) => emojiCounters.get(a) - emojiCounters.get(b))
+    .reverse()
+    .slice(0, perLine * lines)
+    .toArray();
+
+  if (emojis.length < DEFAULTS.length) {
+    emojis = emojis.concat(DEFAULTS.slice(0, DEFAULTS.length - emojis.length));
+  }
+
+  return emojis;
+});
+
+const getCustomEmojis = createSelector([
+  state => state.get('custom_emojis'),
+], emojis => emojis.sort((a, b) => {
+  const aShort = a.get('shortcode').toLowerCase();
+  const bShort = b.get('shortcode').toLowerCase();
+
+  if (aShort < bShort) {
+    return -1;
+  } else if (aShort > bShort ) {
+    return 1;
+  } else {
+    return 0;
+  }
+}));
 
 const mapStateToProps = state => ({
-  custom_emojis: state.get('custom_emojis'),
+  custom_emojis: getCustomEmojis(state),
+  autoPlay: state.getIn(['meta', 'auto_play_gif']),
+  skinTone: state.getIn(['settings', 'skinTone']),
+  frequentlyUsedEmojis: getFrequentlyUsedEmojis(state),
+});
+
+const mapDispatchToProps = (dispatch, { onPickEmoji }) => ({
+  onSkinTone: skinTone => {
+    dispatch(changeSetting(['skinTone'], skinTone));
+  },
+
+  onPickEmoji: emoji => {
+    dispatch(useEmoji(emoji));
+
+    if (onPickEmoji) {
+      onPickEmoji(emoji);
+    }
+  },
 });
 
-export default connect(mapStateToProps)(EmojiPickerDropdown);
+export default connect(mapStateToProps, mapDispatchToProps)(EmojiPickerDropdown);
diff --git a/app/javascript/mastodon/features/compose/containers/upload_container.js b/app/javascript/mastodon/features/compose/containers/upload_container.js
new file mode 100644
index 000000000..ca9c3b704
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/containers/upload_container.js
@@ -0,0 +1,21 @@
+import { connect } from 'react-redux';
+import Upload from '../components/upload';
+import { undoUploadCompose, changeUploadCompose } from '../../../actions/compose';
+
+const mapStateToProps = (state, { id }) => ({
+  media: state.getIn(['compose', 'media_attachments']).find(item => item.get('id') === id),
+});
+
+const mapDispatchToProps = dispatch => ({
+
+  onUndo: id => {
+    dispatch(undoUploadCompose(id));
+  },
+
+  onDescriptionChange: (id, description) => {
+    dispatch(changeUploadCompose(id, description));
+  },
+
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(Upload);
diff --git a/app/javascript/mastodon/features/compose/containers/upload_form_container.js b/app/javascript/mastodon/features/compose/containers/upload_form_container.js
index 4612599f1..a6798bf51 100644
--- a/app/javascript/mastodon/features/compose/containers/upload_form_container.js
+++ b/app/javascript/mastodon/features/compose/containers/upload_form_container.js
@@ -1,17 +1,8 @@
 import { connect } from 'react-redux';
 import UploadForm from '../components/upload_form';
-import { undoUploadCompose } from '../../../actions/compose';
 
 const mapStateToProps = state => ({
-  media: state.getIn(['compose', 'media_attachments']),
+  mediaIds: state.getIn(['compose', 'media_attachments']).map(item => item.get('id')),
 });
 
-const mapDispatchToProps = dispatch => ({
-
-  onRemoveFile (media_id) {
-    dispatch(undoUploadCompose(media_id));
-  },
-
-});
-
-export default connect(mapStateToProps, mapDispatchToProps)(UploadForm);
+export default connect(mapStateToProps)(UploadForm);
diff --git a/app/javascript/mastodon/features/compose/index.js b/app/javascript/mastodon/features/compose/index.js
index f0bce1e40..9068648bd 100644
--- a/app/javascript/mastodon/features/compose/index.js
+++ b/app/javascript/mastodon/features/compose/index.js
@@ -7,7 +7,7 @@ import { connect } from 'react-redux';
 import { mountCompose, unmountCompose } from '../../actions/compose';
 import { openModal } from '../../actions/modal';
 import { changeLocalSetting } from '../../../glitch/actions/local_settings';
-import Link from 'react-router-dom/Link';
+import { Link } from 'react-router-dom';
 import { injectIntl, defineMessages } from 'react-intl';
 import SearchContainer from './containers/search_container';
 import Motion from 'react-motion/lib/Motion';
diff --git a/app/javascript/mastodon/features/emoji/emoji.js b/app/javascript/mastodon/features/emoji/emoji.js
new file mode 100644
index 000000000..b70fc2b37
--- /dev/null
+++ b/app/javascript/mastodon/features/emoji/emoji.js
@@ -0,0 +1,77 @@
+import unicodeMapping from './emoji_unicode_mapping_light';
+import Trie from 'substring-trie';
+
+const trie = new Trie(Object.keys(unicodeMapping));
+
+const assetHost = process.env.CDN_HOST || '';
+
+let allowAnimations = false;
+
+const emojify = (str, customEmojis = {}) => {
+  let rtn = '';
+  for (;;) {
+    let match, i = 0, tag;
+    while (i < str.length && (tag = '<&:'.indexOf(str[i])) === -1 && !(match = trie.search(str.slice(i)))) {
+      i += str.codePointAt(i) < 65536 ? 1 : 2;
+    }
+    let rend, replacement = '';
+    if (i === str.length) {
+      break;
+    } else if (str[i] === ':') {
+      if (!(() => {
+        rend = str.indexOf(':', i + 1) + 1;
+        if (!rend) return false; // no pair of ':'
+        const lt = str.indexOf('<', i + 1);
+        if (!(lt === -1 || lt >= rend)) return false; // tag appeared before closing ':'
+        const shortname = str.slice(i, rend);
+        // now got a replacee as ':shortname:'
+        // if you want additional emoji handler, add statements below which set replacement and return true.
+        if (shortname in customEmojis) {
+          const filename = allowAnimations ? customEmojis[shortname].url : customEmojis[shortname].static_url;
+          replacement = `<img draggable="false" class="emojione" alt="${shortname}" title="${shortname}" src="${filename}" />`;
+          return true;
+        }
+        return false;
+      })()) rend = ++i;
+    } else if (tag >= 0) { // <, &
+      rend = str.indexOf('>;'[tag], i + 1) + 1;
+      if (!rend) break;
+      i = rend;
+    } 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" />`;
+      rend = i + match.length;
+    }
+    rtn += str.slice(0, i) + replacement;
+    str = str.slice(rend);
+  }
+  return rtn + str;
+};
+
+export default emojify;
+
+export const buildCustomEmojis = (customEmojis, overrideAllowAnimations = false) => {
+  const emojis = [];
+
+  allowAnimations = overrideAllowAnimations;
+
+  customEmojis.forEach(emoji => {
+    const shortcode = emoji.get('shortcode');
+    const url       = allowAnimations ? emoji.get('url') : emoji.get('static_url');
+    const name      = shortcode.replace(':', '');
+
+    emojis.push({
+      id: name,
+      name,
+      short_names: [name],
+      text: '',
+      emoticons: [],
+      keywords: [name],
+      imageUrl: url,
+      custom: true,
+    });
+  });
+
+  return emojis;
+};
diff --git a/app/javascript/mastodon/features/emoji/emoji_compressed.js b/app/javascript/mastodon/features/emoji/emoji_compressed.js
new file mode 100644
index 000000000..3bd89cf3b
--- /dev/null
+++ b/app/javascript/mastodon/features/emoji/emoji_compressed.js
@@ -0,0 +1,92 @@
+// @preval
+// http://www.unicode.org/Public/emoji/5.0/emoji-test.txt
+// This file contains the compressed version of the emoji data from
+// both emoji_map.json and from emoji-mart's emojiIndex and data objects.
+// It's designed to be emitted in an array format to take up less space
+// over the wire.
+
+const { unicodeToFilename } = require('./unicode_to_filename');
+const { unicodeToUnifiedName } = require('./unicode_to_unified_name');
+const emojiMap         = require('./emoji_map.json');
+const { emojiIndex } = require('emoji-mart');
+const emojiMartData = require('emoji-mart/dist/data').default;
+const excluded       = ['®', '©', '™'];
+const skins          = ['🏻', '🏼', '🏽', '🏾', '🏿'];
+const shortcodeMap   = {};
+
+const shortCodesToEmojiData = {};
+const emojisWithoutShortCodes = [];
+
+Object.keys(emojiIndex.emojis).forEach(key => {
+  shortcodeMap[emojiIndex.emojis[key].native] = emojiIndex.emojis[key].id;
+});
+
+const stripModifiers = unicode => {
+  skins.forEach(tone => {
+    unicode = unicode.replace(tone, '');
+  });
+
+  return unicode;
+};
+
+Object.keys(emojiMap).forEach(key => {
+  if (excluded.includes(key)) {
+    delete emojiMap[key];
+    return;
+  }
+
+  const normalizedKey = stripModifiers(key);
+  let shortcode       = shortcodeMap[normalizedKey];
+
+  if (!shortcode) {
+    shortcode = shortcodeMap[normalizedKey + '\uFE0F'];
+  }
+
+  const filename = emojiMap[key];
+
+  const filenameData = [key];
+
+  if (unicodeToFilename(key) !== filename) {
+    // filename can't be derived using unicodeToFilename
+    filenameData.push(filename);
+  }
+
+  if (typeof shortcode === 'undefined') {
+    emojisWithoutShortCodes.push(filenameData);
+  } else {
+    if (!Array.isArray(shortCodesToEmojiData[shortcode])) {
+      shortCodesToEmojiData[shortcode] = [[]];
+    }
+    shortCodesToEmojiData[shortcode][0].push(filenameData);
+  }
+});
+
+Object.keys(emojiIndex.emojis).forEach(key => {
+  const { native } = emojiIndex.emojis[key];
+  const { short_names, search, unified } = emojiMartData.emojis[key];
+  if (short_names[0] !== key) {
+    throw new Error('The compresser expects the first short_code to be the ' +
+      'key. It may need to be rewritten if the emoji change such that this ' +
+      'is no longer the case.');
+  }
+
+  short_names.splice(0, 1); // first short name can be inferred from the key
+
+  const searchData = [native, short_names, search];
+  if (unicodeToUnifiedName(native) !== unified) {
+    // unified name can't be derived from unicodeToUnifiedName
+    searchData.push(unified);
+  }
+
+  shortCodesToEmojiData[key].push(searchData);
+});
+
+// JSON.parse/stringify is to emulate what @preval is doing and avoid any
+// inconsistent behavior in dev mode
+module.exports = JSON.parse(JSON.stringify([
+  shortCodesToEmojiData,
+  emojiMartData.skins,
+  emojiMartData.categories,
+  emojiMartData.short_names,
+  emojisWithoutShortCodes,
+]));
diff --git a/app/javascript/mastodon/emoji_map.json b/app/javascript/mastodon/features/emoji/emoji_map.json
index 13753ba84..13753ba84 100644
--- a/app/javascript/mastodon/emoji_map.json
+++ b/app/javascript/mastodon/features/emoji/emoji_map.json
diff --git a/app/javascript/mastodon/features/emoji/emoji_mart_data_light.js b/app/javascript/mastodon/features/emoji/emoji_mart_data_light.js
new file mode 100644
index 000000000..45086fc4c
--- /dev/null
+++ b/app/javascript/mastodon/features/emoji/emoji_mart_data_light.js
@@ -0,0 +1,41 @@
+// The output of this module is designed to mimic emoji-mart's
+// "data" object, such that we can use it for a light version of emoji-mart's
+// emojiIndex.search functionality.
+const { unicodeToUnifiedName } = require('./unicode_to_unified_name');
+const [ shortCodesToEmojiData, skins, categories, short_names ] = require('./emoji_compressed');
+
+const emojis = {};
+
+// decompress
+Object.keys(shortCodesToEmojiData).forEach((shortCode) => {
+  let [
+    filenameData, // eslint-disable-line no-unused-vars
+    searchData,
+  ] = shortCodesToEmojiData[shortCode];
+  let [
+    native,
+    short_names,
+    search,
+    unified,
+  ] = searchData;
+
+  if (!unified) {
+    // unified name can be derived from unicodeToUnifiedName
+    unified = unicodeToUnifiedName(native);
+  }
+
+  short_names = [shortCode].concat(short_names);
+  emojis[shortCode] = {
+    native,
+    search,
+    short_names,
+    unified,
+  };
+});
+
+module.exports = {
+  emojis,
+  skins,
+  categories,
+  short_names,
+};
diff --git a/app/javascript/mastodon/features/emoji/emoji_mart_search_light.js b/app/javascript/mastodon/features/emoji/emoji_mart_search_light.js
new file mode 100644
index 000000000..5755bf1c4
--- /dev/null
+++ b/app/javascript/mastodon/features/emoji/emoji_mart_search_light.js
@@ -0,0 +1,157 @@
+// This code is largely borrowed from:
+// https://github.com/missive/emoji-mart/blob/5f2ffcc/src/utils/emoji-index.js
+
+import data from './emoji_mart_data_light';
+import { getData, getSanitizedData, intersect } from './emoji_utils';
+
+let originalPool = {};
+let index = {};
+let emojisList = {};
+let emoticonsList = {};
+
+for (let emoji in data.emojis) {
+  let emojiData = data.emojis[emoji];
+  let { short_names, emoticons } = emojiData;
+  let id = short_names[0];
+
+  if (emoticons) {
+    emoticons.forEach(emoticon => {
+      if (emoticonsList[emoticon]) {
+        return;
+      }
+
+      emoticonsList[emoticon] = id;
+    });
+  }
+
+  emojisList[id] = getSanitizedData(id);
+  originalPool[id] = emojiData;
+}
+
+function addCustomToPool(custom, pool) {
+  custom.forEach((emoji) => {
+    let emojiId = emoji.id || emoji.short_names[0];
+
+    if (emojiId && !pool[emojiId]) {
+      pool[emojiId] = getData(emoji);
+      emojisList[emojiId] = getSanitizedData(emoji);
+    }
+  });
+}
+
+function search(value, { emojisToShowFilter, maxResults, include, exclude, custom = [] } = {}) {
+  addCustomToPool(custom, originalPool);
+
+  maxResults = maxResults || 75;
+  include = include || [];
+  exclude = exclude || [];
+
+  let results = null,
+    pool = originalPool;
+
+  if (value.length) {
+    if (value === '-' || value === '-1') {
+      return [emojisList['-1']];
+    }
+
+    let values = value.toLowerCase().split(/[\s|,|\-|_]+/),
+      allResults = [];
+
+    if (values.length > 2) {
+      values = [values[0], values[1]];
+    }
+
+    if (include.length || exclude.length) {
+      pool = {};
+
+      data.categories.forEach(category => {
+        let isIncluded = include && include.length ? include.indexOf(category.name.toLowerCase()) > -1 : true;
+        let isExcluded = exclude && exclude.length ? exclude.indexOf(category.name.toLowerCase()) > -1 : false;
+        if (!isIncluded || isExcluded) {
+          return;
+        }
+
+        category.emojis.forEach(emojiId => pool[emojiId] = data.emojis[emojiId]);
+      });
+
+      if (custom.length) {
+        let customIsIncluded = include && include.length ? include.indexOf('custom') > -1 : true;
+        let customIsExcluded = exclude && exclude.length ? exclude.indexOf('custom') > -1 : false;
+        if (customIsIncluded && !customIsExcluded) {
+          addCustomToPool(custom, pool);
+        }
+      }
+    }
+
+    allResults = values.map((value) => {
+      let aPool = pool,
+        aIndex = index,
+        length = 0;
+
+      for (let charIndex = 0; charIndex < value.length; charIndex++) {
+        const char = value[charIndex];
+        length++;
+
+        aIndex[char] = aIndex[char] || {};
+        aIndex = aIndex[char];
+
+        if (!aIndex.results) {
+          let scores = {};
+
+          aIndex.results = [];
+          aIndex.pool = {};
+
+          for (let id in aPool) {
+            let emoji = aPool[id],
+              { search } = emoji,
+              sub = value.substr(0, length),
+              subIndex = search.indexOf(sub);
+
+            if (subIndex !== -1) {
+              let score = subIndex + 1;
+              if (sub === id) score = 0;
+
+              aIndex.results.push(emojisList[id]);
+              aIndex.pool[id] = emoji;
+
+              scores[id] = score;
+            }
+          }
+
+          aIndex.results.sort((a, b) => {
+            let aScore = scores[a.id],
+              bScore = scores[b.id];
+
+            return aScore - bScore;
+          });
+        }
+
+        aPool = aIndex.pool;
+      }
+
+      return aIndex.results;
+    }).filter(a => a);
+
+    if (allResults.length > 1) {
+      results = intersect.apply(null, allResults);
+    } else if (allResults.length) {
+      results = allResults[0];
+    } else {
+      results = [];
+    }
+  }
+
+  if (results) {
+    if (emojisToShowFilter) {
+      results = results.filter((result) => emojisToShowFilter(data.emojis[result.id].unified));
+    }
+
+    if (results && results.length > maxResults) {
+      results = results.slice(0, maxResults);
+    }
+  }
+
+  return results;
+}
+
+export { search };
diff --git a/app/javascript/mastodon/features/emoji/emoji_picker.js b/app/javascript/mastodon/features/emoji/emoji_picker.js
new file mode 100644
index 000000000..7e145381e
--- /dev/null
+++ b/app/javascript/mastodon/features/emoji/emoji_picker.js
@@ -0,0 +1,7 @@
+import Picker from 'emoji-mart/dist-es/components/picker';
+import Emoji from 'emoji-mart/dist-es/components/emoji';
+
+export {
+  Picker,
+  Emoji,
+};
diff --git a/app/javascript/mastodon/features/emoji/emoji_unicode_mapping_light.js b/app/javascript/mastodon/features/emoji/emoji_unicode_mapping_light.js
new file mode 100644
index 000000000..918684c31
--- /dev/null
+++ b/app/javascript/mastodon/features/emoji/emoji_unicode_mapping_light.js
@@ -0,0 +1,35 @@
+// A mapping of unicode strings to an object containing the filename
+// (i.e. the svg filename) and a shortCode intended to be shown
+// as a "title" attribute in an HTML element (aka tooltip).
+
+const [
+  shortCodesToEmojiData,
+  skins, // eslint-disable-line no-unused-vars
+  categories, // eslint-disable-line no-unused-vars
+  short_names, // eslint-disable-line no-unused-vars
+  emojisWithoutShortCodes,
+] = require('./emoji_compressed');
+const { unicodeToFilename } = require('./unicode_to_filename');
+
+// decompress
+const unicodeMapping = {};
+
+function processEmojiMapData(emojiMapData, shortCode) {
+  let [ native, filename ] = emojiMapData;
+  if (!filename) {
+    // filename name can be derived from unicodeToFilename
+    filename = unicodeToFilename(native);
+  }
+  unicodeMapping[native] = {
+    shortCode: shortCode,
+    filename: filename,
+  };
+}
+
+Object.keys(shortCodesToEmojiData).forEach((shortCode) => {
+  let [ filenameData ] = shortCodesToEmojiData[shortCode];
+  filenameData.forEach(emojiMapData => processEmojiMapData(emojiMapData, shortCode));
+});
+emojisWithoutShortCodes.forEach(emojiMapData => processEmojiMapData(emojiMapData));
+
+module.exports = unicodeMapping;
diff --git a/app/javascript/mastodon/features/emoji/emoji_utils.js b/app/javascript/mastodon/features/emoji/emoji_utils.js
new file mode 100644
index 000000000..dbf725c1f
--- /dev/null
+++ b/app/javascript/mastodon/features/emoji/emoji_utils.js
@@ -0,0 +1,258 @@
+// This code is largely borrowed from:
+// https://github.com/missive/emoji-mart/blob/5f2ffcc/src/utils/index.js
+
+import data from './emoji_mart_data_light';
+
+const buildSearch = (data) => {
+  const search = [];
+
+  let addToSearch = (strings, split) => {
+    if (!strings) {
+      return;
+    }
+
+    (Array.isArray(strings) ? strings : [strings]).forEach((string) => {
+      (split ? string.split(/[-|_|\s]+/) : [string]).forEach((s) => {
+        s = s.toLowerCase();
+
+        if (search.indexOf(s) === -1) {
+          search.push(s);
+        }
+      });
+    });
+  };
+
+  addToSearch(data.short_names, true);
+  addToSearch(data.name, true);
+  addToSearch(data.keywords, false);
+  addToSearch(data.emoticons, false);
+
+  return search.join(',');
+};
+
+const _String = String;
+
+const stringFromCodePoint = _String.fromCodePoint || function () {
+  let MAX_SIZE = 0x4000;
+  let codeUnits = [];
+  let highSurrogate;
+  let lowSurrogate;
+  let index = -1;
+  let length = arguments.length;
+  if (!length) {
+    return '';
+  }
+  let result = '';
+  while (++index < length) {
+    let codePoint = Number(arguments[index]);
+    if (
+      !isFinite(codePoint) ||       // `NaN`, `+Infinity`, or `-Infinity`
+      codePoint < 0 ||              // not a valid Unicode code point
+      codePoint > 0x10FFFF ||       // not a valid Unicode code point
+      Math.floor(codePoint) !== codePoint // not an integer
+    ) {
+      throw RangeError('Invalid code point: ' + codePoint);
+    }
+    if (codePoint <= 0xFFFF) { // BMP code point
+      codeUnits.push(codePoint);
+    } else { // Astral code point; split in surrogate halves
+      // http://mathiasbynens.be/notes/javascript-encoding#surrogate-formulae
+      codePoint -= 0x10000;
+      highSurrogate = (codePoint >> 10) + 0xD800;
+      lowSurrogate = (codePoint % 0x400) + 0xDC00;
+      codeUnits.push(highSurrogate, lowSurrogate);
+    }
+    if (index + 1 === length || codeUnits.length > MAX_SIZE) {
+      result += String.fromCharCode.apply(null, codeUnits);
+      codeUnits.length = 0;
+    }
+  }
+  return result;
+};
+
+
+const _JSON = JSON;
+
+const COLONS_REGEX = /^(?:\:([^\:]+)\:)(?:\:skin-tone-(\d)\:)?$/;
+const SKINS = [
+  '1F3FA', '1F3FB', '1F3FC',
+  '1F3FD', '1F3FE', '1F3FF',
+];
+
+function unifiedToNative(unified) {
+  let unicodes = unified.split('-'),
+    codePoints = unicodes.map((u) => `0x${u}`);
+
+  return stringFromCodePoint.apply(null, codePoints);
+}
+
+function sanitize(emoji) {
+  let { name, short_names, skin_tone, skin_variations, emoticons, unified, custom, imageUrl } = emoji,
+    id = emoji.id || short_names[0],
+    colons = `:${id}:`;
+
+  if (custom) {
+    return {
+      id,
+      name,
+      colons,
+      emoticons,
+      custom,
+      imageUrl,
+    };
+  }
+
+  if (skin_tone) {
+    colons += `:skin-tone-${skin_tone}:`;
+  }
+
+  return {
+    id,
+    name,
+    colons,
+    emoticons,
+    unified: unified.toLowerCase(),
+    skin: skin_tone || (skin_variations ? 1 : null),
+    native: unifiedToNative(unified),
+  };
+}
+
+function getSanitizedData() {
+  return sanitize(getData(...arguments));
+}
+
+function getData(emoji, skin, set) {
+  let emojiData = {};
+
+  if (typeof emoji === 'string') {
+    let matches = emoji.match(COLONS_REGEX);
+
+    if (matches) {
+      emoji = matches[1];
+
+      if (matches[2]) {
+        skin = parseInt(matches[2]);
+      }
+    }
+
+    if (data.short_names.hasOwnProperty(emoji)) {
+      emoji = data.short_names[emoji];
+    }
+
+    if (data.emojis.hasOwnProperty(emoji)) {
+      emojiData = data.emojis[emoji];
+    }
+  } else if (emoji.id) {
+    if (data.short_names.hasOwnProperty(emoji.id)) {
+      emoji.id = data.short_names[emoji.id];
+    }
+
+    if (data.emojis.hasOwnProperty(emoji.id)) {
+      emojiData = data.emojis[emoji.id];
+      skin = skin || emoji.skin;
+    }
+  }
+
+  if (!Object.keys(emojiData).length) {
+    emojiData = emoji;
+    emojiData.custom = true;
+
+    if (!emojiData.search) {
+      emojiData.search = buildSearch(emoji);
+    }
+  }
+
+  emojiData.emoticons = emojiData.emoticons || [];
+  emojiData.variations = emojiData.variations || [];
+
+  if (emojiData.skin_variations && skin > 1 && set) {
+    emojiData = JSON.parse(_JSON.stringify(emojiData));
+
+    let skinKey = SKINS[skin - 1],
+      variationData = emojiData.skin_variations[skinKey];
+
+    if (!variationData.variations && emojiData.variations) {
+      delete emojiData.variations;
+    }
+
+    if (variationData[`has_img_${set}`]) {
+      emojiData.skin_tone = skin;
+
+      for (let k in variationData) {
+        let v = variationData[k];
+        emojiData[k] = v;
+      }
+    }
+  }
+
+  if (emojiData.variations && emojiData.variations.length) {
+    emojiData = JSON.parse(_JSON.stringify(emojiData));
+    emojiData.unified = emojiData.variations.shift();
+  }
+
+  return emojiData;
+}
+
+function uniq(arr) {
+  return arr.reduce((acc, item) => {
+    if (acc.indexOf(item) === -1) {
+      acc.push(item);
+    }
+    return acc;
+  }, []);
+}
+
+function intersect(a, b) {
+  const uniqA = uniq(a);
+  const uniqB = uniq(b);
+
+  return uniqA.filter(item => uniqB.indexOf(item) >= 0);
+}
+
+function deepMerge(a, b) {
+  let o = {};
+
+  for (let key in a) {
+    let originalValue = a[key],
+      value = originalValue;
+
+    if (b.hasOwnProperty(key)) {
+      value = b[key];
+    }
+
+    if (typeof value === 'object') {
+      value = deepMerge(originalValue, value);
+    }
+
+    o[key] = value;
+  }
+
+  return o;
+}
+
+// https://github.com/sonicdoe/measure-scrollbar
+function measureScrollbar() {
+  const div = document.createElement('div');
+
+  div.style.width = '100px';
+  div.style.height = '100px';
+  div.style.overflow = 'scroll';
+  div.style.position = 'absolute';
+  div.style.top = '-9999px';
+
+  document.body.appendChild(div);
+  const scrollbarWidth = div.offsetWidth - div.clientWidth;
+  document.body.removeChild(div);
+
+  return scrollbarWidth;
+}
+
+export {
+  getData,
+  getSanitizedData,
+  uniq,
+  intersect,
+  deepMerge,
+  unifiedToNative,
+  measureScrollbar,
+};
diff --git a/app/javascript/mastodon/features/emoji/unicode_to_filename.js b/app/javascript/mastodon/features/emoji/unicode_to_filename.js
new file mode 100644
index 000000000..c75c4cd7d
--- /dev/null
+++ b/app/javascript/mastodon/features/emoji/unicode_to_filename.js
@@ -0,0 +1,26 @@
+// taken from:
+// https://github.com/twitter/twemoji/blob/47732c7/twemoji-generator.js#L848-L866
+exports.unicodeToFilename = (str) => {
+  let result = '';
+  let charCode = 0;
+  let p = 0;
+  let i = 0;
+  while (i < str.length) {
+    charCode = str.charCodeAt(i++);
+    if (p) {
+      if (result.length > 0) {
+        result += '-';
+      }
+      result += (0x10000 + ((p - 0xD800) << 10) + (charCode - 0xDC00)).toString(16);
+      p = 0;
+    } else if (0xD800 <= charCode && charCode <= 0xDBFF) {
+      p = charCode;
+    } else {
+      if (result.length > 0) {
+        result += '-';
+      }
+      result += charCode.toString(16);
+    }
+  }
+  return result;
+};
diff --git a/app/javascript/mastodon/features/emoji/unicode_to_unified_name.js b/app/javascript/mastodon/features/emoji/unicode_to_unified_name.js
new file mode 100644
index 000000000..808ac197e
--- /dev/null
+++ b/app/javascript/mastodon/features/emoji/unicode_to_unified_name.js
@@ -0,0 +1,17 @@
+function padLeft(str, num) {
+  while (str.length < num) {
+    str = '0' + str;
+  }
+  return str;
+}
+
+exports.unicodeToUnifiedName = (str) => {
+  let output = '';
+  for (let i = 0; i < str.length; i += 2) {
+    if (i > 0) {
+      output += '-';
+    }
+    output += padLeft(str.codePointAt(i).toString(16).toUpperCase(), 4);
+  }
+  return output;
+};
diff --git a/app/javascript/mastodon/features/home_timeline/index.js b/app/javascript/mastodon/features/home_timeline/index.js
index 1bb4bd279..8d74783a3 100644
--- a/app/javascript/mastodon/features/home_timeline/index.js
+++ b/app/javascript/mastodon/features/home_timeline/index.js
@@ -8,7 +8,7 @@ import ColumnHeader from '../../components/column_header';
 import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
 import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
 import ColumnSettingsContainer from './containers/column_settings_container';
-import Link from 'react-router-dom/Link';
+import { Link } from 'react-router-dom';
 
 const messages = defineMessages({
   title: { id: 'column.home', defaultMessage: 'Home' },
diff --git a/app/javascript/mastodon/features/notifications/components/notification.js b/app/javascript/mastodon/features/notifications/components/notification.js
index b52c3c934..903526822 100644
--- a/app/javascript/mastodon/features/notifications/components/notification.js
+++ b/app/javascript/mastodon/features/notifications/components/notification.js
@@ -9,61 +9,126 @@ import AccountContainer from '../../../containers/account_container';
 import { FormattedMessage } from 'react-intl';
 import Permalink from '../../../components/permalink';
 import ImmutablePureComponent from 'react-immutable-pure-component';
+import { HotKeys } from 'react-hotkeys';
 
 export default class Notification extends ImmutablePureComponent {
 
+  static contextTypes = {
+    router: PropTypes.object,
+  };
+
   static propTypes = {
     notification: ImmutablePropTypes.map.isRequired,
     hidden: PropTypes.bool,
+    onMoveUp: PropTypes.func.isRequired,
+    onMoveDown: PropTypes.func.isRequired,
+    onMention: PropTypes.func.isRequired,
   };
 
+  handleMoveUp = () => {
+    const { notification, onMoveUp } = this.props;
+    onMoveUp(notification.get('id'));
+  }
+
+  handleMoveDown = () => {
+    const { notification, onMoveDown } = this.props;
+    onMoveDown(notification.get('id'));
+  }
+
+  handleOpen = () => {
+    const { notification } = this.props;
+
+    if (notification.get('status')) {
+      this.context.router.history.push(`/statuses/${notification.get('status')}`);
+    } else {
+      this.handleOpenProfile();
+    }
+  }
+
+  handleOpenProfile = () => {
+    const { notification } = this.props;
+    this.context.router.history.push(`/accounts/${notification.getIn(['account', 'id'])}`);
+  }
+
+  handleMention = e => {
+    e.preventDefault();
+
+    const { notification, onMention } = this.props;
+    onMention(notification.get('account'), this.context.router.history);
+  }
+
+  getHandlers () {
+    return {
+      moveUp: this.handleMoveUp,
+      moveDown: this.handleMoveDown,
+      open: this.handleOpen,
+      openProfile: this.handleOpenProfile,
+      mention: this.handleMention,
+      reply: this.handleMention,
+    };
+  }
+
   renderFollow (account, link) {
     return (
-      <div className='notification notification-follow'>
-        <div className='notification__message'>
-          <div className='notification__favourite-icon-wrapper'>
-            <i className='fa fa-fw fa-user-plus' />
+      <HotKeys handlers={this.getHandlers()}>
+        <div className='notification notification-follow focusable' tabIndex='0'>
+          <div className='notification__message'>
+            <div className='notification__favourite-icon-wrapper'>
+              <i className='fa fa-fw fa-user-plus' />
+            </div>
+
+            <FormattedMessage id='notification.follow' defaultMessage='{name} followed you' values={{ name: link }} />
           </div>
 
-          <FormattedMessage id='notification.follow' defaultMessage='{name} followed you' values={{ name: link }} />
+          <AccountContainer id={account.get('id')} withNote={false} hidden={this.props.hidden} />
         </div>
-
-        <AccountContainer id={account.get('id')} withNote={false} hidden={this.props.hidden} />
-      </div>
+      </HotKeys>
     );
   }
 
   renderMention (notification) {
-    return <StatusContainer id={notification.get('status')} withDismiss hidden={this.props.hidden} />;
+    return (
+      <StatusContainer
+        id={notification.get('status')}
+        withDismiss
+        hidden={this.props.hidden}
+        onMoveDown={this.handleMoveDown}
+        onMoveUp={this.handleMoveUp}
+      />
+    );
   }
 
   renderFavourite (notification, link) {
     return (
-      <div className='notification notification-favourite'>
-        <div className='notification__message'>
-          <div className='notification__favourite-icon-wrapper'>
-            <i className='fa fa-fw fa-star star-icon' />
+      <HotKeys handlers={this.getHandlers()}>
+        <div className='notification notification-favourite focusable' tabIndex='0'>
+          <div className='notification__message'>
+            <div className='notification__favourite-icon-wrapper'>
+              <i className='fa fa-fw fa-star star-icon' />
+            </div>
+            <FormattedMessage id='notification.favourite' defaultMessage='{name} favourited your status' values={{ name: link }} />
           </div>
-          <FormattedMessage id='notification.favourite' defaultMessage='{name} favourited your status' values={{ name: link }} />
-        </div>
 
-        <StatusContainer id={notification.get('status')} account={notification.get('account')} muted withDismiss hidden={!!this.props.hidden} />
-      </div>
+          <StatusContainer id={notification.get('status')} account={notification.get('account')} muted withDismiss hidden={!!this.props.hidden} />
+        </div>
+      </HotKeys>
     );
   }
 
   renderReblog (notification, link) {
     return (
-      <div className='notification notification-reblog'>
-        <div className='notification__message'>
-          <div className='notification__favourite-icon-wrapper'>
-            <i className='fa fa-fw fa-retweet' />
+      <HotKeys handlers={this.getHandlers()}>
+        <div className='notification notification-reblog focusable' tabIndex='0'>
+          <div className='notification__message'>
+            <div className='notification__favourite-icon-wrapper'>
+              <i className='fa fa-fw fa-retweet' />
+            </div>
+            <FormattedMessage id='notification.reblog' defaultMessage='{name} boosted your status' values={{ name: link }} />
           </div>
-          <FormattedMessage id='notification.reblog' defaultMessage='{name} boosted your status' values={{ name: link }} />
-        </div>
 
-        <StatusContainer id={notification.get('status')} account={notification.get('account')} muted withDismiss hidden={this.props.hidden} />
-      </div>
+          <StatusContainer id={notification.get('status')} account={notification.get('account')} muted withDismiss hidden={this.props.hidden} />
+        </div>
+      </HotKeys>
     );
   }
 
diff --git a/app/javascript/mastodon/features/notifications/containers/notification_container.js b/app/javascript/mastodon/features/notifications/containers/notification_container.js
index 1f98a66d2..fd16c4331 100644
--- a/app/javascript/mastodon/features/notifications/containers/notification_container.js
+++ b/app/javascript/mastodon/features/notifications/containers/notification_container.js
@@ -4,6 +4,7 @@
 import { connect } from 'react-redux';
 import { makeGetNotification } from '../../../selectors';
 import Notification from '../components/notification';
+import { mentionCompose } from '../../../actions/compose';
 
 const makeMapStateToProps = () => {
   const getNotification = makeGetNotification();
@@ -15,4 +16,10 @@ const makeMapStateToProps = () => {
   return mapStateToProps;
 };
 
-export default connect(makeMapStateToProps)(Notification);
+const mapDispatchToProps = dispatch => ({
+  onMention: (account, router) => {
+    dispatch(mentionCompose(account, router));
+  },
+});
+
+export default connect(makeMapStateToProps, mapDispatchToProps)(Notification);
diff --git a/app/javascript/mastodon/features/notifications/index.js b/app/javascript/mastodon/features/notifications/index.js
index 0ed940c6d..9c6802482 100644
--- a/app/javascript/mastodon/features/notifications/index.js
+++ b/app/javascript/mastodon/features/notifications/index.js
@@ -103,6 +103,24 @@ export default class Notifications extends React.PureComponent {
     this.column = c;
   }
 
+  handleMoveUp = id => {
+    const elementIndex = this.props.notifications.findIndex(item => item.get('id') === id) - 1;
+    this._selectChild(elementIndex);
+  }
+
+  handleMoveDown = id => {
+    const elementIndex = this.props.notifications.findIndex(item => item.get('id') === id) + 1;
+    this._selectChild(elementIndex);
+  }
+
+  _selectChild (index) {
+    const element = this.column.node.querySelector(`article:nth-of-type(${index + 1}) .focusable`);
+
+    if (element) {
+      element.focus();
+    }
+  }
+
   render () {
     const { intl, notifications, shouldUpdateScroll, isLoading, isUnread, columnId, multiColumn, hasMore } = this.props;
     const pinned = !!columnId;
@@ -113,7 +131,15 @@ export default class Notifications extends React.PureComponent {
     if (isLoading && this.scrollableContent) {
       scrollableContent = this.scrollableContent;
     } else if (notifications.size > 0 || hasMore) {
-      scrollableContent = notifications.map((item) => <NotificationContainer key={item.get('id')} notification={item} accountId={item.get('account')} />);
+      scrollableContent = notifications.map((item) => (
+        <NotificationContainer
+          key={item.get('id')}
+          notification={item}
+          accountId={item.get('account')}
+          onMoveUp={this.handleMoveUp}
+          onMoveDown={this.handleMoveDown}
+        />
+      ));
     } else {
       scrollableContent = null;
     }
diff --git a/app/javascript/mastodon/features/standalone/hashtag_timeline/index.js b/app/javascript/mastodon/features/standalone/hashtag_timeline/index.js
new file mode 100644
index 000000000..f15fbb2f4
--- /dev/null
+++ b/app/javascript/mastodon/features/standalone/hashtag_timeline/index.js
@@ -0,0 +1,70 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import StatusListContainer from '../../ui/containers/status_list_container';
+import {
+  refreshHashtagTimeline,
+  expandHashtagTimeline,
+} from '../../../actions/timelines';
+import Column from '../../../components/column';
+import ColumnHeader from '../../../components/column_header';
+
+@connect()
+export default class HashtagTimeline extends React.PureComponent {
+
+  static propTypes = {
+    dispatch: PropTypes.func.isRequired,
+    hashtag: PropTypes.string.isRequired,
+  };
+
+  handleHeaderClick = () => {
+    this.column.scrollTop();
+  }
+
+  setRef = c => {
+    this.column = c;
+  }
+
+  componentDidMount () {
+    const { dispatch, hashtag } = this.props;
+
+    dispatch(refreshHashtagTimeline(hashtag));
+
+    this.polling = setInterval(() => {
+      dispatch(refreshHashtagTimeline(hashtag));
+    }, 10000);
+  }
+
+  componentWillUnmount () {
+    if (typeof this.polling !== 'undefined') {
+      clearInterval(this.polling);
+      this.polling = null;
+    }
+  }
+
+  handleLoadMore = () => {
+    this.props.dispatch(expandHashtagTimeline(this.props.hashtag));
+  }
+
+  render () {
+    const { hashtag } = this.props;
+
+    return (
+      <Column ref={this.setRef}>
+        <ColumnHeader
+          icon='hashtag'
+          title={hashtag}
+          onClick={this.handleHeaderClick}
+        />
+
+        <StatusListContainer
+          trackScroll={false}
+          scrollKey='standalone_hashtag_timeline'
+          timelineId={`hashtag:${hashtag}`}
+          loadMore={this.handleLoadMore}
+        />
+      </Column>
+    );
+  }
+
+}
diff --git a/app/javascript/mastodon/features/status/components/card.js b/app/javascript/mastodon/features/status/components/card.js
index 41c4300d3..bb83374b9 100644
--- a/app/javascript/mastodon/features/status/components/card.js
+++ b/app/javascript/mastodon/features/status/components/card.js
@@ -30,6 +30,10 @@ export default class Card extends React.PureComponent {
     maxDescription: 50,
   };
 
+  state = {
+    width: 0,
+  };
+
   renderLink () {
     const { card, maxDescription } = this.props;
 
@@ -75,14 +79,25 @@ export default class Card extends React.PureComponent {
     );
   }
 
+  setRef = c => {
+    if (c) {
+      this.setState({ width: c.offsetWidth });
+    }
+  }
+
   renderVideo () {
-    const { card } = this.props;
-    const content  = { __html: card.get('html') };
+    const { card }  = this.props;
+    const content   = { __html: card.get('html') };
+    const { width } = this.state;
+    const ratio     = card.get('width') / card.get('height');
+    const height    = card.get('width') > card.get('height') ? (width / ratio) : (width * ratio);
 
     return (
       <div
+        ref={this.setRef}
         className='status-card-video'
         dangerouslySetInnerHTML={content}
+        style={{ height }}
       />
     );
   }
diff --git a/app/javascript/mastodon/features/status/components/detailed_status.js b/app/javascript/mastodon/features/status/components/detailed_status.js
index 232eccf70..816f83e45 100644
--- a/app/javascript/mastodon/features/status/components/detailed_status.js
+++ b/app/javascript/mastodon/features/status/components/detailed_status.js
@@ -7,7 +7,7 @@ import StatusContent from '../../../../glitch/components/status/content';
 import StatusGallery from '../../../../glitch/components/status/gallery';
 import StatusPlayer from '../../../../glitch/components/status/player';
 import AttachmentList from '../../../components/attachment_list';
-import Link from 'react-router-dom/Link';
+import { Link } from 'react-router-dom';
 import { FormattedDate, FormattedNumber } from 'react-intl';
 import CardContainer from '../containers/card_container';
 import ImmutablePureComponent from 'react-immutable-pure-component';
diff --git a/app/javascript/mastodon/features/status/index.js b/app/javascript/mastodon/features/status/index.js
index fc45d5f21..fff5f529c 100644
--- a/app/javascript/mastodon/features/status/index.js
+++ b/app/javascript/mastodon/features/status/index.js
@@ -28,6 +28,7 @@ import StatusContainer from '../../../glitch/components/status/container';
 import { openModal } from '../../actions/modal';
 import { defineMessages, injectIntl } from 'react-intl';
 import ImmutablePureComponent from 'react-immutable-pure-component';
+import { HotKeys } from 'react-hotkeys';
 
 const messages = defineMessages({
   deleteConfirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' },
@@ -153,8 +154,100 @@ export default class Status extends ImmutablePureComponent {
     this.props.dispatch(openModal('EMBED', { url: status.get('url') }));
   }
 
+  handleHotkeyMoveUp = () => {
+    this.handleMoveUp(this.props.status.get('id'));
+  }
+
+  handleHotkeyMoveDown = () => {
+    this.handleMoveDown(this.props.status.get('id'));
+  }
+
+  handleHotkeyReply = e => {
+    e.preventDefault();
+    this.handleReplyClick(this.props.status);
+  }
+
+  handleHotkeyFavourite = () => {
+    this.handleFavouriteClick(this.props.status);
+  }
+
+  handleHotkeyBoost = () => {
+    this.handleReblogClick(this.props.status);
+  }
+
+  handleHotkeyMention = e => {
+    e.preventDefault();
+    this.handleMentionClick(this.props.status);
+  }
+
+  handleHotkeyOpenProfile = () => {
+    this.context.router.history.push(`/accounts/${this.props.status.getIn(['account', 'id'])}`);
+  }
+
+  handleMoveUp = id => {
+    const { status, ancestorsIds, descendantsIds } = this.props;
+
+    if (id === status.get('id')) {
+      this._selectChild(ancestorsIds.size - 1);
+    } else {
+      let index = ancestorsIds.indexOf(id);
+
+      if (index === -1) {
+        index = descendantsIds.indexOf(id);
+        this._selectChild(ancestorsIds.size + index);
+      } else {
+        this._selectChild(index - 1);
+      }
+    }
+  }
+
+  handleMoveDown = id => {
+    const { status, ancestorsIds, descendantsIds } = this.props;
+
+    if (id === status.get('id')) {
+      this._selectChild(ancestorsIds.size + 1);
+    } else {
+      let index = ancestorsIds.indexOf(id);
+
+      if (index === -1) {
+        index = descendantsIds.indexOf(id);
+        this._selectChild(ancestorsIds.size + index + 2);
+      } else {
+        this._selectChild(index + 1);
+      }
+    }
+  }
+
+  _selectChild (index) {
+    const element = this.node.querySelectorAll('.focusable')[index];
+
+    if (element) {
+      element.focus();
+    }
+  }
+
   renderChildren (list) {
-    return list.map(id => <StatusContainer key={id} id={id} />);
+    return list.map(id => (
+      <StatusContainer
+        key={id}
+        id={id}
+        onMoveUp={this.handleMoveUp}
+        onMoveDown={this.handleMoveDown}
+      />
+    ));
+  }
+
+  setRef = c => {
+    this.node = c;
+  }
+
+  componentDidUpdate () {
+    const { status, ancestorsIds } = this.props;
+
+    if (status && ancestorsIds && ancestorsIds.size > 0) {
+      const element = this.node.querySelectorAll('.focusable')[ancestorsIds.size];
+      element.scrollIntoView();
+    }
   }
 
   render () {
@@ -178,35 +271,49 @@ export default class Status extends ImmutablePureComponent {
       descendants = <div>{this.renderChildren(descendantsIds)}</div>;
     }
 
+    const handlers = {
+      moveUp: this.handleHotkeyMoveUp,
+      moveDown: this.handleHotkeyMoveDown,
+      reply: this.handleHotkeyReply,
+      favourite: this.handleHotkeyFavourite,
+      boost: this.handleHotkeyBoost,
+      mention: this.handleHotkeyMention,
+      openProfile: this.handleHotkeyOpenProfile,
+    };
+
     return (
       <Column>
         <ColumnBackButton />
 
         <ScrollContainer scrollKey='thread'>
-          <div className='scrollable detailed-status__wrapper'>
+          <div className='scrollable detailed-status__wrapper' ref={this.setRef}>
             {ancestors}
 
-            <DetailedStatus
-              status={status}
-              settings={settings}
-              autoPlayGif={autoPlayGif}
-              me={me}
-              onOpenVideo={this.handleOpenVideo}
-              onOpenMedia={this.handleOpenMedia}
-            />
-
-            <ActionBar
-              status={status}
-              me={me}
-              onReply={this.handleReplyClick}
-              onFavourite={this.handleFavouriteClick}
-              onReblog={this.handleReblogClick}
-              onDelete={this.handleDeleteClick}
-              onMention={this.handleMentionClick}
-              onReport={this.handleReport}
-              onPin={this.handlePin}
-              onEmbed={this.handleEmbed}
-            />
+            <HotKeys handlers={handlers}>
+              <div className='focusable' tabIndex='0'>
+                <DetailedStatus
+                  status={status}
+                  settings={settings}
+                  autoPlayGif={autoPlayGif}
+                  me={me}
+                  onOpenVideo={this.handleOpenVideo}
+                  onOpenMedia={this.handleOpenMedia}
+                />
+
+                <ActionBar
+                  status={status}
+                  me={me}
+                  onReply={this.handleReplyClick}
+                  onFavourite={this.handleFavouriteClick}
+                  onReblog={this.handleReblogClick}
+                  onDelete={this.handleDeleteClick}
+                  onMention={this.handleMentionClick}
+                  onReport={this.handleReport}
+                  onPin={this.handlePin}
+                  onEmbed={this.handleEmbed}
+                />
+              </div>
+            </HotKeys>
 
             {descendants}
           </div>
diff --git a/app/javascript/mastodon/features/ui/components/column_link.js b/app/javascript/mastodon/features/ui/components/column_link.js
index 62aab9a23..b845d1895 100644
--- a/app/javascript/mastodon/features/ui/components/column_link.js
+++ b/app/javascript/mastodon/features/ui/components/column_link.js
@@ -1,6 +1,6 @@
 import React from 'react';
 import PropTypes from 'prop-types';
-import Link from 'react-router-dom/Link';
+import { Link } from 'react-router-dom';
 
 const ColumnLink = ({ icon, text, to, onClick, href, method }) => {
   if (href) {
diff --git a/app/javascript/mastodon/features/ui/components/media_modal.js b/app/javascript/mastodon/features/ui/components/media_modal.js
index 828419d5a..f41a83089 100644
--- a/app/javascript/mastodon/features/ui/components/media_modal.js
+++ b/app/javascript/mastodon/features/ui/components/media_modal.js
@@ -29,7 +29,7 @@ export default class MediaModal extends ImmutablePureComponent {
   };
 
   handleSwipe = (index) => {
-    this.setState({ index: (index) % this.props.media.size });
+    this.setState({ index: index % this.props.media.size });
   }
 
   handleNextClick = () => {
@@ -40,6 +40,11 @@ export default class MediaModal extends ImmutablePureComponent {
     this.setState({ index: (this.props.media.size + this.getIndex() - 1) % this.props.media.size });
   }
 
+  handleChangeIndex = (e) => {
+    const index = Number(e.currentTarget.getAttribute('data-index'));
+    this.setState({ index: index % this.props.media.size });
+  }
+
   handleKeyUp = (e) => {
     switch(e.key) {
     case 'ArrowLeft':
@@ -67,33 +72,51 @@ export default class MediaModal extends ImmutablePureComponent {
     const { media, intl, onClose } = this.props;
 
     const index = this.getIndex();
+    let pagination = [];
 
     const leftNav  = media.size > 1 && <button tabIndex='0' className='modal-container__nav modal-container__nav--left' onClick={this.handlePrevClick} aria-label={intl.formatMessage(messages.previous)}><i className='fa fa-fw fa-chevron-left' /></button>;
     const rightNav = media.size > 1 && <button tabIndex='0' className='modal-container__nav  modal-container__nav--right' onClick={this.handleNextClick} aria-label={intl.formatMessage(messages.next)}><i className='fa fa-fw fa-chevron-right' /></button>;
 
+    if (media.size > 1) {
+      pagination = media.map((item, i) => {
+        const classes = ['media-modal__button'];
+        if (i === index) {
+          classes.push('media-modal__button--active');
+        }
+        return (<li className='media-modal__page-dot' key={i}><button tabIndex='0' className={classes.join(' ')} onClick={this.handleChangeIndex} data-index={i}>{i + 1}</button></li>);
+      });
+    }
+
     const content = media.map((image) => {
       const width  = image.getIn(['meta', 'original', 'width']) || null;
       const height = image.getIn(['meta', 'original', 'height']) || null;
 
       if (image.get('type') === 'image') {
-        return <ImageLoader previewSrc={image.get('preview_url')} src={image.get('url')} width={width} height={height} key={image.get('preview_url')} />;
+        return <ImageLoader previewSrc={image.get('preview_url')} src={image.get('url')} width={width} height={height} alt={image.get('description')} key={image.get('preview_url')} />;
       } else if (image.get('type') === 'gifv') {
-        return <ExtendedVideoPlayer src={image.get('url')} muted controls={false} width={width} height={height} key={image.get('preview_url')} />;
+        return <ExtendedVideoPlayer src={image.get('url')} muted controls={false} width={width} height={height} key={image.get('preview_url')} alt={image.get('description')} />;
       }
 
       return null;
     }).toArray();
 
+    const containerStyle = {
+      alignItems: 'center', // center vertically
+    };
+
     return (
       <div className='modal-root__modal media-modal'>
         {leftNav}
 
         <div className='media-modal__content'>
           <IconButton className='media-modal__close' title={intl.formatMessage(messages.close)} icon='times' onClick={onClose} size={16} />
-          <ReactSwipeableViews onChangeIndex={this.handleSwipe} index={index} animateHeight>
+          <ReactSwipeableViews containerStyle={containerStyle} onChangeIndex={this.handleSwipe} index={index}>
             {content}
           </ReactSwipeableViews>
         </div>
+        <ul className='media-modal__pagination'>
+          {pagination}
+        </ul>
 
         {rightNav}
       </div>
diff --git a/app/javascript/mastodon/features/ui/components/modal_root.js b/app/javascript/mastodon/features/ui/components/modal_root.js
index 6347c4b22..88a4d0a59 100644
--- a/app/javascript/mastodon/features/ui/components/modal_root.js
+++ b/app/javascript/mastodon/features/ui/components/modal_root.js
@@ -1,7 +1,5 @@
 import React from 'react';
 import PropTypes from 'prop-types';
-import TransitionMotion from 'react-motion/lib/TransitionMotion';
-import spring from 'react-motion/lib/spring';
 import BundleContainer from '../containers/bundle_container';
 import BundleModalError from './bundle_modal_error';
 import ModalLoading from './modal_loading';
@@ -39,6 +37,10 @@ export default class ModalRoot extends React.PureComponent {
     onClose: PropTypes.func.isRequired,
   };
 
+  state = {
+    revealed: false,
+  };
+
   handleKeyUp = (e) => {
     if ((e.key === 'Escape' || e.key === 'Esc' || e.keyCode === 27)
          && !!this.props.type) {
@@ -55,6 +57,8 @@ export default class ModalRoot extends React.PureComponent {
       this.activeElement = document.activeElement;
 
       this.getSiblings().forEach(sibling => sibling.setAttribute('inert', true));
+    } else if (!nextProps.type) {
+      this.setState({ revealed: false });
     }
   }
 
@@ -64,6 +68,11 @@ export default class ModalRoot extends React.PureComponent {
       this.activeElement.focus();
       this.activeElement = null;
     }
+    if (this.props.type) {
+      requestAnimationFrame(() => {
+        this.setState({ revealed: true });
+      });
+    }
   }
 
   componentWillUnmount () {
@@ -78,14 +87,6 @@ export default class ModalRoot extends React.PureComponent {
     this.node = ref;
   }
 
-  willEnter () {
-    return { opacity: 0, scale: 0.98 };
-  }
-
-  willLeave () {
-    return { opacity: spring(0), scale: spring(0.98) };
-  }
-
   renderLoading = modalId => () => {
     return ['MEDIA', 'VIDEO', 'BOOST', 'CONFIRM', 'ACTIONS'].indexOf(modalId) === -1 ? <ModalLoading /> : null;
   }
@@ -98,38 +99,30 @@ export default class ModalRoot extends React.PureComponent {
 
   render () {
     const { type, props, onClose } = this.props;
+    const { revealed } = this.state;
     const visible = !!type;
-    const items = [];
 
-    if (visible) {
-      items.push({
-        key: type,
-        data: { type, props },
-        style: { opacity: spring(1), scale: spring(1, { stiffness: 120, damping: 14 }) },
-      });
+    if (!visible) {
+      return (
+        <div className='modal-root' ref={this.setRef} style={{ opacity: 0 }} />
+      );
     }
 
     return (
-      <TransitionMotion
-        styles={items}
-        willEnter={this.willEnter}
-        willLeave={this.willLeave}
-      >
-        {interpolatedStyles =>
-          <div className='modal-root' ref={this.setRef}>
-            {interpolatedStyles.map(({ key, data: { type, props }, style }) => (
-              <div key={key} style={{ pointerEvents: visible ? 'auto' : 'none' }}>
-                <div role='presentation' className='modal-root__overlay' style={{ opacity: style.opacity }} onClick={onClose} />
-                <div role='dialog' className='modal-root__container' style={{ opacity: style.opacity, transform: `translateZ(0px) scale(${style.scale})` }}>
-                  <BundleContainer fetchComponent={MODAL_COMPONENTS[type]} loading={this.renderLoading(type)} error={this.renderError} renderDelay={200}>
-                    {(SpecificComponent) => <SpecificComponent {...props} onClose={onClose} />}
-                  </BundleContainer>
-                </div>
-              </div>
-            ))}
+      <div className='modal-root' ref={this.setRef} style={{ opacity: revealed ? 1 : 0 }}>
+        <div style={{ pointerEvents: visible ? 'auto' : 'none' }}>
+          <div role='presentation' className='modal-root__overlay' onClick={onClose} />
+          <div role='dialog' className='modal-root__container'>
+            {
+              visible ?
+                (<BundleContainer fetchComponent={MODAL_COMPONENTS[type]} loading={this.renderLoading(type)} error={this.renderError} renderDelay={200}>
+                  {(SpecificComponent) => <SpecificComponent {...props} onClose={onClose} />}
+                </BundleContainer>) :
+              null
+            }
           </div>
-        }
-      </TransitionMotion>
+        </div>
+      </div>
     );
   }
 
diff --git a/app/javascript/mastodon/features/ui/components/tabs_bar.js b/app/javascript/mastodon/features/ui/components/tabs_bar.js
index af9e6bf45..7694e5ab3 100644
--- a/app/javascript/mastodon/features/ui/components/tabs_bar.js
+++ b/app/javascript/mastodon/features/ui/components/tabs_bar.js
@@ -1,6 +1,6 @@
 import React from 'react';
 import PropTypes from 'prop-types';
-import NavLink from 'react-router-dom/NavLink';
+import { NavLink } from 'react-router-dom';
 import { FormattedMessage, injectIntl } from 'react-intl';
 import { debounce } from 'lodash';
 import { isUserTouching } from '../../../is_mobile';
diff --git a/app/javascript/mastodon/features/ui/components/video_modal.js b/app/javascript/mastodon/features/ui/components/video_modal.js
index 867c73ed5..1437deeb0 100644
--- a/app/javascript/mastodon/features/ui/components/video_modal.js
+++ b/app/javascript/mastodon/features/ui/components/video_modal.js
@@ -23,6 +23,7 @@ export default class VideoModal extends ImmutablePureComponent {
             src={media.get('url')}
             startTime={time}
             onCloseVideo={onClose}
+            description={media.get('description')}
           />
         </div>
       </div>
diff --git a/app/javascript/mastodon/features/ui/index.js b/app/javascript/mastodon/features/ui/index.js
index 73bd23432..14a5f6224 100644
--- a/app/javascript/mastodon/features/ui/index.js
+++ b/app/javascript/mastodon/features/ui/index.js
@@ -8,7 +8,7 @@ import { connect } from 'react-redux';
 import { Redirect, withRouter } from 'react-router-dom';
 import { isMobile } from '../../is_mobile';
 import { debounce } from 'lodash';
-import { uploadCompose } from '../../actions/compose';
+import { uploadCompose, resetCompose } from '../../actions/compose';
 import { refreshHomeTimeline } from '../../actions/timelines';
 import { refreshNotifications } from '../../actions/notifications';
 import { clearHeight } from '../../actions/height_cache';
@@ -38,6 +38,7 @@ import {
   Mutes,
   PinnedStatuses,
 } from './util/async-components';
+import { HotKeys } from 'react-hotkeys';
 
 // Dummy import, to make sure that <Status /> ends up in the application bundle.
 // Without this it ends up in ~8 very commonly used bundles.
@@ -48,12 +49,39 @@ const mapStateToProps = state => ({
   layout: state.getIn(['local_settings', 'layout']),
   isWide: state.getIn(['local_settings', 'stretch']),
   navbarUnder: state.getIn(['local_settings', 'navbar_under']),
+  me: state.getIn(['meta', 'me']),
   isComposing: state.getIn(['compose', 'is_composing']),
 });
 
+const keyMap = {
+  new: 'n',
+  search: 's',
+  forceNew: 'option+n',
+  focusColumn: ['1', '2', '3', '4', '5', '6', '7', '8', '9'],
+  reply: 'r',
+  favourite: 'f',
+  boost: 'b',
+  mention: 'm',
+  open: ['enter', 'o'],
+  openProfile: 'p',
+  moveDown: ['down', 'j'],
+  moveUp: ['up', 'k'],
+  back: 'backspace',
+  goToHome: 'g h',
+  goToNotifications: 'g n',
+  goToLocal: 'g l',
+  goToFederated: 'g t',
+  goToStart: 'g s',
+  goToFavourites: 'g f',
+  goToPinned: 'g p',
+  goToProfile: 'g u',
+  goToBlocked: 'g b',
+  goToMuted: 'g m',
+};
+
 @connect(mapStateToProps)
 @withRouter
-export default class UI extends React.PureComponent {
+export default class UI extends React.Component {
 
   static contextTypes = {
     router: PropTypes.object.isRequired,
@@ -67,6 +95,7 @@ export default class UI extends React.PureComponent {
     systemFontUi: PropTypes.bool,
     navbarUnder: PropTypes.bool,
     isComposing: PropTypes.bool,
+    me: PropTypes.string,
     location: PropTypes.object,
   };
 
@@ -164,6 +193,12 @@ export default class UI extends React.PureComponent {
     this.props.dispatch(refreshNotifications());
   }
 
+  componentDidMount () {
+    this.hotkeys.__mousetrap__.stopCallback = (e, element) => {
+      return ['TEXTAREA', 'SELECT', 'INPUT'].includes(element.tagName);
+    };
+  }
+
   shouldComponentUpdate (nextProps) {
     if (nextProps.isComposing !== this.props.isComposing) {
       // Avoid expensive update just to toggle a class
@@ -201,8 +236,94 @@ export default class UI extends React.PureComponent {
     this.columnsAreaNode = c.getWrappedInstance().getWrappedInstance();
   }
 
-  setOverlayRef = c => {
-    this.overlay = c;
+  handleHotkeyNew = e => {
+    e.preventDefault();
+
+    const element = this.node.querySelector('.compose-form__autosuggest-wrapper textarea');
+
+    if (element) {
+      element.focus();
+    }
+  }
+
+  handleHotkeySearch = e => {
+    e.preventDefault();
+
+    const element = this.node.querySelector('.search__input');
+
+    if (element) {
+      element.focus();
+    }
+  }
+
+  handleHotkeyForceNew = e => {
+    this.handleHotkeyNew(e);
+    this.props.dispatch(resetCompose());
+  }
+
+  handleHotkeyFocusColumn = e => {
+    const index  = (e.key * 1) + 1; // First child is drawer, skip that
+    const column = this.node.querySelector(`.column:nth-child(${index})`);
+
+    if (column) {
+      const status = column.querySelector('.focusable');
+
+      if (status) {
+        status.focus();
+      }
+    }
+  }
+
+  handleHotkeyBack = () => {
+    if (window.history && window.history.length === 1) {
+      this.context.router.history.push('/');
+    } else {
+      this.context.router.history.goBack();
+    }
+  }
+
+  setHotkeysRef = c => {
+    this.hotkeys = c;
+  }
+
+  handleHotkeyGoToHome = () => {
+    this.context.router.history.push('/timelines/home');
+  }
+
+  handleHotkeyGoToNotifications = () => {
+    this.context.router.history.push('/notifications');
+  }
+
+  handleHotkeyGoToLocal = () => {
+    this.context.router.history.push('/timelines/public/local');
+  }
+
+  handleHotkeyGoToFederated = () => {
+    this.context.router.history.push('/timelines/public');
+  }
+
+  handleHotkeyGoToStart = () => {
+    this.context.router.history.push('/getting-started');
+  }
+
+  handleHotkeyGoToFavourites = () => {
+    this.context.router.history.push('/favourites');
+  }
+
+  handleHotkeyGoToPinned = () => {
+    this.context.router.history.push('/pinned');
+  }
+
+  handleHotkeyGoToProfile = () => {
+    this.context.router.history.push(`/accounts/${this.props.me}`);
+  }
+
+  handleHotkeyGoToBlocked = () => {
+    this.context.router.history.push('/blocks');
+  }
+
+  handleHotkeyGoToMuted = () => {
+    this.context.router.history.push('/mutes');
   }
 
   render () {
@@ -226,45 +347,67 @@ export default class UI extends React.PureComponent {
       'navbar-under': navbarUnder,
     });
 
+    const handlers = {
+      new: this.handleHotkeyNew,
+      search: this.handleHotkeySearch,
+      forceNew: this.handleHotkeyForceNew,
+      focusColumn: this.handleHotkeyFocusColumn,
+      back: this.handleHotkeyBack,
+      goToHome: this.handleHotkeyGoToHome,
+      goToNotifications: this.handleHotkeyGoToNotifications,
+      goToLocal: this.handleHotkeyGoToLocal,
+      goToFederated: this.handleHotkeyGoToFederated,
+      goToStart: this.handleHotkeyGoToStart,
+      goToFavourites: this.handleHotkeyGoToFavourites,
+      goToPinned: this.handleHotkeyGoToPinned,
+      goToProfile: this.handleHotkeyGoToProfile,
+      goToBlocked: this.handleHotkeyGoToBlocked,
+      goToMuted: this.handleHotkeyGoToMuted,
+    };
+
     return (
-      <div className={className} ref={this.setRef}>
-        {navbarUnder ? null : (<TabsBar />)}
-        <ColumnsAreaContainer ref={this.setColumnsAreaRef} singleColumn={isMobile(width, layout)}>
-          <WrappedSwitch>
-            <Redirect from='/' to='/getting-started' exact />
-            <WrappedRoute path='/getting-started' component={GettingStarted} content={children} />
-            <WrappedRoute path='/timelines/home' component={HomeTimeline} content={children} />
-            <WrappedRoute path='/timelines/public' exact component={PublicTimeline} content={children} />
-            <WrappedRoute path='/timelines/public/local' component={CommunityTimeline} content={children} />
-            <WrappedRoute path='/timelines/tag/:id' component={HashtagTimeline} content={children} />
-
-            <WrappedRoute path='/notifications' component={Notifications} content={children} />
-            <WrappedRoute path='/favourites' component={FavouritedStatuses} content={children} />
-            <WrappedRoute path='/pinned' component={PinnedStatuses} content={children} />
-
-            <WrappedRoute path='/statuses/new' component={Compose} content={children} />
-            <WrappedRoute path='/statuses/:statusId' exact component={Status} content={children} />
-            <WrappedRoute path='/statuses/:statusId/reblogs' component={Reblogs} content={children} />
-            <WrappedRoute path='/statuses/:statusId/favourites' component={Favourites} content={children} />
-
-            <WrappedRoute path='/accounts/:accountId' exact component={AccountTimeline} content={children} />
-            <WrappedRoute path='/accounts/:accountId/followers' component={Followers} content={children} />
-            <WrappedRoute path='/accounts/:accountId/following' component={Following} content={children} />
-            <WrappedRoute path='/accounts/:accountId/media' component={AccountGallery} content={children} />
-
-            <WrappedRoute path='/follow_requests' component={FollowRequests} content={children} />
-            <WrappedRoute path='/blocks' component={Blocks} content={children} />
-            <WrappedRoute path='/mutes' component={Mutes} content={children} />
-
-            <WrappedRoute component={GenericNotFound} content={children} />
-          </WrappedSwitch>
-        </ColumnsAreaContainer>
-        <NotificationsContainer />
-        {navbarUnder ? (<TabsBar />) : null}
-        <LoadingBarContainer className='loading-bar' />
-        <ModalContainer />
-        <UploadArea active={draggingOver} onClose={this.closeUploadModal} />
-      </div>
+      <HotKeys keyMap={keyMap} handlers={handlers} ref={this.setHotkeysRef}>
+        <div className={className} ref={this.setRef}>
+        	{navbarUnder ? null : (<TabsBar />)}
+
+          <ColumnsAreaContainer ref={this.setColumnsAreaRef} singleColumn={isMobile(width, layout)}>
+            <WrappedSwitch>
+              <Redirect from='/' to='/getting-started' exact />
+              <WrappedRoute path='/getting-started' component={GettingStarted} content={children} />
+              <WrappedRoute path='/timelines/home' component={HomeTimeline} content={children} />
+              <WrappedRoute path='/timelines/public' exact component={PublicTimeline} content={children} />
+              <WrappedRoute path='/timelines/public/local' component={CommunityTimeline} content={children} />
+              <WrappedRoute path='/timelines/tag/:id' component={HashtagTimeline} content={children} />
+
+              <WrappedRoute path='/notifications' component={Notifications} content={children} />
+              <WrappedRoute path='/favourites' component={FavouritedStatuses} content={children} />
+              <WrappedRoute path='/pinned' component={PinnedStatuses} content={children} />
+
+              <WrappedRoute path='/statuses/new' component={Compose} content={children} />
+              <WrappedRoute path='/statuses/:statusId' exact component={Status} content={children} />
+              <WrappedRoute path='/statuses/:statusId/reblogs' component={Reblogs} content={children} />
+              <WrappedRoute path='/statuses/:statusId/favourites' component={Favourites} content={children} />
+
+              <WrappedRoute path='/accounts/:accountId' exact component={AccountTimeline} content={children} />
+              <WrappedRoute path='/accounts/:accountId/followers' component={Followers} content={children} />
+              <WrappedRoute path='/accounts/:accountId/following' component={Following} content={children} />
+              <WrappedRoute path='/accounts/:accountId/media' component={AccountGallery} content={children} />
+
+              <WrappedRoute path='/follow_requests' component={FollowRequests} content={children} />
+              <WrappedRoute path='/blocks' component={Blocks} content={children} />
+              <WrappedRoute path='/mutes' component={Mutes} content={children} />
+
+              <WrappedRoute component={GenericNotFound} content={children} />
+            </WrappedSwitch>
+          </ColumnsAreaContainer>
+
+          <NotificationsContainer />
+        	{navbarUnder ? (<TabsBar />) : null}
+          <LoadingBarContainer className='loading-bar' />
+          <ModalContainer />
+          <UploadArea active={draggingOver} onClose={this.closeUploadModal} />
+        </div>
+      </HotKeys>
     );
   }
 
diff --git a/app/javascript/mastodon/features/ui/util/async-components.js b/app/javascript/mastodon/features/ui/util/async-components.js
index 5d640810f..7f2b303a7 100644
--- a/app/javascript/mastodon/features/ui/util/async-components.js
+++ b/app/javascript/mastodon/features/ui/util/async-components.js
@@ -1,3 +1,7 @@
+export function EmojiPicker () {
+  return import(/* webpackChunkName: "emoji_picker" */'../../emoji/emoji_picker');
+}
+
 export function Compose () {
   return import(/* webpackChunkName: "features/compose" */'../../compose');
 }
@@ -101,10 +105,6 @@ export function MediaGallery () {
   return import(/* webpackChunkName: "status/media_gallery" */'../../../components/media_gallery');
 }
 
-export function VideoPlayer () {
-  return import(/* webpackChunkName: "status/video_player" */'../../../components/video_player');
-}
-
 export function Video () {
   return import(/* webpackChunkName: "features/video" */'../../video');
 }
diff --git a/app/javascript/mastodon/features/ui/util/fullscreen.js b/app/javascript/mastodon/features/ui/util/fullscreen.js
new file mode 100644
index 000000000..cf5d0cf98
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/util/fullscreen.js
@@ -0,0 +1,46 @@
+// APIs for normalizing fullscreen operations. Note that Edge uses
+// the WebKit-prefixed APIs currently (as of Edge 16).
+
+export const isFullscreen = () => document.fullscreenElement ||
+  document.webkitFullscreenElement ||
+  document.mozFullScreenElement;
+
+export const exitFullscreen = () => {
+  if (document.exitFullscreen) {
+    document.exitFullscreen();
+  } else if (document.webkitExitFullscreen) {
+    document.webkitExitFullscreen();
+  } else if (document.mozCancelFullScreen) {
+    document.mozCancelFullScreen();
+  }
+};
+
+export const requestFullscreen = el => {
+  if (el.requestFullscreen) {
+    el.requestFullscreen();
+  } else if (el.webkitRequestFullscreen) {
+    el.webkitRequestFullscreen();
+  } else if (el.mozRequestFullScreen) {
+    el.mozRequestFullScreen();
+  }
+};
+
+export const attachFullscreenListener = (listener) => {
+  if ('onfullscreenchange' in document) {
+    document.addEventListener('fullscreenchange', listener);
+  } else if ('onwebkitfullscreenchange' in document) {
+    document.addEventListener('webkitfullscreenchange', listener);
+  } else if ('onmozfullscreenchange' in document) {
+    document.addEventListener('mozfullscreenchange', listener);
+  }
+};
+
+export const detachFullscreenListener = (listener) => {
+  if ('onfullscreenchange' in document) {
+    document.removeEventListener('fullscreenchange', listener);
+  } else if ('onwebkitfullscreenchange' in document) {
+    document.removeEventListener('webkitfullscreenchange', listener);
+  } else if ('onmozfullscreenchange' in document) {
+    document.removeEventListener('mozfullscreenchange', listener);
+  }
+};
diff --git a/app/javascript/mastodon/features/ui/util/react_router_helpers.js b/app/javascript/mastodon/features/ui/util/react_router_helpers.js
index ede578e56..86b30d488 100644
--- a/app/javascript/mastodon/features/ui/util/react_router_helpers.js
+++ b/app/javascript/mastodon/features/ui/util/react_router_helpers.js
@@ -1,7 +1,6 @@
 import React from 'react';
 import PropTypes from 'prop-types';
-import Switch from 'react-router-dom/Switch';
-import Route from 'react-router-dom/Route';
+import { Switch, Route } from 'react-router-dom';
 
 import ColumnLoading from '../components/column_loading';
 import BundleColumnError from '../components/bundle_column_error';
diff --git a/app/javascript/mastodon/features/video/index.js b/app/javascript/mastodon/features/video/index.js
index f228e434b..003bf23a8 100644
--- a/app/javascript/mastodon/features/video/index.js
+++ b/app/javascript/mastodon/features/video/index.js
@@ -3,6 +3,7 @@ import PropTypes from 'prop-types';
 import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
 import { throttle } from 'lodash';
 import classNames from 'classnames';
+import { isFullscreen, requestFullscreen, exitFullscreen } from '../ui/util/fullscreen';
 
 const messages = defineMessages({
   play: { id: 'video.play', defaultMessage: 'Play' },
@@ -69,41 +70,13 @@ const getPointerPosition = (el, event) => {
   return position;
 };
 
-const isFullscreen = () => document.fullscreenElement ||
-  document.webkitFullscreenElement ||
-  document.mozFullScreenElement ||
-  document.msFullscreenElement;
-
-const exitFullscreen = () => {
-  if (document.exitFullscreen) {
-    document.exitFullscreen();
-  } else if (document.webkitExitFullscreen) {
-    document.webkitExitFullscreen();
-  } else if (document.mozCancelFullScreen) {
-    document.mozCancelFullScreen();
-  } else if (document.msExitFullscreen) {
-    document.msExitFullscreen();
-  }
-};
-
-const requestFullscreen = el => {
-  if (el.requestFullscreen) {
-    el.requestFullscreen();
-  } else if (el.webkitRequestFullscreen) {
-    el.webkitRequestFullscreen();
-  } else if (el.mozRequestFullScreen) {
-    el.mozRequestFullScreen();
-  } else if (el.msRequestFullscreen) {
-    el.msRequestFullscreen();
-  }
-};
-
 @injectIntl
 export default class Video extends React.PureComponent {
 
   static propTypes = {
     preview: PropTypes.string,
     src: PropTypes.string.isRequired,
+    alt: PropTypes.string,
     width: PropTypes.number,
     height: PropTypes.number,
     sensitive: PropTypes.bool,
@@ -236,6 +209,12 @@ export default class Video extends React.PureComponent {
     }
   }
 
+  handleProgress = () => {
+    if (this.video.buffered.length > 0) {
+      this.setState({ buffer: this.video.buffered.end(0) / this.video.duration * 100 });
+    }
+  }
+
   handleOpenVideo = () => {
     this.video.pause();
     this.props.onOpenVideo(this.video.currentTime);
@@ -247,8 +226,8 @@ export default class Video extends React.PureComponent {
   }
 
   render () {
-    const { preview, src, width, height, startTime, onOpenVideo, onCloseVideo, intl } = this.props;
-    const { progress, dragging, paused, fullscreen, hovered, muted, revealed } = this.state;
+    const { preview, src, width, height, startTime, onOpenVideo, onCloseVideo, intl, alt } = this.props;
+    const { progress, buffer, dragging, paused, fullscreen, hovered, muted, revealed } = this.state;
 
     return (
       <div className={classNames('video-player', { inactive: !revealed, inline: width && height && !fullscreen, fullscreen })} style={{ width, height }} ref={this.setPlayerRef} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}>
@@ -256,10 +235,11 @@ export default class Video extends React.PureComponent {
           ref={this.setVideoRef}
           src={src}
           poster={preview}
-          preload={!!startTime}
+          preload={startTime ? 'auto' : 'none'}
           loop
           role='button'
           tabIndex='0'
+          aria-label={alt}
           width={width}
           height={height}
           onClick={this.togglePlay}
@@ -267,6 +247,7 @@ export default class Video extends React.PureComponent {
           onPause={this.handlePause}
           onTimeUpdate={this.handleTimeUpdate}
           onLoadedData={this.handleLoadedData}
+          onProgress={this.handleProgress}
         />
 
         <button className={classNames('video-player__spoiler', { active: !revealed })} onClick={this.toggleReveal}>
@@ -276,6 +257,7 @@ export default class Video extends React.PureComponent {
 
         <div className={classNames('video-player__controls', { active: paused || hovered })}>
           <div className='video-player__seek' onMouseDown={this.handleMouseDown} ref={this.setSeekRef}>
+            <div className='video-player__seek__buffer' style={{ width: `${buffer}%` }} />
             <div className='video-player__seek__progress' style={{ width: `${progress}%` }} />
 
             <span
diff --git a/app/javascript/mastodon/locales/ar.json b/app/javascript/mastodon/locales/ar.json
index bd09f1970..799819c7c 100644
--- a/app/javascript/mastodon/locales/ar.json
+++ b/app/javascript/mastodon/locales/ar.json
@@ -1,31 +1,31 @@
 {
   "account.block": "حظر @{name}",
-  "account.block_domain": "Hide everything from {domain}",
-  "account.disclaimer_full": "Information below may reflect the user's profile incompletely.",
+  "account.block_domain": "إخفاء كل شيئ قادم من إسم النطاق {domain}",
+  "account.disclaimer_full": "قد لا تعكس المعلومات أدناه الملف الشخصي الكامل للمستخدم.",
   "account.edit_profile": "تعديل الملف الشخصي",
   "account.follow": "تابِع",
   "account.followers": "المتابعون",
   "account.follows": "يتبع",
   "account.follows_you": "يتابعك",
-  "account.media": "Media",
+  "account.media": "وسائط",
   "account.mention": "أُذكُر @{name}",
   "account.mute": "أكتم @{name}",
   "account.posts": "المشاركات",
   "account.report": "أبلغ عن @{name}",
   "account.requested": "في انتظار الموافقة",
-  "account.share": "Share @{name}'s profile",
+  "account.share": "مشاركة @{name}'s profile",
   "account.unblock": "إلغاء الحظر عن @{name}",
-  "account.unblock_domain": "Unhide {domain}",
+  "account.unblock_domain": "فك حظر {domain}",
   "account.unfollow": "إلغاء المتابعة",
   "account.unmute": "إلغاء الكتم عن @{name}",
-  "account.view_full_profile": "View full profile",
+  "account.view_full_profile": "عرض الملف الشخصي كاملا",
   "boost_modal.combo": "يمكنك ضغط {combo} لتخطّي هذه في المرّة القادمة",
-  "bundle_column_error.body": "Something went wrong while loading this component.",
-  "bundle_column_error.retry": "Try again",
-  "bundle_column_error.title": "Network error",
-  "bundle_modal_error.close": "Close",
-  "bundle_modal_error.message": "Something went wrong while loading this component.",
-  "bundle_modal_error.retry": "Try again",
+  "bundle_column_error.body": "لقد وقع هناك خطأ أثناء عملية تحميل هذا العنصر.",
+  "bundle_column_error.retry": "إعادة المحاولة",
+  "bundle_column_error.title": "خطأ في الشبكة",
+  "bundle_modal_error.close": "أغلق",
+  "bundle_modal_error.message": "لقد وقع هناك خطأ أثناء عملية تحميل هذا العنصر.",
+  "bundle_modal_error.retry": "إعادة المحاولة",
   "column.blocks": "الحسابات المحجوبة",
   "column.community": "الخيط العام المحلي",
   "column.favourites": "المفضلة",
@@ -33,15 +33,15 @@
   "column.home": "الرئيسية",
   "column.mutes": "الحسابات المكتومة",
   "column.notifications": "الإشعارات",
-  "column.pins": "Pinned toot",
+  "column.pins": "التبويقات المثبتة",
   "column.public": "الخيط العام الموحد",
   "column_back_button.label": "العودة",
-  "column_header.hide_settings": "Hide settings",
-  "column_header.moveLeft_settings": "Move column to the left",
-  "column_header.moveRight_settings": "Move column to the right",
-  "column_header.pin": "Pin",
-  "column_header.show_settings": "Show settings",
-  "column_header.unpin": "Unpin",
+  "column_header.hide_settings": "إخفاء الإعدادات",
+  "column_header.moveLeft_settings": "نقل القائمة إلى اليسار",
+  "column_header.moveRight_settings": "نقل القائمة إلى اليمين",
+  "column_header.pin": "تدبيس",
+  "column_header.show_settings": "عرض الإعدادات",
+  "column_header.unpin": "فك التدبيس",
   "column_subheading.navigation": "التصفح",
   "column_subheading.settings": "الإعدادات",
   "compose_form.lock_disclaimer": "حسابك ليس {locked}. يمكن لأي شخص متابعتك و عرض المنشورات.",
@@ -57,16 +57,16 @@
   "confirmations.block.message": "هل أنت متأكد أنك تريد حجب {name} ؟",
   "confirmations.delete.confirm": "حذف",
   "confirmations.delete.message": "هل أنت متأكد أنك تريد حذف هذا المنشور ؟",
-  "confirmations.domain_block.confirm": "Hide entire domain",
+  "confirmations.domain_block.confirm": "إخفاء إسم النطاق كاملا",
   "confirmations.domain_block.message": "Are you really, really sure you want to block the entire {domain}? In most cases a few targeted blocks or mutes are sufficient and preferable.",
   "confirmations.mute.confirm": "أكتم",
   "confirmations.mute.message": "هل أنت متأكد أنك تريد كتم {name} ؟",
-  "confirmations.unfollow.confirm": "Unfollow",
-  "confirmations.unfollow.message": "Are you sure you want to unfollow {name}?",
-  "embed.instructions": "Embed this status on your website by copying the code below.",
-  "embed.preview": "Here is what it will look like:",
+  "confirmations.unfollow.confirm": "إلغاء المتابعة",
+  "confirmations.unfollow.message": "متأكد من أنك تريد إلغاء متابعة {name} ؟",
+  "embed.instructions": "يمكنكم إدماج هذه الحالة على موقعكم الإلكتروني عن طريق نسخ الشفرة أدناه.",
+  "embed.preview": "هكذا ما سوف يبدو عليه :",
   "emoji_button.activity": "الأنشطة",
-  "emoji_button.custom": "Custom",
+  "emoji_button.custom": "مخصص",
   "emoji_button.flags": "الأعلام",
   "emoji_button.food": "الطعام والشراب",
   "emoji_button.label": "أدرج إيموجي",
@@ -74,9 +74,9 @@
   "emoji_button.not_found": "No emojos!! (╯°□°)╯︵ ┻━┻",
   "emoji_button.objects": "أشياء",
   "emoji_button.people": "الناس",
-  "emoji_button.recent": "Frequently used",
+  "emoji_button.recent": "الشائعة الإستخدام",
   "emoji_button.search": "ابحث...",
-  "emoji_button.search_results": "Search results",
+  "emoji_button.search_results": "نتائج البحث",
   "emoji_button.symbols": "رموز",
   "emoji_button.travel": "أماكن و أسفار",
   "empty_column.community": "الخط الزمني المحلي فارغ. اكتب شيئا ما للعامة كبداية.",
@@ -100,8 +100,8 @@
   "home.column_settings.show_replies": "عرض الردود",
   "home.settings": "إعدادات العمود",
   "lightbox.close": "إغلاق",
-  "lightbox.next": "Next",
-  "lightbox.previous": "Previous",
+  "lightbox.next": "التالي",
+  "lightbox.previous": "العودة",
   "loading_indicator.label": "تحميل ...",
   "media_gallery.toggle_visible": "عرض / إخفاء",
   "missing_indicator.label": "تعذر العثور عليه",
@@ -113,7 +113,7 @@
   "navigation_bar.info": "معلومات إضافية",
   "navigation_bar.logout": "خروج",
   "navigation_bar.mutes": "الحسابات المكتومة",
-  "navigation_bar.pins": "Pinned toots",
+  "navigation_bar.pins": "التبويقات المثبتة",
   "navigation_bar.preferences": "التفضيلات",
   "navigation_bar.public_timeline": "الخيط العام الموحد",
   "notification.favourite": "{name} أعجب بمنشورك",
@@ -126,8 +126,8 @@
   "notifications.column_settings.favourite": "المُفَضَّلة :",
   "notifications.column_settings.follow": "متابعُون جُدُد :",
   "notifications.column_settings.mention": "الإشارات :",
-  "notifications.column_settings.push": "Push notifications",
-  "notifications.column_settings.push_meta": "This device",
+  "notifications.column_settings.push": "الإخطارات المدفوعة",
+  "notifications.column_settings.push_meta": "هذا الجهاز",
   "notifications.column_settings.reblog": "الترقيّات:",
   "notifications.column_settings.show": "إعرِضها في عمود",
   "notifications.column_settings.sound": "أصدر صوتا",
@@ -165,18 +165,23 @@
   "report.submit": "إرسال",
   "report.target": "إبلاغ",
   "search.placeholder": "ابحث",
+  "search_popout.search_format": "نمط البحث المتقدم",
+  "search_popout.tips.hashtag": "وسم",
+  "search_popout.tips.status": "حالة",
+  "search_popout.tips.text": "Simple text returns matching display names, usernames and hashtags",
+  "search_popout.tips.user": "مستخدِم",
   "search_results.total": "{count, number} {count, plural, one {result} other {results}}",
-  "standalone.public_title": "A look inside...",
+  "standalone.public_title": "نظرة على ...",
   "status.cannot_reblog": "تعذرت ترقية هذا المنشور",
   "status.delete": "إحذف",
-  "status.embed": "Embed",
+  "status.embed": "إدماج",
   "status.favourite": "أضف إلى المفضلة",
   "status.load_more": "حمّل المزيد",
   "status.media_hidden": "الصورة مستترة",
   "status.mention": "أذكُر @{name}",
-  "status.mute_conversation": "Mute conversation",
+  "status.mute_conversation": "كتم المحادثة",
   "status.open": "وسع هذه المشاركة",
-  "status.pin": "Pin on profile",
+  "status.pin": "تدبيس على الملف الشخصي",
   "status.reblog": "رَقِّي",
   "status.reblogged_by": "{name} رقى",
   "status.reply": "ردّ",
@@ -184,11 +189,11 @@
   "status.report": "إبلِغ عن @{name}",
   "status.sensitive_toggle": "اضغط للعرض",
   "status.sensitive_warning": "محتوى حساس",
-  "status.share": "Share",
+  "status.share": "مشاركة",
   "status.show_less": "إعرض أقلّ",
   "status.show_more": "أظهر المزيد",
-  "status.unmute_conversation": "Unmute conversation",
-  "status.unpin": "Unpin from profile",
+  "status.unmute_conversation": "فك الكتم عن المحادثة",
+  "status.unpin": "فك التدبيس من الملف الشخصي",
   "tabs_bar.compose": "تحرير",
   "tabs_bar.federated_timeline": "الموحَّد",
   "tabs_bar.home": "الرئيسية",
@@ -196,19 +201,16 @@
   "tabs_bar.notifications": "الإخطارات",
   "upload_area.title": "إسحب ثم أفلت للرفع",
   "upload_button.label": "إضافة وسائط",
+  "upload_form.description": "وصف للمعاقين بصريا",
   "upload_form.undo": "إلغاء",
   "upload_progress.label": "يرفع...",
-  "video.close": "Close video",
-  "video.exit_fullscreen": "Exit full screen",
-  "video.expand": "Expand video",
-  "video.fullscreen": "Full screen",
-  "video.hide": "Hide video",
-  "video.mute": "Mute sound",
-  "video.pause": "Pause",
-  "video.play": "Play",
-  "video.unmute": "Unmute sound",
-  "video_player.expand": "وسّع الفيديو",
-  "video_player.toggle_sound": "تبديل الصوت",
-  "video_player.toggle_visible": "إظهار / إخفاء الفيديو",
-  "video_player.video_error": "تعذر تشغيل الفيديو"
+  "video.close": "إغلاق الفيديو",
+  "video.exit_fullscreen": "الخروج من وضع الشاشة المليئة",
+  "video.expand": "توسيع الفيديو",
+  "video.fullscreen": "ملء الشاشة",
+  "video.hide": "إخفاء الفيديو",
+  "video.mute": "كتم الصوت",
+  "video.pause": "إيقاف مؤقت",
+  "video.play": "تشغيل",
+  "video.unmute": "تشغيل الصوت"
 }
diff --git a/app/javascript/mastodon/locales/bg.json b/app/javascript/mastodon/locales/bg.json
index d391a57ba..240e3725e 100644
--- a/app/javascript/mastodon/locales/bg.json
+++ b/app/javascript/mastodon/locales/bg.json
@@ -165,6 +165,11 @@
   "report.submit": "Submit",
   "report.target": "Reporting",
   "search.placeholder": "Търсене",
+  "search_popout.search_format": "Advanced search format",
+  "search_popout.tips.hashtag": "hashtag",
+  "search_popout.tips.status": "status",
+  "search_popout.tips.text": "Simple text returns matching display names, usernames and hashtags",
+  "search_popout.tips.user": "user",
   "search_results.total": "{count, number} {count, plural, one {result} other {results}}",
   "standalone.public_title": "A look inside...",
   "status.cannot_reblog": "This post cannot be boosted",
@@ -196,6 +201,7 @@
   "tabs_bar.notifications": "Известия",
   "upload_area.title": "Drag & drop to upload",
   "upload_button.label": "Добави медия",
+  "upload_form.description": "Describe for the visually impaired",
   "upload_form.undo": "Отмяна",
   "upload_progress.label": "Uploading...",
   "video.close": "Close video",
@@ -206,9 +212,5 @@
   "video.mute": "Mute sound",
   "video.pause": "Pause",
   "video.play": "Play",
-  "video.unmute": "Unmute sound",
-  "video_player.expand": "Expand video",
-  "video_player.toggle_sound": "Звук",
-  "video_player.toggle_visible": "Toggle visibility",
-  "video_player.video_error": "Video could not be played"
+  "video.unmute": "Unmute sound"
 }
diff --git a/app/javascript/mastodon/locales/ca.json b/app/javascript/mastodon/locales/ca.json
index 286da3ac6..b5051a32d 100644
--- a/app/javascript/mastodon/locales/ca.json
+++ b/app/javascript/mastodon/locales/ca.json
@@ -165,6 +165,11 @@
   "report.submit": "Enviar",
   "report.target": "Informes",
   "search.placeholder": "Cercar",
+  "search_popout.search_format": "Advanced search format",
+  "search_popout.tips.hashtag": "hashtag",
+  "search_popout.tips.status": "status",
+  "search_popout.tips.text": "Simple text returns matching display names, usernames and hashtags",
+  "search_popout.tips.user": "user",
   "search_results.total": "{count, number} {count, plural, one {result} other {results}}",
   "standalone.public_title": "A look inside...",
   "status.cannot_reblog": "Aquesta publicació no pot ser retootejada",
@@ -196,6 +201,7 @@
   "tabs_bar.notifications": "Notificacions",
   "upload_area.title": "Arrossega i deixa anar per carregar",
   "upload_button.label": "Afegir multimèdia",
+  "upload_form.description": "Describe for the visually impaired",
   "upload_form.undo": "Desfer",
   "upload_progress.label": "Pujant...",
   "video.close": "Close video",
@@ -206,9 +212,5 @@
   "video.mute": "Mute sound",
   "video.pause": "Pause",
   "video.play": "Play",
-  "video.unmute": "Unmute sound",
-  "video_player.expand": "Ampliar el vídeo",
-  "video_player.toggle_sound": "Alternar so",
-  "video_player.toggle_visible": "Alternar visibilitat",
-  "video_player.video_error": "El vídeo no es pot reproduir"
+  "video.unmute": "Unmute sound"
 }
diff --git a/app/javascript/mastodon/locales/de.json b/app/javascript/mastodon/locales/de.json
index 461e7e304..b79b1b2f0 100644
--- a/app/javascript/mastodon/locales/de.json
+++ b/app/javascript/mastodon/locales/de.json
@@ -1,7 +1,7 @@
 {
   "account.block": "@{name} blocken",
   "account.block_domain": "Alles von {domain} verstecken",
-  "account.disclaimer_full": "Hier aufgeführten Informationen können unvollständig sein.",
+  "account.disclaimer_full": "Das Profil wird möglicherweise unvollständig wiedergegeben.",
   "account.edit_profile": "Profil bearbeiten",
   "account.follow": "Folgen",
   "account.followers": "Folgende",
@@ -18,11 +18,11 @@
   "account.unblock_domain": "{domain} wieder anzeigen",
   "account.unfollow": "Entfolgen",
   "account.unmute": "@{name} nicht mehr stummschalten",
-  "account.view_full_profile": "Komplettes Profil anzeigen",
+  "account.view_full_profile": "Vollständiges Profil anzeigen",
   "boost_modal.combo": "Du kannst {combo} drücken, um dies beim nächsten Mal zu überspringen",
   "bundle_column_error.body": "Etwas ist beim Laden schiefgelaufen.",
   "bundle_column_error.retry": "Erneut versuchen",
-  "bundle_column_error.title": "Netzwerkfehlher",
+  "bundle_column_error.title": "Netzwerkfehler",
   "bundle_modal_error.close": "Schließen",
   "bundle_modal_error.message": "Etwas ist beim Laden schiefgelaufen.",
   "bundle_modal_error.retry": "Erneut versuchen",
@@ -33,18 +33,18 @@
   "column.home": "Startseite",
   "column.mutes": "Stummgeschaltete Profile",
   "column.notifications": "Mitteilungen",
-  "column.pins": "Pinned toot",
+  "column.pins": "Angeheftete Beiträge",
   "column.public": "Gesamtes bekanntes Netz",
   "column_back_button.label": "Zurück",
   "column_header.hide_settings": "Einstellungen verbergen",
-  "column_header.moveLeft_settings": "Spalte links verschieben",
-  "column_header.moveRight_settings": "Spalte rechts verschieben",
+  "column_header.moveLeft_settings": "Spalte nach links verschieben",
+  "column_header.moveRight_settings": "Spalte nach rechts verschieben",
   "column_header.pin": "Anheften",
   "column_header.show_settings": "Einstellungen anzeigen",
   "column_header.unpin": "Lösen",
   "column_subheading.navigation": "Navigation",
   "column_subheading.settings": "Einstellungen",
-  "compose_form.lock_disclaimer": "Dein Profil ist nicht {locked}. Jeder kann dir jederzeit folgen, um deine privaten Beiträge einzusehen.",
+  "compose_form.lock_disclaimer": "Dein Profil ist nicht {locked}. Wer dir folgen will, kann das jederzeit tun und dann auch deine privaten Beiträge sehen.",
   "compose_form.lock_disclaimer.lock": "gesperrt",
   "compose_form.placeholder": "Worüber möchtest du schreiben?",
   "compose_form.publish": "Tröt",
@@ -56,106 +56,106 @@
   "confirmations.block.confirm": "Blockieren",
   "confirmations.block.message": "Bist du dir sicher, dass du {name} blockieren möchtest?",
   "confirmations.delete.confirm": "Löschen",
-  "confirmations.delete.message": "Bist du dir sicher, dass du diesen Beitrag löschen möchstest?",
+  "confirmations.delete.message": "Bist du dir sicher, dass du diesen Beitrag löschen möchtest?",
   "confirmations.domain_block.confirm": "Die ganze Domain verbergen",
-  "confirmations.domain_block.message": "Bist du dir wirklich sicher, dass du die ganze Domain {domain} verbergen willst? In den meisten Fällen sind ein paar gezielte Blocks genug.",
+  "confirmations.domain_block.message": "Bist du dir wirklich sicher, dass du die ganze Domain {domain} verbergen willst? In den meisten Fällen reichen ein paar gezielte Blocks aus.",
   "confirmations.mute.confirm": "Stummschalten",
-  "confirmations.mute.message": "Bist du dir sicher, dass du {name} stummschalten möchstest?",
+  "confirmations.mute.message": "Bist du dir sicher, dass du {name} stummschalten möchtest?",
   "confirmations.unfollow.confirm": "Entfolgen",
-  "confirmations.unfollow.message": "Bist du dir sicher, dass du {name} entfolgen möchstest?",
-  "embed.instructions": "Du kannst diesen Beitrag auf deiner Webseite einbetten, in dem du den folgenden Code einfügst.",
+  "confirmations.unfollow.message": "Bist du dir sicher, dass du {name} entfolgen möchtest?",
+  "embed.instructions": "Du kannst diesen Beitrag auf deiner Webseite einbetten, indem du den folgenden Code einfügst.",
   "embed.preview": "So wird es aussehen:",
   "emoji_button.activity": "Aktivitäten",
-  "emoji_button.custom": "Custom",
+  "emoji_button.custom": "Eigene",
   "emoji_button.flags": "Flaggen",
   "emoji_button.food": "Essen und Trinken",
   "emoji_button.label": "Emoji einfügen",
   "emoji_button.nature": "Natur",
-  "emoji_button.not_found": "No emojos!! (╯°□°)╯︵ ┻━┻",
-  "emoji_button.objects": "Dinge",
-  "emoji_button.people": "Leute",
-  "emoji_button.recent": "Frequently used",
-  "emoji_button.search": "Suche…",
-  "emoji_button.search_results": "Search results",
+  "emoji_button.not_found": "Keine Emojis!! (╯°□°)╯︵ ┻━┻",
+  "emoji_button.objects": "Gegenstände",
+  "emoji_button.people": "Personen",
+  "emoji_button.recent": "Häufig benutzt",
+  "emoji_button.search": "Suchen",
+  "emoji_button.search_results": "Suchergebnisse",
   "emoji_button.symbols": "Symbole",
-  "emoji_button.travel": "Reise und Orte",
-  "empty_column.community": "Die lokale Zeitleiste ist leer. Schreibe etwas öffentlich, um den Ball ins Rollen zu bringen!",
-  "empty_column.hashtag": "Es gibt noch nichts unter diesem Hashtag.",
-  "empty_column.home": "Du folgst noch niemandem. Besuche {public} oder benutze die Suche, um zu starten oder andere Profile zu finden.",
-  "empty_column.home.inactivity": "Deine Zeitleiste ist leer. Falls du eine längere Zeit inaktiv gewesen bist, wird sie für dich so schnell wie möglich wiedererstellt.",
+  "emoji_button.travel": "Reisen und Orte",
+  "empty_column.community": "Die lokale Zeitleiste ist leer. Schreibe einen öffentlichen Beitrag, um den Ball ins Rollen zu bringen!",
+  "empty_column.hashtag": "Unter diesem Hashtag gibt es noch nichts.",
+  "empty_column.home": "Du folgst noch niemandem. Besuche {public} oder nutze die Suche, um loszulegen und andere Leute zu finden.",
+  "empty_column.home.inactivity": "Deine Zeitleiste ist leer. Falls du eine längere Zeit inaktiv warst, wird sie für dich so schnell wie möglich neu erstellt.",
   "empty_column.home.public_timeline": "die öffentliche Zeitleiste",
-  "empty_column.notifications": "Du hast noch keine Mitteilungen. Interagiere mit anderen, um die Konversation zu starten.",
-  "empty_column.public": "Hier ist nichts zu sehen! Schreibe etwas öffentlich oder folge Profilen von anderen Instanzen, um es aufzufüllen.",
+  "empty_column.notifications": "Du hast noch keine Mitteilungen. Interagiere mit anderen, um ins Gespräch zu kommen.",
+  "empty_column.public": "Hier ist nichts zu sehen! Schreibe etwas öffentlich oder folge Profilen von anderen Instanzen, um die Zeitleiste aufzufüllen",
   "follow_request.authorize": "Erlauben",
   "follow_request.reject": "Ablehnen",
-  "getting_started.appsshort": "Anwendungen",
+  "getting_started.appsshort": "Apps",
   "getting_started.faq": "Häufig gestellte Fragen",
   "getting_started.heading": "Erste Schritte",
-  "getting_started.open_source_notice": "Mastodon ist quelloffene Software. Du kannst auf {github} dazu beitragen oder Probleme melden.",
+  "getting_started.open_source_notice": "Mastodon ist quelloffene Software. Du kannst auf GitHub unter {github} dazu beitragen oder Probleme melden.",
   "getting_started.userguide": "Bedienungsanleitung",
-  "home.column_settings.advanced": "Fortgeschritten",
+  "home.column_settings.advanced": "Erweitert",
   "home.column_settings.basic": "Einfach",
-  "home.column_settings.filter_regex": "Filter durch reguläre Ausdrücke",
+  "home.column_settings.filter_regex": "Mit regulären Ausdrücken filtern",
   "home.column_settings.show_reblogs": "Geteilte Beiträge anzeigen",
   "home.column_settings.show_replies": "Antworten anzeigen",
   "home.settings": "Spalteneinstellungen",
   "lightbox.close": "Schließen",
   "lightbox.next": "Weiter",
   "lightbox.previous": "Zurück",
-  "loading_indicator.label": "Lade…",
-  "media_gallery.toggle_visible": "Sichtbarkeit einstellen",
+  "loading_indicator.label": "Wird geladen …",
+  "media_gallery.toggle_visible": "Sichtbarkeit umschalten",
   "missing_indicator.label": "Nicht gefunden",
   "navigation_bar.blocks": "Blockierte Profile",
   "navigation_bar.community_timeline": "Lokale Zeitleiste",
   "navigation_bar.edit_profile": "Profil bearbeiten",
   "navigation_bar.favourites": "Favoriten",
   "navigation_bar.follow_requests": "Folgeanfragen",
-  "navigation_bar.info": "Erweiterte Informationen",
+  "navigation_bar.info": "Über diese Instanz",
   "navigation_bar.logout": "Abmelden",
   "navigation_bar.mutes": "Stummgeschaltete Profile",
-  "navigation_bar.pins": "Pinned toots",
+  "navigation_bar.pins": "Angeheftete Beiträge",
   "navigation_bar.preferences": "Einstellungen",
   "navigation_bar.public_timeline": "Föderierte Zeitleiste",
-  "notification.favourite": "{name} favorisierte deinen Status",
+  "notification.favourite": "{name} hat deinen Beitrag favorisiert",
   "notification.follow": "{name} folgt dir",
-  "notification.mention": "{name} erwähnte dich",
-  "notification.reblog": "{name} teilte deinen Status",
+  "notification.mention": "{name} hat dich erwähnt",
+  "notification.reblog": "{name} hat deinen Beitrag geteilt",
   "notifications.clear": "Mitteilungen löschen",
-  "notifications.clear_confirmation": "Bist du dir sicher, dass du alle Mitteilungen löschen möchstest?",
+  "notifications.clear_confirmation": "Bist du dir sicher, dass du alle Mitteilungen löschen möchtest?",
   "notifications.column_settings.alert": "Desktop-Benachrichtigungen",
   "notifications.column_settings.favourite": "Favorisierungen:",
   "notifications.column_settings.follow": "Neue Folgende:",
   "notifications.column_settings.mention": "Erwähnungen:",
-  "notifications.column_settings.push": "Push notifications",
-  "notifications.column_settings.push_meta": "This device",
+  "notifications.column_settings.push": "Push-Benachrichtigungen",
+  "notifications.column_settings.push_meta": "Auf diesem Gerät",
   "notifications.column_settings.reblog": "Geteilte Beiträge:",
   "notifications.column_settings.show": "In der Spalte anzeigen",
   "notifications.column_settings.sound": "Ton abspielen",
   "onboarding.done": "Fertig",
   "onboarding.next": "Weiter",
-  "onboarding.page_five.public_timelines": "Die lokale Zeitleiste zeigt alle Beiträge von Leuten, die auch auf deiner Instanz {domain} sind. Das gesamte bekannte Netz zeigt Beiträge von allen, die kollektiv aus deiner Instanz heraus gefolgt werden. Zusammen werden die beiden Leisten auch öffentliche Zeitleisten genannt, durch sie kannst du viel neues entdecken.",
+  "onboarding.page_five.public_timelines": "Die lokale Zeitleiste zeigt alle Beiträge von Leuten, die auch auf {domain} sind. Das gesamte bekannte Netz zeigt Beiträge von allen, denen von Leuten auf {domain} gefolgt wird. Zusammen sind sie die öffentlichen Zeitleisten. In ihnen kannst du viel Neues entdecken!",
   "onboarding.page_four.home": "Die Startseite zeigt dir Beiträge von Leuten, denen du folgst.",
-  "onboarding.page_four.notifications": "Wenn jemand mir dir interagiert, bekommst du eine Mitteilung.",
+  "onboarding.page_four.notifications": "Wenn jemand mit dir interagiert, bekommst du eine Mitteilung.",
   "onboarding.page_one.federation": "Mastodon ist ein soziales Netzwerk, das aus unabhängigen Servern besteht. Diese Server nennen wir auch Instanzen.",
   "onboarding.page_one.handle": "Du bist auf der Instanz {domain}, also ist dein vollständiger Profilname im Netzwerk {handle}",
   "onboarding.page_one.welcome": "Willkommen bei Mastodon!",
   "onboarding.page_six.admin": "Für deine Instanz ist {admin} zuständig.",
-  "onboarding.page_six.almost_done": "Fast fertig…",
+  "onboarding.page_six.almost_done": "Fast fertig …",
   "onboarding.page_six.appetoot": "Guten Appetröt!",
-  "onboarding.page_six.apps_available": "Es gibt verschiedene {apps} für iOS, Android und andere Plattformen.",
-  "onboarding.page_six.github": "Mastodon ist freie, quelloffene Software. Du kannst auf {github} dazu beitragen oder Probleme melden.",
+  "onboarding.page_six.apps_available": "Es gibt verschiedene {apps} für iOS, Android und weitere Plattformen.",
+  "onboarding.page_six.github": "Mastodon ist freie, quelloffene Software. Du kannst auf GitHub unter {github} dazu beitragen, Probleme melden und Wünsche äußern.",
   "onboarding.page_six.guidelines": "Richtlinien",
-  "onboarding.page_six.read_guidelines": "Bitte mach dich mit den {guidelines} von {domain} vertraut!",
-  "onboarding.page_six.various_app": "mobile Anwendungen",
-  "onboarding.page_three.profile": "Bearbeite dein Profil, um dein Bild, deinen Namen oder deine Beschreibung anzupassen. Dort findest du auch andere Einstellungen.",
-  "onboarding.page_three.search": "Benutze die Suchfunktion, um Leute oder Themen zu finden. Zum Beispiel, die Hashtags {illustration} oder {introductions}. Um eine Person zu finden, die auf einer anderen Instanz ist, benutze den vollständigen Profilnamen.",
-  "onboarding.page_two.compose": "Schreibe Beiträge aus der Schreiben-Spalte. Du kannst Bilder und kurze Videos hochladen, Sichtbarkeitseinstellungen ändern und Inhaltswarnungen hinzufügen.",
+  "onboarding.page_six.read_guidelines": "Bitte mach dich mit den {guidelines} von {domain} vertraut.",
+  "onboarding.page_six.various_app": "Apps",
+  "onboarding.page_three.profile": "Bearbeite dein Profil, um dein Bild, deinen Namen und deine Beschreibung anzupassen. Dort findest du auch weitere Einstellungen.",
+  "onboarding.page_three.search": "Benutze die Suchfunktion, um Leute zu finden und mit Hashtags wie {illustration} oder {introductions} nach Beiträgen zu suchen. Um eine Person zu finden, die auf einer anderen Instanz ist, benutze den vollständigen Profilnamen.",
+  "onboarding.page_two.compose": "Schreibe deine Beiträge in der Schreiben-Spalte. Mit den Symbolen unter dem Eingabefeld kannst du Bilder hochladen, Sichtbarkeits-Einstellungen ändern und Inhaltswarnungen hinzufügen.",
   "onboarding.skip": "Überspringen",
-  "privacy.change": "Privatsphäre des Status anpassen",
+  "privacy.change": "Sichtbarkeit des Beitrags anpassen",
   "privacy.direct.long": "Beitrag nur an erwähnte Profile",
   "privacy.direct.short": "Direkt",
   "privacy.private.long": "Beitrag nur an Folgende",
-  "privacy.private.short": "Privat",
+  "privacy.private.short": "Nur Folgende",
   "privacy.public.long": "Beitrag an öffentliche Zeitleisten",
   "privacy.public.short": "Öffentlich",
   "privacy.unlisted.long": "Nicht in öffentlichen Zeitleisten anzeigen",
@@ -163,26 +163,31 @@
   "reply_indicator.cancel": "Abbrechen",
   "report.placeholder": "Zusätzliche Kommentare",
   "report.submit": "Absenden",
-  "report.target": "Melden",
+  "report.target": "{target} melden",
   "search.placeholder": "Suche",
+  "search_popout.search_format": "Advanced search format",
+  "search_popout.tips.hashtag": "hashtag",
+  "search_popout.tips.status": "status",
+  "search_popout.tips.text": "Simple text returns matching display names, usernames and hashtags",
+  "search_popout.tips.user": "user",
   "search_results.total": "{count, number} {count, plural, one {Ergebnis} other {Ergebnisse}}",
-  "standalone.public_title": "Vorschau…",
+  "standalone.public_title": "Ein kleiner Einblick …",
   "status.cannot_reblog": "Dieser Beitrag kann nicht geteilt werden",
   "status.delete": "Löschen",
   "status.embed": "Einbetten",
   "status.favourite": "Favorisieren",
   "status.load_more": "Weitere laden",
   "status.media_hidden": "Medien versteckt",
-  "status.mention": "Erwähnen",
+  "status.mention": "@{name} erwähnen",
   "status.mute_conversation": "Thread stummschalten",
-  "status.open": "Öffnen",
-  "status.pin": "Auf dem Profil anheften",
+  "status.open": "Diesen Beitrag öffnen",
+  "status.pin": "Im Profil anheften",
   "status.reblog": "Teilen",
   "status.reblogged_by": "{name} teilte",
   "status.reply": "Antworten",
   "status.replyAll": "Auf Thread antworten",
   "status.report": "@{name} melden",
-  "status.sensitive_toggle": "Klicke, um sie zu sehen",
+  "status.sensitive_toggle": "Zum Ansehen klicken",
   "status.sensitive_warning": "Heikle Inhalte",
   "status.share": "Teilen",
   "status.show_less": "Weniger anzeigen",
@@ -194,21 +199,18 @@
   "tabs_bar.home": "Startseite",
   "tabs_bar.local_timeline": "Lokal",
   "tabs_bar.notifications": "Mitteilungen",
-  "upload_area.title": "Hereinziehen zum Hochladen",
+  "upload_area.title": "Zum Hochladen hereinziehen",
   "upload_button.label": "Mediendatei hinzufügen",
+  "upload_form.description": "Für Menschen mit Sehbehinderung beschreiben",
   "upload_form.undo": "Entfernen",
-  "upload_progress.label": "Lade hoch…",
-  "video.close": "Close video",
-  "video.exit_fullscreen": "Exit full screen",
-  "video.expand": "Expand video",
-  "video.fullscreen": "Full screen",
-  "video.hide": "Hide video",
-  "video.mute": "Mute sound",
+  "upload_progress.label": "Wird hochgeladen …",
+  "video.close": "Video schließen",
+  "video.exit_fullscreen": "Vollbild verlassen",
+  "video.expand": "Video vergrößern",
+  "video.fullscreen": "Vollbild",
+  "video.hide": "Video verbergen",
+  "video.mute": "Stummschalten",
   "video.pause": "Pause",
-  "video.play": "Play",
-  "video.unmute": "Unmute sound",
-  "video_player.expand": "Videoanzeige vergrößern",
-  "video_player.toggle_sound": "Ton umschalten",
-  "video_player.toggle_visible": "Sichtbarkeit umschalten",
-  "video_player.video_error": "Video konnte nicht abgespielt werden"
+  "video.play": "Abspielen",
+  "video.unmute": "Ton einschalten"
 }
diff --git a/app/javascript/mastodon/locales/defaultMessages.json b/app/javascript/mastodon/locales/defaultMessages.json
index 5b711fd26..8fdb8c44c 100644
--- a/app/javascript/mastodon/locales/defaultMessages.json
+++ b/app/javascript/mastodon/locales/defaultMessages.json
@@ -135,6 +135,31 @@
   {
     "descriptors": [
       {
+        "defaultMessage": "now",
+        "id": "relative_time.just_now"
+      },
+      {
+        "defaultMessage": "{number}s",
+        "id": "relative_time.seconds"
+      },
+      {
+        "defaultMessage": "{number}m",
+        "id": "relative_time.minutes"
+      },
+      {
+        "defaultMessage": "{number}h",
+        "id": "relative_time.hours"
+      },
+      {
+        "defaultMessage": "{number}d",
+        "id": "relative_time.days"
+      }
+    ],
+    "path": "app/javascript/mastodon/components/relative_timestamp.json"
+  },
+  {
+    "descriptors": [
+      {
         "defaultMessage": "Delete",
         "id": "status.delete"
       },
@@ -230,39 +255,6 @@
   {
     "descriptors": [
       {
-        "defaultMessage": "Toggle sound",
-        "id": "video_player.toggle_sound"
-      },
-      {
-        "defaultMessage": "Toggle visibility",
-        "id": "video_player.toggle_visible"
-      },
-      {
-        "defaultMessage": "Expand video",
-        "id": "video_player.expand"
-      },
-      {
-        "defaultMessage": "Sensitive content",
-        "id": "status.sensitive_warning"
-      },
-      {
-        "defaultMessage": "Click to view",
-        "id": "status.sensitive_toggle"
-      },
-      {
-        "defaultMessage": "Media hidden",
-        "id": "status.media_hidden"
-      },
-      {
-        "defaultMessage": "Video could not be played",
-        "id": "video_player.video_error"
-      }
-    ],
-    "path": "app/javascript/mastodon/components/video_player.json"
-  },
-  {
-    "descriptors": [
-      {
         "defaultMessage": "Unfollow",
         "id": "confirmations.unfollow.confirm"
       },
@@ -640,6 +632,26 @@
       {
         "defaultMessage": "Search",
         "id": "search.placeholder"
+      },
+      {
+        "defaultMessage": "Advanced search format",
+        "id": "search_popout.search_format"
+      },
+      {
+        "defaultMessage": "hashtag",
+        "id": "search_popout.tips.hashtag"
+      },
+      {
+        "defaultMessage": "user",
+        "id": "search_popout.tips.user"
+      },
+      {
+        "defaultMessage": "status",
+        "id": "search_popout.tips.status"
+      },
+      {
+        "defaultMessage": "Simple text returns matching display names, usernames and hashtags",
+        "id": "search_popout.tips.text"
       }
     ],
     "path": "app/javascript/mastodon/features/compose/components/search.json"
@@ -656,20 +668,24 @@
   {
     "descriptors": [
       {
-        "defaultMessage": "Undo",
-        "id": "upload_form.undo"
+        "defaultMessage": "Uploading...",
+        "id": "upload_progress.label"
       }
     ],
-    "path": "app/javascript/mastodon/features/compose/components/upload_form.json"
+    "path": "app/javascript/mastodon/features/compose/components/upload_progress.json"
   },
   {
     "descriptors": [
       {
-        "defaultMessage": "Uploading...",
-        "id": "upload_progress.label"
+        "defaultMessage": "Undo",
+        "id": "upload_form.undo"
+      },
+      {
+        "defaultMessage": "Describe for the visually impaired",
+        "id": "upload_form.description"
       }
     ],
-    "path": "app/javascript/mastodon/features/compose/components/upload_progress.json"
+    "path": "app/javascript/mastodon/features/compose/components/upload.json"
   },
   {
     "descriptors": [
diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json
index fc6aa4280..b0dbc46bd 100644
--- a/app/javascript/mastodon/locales/en.json
+++ b/app/javascript/mastodon/locales/en.json
@@ -165,6 +165,11 @@
   "report.submit": "Submit",
   "report.target": "Reporting {target}",
   "search.placeholder": "Search",
+  "search_popout.search_format": "Advanced search format",
+  "search_popout.tips.hashtag": "hashtag",
+  "search_popout.tips.status": "status",
+  "search_popout.tips.text": "Simple text returns matching display names, usernames and hashtags",
+  "search_popout.tips.user": "user",
   "search_results.total": "{count, number} {count, plural, one {result} other {results}}",
   "standalone.public_title": "A look inside...",
   "status.cannot_reblog": "This post cannot be boosted",
@@ -196,6 +201,7 @@
   "tabs_bar.notifications": "Notifications",
   "upload_area.title": "Drag & drop to upload",
   "upload_button.label": "Add media",
+  "upload_form.description": "Describe for the visually impaired",
   "upload_form.undo": "Undo",
   "upload_progress.label": "Uploading...",
   "video.close": "Close video",
@@ -206,9 +212,5 @@
   "video.mute": "Mute sound",
   "video.pause": "Pause",
   "video.play": "Play",
-  "video.unmute": "Unmute sound",
-  "video_player.expand": "Expand video",
-  "video_player.toggle_sound": "Toggle sound",
-  "video_player.toggle_visible": "Toggle visibility",
-  "video_player.video_error": "Video could not be played"
+  "video.unmute": "Unmute sound"
 }
diff --git a/app/javascript/mastodon/locales/eo.json b/app/javascript/mastodon/locales/eo.json
index 21b92ed3a..1ccd2b817 100644
--- a/app/javascript/mastodon/locales/eo.json
+++ b/app/javascript/mastodon/locales/eo.json
@@ -165,6 +165,11 @@
   "report.submit": "Submit",
   "report.target": "Reporting",
   "search.placeholder": "Serĉi",
+  "search_popout.search_format": "Advanced search format",
+  "search_popout.tips.hashtag": "hashtag",
+  "search_popout.tips.status": "status",
+  "search_popout.tips.text": "Simple text returns matching display names, usernames and hashtags",
+  "search_popout.tips.user": "user",
   "search_results.total": "{count, number} {count, plural, one {result} other {results}}",
   "standalone.public_title": "A look inside...",
   "status.cannot_reblog": "This post cannot be boosted",
@@ -196,6 +201,7 @@
   "tabs_bar.notifications": "Sciigoj",
   "upload_area.title": "Drag & drop to upload",
   "upload_button.label": "Aldoni enhavaĵon",
+  "upload_form.description": "Describe for the visually impaired",
   "upload_form.undo": "Malfari",
   "upload_progress.label": "Uploading...",
   "video.close": "Close video",
@@ -206,9 +212,5 @@
   "video.mute": "Mute sound",
   "video.pause": "Pause",
   "video.play": "Play",
-  "video.unmute": "Unmute sound",
-  "video_player.expand": "Expand video",
-  "video_player.toggle_sound": "Aktivigi sonojn",
-  "video_player.toggle_visible": "Toggle visibility",
-  "video_player.video_error": "Video could not be played"
+  "video.unmute": "Unmute sound"
 }
diff --git a/app/javascript/mastodon/locales/es.json b/app/javascript/mastodon/locales/es.json
index 59c7dc5a7..f6bfbb04d 100644
--- a/app/javascript/mastodon/locales/es.json
+++ b/app/javascript/mastodon/locales/es.json
@@ -165,6 +165,11 @@
   "report.submit": "Publicar",
   "report.target": "Reportando",
   "search.placeholder": "Buscar",
+  "search_popout.search_format": "Advanced search format",
+  "search_popout.tips.hashtag": "hashtag",
+  "search_popout.tips.status": "status",
+  "search_popout.tips.text": "Simple text returns matching display names, usernames and hashtags",
+  "search_popout.tips.user": "user",
   "search_results.total": "{count, number} {count, plural, one {resultado} other {resultados}}",
   "standalone.public_title": "Un pequeño vistazo...",
   "status.cannot_reblog": "Este toot no puede retootearse",
@@ -196,6 +201,7 @@
   "tabs_bar.notifications": "Notificaciones",
   "upload_area.title": "Arrastra y suelta para subir",
   "upload_button.label": "Subir multimedia",
+  "upload_form.description": "Describe for the visually impaired",
   "upload_form.undo": "Deshacer",
   "upload_progress.label": "Subiendo…",
   "video.close": "Cerrar video",
@@ -206,9 +212,5 @@
   "video.mute": "Silenciar sonido",
   "video.pause": "Pausar",
   "video.play": "Reproducir",
-  "video.unmute": "Dejar de silenciar sonido",
-  "video_player.expand": "Expandir vídeo",
-  "video_player.toggle_sound": "Activar/Desactivar sonido",
-  "video_player.toggle_visible": "Cambiar visibilidad",
-  "video_player.video_error": "No se pudo reproducir el vídeo"
+  "video.unmute": "Dejar de silenciar sonido"
 }
diff --git a/app/javascript/mastodon/locales/fa.json b/app/javascript/mastodon/locales/fa.json
index 6e4771392..13fb91278 100644
--- a/app/javascript/mastodon/locales/fa.json
+++ b/app/javascript/mastodon/locales/fa.json
@@ -165,6 +165,11 @@
   "report.submit": "بفرست",
   "report.target": "گزارش‌دادن",
   "search.placeholder": "جستجو",
+  "search_popout.search_format": "Advanced search format",
+  "search_popout.tips.hashtag": "hashtag",
+  "search_popout.tips.status": "status",
+  "search_popout.tips.text": "Simple text returns matching display names, usernames and hashtags",
+  "search_popout.tips.user": "user",
   "search_results.total": "{count, number} {count, plural, one {نتیجه} other {نتیجه}}",
   "standalone.public_title": "نگاهی به کاربران این سرور...",
   "status.cannot_reblog": "این نوشته را نمی‌شود بازبوقید",
@@ -196,6 +201,7 @@
   "tabs_bar.notifications": "اعلان‌ها",
   "upload_area.title": "برای بارگذاری به این‌جا بکشید",
   "upload_button.label": "افزودن تصویر",
+  "upload_form.description": "Describe for the visually impaired",
   "upload_form.undo": "واگردانی",
   "upload_progress.label": "بارگذاری...",
   "video.close": "بستن ویدیو",
@@ -206,9 +212,5 @@
   "video.mute": "قطع صدا",
   "video.pause": "توقف",
   "video.play": "پخش",
-  "video.unmute": "پخش صدا",
-  "video_player.expand": "بازکردن ویدیو",
-  "video_player.toggle_sound": "تغییر صداداری",
-  "video_player.toggle_visible": "تغییر پیدایی",
-  "video_player.video_error": "ویدیو نمی‌تواند پخش شود"
+  "video.unmute": "پخش صدا"
 }
diff --git a/app/javascript/mastodon/locales/fi.json b/app/javascript/mastodon/locales/fi.json
index ccdf19dd6..425b3d82a 100644
--- a/app/javascript/mastodon/locales/fi.json
+++ b/app/javascript/mastodon/locales/fi.json
@@ -165,6 +165,11 @@
   "report.submit": "Submit",
   "report.target": "Reporting",
   "search.placeholder": "Hae",
+  "search_popout.search_format": "Advanced search format",
+  "search_popout.tips.hashtag": "hashtag",
+  "search_popout.tips.status": "status",
+  "search_popout.tips.text": "Simple text returns matching display names, usernames and hashtags",
+  "search_popout.tips.user": "user",
   "search_results.total": "{count, number} {count, plural, one {result} other {results}}",
   "standalone.public_title": "A look inside...",
   "status.cannot_reblog": "This post cannot be boosted",
@@ -196,6 +201,7 @@
   "tabs_bar.notifications": "Ilmoitukset",
   "upload_area.title": "Drag & drop to upload",
   "upload_button.label": "Lisää mediaa",
+  "upload_form.description": "Describe for the visually impaired",
   "upload_form.undo": "Peru",
   "upload_progress.label": "Uploading...",
   "video.close": "Close video",
@@ -206,9 +212,5 @@
   "video.mute": "Mute sound",
   "video.pause": "Pause",
   "video.play": "Play",
-  "video.unmute": "Unmute sound",
-  "video_player.expand": "Expand video",
-  "video_player.toggle_sound": "Äänet päälle/pois",
-  "video_player.toggle_visible": "Toggle visibility",
-  "video_player.video_error": "Video could not be played"
+  "video.unmute": "Unmute sound"
 }
diff --git a/app/javascript/mastodon/locales/fr.json b/app/javascript/mastodon/locales/fr.json
index 417c1062a..350d92c44 100644
--- a/app/javascript/mastodon/locales/fr.json
+++ b/app/javascript/mastodon/locales/fr.json
@@ -66,7 +66,7 @@
   "embed.instructions": "Intégrez ce statut à votre site en copiant ce code ci-dessous.",
   "embed.preview": "Il apparaîtra comme cela : ",
   "emoji_button.activity": "Activités",
-  "emoji_button.custom": "Custom",
+  "emoji_button.custom": "Personnalisés",
   "emoji_button.flags": "Drapeaux",
   "emoji_button.food": "Boire et manger",
   "emoji_button.label": "Insérer un emoji",
@@ -74,9 +74,9 @@
   "emoji_button.not_found": "No emojos!! (╯°□°)╯︵ ┻━┻",
   "emoji_button.objects": "Objets",
   "emoji_button.people": "Personnages",
-  "emoji_button.recent": "Frequently used",
+  "emoji_button.recent": "Fréquemment utilisés",
   "emoji_button.search": "Recherche…",
-  "emoji_button.search_results": "Search results",
+  "emoji_button.search_results": "Résultats de la recherche",
   "emoji_button.symbols": "Symboles",
   "emoji_button.travel": "Lieux et voyages",
   "empty_column.community": "Le fil public local est vide. Écrivez-donc quelque chose pour le remplir !",
@@ -165,6 +165,11 @@
   "report.submit": "Envoyer",
   "report.target": "Signalement",
   "search.placeholder": "Rechercher",
+  "search_popout.search_format": "Recherche avancée",
+  "search_popout.tips.hashtag": "hashtag",
+  "search_popout.tips.status": "statuts",
+  "search_popout.tips.text": "Un texte simple renvoie les noms affichés, les noms d'utilisateur et les hashtags correspondants",
+  "search_popout.tips.user": "utilisateur",
   "search_results.total": "{count, number} {count, plural, one {résultat} other {résultats}}",
   "standalone.public_title": "Jeter un coup d’œil…",
   "status.cannot_reblog": "Cette publication ne peut être boostée",
@@ -196,6 +201,7 @@
   "tabs_bar.notifications": "Notifications",
   "upload_area.title": "Glissez et déposez pour envoyer",
   "upload_button.label": "Joindre un média",
+  "upload_form.description": "Décrire pour les malvoyants",
   "upload_form.undo": "Annuler",
   "upload_progress.label": "Envoi en cours…",
   "video.close": "Fermer la vidéo",
@@ -206,9 +212,5 @@
   "video.mute": "Couper le son",
   "video.pause": "Pause",
   "video.play": "Lecture",
-  "video.unmute": "Rétablir le son",
-  "video_player.expand": "Agrandir la vidéo",
-  "video_player.toggle_sound": "Activer/Désactiver le son",
-  "video_player.toggle_visible": "Afficher/Cacher la vidéo",
-  "video_player.video_error": "Erreur lors de la lecture de la vidéo"
+  "video.unmute": "Rétablir le son"
 }
diff --git a/app/javascript/mastodon/locales/he.json b/app/javascript/mastodon/locales/he.json
index f78c31a46..beaa4fd3a 100644
--- a/app/javascript/mastodon/locales/he.json
+++ b/app/javascript/mastodon/locales/he.json
@@ -165,6 +165,11 @@
   "report.submit": "שליחה",
   "report.target": "דיווח",
   "search.placeholder": "חיפוש",
+  "search_popout.search_format": "Advanced search format",
+  "search_popout.tips.hashtag": "hashtag",
+  "search_popout.tips.status": "status",
+  "search_popout.tips.text": "Simple text returns matching display names, usernames and hashtags",
+  "search_popout.tips.user": "user",
   "search_results.total": "{count, number} {count, plural, one {תוצאה} other {תוצאות}}",
   "standalone.public_title": "A look inside...",
   "status.cannot_reblog": "לא ניתן להדהד הודעה זו",
@@ -196,6 +201,7 @@
   "tabs_bar.notifications": "התראות",
   "upload_area.title": "ניתן להעלות על ידי Drag & drop",
   "upload_button.label": "הוספת מדיה",
+  "upload_form.description": "Describe for the visually impaired",
   "upload_form.undo": "ביטול",
   "upload_progress.label": "עולה...",
   "video.close": "Close video",
@@ -206,9 +212,5 @@
   "video.mute": "Mute sound",
   "video.pause": "Pause",
   "video.play": "Play",
-  "video.unmute": "Unmute sound",
-  "video_player.expand": "הרחבת וידאו",
-  "video_player.toggle_sound": "הפעלת\\ביטול שמע",
-  "video_player.toggle_visible": "הפעלת\\ביטול תצוגה",
-  "video_player.video_error": "לא ניתן לנגן וידאו"
+  "video.unmute": "Unmute sound"
 }
diff --git a/app/javascript/mastodon/locales/hr.json b/app/javascript/mastodon/locales/hr.json
index 43fe95eb8..cef61f15e 100644
--- a/app/javascript/mastodon/locales/hr.json
+++ b/app/javascript/mastodon/locales/hr.json
@@ -165,6 +165,11 @@
   "report.submit": "Pošalji",
   "report.target": "Prijavljivanje",
   "search.placeholder": "Traži",
+  "search_popout.search_format": "Advanced search format",
+  "search_popout.tips.hashtag": "hashtag",
+  "search_popout.tips.status": "status",
+  "search_popout.tips.text": "Simple text returns matching display names, usernames and hashtags",
+  "search_popout.tips.user": "user",
   "search_results.total": "{count, number} {count, plural, one {result} other {results}}",
   "standalone.public_title": "A look inside...",
   "status.cannot_reblog": "Ovaj post ne može biti boostan",
@@ -196,6 +201,7 @@
   "tabs_bar.notifications": "Notifikacije",
   "upload_area.title": "Povuci i spusti kako bi uploadao",
   "upload_button.label": "Dodaj media",
+  "upload_form.description": "Describe for the visually impaired",
   "upload_form.undo": "Poništi",
   "upload_progress.label": "Uploadam...",
   "video.close": "Close video",
@@ -206,9 +212,5 @@
   "video.mute": "Mute sound",
   "video.pause": "Pause",
   "video.play": "Play",
-  "video.unmute": "Unmute sound",
-  "video_player.expand": "Proširi video",
-  "video_player.toggle_sound": "Toggle zvuk",
-  "video_player.toggle_visible": "Preklopi vidljivost",
-  "video_player.video_error": "Video ne može biti reproduciran"
+  "video.unmute": "Unmute sound"
 }
diff --git a/app/javascript/mastodon/locales/hu.json b/app/javascript/mastodon/locales/hu.json
index f73295dca..7b9c1b293 100644
--- a/app/javascript/mastodon/locales/hu.json
+++ b/app/javascript/mastodon/locales/hu.json
@@ -165,6 +165,11 @@
   "report.submit": "Submit",
   "report.target": "Reporting",
   "search.placeholder": "Keresés",
+  "search_popout.search_format": "Advanced search format",
+  "search_popout.tips.hashtag": "hashtag",
+  "search_popout.tips.status": "status",
+  "search_popout.tips.text": "Simple text returns matching display names, usernames and hashtags",
+  "search_popout.tips.user": "user",
   "search_results.total": "{count, number} {count, plural, one {result} other {results}}",
   "standalone.public_title": "A look inside...",
   "status.cannot_reblog": "This post cannot be boosted",
@@ -196,6 +201,7 @@
   "tabs_bar.notifications": "Notifications",
   "upload_area.title": "Drag & drop to upload",
   "upload_button.label": "Média hozzáadása",
+  "upload_form.description": "Describe for the visually impaired",
   "upload_form.undo": "Mégsem",
   "upload_progress.label": "Uploading...",
   "video.close": "Close video",
@@ -206,9 +212,5 @@
   "video.mute": "Mute sound",
   "video.pause": "Pause",
   "video.play": "Play",
-  "video.unmute": "Unmute sound",
-  "video_player.expand": "Expand video",
-  "video_player.toggle_sound": "Hang kapcsolása",
-  "video_player.toggle_visible": "Toggle visibility",
-  "video_player.video_error": "Video could not be played"
+  "video.unmute": "Unmute sound"
 }
diff --git a/app/javascript/mastodon/locales/id.json b/app/javascript/mastodon/locales/id.json
index 4d5f0a5d8..cc48aa996 100644
--- a/app/javascript/mastodon/locales/id.json
+++ b/app/javascript/mastodon/locales/id.json
@@ -165,6 +165,11 @@
   "report.submit": "Kirim",
   "report.target": "Melaporkan",
   "search.placeholder": "Pencarian",
+  "search_popout.search_format": "Advanced search format",
+  "search_popout.tips.hashtag": "hashtag",
+  "search_popout.tips.status": "status",
+  "search_popout.tips.text": "Simple text returns matching display names, usernames and hashtags",
+  "search_popout.tips.user": "user",
   "search_results.total": "{count} {count, plural, one {hasil} other {hasil}}",
   "standalone.public_title": "A look inside...",
   "status.cannot_reblog": "This post cannot be boosted",
@@ -196,6 +201,7 @@
   "tabs_bar.notifications": "Notifikasi",
   "upload_area.title": "Seret & lepaskan untuk mengunggah",
   "upload_button.label": "Tambahkan media",
+  "upload_form.description": "Describe for the visually impaired",
   "upload_form.undo": "Undo",
   "upload_progress.label": "Mengunggah...",
   "video.close": "Close video",
@@ -206,9 +212,5 @@
   "video.mute": "Mute sound",
   "video.pause": "Pause",
   "video.play": "Play",
-  "video.unmute": "Unmute sound",
-  "video_player.expand": "Tampilkan video",
-  "video_player.toggle_sound": "Suara",
-  "video_player.toggle_visible": "Tampilan",
-  "video_player.video_error": "Video tidak dapat diputar"
+  "video.unmute": "Unmute sound"
 }
diff --git a/app/javascript/mastodon/locales/io.json b/app/javascript/mastodon/locales/io.json
index d2c1ee73d..b484bebc7 100644
--- a/app/javascript/mastodon/locales/io.json
+++ b/app/javascript/mastodon/locales/io.json
@@ -165,6 +165,11 @@
   "report.submit": "Sendar",
   "report.target": "Denuncante",
   "search.placeholder": "Serchez",
+  "search_popout.search_format": "Advanced search format",
+  "search_popout.tips.hashtag": "hashtag",
+  "search_popout.tips.status": "status",
+  "search_popout.tips.text": "Simple text returns matching display names, usernames and hashtags",
+  "search_popout.tips.user": "user",
   "search_results.total": "{count, number} {count, plural, one {rezulto} other {rezulti}}",
   "standalone.public_title": "A look inside...",
   "status.cannot_reblog": "This post cannot be boosted",
@@ -196,6 +201,7 @@
   "tabs_bar.notifications": "Savigi",
   "upload_area.title": "Tranar faligar por kargar",
   "upload_button.label": "Adjuntar kontenajo",
+  "upload_form.description": "Describe for the visually impaired",
   "upload_form.undo": "Desfacar",
   "upload_progress.label": "Kargante...",
   "video.close": "Close video",
@@ -206,9 +212,5 @@
   "video.mute": "Mute sound",
   "video.pause": "Pause",
   "video.play": "Play",
-  "video.unmute": "Unmute sound",
-  "video_player.expand": "Extensar video",
-  "video_player.toggle_sound": "Acendar sono",
-  "video_player.toggle_visible": "Chanjar videbleso",
-  "video_player.video_error": "Video ne povus pleesar"
+  "video.unmute": "Unmute sound"
 }
diff --git a/app/javascript/mastodon/locales/it.json b/app/javascript/mastodon/locales/it.json
index 33f0e7fdc..4d73fbea8 100644
--- a/app/javascript/mastodon/locales/it.json
+++ b/app/javascript/mastodon/locales/it.json
@@ -165,6 +165,11 @@
   "report.submit": "Invia",
   "report.target": "Invio la segnalazione",
   "search.placeholder": "Cerca",
+  "search_popout.search_format": "Advanced search format",
+  "search_popout.tips.hashtag": "hashtag",
+  "search_popout.tips.status": "status",
+  "search_popout.tips.text": "Simple text returns matching display names, usernames and hashtags",
+  "search_popout.tips.user": "user",
   "search_results.total": "{count} {count, plural, one {risultato} other {risultati}}",
   "standalone.public_title": "A look inside...",
   "status.cannot_reblog": "This post cannot be boosted",
@@ -196,6 +201,7 @@
   "tabs_bar.notifications": "Notifiche",
   "upload_area.title": "Trascina per caricare",
   "upload_button.label": "Aggiungi file multimediale",
+  "upload_form.description": "Describe for the visually impaired",
   "upload_form.undo": "Annulla",
   "upload_progress.label": "Sto caricando...",
   "video.close": "Close video",
@@ -206,9 +212,5 @@
   "video.mute": "Mute sound",
   "video.pause": "Pause",
   "video.play": "Play",
-  "video.unmute": "Unmute sound",
-  "video_player.expand": "Espandi video",
-  "video_player.toggle_sound": "Attiva suono",
-  "video_player.toggle_visible": "Attiva visibilità",
-  "video_player.video_error": "Il video non può essere riprodotto"
+  "video.unmute": "Unmute sound"
 }
diff --git a/app/javascript/mastodon/locales/ja.json b/app/javascript/mastodon/locales/ja.json
index c3d96baf3..ce797a7c7 100644
--- a/app/javascript/mastodon/locales/ja.json
+++ b/app/javascript/mastodon/locales/ja.json
@@ -66,17 +66,17 @@
   "embed.instructions": "下記のコードをコピーしてウェブサイトに埋め込みます。",
   "embed.preview": "表示例:",
   "emoji_button.activity": "活動",
-  "emoji_button.custom": "Custom",
+  "emoji_button.custom": "カスタム絵文字",
   "emoji_button.flags": "国旗",
   "emoji_button.food": "食べ物",
   "emoji_button.label": "絵文字を追加",
   "emoji_button.nature": "自然",
-  "emoji_button.not_found": "No emojos!! (╯°□°)╯︵ ┻━┻",
+  "emoji_button.not_found": "絵文字がない!! (╯°□°)╯︵ ┻━┻",
   "emoji_button.objects": "物",
   "emoji_button.people": "人々",
-  "emoji_button.recent": "Frequently used",
+  "emoji_button.recent": "よく使う絵文字",
   "emoji_button.search": "検索...",
-  "emoji_button.search_results": "Search results",
+  "emoji_button.search_results": "検索結果",
   "emoji_button.symbols": "記号",
   "emoji_button.travel": "旅行と場所",
   "empty_column.community": "ローカルタイムラインはまだ使われていません。何か書いてみましょう!",
@@ -160,11 +160,21 @@
   "privacy.public.short": "公開",
   "privacy.unlisted.long": "公開TLで表示しない",
   "privacy.unlisted.short": "未収載",
+  "relative_time.days": "{number}日前",
+  "relative_time.hours": "{number}時間前",
+  "relative_time.just_now": "今",
+  "relative_time.minutes": "{number}分前",
+  "relative_time.seconds": "{number}秒前",
   "reply_indicator.cancel": "キャンセル",
   "report.placeholder": "コメント",
   "report.submit": "通報する",
   "report.target": "{target} を通報する",
   "search.placeholder": "検索",
+  "search_popout.search_format": "高度な検索フォーマット",
+  "search_popout.tips.hashtag": "ハッシュタグ",
+  "search_popout.tips.status": "トゥート",
+  "search_popout.tips.text": "表示名やユーザー名、ハッシュタグに一致する単純なテキスト",
+  "search_popout.tips.user": "ユーザー",
   "search_results.total": "{count, number}件の結果",
   "standalone.public_title": "今こんな話をしています",
   "status.cannot_reblog": "この投稿はブーストできません",
@@ -196,6 +206,7 @@
   "tabs_bar.notifications": "通知",
   "upload_area.title": "ドラッグ&ドロップでアップロード",
   "upload_button.label": "メディアを追加",
+  "upload_form.description": "視覚障害者のための説明",
   "upload_form.undo": "やり直す",
   "upload_progress.label": "アップロード中...",
   "video.close": "動画を閉じる",
@@ -206,9 +217,5 @@
   "video.mute": "ミュート",
   "video.pause": "一時停止",
   "video.play": "再生",
-  "video.unmute": "ミュートを解除する",
-  "video_player.expand": "動画の詳細",
-  "video_player.toggle_sound": "音の切り替え",
-  "video_player.toggle_visible": "表示切り替え",
-  "video_player.video_error": "動画の再生に失敗しました"
+  "video.unmute": "ミュートを解除する"
 }
diff --git a/app/javascript/mastodon/locales/ko.json b/app/javascript/mastodon/locales/ko.json
index c50bb2f34..c1768cf8f 100644
--- a/app/javascript/mastodon/locales/ko.json
+++ b/app/javascript/mastodon/locales/ko.json
@@ -165,6 +165,11 @@
   "report.submit": "신고하기",
   "report.target": "문제가 된 사용자",
   "search.placeholder": "검색",
+  "search_popout.search_format": "Advanced search format",
+  "search_popout.tips.hashtag": "hashtag",
+  "search_popout.tips.status": "status",
+  "search_popout.tips.text": "Simple text returns matching display names, usernames and hashtags",
+  "search_popout.tips.user": "user",
   "search_results.total": "{count, number}건의 결과",
   "standalone.public_title": "A look inside...",
   "status.cannot_reblog": "이 포스트는 부스트 할 수 없습니다",
@@ -196,6 +201,7 @@
   "tabs_bar.notifications": "알림",
   "upload_area.title": "드래그 & 드롭으로 업로드",
   "upload_button.label": "미디어 추가",
+  "upload_form.description": "Describe for the visually impaired",
   "upload_form.undo": "재시도",
   "upload_progress.label": "업로드 중...",
   "video.close": "Close video",
@@ -206,9 +212,5 @@
   "video.mute": "Mute sound",
   "video.pause": "Pause",
   "video.play": "Play",
-  "video.unmute": "Unmute sound",
-  "video_player.expand": "동영상 자세히 보기",
-  "video_player.toggle_sound": "소리 토글하기",
-  "video_player.toggle_visible": "표시 전환",
-  "video_player.video_error": "동영상 재생에 실패했습니다"
+  "video.unmute": "Unmute sound"
 }
diff --git a/app/javascript/mastodon/locales/nl.json b/app/javascript/mastodon/locales/nl.json
index c333bec70..bad2d78c5 100644
--- a/app/javascript/mastodon/locales/nl.json
+++ b/app/javascript/mastodon/locales/nl.json
@@ -165,6 +165,11 @@
   "report.submit": "Verzenden",
   "report.target": "Rapporteren van",
   "search.placeholder": "Zoeken",
+  "search_popout.search_format": "Advanced search format",
+  "search_popout.tips.hashtag": "hashtag",
+  "search_popout.tips.status": "status",
+  "search_popout.tips.text": "Simple text returns matching display names, usernames and hashtags",
+  "search_popout.tips.user": "user",
   "search_results.total": "{count, number} {count, plural, one {resultaat} other {resultaten}}",
   "standalone.public_title": "Een kijkje binnenin...",
   "status.cannot_reblog": "Deze toot kan niet geboost worden",
@@ -196,6 +201,7 @@
   "tabs_bar.notifications": "Meldingen",
   "upload_area.title": "Hierin slepen om te uploaden",
   "upload_button.label": "Media toevoegen",
+  "upload_form.description": "Describe for the visually impaired",
   "upload_form.undo": "Ongedaan maken",
   "upload_progress.label": "Uploaden...",
   "video.close": "Close video",
@@ -206,9 +212,5 @@
   "video.mute": "Geluid uitschakelen",
   "video.pause": "Pauze",
   "video.play": "Afspelen",
-  "video.unmute": "Geluid inschakelen",
-  "video_player.expand": "Video groter maken",
-  "video_player.toggle_sound": "Geluid in-/uitschakelen",
-  "video_player.toggle_visible": "Video wel/niet tonen",
-  "video_player.video_error": "Video kon niet afgespeeld worden"
+  "video.unmute": "Geluid inschakelen"
 }
diff --git a/app/javascript/mastodon/locales/no.json b/app/javascript/mastodon/locales/no.json
index d28190faf..26556b290 100644
--- a/app/javascript/mastodon/locales/no.json
+++ b/app/javascript/mastodon/locales/no.json
@@ -165,6 +165,11 @@
   "report.submit": "Send inn",
   "report.target": "Rapporterer",
   "search.placeholder": "Søk",
+  "search_popout.search_format": "Advanced search format",
+  "search_popout.tips.hashtag": "hashtag",
+  "search_popout.tips.status": "status",
+  "search_popout.tips.text": "Simple text returns matching display names, usernames and hashtags",
+  "search_popout.tips.user": "user",
   "search_results.total": "{count, number} {count, plural, one {resultat} other {resultater}}",
   "standalone.public_title": "A look inside...",
   "status.cannot_reblog": "Denne posten kan ikke fremheves",
@@ -196,6 +201,7 @@
   "tabs_bar.notifications": "Varslinger",
   "upload_area.title": "Dra og slipp for å laste opp",
   "upload_button.label": "Legg til media",
+  "upload_form.description": "Describe for the visually impaired",
   "upload_form.undo": "Angre",
   "upload_progress.label": "Laster opp...",
   "video.close": "Close video",
@@ -206,9 +212,5 @@
   "video.mute": "Mute sound",
   "video.pause": "Pause",
   "video.play": "Play",
-  "video.unmute": "Unmute sound",
-  "video_player.expand": "Utvid video",
-  "video_player.toggle_sound": "Veksle lyd",
-  "video_player.toggle_visible": "Veksle synlighet",
-  "video_player.video_error": "Video kunne ikke spilles av"
+  "video.unmute": "Unmute sound"
 }
diff --git a/app/javascript/mastodon/locales/oc.json b/app/javascript/mastodon/locales/oc.json
index 8e9d06642..d730b47f4 100644
--- a/app/javascript/mastodon/locales/oc.json
+++ b/app/javascript/mastodon/locales/oc.json
@@ -165,6 +165,11 @@
   "report.submit": "Mandar",
   "report.target": "Senhalar {target}",
   "search.placeholder": "Recercar",
+  "search_popout.search_format": "Format recèrca avançada",
+  "search_popout.tips.hashtag": "etiqueta",
+  "search_popout.tips.status": "estatut",
+  "search_popout.tips.text": "Tèxt brut tòrna escais, noms d’utilizaire e etiquetas correspondents",
+  "search_popout.tips.user": "utilizaire",
   "search_results.total": "{count, number} {count, plural, one {resultat} other {resultats}}",
   "standalone.public_title": "Una ulhada dedins…",
   "status.cannot_reblog": "Aqueste estatut pòt pas èsser partejat",
@@ -196,6 +201,7 @@
   "tabs_bar.notifications": "Notificacions",
   "upload_area.title": "Lisatz e depausatz per mandar",
   "upload_button.label": "Ajustar un mèdia",
+  "upload_form.description": "Descripcion se per cas i aja un problèma",
   "upload_form.undo": "Anullar",
   "upload_progress.label": "Mandadís…",
   "video.close": "Tampar la vidèo",
@@ -206,9 +212,5 @@
   "video.mute": "Copar lo son",
   "video.pause": "Pausa",
   "video.play": "Lectura",
-  "video.unmute": "Restablir lo son",
-  "video_player.expand": "Mostrar la vidèo",
-  "video_player.toggle_sound": "Activar/Desactivar lo son",
-  "video_player.toggle_visible": "Mostrar/Rescondre la vidèo",
-  "video_player.video_error": "Fracàs de la lectura de la vidèo"
+  "video.unmute": "Restablir lo son"
 }
diff --git a/app/javascript/mastodon/locales/pl.json b/app/javascript/mastodon/locales/pl.json
index 35b1a3101..c8228c0cb 100644
--- a/app/javascript/mastodon/locales/pl.json
+++ b/app/javascript/mastodon/locales/pl.json
@@ -56,14 +56,14 @@
   "confirmations.block.confirm": "Zablokuj",
   "confirmations.block.message": "Czy na pewno chcesz zablokować {name}?",
   "confirmations.delete.confirm": "Usuń",
-  "confirmations.delete.message": "Czy na pewno chcesz usunąć ten status?",
+  "confirmations.delete.message": "Czy na pewno chcesz usunąć ten wpis?",
   "confirmations.domain_block.confirm": "Ukryj wszysyko z domeny",
   "confirmations.domain_block.message": "Czy na pewno chcesz zablokować całą domenę {domain}? Zwykle lepszym rozwiązaniem jest blokada lub wyciszenie kilku użytkowników.",
   "confirmations.mute.confirm": "Wycisz",
   "confirmations.mute.message": "Czy na pewno chcesz wyciszyć {name}?",
   "confirmations.unfollow.confirm": "Przestań śledzić",
   "confirmations.unfollow.message": "Czy na pewno zamierzasz przestać śledzić {name}?",
-  "embed.instructions": "Osadź ten status na swojej stronie wklejając poniższy kod.",
+  "embed.instructions": "Osadź ten wpis na swojej stronie wklejając poniższy kod.",
   "embed.preview": "Tak będzie to wyglądać:",
   "emoji_button.activity": "Aktywność",
   "emoji_button.custom": "Niestandardowe",
@@ -116,10 +116,10 @@
   "navigation_bar.pins": "Przypięte wpisy",
   "navigation_bar.preferences": "Preferencje",
   "navigation_bar.public_timeline": "Oś czasu federacji",
-  "notification.favourite": "{name} dodał Twój status do ulubionych",
+  "notification.favourite": "{name} dodał Twój wpis do ulubionych",
   "notification.follow": "{name} zaczął Cię śledzić",
   "notification.mention": "{name} wspomniał o tobie",
-  "notification.reblog": "{name} podbił Twój status",
+  "notification.reblog": "{name} podbił Twój wpis",
   "notifications.clear": "Wyczyść powiadomienia",
   "notifications.clear_confirmation": "Czy na pewno chcesz bezpowrotnie usunąć wszystkie powiadomienia?",
   "notifications.column_settings.alert": "Powiadomienia na pulpicie",
@@ -165,6 +165,11 @@
   "report.submit": "Wyślij",
   "report.target": "Zgłaszanie {target}",
   "search.placeholder": "Szukaj",
+  "search_popout.search_format": "Zaawansowane wyszukiwanie",
+  "search_popout.tips.hashtag": "hashtag",
+  "search_popout.tips.status": "wpis",
+  "search_popout.tips.text": "Proste wyszukiwanie pasujących pseudonimów, nazw użytkowników i hashtagów",
+  "search_popout.tips.user": "użytkownik",
   "search_results.total": "{count, number} {count, plural, one {wynik} more {wyniki}}",
   "standalone.public_title": "Spojrzenie w głąb…",
   "status.cannot_reblog": "Ten wpis nie może zostać podbity",
@@ -175,7 +180,7 @@
   "status.media_hidden": "Zawartość multimedialna ukryta",
   "status.mention": "Wspomnij o @{name}",
   "status.mute_conversation": "Wycisz konwersację",
-  "status.open": "Rozszerz ten status",
+  "status.open": "Rozszerz ten wpis",
   "status.pin": "Przypnij do profilu",
   "status.reblog": "Podbij",
   "status.reblogged_by": "{name} podbił",
@@ -196,6 +201,7 @@
   "tabs_bar.notifications": "Powiadomienia",
   "upload_area.title": "Przeciągnij i upuść aby wysłać",
   "upload_button.label": "Dodaj zawartość multimedialną",
+  "upload_form.description": "Wprowadź opis dla niewidomych i niedowidzących",
   "upload_form.undo": "Cofnij",
   "upload_progress.label": "Wysyłanie",
   "video.close": "Zamknij film",
@@ -206,9 +212,5 @@
   "video.mute": "Wycisz",
   "video.pause": "Pauzuj",
   "video.play": "Odtwórz",
-  "video.unmute": "Cofnij wyciszenie",
-  "video_player.expand": "Rozszerz film",
-  "video_player.toggle_sound": "Przełącz dźwięk",
-  "video_player.toggle_visible": "Przełącz widoczność",
-  "video_player.video_error": "Nie można odtworzyć pliku wideo"
+  "video.unmute": "Cofnij wyciszenie"
 }
diff --git a/app/javascript/mastodon/locales/pt-BR.json b/app/javascript/mastodon/locales/pt-BR.json
index 187343e83..61674b37e 100644
--- a/app/javascript/mastodon/locales/pt-BR.json
+++ b/app/javascript/mastodon/locales/pt-BR.json
@@ -165,6 +165,11 @@
   "report.submit": "Enviar",
   "report.target": "Denunciar",
   "search.placeholder": "Pesquisar",
+  "search_popout.search_format": "Advanced search format",
+  "search_popout.tips.hashtag": "hashtag",
+  "search_popout.tips.status": "status",
+  "search_popout.tips.text": "Simple text returns matching display names, usernames and hashtags",
+  "search_popout.tips.user": "user",
   "search_results.total": "{count, number} {count, plural, one {resultado} other {resultados}}",
   "standalone.public_title": "Dê uma espiada...",
   "status.cannot_reblog": "Esta postagem não pode ser compartilhada",
@@ -196,6 +201,7 @@
   "tabs_bar.notifications": "Notificações",
   "upload_area.title": "Arraste e solte para enviar",
   "upload_button.label": "Adicionar mídia",
+  "upload_form.description": "Describe for the visually impaired",
   "upload_form.undo": "Anular",
   "upload_progress.label": "Salvando...",
   "video.close": "Fechar vídeo",
@@ -206,9 +212,5 @@
   "video.mute": "Silenciar vídeo",
   "video.pause": "Parar",
   "video.play": "Reproduzir",
-  "video.unmute": "Retirar silêncio",
-  "video_player.expand": "Expandir vídeo",
-  "video_player.toggle_sound": "Ligar/Desligar som",
-  "video_player.toggle_visible": "Ligar/Desligar vídeo",
-  "video_player.video_error": "Não é possível ver o vídeo"
+  "video.unmute": "Retirar silêncio"
 }
diff --git a/app/javascript/mastodon/locales/pt.json b/app/javascript/mastodon/locales/pt.json
index 782aaf114..ecd0689df 100644
--- a/app/javascript/mastodon/locales/pt.json
+++ b/app/javascript/mastodon/locales/pt.json
@@ -165,6 +165,11 @@
   "report.submit": "Enviar",
   "report.target": "Denunciar",
   "search.placeholder": "Pesquisar",
+  "search_popout.search_format": "Advanced search format",
+  "search_popout.tips.hashtag": "hashtag",
+  "search_popout.tips.status": "status",
+  "search_popout.tips.text": "Simple text returns matching display names, usernames and hashtags",
+  "search_popout.tips.user": "user",
   "search_results.total": "{count, number} {count, plural, one {resultado} other {resultados}}",
   "standalone.public_title": "A look inside...",
   "status.cannot_reblog": "This post cannot be boosted",
@@ -196,6 +201,7 @@
   "tabs_bar.notifications": "Notificações",
   "upload_area.title": "Arraste e solte para enviar",
   "upload_button.label": "Adicionar media",
+  "upload_form.description": "Describe for the visually impaired",
   "upload_form.undo": "Anular",
   "upload_progress.label": "A gravar...",
   "video.close": "Close video",
@@ -206,9 +212,5 @@
   "video.mute": "Mute sound",
   "video.pause": "Pause",
   "video.play": "Play",
-  "video.unmute": "Unmute sound",
-  "video_player.expand": "Expandir vídeo",
-  "video_player.toggle_sound": "Ligar/Desligar som",
-  "video_player.toggle_visible": "Ligar/Desligar vídeo",
-  "video_player.video_error": "Não é possível ver o vídeo"
+  "video.unmute": "Unmute sound"
 }
diff --git a/app/javascript/mastodon/locales/ru.json b/app/javascript/mastodon/locales/ru.json
index 6f39d098c..bf32c820d 100644
--- a/app/javascript/mastodon/locales/ru.json
+++ b/app/javascript/mastodon/locales/ru.json
@@ -165,6 +165,11 @@
   "report.submit": "Отправить",
   "report.target": "Жалуемся на",
   "search.placeholder": "Поиск",
+  "search_popout.search_format": "Advanced search format",
+  "search_popout.tips.hashtag": "hashtag",
+  "search_popout.tips.status": "status",
+  "search_popout.tips.text": "Simple text returns matching display names, usernames and hashtags",
+  "search_popout.tips.user": "user",
   "search_results.total": "{count, number} {count, plural, one {результат} few {результата} many {результатов} other {результатов}}",
   "standalone.public_title": "A look inside...",
   "status.cannot_reblog": "Этот статус не может быть продвинут",
@@ -196,6 +201,7 @@
   "tabs_bar.notifications": "Уведомления",
   "upload_area.title": "Перетащите сюда, чтобы загрузить",
   "upload_button.label": "Добавить медиаконтент",
+  "upload_form.description": "Describe for the visually impaired",
   "upload_form.undo": "Отменить",
   "upload_progress.label": "Загрузка...",
   "video.close": "Close video",
@@ -206,9 +212,5 @@
   "video.mute": "Mute sound",
   "video.pause": "Pause",
   "video.play": "Play",
-  "video.unmute": "Unmute sound",
-  "video_player.expand": "Развернуть видео",
-  "video_player.toggle_sound": "Вкл./выкл. звук",
-  "video_player.toggle_visible": "Показать/скрыть",
-  "video_player.video_error": "Видео не может быть проиграно"
+  "video.unmute": "Unmute sound"
 }
diff --git a/app/javascript/mastodon/locales/th.json b/app/javascript/mastodon/locales/th.json
index ecc7a00db..f3ec9c532 100644
--- a/app/javascript/mastodon/locales/th.json
+++ b/app/javascript/mastodon/locales/th.json
@@ -165,6 +165,11 @@
   "report.submit": "Submit",
   "report.target": "Reporting",
   "search.placeholder": "Search",
+  "search_popout.search_format": "Advanced search format",
+  "search_popout.tips.hashtag": "hashtag",
+  "search_popout.tips.status": "status",
+  "search_popout.tips.text": "Simple text returns matching display names, usernames and hashtags",
+  "search_popout.tips.user": "user",
   "search_results.total": "{count, number} {count, plural, one {result} other {results}}",
   "standalone.public_title": "A look inside...",
   "status.cannot_reblog": "This post cannot be boosted",
@@ -196,6 +201,7 @@
   "tabs_bar.notifications": "Notifications",
   "upload_area.title": "Drag & drop to upload",
   "upload_button.label": "Add media",
+  "upload_form.description": "Describe for the visually impaired",
   "upload_form.undo": "Undo",
   "upload_progress.label": "Uploading...",
   "video.close": "Close video",
@@ -206,9 +212,5 @@
   "video.mute": "Mute sound",
   "video.pause": "Pause",
   "video.play": "Play",
-  "video.unmute": "Unmute sound",
-  "video_player.expand": "Expand video",
-  "video_player.toggle_sound": "Toggle sound",
-  "video_player.toggle_visible": "Toggle visibility",
-  "video_player.video_error": "Video could not be played"
+  "video.unmute": "Unmute sound"
 }
diff --git a/app/javascript/mastodon/locales/tr.json b/app/javascript/mastodon/locales/tr.json
index b7ecd2cdb..afc6383b4 100644
--- a/app/javascript/mastodon/locales/tr.json
+++ b/app/javascript/mastodon/locales/tr.json
@@ -165,6 +165,11 @@
   "report.submit": "Gönder",
   "report.target": "Raporlama",
   "search.placeholder": "Ara",
+  "search_popout.search_format": "Advanced search format",
+  "search_popout.tips.hashtag": "hashtag",
+  "search_popout.tips.status": "status",
+  "search_popout.tips.text": "Simple text returns matching display names, usernames and hashtags",
+  "search_popout.tips.user": "user",
   "search_results.total": "{count, number} {count, plural, one {sonuç} other {sonuçlar}}",
   "standalone.public_title": "A look inside...",
   "status.cannot_reblog": "Bu gönderi boost edilemez",
@@ -196,6 +201,7 @@
   "tabs_bar.notifications": "Bildirimler",
   "upload_area.title": "Upload için sürükle bırak yapınız",
   "upload_button.label": "Görsel ekle",
+  "upload_form.description": "Describe for the visually impaired",
   "upload_form.undo": "Geri al",
   "upload_progress.label": "Yükleniyor...",
   "video.close": "Close video",
@@ -206,9 +212,5 @@
   "video.mute": "Mute sound",
   "video.pause": "Pause",
   "video.play": "Play",
-  "video.unmute": "Unmute sound",
-  "video_player.expand": "Videoyu genişlet",
-  "video_player.toggle_sound": "Sesi aç/kapa",
-  "video_player.toggle_visible": "Göster/gizle",
-  "video_player.video_error": "Video oynatılamadı"
+  "video.unmute": "Unmute sound"
 }
diff --git a/app/javascript/mastodon/locales/uk.json b/app/javascript/mastodon/locales/uk.json
index 45b2c2ee0..d0aae032b 100644
--- a/app/javascript/mastodon/locales/uk.json
+++ b/app/javascript/mastodon/locales/uk.json
@@ -165,6 +165,11 @@
   "report.submit": "Відправити",
   "report.target": "Скаржимося на",
   "search.placeholder": "Пошук",
+  "search_popout.search_format": "Advanced search format",
+  "search_popout.tips.hashtag": "hashtag",
+  "search_popout.tips.status": "status",
+  "search_popout.tips.text": "Simple text returns matching display names, usernames and hashtags",
+  "search_popout.tips.user": "user",
   "search_results.total": "{count, number} {count, plural, one {результат} few {результати} many {результатів} other {результатів}}",
   "standalone.public_title": "A look inside...",
   "status.cannot_reblog": "Цей допис не може бути передмухнутий",
@@ -196,6 +201,7 @@
   "tabs_bar.notifications": "Сповіщення",
   "upload_area.title": "Перетягніть сюди, щоб завантажити",
   "upload_button.label": "Додати медіаконтент",
+  "upload_form.description": "Describe for the visually impaired",
   "upload_form.undo": "Відмінити",
   "upload_progress.label": "Завантаження...",
   "video.close": "Close video",
@@ -206,9 +212,5 @@
   "video.mute": "Mute sound",
   "video.pause": "Pause",
   "video.play": "Play",
-  "video.unmute": "Unmute sound",
-  "video_player.expand": "Розгорнути ",
-  "video_player.toggle_sound": "Увімкнути/вимкнути звук",
-  "video_player.toggle_visible": "Показати/приховати",
-  "video_player.video_error": "Відео не може бути відтворено"
+  "video.unmute": "Unmute sound"
 }
diff --git a/app/javascript/mastodon/locales/zh-CN.json b/app/javascript/mastodon/locales/zh-CN.json
index 58e3d6780..e0ffc16df 100644
--- a/app/javascript/mastodon/locales/zh-CN.json
+++ b/app/javascript/mastodon/locales/zh-CN.json
@@ -165,6 +165,11 @@
   "report.submit": "提交",
   "report.target": "Reporting",
   "search.placeholder": "搜索",
+  "search_popout.search_format": "Advanced search format",
+  "search_popout.tips.hashtag": "hashtag",
+  "search_popout.tips.status": "status",
+  "search_popout.tips.text": "Simple text returns matching display names, usernames and hashtags",
+  "search_popout.tips.user": "user",
   "search_results.total": "{count, number} {count, plural, one {result} other {results}}",
   "standalone.public_title": "大家都在干啥?",
   "status.cannot_reblog": "没法转嘟这条嘟文啦……",
@@ -196,6 +201,7 @@
   "tabs_bar.notifications": "通知",
   "upload_area.title": "将文件拖放至此上传",
   "upload_button.label": "上传媒体文件",
+  "upload_form.description": "Describe for the visually impaired",
   "upload_form.undo": "还原",
   "upload_progress.label": "上传中……",
   "video.close": "关闭影片",
@@ -206,9 +212,5 @@
   "video.mute": "静音",
   "video.pause": "暂停",
   "video.play": "播放",
-  "video.unmute": "解除静音",
-  "video_player.expand": "展开影片",
-  "video_player.toggle_sound": "开关音效",
-  "video_player.toggle_visible": "打开或关上",
-  "video_player.video_error": "视频无法播放啦……"
+  "video.unmute": "解除静音"
 }
diff --git a/app/javascript/mastodon/locales/zh-HK.json b/app/javascript/mastodon/locales/zh-HK.json
index 610aa6daf..053e971aa 100644
--- a/app/javascript/mastodon/locales/zh-HK.json
+++ b/app/javascript/mastodon/locales/zh-HK.json
@@ -165,6 +165,11 @@
   "report.submit": "提交",
   "report.target": "舉報",
   "search.placeholder": "搜尋",
+  "search_popout.search_format": "Advanced search format",
+  "search_popout.tips.hashtag": "hashtag",
+  "search_popout.tips.status": "status",
+  "search_popout.tips.text": "Simple text returns matching display names, usernames and hashtags",
+  "search_popout.tips.user": "user",
   "search_results.total": "{count, number} 項結果",
   "standalone.public_title": "站點一瞥…",
   "status.cannot_reblog": "這篇文章無法被轉推",
@@ -196,6 +201,7 @@
   "tabs_bar.notifications": "通知",
   "upload_area.title": "將檔案拖放至此上載",
   "upload_button.label": "上載媒體檔案",
+  "upload_form.description": "Describe for the visually impaired",
   "upload_form.undo": "還原",
   "upload_progress.label": "上載中……",
   "video.close": "關閉影片",
@@ -206,9 +212,5 @@
   "video.mute": "靜音",
   "video.pause": "暫停",
   "video.play": "播放",
-  "video.unmute": "解除靜音",
-  "video_player.expand": "展開影片",
-  "video_player.toggle_sound": "開關音效",
-  "video_player.toggle_visible": "打開或關上",
-  "video_player.video_error": "無法播放影片"
+  "video.unmute": "解除靜音"
 }
diff --git a/app/javascript/mastodon/locales/zh-TW.json b/app/javascript/mastodon/locales/zh-TW.json
index ad2f1a05a..a22d66fa1 100644
--- a/app/javascript/mastodon/locales/zh-TW.json
+++ b/app/javascript/mastodon/locales/zh-TW.json
@@ -165,6 +165,11 @@
   "report.submit": "送出",
   "report.target": "通報中",
   "search.placeholder": "搜尋",
+  "search_popout.search_format": "Advanced search format",
+  "search_popout.tips.hashtag": "hashtag",
+  "search_popout.tips.status": "status",
+  "search_popout.tips.text": "Simple text returns matching display names, usernames and hashtags",
+  "search_popout.tips.user": "user",
   "search_results.total": "{count, number} 項結果",
   "standalone.public_title": "站點一瞥…",
   "status.cannot_reblog": "此貼文無法轉推",
@@ -196,6 +201,7 @@
   "tabs_bar.notifications": "通知",
   "upload_area.title": "拖放來上傳",
   "upload_button.label": "增加媒體",
+  "upload_form.description": "Describe for the visually impaired",
   "upload_form.undo": "復原",
   "upload_progress.label": "上傳中...",
   "video.close": "關閉影片",
@@ -206,9 +212,5 @@
   "video.mute": "消音",
   "video.pause": "暫停",
   "video.play": "播放",
-  "video.unmute": "解除消音",
-  "video_player.expand": "展開影片",
-  "video_player.toggle_sound": "切換音效",
-  "video_player.toggle_visible": "切換可見性",
-  "video_player.video_error": "無法播放這影片"
+  "video.unmute": "解除消音"
 }
diff --git a/app/javascript/mastodon/performance.js b/app/javascript/mastodon/performance.js
index 396c605e4..450a90626 100644
--- a/app/javascript/mastodon/performance.js
+++ b/app/javascript/mastodon/performance.js
@@ -14,8 +14,8 @@ if (process.env.NODE_ENV === 'development') {
   }
   marky = require('marky');
   // allows us to easily do e.g. ReactPerf.printWasted() while debugging
-  window.ReactPerf = require('react-addons-perf');
-  window.ReactPerf.start();
+  //window.ReactPerf = require('react-addons-perf');
+  //window.ReactPerf.start();
 }
 
 export function start(name) {
diff --git a/app/javascript/mastodon/reducers/accounts.js b/app/javascript/mastodon/reducers/accounts.js
index 5391a93ae..8a4d69f26 100644
--- a/app/javascript/mastodon/reducers/accounts.js
+++ b/app/javascript/mastodon/reducers/accounts.js
@@ -44,7 +44,7 @@ import {
   FAVOURITED_STATUSES_EXPAND_SUCCESS,
 } from '../actions/favourites';
 import { STORE_HYDRATE } from '../actions/store';
-import emojify from '../emoji';
+import emojify from '../features/emoji/emoji';
 import { Map as ImmutableMap, fromJS } from 'immutable';
 import escapeTextContentForBrowser from 'escape-html';
 
diff --git a/app/javascript/mastodon/reducers/compose.js b/app/javascript/mastodon/reducers/compose.js
index 5756a393f..b1d590748 100644
--- a/app/javascript/mastodon/reducers/compose.js
+++ b/app/javascript/mastodon/reducers/compose.js
@@ -23,6 +23,10 @@ import {
   COMPOSE_VISIBILITY_CHANGE,
   COMPOSE_COMPOSING_CHANGE,
   COMPOSE_EMOJI_INSERT,
+  COMPOSE_UPLOAD_CHANGE_REQUEST,
+  COMPOSE_UPLOAD_CHANGE_SUCCESS,
+  COMPOSE_UPLOAD_CHANGE_FAIL,
+  COMPOSE_RESET,
 } from '../actions/compose';
 import { TIMELINE_DELETE } from '../actions/timelines';
 import { STORE_HYDRATE } from '../actions/store';
@@ -227,6 +231,7 @@ export default function compose(state = initialState, action) {
       }
     });
   case COMPOSE_REPLY_CANCEL:
+  case COMPOSE_RESET:
     return state.withMutations(map => {
       map.set('in_reply_to', null);
       map.set('text', '');
@@ -237,15 +242,15 @@ export default function compose(state = initialState, action) {
       map.set('idempotencyKey', uuid());
     });
   case COMPOSE_SUBMIT_REQUEST:
+  case COMPOSE_UPLOAD_CHANGE_REQUEST:
     return state.set('is_submitting', true);
   case COMPOSE_SUBMIT_SUCCESS:
     return clearAll(state);
   case COMPOSE_SUBMIT_FAIL:
+  case COMPOSE_UPLOAD_CHANGE_FAIL:
     return state.set('is_submitting', false);
   case COMPOSE_UPLOAD_REQUEST:
-    return state.withMutations(map => {
-      map.set('is_uploading', true);
-    });
+    return state.set('is_uploading', true);
   case COMPOSE_UPLOAD_SUCCESS:
     return appendMedia(state, fromJS(action.media));
   case COMPOSE_UPLOAD_FAIL:
@@ -273,6 +278,16 @@ export default function compose(state = initialState, action) {
     }
   case COMPOSE_EMOJI_INSERT:
     return insertEmoji(state, action.position, action.emoji);
+  case COMPOSE_UPLOAD_CHANGE_SUCCESS:
+    return state
+      .set('is_submitting', false)
+      .update('media_attachments', list => list.map(item => {
+        if (item.get('id') === action.media.id) {
+          return item.set('description', action.media.description);
+        }
+
+        return item;
+      }));
   default:
     return state;
   }
diff --git a/app/javascript/mastodon/reducers/contexts.js b/app/javascript/mastodon/reducers/contexts.js
index 9bfc09aa7..64d584a01 100644
--- a/app/javascript/mastodon/reducers/contexts.js
+++ b/app/javascript/mastodon/reducers/contexts.js
@@ -1,6 +1,6 @@
 import { CONTEXT_FETCH_SUCCESS } from '../actions/statuses';
-import { TIMELINE_DELETE } from '../actions/timelines';
-import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable';
+import { TIMELINE_DELETE, TIMELINE_CONTEXT_UPDATE } from '../actions/timelines';
+import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
 
 const initialState = ImmutableMap({
   ancestors: ImmutableMap(),
@@ -8,8 +8,8 @@ const initialState = ImmutableMap({
 });
 
 const normalizeContext = (state, id, ancestors, descendants) => {
-  const ancestorsIds   = ancestors.map(ancestor => ancestor.get('id'));
-  const descendantsIds = descendants.map(descendant => descendant.get('id'));
+  const ancestorsIds   = ImmutableList(ancestors.map(ancestor => ancestor.id));
+  const descendantsIds = ImmutableList(descendants.map(descendant => descendant.id));
 
   return state.withMutations(map => {
     map.setIn(['ancestors', id], ancestorsIds);
@@ -31,12 +31,30 @@ const deleteFromContexts = (state, id) => {
   return state;
 };
 
+const updateContext = (state, status, references) => {
+  return state.update('descendants', map => {
+    references.forEach(parentId => {
+      map = map.update(parentId, ImmutableList(), list => {
+        if (list.includes(status.id)) {
+          return list;
+        }
+
+        return list.push(status.id);
+      });
+    });
+
+    return map;
+  });
+};
+
 export default function contexts(state = initialState, action) {
   switch(action.type) {
   case CONTEXT_FETCH_SUCCESS:
-    return normalizeContext(state, action.id, fromJS(action.ancestors), fromJS(action.descendants));
+    return normalizeContext(state, action.id, action.ancestors, action.descendants);
   case TIMELINE_DELETE:
     return deleteFromContexts(state, action.id);
+  case TIMELINE_CONTEXT_UPDATE:
+    return updateContext(state, action.status, action.references);
   default:
     return state;
   }
diff --git a/app/javascript/mastodon/reducers/custom_emojis.js b/app/javascript/mastodon/reducers/custom_emojis.js
index d80c0d156..f2a8ca5d2 100644
--- a/app/javascript/mastodon/reducers/custom_emojis.js
+++ b/app/javascript/mastodon/reducers/custom_emojis.js
@@ -1,14 +1,14 @@
 import { List as ImmutableList } from 'immutable';
 import { STORE_HYDRATE } from '../actions/store';
-import { emojiIndex } from 'emoji-mart';
-import { buildCustomEmojis } from '../emoji';
+import { search as emojiSearch } from '../features/emoji/emoji_mart_search_light';
+import { buildCustomEmojis } from '../features/emoji/emoji';
 
 const initialState = ImmutableList();
 
 export default function custom_emojis(state = initialState, action) {
   switch(action.type) {
   case STORE_HYDRATE:
-    emojiIndex.search('', { custom: buildCustomEmojis(action.state.get('custom_emojis', [])) });
+    emojiSearch('', { custom: buildCustomEmojis(action.state.get('custom_emojis', []), action.state.getIn(['meta', 'auto_play_gif'], false)) });
     return action.state.get('custom_emojis');
   default:
     return state;
diff --git a/app/javascript/mastodon/reducers/notifications.js b/app/javascript/mastodon/reducers/notifications.js
index ecce8dcb6..48850ab01 100644
--- a/app/javascript/mastodon/reducers/notifications.js
+++ b/app/javascript/mastodon/reducers/notifications.js
@@ -15,7 +15,10 @@ import {
   NOTIFICATIONS_ENTER_CLEARING_MODE,
   NOTIFICATIONS_MARK_ALL_FOR_DELETE,
 } from '../actions/notifications';
-import { ACCOUNT_BLOCK_SUCCESS } from '../actions/accounts';
+import {
+  ACCOUNT_BLOCK_SUCCESS,
+  ACCOUNT_MUTE_SUCCESS,
+} from '../actions/accounts';
 import { TIMELINE_DELETE } from '../actions/timelines';
 import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
 
@@ -151,6 +154,7 @@ export default function notifications(state = initialState, action) {
   case NOTIFICATIONS_EXPAND_SUCCESS:
     return appendNormalizedNotifications(state, action.notifications, action.next);
   case ACCOUNT_BLOCK_SUCCESS:
+  case ACCOUNT_MUTE_SUCCESS:
     return filterNotifications(state, action.relationship);
   case NOTIFICATIONS_CLEAR:
     return state.set('items', ImmutableList()).set('next', null);
diff --git a/app/javascript/mastodon/reducers/settings.js b/app/javascript/mastodon/reducers/settings.js
index 1bdee7356..0c0dae388 100644
--- a/app/javascript/mastodon/reducers/settings.js
+++ b/app/javascript/mastodon/reducers/settings.js
@@ -1,13 +1,18 @@
-import { SETTING_CHANGE } from '../actions/settings';
+import { SETTING_CHANGE, SETTING_SAVE } from '../actions/settings';
 import { COLUMN_ADD, COLUMN_REMOVE, COLUMN_MOVE } from '../actions/columns';
 import { STORE_HYDRATE } from '../actions/store';
+import { EMOJI_USE } from '../actions/emojis';
 import { Map as ImmutableMap, fromJS } from 'immutable';
 import uuid from '../uuid';
 
 const initialState = ImmutableMap({
+  saved: true,
+
   onboarded: false,
   layout: 'auto',
 
+  skinTone: 1,
+
   home: ImmutableMap({
     shows: ImmutableMap({
       reblog: true,
@@ -73,21 +78,35 @@ const moveColumn = (state, uuid, direction) => {
   newColumns = columns.splice(index, 1);
   newColumns = newColumns.splice(newIndex, 0, columns.get(index));
 
-  return state.set('columns', newColumns);
+  return state
+    .set('columns', newColumns)
+    .set('saved', false);
 };
 
+const updateFrequentEmojis = (state, emoji) => state.update('frequentlyUsedEmojis', ImmutableMap(), map => map.update(emoji.id, 0, count => count + 1)).set('saved', false);
+
 export default function settings(state = initialState, action) {
   switch(action.type) {
   case STORE_HYDRATE:
     return hydrate(state, action.state.get('settings'));
   case SETTING_CHANGE:
-    return state.setIn(action.key, action.value);
+    return state
+      .setIn(action.key, action.value)
+      .set('saved', false);
   case COLUMN_ADD:
-    return state.update('columns', list => list.push(fromJS({ id: action.id, uuid: uuid(), params: action.params })));
+    return state
+      .update('columns', list => list.push(fromJS({ id: action.id, uuid: uuid(), params: action.params })))
+      .set('saved', false);
   case COLUMN_REMOVE:
-    return state.update('columns', list => list.filterNot(item => item.get('uuid') === action.uuid));
+    return state
+      .update('columns', list => list.filterNot(item => item.get('uuid') === action.uuid))
+      .set('saved', false);
   case COLUMN_MOVE:
     return moveColumn(state, action.uuid, action.direction);
+  case EMOJI_USE:
+    return updateFrequentEmojis(state, action.emoji);
+  case SETTING_SAVE:
+    return state.set('saved', true);
   default:
     return state;
   }
diff --git a/app/javascript/mastodon/reducers/statuses.js b/app/javascript/mastodon/reducers/statuses.js
index 38b23504e..b1fb4c5da 100644
--- a/app/javascript/mastodon/reducers/statuses.js
+++ b/app/javascript/mastodon/reducers/statuses.js
@@ -24,6 +24,7 @@ import {
 } from '../actions/timelines';
 import {
   ACCOUNT_BLOCK_SUCCESS,
+  ACCOUNT_MUTE_SUCCESS,
 } from '../actions/accounts';
 import {
   NOTIFICATIONS_UPDATE,
@@ -38,7 +39,7 @@ import {
   PINNED_STATUSES_FETCH_SUCCESS,
 } from '../actions/pin_statuses';
 import { SEARCH_FETCH_SUCCESS } from '../actions/search';
-import emojify from '../emoji';
+import emojify from '../features/emoji/emoji';
 import { Map as ImmutableMap, fromJS } from 'immutable';
 import escapeTextContentForBrowser from 'escape-html';
 
@@ -57,9 +58,10 @@ const normalizeStatus = (state, status) => {
     normalStatus.reblog = status.reblog.id;
   }
 
-  const searchContent = [status.spoiler_text, status.content].join(' ').replace(/<br \/>/g, '\n').replace(/<\/p><p>/g, '\n\n');
+  const searchContent = [status.spoiler_text, status.content].join('\n\n').replace(/<br \/>/g, '\n').replace(/<\/p><p>/g, '\n\n');
+
   const emojiMap = normalStatus.emojis.reduce((obj, emoji) => {
-    obj[`:${emoji.shortcode}:`] = emoji.url;
+    obj[`:${emoji.shortcode}:`] = emoji;
     return obj;
   }, {});
 
@@ -138,6 +140,7 @@ export default function statuses(state = initialState, action) {
   case TIMELINE_DELETE:
     return deleteStatus(state, action.id, action.references);
   case ACCOUNT_BLOCK_SUCCESS:
+  case ACCOUNT_MUTE_SUCCESS:
     return filterStatuses(state, action.relationship);
   default:
     return state;
diff --git a/app/javascript/packs/about.js b/app/javascript/packs/about.js
index 6705377c1..50c81198e 100644
--- a/app/javascript/packs/about.js
+++ b/app/javascript/packs/about.js
@@ -4,9 +4,9 @@ require.context('../images/', true);
 
 function loaded() {
   const TimelineContainer = require('../mastodon/containers/timeline_container').default;
-  const React = require('react');
-  const ReactDOM = require('react-dom');
-  const mountNode = document.getElementById('mastodon-timeline');
+  const React             = require('react');
+  const ReactDOM          = require('react-dom');
+  const mountNode         = document.getElementById('mastodon-timeline');
 
   if (mountNode !== null) {
     const props = JSON.parse(mountNode.getAttribute('data-props'));
diff --git a/app/javascript/packs/public.js b/app/javascript/packs/public.js
index 8842d6dcb..59d0e98dd 100644
--- a/app/javascript/packs/public.js
+++ b/app/javascript/packs/public.js
@@ -22,7 +22,7 @@ function main() {
   const { length } = require('stringz');
   const IntlRelativeFormat = require('intl-relativeformat').default;
   const { delegate } = require('rails-ujs');
-  const emojify = require('../mastodon/emoji').default;
+  const emojify = require('../mastodon/features/emoji/emoji').default;
   const { getLocale } = require('../mastodon/locales');
   const { localeData } = getLocale();
   const VideoContainer = require('../mastodon/containers/video_container').default;
diff --git a/app/javascript/styles/about.scss b/app/javascript/styles/about.scss
index 2adcb5ba2..a15afc32c 100644
--- a/app/javascript/styles/about.scss
+++ b/app/javascript/styles/about.scss
@@ -481,6 +481,7 @@
       flex: 0 0 auto;
       background: $ui-base-color;
       overflow: hidden;
+      border-radius: 4px;
       box-shadow: 0 0 6px rgba($black, 0.1);
 
       .column-header {
@@ -703,8 +704,98 @@
     .features #mastodon-timeline {
       height: 70vh;
       width: 100%;
+      min-width: 330px;
       margin-bottom: 50px;
+
+      .column {
+        width: 100%;
+      }
+    }
+  }
+
+  .cta {
+    margin: 20px;
+  }
+
+  &.tag-page {
+    .brand {
+      padding-top: 20px;
+      margin-bottom: 20px;
+
+      img {
+        height: 48px;
+        width: auto;
+      }
+    }
+
+    .container {
+      max-width: 690px;
+    }
+
+    .cta {
+      margin: 40px 0;
+      margin-bottom: 80px;
+
+      .button {
+        margin-right: 4px;
+      }
+    }
+
+    .about-mastodon {
+      max-width: 330px;
+
+      p {
+        strong {
+          color: $ui-secondary-color;
+          font-weight: 700;
+        }
+      }
     }
+
+    @media screen and (max-width: 675px) {
+      .container {
+        display: flex;
+        flex-direction: column;
+      }
+
+      .features {
+        padding: 20px 0;
+      }
+
+      .about-mastodon {
+        order: 1;
+        flex: 0 0 auto;
+        max-width: 100%;
+      }
+
+      #mastodon-timeline {
+        order: 2;
+        flex: 0 0 auto;
+        height: 60vh;
+      }
+
+      .cta {
+        margin: 20px 0;
+        margin-bottom: 30px;
+      }
+
+      .features-list {
+        display: none;
+      }
+
+      .stripe {
+        display: none;
+      }
+    }
+  }
+
+  .stripe {
+    width: 100%;
+    height: 360px;
+    overflow: hidden;
+    background: darken($ui-base-color, 4%);
+    position: absolute;
+    z-index: -1;
   }
 }
 
diff --git a/app/javascript/styles/accounts.scss b/app/javascript/styles/accounts.scss
index 9d1e322b9..e2db9992d 100644
--- a/app/javascript/styles/accounts.scss
+++ b/app/javascript/styles/accounts.scss
@@ -69,12 +69,16 @@
     position: relative;
     z-index: 2;
     margin-bottom: 30px;
+    overflow: hidden;
+    text-overflow: ellipsis;
 
     small {
       display: block;
       font-size: 14px;
       color: $ui-highlight-color;
       font-weight: 400;
+      overflow: hidden;
+      text-overflow: ellipsis;
     }
   }
 
@@ -328,21 +332,15 @@
     color: lighten($ui-base-color, 10%);
   }
 
-  @media screen and (max-width: 360px) {
+  @media screen and (max-width: 700px) {
     padding: 30px 20px;
 
-    a,
-    .current,
-    .next,
-    .prev,
-    .gap {
+    .page {
       display: none;
     }
 
     .next,
-    .prev,
-    .next a,
-    .prev a {
+    .prev {
       display: inline-block;
     }
   }
@@ -419,6 +417,7 @@
         height: 80px;
         border-radius: 80px;
         border: 2px solid $simple-background-color;
+        background: $simple-background-color;
       }
     }
 
diff --git a/app/javascript/styles/basics.scss b/app/javascript/styles/basics.scss
index 96f0023c3..b829191ad 100644
--- a/app/javascript/styles/basics.scss
+++ b/app/javascript/styles/basics.scss
@@ -29,7 +29,8 @@ body {
     font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", mastodon-font-sans-serif, sans-serif;
   }
 
-  &.app-body {
+  &.app-body,
+  &.error {
     position: fixed;
     width: 100%;
     height: 100%;
@@ -42,6 +43,11 @@ body {
     padding-bottom: 0;
   }
 
+  &.tag-body {
+    background: darken($ui-base-color, 8%);
+    padding-bottom: 0;
+  }
+
   &.embed {
     background: transparent;
     margin: 0;
@@ -67,13 +73,16 @@ body {
     text-align: center;
     color: $ui-primary-color;
     padding: 20px;
+    display: flex;
+    justify-content: center;
+    align-items: center;
 
     .dialog img {
       display: block;
-      margin: 0 auto;
       max-width: 470px;
       width: 100%;
       height: auto;
+      margin-top: -120px;
     }
 
     .dialog h1 {
@@ -94,9 +103,12 @@ button {
 }
 
 .app-holder {
-  display: flex;
-  width: 100%;
-  height: 100%;
-  align-items: center;
-  justify-content: center;
+  &,
+  & > div {
+    display: flex;
+    width: 100%;
+    height: 100%;
+    align-items: center;
+    justify-content: center;
+  }
 }
diff --git a/app/javascript/styles/components.scss b/app/javascript/styles/components.scss
index 2f02af098..8ecc0b91b 100644
--- a/app/javascript/styles/components.scss
+++ b/app/javascript/styles/components.scss
@@ -75,6 +75,7 @@
     text-transform: none;
     background: transparent;
     padding: 3px 15px;
+    border-radius: 4px;
     border: 1px solid $ui-primary-color;
 
     &:active,
@@ -344,12 +345,57 @@
 
 .compose-form__uploads-wrapper {
   display: flex;
+  flex-direction: row;
   padding: 5px;
+  flex-wrap: wrap;
 }
 
 .compose-form__upload {
   flex: 1 1 0;
+  min-width: 40%;
   margin: 5px;
+
+  &-description {
+    position: absolute;
+    z-index: 2;
+    bottom: 0;
+    left: 0;
+    right: 0;
+    box-sizing: border-box;
+    background: linear-gradient(0deg, rgba($base-shadow-color, 0.8) 0, rgba($base-shadow-color, 0.35) 80%, transparent);
+    padding: 10px;
+    opacity: 0;
+    transition: opacity .1s ease;
+
+    input {
+      background: transparent;
+      color: $ui-secondary-color;
+      border: 0;
+      padding: 0;
+      margin: 0;
+      width: 100%;
+      font-family: inherit;
+      font-size: 14px;
+      font-weight: 500;
+
+      &:focus {
+        color: $white;
+      }
+
+      &::placeholder {
+        opacity: 0.54;
+        color: $ui-secondary-color;
+      }
+    }
+
+    &.active {
+      opacity: 1;
+    }
+  }
+
+  .icon-button {
+    mix-blend-mode: difference;
+  }
 }
 
 .compose-form__upload-thumbnail {
@@ -361,13 +407,6 @@
   width: 100%;
 }
 
-.compose-form__upload-cancel {
-  background-size: cover;
-  border-radius: 4px;
-  height: 100px;
-  width: 100px;
-}
-
 .compose-form__label {
   display: block;
   line-height: 24px;
@@ -614,6 +653,22 @@
   }
 }
 
+.focusable {
+  &:focus {
+    outline: 0;
+    background: lighten($ui-base-color, 4%);
+
+    &.status-direct {
+      background: lighten($ui-base-color, 12%);
+    }
+
+    .detailed-status,
+    .detailed-status__action-bar {
+      background: lighten($ui-base-color, 8%);
+    }
+  }
+}
+
 .status {
   padding: 8px 10px;
   position: relative;
@@ -792,6 +847,12 @@
   .status__display-name strong {
     color: $ui-base-lighter-color;
   }
+
+  > span {
+    display: block;
+    overflow: hidden;
+    text-overflow: ellipsis;
+  }
 }
 
 .status__action-bar {
@@ -978,9 +1039,12 @@
   .account__header__display-name {
     color: $primary-text-color;
     display: inline-block;
+    width: 100%;
     font-size: 20px;
     line-height: 27px;
     font-weight: 500;
+    overflow: hidden;
+    text-overflow: ellipsis;
   }
 
   .account__header__username {
@@ -989,6 +1053,8 @@
     font-weight: 400;
     display: block;
     margin-bottom: 10px;
+    overflow: hidden;
+    text-overflow: ellipsis;
   }
 }
 
@@ -1199,8 +1265,16 @@
   }
 }
 
+.muted {
+  .emojione {
+    opacity: 0.5;
+  }
+}
+
 .account__display-name strong {
   display: block;
+  overflow: hidden;
+  text-overflow: ellipsis;
 }
 
 .detailed-status__application,
@@ -1275,6 +1349,12 @@
   .fa {
     color: $ui-highlight-color;
   }
+
+  > span {
+    display: block;
+    overflow: hidden;
+    text-overflow: ellipsis;
+  }
 }
 
 .notification__favourite-icon-wrapper {
@@ -1403,11 +1483,14 @@
 .navigation-bar__profile {
   flex: 1 1 auto;
   margin-left: 8px;
+  overflow: hidden;
 }
 
 .navigation-bar__profile-account {
   display: block;
   font-weight: 500;
+  overflow: hidden;
+  text-overflow: ellipsis;
 }
 
 .navigation-bar__profile-edit {
@@ -1434,7 +1517,7 @@
   background: $ui-secondary-color;
   padding: 4px 0;
   border-radius: 4px;
-  box-shadow: 0 0 15px rgba($base-shadow-color, 0.4);
+  box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4);
 
   ul {
     list-style: none;
@@ -1851,6 +1934,16 @@
   &.optionally-scrollable {
     overflow-y: auto;
   }
+
+  @supports(display: grid) { // hack to fix Chrome <57
+    contain: strict;
+  }
+}
+
+.scrollable.fullscreen {
+  @supports(display: grid) { // hack to fix Chrome <57
+    contain: none;
+  }
 }
 
 .column-back-button {
@@ -2280,22 +2373,9 @@ button.icon-button.active i.fa-retweet {
 }
 
 .status-card-video {
-  position: relative;
-  width: 100%;
-  height: auto;
-  padding-top: 56.25%;
-
   iframe {
-    position: absolute;
-    top: 0;
-    left: 0;
-    bottom: 0;
-    right: 0;
-    width: 1px;
-    min-width: 100%;
-    height: 1px;
-    min-height: 100%;
-    margin: auto;
+    width: 100%;
+    height: 100%;
   }
 }
 
@@ -2876,19 +2956,36 @@ button.icon-button.active i.fa-retweet {
   flex-direction: column;
 }
 
-@keyframes pulse {
-  0% {
-    opacity: 1;
+@keyframes heartbeat {
+  from {
+    transform: scale(1);
+    transform-origin: center center;
+    animation-timing-function: ease-out;
   }
 
-  100% {
-    opacity: 0.5;
+  10% {
+    transform: scale(0.91);
+    animation-timing-function: ease-in;
+  }
+
+  17% {
+    transform: scale(0.98);
+    animation-timing-function: ease-out;
+  }
+
+  33% {
+    transform: scale(0.87);
+    animation-timing-function: ease-in;
+  }
+
+  45% {
+    transform: scale(1);
+    animation-timing-function: ease-out;
   }
 }
 
 .pulse-loading {
-  animation: pulse 1s ease-in-out infinite;
-  animation-direction: alternate;
+  animation: heartbeat 1.5s ease-in-out infinite both;
 }
 
 .emoji-picker-dropdown__menu {
@@ -3081,19 +3178,12 @@ button.icon-button.active i.fa-retweet {
   filter: none;
 }
 
-.privacy-dropdown {
-  position: relative;
-}
-
 .privacy-dropdown__dropdown {
-  display: none;
   position: absolute;
-  left: 0;
-  top: 27px;
-  width: 230px;
   background: $simple-background-color;
-  border-radius: 0 4px 4px;
-  z-index: 2;
+  box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4);
+  border-radius: 4px;
+  margin-left: 40px;
   overflow: hidden;
 }
 
@@ -3145,6 +3235,18 @@ button.icon-button.active i.fa-retweet {
     background: $simple-background-color;
     border-radius: 4px 4px 0 0;
     box-shadow: 0 -4px 4px rgba($base-shadow-color, 0.1);
+
+    .icon-button {
+      transition: none;
+    }
+
+    &.active {
+      background: $ui-highlight-color;
+
+      .icon-button {
+        color: $primary-text-color;
+      }
+    }
   }
 
   .privacy-dropdown__dropdown {
@@ -3337,14 +3439,18 @@ button.icon-button.active i.fa-retweet {
   }
 }
 
+.modal-root {
+  transition: opacity 0.3s linear;
+  will-change: opacity;
+  z-index: 9999;
+}
+
 .modal-root__overlay {
   position: absolute;
   top: 0;
   left: 0;
   right: 0;
   bottom: 0;
-  z-index: 9999;
-  opacity: 0;
   background: rgba($base-overlay-background, 0.7);
   transform: translateZ(0);
 }
@@ -3361,7 +3467,6 @@ button.icon-button.active i.fa-retweet {
   justify-content: center;
   align-content: space-around;
   z-index: 9999;
-  opacity: 0;
   pointer-events: none;
   user-select: none;
 }
@@ -3411,6 +3516,33 @@ button.icon-button.active i.fa-retweet {
   background: $base-overlay-background;
 }
 
+.media-modal__pagination {
+  width: 100%;
+  text-align: center;
+  position: absolute;
+  left: 0;
+  bottom: -40px;
+}
+
+.media-modal__page-dot {
+  display: inline-block;
+}
+
+.media-modal__button {
+  background-color: $white;
+  height: 12px;
+  width: 12px;
+  border-radius: 6px;
+  margin: 10px;
+  padding: 0;
+  border: 0;
+  font-size: 0;
+}
+
+.media-modal__button--active {
+  background-color: $ui-highlight-color;
+}
+
 .media-modal__close {
   position: absolute;
   right: 4px;
@@ -4293,7 +4425,8 @@ button.icon-button.active i.fa-retweet {
       top: 10px;
     }
 
-    &__progress {
+    &__progress,
+    &__buffer {
       display: block;
       position: absolute;
       height: 4px;
@@ -4301,6 +4434,10 @@ button.icon-button.active i.fa-retweet {
       background: $ui-highlight-color;
     }
 
+    &__buffer {
+      background: rgba($white, 0.2);
+    }
+
     &__handle {
       position: absolute;
       z-index: 3;
@@ -4420,6 +4557,37 @@ button.icon-button.active i.fa-retweet {
   border-radius: 0;
 }
 
+.search-popout {
+  background: $simple-background-color;
+  border-radius: 4px;
+  padding: 10px 14px;
+  padding-bottom: 14px;
+  margin-top: 10px;
+  color: $ui-primary-color;
+  box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4);
+
+  h4 {
+    text-transform: uppercase;
+    color: $ui-primary-color;
+    font-size: 13px;
+    font-weight: 500;
+    margin-bottom: 10px;
+  }
+
+  li {
+    padding: 4px 0;
+  }
+
+  ul {
+    margin-bottom: 10px;
+  }
+
+  em {
+    font-weight: 500;
+    color: $ui-base-color;
+  }
+}
+
 noscript {
   text-align: center;
 
diff --git a/app/javascript/styles/forms.scss b/app/javascript/styles/forms.scss
index 0526f174c..61fcf286f 100644
--- a/app/javascript/styles/forms.scss
+++ b/app/javascript/styles/forms.scss
@@ -22,6 +22,16 @@ code {
     margin-top: 4px;
   }
 
+  h4 {
+    text-transform: uppercase;
+    font-size: 13px;
+    font-weight: 500;
+    color: $ui-primary-color;
+    padding-bottom: 8px;
+    margin-bottom: 8px;
+    border-bottom: 1px solid lighten($ui-base-color, 8%);
+  }
+
   p.hint {
     margin-bottom: 15px;
     color: $ui-primary-color;
@@ -316,6 +326,7 @@ code {
 
   select {
     font-size: 16px;
+    max-height: 29px;
   }
 
   .input-with-append {
@@ -504,6 +515,7 @@ code {
 
 .action-pagination {
   display: flex;
+  flex-wrap: wrap;
   align-items: center;
 
   .actions,
diff --git a/app/javascript/styles/rtl.scss b/app/javascript/styles/rtl.scss
index 0fdeccd9c..67bfa8a38 100644
--- a/app/javascript/styles/rtl.scss
+++ b/app/javascript/styles/rtl.scss
@@ -128,22 +128,8 @@ body.rtl {
   }
 
   .privacy-dropdown__dropdown {
-    left: auto;
-    right: 0;
-  }
-
-  .dropdown--active .dropdown__content {
-    text-align: right;
-  }
-
-  .dropdown--active .dropdown__content::before {
-    left: auto;
-    right: 8px;
-  }
-
-  .dropdown--active .dropdown__content > ul {
-    left: auto;
-    right: -10px;
+    margin-left: 0;
+    margin-right: 40px;
   }
 
   .privacy-dropdown__option__icon {
diff --git a/app/lib/activitypub/activity.rb b/app/lib/activitypub/activity.rb
index b06dd6194..9688f57a6 100644
--- a/app/lib/activitypub/activity.rb
+++ b/app/lib/activitypub/activity.rb
@@ -3,10 +3,11 @@
 class ActivityPub::Activity
   include JsonLdHelper
 
-  def initialize(json, account)
+  def initialize(json, account, options = {})
     @json    = json
     @account = account
     @object  = @json['object']
+    @options = options
   end
 
   def perform
@@ -14,9 +15,9 @@ class ActivityPub::Activity
   end
 
   class << self
-    def factory(json, account)
+    def factory(json, account, options = {})
       @json = json
-      klass&.new(json, account)
+      klass&.new(json, account, options)
     end
 
     private
diff --git a/app/lib/activitypub/activity/announce.rb b/app/lib/activitypub/activity/announce.rb
index 4516454e1..b84098933 100644
--- a/app/lib/activitypub/activity/announce.rb
+++ b/app/lib/activitypub/activity/announce.rb
@@ -15,8 +15,9 @@ class ActivityPub::Activity::Announce < ActivityPub::Activity
       account: @account,
       reblog: original_status,
       uri: @json['id'],
-      created_at: @json['published'] || Time.now.utc
+      created_at: @options[:override_timestamps] ? nil : @json['published']
     )
+
     distribute(status)
     status
   end
@@ -27,7 +28,7 @@ class ActivityPub::Activity::Announce < ActivityPub::Activity
     if object_uri.start_with?('http')
       return if ActivityPub::TagManager.instance.local_uri?(object_uri)
 
-      ActivityPub::FetchRemoteStatusService.new.call(object_uri)
+      ActivityPub::FetchRemoteStatusService.new.call(object_uri, id: true)
     elsif @object['url'].present?
       ::FetchRemoteStatusService.new.call(@object['url'])
     end
diff --git a/app/lib/activitypub/activity/create.rb b/app/lib/activitypub/activity/create.rb
index 4e19b3096..d6e9bc1de 100644
--- a/app/lib/activitypub/activity/create.rb
+++ b/app/lib/activitypub/activity/create.rb
@@ -43,7 +43,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
       text: text_from_content || '',
       language: language_from_content,
       spoiler_text: @object['summary'] || '',
-      created_at: @object['published'] || Time.now.utc,
+      created_at: @options[:override_timestamps] ? nil : @object['published'],
       reply: @object['inReplyTo'].present?,
       sensitive: @object['sensitive'] || false,
       visibility: visibility_from_audience,
@@ -80,21 +80,25 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
     return if tag['href'].blank?
 
     account = account_from_uri(tag['href'])
-    account = FetchRemoteAccountService.new.call(tag['href']) if account.nil?
+    account = FetchRemoteAccountService.new.call(tag['href'], id: false) if account.nil?
     return if account.nil?
     account.mentions.create(status: status)
   end
 
   def process_emoji(tag, _status)
-    return if tag['name'].blank? || tag['href'].blank?
+    return if skip_download?
+    return if tag['name'].blank? || tag['icon'].blank? || tag['icon']['url'].blank?
 
     shortcode = tag['name'].delete(':')
+    image_url = tag['icon']['url']
+    uri       = tag['id']
+    updated   = tag['updated']
     emoji     = CustomEmoji.find_by(shortcode: shortcode, domain: @account.domain)
 
-    return if !emoji.nil? || skip_download?
+    return unless emoji.nil? || emoji.updated_at >= updated
 
-    emoji = CustomEmoji.new(domain: @account.domain, shortcode: shortcode)
-    emoji.image_remote_url = tag['href']
+    emoji ||= CustomEmoji.new(domain: @account.domain, shortcode: shortcode, uri: uri)
+    emoji.image_remote_url = image_url
     emoji.save
   end
 
@@ -105,7 +109,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
       next if unsupported_media_type?(attachment['mediaType']) || attachment['url'].blank?
 
       href             = Addressable::URI.parse(attachment['url']).normalize.to_s
-      media_attachment = MediaAttachment.create(status: status, account: status.account, remote_url: href)
+      media_attachment = MediaAttachment.create(status: status, account: status.account, remote_url: href, description: attachment['name'].presence)
 
       next if skip_download?
 
diff --git a/app/lib/activitypub/linked_data_signature.rb b/app/lib/activitypub/linked_data_signature.rb
index adb8b6cdf..16142a6ff 100644
--- a/app/lib/activitypub/linked_data_signature.rb
+++ b/app/lib/activitypub/linked_data_signature.rb
@@ -19,7 +19,7 @@ class ActivityPub::LinkedDataSignature
     return unless type == 'RsaSignature2017'
 
     creator   = ActivityPub::TagManager.instance.uri_to_resource(creator_uri, Account)
-    creator ||= ActivityPub::FetchRemoteKeyService.new.call(creator_uri)
+    creator ||= ActivityPub::FetchRemoteKeyService.new.call(creator_uri, id: false)
 
     return if creator.nil?
 
diff --git a/app/lib/activitypub/tag_manager.rb b/app/lib/activitypub/tag_manager.rb
index 4ec3b8c56..0708713e6 100644
--- a/app/lib/activitypub/tag_manager.rb
+++ b/app/lib/activitypub/tag_manager.rb
@@ -33,6 +33,8 @@ class ActivityPub::TagManager
     when :note, :comment, :activity
       return activity_account_status_url(target.account, target) if target.reblog?
       account_status_url(target.account, target)
+    when :emoji
+      emoji_url(target)
     end
   end
 
diff --git a/app/lib/delivery_failure_tracker.rb b/app/lib/delivery_failure_tracker.rb
new file mode 100644
index 000000000..8d3be35de
--- /dev/null
+++ b/app/lib/delivery_failure_tracker.rb
@@ -0,0 +1,56 @@
+# frozen_string_literal: true
+
+class DeliveryFailureTracker
+  FAILURE_DAYS_THRESHOLD = 7
+
+  def initialize(inbox_url)
+    @inbox_url = inbox_url
+  end
+
+  def track_failure!
+    Redis.current.sadd(exhausted_deliveries_key, today)
+    Redis.current.sadd('unavailable_inboxes', @inbox_url) if reached_failure_threshold?
+  end
+
+  def track_success!
+    Redis.current.del(exhausted_deliveries_key)
+    Redis.current.srem('unavailable_inboxes', @inbox_url)
+  end
+
+  def days
+    Redis.current.scard(exhausted_deliveries_key) || 0
+  end
+
+  class << self
+    def filter(arr)
+      arr.reject(&method(:unavailable?))
+    end
+
+    def unavailable?(url)
+      Redis.current.sismember('unavailable_inboxes', url)
+    end
+
+    def available?(url)
+      !unavailable?(url)
+    end
+
+    def track_inverse_success!(from_account)
+      new(from_account.inbox_url).track_success! if from_account.inbox_url.present?
+      new(from_account.shared_inbox_url).track_success! if from_account.shared_inbox_url.present?
+    end
+  end
+
+  private
+
+  def exhausted_deliveries_key
+    "exhausted_deliveries:#{@inbox_url}"
+  end
+
+  def today
+    Time.now.utc.strftime('%Y%m%d')
+  end
+
+  def reached_failure_threshold?
+    days >= FAILURE_DAYS_THRESHOLD
+  end
+end
diff --git a/app/lib/feed_manager.rb b/app/lib/feed_manager.rb
index 3b6796142..f6a694135 100644
--- a/app/lib/feed_manager.rb
+++ b/app/lib/feed_manager.rb
@@ -7,8 +7,13 @@ class FeedManager
 
   MAX_ITEMS = 400
 
-  def key(type, id)
-    "feed:#{type}:#{id}"
+  # Must be <= MAX_ITEMS or the tracking sets will grow forever
+  REBLOG_FALLOFF = 40
+
+  def key(type, id, subtype = nil)
+    return "feed:#{type}:#{id}" unless subtype
+
+    "feed:#{type}:#{id}:#{subtype}"
   end
 
   def filter?(timeline_type, status, receiver_id)
@@ -22,23 +27,36 @@ class FeedManager
   end
 
   def push(timeline_type, account, status)
-    timeline_key = key(timeline_type, account.id)
+    return false unless add_to_feed(timeline_type, account, status)
 
-    if status.reblog?
-      # If the original status is within 40 statuses from top, do not re-insert it into the feed
-      rank = redis.zrevrank(timeline_key, status.reblog_of_id)
-      return if !rank.nil? && rank < 40
-      redis.zadd(timeline_key, status.id, status.reblog_of_id)
-    else
-      redis.zadd(timeline_key, status.id, status.id)
-      trim(timeline_type, account.id)
-    end
+    trim(timeline_type, account.id)
 
     PushUpdateWorker.perform_async(account.id, status.id) if push_update_required?(timeline_type, account.id)
+
+    true
+  end
+
+  def unpush(timeline_type, account, status)
+    return false unless remove_from_feed(timeline_type, account, status)
+
+    payload = Oj.dump(event: :delete, payload: status.id.to_s)
+    Redis.current.publish("timeline:#{account.id}", payload)
+
+    true
   end
 
   def trim(type, account_id)
-    redis.zremrangebyrank(key(type, account_id), '0', (-(FeedManager::MAX_ITEMS + 1)).to_s)
+    timeline_key = key(type, account_id)
+    reblog_key = key(type, account_id, 'reblogs')
+    # Remove any items past the MAX_ITEMS'th entry in our feed
+    redis.zremrangebyrank(timeline_key, '0', (-(FeedManager::MAX_ITEMS + 1)).to_s)
+
+    # Get the score of the REBLOG_FALLOFF'th item in our feed, and stop
+    # tracking anything after it for deduplication purposes.
+    falloff_rank = FeedManager::REBLOG_FALLOFF - 1
+    falloff_range = redis.zrevrange(timeline_key, falloff_rank, falloff_rank, with_scores: true)
+    falloff_score = falloff_range&.first&.last&.to_i || 0
+    redis.zremrangebyscore(reblog_key, 0, falloff_score)
   end
 
   def push_update_required?(timeline_type, account_id)
@@ -54,11 +72,9 @@ class FeedManager
       query = query.where('id > ?', oldest_home_score)
     end
 
-    redis.pipelined do
-      query.each do |status|
-        next if status.direct_visibility? || filter?(:home, status, into_account)
-        redis.zadd(timeline_key, status.id, status.id)
-      end
+    query.each do |status|
+      next if status.direct_visibility? || filter?(:home, status, into_account)
+      add_to_feed(:home, into_account, status)
     end
 
     trim(:home, into_account.id)
@@ -68,22 +84,28 @@ class FeedManager
     timeline_key = key(:home, into_account.id)
     oldest_home_score = redis.zrange(timeline_key, 0, 0, with_scores: true)&.first&.last&.to_i || 0
 
-    from_account.statuses.select('id').where('id > ?', oldest_home_score).reorder(nil).find_in_batches do |statuses|
-      redis.pipelined do
-        statuses.each do |status|
-          redis.zrem(timeline_key, status.id)
-          redis.zremrangebyscore(timeline_key, status.id, status.id)
-        end
-      end
+    from_account.statuses.select('id, reblog_of_id').where('id > ?', oldest_home_score).reorder(nil).find_each do |status|
+      unpush(:home, into_account, status)
     end
   end
 
   def clear_from_timeline(account, target_account)
     timeline_key = key(:home, account.id)
     timeline_status_ids = redis.zrange(timeline_key, 0, -1)
-    target_status_ids = Status.where(id: timeline_status_ids, account: target_account).ids
+    target_statuses = Status.where(id: timeline_status_ids, account: target_account)
+
+    target_statuses.each do |status|
+      unpush(:home, account, status)
+    end
+  end
 
-    redis.zrem(timeline_key, target_status_ids) if target_status_ids.present?
+  def populate_feed(account)
+    prepopulate_limit = FeedManager::MAX_ITEMS / 4
+    statuses = Status.as_home_timeline(account).order(account_id: :desc).limit(prepopulate_limit)
+    statuses.reverse_each do |status|
+      next if filter_from_home?(status, account)
+      add_to_feed(:home, account, status)
+    end
   end
 
   private
@@ -137,4 +159,58 @@ class FeedManager
 
     should_filter
   end
+
+  # Adds a status to an account's feed, returning true if a status was
+  # added, and false if it was not added to the feed. Note that this is
+  # an internal helper: callers must call trim or push updates if
+  # either action is appropriate.
+  def add_to_feed(timeline_type, account, status)
+    timeline_key = key(timeline_type, account.id)
+    reblog_key = key(timeline_type, account.id, 'reblogs')
+
+    if status.reblog?
+      # If the original status or a reblog of it is within
+      # REBLOG_FALLOFF statuses from the top, do not re-insert it into
+      # the feed
+      rank = redis.zrevrank(timeline_key, status.reblog_of_id)
+      return false if !rank.nil? && rank < FeedManager::REBLOG_FALLOFF
+
+      reblog_rank = redis.zrevrank(reblog_key, status.reblog_of_id)
+      return false unless reblog_rank.nil?
+
+      redis.zadd(timeline_key, status.id, status.id)
+      redis.zadd(reblog_key, status.id, status.reblog_of_id)
+    else
+      redis.zadd(timeline_key, status.id, status.id)
+    end
+
+    true
+  end
+
+  # Removes an individual status from a feed, correctly handling cases
+  # with reblogs, and returning true if a status was removed. As with
+  # `add_to_feed`, this does not trigger push updates, so callers must
+  # do so if appropriate.
+  def remove_from_feed(timeline_type, account, status)
+    timeline_key = key(timeline_type, account.id)
+    reblog_key = key(timeline_type, account.id, 'reblogs')
+
+    if status.reblog?
+      # 1. If the reblogging status is not in the feed, stop.
+      status_rank = redis.zrevrank(timeline_key, status.id)
+      return false if status_rank.nil?
+
+      # 2. Remove the reblogged status from the `:reblogs` zset.
+      redis.zrem(reblog_key, status.reblog_of_id)
+
+      # 3. Add the reblogged status to the feed using the reblogging
+      # status' ID as its score, and the reblogged status' ID as its
+      # value.
+      redis.zadd(timeline_key, status.id, status.reblog_of_id)
+
+      # 4. Remove the reblogging status from the feed (as normal)
+    end
+
+    redis.zrem(timeline_key, status.id)
+  end
 end
diff --git a/app/lib/formatter.rb b/app/lib/formatter.rb
index 42cd72990..57f105da7 100644
--- a/app/lib/formatter.rb
+++ b/app/lib/formatter.rb
@@ -50,7 +50,7 @@ class Formatter
   end
 
   def simplified_format(account)
-    return reformat(account.note) unless account.local?
+    return reformat(account.note).html_safe unless account.local? # rubocop:disable Rails/OutputSafety
 
     html = encode_and_link_urls(account.note)
     html = simple_format(html, {}, sanitize: false)
@@ -92,7 +92,7 @@ class Formatter
   def encode_custom_emojis(html, emojis)
     return html if emojis.empty?
 
-    emoji_map = emojis.map { |e| [e.shortcode, full_asset_url(e.image.url)] }.to_h
+    emoji_map = emojis.map { |e| [e.shortcode, full_asset_url(e.image.url(:static))] }.to_h
 
     i                     = -1
     inside_tag            = false
diff --git a/app/lib/ostatus/activity/base.rb b/app/lib/ostatus/activity/base.rb
index 039381397..8b27b124f 100644
--- a/app/lib/ostatus/activity/base.rb
+++ b/app/lib/ostatus/activity/base.rb
@@ -1,9 +1,10 @@
 # frozen_string_literal: true
 
 class OStatus::Activity::Base
-  def initialize(xml, account = nil)
-    @xml = xml
+  def initialize(xml, account = nil, options = {})
+    @xml     = xml
     @account = account
+    @options = options
   end
 
   def status?
diff --git a/app/lib/ostatus/activity/creation.rb b/app/lib/ostatus/activity/creation.rb
index 2687776f9..a1ab522e2 100644
--- a/app/lib/ostatus/activity/creation.rb
+++ b/app/lib/ostatus/activity/creation.rb
@@ -9,11 +9,6 @@ class OStatus::Activity::Creation < OStatus::Activity::Base
 
     return [nil, false] if @account.suspended?
 
-    if activitypub_uri? && [:public, :unlisted].include?(visibility_scope)
-      result = perform_via_activitypub
-      return result if result.first.present?
-    end
-
     RedisLock.acquire(lock_options) do |lock|
       if lock.acquired?
         # Return early if status already exists in db
@@ -39,7 +34,7 @@ class OStatus::Activity::Creation < OStatus::Activity::Base
         reblog: cached_reblog,
         text: content,
         spoiler_text: content_warning,
-        created_at: published,
+        created_at: @options[:override_timestamps] ? nil : published,
         reply: thread?,
         language: content_language,
         visibility: visibility_scope,
@@ -66,10 +61,6 @@ class OStatus::Activity::Creation < OStatus::Activity::Base
     status
   end
 
-  def perform_via_activitypub
-    [find_status(activitypub_uri) || ActivityPub::FetchRemoteStatusService.new.call(activitypub_uri), false]
-  end
-
   def content
     @xml.at_xpath('./xmlns:content', xmlns: OStatus::TagManager::XMLNS).content
   end
diff --git a/app/lib/ostatus/activity/general.rb b/app/lib/ostatus/activity/general.rb
index b3bef9861..8a6aabc33 100644
--- a/app/lib/ostatus/activity/general.rb
+++ b/app/lib/ostatus/activity/general.rb
@@ -2,7 +2,7 @@
 
 class OStatus::Activity::General < OStatus::Activity::Base
   def specialize
-    special_class&.new(@xml, @account)
+    special_class&.new(@xml, @account, @options)
   end
 
   private
diff --git a/app/lib/request.rb b/app/lib/request.rb
index b083edaf7..30ea0e7ee 100644
--- a/app/lib/request.rb
+++ b/app/lib/request.rb
@@ -32,7 +32,7 @@ class Request
   def perform
     http_client.headers(headers).public_send(@verb, @url.to_s, @options)
   rescue => e
-    raise e.class, "#{e.message} on #{@url}"
+    raise e.class, "#{e.message} on #{@url}", e.backtrace[0]
   end
 
   def headers
@@ -85,6 +85,6 @@ class Request
   end
 
   def http_client
-    HTTP.timeout(:per_operation, timeout).follow
+    HTTP.timeout(:per_operation, timeout).follow(max_hops: 2)
   end
 end
diff --git a/app/lib/user_settings_decorator.rb b/app/lib/user_settings_decorator.rb
index 3b156b98c..3b7a856ee 100644
--- a/app/lib/user_settings_decorator.rb
+++ b/app/lib/user_settings_decorator.rb
@@ -15,17 +15,17 @@ class UserSettingsDecorator
   private
 
   def process_update
-    user.settings['notification_emails'] = merged_notification_emails
-    user.settings['interactions'] = merged_interactions
-    user.settings['default_privacy'] = default_privacy_preference
-    user.settings['default_sensitive'] = default_sensitive_preference
-    user.settings['unfollow_modal'] = unfollow_modal_preference
-    user.settings['boost_modal'] = boost_modal_preference
-    user.settings['delete_modal'] = delete_modal_preference
-    user.settings['auto_play_gif'] = auto_play_gif_preference
-    user.settings['system_font_ui'] = system_font_ui_preference
-    user.settings['noindex'] = noindex_preference
-    user.settings['theme'] = theme_preference
+    user.settings['notification_emails'] = merged_notification_emails if change?('notification_emails')
+    user.settings['interactions']        = merged_interactions if change?('interactions')
+    user.settings['default_privacy']     = default_privacy_preference if change?('setting_default_privacy')
+    user.settings['default_sensitive']   = default_sensitive_preference if change?('setting_default_sensitive')
+    user.settings['unfollow_modal']      = unfollow_modal_preference if change?('setting_unfollow_modal')
+    user.settings['boost_modal']         = boost_modal_preference if change?('setting_boost_modal')
+    user.settings['delete_modal']        = delete_modal_preference if change?('setting_delete_modal')
+    user.settings['auto_play_gif']       = auto_play_gif_preference if change?('setting_auto_play_gif')
+    user.settings['system_font_ui']      = system_font_ui_preference if change?('setting_system_font_ui')
+    user.settings['noindex']             = noindex_preference if change?('setting_noindex')
+    user.settings['theme']               = theme_preference if change?('setting_theme')
   end
 
   def merged_notification_emails
@@ -83,4 +83,8 @@ class UserSettingsDecorator
   def coerce_values(params_hash)
     params_hash.transform_values { |x| x == '1' }
   end
+
+  def change?(key)
+    !settings[key].nil?
+  end
 end
diff --git a/app/mailers/user_mailer.rb b/app/mailers/user_mailer.rb
index 1517c027e..c475a9911 100644
--- a/app/mailers/user_mailer.rb
+++ b/app/mailers/user_mailer.rb
@@ -18,6 +18,7 @@ class UserMailer < Devise::Mailer
   def reset_password_instructions(user, token, _opts = {})
     @resource = user
     @token    = token
+    @instance = Rails.configuration.x.local_domain
 
     I18n.with_locale(@resource.locale || I18n.default_locale) do
       mail to: @resource.email, subject: I18n.t('devise.mailer.reset_password_instructions.subject')
@@ -26,6 +27,7 @@ class UserMailer < Devise::Mailer
 
   def password_change(user, _opts = {})
     @resource = user
+    @instance = Rails.configuration.x.local_domain
 
     I18n.with_locale(@resource.locale || I18n.default_locale) do
       mail to: @resource.email, subject: I18n.t('devise.mailer.password_change.subject')
diff --git a/app/models/account.rb b/app/models/account.rb
index de7998db4..85684c259 100644
--- a/app/models/account.rb
+++ b/app/models/account.rb
@@ -92,6 +92,10 @@ class Account < ApplicationRecord
   has_many :reports
   has_many :targeted_reports, class_name: 'Report', foreign_key: :target_account_id
 
+  # Moderation notes
+  has_many :account_moderation_notes, dependent: :destroy
+  has_many :targeted_moderation_notes, class_name: 'AccountModerationNote', foreign_key: :target_account_id, dependent: :destroy
+
   scope :remote, -> { where.not(domain: nil) }
   scope :local, -> { where(domain: nil) }
   scope :without_followers, -> { where(followers_count: 0) }
@@ -139,6 +143,15 @@ class Account < ApplicationRecord
     subscription_expires_at.present?
   end
 
+  def possibly_stale?
+    last_webfingered_at.nil? || last_webfingered_at <= 1.day.ago
+  end
+
+  def refresh!
+    return if local?
+    ResolveRemoteAccountService.new.call(acct)
+  end
+
   def keypair
     @keypair ||= OpenSSL::PKey::RSA.new(private_key || public_key)
   end
@@ -183,7 +196,8 @@ class Account < ApplicationRecord
     end
 
     def inboxes
-      reorder(nil).where(protocol: :activitypub).pluck("distinct coalesce(nullif(accounts.shared_inbox_url, ''), accounts.inbox_url)")
+      urls = reorder(nil).where(protocol: :activitypub).pluck("distinct coalesce(nullif(accounts.shared_inbox_url, ''), accounts.inbox_url)")
+      DeliveryFailureTracker.filter(urls)
     end
 
     def triadic_closures(account, limit: 5, offset: 0)
diff --git a/app/models/account_domain_block.rb b/app/models/account_domain_block.rb
index bdd64c01a..fb695e473 100644
--- a/app/models/account_domain_block.rb
+++ b/app/models/account_domain_block.rb
@@ -3,11 +3,11 @@
 #
 # Table name: account_domain_blocks
 #
-#  id         :integer          not null, primary key
-#  account_id :integer
 #  domain     :string
 #  created_at :datetime         not null
 #  updated_at :datetime         not null
+#  account_id :integer
+#  id         :integer          not null, primary key
 #
 
 class AccountDomainBlock < ApplicationRecord
diff --git a/app/models/account_filter.rb b/app/models/account_filter.rb
index 1a8cc5192..189872368 100644
--- a/app/models/account_filter.rb
+++ b/app/models/account_filter.rb
@@ -9,9 +9,11 @@ class AccountFilter
 
   def results
     scope = Account.alphabetic
+
     params.each do |key, value|
       scope.merge!(scope_for(key, value)) if value.present?
     end
+
     scope
   end
 
diff --git a/app/models/account_moderation_note.rb b/app/models/account_moderation_note.rb
new file mode 100644
index 000000000..3ac9b1ac1
--- /dev/null
+++ b/app/models/account_moderation_note.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+# == Schema Information
+#
+# Table name: account_moderation_notes
+#
+#  id                :integer          not null, primary key
+#  content           :text             not null
+#  account_id        :integer          not null
+#  target_account_id :integer          not null
+#  created_at        :datetime         not null
+#  updated_at        :datetime         not null
+#
+
+class AccountModerationNote < ApplicationRecord
+  belongs_to :account
+  belongs_to :target_account, class_name: 'Account'
+
+  scope :latest, -> { reorder('created_at DESC') }
+
+  validates :content, presence: true, length: { maximum: 500 }
+end
diff --git a/app/models/block.rb b/app/models/block.rb
index edb0d2d11..a913782ed 100644
--- a/app/models/block.rb
+++ b/app/models/block.rb
@@ -3,11 +3,11 @@
 #
 # Table name: blocks
 #
-#  id                :integer          not null, primary key
-#  account_id        :integer          not null
-#  target_account_id :integer          not null
 #  created_at        :datetime         not null
 #  updated_at        :datetime         not null
+#  account_id        :integer          not null
+#  id                :integer          not null, primary key
+#  target_account_id :integer          not null
 #
 
 class Block < ApplicationRecord
diff --git a/app/models/conversation_mute.rb b/app/models/conversation_mute.rb
index 79299b995..8d2399adf 100644
--- a/app/models/conversation_mute.rb
+++ b/app/models/conversation_mute.rb
@@ -3,9 +3,9 @@
 #
 # Table name: conversation_mutes
 #
-#  id              :integer          not null, primary key
-#  account_id      :integer          not null
 #  conversation_id :integer          not null
+#  account_id      :integer          not null
+#  id              :integer          not null, primary key
 #
 
 class ConversationMute < ApplicationRecord
diff --git a/app/models/custom_emoji.rb b/app/models/custom_emoji.rb
index e80c58155..65d9840d5 100644
--- a/app/models/custom_emoji.rb
+++ b/app/models/custom_emoji.rb
@@ -12,6 +12,9 @@
 #  image_updated_at   :datetime
 #  created_at         :datetime         not null
 #  updated_at         :datetime         not null
+#  disabled           :boolean          default(FALSE), not null
+#  uri                :string
+#  image_remote_url   :string
 #
 
 class CustomEmoji < ApplicationRecord
@@ -21,15 +24,25 @@ class CustomEmoji < ApplicationRecord
     :(#{SHORTCODE_RE_FRAGMENT}):
     (?=[^[:alnum:]:]|$)/x
 
-  has_attached_file :image
+  has_attached_file :image, styles: { static: { format: 'png', convert_options: '-coalesce -strip' } }
 
   validates_attachment :image, content_type: { content_type: 'image/png' }, presence: true, size: { in: 0..50.kilobytes }
   validates :shortcode, uniqueness: { scope: :domain }, format: { with: /\A#{SHORTCODE_RE_FRAGMENT}\z/ }, length: { minimum: 2 }
 
-  scope :local, -> { where(domain: nil) }
+  scope :local,      -> { where(domain: nil) }
+  scope :remote,     -> { where.not(domain: nil) }
+  scope :alphabetic, -> { order(domain: :asc, shortcode: :asc) }
 
   include Remotable
 
+  def local?
+    domain.nil?
+  end
+
+  def object_type
+    :emoji
+  end
+
   class << self
     def from_text(text, domain)
       return [] if text.blank?
@@ -38,7 +51,7 @@ class CustomEmoji < ApplicationRecord
 
       return [] if shortcodes.empty?
 
-      where(shortcode: shortcodes, domain: domain)
+      where(shortcode: shortcodes, domain: domain, disabled: false)
     end
   end
 end
diff --git a/app/models/custom_emoji_filter.rb b/app/models/custom_emoji_filter.rb
new file mode 100644
index 000000000..2d1394a59
--- /dev/null
+++ b/app/models/custom_emoji_filter.rb
@@ -0,0 +1,34 @@
+# frozen_string_literal: true
+
+class CustomEmojiFilter
+  attr_reader :params
+
+  def initialize(params)
+    @params = params
+  end
+
+  def results
+    scope = CustomEmoji.alphabetic
+
+    params.each do |key, value|
+      scope.merge!(scope_for(key, value)) if value.present?
+    end
+
+    scope
+  end
+
+  private
+
+  def scope_for(key, value)
+    case key.to_s
+    when 'local'
+      CustomEmoji.local
+    when 'remote'
+      CustomEmoji.remote
+    when 'by_domain'
+      CustomEmoji.where(domain: value)
+    else
+      raise "Unknown filter: #{key}"
+    end
+  end
+end
diff --git a/app/models/domain_block.rb b/app/models/domain_block.rb
index aea8919af..1268290bc 100644
--- a/app/models/domain_block.rb
+++ b/app/models/domain_block.rb
@@ -3,12 +3,12 @@
 #
 # Table name: domain_blocks
 #
-#  id           :integer          not null, primary key
 #  domain       :string           default(""), not null
 #  created_at   :datetime         not null
 #  updated_at   :datetime         not null
 #  severity     :integer          default("silence")
 #  reject_media :boolean          default(FALSE), not null
+#  id           :integer          not null, primary key
 #
 
 class DomainBlock < ApplicationRecord
diff --git a/app/models/email_domain_block.rb b/app/models/email_domain_block.rb
new file mode 100644
index 000000000..839038bea
--- /dev/null
+++ b/app/models/email_domain_block.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+# == Schema Information
+#
+# Table name: email_domain_blocks
+#
+#  id         :integer          not null, primary key
+#  domain     :string           not null
+#  created_at :datetime         not null
+#  updated_at :datetime         not null
+#
+
+class EmailDomainBlock < ApplicationRecord
+  def self.block?(email)
+    domain = email.gsub(/.+@([^.]+)/, '\1')
+    where(domain: domain).exists?
+  end
+end
diff --git a/app/models/favourite.rb b/app/models/favourite.rb
index 53c79ccea..d28d5c05b 100644
--- a/app/models/favourite.rb
+++ b/app/models/favourite.rb
@@ -3,11 +3,11 @@
 #
 # Table name: favourites
 #
-#  id         :integer          not null, primary key
-#  account_id :integer          not null
-#  status_id  :integer          not null
 #  created_at :datetime         not null
 #  updated_at :datetime         not null
+#  account_id :integer          not null
+#  id         :integer          not null, primary key
+#  status_id  :integer          not null
 #
 
 class Favourite < ApplicationRecord
diff --git a/app/models/feed.rb b/app/models/feed.rb
index beb4a8de3..5f7b7877a 100644
--- a/app/models/feed.rb
+++ b/app/models/feed.rb
@@ -19,7 +19,7 @@ class Feed
   def from_redis(limit, max_id, since_id)
     max_id     = '+inf' if max_id.blank?
     since_id   = '-inf' if since_id.blank?
-    unhydrated = redis.zrevrangebyscore(key, "(#{max_id}", "(#{since_id}", limit: [0, limit], with_scores: true).map(&:last).map(&:to_i)
+    unhydrated = redis.zrevrangebyscore(key, "(#{max_id}", "(#{since_id}", limit: [0, limit], with_scores: true).map(&:first).map(&:to_i)
     Status.where(id: unhydrated).cache_ids
   end
 
diff --git a/app/models/follow.rb b/app/models/follow.rb
index 62f6fb670..667720a88 100644
--- a/app/models/follow.rb
+++ b/app/models/follow.rb
@@ -3,11 +3,11 @@
 #
 # Table name: follows
 #
-#  id                :integer          not null, primary key
-#  account_id        :integer          not null
-#  target_account_id :integer          not null
 #  created_at        :datetime         not null
 #  updated_at        :datetime         not null
+#  account_id        :integer          not null
+#  id                :integer          not null, primary key
+#  target_account_id :integer          not null
 #
 
 class Follow < ApplicationRecord
diff --git a/app/models/follow_request.rb b/app/models/follow_request.rb
index 458c3a2cd..60036d903 100644
--- a/app/models/follow_request.rb
+++ b/app/models/follow_request.rb
@@ -3,11 +3,11 @@
 #
 # Table name: follow_requests
 #
-#  id                :integer          not null, primary key
-#  account_id        :integer          not null
-#  target_account_id :integer          not null
 #  created_at        :datetime         not null
 #  updated_at        :datetime         not null
+#  account_id        :integer          not null
+#  id                :integer          not null, primary key
+#  target_account_id :integer          not null
 #
 
 class FollowRequest < ApplicationRecord
diff --git a/app/models/import.rb b/app/models/import.rb
index 4656c3af6..8ae7e3a46 100644
--- a/app/models/import.rb
+++ b/app/models/import.rb
@@ -3,8 +3,6 @@
 #
 # Table name: imports
 #
-#  id                :integer          not null, primary key
-#  account_id        :integer          not null
 #  type              :integer          not null
 #  approved          :boolean          default(FALSE), not null
 #  created_at        :datetime         not null
@@ -13,6 +11,8 @@
 #  data_content_type :string
 #  data_file_size    :integer
 #  data_updated_at   :datetime
+#  account_id        :integer          not null
+#  id                :integer          not null, primary key
 #
 
 class Import < ApplicationRecord
diff --git a/app/models/media_attachment.rb b/app/models/media_attachment.rb
index 65ff893a8..f6c8879c5 100644
--- a/app/models/media_attachment.rb
+++ b/app/models/media_attachment.rb
@@ -16,6 +16,7 @@
 #  shortcode         :string
 #  type              :integer          default("image"), not null
 #  file_meta         :json
+#  description       :text
 #
 
 require 'mime/types'
@@ -75,6 +76,7 @@ class MediaAttachment < ApplicationRecord
   validates_attachment_size :file, less_than: 8.megabytes
 
   validates :account, presence: true
+  validates :description, length: { maximum: 420 }, if: :local?
 
   scope :attached,   -> { where.not(status_id: nil) }
   scope :unattached, -> { where(status_id: nil) }
@@ -95,6 +97,7 @@ class MediaAttachment < ApplicationRecord
     shortcode
   end
 
+  before_create :prepare_description, unless: :local?
   before_create :set_shortcode
   before_post_process :set_type_and_extension
   before_save :set_meta
@@ -157,6 +160,10 @@ class MediaAttachment < ApplicationRecord
     end
   end
 
+  def prepare_description
+    self.description = description.strip[0...420] unless description.nil?
+  end
+
   def set_type_and_extension
     self.type = VIDEO_MIME_TYPES.include?(file_content_type) ? :video : AUDIO_MIME_TYPES.include?(file_content_type) ? :audio : :image
     extension = AUDIO_MIME_TYPES.include?(file_content_type) ? '.mp4' : appropriate_extension
diff --git a/app/models/mention.rb b/app/models/mention.rb
index 7450b1b85..3700c781c 100644
--- a/app/models/mention.rb
+++ b/app/models/mention.rb
@@ -3,11 +3,11 @@
 #
 # Table name: mentions
 #
-#  id         :integer          not null, primary key
-#  account_id :integer
 #  status_id  :integer
 #  created_at :datetime         not null
 #  updated_at :datetime         not null
+#  account_id :integer
+#  id         :integer          not null, primary key
 #
 
 class Mention < ApplicationRecord
diff --git a/app/models/mute.rb b/app/models/mute.rb
index 0d597a275..bcd3d247c 100644
--- a/app/models/mute.rb
+++ b/app/models/mute.rb
@@ -3,11 +3,11 @@
 #
 # Table name: mutes
 #
-#  id                 :integer          not null, primary key
-#  account_id         :integer          not null
-#  target_account_id  :integer          not null
 #  created_at         :datetime         not null
 #  updated_at         :datetime         not null
+#  account_id         :integer          not null
+#  id                 :integer          not null, primary key
+#  target_account_id  :integer          not null
 #  hide_notifications :boolean          default(TRUE), not null
 #
 
diff --git a/app/models/report.rb b/app/models/report.rb
index 479aa17bb..bffb42b48 100644
--- a/app/models/report.rb
+++ b/app/models/report.rb
@@ -3,15 +3,15 @@
 #
 # Table name: reports
 #
-#  id                         :integer          not null, primary key
-#  account_id                 :integer          not null
-#  target_account_id          :integer          not null
 #  status_ids                 :integer          default([]), not null, is an Array
 #  comment                    :text             default(""), not null
 #  action_taken               :boolean          default(FALSE), not null
 #  created_at                 :datetime         not null
 #  updated_at                 :datetime         not null
+#  account_id                 :integer          not null
 #  action_taken_by_account_id :integer
+#  id                         :integer          not null, primary key
+#  target_account_id          :integer          not null
 #
 
 class Report < ApplicationRecord
diff --git a/app/models/setting.rb b/app/models/setting.rb
index 340552581..a14f156a1 100644
--- a/app/models/setting.rb
+++ b/app/models/setting.rb
@@ -3,13 +3,13 @@
 #
 # Table name: settings
 #
-#  id         :integer          not null, primary key
 #  var        :string           not null
 #  value      :text
 #  thing_type :string
-#  thing_id   :integer
 #  created_at :datetime
 #  updated_at :datetime
+#  id         :integer          not null, primary key
+#  thing_id   :integer
 #
 
 class Setting < RailsSettings::Base
diff --git a/app/models/status.rb b/app/models/status.rb
index e1697b8af..107ccface 100644
--- a/app/models/status.rb
+++ b/app/models/status.rb
@@ -136,6 +136,8 @@ class Status < ApplicationRecord
 
   after_create :store_uri, if: :local?
 
+  around_create Mastodon::Snowflake::Callbacks
+
   before_validation :prepare_contents, if: :local?
   before_validation :set_reblog
   before_validation :set_visibility
diff --git a/app/models/stream_entry.rb b/app/models/stream_entry.rb
index cff232916..720cd518c 100644
--- a/app/models/stream_entry.rb
+++ b/app/models/stream_entry.rb
@@ -1,16 +1,15 @@
 # frozen_string_literal: true
-
 # == Schema Information
 #
 # Table name: stream_entries
 #
-#  id            :integer          not null, primary key
-#  account_id    :integer
 #  activity_id   :integer
 #  activity_type :string
 #  created_at    :datetime         not null
 #  updated_at    :datetime         not null
 #  hidden        :boolean          default(FALSE), not null
+#  account_id    :integer
+#  id            :integer          not null, primary key
 #
 
 class StreamEntry < ApplicationRecord
diff --git a/app/models/subscription.rb b/app/models/subscription.rb
index 14f1a140c..39860196b 100644
--- a/app/models/subscription.rb
+++ b/app/models/subscription.rb
@@ -3,16 +3,16 @@
 #
 # Table name: subscriptions
 #
-#  id                          :integer          not null, primary key
 #  callback_url                :string           default(""), not null
 #  secret                      :string
 #  expires_at                  :datetime
 #  confirmed                   :boolean          default(FALSE), not null
-#  account_id                  :integer          not null
 #  created_at                  :datetime         not null
 #  updated_at                  :datetime         not null
 #  last_successful_delivery_at :datetime
 #  domain                      :string
+#  account_id                  :integer          not null
+#  id                          :integer          not null, primary key
 #
 
 class Subscription < ApplicationRecord
diff --git a/app/models/web/setting.rb b/app/models/web/setting.rb
index 04a049523..1b0bfb2b7 100644
--- a/app/models/web/setting.rb
+++ b/app/models/web/setting.rb
@@ -3,11 +3,11 @@
 #
 # Table name: web_settings
 #
-#  id         :integer          not null, primary key
-#  user_id    :integer
 #  data       :json
 #  created_at :datetime         not null
 #  updated_at :datetime         not null
+#  id         :integer          not null, primary key
+#  user_id    :integer
 #
 
 class Web::Setting < ApplicationRecord
diff --git a/app/serializers/activitypub/activity_serializer.rb b/app/serializers/activitypub/activity_serializer.rb
index b252e008b..df399211c 100644
--- a/app/serializers/activitypub/activity_serializer.rb
+++ b/app/serializers/activitypub/activity_serializer.rb
@@ -3,10 +3,11 @@
 class ActivityPub::ActivitySerializer < ActiveModel::Serializer
   attributes :id, :type, :actor, :published, :to, :cc
 
-  has_one :proper, key: :object, serializer: ActivityPub::NoteSerializer
+  has_one :proper, key: :object, serializer: ActivityPub::NoteSerializer, unless: :announce?
+  attribute :proper_uri, key: :object, if: :announce?
 
   def id
-    [ActivityPub::TagManager.instance.activity_uri_for(object)].join
+    ActivityPub::TagManager.instance.activity_uri_for(object)
   end
 
   def type
@@ -29,6 +30,10 @@ class ActivityPub::ActivitySerializer < ActiveModel::Serializer
     ActivityPub::TagManager.instance.cc(object)
   end
 
+  def proper_uri
+    ActivityPub::TagManager.instance.uri_for(object.proper)
+  end
+
   def announce?
     object.reblog?
   end
diff --git a/app/serializers/activitypub/actor_serializer.rb b/app/serializers/activitypub/actor_serializer.rb
index a11178f5b..896d67115 100644
--- a/app/serializers/activitypub/actor_serializer.rb
+++ b/app/serializers/activitypub/actor_serializer.rb
@@ -10,20 +10,6 @@ class ActivityPub::ActorSerializer < ActiveModel::Serializer
 
   has_one :public_key, serializer: ActivityPub::PublicKeySerializer
 
-  class ImageSerializer < ActiveModel::Serializer
-    include RoutingHelper
-
-    attributes :type, :url
-
-    def type
-      'Image'
-    end
-
-    def url
-      full_asset_url(object.url(:original))
-    end
-  end
-
   class EndpointsSerializer < ActiveModel::Serializer
     include RoutingHelper
 
@@ -36,8 +22,8 @@ class ActivityPub::ActorSerializer < ActiveModel::Serializer
 
   has_one :endpoints, serializer: EndpointsSerializer
 
-  has_one :icon,  serializer: ImageSerializer, if: :avatar_exists?
-  has_one :image, serializer: ImageSerializer, if: :header_exists?
+  has_one :icon,  serializer: ActivityPub::ImageSerializer, if: :avatar_exists?
+  has_one :image, serializer: ActivityPub::ImageSerializer, if: :header_exists?
 
   def id
     account_url(object)
diff --git a/app/serializers/activitypub/emoji_serializer.rb b/app/serializers/activitypub/emoji_serializer.rb
new file mode 100644
index 000000000..7b06b1e5d
--- /dev/null
+++ b/app/serializers/activitypub/emoji_serializer.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+class ActivityPub::EmojiSerializer < ActiveModel::Serializer
+  include RoutingHelper
+
+  attributes :id, :type, :name, :updated
+
+  has_one :icon, serializer: ActivityPub::ImageSerializer
+
+  def id
+    ActivityPub::TagManager.instance.uri_for(object)
+  end
+
+  def type
+    'Emoji'
+  end
+
+  def icon
+    object.image
+  end
+
+  def updated
+    object.updated_at.iso8601
+  end
+
+  def name
+    ":#{object.shortcode}:"
+  end
+end
diff --git a/app/serializers/activitypub/image_serializer.rb b/app/serializers/activitypub/image_serializer.rb
new file mode 100644
index 000000000..a015c6b1b
--- /dev/null
+++ b/app/serializers/activitypub/image_serializer.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+class ActivityPub::ImageSerializer < ActiveModel::Serializer
+  include RoutingHelper
+
+  attributes :type, :media_type, :url
+
+  def type
+    'Image'
+  end
+
+  def url
+    full_asset_url(object.url(:original))
+  end
+
+  def media_type
+    object.content_type
+  end
+end
diff --git a/app/serializers/activitypub/note_serializer.rb b/app/serializers/activitypub/note_serializer.rb
index f94c3b9dc..24c39f3c9 100644
--- a/app/serializers/activitypub/note_serializer.rb
+++ b/app/serializers/activitypub/note_serializer.rb
@@ -89,12 +89,16 @@ class ActivityPub::NoteSerializer < ActiveModel::Serializer
   class MediaAttachmentSerializer < ActiveModel::Serializer
     include RoutingHelper
 
-    attributes :type, :media_type, :url
+    attributes :type, :media_type, :url, :name
 
     def type
       'Document'
     end
 
+    def name
+      object.description
+    end
+
     def media_type
       object.file_content_type
     end
@@ -138,21 +142,6 @@ class ActivityPub::NoteSerializer < ActiveModel::Serializer
     end
   end
 
-  class CustomEmojiSerializer < ActiveModel::Serializer
-    include RoutingHelper
-
-    attributes :type, :href, :name
-
-    def type
-      'Emoji'
-    end
-
-    def href
-      full_asset_url(object.image.url)
-    end
-
-    def name
-      ":#{object.shortcode}:"
-    end
+  class CustomEmojiSerializer < ActivityPub::EmojiSerializer
   end
 end
diff --git a/app/serializers/manifest_serializer.rb b/app/serializers/manifest_serializer.rb
new file mode 100644
index 000000000..95bcc21bb
--- /dev/null
+++ b/app/serializers/manifest_serializer.rb
@@ -0,0 +1,52 @@
+# frozen_string_literal: true
+
+class ManifestSerializer < ActiveModel::Serializer
+  include RoutingHelper
+  include ActionView::Helpers::TextHelper
+
+  attributes :name, :short_name, :description,
+             :icons, :theme_color, :background_color,
+             :display, :start_url, :scope
+
+  def name
+    object.site_title
+  end
+
+  def short_name
+    object.site_title
+  end
+
+  def description
+    strip_tags(object.site_description.presence || I18n.t('about.about_mastodon_html'))
+  end
+
+  def icons
+    [
+      {
+        src: '/android-chrome-192x192.png',
+        sizes: '192x192',
+        type: 'image/png',
+      },
+    ]
+  end
+
+  def theme_color
+    '#282c37'
+  end
+
+  def background_color
+    '#191b22'
+  end
+
+  def display
+    'standalone'
+  end
+
+  def start_url
+    '/web/timelines/home'
+  end
+
+  def scope
+    root_url
+  end
+end
diff --git a/app/serializers/rest/application_serializer.rb b/app/serializers/rest/application_serializer.rb
index a8945f66e..a9316cd4b 100644
--- a/app/serializers/rest/application_serializer.rb
+++ b/app/serializers/rest/application_serializer.rb
@@ -15,4 +15,8 @@ class REST::ApplicationSerializer < ActiveModel::Serializer
   def client_secret
     object.secret
   end
+
+  def website
+    object.website.presence
+  end
 end
diff --git a/app/serializers/rest/custom_emoji_serializer.rb b/app/serializers/rest/custom_emoji_serializer.rb
index b744dd4ec..b958e6a5d 100644
--- a/app/serializers/rest/custom_emoji_serializer.rb
+++ b/app/serializers/rest/custom_emoji_serializer.rb
@@ -3,9 +3,13 @@
 class REST::CustomEmojiSerializer < ActiveModel::Serializer
   include RoutingHelper
 
-  attributes :shortcode, :url
+  attributes :shortcode, :url, :static_url
 
   def url
     full_asset_url(object.image.url)
   end
+
+  def static_url
+    full_asset_url(object.image.url(:static))
+  end
 end
diff --git a/app/serializers/rest/media_attachment_serializer.rb b/app/serializers/rest/media_attachment_serializer.rb
index f6e7c79d1..51011788b 100644
--- a/app/serializers/rest/media_attachment_serializer.rb
+++ b/app/serializers/rest/media_attachment_serializer.rb
@@ -4,7 +4,8 @@ class REST::MediaAttachmentSerializer < ActiveModel::Serializer
   include RoutingHelper
 
   attributes :id, :type, :url, :preview_url,
-             :remote_url, :text_url, :meta
+             :remote_url, :text_url, :meta,
+             :description
 
   def id
     object.id.to_s
@@ -18,6 +19,10 @@ class REST::MediaAttachmentSerializer < ActiveModel::Serializer
     end
   end
 
+  def remote_url
+    object.remote_url.presence
+  end
+
   def preview_url
     if object.needs_redownload?
       media_proxy_url(object.id, :small)
diff --git a/app/services/activitypub/fetch_remote_account_service.rb b/app/services/activitypub/fetch_remote_account_service.rb
index 3eeca585e..d6ba625a9 100644
--- a/app/services/activitypub/fetch_remote_account_service.rb
+++ b/app/services/activitypub/fetch_remote_account_service.rb
@@ -5,14 +5,18 @@ class ActivityPub::FetchRemoteAccountService < BaseService
 
   # Should be called when uri has already been checked for locality
   # Does a WebFinger roundtrip on each call
-  def call(uri, prefetched_json = nil)
-    @json = body_to_json(prefetched_json) || fetch_resource(uri)
+  def call(uri, id: true, prefetched_body: nil)
+    @json = if prefetched_body.nil?
+              fetch_resource(uri, id)
+            else
+              body_to_json(prefetched_body)
+            end
 
     return unless supported_context? && expected_type?
 
     @uri      = @json['id']
     @username = @json['preferredUsername']
-    @domain   = Addressable::URI.parse(uri).normalized_host
+    @domain   = Addressable::URI.parse(@uri).normalized_host
 
     return unless verified_webfinger?
 
@@ -27,17 +31,15 @@ class ActivityPub::FetchRemoteAccountService < BaseService
     webfinger                            = Goldfinger.finger("acct:#{@username}@#{@domain}")
     confirmed_username, confirmed_domain = split_acct(webfinger.subject)
 
-    return true if @username.casecmp(confirmed_username).zero? && @domain.casecmp(confirmed_domain).zero?
+    return webfinger.link('self')&.href == @uri if @username.casecmp(confirmed_username).zero? && @domain.casecmp(confirmed_domain).zero?
 
     webfinger                            = Goldfinger.finger("acct:#{confirmed_username}@#{confirmed_domain}")
-    confirmed_username, confirmed_domain = split_acct(webfinger.subject)
+    @username, @domain                   = split_acct(webfinger.subject)
     self_reference                       = webfinger.link('self')
 
+    return false unless @username.casecmp(confirmed_username).zero? && @domain.casecmp(confirmed_domain).zero?
     return false if self_reference&.href != @uri
 
-    @username = confirmed_username
-    @domain   = confirmed_domain
-
     true
   rescue Goldfinger::Error
     false
diff --git a/app/services/activitypub/fetch_remote_key_service.rb b/app/services/activitypub/fetch_remote_key_service.rb
index ebd64071e..ce1048fee 100644
--- a/app/services/activitypub/fetch_remote_key_service.rb
+++ b/app/services/activitypub/fetch_remote_key_service.rb
@@ -4,13 +4,26 @@ class ActivityPub::FetchRemoteKeyService < BaseService
   include JsonLdHelper
 
   # Returns account that owns the key
-  def call(uri, prefetched_json = nil)
-    @json = body_to_json(prefetched_json) || fetch_resource(uri)
+  def call(uri, id: true, prefetched_body: nil)
+    if prefetched_body.nil?
+      if id
+        @json = fetch_resource_without_id_validation(uri)
+        if person?
+          @json = fetch_resource(@json['id'], true)
+        elsif uri != @json['id']
+          return
+        end
+      else
+        @json = fetch_resource(uri, id)
+      end
+    else
+      @json = body_to_json(prefetched_body)
+    end
 
     return unless supported_context?(@json) && expected_type?
-    return find_account(uri, @json) if person?
+    return find_account(@json['id'], @json) if person?
 
-    @owner = fetch_resource(owner_uri)
+    @owner = fetch_resource(owner_uri, true)
 
     return unless supported_context?(@owner) && confirmed_owner?
 
@@ -19,9 +32,9 @@ class ActivityPub::FetchRemoteKeyService < BaseService
 
   private
 
-  def find_account(uri, prefetched_json)
+  def find_account(uri, prefetched_body)
     account   = ActivityPub::TagManager.instance.uri_to_resource(uri, Account)
-    account ||= ActivityPub::FetchRemoteAccountService.new.call(uri, prefetched_json)
+    account ||= ActivityPub::FetchRemoteAccountService.new.call(uri, prefetched_body: prefetched_body)
     account
   end
 
diff --git a/app/services/activitypub/fetch_remote_status_service.rb b/app/services/activitypub/fetch_remote_status_service.rb
index a95931afe..e2a89a87c 100644
--- a/app/services/activitypub/fetch_remote_status_service.rb
+++ b/app/services/activitypub/fetch_remote_status_service.rb
@@ -4,36 +4,33 @@ class ActivityPub::FetchRemoteStatusService < BaseService
   include JsonLdHelper
 
   # Should be called when uri has already been checked for locality
-  def call(uri, prefetched_json = nil)
-    @json = body_to_json(prefetched_json) || fetch_resource(uri)
+  def call(uri, id: true, prefetched_body: nil)
+    @json = if prefetched_body.nil?
+              fetch_resource(uri, id)
+            else
+              body_to_json(prefetched_body)
+            end
 
-    return unless supported_context?
+    return unless supported_context? && expected_type?
 
-    activity = activity_json
-    actor_id = value_or_id(activity['actor'])
-
-    return unless expected_type?(activity) && trustworthy_attribution?(uri, actor_id)
+    return if actor_id.nil? || !trustworthy_attribution?(@json['id'], actor_id)
 
     actor = ActivityPub::TagManager.instance.uri_to_resource(actor_id, Account)
-    actor = ActivityPub::FetchRemoteAccountService.new.call(actor_id) if actor.nil?
+    actor = ActivityPub::FetchRemoteAccountService.new.call(actor_id, id: true) if actor.nil?
 
     return if actor.suspended?
 
-    ActivityPub::Activity.factory(activity, actor).perform
+    ActivityPub::Activity.factory(activity_json, actor).perform
   end
 
   private
 
   def activity_json
-    if %w(Note Article).include? @json['type']
-      {
-        'type'   => 'Create',
-        'actor'  => first_of_value(@json['attributedTo']),
-        'object' => @json,
-      }
-    else
-      @json
-    end
+    { 'type' => 'Create', 'actor' => actor_id, 'object' => @json }
+  end
+
+  def actor_id
+    first_of_value(@json['attributedTo'])
   end
 
   def trustworthy_attribution?(uri, attributed_to)
@@ -44,7 +41,7 @@ class ActivityPub::FetchRemoteStatusService < BaseService
     super(@json)
   end
 
-  def expected_type?(json)
-    %w(Create Announce).include? json['type']
+  def expected_type?
+    %w(Note Article).include? @json['type']
   end
 end
diff --git a/app/services/activitypub/process_account_service.rb b/app/services/activitypub/process_account_service.rb
index 811209537..f93baf4b5 100644
--- a/app/services/activitypub/process_account_service.rb
+++ b/app/services/activitypub/process_account_service.rb
@@ -90,7 +90,7 @@ class ActivityPub::ProcessAccountService < BaseService
     return if value.nil?
     return value['url'] if value.is_a?(Hash)
 
-    image = fetch_resource(value)
+    image = fetch_resource_without_id_validation(value)
     image['url'] if image
   end
 
@@ -100,7 +100,7 @@ class ActivityPub::ProcessAccountService < BaseService
     return if value.nil?
     return value['publicKeyPem'] if value.is_a?(Hash)
 
-    key = fetch_resource(value)
+    key = fetch_resource_without_id_validation(value)
     key['publicKeyPem'] if key
   end
 
@@ -130,7 +130,7 @@ class ActivityPub::ProcessAccountService < BaseService
     return if @json[type].blank?
     return @collections[type] if @collections.key?(type)
 
-    collection = fetch_resource(@json[type])
+    collection = fetch_resource_without_id_validation(@json[type])
 
     @collections[type] = collection.is_a?(Hash) && collection['totalItems'].present? && collection['totalItems'].is_a?(Numeric) ? collection['totalItems'] : nil
   rescue HTTP::Error, OpenSSL::SSL::SSLError
diff --git a/app/services/activitypub/process_collection_service.rb b/app/services/activitypub/process_collection_service.rb
index 59cb65c65..db4d1b4bc 100644
--- a/app/services/activitypub/process_collection_service.rb
+++ b/app/services/activitypub/process_collection_service.rb
@@ -3,9 +3,10 @@
 class ActivityPub::ProcessCollectionService < BaseService
   include JsonLdHelper
 
-  def call(body, account)
+  def call(body, account, options = {})
     @account = account
     @json    = Oj.load(body, mode: :strict)
+    @options = options
 
     return unless supported_context?
     return if different_actor? && verify_account!.nil?
@@ -38,7 +39,7 @@ class ActivityPub::ProcessCollectionService < BaseService
   end
 
   def process_item(item)
-    activity = ActivityPub::Activity.factory(item, @account)
+    activity = ActivityPub::Activity.factory(item, @account, @options)
     activity&.perform
   end
 
diff --git a/app/services/batched_remove_status_service.rb b/app/services/batched_remove_status_service.rb
index 2fd623922..5d83771c9 100644
--- a/app/services/batched_remove_status_service.rb
+++ b/app/services/batched_remove_status_service.rb
@@ -29,7 +29,7 @@ class BatchedRemoveStatusService < BaseService
     statuses.group_by(&:account_id).each do |_, account_statuses|
       account = account_statuses.first.account
 
-      unpush_from_home_timelines(account_statuses)
+      unpush_from_home_timelines(account, account_statuses)
 
       if account.local?
         batch_stream_entries(account, account_statuses)
@@ -72,14 +72,15 @@ class BatchedRemoveStatusService < BaseService
     end
   end
 
-  def unpush_from_home_timelines(statuses)
-    account    = statuses.first.account
-    recipients = account.followers.local.pluck(:id)
+  def unpush_from_home_timelines(account, statuses)
+    recipients = account.followers.local.to_a
 
-    recipients << account.id if account.local?
+    recipients << account if account.local?
 
-    recipients.each do |follower_id|
-      unpush(follower_id, statuses)
+    recipients.each do |follower|
+      statuses.each do |status|
+        FeedManager.instance.unpush(:home, follower, status)
+      end
     end
   end
 
@@ -109,28 +110,6 @@ class BatchedRemoveStatusService < BaseService
     end
   end
 
-  def unpush(follower_id, statuses)
-    key = FeedManager.instance.key(:home, follower_id)
-
-    originals = statuses.reject(&:reblog?)
-    reblogs   = statuses.select(&:reblog?)
-
-    # Quickly remove all originals
-    redis.pipelined do
-      originals.each do |status|
-        redis.zremrangebyscore(key, status.id, status.id)
-        redis.publish("timeline:#{follower_id}", @json_payloads[status.id])
-      end
-    end
-
-    # For reblogs, re-add original status to feed, unless the reblog
-    # was not in the feed in the first place
-    reblogs.each do |status|
-      redis.zadd(key, status.reblog_of_id, status.reblog_of_id) unless redis.zscore(key, status.reblog_of_id).nil?
-      redis.publish("timeline:#{follower_id}", @json_payloads[status.id])
-    end
-  end
-
   def redis
     Redis.current
   end
diff --git a/app/services/fetch_atom_service.rb b/app/services/fetch_atom_service.rb
index 9c5777b5d..1c47a22da 100644
--- a/app/services/fetch_atom_service.rb
+++ b/app/services/fetch_atom_service.rb
@@ -41,10 +41,11 @@ class FetchAtomService < BaseService
     return nil if @response.code != 200
 
     if @response.mime_type == 'application/atom+xml'
-      [@url, @response.to_s, :ostatus]
+      [@url, { prefetched_body: @response.to_s }, :ostatus]
     elsif ['application/activity+json', 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"'].include?(@response.mime_type)
-      if supported_activity?(@response.to_s)
-        [@url, @response.to_s, :activitypub]
+      json = body_to_json(@response.to_s)
+      if supported_context?(json) && json['type'] == 'Person' && json['inbox'].present?
+        [json['id'], { prefetched_body: @response.to_s, id: true }, :activitypub]
       else
         @unsupported_activity = true
         nil
@@ -79,10 +80,4 @@ class FetchAtomService < BaseService
 
     result
   end
-
-  def supported_activity?(body)
-    json = body_to_json(body)
-    return false unless supported_context?(json)
-    json['type'] == 'Person' ? json['inbox'].present? : true
-  end
 end
diff --git a/app/services/fetch_link_card_service.rb b/app/services/fetch_link_card_service.rb
index 4acbfae7a..cf3d78683 100644
--- a/app/services/fetch_link_card_service.rb
+++ b/app/services/fetch_link_card_service.rb
@@ -27,7 +27,8 @@ class FetchLinkCardService < BaseService
     end
 
     attach_card if @card&.persisted?
-  rescue HTTP::ConnectionError, OpenSSL::SSL::SSLError
+  rescue HTTP::Error, Addressable::URI::InvalidURIError => e
+    Rails.logger.debug "Error fetching link #{@url}: #{e}"
     nil
   end
 
diff --git a/app/services/fetch_remote_account_service.rb b/app/services/fetch_remote_account_service.rb
index bd98e70d1..a0f031a44 100644
--- a/app/services/fetch_remote_account_service.rb
+++ b/app/services/fetch_remote_account_service.rb
@@ -5,24 +5,24 @@ class FetchRemoteAccountService < BaseService
 
   def call(url, prefetched_body = nil, protocol = :ostatus)
     if prefetched_body.nil?
-      resource_url, body, protocol = FetchAtomService.new.call(url)
+      resource_url, resource_options, protocol = FetchAtomService.new.call(url)
     else
-      resource_url = url
-      body         = prefetched_body
+      resource_url     = url
+      resource_options = { prefetched_body: prefetched_body }
     end
 
     case protocol
     when :ostatus
-      process_atom(resource_url, body)
+      process_atom(resource_url, **resource_options)
     when :activitypub
-      ActivityPub::FetchRemoteAccountService.new.call(resource_url, body)
+      ActivityPub::FetchRemoteAccountService.new.call(resource_url, **resource_options)
     end
   end
 
   private
 
-  def process_atom(url, body)
-    xml = Nokogiri::XML(body)
+  def process_atom(url, prefetched_body:)
+    xml = Nokogiri::XML(prefetched_body)
     xml.encoding = 'utf-8'
 
     account = author_from_xml(xml.at_xpath('/xmlns:feed', xmlns: OStatus::TagManager::XMLNS), false)
diff --git a/app/services/fetch_remote_resource_service.rb b/app/services/fetch_remote_resource_service.rb
index 341664272..6d40796f2 100644
--- a/app/services/fetch_remote_resource_service.rb
+++ b/app/services/fetch_remote_resource_service.rb
@@ -33,7 +33,7 @@ class FetchRemoteResourceService < BaseService
   end
 
   def body
-    fetched_atom_feed.second
+    fetched_atom_feed.second[:prefetched_body]
   end
 
   def protocol
diff --git a/app/services/fetch_remote_status_service.rb b/app/services/fetch_remote_status_service.rb
index 1b90854c4..cacf6ba51 100644
--- a/app/services/fetch_remote_status_service.rb
+++ b/app/services/fetch_remote_status_service.rb
@@ -5,26 +5,26 @@ class FetchRemoteStatusService < BaseService
 
   def call(url, prefetched_body = nil, protocol = :ostatus)
     if prefetched_body.nil?
-      resource_url, body, protocol = FetchAtomService.new.call(url)
+      resource_url, resource_options, protocol = FetchAtomService.new.call(url)
     else
-      resource_url = url
-      body         = prefetched_body
+      resource_url     = url
+      resource_options = { prefetched_body: prefetched_body }
     end
 
     case protocol
     when :ostatus
-      process_atom(resource_url, body)
+      process_atom(resource_url, **resource_options)
     when :activitypub
-      ActivityPub::FetchRemoteStatusService.new.call(resource_url, body)
+      ActivityPub::FetchRemoteStatusService.new.call(resource_url, **resource_options)
     end
   end
 
   private
 
-  def process_atom(url, body)
+  def process_atom(url, prefetched_body:)
     Rails.logger.debug "Processing Atom for remote status at #{url}"
 
-    xml = Nokogiri::XML(body)
+    xml = Nokogiri::XML(prefetched_body)
     xml.encoding = 'utf-8'
 
     account = author_from_xml(xml.at_xpath('/xmlns:entry', xmlns: OStatus::TagManager::XMLNS))
@@ -32,7 +32,7 @@ class FetchRemoteStatusService < BaseService
 
     return nil unless !account.nil? && confirmed_domain?(domain, account)
 
-    statuses = ProcessFeedService.new.call(body, account)
+    statuses = ProcessFeedService.new.call(prefetched_body, account)
     statuses.first
   rescue Nokogiri::XML::XPath::SyntaxError
     Rails.logger.debug 'Invalid XML or missing namespace'
diff --git a/app/services/mute_service.rb b/app/services/mute_service.rb
index 56cbebd5d..a9a02937e 100644
--- a/app/services/mute_service.rb
+++ b/app/services/mute_service.rb
@@ -3,7 +3,8 @@
 class MuteService < BaseService
   def call(account, target_account, notifications: nil)
     return if account.id == target_account.id
-    FeedManager.instance.clear_from_timeline(account, target_account)
     account.mute!(target_account, notifications: notifications)
+    BlockWorker.perform_async(account.id, target_account.id)
+    mute
   end
 end
diff --git a/app/services/precompute_feed_service.rb b/app/services/precompute_feed_service.rb
index 85635a008..36aabaa00 100644
--- a/app/services/precompute_feed_service.rb
+++ b/app/services/precompute_feed_service.rb
@@ -1,43 +1,7 @@
 # frozen_string_literal: true
 
 class PrecomputeFeedService < BaseService
-  LIMIT = FeedManager::MAX_ITEMS / 4
-
   def call(account)
-    @account = account
-    populate_feed
-  end
-
-  private
-
-  attr_reader :account
-
-  def populate_feed
-    pairs = statuses.reverse_each.lazy.reject(&method(:status_filtered?)).map(&method(:process_status)).to_a
-
-    redis.pipelined do
-      redis.zadd(account_home_key, pairs) if pairs.any?
-      redis.del("account:#{@account.id}:regeneration")
-    end
-  end
-
-  def process_status(status)
-    [status.id, status.reblog? ? status.reblog_of_id : status.id]
-  end
-
-  def status_filtered?(status)
-    FeedManager.instance.filter?(:home, status, account.id)
-  end
-
-  def account_home_key
-    FeedManager.instance.key(:home, account.id)
-  end
-
-  def statuses
-    Status.as_home_timeline(account).order(account_id: :desc).limit(LIMIT)
-  end
-
-  def redis
-    Redis.current
+    FeedManager.instance.populate_feed(account)
   end
 end
diff --git a/app/services/process_feed_service.rb b/app/services/process_feed_service.rb
index 2a5f1e2bc..60eff135e 100644
--- a/app/services/process_feed_service.rb
+++ b/app/services/process_feed_service.rb
@@ -1,7 +1,9 @@
 # frozen_string_literal: true
 
 class ProcessFeedService < BaseService
-  def call(body, account)
+  def call(body, account, options = {})
+    @options = options
+
     xml = Nokogiri::XML(body)
     xml.encoding = 'utf-8'
 
@@ -20,7 +22,7 @@ class ProcessFeedService < BaseService
   end
 
   def process_entry(xml, account)
-    activity = OStatus::Activity::General.new(xml, account)
+    activity = OStatus::Activity::General.new(xml, account, @options)
     activity.specialize&.perform if activity.status?
   rescue ActiveRecord::RecordInvalid => e
     Rails.logger.debug "Nothing was saved for #{activity.id} because: #{e}"
diff --git a/app/services/remove_status_service.rb b/app/services/remove_status_service.rb
index 14f24908c..96d9208cc 100644
--- a/app/services/remove_status_service.rb
+++ b/app/services/remove_status_service.rb
@@ -102,13 +102,7 @@ class RemoveStatusService < BaseService
   end
 
   def unpush(type, receiver, status)
-    if status.reblog? && !redis.zscore(FeedManager.instance.key(type, receiver.id), status.reblog_of_id).nil?
-      redis.zadd(FeedManager.instance.key(type, receiver.id), status.reblog_of_id, status.reblog_of_id)
-    else
-      redis.zremrangebyscore(FeedManager.instance.key(type, receiver.id), status.id, status.id)
-    end
-
-    Redis.current.publish("timeline:#{receiver.id}", @payload)
+    FeedManager.instance.unpush(type, receiver, status)
   end
 
   def remove_from_hashtags
diff --git a/app/services/resolve_remote_account_service.rb b/app/services/resolve_remote_account_service.rb
index 57c80fc82..3d0a36f6c 100644
--- a/app/services/resolve_remote_account_service.rb
+++ b/app/services/resolve_remote_account_service.rb
@@ -74,7 +74,7 @@ class ResolveRemoteAccountService < BaseService
   end
 
   def webfinger_update_due?
-    @account.nil? || @account.last_webfingered_at.nil? || @account.last_webfingered_at <= 1.day.ago
+    @account.nil? || @account.possibly_stale?
   end
 
   def activitypub_ready?
@@ -189,7 +189,7 @@ class ResolveRemoteAccountService < BaseService
   def actor_json
     return @actor_json if defined?(@actor_json)
 
-    json        = fetch_resource(actor_url)
+    json        = fetch_resource(actor_url, false)
     @actor_json = supported_context?(json) && json['type'] == 'Person' ? json : nil
   end
 
diff --git a/app/services/send_interaction_service.rb b/app/services/send_interaction_service.rb
index c11813abc..af205c9c9 100644
--- a/app/services/send_interaction_service.rb
+++ b/app/services/send_interaction_service.rb
@@ -12,7 +12,7 @@ class SendInteractionService < BaseService
 
     return if !target_account.ostatus? || block_notification?
 
-    delivery = build_request.perform
+    delivery = build_request.perform.flush
 
     raise Mastodon::UnexpectedResponseError, delivery unless delivery.code > 199 && delivery.code < 300
   end
diff --git a/app/services/subscribe_service.rb b/app/services/subscribe_service.rb
index bfa7ff8c8..2d8af0203 100644
--- a/app/services/subscribe_service.rb
+++ b/app/services/subscribe_service.rb
@@ -6,7 +6,7 @@ class SubscribeService < BaseService
 
     @account        = account
     @account.secret = SecureRandom.hex
-    @response       = build_request.perform
+    @response       = build_request.perform.flush
 
     if response_failed_permanently?
       # We're not allowed to subscribe. Fail and move on.
diff --git a/app/services/unsubscribe_service.rb b/app/services/unsubscribe_service.rb
index b99046712..d84a5a530 100644
--- a/app/services/unsubscribe_service.rb
+++ b/app/services/unsubscribe_service.rb
@@ -7,7 +7,7 @@ class UnsubscribeService < BaseService
     @account = account
 
     begin
-      @response = build_request.perform
+      @response = build_request.perform.flush
 
       Rails.logger.debug "PuSH unsubscribe for #{@account.acct} failed: #{@response.status}" unless @response.status.success?
     rescue HTTP::Error, OpenSSL::SSL::SSLError => e
diff --git a/app/validators/blacklisted_email_validator.rb b/app/validators/blacklisted_email_validator.rb
index 0ba79694b..3f203f49a 100644
--- a/app/validators/blacklisted_email_validator.rb
+++ b/app/validators/blacklisted_email_validator.rb
@@ -12,6 +12,7 @@ class BlacklistedEmailValidator < ActiveModel::Validator
   end
 
   def on_blacklist?(value)
+    return true if EmailDomainBlock.block?(value)
     return false if Rails.configuration.x.email_domains_blacklist.blank?
 
     domains = Rails.configuration.x.email_domains_blacklist.gsub('.', '\.')
diff --git a/app/views/about/more.html.haml b/app/views/about/more.html.haml
index 6e4d0cdd1..7ffa5ecc3 100644
--- a/app/views/about/more.html.haml
+++ b/app/views/about/more.html.haml
@@ -3,7 +3,7 @@
 
 - content_for :header_tags do
   = javascript_pack_tag 'public', integrity: true, crossorigin: 'anonymous'
-  = render partial: 'og'
+  = render partial: 'shared/og'
 
 .landing-page
   .header-wrapper.compact
diff --git a/app/views/about/show.html.haml b/app/views/about/show.html.haml
index c0fa944ae..385b0b1dc 100644
--- a/app/views/about/show.html.haml
+++ b/app/views/about/show.html.haml
@@ -4,7 +4,7 @@
 - content_for :header_tags do
   %script#initial-state{ type: 'application/json' }!= json_escape(@initial_state_json)
   = javascript_pack_tag 'about', integrity: true, crossorigin: 'anonymous'
-  = render partial: 'og'
+  = render partial: 'shared/og'
 
 .landing-page
   .header-wrapper
@@ -69,7 +69,7 @@
       .about-mastodon
         %h3= t 'about.what_is_mastodon'
         %p= t 'about.about_mastodon_html'
-        %a.button.button-secondary{ href: 'https://joinmastodon.org/' }= t 'about.learn_more'
+        = link_to t('about.learn_more'), 'https://joinmastodon.org/', class: 'button button-secondary'
         = render 'features'
   .footer-links
     .container
diff --git a/app/views/admin/account_moderation_notes/_account_moderation_note.html.haml b/app/views/admin/account_moderation_notes/_account_moderation_note.html.haml
new file mode 100644
index 000000000..4651630e9
--- /dev/null
+++ b/app/views/admin/account_moderation_notes/_account_moderation_note.html.haml
@@ -0,0 +1,10 @@
+%tr
+  %td
+    = simple_format(h(account_moderation_note.content))
+  %td
+    = account_moderation_note.account.acct
+  %td
+    %time.formatted{ datetime: account_moderation_note.created_at.iso8601, title: l(account_moderation_note.created_at) }
+      = l account_moderation_note.created_at
+  %td
+    = link_to t('admin.account_moderation_notes.delete'), admin_account_moderation_note_path(account_moderation_note), method: :delete
diff --git a/app/views/admin/accounts/show.html.haml b/app/views/admin/accounts/show.html.haml
index 3775b6721..1f5c8fcf5 100644
--- a/app/views/admin/accounts/show.html.haml
+++ b/app/views/admin/accounts/show.html.haml
@@ -129,3 +129,25 @@
         %tr
           %th= t('admin.accounts.followers_url')
           %td= link_to @account.followers_url, @account.followers_url
+
+%hr
+%h3= t('admin.accounts.moderation_notes')
+
+= simple_form_for @account_moderation_note, url: admin_account_moderation_notes_path do |f|
+  = render 'shared/error_messages', object: @account_moderation_note
+
+  = f.input :content
+  = f.hidden_field :target_account_id
+
+  .actions
+  = f.button :button, t('admin.account_moderation_notes.create'), type: :submit
+
+.table-wrapper
+  %table.table
+    %thead
+      %tr
+        %th
+        %th= t('admin.account_moderation_notes.account')
+        %th= t('admin.account_moderation_notes.created_at')
+    %tbody
+      = render @moderation_notes
diff --git a/app/views/admin/custom_emojis/_custom_emoji.html.haml b/app/views/admin/custom_emojis/_custom_emoji.html.haml
index ff1aa9925..53263c43f 100644
--- a/app/views/admin/custom_emojis/_custom_emoji.html.haml
+++ b/app/views/admin/custom_emojis/_custom_emoji.html.haml
@@ -4,4 +4,17 @@
   %td
     %samp= ":#{custom_emoji.shortcode}:"
   %td
+    - if custom_emoji.local?
+      = t('admin.accounts.location.local')
+    - else
+      = custom_emoji.domain
+  %td
+    - unless custom_emoji.local?
+      = table_link_to 'copy', t('admin.custom_emojis.copy'), copy_admin_custom_emoji_path(custom_emoji), method: :post
+  %td
+    - if custom_emoji.disabled?
+      = table_link_to 'power-off', t('admin.custom_emojis.enable'), enable_admin_custom_emoji_path(custom_emoji), method: :post, data: { confirm: t('admin.accounts.are_you_sure') }
+    - else
+      = table_link_to 'power-off', t('admin.custom_emojis.disable'), disable_admin_custom_emoji_path(custom_emoji), method: :post, data: { confirm: t('admin.accounts.are_you_sure') }
+  %td
     = table_link_to 'times', t('admin.custom_emojis.delete'), admin_custom_emoji_path(custom_emoji), method: :delete, data: { confirm: t('admin.accounts.are_you_sure') }
diff --git a/app/views/admin/custom_emojis/index.html.haml b/app/views/admin/custom_emojis/index.html.haml
index d5f32e84b..20ffb8529 100644
--- a/app/views/admin/custom_emojis/index.html.haml
+++ b/app/views/admin/custom_emojis/index.html.haml
@@ -1,14 +1,34 @@
 - content_for :page_title do
   = t('admin.custom_emojis.title')
 
+.filters
+  .filter-subset
+    %strong= t('admin.accounts.location.title')
+    %ul
+      %li= filter_link_to t('admin.accounts.location.all'), local: nil, remote: nil
+      %li
+        - if selected? local: '1', remote: nil
+          = filter_link_to t('admin.accounts.location.local'), {local: nil, remote: nil}, {local: '1', remote: nil}
+        - else
+          = filter_link_to t('admin.accounts.location.local'), local: '1', remote: nil
+      %li
+        - if selected? remote: '1', local: nil
+          = filter_link_to t('admin.accounts.location.remote'), {remote: nil, local: nil}, {remote: '1', local: nil}
+        - else
+          = filter_link_to t('admin.accounts.location.remote'), remote: '1', local: nil
+
 .table-wrapper
   %table.table
     %thead
       %tr
         %th= t('admin.custom_emojis.emoji')
         %th= t('admin.custom_emojis.shortcode')
+        %th= t('admin.accounts.domain')
+        %th
+        %th
         %th
     %tbody
       = render @custom_emojis
 
+= paginate @custom_emojis
 = link_to t('admin.custom_emojis.upload'), new_admin_custom_emoji_path, class: 'button'
diff --git a/app/views/admin/email_domain_blocks/_email_domain_block.html.haml b/app/views/admin/email_domain_blocks/_email_domain_block.html.haml
new file mode 100644
index 000000000..61cff9395
--- /dev/null
+++ b/app/views/admin/email_domain_blocks/_email_domain_block.html.haml
@@ -0,0 +1,5 @@
+%tr
+  %td.domain
+    %samp= email_domain_block.domain
+  %td
+    = table_link_to 'trash', t('admin.email_domain_blocks.delete'), admin_email_domain_block_path(email_domain_block), method: :delete
diff --git a/app/views/admin/email_domain_blocks/index.html.haml b/app/views/admin/email_domain_blocks/index.html.haml
new file mode 100644
index 000000000..7bb204e52
--- /dev/null
+++ b/app/views/admin/email_domain_blocks/index.html.haml
@@ -0,0 +1,14 @@
+- content_for :page_title do
+  = t('admin.email_domain_blocks.title')
+
+.table-wrapper
+  %table.table
+    %thead
+      %tr
+        %th= t('admin.email_domain_blocks.domain')
+        %th
+    %tbody
+      = render @email_domain_blocks
+
+= paginate @email_domain_blocks
+= link_to t('admin.email_domain_blocks.add_new'), new_admin_email_domain_block_path, class: 'button'
diff --git a/app/views/admin/email_domain_blocks/new.html.haml b/app/views/admin/email_domain_blocks/new.html.haml
new file mode 100644
index 000000000..bcae867d9
--- /dev/null
+++ b/app/views/admin/email_domain_blocks/new.html.haml
@@ -0,0 +1,10 @@
+- content_for :page_title do
+  = t('.title')
+
+= simple_form_for @email_domain_block, url: admin_email_domain_blocks_path do |f|
+  = render 'shared/error_messages', object: @email_domain_block
+
+  = f.input :domain, placeholder: t('admin.email_domain_blocks.domain')
+
+  .actions
+    = f.button :button, t('.create'), type: :submit
diff --git a/app/views/auth/registrations/new.html.haml b/app/views/auth/registrations/new.html.haml
index 807020310..f71675df0 100644
--- a/app/views/auth/registrations/new.html.haml
+++ b/app/views/auth/registrations/new.html.haml
@@ -1,6 +1,9 @@
 - content_for :page_title do
   = t('auth.register')
 
+- content_for :header_tags do
+  = render partial: 'shared/og'
+
 = simple_form_for(resource, as: resource_name, url: registration_path(resource_name)) do |f|
   = render 'shared/error_messages', object: resource
 
diff --git a/app/views/auth/sessions/new.html.haml b/app/views/auth/sessions/new.html.haml
index e589377bf..a52b0053b 100644
--- a/app/views/auth/sessions/new.html.haml
+++ b/app/views/auth/sessions/new.html.haml
@@ -1,6 +1,9 @@
 - content_for :page_title do
   = t('auth.login')
 
+- content_for :header_tags do
+  = render partial: 'shared/og'
+
 = simple_form_for(resource, as: resource_name, url: session_path(resource_name)) do |f|
   = f.input :email, autofocus: true, placeholder: t('simple_form.labels.defaults.email'), required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.email') }
   = f.input :password, placeholder: t('simple_form.labels.defaults.password'), required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.password'), :autocomplete => 'off' }
diff --git a/app/views/layouts/error.html.haml b/app/views/layouts/error.html.haml
index 8b260c619..d0eae4434 100644
--- a/app/views/layouts/error.html.haml
+++ b/app/views/layouts/error.html.haml
@@ -3,12 +3,12 @@
   %head
     %meta{ content: 'text/html; charset=UTF-8', 'http-equiv' => 'Content-Type' }/
     %meta{ charset: 'utf-8' }/
-    %title= safe_join([yield(:page_title), title], ' - ')
+    %title= safe_join([yield(:page_title), Setting.default_settings['site_title']], ' - ')
     %meta{ content: 'width=device-width,initial-scale=1', name: 'viewport' }/
     = stylesheet_pack_tag 'common', media: 'all'
     = stylesheet_pack_tag 'application', integrity: true, media: 'all'
   %body.error
     .dialog
-      %img{ alt: title, src: '/oops.gif' }/
+      %img{ alt: Setting.default_settings['site_title'], src: '/oops.gif' }/
       %div
         %h1= yield :content
diff --git a/app/views/manifests/show.json.rabl b/app/views/manifests/show.json.rabl
deleted file mode 100644
index ee0a70324..000000000
--- a/app/views/manifests/show.json.rabl
+++ /dev/null
@@ -1,11 +0,0 @@
-object false
-
-node(:name)             { Setting.site_title }
-node(:short_name)       { Setting.site_title }
-node(:description)      { strip_tags(Setting.site_description.presence || I18n.t('about.about_mastodon_html')) }
-node(:icons)            { [{ src: '/android-chrome-192x192.png', sizes: '192x192', type: 'image/png' }] }
-node(:theme_color)      { '#282c37' }
-node(:background_color) { '#d9e1e8' }
-node(:display)          { 'standalone' }
-node(:start_url)        { '/web/timelines/home' }
-node(:scope)            { root_url }
diff --git a/app/views/settings/notifications/show.html.haml b/app/views/settings/notifications/show.html.haml
new file mode 100644
index 000000000..80cd615c7
--- /dev/null
+++ b/app/views/settings/notifications/show.html.haml
@@ -0,0 +1,25 @@
+- content_for :page_title do
+  = t('settings.notifications')
+
+= simple_form_for current_user, url: settings_notifications_path, html: { method: :put } do |f|
+  = render 'shared/error_messages', object: current_user
+
+  .fields-group
+    = f.simple_fields_for :notification_emails, hash_to_object(current_user.settings.notification_emails) do |ff|
+      = ff.input :follow, as: :boolean, wrapper: :with_label
+      = ff.input :follow_request, as: :boolean, wrapper: :with_label
+      = ff.input :reblog, as: :boolean, wrapper: :with_label
+      = ff.input :favourite, as: :boolean, wrapper: :with_label
+      = ff.input :mention, as: :boolean, wrapper: :with_label
+ 
+  .fields-group
+    = f.simple_fields_for :notification_emails, hash_to_object(current_user.settings.notification_emails) do |ff|
+      = ff.input :digest, as: :boolean, wrapper: :with_label
+
+  .fields-group
+    = f.simple_fields_for :interactions, hash_to_object(current_user.settings.interactions) do |ff|
+      = ff.input :must_be_follower, as: :boolean, wrapper: :with_label
+      = ff.input :must_be_following, as: :boolean, wrapper: :with_label
+
+  .actions
+    = f.button :button, t('generic.save_changes'), type: :submit
diff --git a/app/views/settings/preferences/show.html.haml b/app/views/settings/preferences/show.html.haml
index 5efd538e4..7475e3fd2 100644
--- a/app/views/settings/preferences/show.html.haml
+++ b/app/views/settings/preferences/show.html.haml
@@ -4,48 +4,31 @@
 = simple_form_for current_user, url: settings_preferences_path, html: { method: :put } do |f|
   = render 'shared/error_messages', object: current_user
 
+  %h4= t 'preferences.languages'
+
   .fields-group
-    = f.input :setting_theme, collection: Themes.instance.names, label_method: lambda { |theme| safe_join([I18n.t("themes.#{theme}", default: theme)])}, wrapper: :with_label, include_blank: false
-
-    = f.input :locale,
-      collection: I18n.available_locales,
-      wrapper: :with_label,
-      include_blank: false,
-      label_method: lambda { |locale| human_locale(locale) },
-      selected: I18n.locale
-
-    = f.input :filtered_languages,
-      collection: filterable_languages,
-      wrapper: :with_block_label,
-      include_blank: false,
-      label_method: lambda { |locale| human_locale(locale) },
-      required: false,
-      as: :check_boxes,
-      collection_wrapper_tag: 'ul',
-      item_wrapper_tag: 'li'
+    = f.input :locale, collection: I18n.available_locales, wrapper: :with_label, include_blank: false, label_method: lambda { |locale| human_locale(locale) }, selected: I18n.locale
 
-    = f.input :setting_default_privacy, collection: Status.visibilities.keys - ['direct'], wrapper: :with_label, include_blank: false, label_method: lambda { |visibility| safe_join([I18n.t("statuses.visibilities.#{visibility}"), content_tag(:span, I18n.t("statuses.visibilities.#{visibility}_long"), class: 'hint')]) }, required: false, as: :radio_buttons, collection_wrapper_tag: 'ul', item_wrapper_tag: 'li'
+    = f.input :filtered_languages, collection: filterable_languages, wrapper: :with_block_label, include_blank: false, label_method: lambda { |locale| human_locale(locale) }, required: false, as: :check_boxes, collection_wrapper_tag: 'ul', item_wrapper_tag: 'li'
 
-    = f.input :setting_default_sensitive, as: :boolean, wrapper: :with_label
+  %h4= t 'preferences.publishing'
 
   .fields-group
-    = f.simple_fields_for :notification_emails, hash_to_object(current_user.settings.notification_emails) do |ff|
-      = ff.input :follow, as: :boolean, wrapper: :with_label
-      = ff.input :follow_request, as: :boolean, wrapper: :with_label
-      = ff.input :reblog, as: :boolean, wrapper: :with_label
-      = ff.input :favourite, as: :boolean, wrapper: :with_label
-      = ff.input :mention, as: :boolean, wrapper: :with_label
-      = ff.input :digest, as: :boolean, wrapper: :with_label
+    = f.input :setting_default_privacy, collection: Status.visibilities.keys - ['direct'], wrapper: :with_label, include_blank: false, label_method: lambda { |visibility| safe_join([I18n.t("statuses.visibilities.#{visibility}"), content_tag(:span, I18n.t("statuses.visibilities.#{visibility}_long"), class: 'hint')]) }, required: false, as: :radio_buttons, collection_wrapper_tag: 'ul', item_wrapper_tag: 'li'
 
-  .fields-group
-    = f.simple_fields_for :interactions, hash_to_object(current_user.settings.interactions) do |ff|
-      = ff.input :must_be_follower, as: :boolean, wrapper: :with_label
-      = ff.input :must_be_following, as: :boolean, wrapper: :with_label
+    = f.input :setting_default_sensitive, as: :boolean, wrapper: :with_label
+
+  %h4= t 'preferences.other'
 
   .fields-group
     = f.input :setting_noindex, as: :boolean, wrapper: :with_label
 
+  %h4= t 'preferences.web'
+
   .fields-group
+    - if Themes.instance.names.size > 1
+      = f.input :setting_theme, collection: Themes.instance.names, label_method: lambda { |theme| I18n.t("themes.#{theme}", default: theme) }, wrapper: :with_label, include_blank: false
+
     = f.input :setting_unfollow_modal, as: :boolean, wrapper: :with_label
     = f.input :setting_boost_modal, as: :boolean, wrapper: :with_label
     = f.input :setting_delete_modal, as: :boolean, wrapper: :with_label
diff --git a/app/views/about/_og.html.haml b/app/views/shared/_og.html.haml
index dbd476915..dbd476915 100644
--- a/app/views/about/_og.html.haml
+++ b/app/views/shared/_og.html.haml
diff --git a/app/views/stream_entries/_og_image.html.haml b/app/views/stream_entries/_og_image.html.haml
index b5058583b..1056c1744 100644
--- a/app/views/stream_entries/_og_image.html.haml
+++ b/app/views/stream_entries/_og_image.html.haml
@@ -17,7 +17,9 @@
       - unless media.file.meta.nil?
         = opengraph 'og:video:width', media.file.meta['small']['width']
         = opengraph 'og:video:height', media.file.meta['small']['height']
+  = opengraph 'twitter:card', 'summary_large_image'
 - else
   = opengraph 'og:image', full_asset_url(account.avatar.url(:original))
   = opengraph 'og:image:width', '120'
   = opengraph 'og:image:height','120'
+  = opengraph 'twitter:card', 'summary'
diff --git a/app/views/stream_entries/show.html.haml b/app/views/stream_entries/show.html.haml
index 1bb8a32b2..428069931 100644
--- a/app/views/stream_entries/show.html.haml
+++ b/app/views/stream_entries/show.html.haml
@@ -14,8 +14,6 @@
   = render 'stream_entries/og_description', activity: @stream_entry.activity
   = render 'stream_entries/og_image', activity: @stream_entry.activity, account: @account
 
-  = opengraph 'twitter:card', 'summary_large_image'
-
 - if show_landing_strip?
   = render partial: 'shared/landing_strip', locals: { account: @stream_entry.account }
 
diff --git a/app/views/tags/_og.html.haml b/app/views/tags/_og.html.haml
new file mode 100644
index 000000000..853a499ae
--- /dev/null
+++ b/app/views/tags/_og.html.haml
@@ -0,0 +1,6 @@
+= opengraph 'og:site_name', t('about.hosted_on', domain: site_hostname)
+= opengraph 'og:url', tag_url(@tag)
+= opengraph 'og:type', 'website'
+= opengraph 'og:title', "##{@tag.name}"
+= opengraph 'og:description', t('about.about_hashtag_html', hashtag: @tag.name)
+= opengraph 'twitter:card', 'summary'
diff --git a/app/views/tags/show.html.haml b/app/views/tags/show.html.haml
index 8cd2f1825..6266d3c0c 100644
--- a/app/views/tags/show.html.haml
+++ b/app/views/tags/show.html.haml
@@ -1,19 +1,38 @@
 - content_for :page_title do
   = "##{@tag.name}"
 
-.compact-header
-  %h1<
-    = link_to site_title, root_path
-    %br
-    %small ##{@tag.name}
+- content_for :header_tags do
+  %script#initial-state{ type: 'application/json' }!= json_escape(@initial_state_json)
+  = javascript_pack_tag 'about', integrity: true, crossorigin: 'anonymous'
+  = render 'og'
 
-- if @statuses.empty?
-  .accounts-grid
-    = render partial: 'accounts/nothing_here'
-- else
-  .activity-stream.h-feed
-    = render partial: 'stream_entries/status', collection: @statuses, as: :status
+.landing-page.tag-page
+  .stripe
+  .features
+    .container
+      #mastodon-timeline{ data: { props: Oj.dump(default_props.merge(hashtag: @tag.name)) } }
 
-- if @statuses.size == 20
-  .pagination
-    = link_to safe_join([t('pagination.next'), fa_icon('chevron-right')], ' '), tag_url(@tag, max_id: @statuses.last.id), class: 'next', rel: 'next'
+      .about-mastodon
+        .brand
+          = link_to root_url do
+            = image_tag asset_pack_path('logo_full.svg'), alt: 'Mastodon'
+
+        %p= t 'about.about_hashtag_html', hashtag: @tag.name
+
+        .cta
+          = link_to t('auth.login'), new_user_session_path, class: 'button button-secondary'
+          = link_to t('about.learn_more'), root_url, class: 'button button-alternative'
+
+        .features-list
+          .features-list__row
+            .text
+              %h6= t 'about.features.not_a_product_title'
+              = t 'about.features.not_a_product_body'
+            .visual
+              = fa_icon 'fw users'
+          .features-list__row
+            .text
+              %h6= t 'about.features.humane_approach_title'
+              = t 'about.features.humane_approach_body'
+            .visual
+              = fa_icon 'fw leaf'
diff --git a/app/views/user_mailer/confirmation_instructions.en.html.erb b/app/views/user_mailer/confirmation_instructions.en.html.erb
index f28a38be2..cd0d70377 100644
--- a/app/views/user_mailer/confirmation_instructions.en.html.erb
+++ b/app/views/user_mailer/confirmation_instructions.en.html.erb
@@ -3,10 +3,13 @@
 <p>You just created an account on <%= @instance %>.</p>
 
 <p>To confirm your inscription, please click on the following link : <br>
-<%= link_to 'Confirm my account', confirmation_url(@resource, confirmation_token: @token) %>
+<%= link_to 'Confirm my account', confirmation_url(@resource, confirmation_token: @token) %></p>
+
+<p>If the above link did not work, copy and paste this URL into your address bar: <br>
+<span><%= confirmation_url(@resource, confirmation_token: @token) %></span>
 
 <p>Please also check out our <%= link_to 'terms and conditions', terms_url %>.</p>
 
 <p>Sincerely,<p>
 
-<p>The <%= @instance %> team</p>
\ No newline at end of file
+<p>The <%= @instance %> team</p>
diff --git a/app/views/user_mailer/confirmation_instructions.ko.html.erb b/app/views/user_mailer/confirmation_instructions.ko.html.erb
new file mode 100644
index 000000000..a749cd97b
--- /dev/null
+++ b/app/views/user_mailer/confirmation_instructions.ko.html.erb
@@ -0,0 +1,13 @@
+<p>안녕하세요 <%= @resource.email %> 님!</p>
+
+<p><%= @instance %>에 새로 계정을 만들었습니다.</p>
+
+<p>아래 링크를 눌러 회원가입을 완료 하세요:<br>
+<%= link_to '계정 활성화', confirmation_url(@resource, confirmation_token: @token) %></p>
+
+<p>만약 위의 링크가 작동하지 않는다면 아래 URL을 복사하여 주소창에 붙여넣으세요</p>
+<span><%= confirmation_url(@resource, confirmation_token: @token) %></span>
+
+<p> <%= link_to '약관', terms_url %>도 확인 바랍니다.</p>
+
+<p><%= @instance %> 드림</p>
diff --git a/app/views/user_mailer/confirmation_instructions.ko.text.erb b/app/views/user_mailer/confirmation_instructions.ko.text.erb
new file mode 100644
index 000000000..c46400f07
--- /dev/null
+++ b/app/views/user_mailer/confirmation_instructions.ko.text.erb
@@ -0,0 +1,10 @@
+안녕하세요 <%= @resource.email %> 님!
+
+<%= @instance %>에 새로 계정을 만들었습니다.
+
+아래 링크를 눌러 회원가입을 완료 하세요.
+<%= confirmation_url(@resource, confirmation_token: @token) %>
+
+약관도 확인 바랍니다. <%= terms_url %>
+
+<%= @instance %> 드림
diff --git a/app/workers/activitypub/delivery_worker.rb b/app/workers/activitypub/delivery_worker.rb
index a4e829343..7b1e06a70 100644
--- a/app/workers/activitypub/delivery_worker.rb
+++ b/app/workers/activitypub/delivery_worker.rb
@@ -3,7 +3,7 @@
 class ActivityPub::DeliveryWorker
   include Sidekiq::Worker
 
-  sidekiq_options queue: 'push', retry: 5, dead: false
+  sidekiq_options queue: 'push', retry: 8, dead: false
 
   HEADERS = { 'Content-Type' => 'application/activity+json' }.freeze
 
@@ -15,7 +15,10 @@ class ActivityPub::DeliveryWorker
     perform_request
 
     raise Mastodon::UnexpectedResponseError, @response unless response_successful?
+
+    failure_tracker.track_success!
   rescue => e
+    failure_tracker.track_failure!
     raise e.class, "Delivery failed for #{inbox_url}: #{e.message}", e.backtrace[0]
   end
 
@@ -28,10 +31,14 @@ class ActivityPub::DeliveryWorker
   end
 
   def perform_request
-    @response = build_request.perform
+    @response = build_request.perform.flush
   end
 
   def response_successful?
     @response.code > 199 && @response.code < 300
   end
+
+  def failure_tracker
+    @failure_tracker ||= DeliveryFailureTracker.new(@inbox_url)
+  end
 end
diff --git a/app/workers/activitypub/processing_worker.rb b/app/workers/activitypub/processing_worker.rb
index bb9adf64b..0e2e0eddd 100644
--- a/app/workers/activitypub/processing_worker.rb
+++ b/app/workers/activitypub/processing_worker.rb
@@ -6,6 +6,6 @@ class ActivityPub::ProcessingWorker
   sidekiq_options backtrace: true
 
   def perform(account_id, body)
-    ActivityPub::ProcessCollectionService.new.call(body, Account.find(account_id))
+    ActivityPub::ProcessCollectionService.new.call(body, Account.find(account_id), override_timestamps: true)
   end
 end
diff --git a/app/workers/import/relationship_worker.rb b/app/workers/import/relationship_worker.rb
new file mode 100644
index 000000000..ed4c962c1
--- /dev/null
+++ b/app/workers/import/relationship_worker.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+class Import::RelationshipWorker
+  include Sidekiq::Worker
+
+  sidekiq_options queue: 'pull', retry: 8, dead: false
+
+  def perform(account_id, target_account_uri, relationship)
+    from_account   = Account.find(account_id)
+    target_account = ResolveRemoteAccountService.new.call(target_account_uri)
+
+    return if target_account.nil?
+
+    case relationship
+    when 'follow'
+      FollowService.new.call(from_account, target_account.acct)
+    when 'block'
+      BlockService.new.call(from_account, target_account)
+    when 'mute'
+      MuteService.new.call(from_account, target_account)
+    end
+  rescue ActiveRecord::RecordNotFound
+    true
+  end
+end
diff --git a/app/workers/import_worker.rb b/app/workers/import_worker.rb
index 27cc6b365..d7c126f75 100644
--- a/app/workers/import_worker.rb
+++ b/app/workers/import_worker.rb
@@ -12,13 +12,8 @@ class ImportWorker
   def perform(import_id)
     @import = Import.find(import_id)
 
-    case @import.type
-    when 'blocking'
-      process_blocks
-    when 'following'
-      process_follows
-    when 'muting'
-      process_mutes
+    Import::RelationshipWorker.push_bulk(import_rows) do |row|
+      [@import.account_id, row.first, relationship_type]
     end
 
     @import.destroy
@@ -26,49 +21,22 @@ class ImportWorker
 
   private
 
-  def from_account
-    @import.account
-  end
-
   def import_contents
     Paperclip.io_adapters.for(@import.data).read
   end
 
-  def import_rows
-    CSV.new(import_contents).reject(&:blank?)
-  end
-
-  def process_mutes
-    import_rows.each do |row|
-      begin
-        target_account = ResolveRemoteAccountService.new.call(row.first)
-        next if target_account.nil?
-        MuteService.new.call(from_account, target_account)
-      rescue Mastodon::UnexpectedResponseError, HTTP::Error, OpenSSL::SSL::SSLError
-        next
-      end
-    end
-  end
-
-  def process_blocks
-    import_rows.each do |row|
-      begin
-        target_account = ResolveRemoteAccountService.new.call(row.first)
-        next if target_account.nil?
-        BlockService.new.call(from_account, target_account)
-      rescue Mastodon::UnexpectedResponseError, HTTP::Error, OpenSSL::SSL::SSLError
-        next
-      end
+  def relationship_type
+    case @import.type
+    when 'following'
+      'follow'
+    when 'blocking'
+      'block'
+    when 'muting'
+      'mute'
     end
   end
 
-  def process_follows
-    import_rows.each do |row|
-      begin
-        FollowService.new.call(from_account, row.first)
-      rescue Mastodon::NotPermittedError, ActiveRecord::RecordNotFound, Mastodon::UnexpectedResponseError, HTTP::Error, OpenSSL::SSL::SSLError
-        next
-      end
-    end
+  def import_rows
+    CSV.new(import_contents).reject(&:blank?)
   end
 end
diff --git a/app/workers/link_crawl_worker.rb b/app/workers/link_crawl_worker.rb
index 834b0088b..b3d8aa264 100644
--- a/app/workers/link_crawl_worker.rb
+++ b/app/workers/link_crawl_worker.rb
@@ -3,7 +3,7 @@
 class LinkCrawlWorker
   include Sidekiq::Worker
 
-  sidekiq_options queue: 'pull', retry: false
+  sidekiq_options queue: 'pull', retry: 0
 
   def perform(status_id)
     FetchLinkCardService.new.call(Status.find(status_id))
diff --git a/app/workers/processing_worker.rb b/app/workers/processing_worker.rb
index 5df404bcc..978c3aba2 100644
--- a/app/workers/processing_worker.rb
+++ b/app/workers/processing_worker.rb
@@ -6,6 +6,6 @@ class ProcessingWorker
   sidekiq_options backtrace: true
 
   def perform(account_id, body)
-    ProcessFeedService.new.call(body, Account.find(account_id))
+    ProcessFeedService.new.call(body, Account.find(account_id), override_timestamps: true)
   end
 end
diff --git a/app/workers/pubsubhubbub/delivery_worker.rb b/app/workers/pubsubhubbub/delivery_worker.rb
index 110b8bf16..c3506727b 100644
--- a/app/workers/pubsubhubbub/delivery_worker.rb
+++ b/app/workers/pubsubhubbub/delivery_worker.rb
@@ -37,7 +37,7 @@ class Pubsubhubbub::DeliveryWorker
   def callback_post_payload
     request = Request.new(:post, subscription.callback_url, body: payload)
     request.add_headers(headers)
-    request.perform
+    request.perform.flush
   end
 
   def blocked_domain?