about summary refs log tree commit diff
path: root/app
diff options
context:
space:
mode:
Diffstat (limited to 'app')
-rw-r--r--app/controllers/accounts_controller.rb12
-rw-r--r--app/controllers/activitypub/follows_controller.rb22
-rw-r--r--app/controllers/admin/custom_emojis_controller.rb24
-rw-r--r--app/controllers/admin/settings_controller.rb4
-rw-r--r--app/controllers/api/v1/instances/activity_controller.rb36
-rw-r--r--app/controllers/api/v1/instances/peers_controller.rb17
-rw-r--r--app/controllers/application_controller.rb22
-rw-r--r--app/controllers/auth/confirmations_controller.rb7
-rw-r--r--app/controllers/auth/registrations_controller.rb4
-rw-r--r--app/controllers/authorize_follows_controller.rb5
-rw-r--r--app/controllers/concerns/user_tracking_concern.rb1
-rw-r--r--app/controllers/emojis_controller.rb10
-rw-r--r--app/controllers/remote_follow_controller.rb4
-rw-r--r--app/controllers/shares_controller.rb2
-rw-r--r--app/controllers/statuses_controller.rb19
-rw-r--r--app/controllers/well_known/host_meta_controller.rb6
-rw-r--r--app/controllers/well_known/webfinger_controller.rb6
-rw-r--r--app/helpers/admin/action_logs_helper.rb2
-rw-r--r--app/helpers/routing_helper.rb5
-rw-r--r--app/helpers/settings_helper.rb2
-rw-r--r--app/javascript/flavours/glitch/actions/compose.js25
-rw-r--r--app/javascript/flavours/glitch/components/account.js26
-rw-r--r--app/javascript/flavours/glitch/components/autosuggest_emoji.js42
-rw-r--r--app/javascript/flavours/glitch/components/autosuggest_textarea.js223
-rw-r--r--app/javascript/flavours/glitch/components/avatar.js18
-rw-r--r--app/javascript/flavours/glitch/components/display_name.js12
-rw-r--r--app/javascript/flavours/glitch/components/dropdown_menu.js12
-rw-r--r--app/javascript/flavours/glitch/components/icon.js26
-rw-r--r--app/javascript/flavours/glitch/components/link.js97
-rw-r--r--app/javascript/flavours/glitch/components/text_icon_button.js (renamed from app/javascript/flavours/glitch/features/compose/components/text_icon_button.js)0
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/advanced_options.js62
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/advanced_options_toggle.js35
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/attach_options.js131
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/autosuggest_account.js24
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/character_counter.js25
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/compose_form.js286
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/dropdown.js77
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/navigation_bar.js38
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/privacy_dropdown.js200
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/reply_indicator.js67
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/search.js129
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/search_results.js65
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/upload.js96
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/upload_button.js77
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/upload_form.js29
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/upload_progress.js42
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/warning.js26
-rw-r--r--app/javascript/flavours/glitch/features/compose/containers/advanced_options_container.js20
-rw-r--r--app/javascript/flavours/glitch/features/compose/containers/autosuggest_account_container.js15
-rw-r--r--app/javascript/flavours/glitch/features/compose/containers/compose_form_container.js71
-rw-r--r--app/javascript/flavours/glitch/features/compose/containers/emoji_picker_dropdown_container.js82
-rw-r--r--app/javascript/flavours/glitch/features/compose/containers/navigation_container.js11
-rw-r--r--app/javascript/flavours/glitch/features/compose/containers/privacy_dropdown_container.js24
-rw-r--r--app/javascript/flavours/glitch/features/compose/containers/reply_indicator_container.js24
-rw-r--r--app/javascript/flavours/glitch/features/compose/containers/search_container.js35
-rw-r--r--app/javascript/flavours/glitch/features/compose/containers/search_results_container.js8
-rw-r--r--app/javascript/flavours/glitch/features/compose/containers/sensitive_button_container.js71
-rw-r--r--app/javascript/flavours/glitch/features/compose/containers/spoiler_button_container.js25
-rw-r--r--app/javascript/flavours/glitch/features/compose/containers/upload_button_container.js18
-rw-r--r--app/javascript/flavours/glitch/features/compose/containers/upload_container.js21
-rw-r--r--app/javascript/flavours/glitch/features/compose/containers/upload_form_container.js8
-rw-r--r--app/javascript/flavours/glitch/features/compose/containers/upload_progress_container.js9
-rw-r--r--app/javascript/flavours/glitch/features/compose/containers/warning_container.js24
-rw-r--r--app/javascript/flavours/glitch/features/compose/index.js126
-rw-r--r--app/javascript/flavours/glitch/features/composer/index.js423
-rw-r--r--app/javascript/flavours/glitch/features/composer/options/dropdown/content/index.js138
-rw-r--r--app/javascript/flavours/glitch/features/composer/options/dropdown/content/item/index.js126
-rw-r--r--app/javascript/flavours/glitch/features/composer/options/dropdown/index.js225
-rw-r--r--app/javascript/flavours/glitch/features/composer/options/index.js329
-rw-r--r--app/javascript/flavours/glitch/features/composer/publisher/index.js121
-rw-r--r--app/javascript/flavours/glitch/features/composer/reply/index.js113
-rw-r--r--app/javascript/flavours/glitch/features/composer/spoiler/index.js92
-rw-r--r--app/javascript/flavours/glitch/features/composer/textarea/index.js298
-rw-r--r--app/javascript/flavours/glitch/features/composer/textarea/suggestions/index.js43
-rw-r--r--app/javascript/flavours/glitch/features/composer/textarea/suggestions/item/index.js101
-rw-r--r--app/javascript/flavours/glitch/features/composer/upload_form/index.js53
-rw-r--r--app/javascript/flavours/glitch/features/composer/upload_form/item/index.js177
-rw-r--r--app/javascript/flavours/glitch/features/composer/upload_form/progress/index.js52
-rw-r--r--app/javascript/flavours/glitch/features/composer/warning/index.js54
-rw-r--r--app/javascript/flavours/glitch/features/drawer/account/index.js71
-rw-r--r--app/javascript/flavours/glitch/features/drawer/header/index.js118
-rw-r--r--app/javascript/flavours/glitch/features/drawer/index.js127
-rw-r--r--app/javascript/flavours/glitch/features/drawer/results/index.js116
-rw-r--r--app/javascript/flavours/glitch/features/drawer/search/index.js151
-rw-r--r--app/javascript/flavours/glitch/features/drawer/search/popout/index.js99
-rw-r--r--app/javascript/flavours/glitch/features/emoji_picker/index.js (renamed from app/javascript/flavours/glitch/features/compose/components/emoji_picker_dropdown.js)80
-rw-r--r--app/javascript/flavours/glitch/features/getting_started/index.js6
-rw-r--r--app/javascript/flavours/glitch/features/standalone/compose/index.js4
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/actions_modal.js75
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/columns_area.js4
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/onboarding_modal.js60
-rw-r--r--app/javascript/flavours/glitch/features/ui/index.js12
-rw-r--r--app/javascript/flavours/glitch/locales/pl.js14
-rw-r--r--app/javascript/flavours/glitch/names.yml7
-rw-r--r--app/javascript/flavours/glitch/reducers/compose.js10
-rw-r--r--app/javascript/flavours/glitch/styles/components/composer.scss419
-rw-r--r--app/javascript/flavours/glitch/styles/components/drawer.scss223
-rw-r--r--app/javascript/flavours/glitch/styles/components/index.scss938
-rw-r--r--app/javascript/flavours/glitch/theme.yml2
-rw-r--r--app/javascript/flavours/glitch/util/async-components.js4
-rw-r--r--app/javascript/flavours/glitch/util/dom_helpers.js14
-rw-r--r--app/javascript/flavours/glitch/util/emoji/index.js1
-rw-r--r--app/javascript/flavours/glitch/util/initial_state.js1
-rw-r--r--app/javascript/flavours/glitch/util/react_helpers.js21
-rw-r--r--app/javascript/flavours/glitch/util/redux_helpers.js8
-rw-r--r--app/javascript/flavours/vanilla/names.yml8
-rw-r--r--app/javascript/images/mastodon-getting-started.pngbin46174 -> 0 bytes
-rw-r--r--app/javascript/images/mastodon-ui.pngbin0 -> 32449 bytes
-rw-r--r--app/javascript/images/wave-compose-standalone.pngbin0 -> 5921 bytes
-rw-r--r--app/javascript/images/wave-drawer.pngbin0 -> 3269 bytes
-rw-r--r--app/javascript/images/wave-modal.pngbin0 -> 5200 bytes
-rw-r--r--app/javascript/mastodon/actions/notifications.js2
-rw-r--r--app/javascript/mastodon/actions/push_notifications/index.js4
-rw-r--r--app/javascript/mastodon/actions/push_notifications/registerer.js2
-rw-r--r--app/javascript/mastodon/actions/push_notifications/setter.js4
-rw-r--r--app/javascript/mastodon/actions/settings.js6
-rw-r--r--app/javascript/mastodon/features/compose/containers/warning_container.js9
-rw-r--r--app/javascript/mastodon/features/compose/index.js1
-rw-r--r--app/javascript/mastodon/features/getting_started/index.js48
-rw-r--r--app/javascript/mastodon/features/notifications/components/column_settings.js4
-rw-r--r--app/javascript/mastodon/features/notifications/containers/column_settings_container.js8
-rw-r--r--app/javascript/mastodon/locales/ar.json3
-rw-r--r--app/javascript/mastodon/locales/bg.json3
-rw-r--r--app/javascript/mastodon/locales/ca.json3
-rw-r--r--app/javascript/mastodon/locales/de.json3
-rw-r--r--app/javascript/mastodon/locales/defaultMessages.json30
-rw-r--r--app/javascript/mastodon/locales/en.json3
-rw-r--r--app/javascript/mastodon/locales/eo.json3
-rw-r--r--app/javascript/mastodon/locales/es.json3
-rw-r--r--app/javascript/mastodon/locales/fa.json3
-rw-r--r--app/javascript/mastodon/locales/fi.json3
-rw-r--r--app/javascript/mastodon/locales/fr.json3
-rw-r--r--app/javascript/mastodon/locales/gl.json3
-rw-r--r--app/javascript/mastodon/locales/he.json3
-rw-r--r--app/javascript/mastodon/locales/hr.json3
-rw-r--r--app/javascript/mastodon/locales/hu.json3
-rw-r--r--app/javascript/mastodon/locales/id.json3
-rw-r--r--app/javascript/mastodon/locales/io.json3
-rw-r--r--app/javascript/mastodon/locales/it.json3
-rw-r--r--app/javascript/mastodon/locales/ja.json5
-rw-r--r--app/javascript/mastodon/locales/ko.json3
-rw-r--r--app/javascript/mastodon/locales/nl.json3
-rw-r--r--app/javascript/mastodon/locales/no.json3
-rw-r--r--app/javascript/mastodon/locales/oc.json3
-rw-r--r--app/javascript/mastodon/locales/pl.json3
-rw-r--r--app/javascript/mastodon/locales/pt-BR.json3
-rw-r--r--app/javascript/mastodon/locales/pt.json3
-rw-r--r--app/javascript/mastodon/locales/ru.json3
-rw-r--r--app/javascript/mastodon/locales/sk.json472
-rw-r--r--app/javascript/mastodon/locales/sr-Latn.json262
-rw-r--r--app/javascript/mastodon/locales/sr.json262
-rw-r--r--app/javascript/mastodon/locales/sv.json3
-rw-r--r--app/javascript/mastodon/locales/th.json3
-rw-r--r--app/javascript/mastodon/locales/tr.json3
-rw-r--r--app/javascript/mastodon/locales/uk.json3
-rw-r--r--app/javascript/mastodon/locales/whitelist_sk.json2
-rw-r--r--app/javascript/mastodon/locales/whitelist_sr.json2
-rw-r--r--app/javascript/mastodon/locales/zh-CN.json3
-rw-r--r--app/javascript/mastodon/locales/zh-HK.json3
-rw-r--r--app/javascript/mastodon/locales/zh-TW.json27
-rw-r--r--app/javascript/mastodon/main.js2
-rw-r--r--app/javascript/mastodon/reducers/push_notifications.js2
-rw-r--r--app/javascript/mastodon/reducers/settings.js2
-rw-r--r--app/javascript/styles/application.scss1
-rw-r--r--app/javascript/styles/mastodon/admin.scss10
-rw-r--r--app/javascript/styles/mastodon/components.scss21
-rw-r--r--app/javascript/styles/mastodon/modal.scss20
-rw-r--r--app/lib/activity_tracker.rb31
-rw-r--r--app/lib/activitypub/activity/accept.rb17
-rw-r--r--app/lib/activitypub/activity/delete.rb1
-rw-r--r--app/lib/activitypub/tag_manager.rb8
-rw-r--r--app/lib/formatter.rb6
-rw-r--r--app/lib/provider_discovery.rb2
-rw-r--r--app/lib/sanitize_config.rb8
-rw-r--r--app/mailers/user_mailer.rb15
-rw-r--r--app/models/follow_request.rb4
-rw-r--r--app/models/form/admin_settings.rb4
-rw-r--r--app/models/status.rb6
-rw-r--r--app/models/user.rb26
-rw-r--r--app/serializers/activitypub/delete_actor_serializer.rb22
-rw-r--r--app/serializers/activitypub/follow_serializer.rb9
-rw-r--r--app/serializers/rest/instance_serializer.rb2
-rw-r--r--app/services/activitypub/process_account_service.rb2
-rw-r--r--app/services/batched_remove_status_service.rb38
-rw-r--r--app/services/fetch_atom_service.rb8
-rw-r--r--app/services/suspend_account_service.rb12
-rw-r--r--app/views/accounts/_header.html.haml2
-rw-r--r--app/views/admin/custom_emojis/_custom_emoji.html.haml14
-rw-r--r--app/views/admin/custom_emojis/index.html.haml2
-rw-r--r--app/views/admin/settings/edit.html.haml8
-rwxr-xr-xapp/views/layouts/application.html.haml3
-rw-r--r--app/views/layouts/modal.html.haml2
-rw-r--r--app/views/user_mailer/confirmation_instructions.pl.html.erb2
-rw-r--r--app/views/user_mailer/confirmation_instructions.pl.text.erb2
-rw-r--r--app/views/user_mailer/confirmation_instructions.sr-Latn.html.erb15
-rw-r--r--app/views/user_mailer/confirmation_instructions.sr-Latn.text.erb12
-rw-r--r--app/views/user_mailer/confirmation_instructions.sr.html.erb15
-rw-r--r--app/views/user_mailer/confirmation_instructions.sr.text.erb12
-rw-r--r--app/views/user_mailer/email_changed.en.html.erb15
-rw-r--r--app/views/user_mailer/email_changed.en.text.erb13
-rw-r--r--app/views/user_mailer/email_changed.ja.html.erb13
-rw-r--r--app/views/user_mailer/email_changed.ja.text.erb11
-rw-r--r--app/views/user_mailer/email_changed.pl.html.erb15
-rw-r--r--app/views/user_mailer/email_changed.pl.text.erb13
-rw-r--r--app/views/user_mailer/password_change.sr-Latn.html.erb3
-rw-r--r--app/views/user_mailer/password_change.sr-Latn.text.erb3
-rw-r--r--app/views/user_mailer/password_change.sr.html.erb3
-rw-r--r--app/views/user_mailer/password_change.sr.text.erb3
-rw-r--r--app/views/user_mailer/reconfirmation_instructions.en.html.erb15
-rw-r--r--app/views/user_mailer/reconfirmation_instructions.en.text.erb12
-rw-r--r--app/views/user_mailer/reconfirmation_instructions.ja.html.erb13
-rw-r--r--app/views/user_mailer/reconfirmation_instructions.ja.text.erb10
-rw-r--r--app/views/user_mailer/reconfirmation_instructions.pl.html.erb15
-rw-r--r--app/views/user_mailer/reconfirmation_instructions.pl.text.erb12
-rw-r--r--app/views/user_mailer/reset_password_instructions.sr-Latn.html.erb8
-rw-r--r--app/views/user_mailer/reset_password_instructions.sr-Latn.text.erb8
-rw-r--r--app/views/user_mailer/reset_password_instructions.sr.html.erb8
-rw-r--r--app/views/user_mailer/reset_password_instructions.sr.text.erb8
218 files changed, 5780 insertions, 3662 deletions
diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb
index 31144fe05..c9725ed00 100644
--- a/app/controllers/accounts_controller.rb
+++ b/app/controllers/accounts_controller.rb
@@ -2,7 +2,8 @@
 
 class AccountsController < ApplicationController
   include AccountControllerConcern
-  include SignatureVerification
+
+  before_action :set_cache_headers
 
   def show
     respond_to do |format|
@@ -27,10 +28,11 @@ class AccountsController < ApplicationController
       end
 
       format.json do
-        render json: @account,
-               serializer: ActivityPub::ActorSerializer,
-               adapter: ActivityPub::Adapter,
-               content_type: 'application/activity+json'
+        skip_session!
+
+        render_cached_json(['activitypub', 'actor', @account.cache_key], content_type: 'application/activity+json') do
+          ActiveModelSerializers::SerializableResource.new(@account, serializer: ActivityPub::ActorSerializer, adapter: ActivityPub::Adapter)
+        end
       end
     end
   end
diff --git a/app/controllers/activitypub/follows_controller.rb b/app/controllers/activitypub/follows_controller.rb
new file mode 100644
index 000000000..038bcbabc
--- /dev/null
+++ b/app/controllers/activitypub/follows_controller.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+class ActivityPub::FollowsController < Api::BaseController
+  include SignatureVerification
+
+  def show
+    render json: follow_request,
+           serializer: ActivityPub::FollowSerializer,
+           adapter: ActivityPub::Adapter,
+           content_type: 'application/activity+json'
+  end
+
+  private
+
+  def follow_request
+    FollowRequest.includes(:account).references(:account).find_by!(
+      id: params.require(:id),
+      accounts: { domain: nil, username: params.require(:account_username) },
+      target_account: signed_request_account
+    )
+  end
+end
diff --git a/app/controllers/admin/custom_emojis_controller.rb b/app/controllers/admin/custom_emojis_controller.rb
index ccab03de4..d61bafdf0 100644
--- a/app/controllers/admin/custom_emojis_controller.rb
+++ b/app/controllers/admin/custom_emojis_controller.rb
@@ -3,6 +3,7 @@
 module Admin
   class CustomEmojisController < BaseController
     before_action :set_custom_emoji, except: [:index, :new, :create]
+    before_action :set_filter_params
 
     def index
       authorize :custom_emoji, :index?
@@ -32,23 +33,26 @@ module Admin
 
       if @custom_emoji.update(resource_params)
         log_action :update, @custom_emoji
-        redirect_to admin_custom_emojis_path, notice: I18n.t('admin.custom_emojis.updated_msg')
+        flash[:notice] = I18n.t('admin.custom_emojis.updated_msg')
       else
-        redirect_to admin_custom_emojis_path, notice: I18n.t('admin.custom_emojis.update_failed_msg')
+        flash[:alert] =  I18n.t('admin.custom_emojis.update_failed_msg')
       end
+      redirect_to admin_custom_emojis_path(page: params[:page], **@filter_params)
     end
 
     def destroy
       authorize @custom_emoji, :destroy?
       @custom_emoji.destroy!
       log_action :destroy, @custom_emoji
-      redirect_to admin_custom_emojis_path, notice: I18n.t('admin.custom_emojis.destroyed_msg')
+      flash[:notice] = I18n.t('admin.custom_emojis.destroyed_msg')
+      redirect_to admin_custom_emojis_path(page: params[:page], **@filter_params)
     end
 
     def copy
       authorize @custom_emoji, :copy?
 
-      emoji = CustomEmoji.find_or_initialize_by(domain: nil, shortcode: @custom_emoji.shortcode)
+      emoji = CustomEmoji.find_or_initialize_by(domain: nil,
+                                                shortcode: @custom_emoji.shortcode)
       emoji.image = @custom_emoji.image
 
       if emoji.save
@@ -58,21 +62,23 @@ module Admin
         flash[:alert] = I18n.t('admin.custom_emojis.copy_failed_msg')
       end
 
-      redirect_to admin_custom_emojis_path(page: params[:page])
+      redirect_to admin_custom_emojis_path(page: params[:page], **@filter_params)
     end
 
     def enable
       authorize @custom_emoji, :enable?
       @custom_emoji.update!(disabled: false)
       log_action :enable, @custom_emoji
-      redirect_to admin_custom_emojis_path, notice: I18n.t('admin.custom_emojis.enabled_msg')
+      flash[:notice] = I18n.t('admin.custom_emojis.enabled_msg')
+      redirect_to admin_custom_emojis_path(page: params[:page], **@filter_params)
     end
 
     def disable
       authorize @custom_emoji, :disable?
       @custom_emoji.update!(disabled: true)
       log_action :disable, @custom_emoji
-      redirect_to admin_custom_emojis_path, notice: I18n.t('admin.custom_emojis.disabled_msg')
+      flash[:notice] = I18n.t('admin.custom_emojis.disabled_msg')
+      redirect_to admin_custom_emojis_path(page: params[:page], **@filter_params)
     end
 
     private
@@ -81,6 +87,10 @@ module Admin
       @custom_emoji = CustomEmoji.find(params[:id])
     end
 
+    def set_filter_params
+      @filter_params = filter_params.to_hash.symbolize_keys
+    end
+
     def resource_params
       params.require(:custom_emoji).permit(:shortcode, :image, :visible_in_picker)
     end
diff --git a/app/controllers/admin/settings_controller.rb b/app/controllers/admin/settings_controller.rb
index eed5fb6b5..487282dc3 100644
--- a/app/controllers/admin/settings_controller.rb
+++ b/app/controllers/admin/settings_controller.rb
@@ -17,6 +17,8 @@ module Admin
       bootstrap_timeline_accounts
       thumbnail
       min_invite_role
+      activity_api_enabled
+      peers_api_enabled
     ).freeze
 
     BOOLEAN_SETTINGS = %w(
@@ -24,6 +26,8 @@ module Admin
       open_deletion
       timeline_preview
       show_staff_badge
+      activity_api_enabled
+      peers_api_enabled
     ).freeze
 
     UPLOAD_SETTINGS = %w(
diff --git a/app/controllers/api/v1/instances/activity_controller.rb b/app/controllers/api/v1/instances/activity_controller.rb
new file mode 100644
index 000000000..36f52c38d
--- /dev/null
+++ b/app/controllers/api/v1/instances/activity_controller.rb
@@ -0,0 +1,36 @@
+# frozen_string_literal: true
+
+class Api::V1::Instances::ActivityController < Api::BaseController
+  before_action :require_enabled_api!
+
+  respond_to :json
+
+  def show
+    render_cached_json('api:v1:instances:activity:show', expires_in: 1.day) { activity }
+  end
+
+  private
+
+  def activity
+    weeks = []
+
+    12.times do |i|
+      day     = i.weeks.ago.to_date
+      week_id = day.cweek
+      week    = Date.commercial(day.cwyear, week_id)
+
+      weeks << {
+        week: week.to_time.to_i.to_s,
+        statuses: Redis.current.get("activity:statuses:local:#{week_id}") || 0,
+        logins: Redis.current.pfcount("activity:logins:#{week_id}"),
+        registrations: Redis.current.get("activity:accounts:local:#{week_id}") || 0,
+      }
+    end
+
+    weeks
+  end
+
+  def require_enabled_api!
+    head 404 unless Setting.activity_api_enabled
+  end
+end
diff --git a/app/controllers/api/v1/instances/peers_controller.rb b/app/controllers/api/v1/instances/peers_controller.rb
new file mode 100644
index 000000000..2070c487d
--- /dev/null
+++ b/app/controllers/api/v1/instances/peers_controller.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+class Api::V1::Instances::PeersController < Api::BaseController
+  before_action :require_enabled_api!
+
+  respond_to :json
+
+  def index
+    render_cached_json('api:v1:instances:peers:index', expires_in: 1.day) { Account.remote.domains }
+  end
+
+  private
+
+  def require_enabled_api!
+    head 404 unless Setting.peers_api_enabled
+  end
+end
diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
index 3b2070f39..679b94f1e 100644
--- a/app/controllers/application_controller.rb
+++ b/app/controllers/application_controller.rb
@@ -196,4 +196,26 @@ class ApplicationController < ActionController::Base
       end
     end
   end
+
+  def render_cached_json(cache_key, **options)
+    options[:expires_in] ||= 3.minutes
+    options[:public]     ||= true
+    cache_key              = cache_key.join(':') if cache_key.is_a?(Enumerable)
+    content_type           = options.delete(:content_type) || 'application/json'
+
+    data = Rails.cache.fetch(cache_key, { raw: true }.merge(options)) do
+      yield.to_json
+    end
+
+    expires_in options[:expires_in], public: options[:public]
+    render json: data, content_type: content_type
+  end
+
+  def set_cache_headers
+    response.headers['Vary'] = 'Accept'
+  end
+
+  def skip_session!
+    request.session_options[:skip] = true
+  end
 end
diff --git a/app/controllers/auth/confirmations_controller.rb b/app/controllers/auth/confirmations_controller.rb
index 5ffa1c9a3..72b8e9dd8 100644
--- a/app/controllers/auth/confirmations_controller.rb
+++ b/app/controllers/auth/confirmations_controller.rb
@@ -2,14 +2,9 @@
 
 class Auth::ConfirmationsController < Devise::ConfirmationsController
   layout 'auth'
+
   before_action :set_pack
 
-  def show
-    super do |user|
-      BootstrapTimelineWorker.perform_async(user.account_id) if user.errors.empty?
-    end
-  end
-  
   private
 
   def set_pack
diff --git a/app/controllers/auth/registrations_controller.rb b/app/controllers/auth/registrations_controller.rb
index f4247fd95..2b6a1bdbc 100644
--- a/app/controllers/auth/registrations_controller.rb
+++ b/app/controllers/auth/registrations_controller.rb
@@ -38,6 +38,10 @@ class Auth::RegistrationsController < Devise::RegistrationsController
     new_user_session_path
   end
 
+  def after_update_path_for(_resource)
+    edit_user_registration_path
+  end
+
   def check_enabled_registrations
     redirect_to root_path if single_user_mode? || !allowed_registrations?
   end
diff --git a/app/controllers/authorize_follows_controller.rb b/app/controllers/authorize_follows_controller.rb
index 2d29bd379..eda50e07d 100644
--- a/app/controllers/authorize_follows_controller.rb
+++ b/app/controllers/authorize_follows_controller.rb
@@ -5,6 +5,7 @@ class AuthorizeFollowsController < ApplicationController
 
   before_action :authenticate_user!
   before_action :set_pack
+  before_action :set_body_classes
 
   def show
     @account = located_account || render(:error)
@@ -63,4 +64,8 @@ class AuthorizeFollowsController < ApplicationController
   def acct_params
     params.fetch(:acct, '')
   end
+
+  def set_body_classes
+    @body_classes = 'modal-layout'
+  end
 end
diff --git a/app/controllers/concerns/user_tracking_concern.rb b/app/controllers/concerns/user_tracking_concern.rb
index 8663c3086..1e3132941 100644
--- a/app/controllers/concerns/user_tracking_concern.rb
+++ b/app/controllers/concerns/user_tracking_concern.rb
@@ -17,6 +17,7 @@ module UserTrackingConcern
 
     # Mark as signed-in today
     current_user.update_tracked_fields!(request)
+    ActivityTracker.record('activity:logins', current_user.id)
 
     # Regenerate feed if needed
     regenerate_feed! if user_needs_feed_update?
diff --git a/app/controllers/emojis_controller.rb b/app/controllers/emojis_controller.rb
index a82b9340b..c9725ccc0 100644
--- a/app/controllers/emojis_controller.rb
+++ b/app/controllers/emojis_controller.rb
@@ -2,14 +2,16 @@
 
 class EmojisController < ApplicationController
   before_action :set_emoji
+  before_action :set_cache_headers
 
   def show
     respond_to do |format|
       format.json do
-        render json: @emoji,
-               serializer: ActivityPub::EmojiSerializer,
-               adapter: ActivityPub::Adapter,
-               content_type: 'application/activity+json'
+        skip_session!
+
+        render_cached_json(['activitypub', 'emoji', @emoji.cache_key], content_type: 'application/activity+json') do
+          ActiveModelSerializers::SerializableResource.new(@emoji, serializer: ActivityPub::EmojiSerializer, adapter: ActivityPub::Adapter)
+        end
       end
     end
   end
diff --git a/app/controllers/remote_follow_controller.rb b/app/controllers/remote_follow_controller.rb
index e6f379886..41c021781 100644
--- a/app/controllers/remote_follow_controller.rb
+++ b/app/controllers/remote_follow_controller.rb
@@ -43,4 +43,8 @@ class RemoteFollowController < ApplicationController
   def suspended_account?
     @account.suspended?
   end
+
+  def set_body_classes
+    @body_classes = 'modal-layout'
+  end
 end
diff --git a/app/controllers/shares_controller.rb b/app/controllers/shares_controller.rb
index 81d279c8b..9c738fc4f 100644
--- a/app/controllers/shares_controller.rb
+++ b/app/controllers/shares_controller.rb
@@ -30,6 +30,6 @@ class SharesController < ApplicationController
   end
 
   def set_body_classes
-    @body_classes = 'compose-standalone'
+    @body_classes = 'modal-layout compose-standalone'
   end
 end
diff --git a/app/controllers/statuses_controller.rb b/app/controllers/statuses_controller.rb
index 84c9e7685..d67fac0e5 100644
--- a/app/controllers/statuses_controller.rb
+++ b/app/controllers/statuses_controller.rb
@@ -10,6 +10,7 @@ class StatusesController < ApplicationController
   before_action :set_link_headers
   before_action :check_account_suspension
   before_action :redirect_to_original, only: [:show]
+  before_action :set_cache_headers
 
   def show
     respond_to do |format|
@@ -22,19 +23,21 @@ class StatusesController < ApplicationController
       end
 
       format.json do
-        render json: @status,
-               serializer: ActivityPub::NoteSerializer,
-               adapter: ActivityPub::Adapter,
-               content_type: 'application/activity+json'
+        skip_session! unless @stream_entry.hidden?
+
+        render_cached_json(['activitypub', 'note', @status.cache_key], content_type: 'application/activity+json', public: !@stream_entry.hidden?) do
+          ActiveModelSerializers::SerializableResource.new(@status, serializer: ActivityPub::NoteSerializer, adapter: ActivityPub::Adapter)
+        end
       end
     end
   end
 
   def activity
-    render json: @status,
-           serializer: ActivityPub::ActivitySerializer,
-           adapter: ActivityPub::Adapter,
-           content_type: 'application/activity+json'
+    skip_session!
+
+    render_cached_json(['activitypub', 'activity', @status.cache_key], content_type: 'application/activity+json', public: !@stream_entry.hidden?) do
+      ActiveModelSerializers::SerializableResource.new(@status, serializer: ActivityPub::ActivitySerializer, adapter: ActivityPub::Adapter)
+    end
   end
 
   def embed
diff --git a/app/controllers/well_known/host_meta_controller.rb b/app/controllers/well_known/host_meta_controller.rb
index 40f96eaa2..5fb70288a 100644
--- a/app/controllers/well_known/host_meta_controller.rb
+++ b/app/controllers/well_known/host_meta_controller.rb
@@ -1,15 +1,19 @@
 # frozen_string_literal: true
 
 module WellKnown
-  class HostMetaController < ApplicationController
+  class HostMetaController < ActionController::Base
     include RoutingHelper
 
+    before_action { response.headers['Vary'] = 'Accept' }
+
     def show
       @webfinger_template = "#{webfinger_url}?resource={uri}"
 
       respond_to do |format|
         format.xml { render content_type: 'application/xrd+xml' }
       end
+
+      expires_in(3.days, public: true)
     end
   end
 end
diff --git a/app/controllers/well_known/webfinger_controller.rb b/app/controllers/well_known/webfinger_controller.rb
index 5cc606808..28654b61d 100644
--- a/app/controllers/well_known/webfinger_controller.rb
+++ b/app/controllers/well_known/webfinger_controller.rb
@@ -1,9 +1,11 @@
 # frozen_string_literal: true
 
 module WellKnown
-  class WebfingerController < ApplicationController
+  class WebfingerController < ActionController::Base
     include RoutingHelper
 
+    before_action { response.headers['Vary'] = 'Accept' }
+
     def show
       @account = Account.find_local!(username_from_resource)
 
@@ -16,6 +18,8 @@ module WellKnown
           render content_type: 'application/xrd+xml'
         end
       end
+
+      expires_in(3.days, public: true)
     rescue ActiveRecord::RecordNotFound
       head 404
     end
diff --git a/app/helpers/admin/action_logs_helper.rb b/app/helpers/admin/action_logs_helper.rb
index e85243e57..4475034a5 100644
--- a/app/helpers/admin/action_logs_helper.rb
+++ b/app/helpers/admin/action_logs_helper.rb
@@ -34,7 +34,7 @@ module Admin::ActionLogsHelper
       link_to attributes['domain'], "https://#{attributes['domain']}"
     when 'Status'
       tmp_status = Status.new(attributes)
-      link_to tmp_status.account.acct, TagManager.instance.url_for(tmp_status)
+      link_to tmp_status.account&.acct || "##{tmp_status.account_id}", TagManager.instance.url_for(tmp_status)
     end
   end
 
diff --git a/app/helpers/routing_helper.rb b/app/helpers/routing_helper.rb
index 11894a895..998b7566f 100644
--- a/app/helpers/routing_helper.rb
+++ b/app/helpers/routing_helper.rb
@@ -4,6 +4,7 @@ module RoutingHelper
   extend ActiveSupport::Concern
   include Rails.application.routes.url_helpers
   include ActionView::Helpers::AssetTagHelper
+  include Webpacker::Helper
 
   included do
     def default_url_options
@@ -17,6 +18,10 @@ module RoutingHelper
     URI.join(root_url, source).to_s
   end
 
+  def full_pack_url(source, **options)
+    full_asset_url(asset_pack_path(source, options))
+  end
+
   private
 
   def use_storage?
diff --git a/app/helpers/settings_helper.rb b/app/helpers/settings_helper.rb
index 634c47eaa..a63eb5e43 100644
--- a/app/helpers/settings_helper.rb
+++ b/app/helpers/settings_helper.rb
@@ -29,6 +29,8 @@ module SettingsHelper
     'pt-BR': 'Português do Brasil',
     ru: 'Русский',
     sk: 'Slovensky',
+    sr: 'Српски',
+    'sr-Latn': 'Srpski (latinica)',
     sv: 'Svenska',
     th: 'ภาษาไทย',
     tr: 'Türkçe',
diff --git a/app/javascript/flavours/glitch/actions/compose.js b/app/javascript/flavours/glitch/actions/compose.js
index 32746f27b..31866d223 100644
--- a/app/javascript/flavours/glitch/actions/compose.js
+++ b/app/javascript/flavours/glitch/actions/compose.js
@@ -38,7 +38,6 @@ export const COMPOSE_SPOILERNESS_CHANGE = 'COMPOSE_SPOILERNESS_CHANGE';
 export const COMPOSE_SPOILER_TEXT_CHANGE = 'COMPOSE_SPOILER_TEXT_CHANGE';
 export const COMPOSE_VISIBILITY_CHANGE  = 'COMPOSE_VISIBILITY_CHANGE';
 export const COMPOSE_LISTABILITY_CHANGE = 'COMPOSE_LISTABILITY_CHANGE';
-export const COMPOSE_COMPOSING_CHANGE = 'COMPOSE_COMPOSING_CHANGE';
 
 export const COMPOSE_EMOJI_INSERT = 'COMPOSE_EMOJI_INSERT';
 
@@ -316,21 +315,14 @@ export function readyComposeSuggestionsAccounts(token, accounts) {
 
 export function selectComposeSuggestion(position, token, suggestion) {
   return (dispatch, getState) => {
-    let completion, startPosition;
-
-    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;
-    }
+    const completion = typeof suggestion === 'object' && suggestion.id ? (
+      dispatch(useEmoji(suggestion)),
+      suggestion.native || suggestion.colons
+    ) : '@' + getState().getIn(['accounts', suggestion, 'acct']);
 
     dispatch({
       type: COMPOSE_SUGGESTION_SELECT,
-      position: startPosition,
+      position,
       token,
       completion,
     });
@@ -389,10 +381,3 @@ export function insertEmojiCompose(position, emoji) {
     emoji,
   };
 };
-
-export function changeComposing(value) {
-  return {
-    type: COMPOSE_COMPOSING_CHANGE,
-    value,
-  };
-}
diff --git a/app/javascript/flavours/glitch/components/account.js b/app/javascript/flavours/glitch/components/account.js
index a1f075491..ced18b348 100644
--- a/app/javascript/flavours/glitch/components/account.js
+++ b/app/javascript/flavours/glitch/components/account.js
@@ -30,6 +30,7 @@ export default class Account extends ImmutablePureComponent {
     onMuteNotifications: PropTypes.func.isRequired,
     intl: PropTypes.object.isRequired,
     hidden: PropTypes.bool,
+    small: PropTypes.bool,
   };
 
   handleFollow = () => {
@@ -53,7 +54,12 @@ export default class Account extends ImmutablePureComponent {
   }
 
   render () {
-    const { account, intl, hidden } = this.props;
+    const {
+      account,
+      hidden,
+      intl,
+      small,
+    } = this.props;
 
     if (!account) {
       return <div />;
@@ -70,7 +76,7 @@ export default class Account extends ImmutablePureComponent {
 
     let buttons;
 
-    if (account.get('id') !== me && account.get('relationship', null) !== null) {
+    if (account.get('id') !== me && !small && account.get('relationship', null) !== null) {
       const following = account.getIn(['relationship', 'following']);
       const requested = account.getIn(['relationship', 'requested']);
       const blocking  = account.getIn(['relationship', 'blocking']);
@@ -98,17 +104,23 @@ export default class Account extends ImmutablePureComponent {
       }
     }
 
-    return (
+    return small ? (
+      <div className='account small'>
+        <div className='account__avatar-wrapper'><Avatar account={account} size={18} /></div>
+        <DisplayName account={account} />
+      </div>
+    ) : (
       <div className='account'>
         <div className='account__wrapper'>
           <Permalink key={account.get('id')} className='account__display-name' href={account.get('url')} to={`/accounts/${account.get('id')}`}>
             <div className='account__avatar-wrapper'><Avatar account={account} size={36} /></div>
             <DisplayName account={account} />
           </Permalink>
-
-          <div className='account__relationship'>
-            {buttons}
-          </div>
+          {buttons ?
+            <div className='account__relationship'>
+              {buttons}
+            </div>
+          : null}
         </div>
       </div>
     );
diff --git a/app/javascript/flavours/glitch/components/autosuggest_emoji.js b/app/javascript/flavours/glitch/components/autosuggest_emoji.js
deleted file mode 100644
index 79e113d9c..000000000
--- a/app/javascript/flavours/glitch/components/autosuggest_emoji.js
+++ /dev/null
@@ -1,42 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import unicodeMapping from 'flavours/glitch/util/emoji/emoji_unicode_mapping_light';
-
-const assetHost = process.env.CDN_HOST || '';
-
-export default class AutosuggestEmoji extends React.PureComponent {
-
-  static propTypes = {
-    emoji: PropTypes.object.isRequired,
-  };
-
-  render () {
-    const { emoji } = this.props;
-    let url;
-
-    if (emoji.custom) {
-      url = emoji.imageUrl;
-    } else {
-      const mapping = unicodeMapping[emoji.native] || unicodeMapping[emoji.native.replace(/\uFE0F$/, '')];
-
-      if (!mapping) {
-        return null;
-      }
-
-      url = `${assetHost}/emoji/${mapping.filename}.svg`;
-    }
-
-    return (
-      <div className='autosuggest-emoji'>
-        <img
-          className='emojione'
-          src={url}
-          alt={emoji.native || emoji.colons}
-        />
-
-        {emoji.colons}
-      </div>
-    );
-  }
-
-}
diff --git a/app/javascript/flavours/glitch/components/autosuggest_textarea.js b/app/javascript/flavours/glitch/components/autosuggest_textarea.js
deleted file mode 100644
index a29b2c9c5..000000000
--- a/app/javascript/flavours/glitch/components/autosuggest_textarea.js
+++ /dev/null
@@ -1,223 +0,0 @@
-import React from 'react';
-import AutosuggestAccountContainer from 'flavours/glitch/features/compose/containers/autosuggest_account_container';
-import AutosuggestEmoji from './autosuggest_emoji';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import PropTypes from 'prop-types';
-import { isRtl } from 'flavours/glitch/util/rtl';
-import ImmutablePureComponent from 'react-immutable-pure-component';
-import Textarea from 'react-textarea-autosize';
-import classNames from 'classnames';
-
-const textAtCursorMatchesToken = (str, caretPosition) => {
-  let word;
-
-  let left  = str.slice(0, caretPosition).search(/[^\s\u200B]+$/);
-  let right = str.slice(caretPosition).search(/[\s\u200B]/);
-
-  if (right < 0) {
-    word = str.slice(left);
-  } else {
-    word = str.slice(left, right + caretPosition);
-  }
-
-  if (!word || word.trim().length < 3 || ['@', ':'].indexOf(word[0]) === -1) {
-    return [null, null];
-  }
-
-  word = word.trim().toLowerCase();
-
-  if (word.length > 0) {
-    return [left + 1, word];
-  } else {
-    return [null, null];
-  }
-};
-
-export default class AutosuggestTextarea extends ImmutablePureComponent {
-
-  static propTypes = {
-    value: PropTypes.string,
-    suggestions: ImmutablePropTypes.list,
-    disabled: PropTypes.bool,
-    placeholder: PropTypes.string,
-    onSuggestionSelected: PropTypes.func.isRequired,
-    onSuggestionsClearRequested: PropTypes.func.isRequired,
-    onSuggestionsFetchRequested: PropTypes.func.isRequired,
-    onChange: PropTypes.func.isRequired,
-    onKeyUp: PropTypes.func,
-    onKeyDown: PropTypes.func,
-    onPaste: PropTypes.func.isRequired,
-    autoFocus: PropTypes.bool,
-  };
-
-  static defaultProps = {
-    autoFocus: true,
-  };
-
-  state = {
-    suggestionsHidden: false,
-    selectedSuggestion: 0,
-    lastToken: null,
-    tokenStart: 0,
-  };
-
-  onChange = (e) => {
-    const [ tokenStart, token ] = textAtCursorMatchesToken(e.target.value, e.target.selectionStart);
-
-    if (token !== null && this.state.lastToken !== token) {
-      this.setState({ lastToken: token, selectedSuggestion: 0, tokenStart });
-      this.props.onSuggestionsFetchRequested(token);
-    } else if (token === null) {
-      this.setState({ lastToken: null });
-      this.props.onSuggestionsClearRequested();
-    }
-
-    this.props.onChange(e);
-  }
-
-  onKeyDown = (e) => {
-    const { suggestions, disabled } = this.props;
-    const { selectedSuggestion, suggestionsHidden } = this.state;
-
-    if (disabled) {
-      e.preventDefault();
-      return;
-    }
-
-    switch(e.key) {
-    case 'Escape':
-      if (!suggestionsHidden) {
-        e.preventDefault();
-        this.setState({ suggestionsHidden: true });
-      }
-
-      break;
-    case 'ArrowDown':
-      if (suggestions.size > 0 && !suggestionsHidden) {
-        e.preventDefault();
-        this.setState({ selectedSuggestion: Math.min(selectedSuggestion + 1, suggestions.size - 1) });
-      }
-
-      break;
-    case 'ArrowUp':
-      if (suggestions.size > 0 && !suggestionsHidden) {
-        e.preventDefault();
-        this.setState({ selectedSuggestion: Math.max(selectedSuggestion - 1, 0) });
-      }
-
-      break;
-    case 'Enter':
-    case 'Tab':
-      // Select suggestion
-      if (this.state.lastToken !== null && suggestions.size > 0 && !suggestionsHidden) {
-        e.preventDefault();
-        e.stopPropagation();
-        this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestions.get(selectedSuggestion));
-      }
-
-      break;
-    }
-
-    if (e.defaultPrevented || !this.props.onKeyDown) {
-      return;
-    }
-
-    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 });
-  }
-
-  onSuggestionClick = (e) => {
-    const suggestion = this.props.suggestions.get(e.currentTarget.getAttribute('data-index'));
-    e.preventDefault();
-    this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestion);
-    this.textarea.focus();
-  }
-
-  componentWillReceiveProps (nextProps) {
-    if (nextProps.suggestions !== this.props.suggestions && nextProps.suggestions.size > 0 && this.state.suggestionsHidden) {
-      this.setState({ suggestionsHidden: false });
-    }
-  }
-
-  setTextarea = (c) => {
-    this.textarea = c;
-  }
-
-  onPaste = (e) => {
-    if (e.clipboardData && e.clipboardData.files.length === 1) {
-      this.props.onPaste(e.clipboardData.files);
-      e.preventDefault();
-    }
-  }
-
-  renderSuggestion = (suggestion, i) => {
-    const { selectedSuggestion } = this.state;
-    let inner, key;
-
-    if (typeof suggestion === 'object') {
-      inner = <AutosuggestEmoji emoji={suggestion} />;
-      key   = suggestion.id;
-    } else {
-      inner = <AutosuggestAccountContainer id={suggestion} />;
-      key   = suggestion;
-    }
-
-    return (
-      <div role='button' tabIndex='0' key={key} data-index={i} className={classNames('autosuggest-textarea__suggestions__item', { selected: i === selectedSuggestion })} onMouseDown={this.onSuggestionClick}>
-        {inner}
-      </div>
-    );
-  }
-
-  render () {
-    const { value, suggestions, disabled, placeholder, autoFocus } = this.props;
-    const { suggestionsHidden } = this.state;
-    const style = { direction: 'ltr' };
-
-    if (isRtl(value)) {
-      style.direction = 'rtl';
-    }
-
-    return (
-      <div className='autosuggest-textarea'>
-        <label>
-          <span style={{ display: 'none' }}>{placeholder}</span>
-
-          <Textarea
-            inputRef={this.setTextarea}
-            className='autosuggest-textarea__textarea'
-            disabled={disabled}
-            placeholder={placeholder}
-            autoFocus={autoFocus}
-            value={value}
-            onChange={this.onChange}
-            onKeyDown={this.onKeyDown}
-            onKeyUp={this.onKeyUp}
-            onBlur={this.onBlur}
-            onPaste={this.onPaste}
-            style={style}
-            aria-autocomplete='list'
-          />
-        </label>
-
-        <div className={`autosuggest-textarea__suggestions ${suggestionsHidden || suggestions.isEmpty() ? '' : 'autosuggest-textarea__suggestions--visible'}`}>
-          {suggestions.map(this.renderSuggestion)}
-        </div>
-      </div>
-    );
-  }
-
-}
diff --git a/app/javascript/flavours/glitch/components/avatar.js b/app/javascript/flavours/glitch/components/avatar.js
index 82ab0f45a..c5e9072c4 100644
--- a/app/javascript/flavours/glitch/components/avatar.js
+++ b/app/javascript/flavours/glitch/components/avatar.js
@@ -1,3 +1,4 @@
+import classNames from 'classnames';
 import React from 'react';
 import PropTypes from 'prop-types';
 import ImmutablePropTypes from 'react-immutable-proptypes';
@@ -7,6 +8,7 @@ export default class Avatar extends React.PureComponent {
 
   static propTypes = {
     account: ImmutablePropTypes.map.isRequired,
+    className: PropTypes.string,
     size: PropTypes.number.isRequired,
     style: PropTypes.object,
     inline: PropTypes.bool,
@@ -34,17 +36,19 @@ export default class Avatar extends React.PureComponent {
   }
 
   render () {
-    const { account, size, animate, inline } = this.props;
+    const {
+      account,
+      animate,
+      className,
+      inline,
+      size,
+    } = this.props;
     const { hovering } = this.state;
 
     const src = account.get('avatar');
     const staticSrc = account.get('avatar_static');
 
-    let className = 'account__avatar';
-
-    if (inline) {
-      className = className + ' account__avatar-inline';
-    }
+    const computedClass = classNames('account__avatar', { 'account__avatar-inline': inline }, className);
 
     const style = {
       ...this.props.style,
@@ -61,7 +65,7 @@ export default class Avatar extends React.PureComponent {
 
     return (
       <div
-        className={className}
+        className={computedClass}
         onMouseEnter={this.handleMouseEnter}
         onMouseLeave={this.handleMouseLeave}
         style={style}
diff --git a/app/javascript/flavours/glitch/components/display_name.js b/app/javascript/flavours/glitch/components/display_name.js
index 2cf84f8f4..ad1c3a534 100644
--- a/app/javascript/flavours/glitch/components/display_name.js
+++ b/app/javascript/flavours/glitch/components/display_name.js
@@ -1,3 +1,5 @@
+import classNames from 'classnames';
+import PropTypes from 'prop-types';
 import React from 'react';
 import ImmutablePropTypes from 'react-immutable-proptypes';
 
@@ -5,13 +7,19 @@ export default class DisplayName extends React.PureComponent {
 
   static propTypes = {
     account: ImmutablePropTypes.map.isRequired,
+    className: PropTypes.string,
   };
 
   render () {
-    const displayNameHtml = { __html: this.props.account.get('display_name_html') };
+    const {
+      account,
+      className,
+    } = this.props;
+    const computedClass = classNames('display-name', className);
+    const displayNameHtml = { __html: account.get('display_name_html') };
 
     return (
-      <span className='display-name'>
+      <span className={computedClass}>
         <strong className='display-name__html' dangerouslySetInnerHTML={displayNameHtml} /> <span className='display-name__account'>@{this.props.account.get('acct')}</span>
       </span>
     );
diff --git a/app/javascript/flavours/glitch/components/dropdown_menu.js b/app/javascript/flavours/glitch/components/dropdown_menu.js
index d4a886a8b..706390c92 100644
--- a/app/javascript/flavours/glitch/components/dropdown_menu.js
+++ b/app/javascript/flavours/glitch/components/dropdown_menu.js
@@ -133,8 +133,13 @@ export default class Dropdown extends React.PureComponent {
 
       this.props.onModalOpen({
         status,
-        actions: items,
-        onClick: this.handleItemClick,
+        actions: items.map(
+          (item, i) => item ? {
+            ...item,
+            name: `${item.text}-${i}`,
+            onClick: this.handleItemClick.bind(i),
+          } : null
+        ),
       });
 
       return;
@@ -162,8 +167,7 @@ export default class Dropdown extends React.PureComponent {
     }
   }
 
-  handleItemClick = e => {
-    const i = Number(e.currentTarget.getAttribute('data-index'));
+  handleItemClick = (i, e) => {
     const { action, to } = this.props.items[i];
 
     this.handleClose();
diff --git a/app/javascript/flavours/glitch/components/icon.js b/app/javascript/flavours/glitch/components/icon.js
new file mode 100644
index 000000000..8f55a0115
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/icon.js
@@ -0,0 +1,26 @@
+//  Package imports.
+import classNames from 'classnames';
+import PropTypes from 'prop-types';
+import React from 'react';
+
+//  This just renders a FontAwesome icon.
+export default function Icon ({
+  className,
+  fullwidth,
+  icon,
+}) {
+  const computedClass = classNames('icon', 'fa', { 'fa-fw': fullwidth }, `fa-${icon}`, className);
+  return icon ? (
+    <span
+      aria-hidden='true'
+      className={computedClass}
+    />
+  ) : null;
+}
+
+//  Props.
+Icon.propTypes = {
+  className: PropTypes.string,
+  fullwidth: PropTypes.bool,
+  icon: PropTypes.string,
+};
diff --git a/app/javascript/flavours/glitch/components/link.js b/app/javascript/flavours/glitch/components/link.js
new file mode 100644
index 000000000..de99f7d42
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/link.js
@@ -0,0 +1,97 @@
+//  Inspired by <CommonLink> from Mastodon GO!
+//  ~ 😘 kibi!
+
+//  Package imports.
+import classNames from 'classnames';
+import PropTypes from 'prop-types';
+import React from 'react';
+
+//  Utils.
+import { assignHandlers } from 'flavours/glitch/util/react_helpers';
+
+//  Handlers.
+const handlers = {
+
+  //  We don't handle clicks that are made with modifiers, since these
+  //  often have special browser meanings (eg, "open in new tab").
+  click (e) {
+    const { onClick } = this.props;
+    if (!onClick || e.button || e.ctrlKey || e.shiftKey || e.altKey || e.metaKey) {
+      return;
+    }
+    onClick(e);
+    e.preventDefault();  //  Prevents following of the link
+  },
+};
+
+//  The component.
+export default class Link extends React.PureComponent {
+
+  //  Constructor.
+  constructor (props) {
+    super(props);
+    assignHandlers(this, handlers);
+  }
+
+  //  Rendering.
+  render () {
+    const { click } = this.handlers;
+    const {
+      children,
+      className,
+      href,
+      onClick,
+      role,
+      title,
+      ...rest
+    } = this.props;
+    const computedClass = classNames('link', className, `role-${role}`);
+
+    //  We assume that our `onClick` is a routing function and give it
+    //  the qualities of a link even if no `href` is provided. However,
+    //  if we have neither an `onClick` or an `href`, our link is
+    //  purely presentational.
+    const conditionalProps = {};
+    if (href) {
+      conditionalProps.href = href;
+      conditionalProps.onClick = click;
+    } else if (onClick) {
+      conditionalProps.onClick = click;
+      conditionalProps.role = 'link';
+      conditionalProps.tabIndex = 0;
+    } else {
+      conditionalProps.role = 'presentation';
+    }
+
+    //  If we were provided a `role` it overwrites any that we may have
+    //  set above.  This can be used for "links" which are actually
+    //  buttons.
+    if (role) {
+      conditionalProps.role = role;
+    }
+
+    //  Rendering.  We set `rel='noopener'` for user privacy, and our
+    //  `target` as `'_blank'`.
+    return (
+      <a
+        className={computedClass}
+        {...conditionalProps}
+        rel='noopener'
+        target='_blank'
+        title={title}
+        {...rest}
+      >{children}</a>
+    );
+  }
+
+}
+
+//  Props.
+Link.propTypes = {
+  children: PropTypes.node,
+  className: PropTypes.string,
+  href: PropTypes.string,  //  The link destination
+  onClick: PropTypes.func,  //  A function to call instead of opening the link
+  role: PropTypes.string,  //  An ARIA role for the link
+  title: PropTypes.string,  //  A title for the link
+};
diff --git a/app/javascript/flavours/glitch/features/compose/components/text_icon_button.js b/app/javascript/flavours/glitch/components/text_icon_button.js
index 9c8ffab1f..9c8ffab1f 100644
--- a/app/javascript/flavours/glitch/features/compose/components/text_icon_button.js
+++ b/app/javascript/flavours/glitch/components/text_icon_button.js
diff --git a/app/javascript/flavours/glitch/features/compose/components/advanced_options.js b/app/javascript/flavours/glitch/features/compose/components/advanced_options.js
deleted file mode 100644
index 045bad2e5..000000000
--- a/app/javascript/flavours/glitch/features/compose/components/advanced_options.js
+++ /dev/null
@@ -1,62 +0,0 @@
-//  Package imports.
-import React from 'react';
-import PropTypes from 'prop-types';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import { injectIntl, defineMessages } from 'react-intl';
-
-//  Our imports.
-import ComposeAdvancedOptionsToggle from './advanced_options_toggle';
-import ComposeDropdown from './dropdown';
-
-const messages = defineMessages({
-  local_only_short            :
-    { id: 'advanced-options.local-only.short', defaultMessage: 'Local-only' },
-  local_only_long             :
-    { id: 'advanced-options.local-only.long', defaultMessage: 'Do not post to other instances' },
-  advanced_options_icon_title :
-    { id: 'advanced_options.icon_title', defaultMessage: 'Advanced options' },
-});
-
-@injectIntl
-export default class ComposeAdvancedOptions extends React.PureComponent {
-
-  static propTypes = {
-    values   : ImmutablePropTypes.contains({
-      do_not_federate : PropTypes.bool.isRequired,
-    }).isRequired,
-    onChange : PropTypes.func.isRequired,
-    intl     : PropTypes.object.isRequired,
-  };
-
-  render () {
-    const { intl, values } = this.props;
-    const options = [
-      { icon: 'wifi', shortText: messages.local_only_short, longText: messages.local_only_long, name: 'do_not_federate' },
-    ];
-    const anyEnabled = values.some((enabled) => enabled);
-
-    const optionElems = options.map((option) => {
-      return (
-        <ComposeAdvancedOptionsToggle
-          onChange={this.props.onChange}
-          active={values.get(option.name)}
-          key={option.name}
-          name={option.name}
-          shortText={intl.formatMessage(option.shortText)}
-          longText={intl.formatMessage(option.longText)}
-        />
-      );
-    });
-
-    return (
-      <ComposeDropdown
-        title={intl.formatMessage(messages.advanced_options_icon_title)}
-        icon='home'
-        highlight={anyEnabled}
-      >
-        {optionElems}
-      </ComposeDropdown>
-    );
-  }
-
-}
diff --git a/app/javascript/flavours/glitch/features/compose/components/advanced_options_toggle.js b/app/javascript/flavours/glitch/features/compose/components/advanced_options_toggle.js
deleted file mode 100644
index 98b3b6a44..000000000
--- a/app/javascript/flavours/glitch/features/compose/components/advanced_options_toggle.js
+++ /dev/null
@@ -1,35 +0,0 @@
-//  Package imports.
-import React from 'react';
-import PropTypes from 'prop-types';
-import Toggle from 'react-toggle';
-
-export default class ComposeAdvancedOptionsToggle extends React.PureComponent {
-
-  static propTypes = {
-    onChange: PropTypes.func.isRequired,
-    active: PropTypes.bool.isRequired,
-    name: PropTypes.string.isRequired,
-    shortText: PropTypes.string.isRequired,
-    longText: PropTypes.string.isRequired,
-  }
-
-  onToggle = () => {
-    this.props.onChange(this.props.name);
-  }
-
-  render() {
-    const { active, shortText, longText } = this.props;
-    return (
-      <div role='button' tabIndex='0' className='advanced-options-dropdown__option' onClick={this.onToggle}>
-        <div className='advanced-options-dropdown__option__toggle'>
-          <Toggle checked={active} onChange={this.onToggle} />
-        </div>
-        <div className='advanced-options-dropdown__option__content'>
-          <strong>{shortText}</strong>
-          {longText}
-        </div>
-      </div>
-    );
-  }
-
-}
diff --git a/app/javascript/flavours/glitch/features/compose/components/attach_options.js b/app/javascript/flavours/glitch/features/compose/components/attach_options.js
deleted file mode 100644
index 6c7a1f55f..000000000
--- a/app/javascript/flavours/glitch/features/compose/components/attach_options.js
+++ /dev/null
@@ -1,131 +0,0 @@
-//  Package imports  //
-import React from 'react';
-import PropTypes from 'prop-types';
-import { connect } from 'react-redux';
-import { injectIntl, defineMessages } from 'react-intl';
-
-//  Our imports  //
-import ComposeDropdown from './dropdown';
-import { uploadCompose } from 'flavours/glitch/actions/compose';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import ImmutablePureComponent from 'react-immutable-pure-component';
-import { openModal } from 'flavours/glitch/actions/modal';
-
-const messages = defineMessages({
-  upload :
-    { id: 'compose.attach.upload', defaultMessage: 'Upload a file' },
-  doodle :
-    { id: 'compose.attach.doodle', defaultMessage: 'Draw something' },
-  attach :
-    { id: 'compose.attach', defaultMessage: 'Attach...' },
-});
-
-const mapStateToProps = state => ({
-  // This horrible expression is copied from vanilla upload_button_container
-  disabled: state.getIn(['compose', 'is_uploading']) || (state.getIn(['compose', 'media_attachments']).size > 3 || state.getIn(['compose', 'media_attachments']).some(m => m.get('type') === 'video')),
-  resetFileKey: state.getIn(['compose', 'resetFileKey']),
-  acceptContentTypes: state.getIn(['media_attachments', 'accept_content_types']),
-});
-
-const mapDispatchToProps = dispatch => ({
-  onSelectFile (files) {
-    dispatch(uploadCompose(files));
-  },
-  onOpenDoodle () {
-    dispatch(openModal('DOODLE', { noEsc: true }));
-  },
-});
-
-@injectIntl
-@connect(mapStateToProps, mapDispatchToProps)
-export default class ComposeAttachOptions extends ImmutablePureComponent {
-
-  static propTypes = {
-    intl     : PropTypes.object.isRequired,
-    resetFileKey: PropTypes.number,
-    acceptContentTypes: ImmutablePropTypes.listOf(PropTypes.string).isRequired,
-    disabled: PropTypes.bool,
-    onSelectFile: PropTypes.func.isRequired,
-    onOpenDoodle: PropTypes.func.isRequired,
-  };
-
-  handleItemClick = bt => {
-    if (bt === 'upload') {
-      this.fileElement.click();
-    }
-
-    if (bt === 'doodle') {
-      this.props.onOpenDoodle();
-    }
-
-    this.dropdown.setState({ open: false });
-  };
-
-  handleFileChange = (e) => {
-    if (e.target.files.length > 0) {
-      this.props.onSelectFile(e.target.files);
-    }
-  }
-
-  setFileRef = (c) => {
-    this.fileElement = c;
-  }
-
-  setDropdownRef = (c) => {
-    this.dropdown = c;
-  }
-
-  render () {
-    const { intl, resetFileKey, disabled, acceptContentTypes } = this.props;
-
-    const options = [
-      { icon: 'cloud-upload', text: messages.upload, name: 'upload' },
-      { icon: 'paint-brush', text: messages.doodle, name: 'doodle' },
-    ];
-
-    const optionElems = options.map((item) => {
-      const hdl = () => this.handleItemClick(item.name);
-      return (
-        <div
-          role='button'
-          tabIndex='0'
-          key={item.name}
-          onClick={hdl}
-          className='privacy-dropdown__option'
-        >
-          <div className='privacy-dropdown__option__icon'>
-            <i className={`fa fa-fw fa-${item.icon}`} />
-          </div>
-
-          <div className='privacy-dropdown__option__content'>
-            <strong>{intl.formatMessage(item.text)}</strong>
-          </div>
-        </div>
-      );
-    });
-
-    return (
-      <div>
-        <ComposeDropdown
-          title={intl.formatMessage(messages.attach)}
-          icon='paperclip'
-          disabled={disabled}
-          ref={this.setDropdownRef}
-        >
-          {optionElems}
-        </ComposeDropdown>
-        <input
-          key={resetFileKey}
-          ref={this.setFileRef}
-          type='file'
-          multiple={false}
-          accept={acceptContentTypes.toArray().join(',')}
-          onChange={this.handleFileChange}
-          disabled={disabled}
-          style={{ display: 'none' }}
-        />
-      </div>
-    );
-  }
-
-}
diff --git a/app/javascript/flavours/glitch/features/compose/components/autosuggest_account.js b/app/javascript/flavours/glitch/features/compose/components/autosuggest_account.js
deleted file mode 100644
index 3d474af30..000000000
--- a/app/javascript/flavours/glitch/features/compose/components/autosuggest_account.js
+++ /dev/null
@@ -1,24 +0,0 @@
-import React from 'react';
-import Avatar from 'flavours/glitch/components/avatar';
-import DisplayName from 'flavours/glitch/components/display_name';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import ImmutablePureComponent from 'react-immutable-pure-component';
-
-export default class AutosuggestAccount extends ImmutablePureComponent {
-
-  static propTypes = {
-    account: ImmutablePropTypes.map.isRequired,
-  };
-
-  render () {
-    const { account } = this.props;
-
-    return (
-      <div className='autosuggest-account'>
-        <div className='autosuggest-account-icon'><Avatar account={account} size={18} /></div>
-        <DisplayName account={account} />
-      </div>
-    );
-  }
-
-}
diff --git a/app/javascript/flavours/glitch/features/compose/components/character_counter.js b/app/javascript/flavours/glitch/features/compose/components/character_counter.js
deleted file mode 100644
index 0ecfc9141..000000000
--- a/app/javascript/flavours/glitch/features/compose/components/character_counter.js
+++ /dev/null
@@ -1,25 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import { length } from 'stringz';
-
-export default class CharacterCounter extends React.PureComponent {
-
-  static propTypes = {
-    text: PropTypes.string.isRequired,
-    max: PropTypes.number.isRequired,
-  };
-
-  checkRemainingText (diff) {
-    if (diff < 0) {
-      return <span className='character-counter character-counter--over'>{diff}</span>;
-    }
-
-    return <span className='character-counter'>{diff}</span>;
-  }
-
-  render () {
-    const diff = this.props.max - length(this.props.text);
-    return this.checkRemainingText(diff);
-  }
-
-}
diff --git a/app/javascript/flavours/glitch/features/compose/components/compose_form.js b/app/javascript/flavours/glitch/features/compose/components/compose_form.js
deleted file mode 100644
index 67ce935f4..000000000
--- a/app/javascript/flavours/glitch/features/compose/components/compose_form.js
+++ /dev/null
@@ -1,286 +0,0 @@
-import React from 'react';
-import CharacterCounter from './character_counter';
-import Button from 'flavours/glitch/components/button';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import PropTypes from 'prop-types';
-import ReplyIndicatorContainer from '../containers/reply_indicator_container';
-import AutosuggestTextarea from 'flavours/glitch/components/autosuggest_textarea';
-import { defineMessages, injectIntl } from 'react-intl';
-import Collapsable from 'flavours/glitch/components/collapsable';
-import SpoilerButtonContainer from '../containers/spoiler_button_container';
-import PrivacyDropdownContainer from '../containers/privacy_dropdown_container';
-import ComposeAdvancedOptionsContainer from '../containers/advanced_options_container';
-import SensitiveButtonContainer from '../containers/sensitive_button_container';
-import EmojiPickerDropdown from '../containers/emoji_picker_dropdown_container';
-import UploadFormContainer from '../containers/upload_form_container';
-import WarningContainer from '../containers/warning_container';
-import { isMobile } from 'flavours/glitch/util/is_mobile';
-import ImmutablePureComponent from 'react-immutable-pure-component';
-import { length } from 'stringz';
-import { countableText } from 'flavours/glitch/util/counter';
-import ComposeAttachOptions from './attach_options';
-import initialState from 'flavours/glitch/util/initial_state';
-
-const maxChars = initialState.max_toot_chars;
-
-const messages = defineMessages({
-  placeholder: { id: 'compose_form.placeholder', defaultMessage: 'What is on your mind?' },
-  spoiler_placeholder: { id: 'compose_form.spoiler_placeholder', defaultMessage: 'Write your warning here' },
-  publish: { id: 'compose_form.publish', defaultMessage: 'Toot' },
-  publishLoud: { id: 'compose_form.publish_loud', defaultMessage: '{publish}!' },
-});
-
-@injectIntl
-export default class ComposeForm extends ImmutablePureComponent {
-
-  static propTypes = {
-    intl: PropTypes.object.isRequired,
-    text: PropTypes.string.isRequired,
-    suggestion_token: PropTypes.string,
-    suggestions: ImmutablePropTypes.list,
-    spoiler: PropTypes.bool,
-    privacy: PropTypes.string,
-    advanced_options: ImmutablePropTypes.contains({
-      do_not_federate: PropTypes.bool,
-    }),
-    spoiler_text: PropTypes.string,
-    focusDate: PropTypes.instanceOf(Date),
-    preselectDate: PropTypes.instanceOf(Date),
-    is_submitting: PropTypes.bool,
-    is_uploading: PropTypes.bool,
-    onChange: PropTypes.func.isRequired,
-    onSubmit: PropTypes.func.isRequired,
-    onClearSuggestions: PropTypes.func.isRequired,
-    onFetchSuggestions: PropTypes.func.isRequired,
-    onPrivacyChange: PropTypes.func.isRequired,
-    onSuggestionSelected: PropTypes.func.isRequired,
-    onChangeSpoilerText: PropTypes.func.isRequired,
-    onPaste: PropTypes.func.isRequired,
-    onPickEmoji: PropTypes.func.isRequired,
-    showSearch: PropTypes.bool,
-    settings : ImmutablePropTypes.map.isRequired,
-  };
-
-  static defaultProps = {
-    showSearch: false,
-  };
-
-  handleChange = (e) => {
-    this.props.onChange(e.target.value);
-  }
-
-  handleKeyDown = (e) => {
-    if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) {
-      this.handleSubmit();
-    }
-  }
-
-  handleSubmit2 = () => {
-    this.props.onPrivacyChange(this.props.settings.get('side_arm'));
-    this.handleSubmit();
-  }
-
-  handleSubmit = () => {
-    if (this.props.text !== this.autosuggestTextarea.textarea.value) {
-      // Something changed the text inside the textarea (e.g. browser extensions like Grammarly)
-      // Update the state to match the current text
-      this.props.onChange(this.autosuggestTextarea.textarea.value);
-    }
-
-    this.props.onSubmit();
-  }
-
-  onSuggestionsClearRequested = () => {
-    this.props.onClearSuggestions();
-  }
-
-  onSuggestionsFetchRequested = (token) => {
-    this.props.onFetchSuggestions(token);
-  }
-
-  onSuggestionSelected = (tokenStart, token, value) => {
-    this._restoreCaret = null;
-    this.props.onSuggestionSelected(tokenStart, token, value);
-  }
-
-  handleChangeSpoilerText = (e) => {
-    this.props.onChangeSpoilerText(e.target.value);
-  }
-
-  componentWillReceiveProps (nextProps) {
-    // If this is the update where we've finished uploading,
-    // save the last caret position so we can restore it below!
-    if (!nextProps.is_uploading && this.props.is_uploading) {
-      this._restoreCaret = this.autosuggestTextarea.textarea.selectionStart;
-    }
-  }
-
-  componentDidUpdate (prevProps) {
-    // This statement does several things:
-    // - If we're beginning a reply, and,
-    //     - Replying to zero or one users, places the cursor at the end of the textbox.
-    //     - Replying to more than one user, selects any usernames past the first;
-    //       this provides a convenient shortcut to drop everyone else from the conversation.
-    // - If we've just finished uploading an image, and have a saved caret position,
-    //   restores the cursor to that position after the text changes!
-    if (this.props.focusDate !== prevProps.focusDate || (prevProps.is_uploading && !this.props.is_uploading && typeof this._restoreCaret === 'number')) {
-      let selectionEnd, selectionStart;
-
-      if (this.props.preselectDate !== prevProps.preselectDate) {
-        selectionEnd   = this.props.text.length;
-        selectionStart = this.props.text.search(/\s/) + 1;
-      } else if (typeof this._restoreCaret === 'number') {
-        selectionStart = this._restoreCaret;
-        selectionEnd   = this._restoreCaret;
-      } else {
-        selectionEnd   = this.props.text.length;
-        selectionStart = selectionEnd;
-      }
-
-      this.autosuggestTextarea.textarea.setSelectionRange(selectionStart, selectionEnd);
-      this.autosuggestTextarea.textarea.focus();
-    } else if(prevProps.is_submitting && !this.props.is_submitting) {
-      this.autosuggestTextarea.textarea.focus();
-    }
-  }
-
-  setAutosuggestTextarea = (c) => {
-    this.autosuggestTextarea = c;
-  }
-
-  handleEmojiPick = (data) => {
-    const position     = this.autosuggestTextarea.textarea.selectionStart;
-    const emojiChar    = data.native;
-    this._restoreCaret = position + emojiChar.length + 1;
-    this.props.onPickEmoji(position, data);
-  }
-
-  render () {
-    const { intl, onPaste, showSearch } = this.props;
-    const disabled = this.props.is_submitting;
-    const maybeEye = (this.props.advanced_options && this.props.advanced_options.do_not_federate) ? ' 👁️' : '';
-    const text     = [this.props.spoiler_text, countableText(this.props.text), maybeEye].join('');
-
-    const secondaryVisibility = this.props.settings.get('side_arm');
-    let showSideArm = secondaryVisibility !== 'none';
-
-    let publishText = '';
-    let publishText2 = '';
-    let title = '';
-    let title2 = '';
-
-    const privacyIcons = {
-      none: '',
-      public: 'globe',
-      unlisted: 'unlock-alt',
-      private: 'lock',
-      direct: 'envelope',
-    };
-
-    title = `${intl.formatMessage(messages.publish)}: ${intl.formatMessage({ id: `privacy.${this.props.privacy}.short` })}`;
-
-    if (showSideArm) {
-      // Enhanced behavior with dual toot buttons
-      publishText = (
-        <span>
-          {
-            <i
-              className={`fa fa-${privacyIcons[this.props.privacy]}`}
-              style={{ paddingRight: '5px' }}
-            />
-          }{intl.formatMessage(messages.publish)}
-        </span>
-      );
-
-      title2 = `${intl.formatMessage(messages.publish)}: ${intl.formatMessage({ id: `privacy.${secondaryVisibility}.short` })}`;
-      publishText2 = (
-        <i
-          className={`fa fa-${privacyIcons[secondaryVisibility]}`}
-          aria-label={title2}
-        />
-      );
-    } else {
-      // Original vanilla behavior - no icon if public or unlisted
-      if (this.props.privacy === 'private' || this.props.privacy === 'direct') {
-        publishText = <span className='compose-form__publish-private'><i className='fa fa-lock' /> {intl.formatMessage(messages.publish)}</span>;
-      } else {
-        publishText = this.props.privacy !== 'unlisted' ? intl.formatMessage(messages.publishLoud, { publish: intl.formatMessage(messages.publish) }) : intl.formatMessage(messages.publish);
-      }
-    }
-
-    const submitDisabled = disabled || this.props.is_uploading || length(text) > maxChars || (text.length !== 0 && text.trim().length === 0);
-
-    return (
-      <div className='compose-form'>
-        <Collapsable isVisible={this.props.spoiler} fullHeight={50}>
-          <div className='spoiler-input'>
-            <label>
-              <span style={{ display: 'none' }}>{intl.formatMessage(messages.spoiler_placeholder)}</span>
-              <input placeholder={intl.formatMessage(messages.spoiler_placeholder)} value={this.props.spoiler_text} onChange={this.handleChangeSpoilerText} onKeyDown={this.handleKeyDown} type='text' className='spoiler-input__input'  id='cw-spoiler-input' />
-            </label>
-          </div>
-        </Collapsable>
-
-        <WarningContainer />
-
-        <ReplyIndicatorContainer />
-
-        <div className='compose-form__autosuggest-wrapper'>
-          <AutosuggestTextarea
-            ref={this.setAutosuggestTextarea}
-            placeholder={intl.formatMessage(messages.placeholder)}
-            disabled={disabled}
-            value={this.props.text}
-            onChange={this.handleChange}
-            suggestions={this.props.suggestions}
-            onKeyDown={this.handleKeyDown}
-            onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
-            onSuggestionsClearRequested={this.onSuggestionsClearRequested}
-            onSuggestionSelected={this.onSuggestionSelected}
-            onPaste={onPaste}
-            autoFocus={!showSearch && !isMobile(window.innerWidth)}
-          />
-
-          <EmojiPickerDropdown onPickEmoji={this.handleEmojiPick} />
-        </div>
-
-        <div className='compose-form__modifiers'>
-          <UploadFormContainer />
-        </div>
-
-        <div className='compose-form__buttons'>
-          <ComposeAttachOptions />
-          <SensitiveButtonContainer />
-          <div className='compose-form__buttons-separator' />
-          <PrivacyDropdownContainer />
-          <SpoilerButtonContainer />
-          <ComposeAdvancedOptionsContainer />
-        </div>
-
-        <div className='compose-form__publish'>
-          <div className='character-counter__wrapper'><CharacterCounter max={maxChars} text={text} /></div>
-          <div className='compose-form__publish-button-wrapper'>
-            {
-              showSideArm ?
-                <Button
-                  className='compose-form__publish__side-arm'
-                  text={publishText2}
-                  title={title2}
-                  onClick={this.handleSubmit2}
-                  disabled={submitDisabled}
-                /> : ''
-            }
-            <Button
-              className='compose-form__publish__primary'
-              text={publishText}
-              title={title}
-              onClick={this.handleSubmit}
-              disabled={submitDisabled}
-            />
-          </div>
-        </div>
-      </div>
-    );
-  }
-
-}
diff --git a/app/javascript/flavours/glitch/features/compose/components/dropdown.js b/app/javascript/flavours/glitch/features/compose/components/dropdown.js
deleted file mode 100644
index 1b0000fb7..000000000
--- a/app/javascript/flavours/glitch/features/compose/components/dropdown.js
+++ /dev/null
@@ -1,77 +0,0 @@
-//  Package imports.
-import React from 'react';
-import PropTypes from 'prop-types';
-
-//  Our imports.
-import IconButton from 'flavours/glitch/components/icon_button';
-
-const iconStyle = {
-  height     : null,
-  lineHeight : '27px',
-};
-
-export default class ComposeDropdown extends React.PureComponent {
-
-  static propTypes = {
-    title: PropTypes.string.isRequired,
-    icon: PropTypes.string,
-    highlight: PropTypes.bool,
-    disabled: PropTypes.bool,
-    children: PropTypes.arrayOf(PropTypes.node).isRequired,
-  };
-
-  state = {
-    open: false,
-  };
-
-  onGlobalClick = (e) => {
-    if (e.target !== this.node && !this.node.contains(e.target) && this.state.open) {
-      this.setState({ open: false });
-    }
-  };
-
-  componentDidMount () {
-    window.addEventListener('click', this.onGlobalClick);
-    window.addEventListener('touchstart', this.onGlobalClick);
-  }
-  componentWillUnmount () {
-    window.removeEventListener('click', this.onGlobalClick);
-    window.removeEventListener('touchstart', this.onGlobalClick);
-  }
-
-  onToggleDropdown = () => {
-    if (this.props.disabled) return;
-    this.setState({ open: !this.state.open });
-  };
-
-  setRef = (c) => {
-    this.node = c;
-  };
-
-  render () {
-    const { open } = this.state;
-    let { highlight, title, icon, disabled } = this.props;
-
-    if (!icon) icon = 'ellipsis-h';
-
-    return (
-      <div ref={this.setRef} className={`advanced-options-dropdown ${open ?  'open' : ''} ${highlight ? 'active' : ''} `}>
-        <div className='advanced-options-dropdown__value'>
-          <IconButton
-            className={'inverted'}
-            title={title}
-            icon={icon} active={open || highlight}
-            size={18}
-            style={iconStyle}
-            disabled={disabled}
-            onClick={this.onToggleDropdown}
-          />
-        </div>
-        <div className='advanced-options-dropdown__dropdown'>
-          {this.props.children}
-        </div>
-      </div>
-    );
-  }
-
-}
diff --git a/app/javascript/flavours/glitch/features/compose/components/navigation_bar.js b/app/javascript/flavours/glitch/features/compose/components/navigation_bar.js
deleted file mode 100644
index 1b6d74123..000000000
--- a/app/javascript/flavours/glitch/features/compose/components/navigation_bar.js
+++ /dev/null
@@ -1,38 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import Avatar from 'flavours/glitch/components/avatar';
-import IconButton from 'flavours/glitch/components/icon_button';
-import Permalink from 'flavours/glitch/components/permalink';
-import { FormattedMessage } from 'react-intl';
-import ImmutablePureComponent from 'react-immutable-pure-component';
-
-export default class NavigationBar extends ImmutablePureComponent {
-
-  static propTypes = {
-    account: ImmutablePropTypes.map.isRequired,
-    onClose: PropTypes.func.isRequired,
-  };
-
-  render () {
-    return (
-      <div className='navigation-bar'>
-        <Permalink href={this.props.account.get('url')} to={`/accounts/${this.props.account.get('id')}`}>
-          <span style={{ display: 'none' }}>{this.props.account.get('acct')}</span>
-          <Avatar account={this.props.account} size={40} />
-        </Permalink>
-
-        <div className='navigation-bar__profile'>
-          <Permalink href={this.props.account.get('url')} to={`/accounts/${this.props.account.get('id')}`}>
-            <strong className='navigation-bar__profile-account'>@{this.props.account.get('acct')}</strong>
-          </Permalink>
-
-          <a href='/settings/profile' className='navigation-bar__profile-edit'><FormattedMessage id='navigation_bar.edit_profile' defaultMessage='Edit profile' /></a>
-        </div>
-
-        <IconButton title='' icon='close' onClick={this.props.onClose} />
-      </div>
-    );
-  }
-
-}
diff --git a/app/javascript/flavours/glitch/features/compose/components/privacy_dropdown.js b/app/javascript/flavours/glitch/features/compose/components/privacy_dropdown.js
deleted file mode 100644
index 90f062f8f..000000000
--- a/app/javascript/flavours/glitch/features/compose/components/privacy_dropdown.js
+++ /dev/null
@@ -1,200 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import { injectIntl, defineMessages } from 'react-intl';
-import IconButton from 'flavours/glitch/components/icon_button';
-import Overlay from 'react-overlays/lib/Overlay';
-import Motion from 'flavours/glitch/util/optional_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' },
-  public_long: { id: 'privacy.public.long', defaultMessage: 'Post to public timelines' },
-  unlisted_short: { id: 'privacy.unlisted.short', defaultMessage: 'Unlisted' },
-  unlisted_long: { id: 'privacy.unlisted.long', defaultMessage: 'Do not show in public timelines' },
-  private_short: { id: 'privacy.private.short', defaultMessage: 'Followers-only' },
-  private_long: { id: 'privacy.private.long', defaultMessage: 'Post to followers only' },
-  direct_short: { id: 'privacy.direct.short', defaultMessage: 'Direct' },
-  direct_long: { id: 'privacy.direct.long', defaultMessage: 'Post to mentioned users only' },
-  change_privacy: { id: 'privacy.change', defaultMessage: 'Adjust status privacy' },
-});
-
-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 {
-
-  static propTypes = {
-    isUserTouching: PropTypes.func,
-    isModalOpen: PropTypes.bool.isRequired,
-    onModalOpen: PropTypes.func,
-    onModalClose: PropTypes.func,
-    value: PropTypes.string.isRequired,
-    onChange: PropTypes.func.isRequired,
-    intl: PropTypes.object.isRequired,
-  };
-
-  state = {
-    open: false,
-  };
-
-  handleToggle = () => {
-    if (this.props.isUserTouching()) {
-      if (this.state.open) {
-        this.props.onModalClose();
-      } else {
-        this.props.onModalOpen({
-          actions: this.options.map(option => ({ ...option, active: option.value === this.props.value })),
-          onClick: this.handleModalActionClick,
-        });
-      }
-    } else {
-      this.setState({ open: !this.state.open });
-    }
-  }
-
-  handleModalActionClick = (e) => {
-    e.preventDefault();
-
-    const { value } = this.options[e.currentTarget.getAttribute('data-index')];
-
-    this.props.onModalClose();
-    this.props.onChange(value);
-  }
-
-  handleKeyDown = e => {
-    switch(e.key) {
-    case 'Enter':
-      this.handleToggle();
-      break;
-    case 'Escape':
-      this.handleClose();
-      break;
-    }
-  }
-
-  handleClose = () => {
-    this.setState({ open: false });
-  }
-
-  handleChange = value => {
-    this.props.onChange(value);
-  }
-
-  componentWillMount () {
-    const { intl: { formatMessage } } = this.props;
-
-    this.options = [
-      { icon: 'globe', value: 'public', text: formatMessage(messages.public_short), meta: formatMessage(messages.public_long) },
-      { icon: 'unlock-alt', value: 'unlisted', text: formatMessage(messages.unlisted_short), meta: formatMessage(messages.unlisted_long) },
-      { icon: 'lock', value: 'private', text: formatMessage(messages.private_short), meta: formatMessage(messages.private_long) },
-      { icon: 'envelope', value: 'direct', text: formatMessage(messages.direct_short), meta: formatMessage(messages.direct_long) },
-    ];
-  }
-
-  render () {
-    const { value, intl } = this.props;
-    const { open } = this.state;
-
-    const valueOption = this.options.find(item => item.value === value);
-
-    return (
-      <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/flavours/glitch/features/compose/components/reply_indicator.js b/app/javascript/flavours/glitch/features/compose/components/reply_indicator.js
deleted file mode 100644
index 3048d591b..000000000
--- a/app/javascript/flavours/glitch/features/compose/components/reply_indicator.js
+++ /dev/null
@@ -1,67 +0,0 @@
-import React from 'react';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import PropTypes from 'prop-types';
-import Avatar from 'flavours/glitch/components/avatar';
-import IconButton from 'flavours/glitch/components/icon_button';
-import DisplayName from 'flavours/glitch/components/display_name';
-import { defineMessages, injectIntl } from 'react-intl';
-import ImmutablePureComponent from 'react-immutable-pure-component';
-import { isRtl } from 'flavours/glitch/util/rtl';
-
-const messages = defineMessages({
-  cancel: { id: 'reply_indicator.cancel', defaultMessage: 'Cancel' },
-});
-
-@injectIntl
-export default class ReplyIndicator extends ImmutablePureComponent {
-
-  static contextTypes = {
-    router: PropTypes.object,
-  };
-
-  static propTypes = {
-    status: ImmutablePropTypes.map,
-    onCancel: PropTypes.func.isRequired,
-    intl: PropTypes.object.isRequired,
-  };
-
-  handleClick = () => {
-    this.props.onCancel();
-  }
-
-  handleAccountClick = (e) => {
-    if (e.button === 0) {
-      e.preventDefault();
-      this.context.router.history.push(`/accounts/${this.props.status.getIn(['account', 'id'])}`);
-    }
-  }
-
-  render () {
-    const { status, intl } = this.props;
-
-    if (!status) {
-      return null;
-    }
-
-    const content = { __html: status.get('contentHtml') };
-    const style   = {
-      direction: isRtl(status.get('search_index')) ? 'rtl' : 'ltr',
-    };
-
-    return (
-      <div className='reply-indicator'>
-        <div className='reply-indicator__header'>
-          <div className='reply-indicator__cancel'><IconButton title={intl.formatMessage(messages.cancel)} icon='times' onClick={this.handleClick} /></div>
-
-          <a href={status.getIn(['account', 'url'])} onClick={this.handleAccountClick} className='reply-indicator__display-name'>
-            <div className='reply-indicator__display-avatar'><Avatar account={status.get('account')} size={24} /></div>
-            <DisplayName account={status.get('account')} />
-          </a>
-        </div>
-
-        <div className='reply-indicator__content' style={style} dangerouslySetInnerHTML={content} />
-      </div>
-    );
-  }
-
-}
diff --git a/app/javascript/flavours/glitch/features/compose/components/search.js b/app/javascript/flavours/glitch/features/compose/components/search.js
deleted file mode 100644
index 1ce66b19d..000000000
--- a/app/javascript/flavours/glitch/features/compose/components/search.js
+++ /dev/null
@@ -1,129 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
-import Overlay from 'react-overlays/lib/Overlay';
-import Motion from 'flavours/glitch/util/optional_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 {
-
-  static propTypes = {
-    value: PropTypes.string.isRequired,
-    submitted: PropTypes.bool,
-    onChange: PropTypes.func.isRequired,
-    onSubmit: PropTypes.func.isRequired,
-    onClear: PropTypes.func.isRequired,
-    onShow: PropTypes.func.isRequired,
-    intl: PropTypes.object.isRequired,
-  };
-
-  state = {
-    expanded: false,
-  };
-
-  handleChange = (e) => {
-    this.props.onChange(e.target.value);
-  }
-
-  handleClear = (e) => {
-    e.preventDefault();
-
-    if (this.props.value.length > 0 || this.props.submitted) {
-      this.props.onClear();
-    }
-  }
-
-  handleKeyDown = (e) => {
-    if (e.key === 'Enter') {
-      e.preventDefault();
-      this.props.onSubmit();
-    } else if (e.key === 'Escape') {
-      document.querySelector('.ui').parentElement.focus();
-    }
-  }
-
-  noop () {
-
-  }
-
-  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 (
-      <div className='search'>
-        <label>
-          <span style={{ display: 'none' }}>{intl.formatMessage(messages.placeholder)}</span>
-          <input
-            className='search__input'
-            type='text'
-            placeholder={intl.formatMessage(messages.placeholder)}
-            value={value}
-            onChange={this.handleChange}
-            onKeyUp={this.handleKeyDown}
-            onFocus={this.handleFocus}
-            onBlur={this.handleBlur}
-          />
-        </label>
-
-        <div role='button' tabIndex='0' className='search__icon' onClick={this.handleClear}>
-          <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/flavours/glitch/features/compose/components/search_results.js b/app/javascript/flavours/glitch/features/compose/components/search_results.js
deleted file mode 100644
index 2a4818d4e..000000000
--- a/app/javascript/flavours/glitch/features/compose/components/search_results.js
+++ /dev/null
@@ -1,65 +0,0 @@
-import React from 'react';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import { FormattedMessage } from 'react-intl';
-import AccountContainer from 'flavours/glitch/containers/account_container';
-import StatusContainer from 'flavours/glitch/containers/status_container';
-import { Link } from 'react-router-dom';
-import ImmutablePureComponent from 'react-immutable-pure-component';
-
-export default class SearchResults extends ImmutablePureComponent {
-
-  static propTypes = {
-    results: ImmutablePropTypes.map.isRequired,
-  };
-
-  render () {
-    const { results } = this.props;
-
-    let accounts, statuses, hashtags;
-    let count = 0;
-
-    if (results.get('accounts') && results.get('accounts').size > 0) {
-      count   += results.get('accounts').size;
-      accounts = (
-        <div className='search-results__section'>
-          {results.get('accounts').map(accountId => <AccountContainer key={accountId} id={accountId} />)}
-        </div>
-      );
-    }
-
-    if (results.get('statuses') && results.get('statuses').size > 0) {
-      count   += results.get('statuses').size;
-      statuses = (
-        <div className='search-results__section'>
-          {results.get('statuses').map(statusId => <StatusContainer key={statusId} id={statusId} />)}
-        </div>
-      );
-    }
-
-    if (results.get('hashtags') && results.get('hashtags').size > 0) {
-      count += results.get('hashtags').size;
-      hashtags = (
-        <div className='search-results__section'>
-          {results.get('hashtags').map(hashtag =>
-            <Link key={hashtag} className='search-results__hashtag' to={`/timelines/tag/${hashtag}`}>
-              #{hashtag}
-            </Link>
-          )}
-        </div>
-      );
-    }
-
-    return (
-      <div className='search-results'>
-        <div className='search-results__header'>
-          <FormattedMessage id='search_results.total' defaultMessage='{count, number} {count, plural, one {result} other {results}}' values={{ count }} />
-        </div>
-
-        {accounts}
-        {statuses}
-        {hashtags}
-      </div>
-    );
-  }
-
-}
diff --git a/app/javascript/flavours/glitch/features/compose/components/upload.js b/app/javascript/flavours/glitch/features/compose/components/upload.js
deleted file mode 100644
index a1fc93234..000000000
--- a/app/javascript/flavours/glitch/features/compose/components/upload.js
+++ /dev/null
@@ -1,96 +0,0 @@
-import React from 'react';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import PropTypes from 'prop-types';
-import IconButton from 'flavours/glitch/components/icon_button';
-import Motion from 'flavours/glitch/util/optional_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: `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/flavours/glitch/features/compose/components/upload_button.js b/app/javascript/flavours/glitch/features/compose/components/upload_button.js
deleted file mode 100644
index f06167a2a..000000000
--- a/app/javascript/flavours/glitch/features/compose/components/upload_button.js
+++ /dev/null
@@ -1,77 +0,0 @@
-import React from 'react';
-import IconButton from 'flavours/glitch/components/icon_button';
-import PropTypes from 'prop-types';
-import { defineMessages, injectIntl } from 'react-intl';
-import { connect } from 'react-redux';
-import ImmutablePureComponent from 'react-immutable-pure-component';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-
-const messages = defineMessages({
-  upload: { id: 'upload_button.label', defaultMessage: 'Add media' },
-});
-
-const makeMapStateToProps = () => {
-  const mapStateToProps = state => ({
-    acceptContentTypes: state.getIn(['media_attachments', 'accept_content_types']),
-  });
-
-  return mapStateToProps;
-};
-
-const iconStyle = {
-  height: null,
-  lineHeight: '27px',
-};
-
-@connect(makeMapStateToProps)
-@injectIntl
-export default class UploadButton extends ImmutablePureComponent {
-
-  static propTypes = {
-    disabled: PropTypes.bool,
-    onSelectFile: PropTypes.func.isRequired,
-    style: PropTypes.object,
-    resetFileKey: PropTypes.number,
-    acceptContentTypes: ImmutablePropTypes.listOf(PropTypes.string).isRequired,
-    intl: PropTypes.object.isRequired,
-  };
-
-  handleChange = (e) => {
-    if (e.target.files.length > 0) {
-      this.props.onSelectFile(e.target.files);
-    }
-  }
-
-  handleClick = () => {
-    this.fileElement.click();
-  }
-
-  setRef = (c) => {
-    this.fileElement = c;
-  }
-
-  render () {
-
-    const { intl, resetFileKey, disabled, acceptContentTypes } = this.props;
-
-    return (
-      <div className='compose-form__upload-button'>
-        <IconButton icon='camera' title={intl.formatMessage(messages.upload)} disabled={disabled} onClick={this.handleClick} className='compose-form__upload-button-icon' size={18} inverted style={iconStyle} />
-        <label>
-          <span style={{ display: 'none' }}>{intl.formatMessage(messages.upload)}</span>
-          <input
-            key={resetFileKey}
-            ref={this.setRef}
-            type='file'
-            multiple={false}
-            accept={acceptContentTypes.toArray().join(',')}
-            onChange={this.handleChange}
-            disabled={disabled}
-            style={{ display: 'none' }}
-          />
-        </label>
-      </div>
-    );
-  }
-
-}
diff --git a/app/javascript/flavours/glitch/features/compose/components/upload_form.js b/app/javascript/flavours/glitch/features/compose/components/upload_form.js
deleted file mode 100644
index b7f112205..000000000
--- a/app/javascript/flavours/glitch/features/compose/components/upload_form.js
+++ /dev/null
@@ -1,29 +0,0 @@
-import React from 'react';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import UploadProgressContainer from '../containers/upload_progress_container';
-import ImmutablePureComponent from 'react-immutable-pure-component';
-import UploadContainer from '../containers/upload_container';
-
-export default class UploadForm extends ImmutablePureComponent {
-
-  static propTypes = {
-    mediaIds: ImmutablePropTypes.list.isRequired,
-  };
-
-  render () {
-    const { mediaIds } = this.props;
-
-    return (
-      <div className='compose-form__upload-wrapper'>
-        <UploadProgressContainer />
-
-        <div className='compose-form__uploads-wrapper'>
-          {mediaIds.map(id => (
-            <UploadContainer id={id} key={id} />
-          ))}
-        </div>
-      </div>
-    );
-  }
-
-}
diff --git a/app/javascript/flavours/glitch/features/compose/components/upload_progress.js b/app/javascript/flavours/glitch/features/compose/components/upload_progress.js
deleted file mode 100644
index 2a3b8ceb4..000000000
--- a/app/javascript/flavours/glitch/features/compose/components/upload_progress.js
+++ /dev/null
@@ -1,42 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import Motion from 'flavours/glitch/util/optional_motion';
-import spring from 'react-motion/lib/spring';
-import { FormattedMessage } from 'react-intl';
-
-export default class UploadProgress extends React.PureComponent {
-
-  static propTypes = {
-    active: PropTypes.bool,
-    progress: PropTypes.number,
-  };
-
-  render () {
-    const { active, progress } = this.props;
-
-    if (!active) {
-      return null;
-    }
-
-    return (
-      <div className='upload-progress'>
-        <div className='upload-progress__icon'>
-          <i className='fa fa-upload' />
-        </div>
-
-        <div className='upload-progress__message'>
-          <FormattedMessage id='upload_progress.label' defaultMessage='Uploading...' />
-
-          <div className='upload-progress__backdrop'>
-            <Motion defaultStyle={{ width: 0 }} style={{ width: spring(progress) }}>
-              {({ width }) =>
-                <div className='upload-progress__tracker' style={{ width: `${width}%` }} />
-              }
-            </Motion>
-          </div>
-        </div>
-      </div>
-    );
-  }
-
-}
diff --git a/app/javascript/flavours/glitch/features/compose/components/warning.js b/app/javascript/flavours/glitch/features/compose/components/warning.js
deleted file mode 100644
index 4962e76c8..000000000
--- a/app/javascript/flavours/glitch/features/compose/components/warning.js
+++ /dev/null
@@ -1,26 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import Motion from 'flavours/glitch/util/optional_motion';
-import spring from 'react-motion/lib/spring';
-
-export default class Warning extends React.PureComponent {
-
-  static propTypes = {
-    message: PropTypes.node.isRequired,
-  };
-
-  render () {
-    const { message } = 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='compose-form__warning' style={{ opacity: opacity, transform: `scale(${scaleX}, ${scaleY})` }}>
-            {message}
-          </div>
-        )}
-      </Motion>
-    );
-  }
-
-}
diff --git a/app/javascript/flavours/glitch/features/compose/containers/advanced_options_container.js b/app/javascript/flavours/glitch/features/compose/containers/advanced_options_container.js
deleted file mode 100644
index da381568b..000000000
--- a/app/javascript/flavours/glitch/features/compose/containers/advanced_options_container.js
+++ /dev/null
@@ -1,20 +0,0 @@
-//  Package imports.
-import { connect } from 'react-redux';
-
-//  Our imports.
-import { toggleComposeAdvancedOption } from 'flavours/glitch/actions/compose';
-import ComposeAdvancedOptions from '../components/advanced_options';
-
-const mapStateToProps = state => ({
-  values: state.getIn(['compose', 'advanced_options']),
-});
-
-const mapDispatchToProps = dispatch => ({
-
-  onChange (option) {
-    dispatch(toggleComposeAdvancedOption(option));
-  },
-
-});
-
-export default connect(mapStateToProps, mapDispatchToProps)(ComposeAdvancedOptions);
diff --git a/app/javascript/flavours/glitch/features/compose/containers/autosuggest_account_container.js b/app/javascript/flavours/glitch/features/compose/containers/autosuggest_account_container.js
deleted file mode 100644
index 0e1c328fe..000000000
--- a/app/javascript/flavours/glitch/features/compose/containers/autosuggest_account_container.js
+++ /dev/null
@@ -1,15 +0,0 @@
-import { connect } from 'react-redux';
-import AutosuggestAccount from '../components/autosuggest_account';
-import { makeGetAccount } from 'flavours/glitch/selectors';
-
-const makeMapStateToProps = () => {
-  const getAccount = makeGetAccount();
-
-  const mapStateToProps = (state, { id }) => ({
-    account: getAccount(state, id),
-  });
-
-  return mapStateToProps;
-};
-
-export default connect(makeMapStateToProps)(AutosuggestAccount);
diff --git a/app/javascript/flavours/glitch/features/compose/containers/compose_form_container.js b/app/javascript/flavours/glitch/features/compose/containers/compose_form_container.js
deleted file mode 100644
index e2e93e44b..000000000
--- a/app/javascript/flavours/glitch/features/compose/containers/compose_form_container.js
+++ /dev/null
@@ -1,71 +0,0 @@
-import { connect } from 'react-redux';
-import ComposeForm from '../components/compose_form';
-import { changeComposeVisibility, uploadCompose } from 'flavours/glitch/actions/compose';
-import {
-  changeCompose,
-  submitCompose,
-  clearComposeSuggestions,
-  fetchComposeSuggestions,
-  selectComposeSuggestion,
-  changeComposeSpoilerText,
-  insertEmojiCompose,
-} from 'flavours/glitch/actions/compose';
-
-const mapStateToProps = state => ({
-  text: state.getIn(['compose', 'text']),
-  suggestion_token: state.getIn(['compose', 'suggestion_token']),
-  suggestions: state.getIn(['compose', 'suggestions']),
-  advanced_options: state.getIn(['compose', 'advanced_options']),
-  spoiler: state.getIn(['compose', 'spoiler']),
-  spoiler_text: state.getIn(['compose', 'spoiler_text']),
-  privacy: state.getIn(['compose', 'privacy']),
-  focusDate: state.getIn(['compose', 'focusDate']),
-  preselectDate: state.getIn(['compose', 'preselectDate']),
-  is_submitting: state.getIn(['compose', 'is_submitting']),
-  is_uploading: state.getIn(['compose', 'is_uploading']),
-  showSearch: state.getIn(['search', 'submitted']) && !state.getIn(['search', 'hidden']),
-  settings: state.get('local_settings'),
-  filesAttached: state.getIn(['compose', 'media_attachments']).size > 0,
-});
-
-const mapDispatchToProps = (dispatch) => ({
-
-  onChange (text) {
-    dispatch(changeCompose(text));
-  },
-
-  onPrivacyChange (value) {
-    dispatch(changeComposeVisibility(value));
-  },
-
-  onSubmit () {
-    dispatch(submitCompose());
-  },
-
-  onClearSuggestions () {
-    dispatch(clearComposeSuggestions());
-  },
-
-  onFetchSuggestions (token) {
-    dispatch(fetchComposeSuggestions(token));
-  },
-
-  onSuggestionSelected (position, token, accountId) {
-    dispatch(selectComposeSuggestion(position, token, accountId));
-  },
-
-  onChangeSpoilerText (checked) {
-    dispatch(changeComposeSpoilerText(checked));
-  },
-
-  onPaste (files) {
-    dispatch(uploadCompose(files));
-  },
-
-  onPickEmoji (position, data) {
-    dispatch(insertEmojiCompose(position, data));
-  },
-
-});
-
-export default connect(mapStateToProps, mapDispatchToProps)(ComposeForm);
diff --git a/app/javascript/flavours/glitch/features/compose/containers/emoji_picker_dropdown_container.js b/app/javascript/flavours/glitch/features/compose/containers/emoji_picker_dropdown_container.js
deleted file mode 100644
index ba85edd87..000000000
--- a/app/javascript/flavours/glitch/features/compose/containers/emoji_picker_dropdown_container.js
+++ /dev/null
@@ -1,82 +0,0 @@
-import { connect } from 'react-redux';
-import EmojiPickerDropdown from '../components/emoji_picker_dropdown';
-import { changeSetting } from 'flavours/glitch/actions/settings';
-import { createSelector } from 'reselect';
-import { Map as ImmutableMap } from 'immutable';
-import { useEmoji } from 'flavours/glitch/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.filter(e => e.get('visible_in_picker')).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: getCustomEmojis(state),
-  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, mapDispatchToProps)(EmojiPickerDropdown);
diff --git a/app/javascript/flavours/glitch/features/compose/containers/navigation_container.js b/app/javascript/flavours/glitch/features/compose/containers/navigation_container.js
deleted file mode 100644
index eb630ffbb..000000000
--- a/app/javascript/flavours/glitch/features/compose/containers/navigation_container.js
+++ /dev/null
@@ -1,11 +0,0 @@
-import { connect }   from 'react-redux';
-import NavigationBar from '../components/navigation_bar';
-import { me } from 'flavours/glitch/util/initial_state';
-
-const mapStateToProps = state => {
-  return {
-    account: state.getIn(['accounts', me]),
-  };
-};
-
-export default connect(mapStateToProps)(NavigationBar);
diff --git a/app/javascript/flavours/glitch/features/compose/containers/privacy_dropdown_container.js b/app/javascript/flavours/glitch/features/compose/containers/privacy_dropdown_container.js
deleted file mode 100644
index cb94fcc80..000000000
--- a/app/javascript/flavours/glitch/features/compose/containers/privacy_dropdown_container.js
+++ /dev/null
@@ -1,24 +0,0 @@
-import { connect } from 'react-redux';
-import PrivacyDropdown from '../components/privacy_dropdown';
-import { changeComposeVisibility } from 'flavours/glitch/actions/compose';
-import { openModal, closeModal } from 'flavours/glitch/actions/modal';
-import { isUserTouching } from 'flavours/glitch/util/is_mobile';
-
-const mapStateToProps = state => ({
-  isModalOpen: state.get('modal').modalType === 'ACTIONS',
-  value: state.getIn(['compose', 'privacy']),
-});
-
-const mapDispatchToProps = dispatch => ({
-
-  onChange (value) {
-    dispatch(changeComposeVisibility(value));
-  },
-
-  isUserTouching,
-  onModalOpen: props => dispatch(openModal('ACTIONS', props)),
-  onModalClose: () => dispatch(closeModal()),
-
-});
-
-export default connect(mapStateToProps, mapDispatchToProps)(PrivacyDropdown);
diff --git a/app/javascript/flavours/glitch/features/compose/containers/reply_indicator_container.js b/app/javascript/flavours/glitch/features/compose/containers/reply_indicator_container.js
deleted file mode 100644
index a7c82d135..000000000
--- a/app/javascript/flavours/glitch/features/compose/containers/reply_indicator_container.js
+++ /dev/null
@@ -1,24 +0,0 @@
-import { connect } from 'react-redux';
-import { cancelReplyCompose } from 'flavours/glitch/actions/compose';
-import { makeGetStatus } from 'flavours/glitch/selectors';
-import ReplyIndicator from '../components/reply_indicator';
-
-const makeMapStateToProps = () => {
-  const getStatus = makeGetStatus();
-
-  const mapStateToProps = state => ({
-    status: getStatus(state, state.getIn(['compose', 'in_reply_to'])),
-  });
-
-  return mapStateToProps;
-};
-
-const mapDispatchToProps = dispatch => ({
-
-  onCancel () {
-    dispatch(cancelReplyCompose());
-  },
-
-});
-
-export default connect(makeMapStateToProps, mapDispatchToProps)(ReplyIndicator);
diff --git a/app/javascript/flavours/glitch/features/compose/containers/search_container.js b/app/javascript/flavours/glitch/features/compose/containers/search_container.js
deleted file mode 100644
index 8f4bfcf08..000000000
--- a/app/javascript/flavours/glitch/features/compose/containers/search_container.js
+++ /dev/null
@@ -1,35 +0,0 @@
-import { connect } from 'react-redux';
-import {
-  changeSearch,
-  clearSearch,
-  submitSearch,
-  showSearch,
-} from 'flavours/glitch/actions/search';
-import Search from '../components/search';
-
-const mapStateToProps = state => ({
-  value: state.getIn(['search', 'value']),
-  submitted: state.getIn(['search', 'submitted']),
-});
-
-const mapDispatchToProps = dispatch => ({
-
-  onChange (value) {
-    dispatch(changeSearch(value));
-  },
-
-  onClear () {
-    dispatch(clearSearch());
-  },
-
-  onSubmit () {
-    dispatch(submitSearch());
-  },
-
-  onShow () {
-    dispatch(showSearch());
-  },
-
-});
-
-export default connect(mapStateToProps, mapDispatchToProps)(Search);
diff --git a/app/javascript/flavours/glitch/features/compose/containers/search_results_container.js b/app/javascript/flavours/glitch/features/compose/containers/search_results_container.js
deleted file mode 100644
index 16d95d417..000000000
--- a/app/javascript/flavours/glitch/features/compose/containers/search_results_container.js
+++ /dev/null
@@ -1,8 +0,0 @@
-import { connect } from 'react-redux';
-import SearchResults from '../components/search_results';
-
-const mapStateToProps = state => ({
-  results: state.getIn(['search', 'results']),
-});
-
-export default connect(mapStateToProps)(SearchResults);
diff --git a/app/javascript/flavours/glitch/features/compose/containers/sensitive_button_container.js b/app/javascript/flavours/glitch/features/compose/containers/sensitive_button_container.js
deleted file mode 100644
index cf6706c0e..000000000
--- a/app/javascript/flavours/glitch/features/compose/containers/sensitive_button_container.js
+++ /dev/null
@@ -1,71 +0,0 @@
-import React from 'react';
-import { connect } from 'react-redux';
-import PropTypes from 'prop-types';
-import classNames from 'classnames';
-import IconButton from 'flavours/glitch/components/icon_button';
-import { changeComposeSensitivity } from 'flavours/glitch/actions/compose';
-import Motion from 'flavours/glitch/util/optional_motion';
-import spring from 'react-motion/lib/spring';
-import { injectIntl, defineMessages } from 'react-intl';
-
-const messages = defineMessages({
-  title: { id: 'compose_form.sensitive', defaultMessage: 'Mark media as sensitive' },
-});
-
-const mapStateToProps = state => ({
-  visible: state.getIn(['compose', 'media_attachments']).size > 0,
-  active: state.getIn(['compose', 'sensitive']),
-  disabled: state.getIn(['compose', 'spoiler']),
-});
-
-const mapDispatchToProps = dispatch => ({
-
-  onClick () {
-    dispatch(changeComposeSensitivity());
-  },
-
-});
-
-class SensitiveButton extends React.PureComponent {
-
-  static propTypes = {
-    visible: PropTypes.bool,
-    active: PropTypes.bool,
-    disabled: PropTypes.bool,
-    onClick: PropTypes.func.isRequired,
-    intl: PropTypes.object.isRequired,
-  };
-
-  render () {
-    const { visible, active, disabled, onClick, intl } = this.props;
-
-    return (
-      <Motion defaultStyle={{ scale: 0.87 }} style={{ scale: spring(visible ? 1 : 0.87, { stiffness: 200, damping: 3 }) }}>
-        {({ scale }) => {
-          const icon = active ? 'eye-slash' : 'eye';
-          const className = classNames('compose-form__sensitive-button', {
-            'compose-form__sensitive-button--visible': visible,
-          });
-          return (
-            <div className={className} style={{ transform: `scale(${scale})` }}>
-              <IconButton
-                className='compose-form__sensitive-button__icon'
-                title={intl.formatMessage(messages.title)}
-                icon={icon}
-                onClick={onClick}
-                size={18}
-                active={active}
-                disabled={disabled}
-                style={{ lineHeight: null, height: null }}
-                inverted
-              />
-            </div>
-          );
-        }}
-      </Motion>
-    );
-  }
-
-}
-
-export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(SensitiveButton));
diff --git a/app/javascript/flavours/glitch/features/compose/containers/spoiler_button_container.js b/app/javascript/flavours/glitch/features/compose/containers/spoiler_button_container.js
deleted file mode 100644
index d7b4246bc..000000000
--- a/app/javascript/flavours/glitch/features/compose/containers/spoiler_button_container.js
+++ /dev/null
@@ -1,25 +0,0 @@
-import { connect } from 'react-redux';
-import TextIconButton from '../components/text_icon_button';
-import { changeComposeSpoilerness } from 'flavours/glitch/actions/compose';
-import { injectIntl, defineMessages } from 'react-intl';
-
-const messages = defineMessages({
-  title: { id: 'compose_form.spoiler', defaultMessage: 'Hide text behind warning' },
-});
-
-const mapStateToProps = (state, { intl }) => ({
-  label: 'CW',
-  title: intl.formatMessage(messages.title),
-  active: state.getIn(['compose', 'spoiler']),
-  ariaControls: 'cw-spoiler-input',
-});
-
-const mapDispatchToProps = dispatch => ({
-
-  onClick () {
-    dispatch(changeComposeSpoilerness());
-  },
-
-});
-
-export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(TextIconButton));
diff --git a/app/javascript/flavours/glitch/features/compose/containers/upload_button_container.js b/app/javascript/flavours/glitch/features/compose/containers/upload_button_container.js
deleted file mode 100644
index 4c1cb49e9..000000000
--- a/app/javascript/flavours/glitch/features/compose/containers/upload_button_container.js
+++ /dev/null
@@ -1,18 +0,0 @@
-import { connect } from 'react-redux';
-import UploadButton from '../components/upload_button';
-import { uploadCompose } from 'flavours/glitch/actions/compose';
-
-const mapStateToProps = state => ({
-  disabled: state.getIn(['compose', 'is_uploading']) || (state.getIn(['compose', 'media_attachments']).size > 3 || state.getIn(['compose', 'media_attachments']).some(m => m.get('type') === 'video')),
-  resetFileKey: state.getIn(['compose', 'resetFileKey']),
-});
-
-const mapDispatchToProps = dispatch => ({
-
-  onSelectFile (files) {
-    dispatch(uploadCompose(files));
-  },
-
-});
-
-export default connect(mapStateToProps, mapDispatchToProps)(UploadButton);
diff --git a/app/javascript/flavours/glitch/features/compose/containers/upload_container.js b/app/javascript/flavours/glitch/features/compose/containers/upload_container.js
deleted file mode 100644
index 368038425..000000000
--- a/app/javascript/flavours/glitch/features/compose/containers/upload_container.js
+++ /dev/null
@@ -1,21 +0,0 @@
-import { connect } from 'react-redux';
-import Upload from '../components/upload';
-import { undoUploadCompose, changeUploadCompose } from 'flavours/glitch/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/flavours/glitch/features/compose/containers/upload_form_container.js b/app/javascript/flavours/glitch/features/compose/containers/upload_form_container.js
deleted file mode 100644
index a6798bf51..000000000
--- a/app/javascript/flavours/glitch/features/compose/containers/upload_form_container.js
+++ /dev/null
@@ -1,8 +0,0 @@
-import { connect } from 'react-redux';
-import UploadForm from '../components/upload_form';
-
-const mapStateToProps = state => ({
-  mediaIds: state.getIn(['compose', 'media_attachments']).map(item => item.get('id')),
-});
-
-export default connect(mapStateToProps)(UploadForm);
diff --git a/app/javascript/flavours/glitch/features/compose/containers/upload_progress_container.js b/app/javascript/flavours/glitch/features/compose/containers/upload_progress_container.js
deleted file mode 100644
index 0cfee96da..000000000
--- a/app/javascript/flavours/glitch/features/compose/containers/upload_progress_container.js
+++ /dev/null
@@ -1,9 +0,0 @@
-import { connect } from 'react-redux';
-import UploadProgress from '../components/upload_progress';
-
-const mapStateToProps = state => ({
-  active: state.getIn(['compose', 'is_uploading']),
-  progress: state.getIn(['compose', 'progress']),
-});
-
-export default connect(mapStateToProps)(UploadProgress);
diff --git a/app/javascript/flavours/glitch/features/compose/containers/warning_container.js b/app/javascript/flavours/glitch/features/compose/containers/warning_container.js
deleted file mode 100644
index f20c75b91..000000000
--- a/app/javascript/flavours/glitch/features/compose/containers/warning_container.js
+++ /dev/null
@@ -1,24 +0,0 @@
-import React from 'react';
-import { connect } from 'react-redux';
-import Warning from '../components/warning';
-import PropTypes from 'prop-types';
-import { FormattedMessage } from 'react-intl';
-import { me } from 'flavours/glitch/util/initial_state';
-
-const mapStateToProps = state => ({
-  needsLockWarning: state.getIn(['compose', 'privacy']) === 'private' && !state.getIn(['accounts', me, 'locked']),
-});
-
-const WarningWrapper = ({ needsLockWarning }) => {
-  if (needsLockWarning) {
-    return <Warning message={<FormattedMessage id='compose_form.lock_disclaimer' defaultMessage='Your account is not {locked}. Anyone can follow you to view your follower-only posts.' values={{ locked: <a href='/settings/profile'><FormattedMessage id='compose_form.lock_disclaimer.lock' defaultMessage='locked' /></a> }} />} />;
-  }
-
-  return null;
-};
-
-WarningWrapper.propTypes = {
-  needsLockWarning: PropTypes.bool,
-};
-
-export default connect(mapStateToProps)(WarningWrapper);
diff --git a/app/javascript/flavours/glitch/features/compose/index.js b/app/javascript/flavours/glitch/features/compose/index.js
deleted file mode 100644
index 63c9500b1..000000000
--- a/app/javascript/flavours/glitch/features/compose/index.js
+++ /dev/null
@@ -1,126 +0,0 @@
-import React from 'react';
-import ComposeFormContainer from './containers/compose_form_container';
-import NavigationContainer from './containers/navigation_container';
-import PropTypes from 'prop-types';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import { connect } from 'react-redux';
-import { mountCompose, unmountCompose } from 'flavours/glitch/actions/compose';
-import { openModal } from 'flavours/glitch/actions/modal';
-import { changeLocalSetting } from 'flavours/glitch/actions/local_settings';
-import { Link } from 'react-router-dom';
-import { injectIntl, defineMessages } from 'react-intl';
-import SearchContainer from './containers/search_container';
-import Motion from 'flavours/glitch/util/optional_motion';
-import spring from 'react-motion/lib/spring';
-import SearchResultsContainer from './containers/search_results_container';
-import { changeComposing } from 'flavours/glitch/actions/compose';
-
-const messages = defineMessages({
-  start: { id: 'getting_started.heading', defaultMessage: 'Getting started' },
-  home_timeline: { id: 'tabs_bar.home', defaultMessage: 'Home' },
-  notifications: { id: 'tabs_bar.notifications', defaultMessage: 'Notifications' },
-  public: { id: 'navigation_bar.public_timeline', defaultMessage: 'Federated timeline' },
-  community: { id: 'navigation_bar.community_timeline', defaultMessage: 'Local timeline' },
-  settings: { id: 'navigation_bar.app_settings', defaultMessage: 'App settings' },
-  logout: { id: 'navigation_bar.logout', defaultMessage: 'Logout' },
-});
-
-const mapStateToProps = state => ({
-  columns: state.getIn(['settings', 'columns']),
-  showSearch: state.getIn(['search', 'submitted']) && !state.getIn(['search', 'hidden']),
-});
-
-@connect(mapStateToProps)
-@injectIntl
-export default class Compose extends React.PureComponent {
-
-  static propTypes = {
-    dispatch: PropTypes.func.isRequired,
-    columns: ImmutablePropTypes.list.isRequired,
-    multiColumn: PropTypes.bool,
-    showSearch: PropTypes.bool,
-    intl: PropTypes.object.isRequired,
-  };
-
-  componentDidMount () {
-    this.props.dispatch(mountCompose());
-  }
-
-  componentWillUnmount () {
-    this.props.dispatch(unmountCompose());
-  }
-
-  onLayoutClick = (e) => {
-    const layout = e.currentTarget.getAttribute('data-mastodon-layout');
-    this.props.dispatch(changeLocalSetting(['layout'], layout));
-    e.preventDefault();
-  }
-
-  openSettings = () => {
-    this.props.dispatch(openModal('SETTINGS', {}));
-  }
-
-  onFocus = () => {
-    this.props.dispatch(changeComposing(true));
-  }
-
-  onBlur = () => {
-    this.props.dispatch(changeComposing(false));
-  }
-
-  render () {
-    const { multiColumn, showSearch, intl } = this.props;
-
-    let header = '';
-
-    if (multiColumn) {
-      const { columns } = this.props;
-      header = (
-        <nav className='drawer__header'>
-          <Link to='/getting-started' className='drawer__tab' title={intl.formatMessage(messages.start)} aria-label={intl.formatMessage(messages.start)}><i role='img' className='fa fa-fw fa-asterisk' /></Link>
-          {!columns.some(column => column.get('id') === 'HOME') && (
-            <Link to='/timelines/home' className='drawer__tab' title={intl.formatMessage(messages.home_timeline)} aria-label={intl.formatMessage(messages.home_timeline)}><i role='img' className='fa fa-fw fa-home' /></Link>
-          )}
-          {!columns.some(column => column.get('id') === 'NOTIFICATIONS') && (
-            <Link to='/notifications' className='drawer__tab' title={intl.formatMessage(messages.notifications)} aria-label={intl.formatMessage(messages.notifications)}><i role='img' className='fa fa-fw fa-bell' /></Link>
-          )}
-          {!columns.some(column => column.get('id') === 'COMMUNITY') && (
-            <Link to='/timelines/public/local' className='drawer__tab' title={intl.formatMessage(messages.community)} aria-label={intl.formatMessage(messages.community)}><i role='img' className='fa fa-fw fa-users' /></Link>
-          )}
-          {!columns.some(column => column.get('id') === 'PUBLIC') && (
-            <Link to='/timelines/public' className='drawer__tab' title={intl.formatMessage(messages.public)} aria-label={intl.formatMessage(messages.public)}><i role='img' className='fa fa-fw fa-globe' /></Link>
-          )}
-          <a onClick={this.openSettings} role='button' tabIndex='0' className='drawer__tab' title={intl.formatMessage(messages.settings)} aria-label={intl.formatMessage(messages.settings)}><i role='img' className='fa fa-fw fa-cogs' /></a>
-          <a href='/auth/sign_out' className='drawer__tab' data-method='delete' title={intl.formatMessage(messages.logout)} aria-label={intl.formatMessage(messages.logout)}><i role='img' className='fa fa-fw fa-sign-out' /></a>
-        </nav>
-      );
-    }
-
-
-
-    return (
-      <div className='drawer'>
-        {header}
-
-        <SearchContainer />
-
-        <div className='drawer__pager'>
-          <div className='drawer__inner scrollable optionally-scrollable' onFocus={this.onFocus}>
-            <NavigationContainer onClose={this.onBlur} />
-            <ComposeFormContainer />
-          </div>
-
-          <Motion defaultStyle={{ x: -100 }} style={{ x: spring(showSearch ? 0 : -100, { stiffness: 210, damping: 20 }) }}>
-            {({ x }) =>
-              <div className='drawer__inner darker scrollable optionally-scrollable' style={{ transform: `translateX(${x}%)`, visibility: x === -100 ? 'hidden' : 'visible' }}>
-                <SearchResultsContainer />
-              </div>
-            }
-          </Motion>
-        </div>
-
-      </div>
-    );
-  }
-
-}
diff --git a/app/javascript/flavours/glitch/features/composer/index.js b/app/javascript/flavours/glitch/features/composer/index.js
new file mode 100644
index 000000000..d64bee7ee
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/composer/index.js
@@ -0,0 +1,423 @@
+//  Package imports.
+import PropTypes from 'prop-types';
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+
+//  Actions.
+import {
+  cancelReplyCompose,
+  changeCompose,
+  changeComposeSensitivity,
+  changeComposeSpoilerText,
+  changeComposeSpoilerness,
+  changeComposeVisibility,
+  changeUploadCompose,
+  clearComposeSuggestions,
+  fetchComposeSuggestions,
+  insertEmojiCompose,
+  selectComposeSuggestion,
+  submitCompose,
+  toggleComposeAdvancedOption,
+  undoUploadCompose,
+  uploadCompose,
+} from 'flavours/glitch/actions/compose';
+import {
+  closeModal,
+  openModal,
+} from 'flavours/glitch/actions/modal';
+
+//  Components.
+import ComposerOptions from './options';
+import ComposerPublisher from './publisher';
+import ComposerReply from './reply';
+import ComposerSpoiler from './spoiler';
+import ComposerTextarea from './textarea';
+import ComposerUploadForm from './upload_form';
+import ComposerWarning from './warning';
+
+//  Utils.
+import { countableText } from 'flavours/glitch/util/counter';
+import { me } from 'flavours/glitch/util/initial_state';
+import { isMobile } from 'flavours/glitch/util/is_mobile';
+import { assignHandlers } from 'flavours/glitch/util/react_helpers';
+import { wrap } from 'flavours/glitch/util/redux_helpers';
+
+//  State mapping.
+function mapStateToProps (state) {
+  const inReplyTo = state.getIn(['compose', 'in_reply_to']);
+  return {
+    acceptContentTypes: state.getIn(['media_attachments', 'accept_content_types']).toArray().join(','),
+    amUnlocked: !state.getIn(['accounts', me, 'locked']),
+    doNotFederate: state.getIn(['compose', 'advanced_options', 'do_not_federate']),
+    focusDate: state.getIn(['compose', 'focusDate']),
+    isSubmitting: state.getIn(['compose', 'is_submitting']),
+    isUploading: state.getIn(['compose', 'is_uploading']),
+    layout: state.getIn(['local_settings', 'layout']),
+    media: state.getIn(['compose', 'media_attachments']),
+    preselectDate: state.getIn(['compose', 'preselectDate']),
+    privacy: state.getIn(['compose', 'privacy']),
+    progress: state.getIn(['compose', 'progress']),
+    replyAccount: inReplyTo ? state.getIn(['accounts', state.getIn(['statuses', inReplyTo, 'account'])]) : null,
+    replyContent: inReplyTo ? state.getIn(['statuses', inReplyTo, 'contentHtml']) : null,
+    resetFileKey: state.getIn(['compose', 'resetFileKey']),
+    sideArm: state.getIn(['local_settings', 'side_arm']),
+    sensitive: state.getIn(['compose', 'sensitive']),
+    showSearch: state.getIn(['search', 'submitted']) && !state.getIn(['search', 'hidden']),
+    spoiler: state.getIn(['compose', 'spoiler']),
+    spoilerText: state.getIn(['compose', 'spoiler_text']),
+    suggestionToken: state.getIn(['compose', 'suggestion_token']),
+    suggestions: state.getIn(['compose', 'suggestions']),
+    text: state.getIn(['compose', 'text']),
+  };
+};
+
+//  Dispatch mapping.
+const mapDispatchToProps = {
+  onCancelReply: cancelReplyCompose,
+  onChangeDescription: changeUploadCompose,
+  onChangeSensitivity: changeComposeSensitivity,
+  onChangeSpoilerText: changeComposeSpoilerText,
+  onChangeSpoilerness: changeComposeSpoilerness,
+  onChangeText: changeCompose,
+  onChangeVisibility: changeComposeVisibility,
+  onClearSuggestions: clearComposeSuggestions,
+  onCloseModal: closeModal,
+  onFetchSuggestions: fetchComposeSuggestions,
+  onInsertEmoji: insertEmojiCompose,
+  onOpenActionsModal: openModal.bind(null, 'ACTIONS'),
+  onOpenDoodleModal: openModal.bind(null, 'DOODLE', { noEsc: true }),
+  onSelectSuggestion: selectComposeSuggestion,
+  onSubmit: submitCompose,
+  onToggleAdvancedOption: toggleComposeAdvancedOption,
+  onUndoUpload: undoUploadCompose,
+  onUpload: uploadCompose,
+};
+
+//  Handlers.
+const handlers = {
+
+  //  Changes the text value of the spoiler.
+  handleChangeSpoiler ({ target: { value } }) {
+    const { onChangeSpoilerText } = this.props;
+    if (onChangeSpoilerText) {
+      onChangeSpoilerText(value);
+    }
+  },
+
+  //  Inserts an emoji at the caret.
+  handleEmoji (data) {
+    const { textarea: { selectionStart } } = this;
+    const { onInsertEmoji } = this.props;
+    this.caretPos = selectionStart + data.native.length + 1;
+    if (onInsertEmoji) {
+      onInsertEmoji(selectionStart, data);
+    }
+  },
+
+  //  Handles the secondary submit button.
+  handleSecondarySubmit () {
+    const { handleSubmit } = this.handlers;
+    const {
+      onChangeVisibility,
+      sideArm,
+    } = this.props;
+    if (sideArm !== 'none' && onChangeVisibility) {
+      onChangeVisibility(sideArm);
+    }
+    handleSubmit();
+  },
+
+  //  Selects a suggestion from the autofill.
+  handleSelect (tokenStart, token, value) {
+    const { onSelectSuggestion } = this.props;
+    this.caretPos = null;
+    if (onSelectSuggestion) {
+      onSelectSuggestion(tokenStart, token, value);
+    }
+  },
+
+  //  Submits the status.
+  handleSubmit () {
+    const { textarea: { value } } = this;
+    const {
+      onChangeText,
+      onSubmit,
+      text,
+    } = this.props;
+
+    //  If something changes inside the textarea, then we update the
+    //  state before submitting.
+    if (onChangeText && text !== value) {
+      onChangeText(value);
+    }
+
+    //  Submits the status.
+    if (onSubmit) {
+      onSubmit();
+    }
+  },
+
+  //  Sets a reference to the textarea.
+  handleRefTextarea (textareaComponent) {
+    if (textareaComponent) {
+      this.textarea = textareaComponent.textarea;
+    }
+  },
+};
+
+//  The component.
+class Composer extends React.Component {
+
+  //  Constructor.
+  constructor (props) {
+    super(props);
+    assignHandlers(this, handlers);
+
+    //  Instance variables.
+    this.caretPos = null;
+    this.textarea = null;
+  }
+
+  //  If this is the update where we've finished uploading,
+  //  save the last caret position so we can restore it below!
+  componentWillReceiveProps (nextProps) {
+    const { textarea } = this;
+    const { isUploading } = this.props;
+    if (textarea && isUploading && !nextProps.isUploading) {
+      this.caretPos = textarea.selectionStart;
+    }
+  }
+
+  //  This statement does several things:
+  //  - If we're beginning a reply, and,
+  //      - Replying to zero or one users, places the cursor at the end
+  //        of the textbox.
+  //      - Replying to more than one user, selects any usernames past
+  //        the first; this provides a convenient shortcut to drop
+  //        everyone else from the conversation.
+  // - If we've just finished uploading an image, and have a saved
+  //   caret position, restores the cursor to that position after the
+  //   text changes.
+  componentDidUpdate (prevProps) {
+    const {
+      caretPos,
+      textarea,
+    } = this;
+    const {
+      focusDate,
+      isUploading,
+      isSubmitting,
+      preselectDate,
+      text,
+    } = this.props;
+    let selectionEnd, selectionStart;
+
+    //  Caret/selection handling.
+    if (focusDate !== prevProps.focusDate || (prevProps.isUploading && !isUploading && !isNaN(caretPos) && caretPos !== null)) {
+      switch (true) {
+      case preselectDate !== prevProps.preselectDate:
+        selectionStart = text.search(/\s/) + 1;
+        selectionEnd = text.length;
+        break;
+      case !isNaN(caretPos) && caretPos !== null:
+        selectionStart = selectionEnd = caretPos;
+        break;
+      default:
+        selectionStart = selectionEnd = text.length;
+      }
+      if (textarea) {
+        textarea.setSelectionRange(selectionStart, selectionEnd);
+        textarea.focus();
+      }
+
+    //  Refocuses the textarea after submitting.
+    } else if (textarea && prevProps.isSubmitting && !isSubmitting) {
+      textarea.focus();
+    }
+  }
+
+  render () {
+    const {
+      handleChangeSpoiler,
+      handleEmoji,
+      handleSecondarySubmit,
+      handleSelect,
+      handleSubmit,
+      handleRefTextarea,
+    } = this.handlers;
+    const { history } = this.context;
+    const {
+      acceptContentTypes,
+      amUnlocked,
+      doNotFederate,
+      intl,
+      isSubmitting,
+      isUploading,
+      layout,
+      media,
+      onCancelReply,
+      onChangeDescription,
+      onChangeSensitivity,
+      onChangeSpoilerness,
+      onChangeText,
+      onChangeVisibility,
+      onClearSuggestions,
+      onCloseModal,
+      onFetchSuggestions,
+      onOpenActionsModal,
+      onOpenDoodleModal,
+      onToggleAdvancedOption,
+      onUndoUpload,
+      onUpload,
+      privacy,
+      progress,
+      replyAccount,
+      replyContent,
+      resetFileKey,
+      sensitive,
+      showSearch,
+      sideArm,
+      spoiler,
+      spoilerText,
+      suggestions,
+      text,
+    } = this.props;
+
+    return (
+      <div className='composer'>
+        <ComposerSpoiler
+          hidden={!spoiler}
+          intl={intl}
+          onChange={handleChangeSpoiler}
+          onSubmit={handleSubmit}
+          text={spoilerText}
+        />
+        {privacy === 'private' && amUnlocked ? <ComposerWarning /> : null}
+        {replyContent ? (
+          <ComposerReply
+            account={replyAccount}
+            content={replyContent}
+            history={history}
+            intl={intl}
+            onCancel={onCancelReply}
+          />
+        ) : null}
+        <ComposerTextarea
+          autoFocus={!showSearch && !isMobile(window.innerWidth, layout)}
+          disabled={isSubmitting}
+          intl={intl}
+          onChange={onChangeText}
+          onPaste={onUpload}
+          onPickEmoji={handleEmoji}
+          onSubmit={handleSubmit}
+          onSuggestionsClearRequested={onClearSuggestions}
+          onSuggestionsFetchRequested={onFetchSuggestions}
+          onSuggestionSelected={handleSelect}
+          ref={handleRefTextarea}
+          suggestions={suggestions}
+          value={text}
+        />
+        {isUploading || media && media.size ? (
+          <ComposerUploadForm
+            intl={intl}
+            media={media}
+            onChangeDescription={onChangeDescription}
+            onRemove={onUndoUpload}
+            progress={progress}
+            uploading={isUploading}
+          />
+        ) : null}
+        <ComposerOptions
+          acceptContentTypes={acceptContentTypes}
+          disabled={isSubmitting}
+          doNotFederate={doNotFederate}
+          full={media.size >= 4 || media.some(
+            item => item.get('type') === 'video'
+          )}
+          hasMedia={!!media.size}
+          intl={intl}
+          onChangeSensitivity={onChangeSensitivity}
+          onChangeVisibility={onChangeVisibility}
+          onDoodleOpen={onOpenDoodleModal}
+          onModalClose={onCloseModal}
+          onModalOpen={onOpenActionsModal}
+          onToggleAdvancedOption={onToggleAdvancedOption}
+          onToggleSpoiler={onChangeSpoilerness}
+          onUpload={onUpload}
+          privacy={privacy}
+          resetFileKey={resetFileKey}
+          sensitive={sensitive}
+          spoiler={spoiler}
+        />
+        <ComposerPublisher
+          countText={`${spoilerText}${countableText(text)}${doNotFederate ? ' 👁️' : ''}`}
+          disabled={isSubmitting || isUploading || !!text.length && !text.trim().length}
+          intl={intl}
+          onSecondarySubmit={handleSecondarySubmit}
+          onSubmit={handleSubmit}
+          privacy={privacy}
+          sideArm={sideArm}
+        />
+      </div>
+    );
+  }
+
+}
+
+//  Context
+Composer.contextTypes = {
+  history: PropTypes.object,
+};
+
+//  Props.
+Composer.propTypes = {
+  intl: PropTypes.object.isRequired,
+
+  //  State props.
+  acceptContentTypes: PropTypes.string,
+  amUnlocked: PropTypes.bool,
+  doNotFederate: PropTypes.bool,
+  focusDate: PropTypes.instanceOf(Date),
+  isSubmitting: PropTypes.bool,
+  isUploading: PropTypes.bool,
+  layout: PropTypes.string,
+  media: ImmutablePropTypes.list,
+  preselectDate: PropTypes.instanceOf(Date),
+  privacy: PropTypes.string,
+  progress: PropTypes.number,
+  replyAccount: ImmutablePropTypes.map,
+  replyContent: PropTypes.string,
+  resetFileKey: PropTypes.number,
+  sideArm: PropTypes.string,
+  sensitive: PropTypes.bool,
+  showSearch: PropTypes.bool,
+  spoiler: PropTypes.bool,
+  spoilerText: PropTypes.string,
+  suggestionToken: PropTypes.string,
+  suggestions: ImmutablePropTypes.list,
+  text: PropTypes.string,
+
+  //  Dispatch props.
+  onCancelReply: PropTypes.func,
+  onChangeDescription: PropTypes.func,
+  onChangeSensitivity: PropTypes.func,
+  onChangeSpoilerText: PropTypes.func,
+  onChangeSpoilerness: PropTypes.func,
+  onChangeText: PropTypes.func,
+  onChangeVisibility: PropTypes.func,
+  onClearSuggestions: PropTypes.func,
+  onCloseModal: PropTypes.func,
+  onFetchSuggestions: PropTypes.func,
+  onInsertEmoji: PropTypes.func,
+  onOpenActionsModal: PropTypes.func,
+  onOpenDoodleModal: PropTypes.func,
+  onSelectSuggestion: PropTypes.func,
+  onSubmit: PropTypes.func,
+  onToggleAdvancedOption: PropTypes.func,
+  onUndoUpload: PropTypes.func,
+  onUpload: PropTypes.func,
+};
+
+//  Connecting and export.
+export { Composer as WrappedComponent };
+export default wrap(Composer, mapStateToProps, mapDispatchToProps, true);
diff --git a/app/javascript/flavours/glitch/features/composer/options/dropdown/content/index.js b/app/javascript/flavours/glitch/features/composer/options/dropdown/content/index.js
new file mode 100644
index 000000000..28bdfc0db
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/composer/options/dropdown/content/index.js
@@ -0,0 +1,138 @@
+//  Package imports.
+import PropTypes from 'prop-types';
+import React from 'react';
+import spring from 'react-motion/lib/spring';
+
+//  Components.
+import ComposerOptionsDropdownContentItem from './item';
+
+//  Utils.
+import { withPassive } from 'flavours/glitch/util/dom_helpers';
+import Motion from 'flavours/glitch/util/optional_motion';
+import { assignHandlers } from 'flavours/glitch/util/react_helpers';
+
+//  Handlers.
+const handlers = {
+
+  //  When the document is clicked elsewhere, we close the dropdown.
+  handleDocumentClick ({ target }) {
+    const { node } = this;
+    const { onClose } = this.props;
+    if (onClose && node && !node.contains(target)) {
+      onClose();
+    }
+  },
+
+  //  Stores our node in `this.node`.
+  handleRef (node) {
+    this.node = node;
+  },
+};
+
+//  The spring to use with our motion.
+const springMotion = spring(1, {
+  damping: 35,
+  stiffness: 400,
+});
+
+//  The component.
+export default class ComposerOptionsDropdownContent extends React.PureComponent {
+
+  //  Constructor.
+  constructor (props) {
+    super(props);
+    assignHandlers(this, handlers);
+
+    //  Instance variables.
+    this.node = null;
+  }
+
+  //  On mounting, we add our listeners.
+  componentDidMount () {
+    const { handleDocumentClick } = this.handlers;
+    document.addEventListener('click', handleDocumentClick, false);
+    document.addEventListener('touchend', handleDocumentClick, withPassive);
+  }
+
+  //  On unmounting, we remove our listeners.
+  componentWillUnmount () {
+    const { handleDocumentClick } = this.handlers;
+    document.removeEventListener('click', handleDocumentClick, false);
+    document.removeEventListener('touchend', handleDocumentClick, withPassive);
+  }
+
+  //  Rendering.
+  render () {
+    const { handleRef } = this.handlers;
+    const {
+      items,
+      onChange,
+      onClose,
+      style,
+      value,
+    } = this.props;
+
+    //  The result.
+    return (
+      <Motion
+        defaultStyle={{
+          opacity: 0,
+          scaleX: 0.85,
+          scaleY: 0.75,
+        }}
+        style={{
+          opacity: springMotion,
+          scaleX: springMotion,
+          scaleY: springMotion,
+        }}
+      >
+        {({ opacity, scaleX, scaleY }) => (
+          <div
+            className='composer--options--dropdown--content'
+            ref={handleRef}
+            style={{
+              ...style,
+              opacity: opacity,
+              transform: `scale(${scaleX}, ${scaleY})`,
+            }}
+          >
+            {items.map(
+              ({
+                name,
+                ...rest
+              }) => (
+                <ComposerOptionsDropdownContentItem
+                  active={name === value}
+                  key={name}
+                  name={name}
+                  onChange={onChange}
+                  onClose={onClose}
+                  options={rest}
+                />
+              )
+            )}
+          </div>
+        )}
+      </Motion>
+    );
+  }
+
+}
+
+//  Props.
+ComposerOptionsDropdownContent.propTypes = {
+  items: PropTypes.arrayOf(PropTypes.shape({
+    icon: PropTypes.string,
+    meta: PropTypes.node,
+    name: PropTypes.string.isRequired,
+    on: PropTypes.bool,
+    text: PropTypes.node,
+  })).isRequired,
+  onChange: PropTypes.func,
+  onClose: PropTypes.func,
+  style: PropTypes.object,
+  value: PropTypes.string,
+};
+
+//  Default props.
+ComposerOptionsDropdownContent.defaultProps = { style: {} };
diff --git a/app/javascript/flavours/glitch/features/composer/options/dropdown/content/item/index.js b/app/javascript/flavours/glitch/features/composer/options/dropdown/content/item/index.js
new file mode 100644
index 000000000..605c945bd
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/composer/options/dropdown/content/item/index.js
@@ -0,0 +1,126 @@
+//  Package imports.
+import classNames from 'classnames';
+import PropTypes from 'prop-types';
+import React from 'react';
+import Toggle from 'react-toggle';
+
+//  Components.
+import Icon from 'flavours/glitch/components/icon';
+
+//  Utils.
+import { assignHandlers } from 'flavours/glitch/util/react_helpers';
+
+//  Handlers.
+const handlers = {
+
+  //  This function activates the dropdown item.
+  handleActivate (e) {
+    const {
+      name,
+      onChange,
+      onClose,
+      options: { on },
+    } = this.props;
+
+    //  If the escape key was pressed, we close the dropdown.
+    if (e.key === 'Escape' && onClose) {
+      onClose();
+
+    //  Otherwise, we both close the dropdown and change the value.
+    } else if (onChange && (!e.key || e.key === 'Enter')) {
+      e.preventDefault();  //  Prevents change in focus on click
+      if ((on === null || typeof on === 'undefined') && onClose) {
+        onClose();
+      }
+      onChange(name);
+    }
+  },
+};
+
+//  The component.
+export default class ComposerOptionsDropdownContentItem extends React.PureComponent {
+
+  //  Constructor.
+  constructor (props) {
+    super(props);
+    assignHandlers(this, handlers);
+  }
+
+  //  Rendering.
+  render () {
+    const { handleActivate } = this.handlers;
+    const {
+      active,
+      options: {
+        icon,
+        meta,
+        on,
+        text,
+      },
+    } = this.props;
+    const computedClass = classNames('composer--options--dropdown--content--item', {
+      active,
+      lengthy: meta,
+      'toggled-off': !on && on !== null && typeof on !== 'undefined',
+      'toggled-on': on,
+      'with-icon': icon,
+    });
+
+    //  The result.
+    return (
+      <div
+        className={computedClass}
+        onClick={handleActivate}
+        onKeyDown={handleActivate}
+        role='button'
+        tabIndex='0'
+      >
+        {function () {
+
+          //  We render a `<Toggle>` if we were provided an `on`
+          //  property, and otherwise show an `<Icon>` if available.
+          switch (true) {
+          case on !== null && typeof on !== 'undefined':
+            return (
+              <Toggle
+                checked={on}
+                onChange={handleActivate}
+              />
+            );
+          case !!icon:
+            return (
+              <Icon
+                className='icon'
+                fullwidth
+                icon={icon}
+              />
+            );
+          default:
+            return null;
+          }
+        }()}
+        {meta ? (
+          <div className='content'>
+            <strong>{text}</strong>
+            {meta}
+          </div>
+        ) : <div className='content'>{text}</div>}
+      </div>
+    );
+  }
+
+};
+
+//  Props.
+ComposerOptionsDropdownContentItem.propTypes = {
+  active: PropTypes.bool,
+  name: PropTypes.string,
+  onChange: PropTypes.func,
+  onClose: PropTypes.func,
+  options: PropTypes.shape({
+    icon: PropTypes.string,
+    meta: PropTypes.node,
+    on: PropTypes.bool,
+    text: PropTypes.node,
+  }),
+};
diff --git a/app/javascript/flavours/glitch/features/composer/options/dropdown/index.js b/app/javascript/flavours/glitch/features/composer/options/dropdown/index.js
new file mode 100644
index 000000000..d63d90a9f
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/composer/options/dropdown/index.js
@@ -0,0 +1,225 @@
+//  Package imports.
+import classNames from 'classnames';
+import PropTypes from 'prop-types';
+import React from 'react';
+import Overlay from 'react-overlays/lib/Overlay';
+
+//  Components.
+import IconButton from 'flavours/glitch/components/icon_button';
+import ComposerOptionsDropdownContent from './content';
+
+//  Utils.
+import { isUserTouching } from 'flavours/glitch/util/is_mobile';
+import { assignHandlers } from 'flavours/glitch/util/react_helpers';
+
+//  Handlers.
+const handlers = {
+
+  //  Closes the dropdown.
+  handleClose () {
+    this.setState({ open: false });
+  },
+
+  //  The enter key toggles the dropdown's open state, and the escape
+  //  key closes it.
+  handleKeyDown ({ key }) {
+    const {
+      handleClose,
+      handleToggle,
+    } = this.handlers;
+    switch (key) {
+    case 'Enter':
+      handleToggle();
+      break;
+    case 'Escape':
+      handleClose();
+      break;
+    }
+  },
+
+  //  Creates an action modal object.
+  handleMakeModal () {
+    const component = this;
+    const {
+      items,
+      onChange,
+      onModalOpen,
+      onModalClose,
+      value,
+    } = this.props;
+
+    //  Required props.
+    if (!(onChange && onModalOpen && onModalClose && items)) {
+      return null;
+    }
+
+    //  The object.
+    return {
+      actions: items.map(
+        ({
+          name,
+          ...rest
+        }) => ({
+          ...rest,
+          active: value && name === value,
+          name,
+          onClick (e) {
+            e.preventDefault();  //  Prevents focus from changing
+            onModalClose();
+            onChange(name);
+          },
+          onPassiveClick (e) {
+            e.preventDefault();  //  Prevents focus from changing
+            onChange(name);
+            component.setState({ needsModalUpdate: true });
+          },
+        })
+      ),
+    };
+  },
+
+  //  Toggles opening and closing the dropdown.
+  handleToggle () {
+    const { handleMakeModal } = this.handlers;
+    const { onModalOpen } = this.props;
+    const { open } = this.state;
+
+    //  If this is a touch device, we open a modal instead of the
+    //  dropdown.
+    if (isUserTouching()) {
+
+      //  This gets the modal to open.
+      const modal = handleMakeModal();
+
+      //  If we can, we then open the modal.
+      if (modal && onModalOpen) {
+        onModalOpen(modal);
+        return;
+      }
+    }
+
+    //  Otherwise, we just set our state to open.
+    this.setState({ open: !open });
+  },
+
+  //  If our modal is open and our props update, we need to also update
+  //  the modal.
+  handleUpdate () {
+    const { handleMakeModal } = this.handlers;
+    const { onModalOpen } = this.props;
+    const { needsModalUpdate } = this.state;
+
+    //  Gets our modal object.
+    const modal = handleMakeModal();
+
+    //  Reopens the modal with the new object.
+    if (needsModalUpdate && modal && onModalOpen) {
+      onModalOpen(modal);
+    }
+  },
+};
+
+//  The component.
+export default class ComposerOptionsDropdown extends React.PureComponent {
+
+  //  Constructor.
+  constructor (props) {
+    super(props);
+    assignHandlers(this, handlers);
+    this.state = {
+      needsModalUpdate: false,
+      open: false,
+    };
+  }
+
+  //  Updates our modal as necessary.
+  componentDidUpdate (prevProps) {
+    const { handleUpdate } = this.handlers;
+    const { items } = this.props;
+    const { needsModalUpdate } = this.state;
+    if (needsModalUpdate && items.find(
+      (item, i) => item.on !== prevProps.items[i].on
+    )) {
+      handleUpdate();
+      this.setState({ needsModalUpdate: false });
+    }
+  }
+
+  //  Rendering.
+  render () {
+    const {
+      handleClose,
+      handleKeyDown,
+      handleToggle,
+    } = this.handlers;
+    const {
+      active,
+      disabled,
+      title,
+      icon,
+      items,
+      onChange,
+      value,
+    } = this.props;
+    const { open } = this.state;
+    const computedClass = classNames('composer--options--dropdown', {
+      active,
+      open,
+    });
+
+    //  The result.
+    return (
+      <div
+        className={computedClass}
+        onKeyDown={handleKeyDown}
+      >
+        <IconButton
+          active={open || active}
+          className='value'
+          disabled={disabled}
+          icon={icon}
+          onClick={handleToggle}
+          size={18}
+          style={{
+            height: null,
+            lineHeight: '27px',
+          }}
+          title={title}
+        />
+        <Overlay
+          containerPadding={20}
+          placement='bottom'
+          show={open}
+          target={this}
+        >
+          <ComposerOptionsDropdownContent
+            items={items}
+            onChange={onChange}
+            onClose={handleClose}
+            value={value}
+          />
+        </Overlay>
+      </div>
+    );
+  }
+
+}
+
+//  Props.
+ComposerOptionsDropdown.propTypes = {
+  active: PropTypes.bool,
+  disabled: PropTypes.bool,
+  icon: PropTypes.string,
+  items: PropTypes.arrayOf(PropTypes.shape({
+    icon: PropTypes.string,
+    meta: PropTypes.node,
+    name: PropTypes.string.isRequired,
+    on: PropTypes.bool,
+    text: PropTypes.node,
+  })).isRequired,
+  onChange: PropTypes.func,
+  onModalClose: PropTypes.func,
+  onModalOpen: PropTypes.func,
+  title: PropTypes.string,
+  value: PropTypes.string,
+};
diff --git a/app/javascript/flavours/glitch/features/composer/options/index.js b/app/javascript/flavours/glitch/features/composer/options/index.js
new file mode 100644
index 000000000..e805372ab
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/composer/options/index.js
@@ -0,0 +1,329 @@
+//  Package imports.
+import PropTypes from 'prop-types';
+import React from 'react';
+import {
+  FormattedMessage,
+  defineMessages,
+} from 'react-intl';
+import spring from 'react-motion/lib/spring';
+
+//  Components.
+import IconButton from 'flavours/glitch/components/icon_button';
+import TextIconButton from 'flavours/glitch/components/text_icon_button';
+import Dropdown from './dropdown';
+
+//  Utils.
+import Motion from 'flavours/glitch/util/optional_motion';
+import {
+  assignHandlers,
+  hiddenComponent,
+} from 'flavours/glitch/util/react_helpers';
+
+//  Messages.
+const messages = defineMessages({
+  advanced_options_icon_title: {
+    defaultMessage: 'Advanced options',
+    id: 'advanced_options.icon_title',
+  },
+  attach: {
+    defaultMessage: 'Attach...',
+    id: 'compose.attach',
+  },
+  change_privacy: {
+    defaultMessage: 'Adjust status privacy',
+    id: 'privacy.change',
+  },
+  direct_long: {
+    defaultMessage: 'Post to mentioned users only',
+    id: 'privacy.direct.long',
+  },
+  direct_short: {
+    defaultMessage: 'Direct',
+    id: 'privacy.direct.short',
+  },
+  doodle: {
+    defaultMessage: 'Draw something',
+    id: 'compose.attach.doodle',
+  },
+  local_only_long: {
+    defaultMessage: 'Do not post to other instances',
+    id: 'advanced-options.local-only.long',
+  },
+  local_only_short: {
+    defaultMessage: 'Local-only',
+    id: 'advanced-options.local-only.short',
+  },
+  private_long: {
+    defaultMessage: 'Post to followers only',
+    id: 'privacy.private.long',
+  },
+  private_short: {
+    defaultMessage: 'Followers-only',
+    id: 'privacy.private.short',
+  },
+  public_long: {
+    defaultMessage: 'Post to public timelines',
+    id: 'privacy.public.long',
+  },
+  public_short: {
+    defaultMessage: 'Public',
+    id: 'privacy.public.short',
+  },
+  sensitive: {
+    defaultMessage: 'Mark media as sensitive',
+    id: 'compose_form.sensitive',
+  },
+  spoiler: {
+    defaultMessage: 'Hide text behind warning',
+    id: 'compose_form.spoiler',
+  },
+  unlisted_long: {
+    defaultMessage: 'Do not show in public timelines',
+    id: 'privacy.unlisted.long',
+  },
+  unlisted_short: {
+    defaultMessage: 'Unlisted',
+    id: 'privacy.unlisted.short',
+  },
+  upload: {
+    defaultMessage: 'Upload a file',
+    id: 'compose.attach.upload',
+  },
+});
+
+//  Handlers.
+const handlers = {
+
+  //  Handles file selection.
+  handleChangeFiles ({ target: { files } }) {
+    const { onUpload } = this.props;
+    if (files.length && onUpload) {
+      onUpload(files);
+    }
+  },
+
+  //  Handles attachment clicks.
+  handleClickAttach (name) {
+    const { fileElement } = this;
+    const { onDoodleOpen } = this.props;
+
+    //  We switch over the name of the option.
+    switch (name) {
+    case 'upload':
+      if (fileElement) {
+        fileElement.click();
+      }
+      return;
+    case 'doodle':
+      if (onDoodleOpen) {
+        onDoodleOpen();
+      }
+      return;
+    }
+  },
+
+  //  Handles a ref to the file input.
+  handleRefFileElement (fileElement) {
+    this.fileElement = fileElement;
+  },
+};
+
+//  The component.
+export default class ComposerOptions extends React.PureComponent {
+
+  //  Constructor.
+  constructor (props) {
+    super(props);
+    assignHandlers(this, handlers);
+
+    //  Instance variables.
+    this.fileElement = null;
+  }
+
+  //  Rendering.
+  render () {
+    const {
+      handleChangeFiles,
+      handleClickAttach,
+      handleRefFileElement,
+    } = this.handlers;
+    const {
+      acceptContentTypes,
+      disabled,
+      doNotFederate,
+      full,
+      hasMedia,
+      intl,
+      onChangeSensitivity,
+      onChangeVisibility,
+      onModalClose,
+      onModalOpen,
+      onToggleAdvancedOption,
+      onToggleSpoiler,
+      privacy,
+      resetFileKey,
+      sensitive,
+      spoiler,
+    } = this.props;
+
+    //  We predefine our privacy items so that we can easily pick the
+    //  dropdown icon later.
+    const privacyItems = {
+      direct: {
+        icon: 'envelope',
+        meta: <FormattedMessage {...messages.direct_long} />,
+        name: 'direct',
+        text: <FormattedMessage {...messages.direct_short} />,
+      },
+      private: {
+        icon: 'lock',
+        meta: <FormattedMessage {...messages.private_long} />,
+        name: 'private',
+        text: <FormattedMessage {...messages.private_short} />,
+      },
+      public: {
+        icon: 'globe',
+        meta: <FormattedMessage {...messages.public_long} />,
+        name: 'public',
+        text: <FormattedMessage {...messages.public_short} />,
+      },
+      unlisted: {
+        icon: 'unlock-alt',
+        meta: <FormattedMessage {...messages.unlisted_long} />,
+        name: 'unlisted',
+        text: <FormattedMessage {...messages.unlisted_short} />,
+      },
+    };
+
+    //  The result.
+    return (
+      <div className='composer--options'>
+        <input
+          accept={acceptContentTypes}
+          disabled={disabled || full}
+          key={resetFileKey}
+          onChange={handleChangeFiles}
+          ref={handleRefFileElement}
+          type='file'
+          {...hiddenComponent}
+        />
+        <Dropdown
+          disabled={disabled || full}
+          icon='paperclip'
+          items={[
+            {
+              icon: 'cloud-upload',
+              name: 'upload',
+              text: <FormattedMessage {...messages.upload} />,
+            },
+            {
+              icon: 'paint-brush',
+              name: 'doodle',
+              text: <FormattedMessage {...messages.doodle} />,
+            },
+          ]}
+          onChange={handleClickAttach}
+          onModalClose={onModalClose}
+          onModalOpen={onModalOpen}
+          title={intl.formatMessage(messages.attach)}
+        />
+        <Motion
+          defaultStyle={{ scale: 0.87 }}
+          style={{
+            scale: spring(hasMedia ? 1 : 0.87, {
+              stiffness: 200,
+              damping: 3,
+            }),
+          }}
+        >
+          {({ scale }) => (
+            <div
+              style={{
+                display: hasMedia ? null : 'none',
+                transform: `scale(${scale})`,
+              }}
+            >
+              <IconButton
+                active={sensitive}
+                className='sensitive'
+                disabled={spoiler}
+                icon={sensitive ? 'eye-slash' : 'eye'}
+                inverted
+                onClick={onChangeSensitivity}
+                size={18}
+                style={{
+                  height: null,
+                  lineHeight: null,
+                }}
+                title={intl.formatMessage(messages.sensitive)}
+              />
+            </div>
+          )}
+        </Motion>
+        <hr />
+        <Dropdown
+          disabled={disabled}
+          icon={(privacyItems[privacy] || {}).icon}
+          items={[
+            privacyItems.public,
+            privacyItems.unlisted,
+            privacyItems.private,
+            privacyItems.direct,
+          ]}
+          onChange={onChangeVisibility}
+          onModalClose={onModalClose}
+          onModalOpen={onModalOpen}
+          title={intl.formatMessage(messages.change_privacy)}
+          value={privacy}
+        />
+        <TextIconButton
+          active={spoiler}
+          ariaControls='glitch.composer.spoiler.input'
+          label='CW'
+          onClick={onToggleSpoiler}
+          title={intl.formatMessage(messages.spoiler)}
+        />
+        <Dropdown
+          active={doNotFederate}
+          disabled={disabled}
+          icon='home'
+          items={[
+            {
+              meta: <FormattedMessage {...messages.local_only_long} />,
+              name: 'do_not_federate',
+              on: doNotFederate,
+              text: <FormattedMessage {...messages.local_only_short} />,
+            },
+          ]}
+          onChange={onToggleAdvancedOption}
+          onModalClose={onModalClose}
+          onModalOpen={onModalOpen}
+          title={intl.formatMessage(messages.advanced_options_icon_title)}
+        />
+      </div>
+    );
+  }
+
+}
+
+//  Props.
+ComposerOptions.propTypes = {
+  acceptContentTypes: PropTypes.string,
+  disabled: PropTypes.bool,
+  doNotFederate: PropTypes.bool,
+  full: PropTypes.bool,
+  hasMedia: PropTypes.bool,
+  intl: PropTypes.object.isRequired,
+  onChangeSensitivity: PropTypes.func,
+  onChangeVisibility: PropTypes.func,
+  onDoodleOpen: PropTypes.func,
+  onModalClose: PropTypes.func,
+  onModalOpen: PropTypes.func,
+  onToggleAdvancedOption: PropTypes.func,
+  onToggleSpoiler: PropTypes.func,
+  onUpload: PropTypes.func,
+  privacy: PropTypes.string,
+  resetFileKey: PropTypes.number,
+  sensitive: PropTypes.bool,
+  spoiler: PropTypes.bool,
+};
diff --git a/app/javascript/flavours/glitch/features/composer/publisher/index.js b/app/javascript/flavours/glitch/features/composer/publisher/index.js
new file mode 100644
index 000000000..f54fd68b7
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/composer/publisher/index.js
@@ -0,0 +1,121 @@
+//  Package imports.
+import classNames from 'classnames';
+import PropTypes from 'prop-types';
+import React from 'react';
+import {
+  defineMessages,
+  FormattedMessage,
+} from 'react-intl';
+import { length } from 'stringz';
+
+//  Components.
+import Button from 'flavours/glitch/components/button';
+import Icon from 'flavours/glitch/components/icon';
+
+//  Utils.
+import { maxChars } from 'flavours/glitch/util/initial_state';
+
+//  Messages.
+const messages = defineMessages({
+  publish: {
+    defaultMessage: 'Toot',
+    id: 'compose_form.publish',
+  },
+  publishLoud: {
+    defaultMessage: '{publish}!',
+    id: 'compose_form.publish_loud',
+  },
+});
+
+//  The component.
+export default function ComposerPublisher ({
+  countText,
+  disabled,
+  intl,
+  onSecondarySubmit,
+  onSubmit,
+  privacy,
+  sideArm,
+}) {
+  const diff = maxChars - length(countText || '');
+  const computedClass = classNames('composer--publisher', {
+    disabled: disabled || diff < 0,
+    over: diff < 0,
+  });
+
+  //  The result.
+  return (
+    <div className={computedClass}>
+      <span className='count'>{diff}</span>
+      {sideArm && sideArm !== 'none' ? (
+        <Button
+          className='side_arm'
+          disabled={disabled || diff < 0}
+          onClick={onSecondarySubmit}
+          style={{ padding: null }}
+          text={
+            <span>
+              <Icon
+                icon={{
+                  public: 'globe',
+                  unlisted: 'unlock-alt',
+                  private: 'lock',
+                  direct: 'envelope',
+                }[sideArm]}
+              />
+            </span>
+          }
+          title={`${intl.formatMessage(messages.publish)}: ${intl.formatMessage({ id: `privacy.${sideArm}.short` })}`}
+        />
+      ) : null}
+      <Button
+        className='primary'
+        text={function () {
+          switch (true) {
+          case !!sideArm && sideArm !== 'none':
+          case privacy === 'direct':
+          case privacy === 'private':
+            return (
+              <span>
+                <Icon
+                  icon={{
+                    direct: 'envelope',
+                    private: 'lock',
+                    public: 'globe',
+                    unlisted: 'unlock-alt',
+                  }[privacy]}
+                />
+                <FormattedMessage {...messages.publish} />
+              </span>
+            );
+          case privacy === 'public':
+            return (
+              <span>
+                <FormattedMessage
+                  {...messages.publishLoud}
+                  values={{ publish: <FormattedMessage {...messages.publish} /> }}
+                />
+              </span>
+            );
+          default:
+            return <span><FormattedMessage {...messages.publish} /></span>;
+          }
+        }()}
+        title={`${intl.formatMessage(messages.publish)}: ${intl.formatMessage({ id: `privacy.${privacy}.short` })}`}
+        onClick={onSubmit}
+        disabled={disabled || diff < 0}
+      />
+    </div>
+  );
+}
+
+//  Props.
+ComposerPublisher.propTypes = {
+  countText: PropTypes.string,
+  disabled: PropTypes.bool,
+  intl: PropTypes.object.isRequired,
+  onSecondarySubmit: PropTypes.func,
+  onSubmit: PropTypes.func,
+  privacy: PropTypes.oneOf(['direct', 'private', 'unlisted', 'public']),
+  sideArm: PropTypes.oneOf(['none', 'direct', 'private', 'unlisted', 'public']),
+};
diff --git a/app/javascript/flavours/glitch/features/composer/reply/index.js b/app/javascript/flavours/glitch/features/composer/reply/index.js
new file mode 100644
index 000000000..568705aff
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/composer/reply/index.js
@@ -0,0 +1,113 @@
+//  Package imports.
+import PropTypes from 'prop-types';
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { defineMessages } from 'react-intl';
+
+//  Components.
+import Avatar from 'flavours/glitch/components/avatar';
+import DisplayName from 'flavours/glitch/components/display_name';
+import IconButton from 'flavours/glitch/components/icon_button';
+
+//  Utils.
+import { assignHandlers } from 'flavours/glitch/util/react_helpers';
+import { isRtl } from 'flavours/glitch/util/rtl';
+
+//  Messages.
+const messages = defineMessages({
+  cancel: {
+    defaultMessage: 'Cancel',
+    id: 'reply_indicator.cancel',
+  },
+});
+
+//  Handlers.
+const handlers = {
+
+  //  Handles a click on the "close" button.
+  handleClick () {
+    const { onCancel } = this.props;
+    if (onCancel) {
+      onCancel();
+    }
+  },
+
+  //  Handles a click on the status's account.
+  handleClickAccount () {
+    const {
+      account,
+      history,
+    } = this.props;
+    if (history) {
+      history.push(`/accounts/${account.get('id')}`);
+    }
+  },
+};
+
+//  The component.
+export default class ComposerReply extends React.PureComponent {
+
+  //  Constructor.
+  constructor (props) {
+    super(props);
+    assignHandlers(this, handlers);
+  }
+
+  //  Rendering.
+  render () {
+    const {
+      handleClick,
+      handleClickAccount,
+    } = this.handlers;
+    const {
+      account,
+      content,
+      intl,
+    } = this.props;
+
+    //  The result.
+    return (
+      <article className='composer--reply'>
+        <header>
+          <IconButton
+            className='cancel'
+            icon='times'
+            onClick={handleClick}
+            title={intl.formatMessage(messages.cancel)}
+          />
+          {account ? (
+            <a
+              className='account'
+              href={account.get('url')}
+              onClick={handleClickAccount}
+            >
+              <Avatar
+                account={account}
+                className='avatar'
+                size={24}
+              />
+              <DisplayName
+                account={account}
+                className='display_name'
+              />
+            </a>
+          ) : null}
+        </header>
+        <div
+          className='content'
+          dangerouslySetInnerHTML={{ __html: content || '' }}
+          style={{ direction: isRtl(content) ? 'rtl' : 'ltr' }}
+        />
+      </article>
+    );
+  }
+
+}
+
+ComposerReply.propTypes = {
+  account: ImmutablePropTypes.map,
+  content: PropTypes.string,
+  history: PropTypes.object,
+  intl: PropTypes.object.isRequired,
+  onCancel: PropTypes.func,
+};
diff --git a/app/javascript/flavours/glitch/features/composer/spoiler/index.js b/app/javascript/flavours/glitch/features/composer/spoiler/index.js
new file mode 100644
index 000000000..a49b0e10f
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/composer/spoiler/index.js
@@ -0,0 +1,92 @@
+//  Package imports.
+import React from 'react';
+import PropTypes from 'prop-types';
+import { defineMessages, FormattedMessage } from 'react-intl';
+
+//  Components.
+import Collapsable from 'flavours/glitch/components/collapsable';
+
+//  Utils.
+import {
+  assignHandlers,
+  hiddenComponent,
+} from 'flavours/glitch/util/react_helpers';
+
+//  Messages.
+const messages = defineMessages({
+  placeholder: {
+    defaultMessage: 'Write your warning here',
+    id: 'compose_form.spoiler_placeholder',
+  },
+});
+
+//  Handlers.
+const handlers = {
+
+  //  Handles a keypress.
+  handleKeyDown ({
+    ctrlKey,
+    keyCode,
+    metaKey,
+  }) {
+    const { onSubmit } = this.props;
+
+    //  We submit the status on control/meta + enter.
+    if (onSubmit && keyCode === 13 && (ctrlKey || metaKey)) {
+      onSubmit();
+    }
+  },
+};
+
+//  The component.
+export default class ComposerSpoiler extends React.PureComponent {
+
+  //  Constructor.
+  constructor (props) {
+    super(props);
+    assignHandlers(this, handlers);
+  }
+
+  //  Rendering.
+  render () {
+    const { handleKeyDown } = this.handlers;
+    const {
+      hidden,
+      intl,
+      onChange,
+      text,
+    } = this.props;
+
+    //  The result.
+    return (
+      <Collapsable
+        isVisible={!hidden}
+        fullHeight={50}
+      >
+        <label className='composer--spoiler'>
+          <span {...hiddenComponent}>
+            <FormattedMessage {...messages.placeholder} />
+          </span>
+          <input
+            id='glitch.composer.spoiler.input'
+            onChange={onChange}
+            onKeyDown={handleKeyDown}
+            placeholder={intl.formatMessage(messages.placeholder)}
+            type='text'
+            value={text}
+          />
+        </label>
+      </Collapsable>
+    );
+  }
+
+}
+
+//  Props.
+ComposerSpoiler.propTypes = {
+  hidden: PropTypes.bool,
+  intl: PropTypes.object.isRequired,
+  onChange: PropTypes.func,
+  onSubmit: PropTypes.func,
+  text: PropTypes.string,
+};
diff --git a/app/javascript/flavours/glitch/features/composer/textarea/index.js b/app/javascript/flavours/glitch/features/composer/textarea/index.js
new file mode 100644
index 000000000..955c06098
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/composer/textarea/index.js
@@ -0,0 +1,298 @@
+//  Package imports.
+import PropTypes from 'prop-types';
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import {
+  defineMessages,
+  FormattedMessage,
+} from 'react-intl';
+import Textarea from 'react-textarea-autosize';
+
+//  Components.
+import EmojiPicker from 'flavours/glitch/features/emoji_picker';
+import ComposerTextareaSuggestions from './suggestions';
+
+//  Utils.
+import { isRtl } from 'flavours/glitch/util/rtl';
+import {
+  assignHandlers,
+  hiddenComponent,
+} from 'flavours/glitch/util/react_helpers';
+
+//  Messages.
+const messages = defineMessages({
+  placeholder: {
+    defaultMessage: 'What is on your mind?',
+    id: 'compose_form.placeholder',
+  },
+});
+
+//  Handlers.
+const handlers = {
+
+  //  When blurring the textarea, suggestions are hidden.
+  handleBlur () {
+    //this.setState({ suggestionsHidden: true });
+  },
+
+  //  When the contents of the textarea change, we have to pull up new
+  //  autosuggest suggestions if applicable, and also change the value
+  //  of the textarea in our store.
+  handleChange ({
+    target: {
+      selectionStart,
+      value,
+    },
+  }) {
+    const {
+      onChange,
+      onSuggestionsFetchRequested,
+      onSuggestionsClearRequested,
+    } = this.props;
+    const { lastToken } = this.state;
+
+    //  This gets the token at the caret location, if it begins with an
+    //  `@` (mentions) or `:` (shortcodes).
+    const left = value.slice(0, selectionStart).search(/[^\s\u200B]+$/);
+    const right = value.slice(selectionStart).search(/[\s\u200B]/);
+    const token = function () {
+      switch (true) {
+      case left < 0 || /[@:]/.test(!value[left]):
+        return null;
+      case right < 0:
+        return value.slice(left);
+      default:
+        return value.slice(left, right + selectionStart).trim().toLowerCase();
+      }
+    }();
+
+    //  We only request suggestions for tokens which are at least 3
+    //  characters long.
+    if (onSuggestionsFetchRequested && token && token.length >= 3) {
+      if (lastToken !== token) {
+        this.setState({
+          lastToken: token,
+          selectedSuggestion: 0,
+          tokenStart: left,
+        });
+        onSuggestionsFetchRequested(token);
+      }
+    } else {
+      this.setState({ lastToken: null });
+      if (onSuggestionsClearRequested) {
+        onSuggestionsClearRequested();
+      }
+    }
+
+    //  Updates the value of the textarea.
+    if (onChange) {
+      onChange(value);
+    }
+  },
+
+  //  Handles a click on an autosuggestion.
+  handleClickSuggestion (index) {
+    const { textarea } = this;
+    const {
+      onSuggestionSelected,
+      suggestions,
+    } = this.props;
+    const {
+      lastToken,
+      tokenStart,
+    } = this.state;
+    onSuggestionSelected(tokenStart, lastToken, suggestions.get(index));
+    textarea.focus();
+  },
+
+  //  Handles a keypress.  If the autosuggestions are visible, we need
+  //  to allow keypresses to navigate and sleect them.
+  handleKeyDown (e) {
+    const {
+      disabled,
+      onSubmit,
+      onSuggestionSelected,
+      suggestions,
+    } = this.props;
+    const {
+      lastToken,
+      suggestionsHidden,
+      selectedSuggestion,
+      tokenStart,
+    } = this.state;
+
+    //  Keypresses do nothing if the composer is disabled.
+    if (disabled) {
+      e.preventDefault();
+      return;
+    }
+
+    //  Switches over the pressed key.
+    switch(e.key) {
+
+    //  On arrow down, we pick the next suggestion.
+    case 'ArrowDown':
+      if (suggestions && suggestions.size > 0 && !suggestionsHidden) {
+        e.preventDefault();
+        this.setState({ selectedSuggestion: Math.min(selectedSuggestion + 1, suggestions.size - 1) });
+      }
+      return;
+
+    //  On arrow up, we pick the previous suggestion.
+    case 'ArrowUp':
+      if (suggestions && suggestions.size > 0 && !suggestionsHidden) {
+        e.preventDefault();
+        this.setState({ selectedSuggestion: Math.max(selectedSuggestion - 1, 0) });
+      }
+      return;
+
+    //  On enter or tab, we select the suggestion.
+    case 'Enter':
+    case 'Tab':
+      if (onSuggestionSelected && lastToken !== null && suggestions && suggestions.size > 0 && !suggestionsHidden) {
+        e.preventDefault();
+        e.stopPropagation();
+        onSuggestionSelected(tokenStart, lastToken, suggestions.get(selectedSuggestion));
+      }
+      return;
+    }
+
+    //  We submit the status on control/meta + enter.
+    if (onSubmit && e.keyCode === 13 && (e.ctrlKey || e.metaKey)) {
+      onSubmit();
+    }
+  },
+
+  //  When the escape key is released, we either close the suggestions
+  //  window or focus the UI.
+  handleKeyUp ({ key }) {
+    const { suggestionsHidden } = this.state;
+    if (key === 'Escape') {
+      if (!suggestionsHidden) {
+        this.setState({ suggestionsHidden: true });
+      } else {
+        document.querySelector('.ui').parentElement.focus();
+      }
+    }
+  },
+
+  //  Handles the pasting of images into the composer.
+  handlePaste (e) {
+    const { onPaste } = this.props;
+    let d;
+    if (onPaste && (d = e.clipboardData) && (d = d.files).length === 1) {
+      onPaste(d);
+      e.preventDefault();
+    }
+  },
+
+  //  Saves a reference to the textarea.
+  handleRefTextarea (textarea) {
+    this.textarea = textarea;
+  },
+};
+
+//  The component.
+export default class ComposerTextarea extends React.Component {
+
+  //  Constructor.
+  constructor (props) {
+    super(props);
+    assignHandlers(this, handlers);
+    this.state = {
+      suggestionsHidden: false,
+      selectedSuggestion: 0,
+      lastToken: null,
+      tokenStart: 0,
+    };
+
+    //  Instance variables.
+    this.textarea = null;
+  }
+
+  //  When we receive new suggestions, we unhide the suggestions window
+  //  if we didn't have any suggestions before.
+  componentWillReceiveProps (nextProps) {
+    const { suggestions } = this.props;
+    const { suggestionsHidden } = this.state;
+    if (nextProps.suggestions && nextProps.suggestions !== suggestions && nextProps.suggestions.size > 0 && suggestionsHidden) {
+      this.setState({ suggestionsHidden: false });
+    }
+  }
+
+  //  Rendering.
+  render () {
+    const {
+      handleBlur,
+      handleChange,
+      handleClickSuggestion,
+      handleKeyDown,
+      handleKeyUp,
+      handlePaste,
+      handleRefTextarea,
+    } = this.handlers;
+    const {
+      autoFocus,
+      disabled,
+      intl,
+      onPickEmoji,
+      suggestions,
+      value,
+    } = this.props;
+    const {
+      selectedSuggestion,
+      suggestionsHidden,
+    } = this.state;
+
+    //  The result.
+    return (
+      <div className='composer--textarea'>
+        <label>
+          <span {...hiddenComponent}><FormattedMessage {...messages.placeholder} /></span>
+          <Textarea
+            aria-autocomplete='list'
+            autoFocus={autoFocus}
+            className='textarea'
+            disabled={disabled}
+            inputRef={handleRefTextarea}
+            onBlur={handleBlur}
+            onChange={handleChange}
+            onKeyDown={handleKeyDown}
+            onKeyUp={handleKeyUp}
+            onPaste={handlePaste}
+            placeholder={intl.formatMessage(messages.placeholder)}
+            value={value}
+            style={{ direction: isRtl(value) ? 'rtl' : 'ltr' }}
+          />
+        </label>
+        <EmojiPicker onPickEmoji={onPickEmoji} />
+        <ComposerTextareaSuggestions
+          hidden={suggestionsHidden}
+          onSuggestionClick={handleClickSuggestion}
+          suggestions={suggestions}
+          value={selectedSuggestion}
+        />
+      </div>
+    );
+  }
+
+}
+
+//  Props.
+ComposerTextarea.propTypes = {
+  autoFocus: PropTypes.bool,
+  disabled: PropTypes.bool,
+  intl: PropTypes.object.isRequired,
+  onChange: PropTypes.func,
+  onPaste: PropTypes.func,
+  onPickEmoji: PropTypes.func,
+  onSubmit: PropTypes.func,
+  onSuggestionsClearRequested: PropTypes.func,
+  onSuggestionsFetchRequested: PropTypes.func,
+  onSuggestionSelected: PropTypes.func,
+  suggestions: ImmutablePropTypes.list,
+  value: PropTypes.string,
+};
+
+//  Default props.
+ComposerTextarea.defaultProps = { autoFocus: true };
diff --git a/app/javascript/flavours/glitch/features/composer/textarea/suggestions/index.js b/app/javascript/flavours/glitch/features/composer/textarea/suggestions/index.js
new file mode 100644
index 000000000..dc72585f2
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/composer/textarea/suggestions/index.js
@@ -0,0 +1,43 @@
+//  Package imports.
+import PropTypes from 'prop-types';
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+
+//  Components.
+import ComposerTextareaSuggestionsItem from './item';
+
+//  The component.
+export default function ComposerTextareaSuggestions ({
+  hidden,
+  onSuggestionClick,
+  suggestions,
+  value,
+}) {
+
+  //  The result.
+  return (
+    <div
+      className='composer--textarea--suggestions'
+      hidden={hidden || !suggestions || suggestions.isEmpty()}
+    >
+      {!hidden && suggestions ? suggestions.map(
+        (suggestion, index) => (
+          <ComposerTextareaSuggestionsItem
+            index={index}
+            key={typeof suggestion === 'object' ? suggestion.id : suggestion}
+            onClick={onSuggestionClick}
+            selected={index === value}
+            suggestion={suggestion}
+          />
+        )
+      ) : null}
+    </div>
+  );
+}
+
+ComposerTextareaSuggestions.propTypes = {
+  hidden: PropTypes.bool,
+  onSuggestionClick: PropTypes.func,
+  suggestions: ImmutablePropTypes.list,
+  value: PropTypes.number,
+};
diff --git a/app/javascript/flavours/glitch/features/composer/textarea/suggestions/item/index.js b/app/javascript/flavours/glitch/features/composer/textarea/suggestions/item/index.js
new file mode 100644
index 000000000..d2c794ae9
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/composer/textarea/suggestions/item/index.js
@@ -0,0 +1,101 @@
+//  Package imports.
+import classNames from 'classnames';
+import PropTypes from 'prop-types';
+import React from 'react';
+
+//  Components.
+import AccountContainer from 'flavours/glitch/containers/account_container';
+
+//  Utils.
+import { unicodeMapping } from 'flavours/glitch/util/emoji';
+import { assignHandlers } from 'flavours/glitch/util/react_helpers';
+
+//  Gets our asset host from the environment, if available.
+const assetHost = ((process || {}).env || {}).CDN_HOST || '';
+
+//  Handlers.
+const handlers = {
+
+  //  Handles a click on a suggestion.
+  handleClick (e) {
+    const {
+      index,
+      onClick,
+    } = this.props;
+    if (onClick) {
+      e.preventDefault();
+      onClick(index);
+    }
+  },
+};
+
+//  The component.
+export default class ComposerTextareaSuggestionsItem extends React.Component {
+
+  //  Constructor.
+  constructor (props) {
+    super(props);
+    assignHandlers(this, handlers);
+  }
+
+  //  Rendering.
+  render () {
+    const { handleClick } = this.handlers;
+    const {
+      selected,
+      suggestion,
+    } = this.props;
+    const computedClass = classNames('composer--textarea--suggestions--item', { selected });
+
+    //  The result.
+    return (
+      <div
+        className={computedClass}
+        onMouseDown={handleClick}
+        role='button'
+        tabIndex='0'
+      >
+        { //  If the suggestion is an object, then we render an emoji.
+          //  Otherwise, we render an account.
+          typeof suggestion === 'object' ? function () {
+            const url = function () {
+              if (suggestion.custom) {
+                return suggestion.imageUrl;
+              } else {
+                const mapping = unicodeMapping[suggestion.native] || unicodeMapping[suggestion.native.replace(/\uFE0F$/, '')];
+                if (!mapping) {
+                  return null;
+                }
+                return `${assetHost}/emoji/${mapping.filename}.svg`;
+              }
+            }();
+            return url ? (
+              <div className='emoji'>
+                <img
+                  alt={suggestion.native || suggestion.colons}
+                  className='emojione'
+                  src={url}
+                />
+                {suggestion.colons}
+              </div>
+            ) : null;
+          }() : (
+            <AccountContainer
+              id={suggestion}
+              small
+            />
+          )
+        }
+      </div>
+    );
+  }
+
+}
+
+//  Props.
+ComposerTextareaSuggestionsItem.propTypes = {
+  index: PropTypes.number,
+  onClick: PropTypes.func,
+  selected: PropTypes.bool,
+  suggestion: PropTypes.oneOfType([PropTypes.object, PropTypes.string]),
+};
diff --git a/app/javascript/flavours/glitch/features/composer/upload_form/index.js b/app/javascript/flavours/glitch/features/composer/upload_form/index.js
new file mode 100644
index 000000000..53b14acc7
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/composer/upload_form/index.js
@@ -0,0 +1,53 @@
+//  Package imports.
+import classNames from 'classnames';
+import PropTypes from 'prop-types';
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+
+//  Components.
+import ComposerUploadFormItem from './item';
+import ComposerUploadFormProgress from './progress';
+
+//  The component.
+export default function ComposerUploadForm ({
+  intl,
+  media,
+  onChangeDescription,
+  onRemove,
+  progress,
+  uploading,
+}) {
+  const computedClass = classNames('composer--upload_form', { uploading });
+
+  //  The result.
+  return (
+    <div className={computedClass}>
+      {uploading ? <ComposerUploadFormProgress progress={progress} /> : null}
+      {media ? (
+        <div className='content'>
+          {media.map(item => (
+            <ComposerUploadFormItem
+              description={item.get('description')}
+              key={item.get('id')}
+              id={item.get('id')}
+              intl={intl}
+              preview={item.get('preview_url')}
+              onChangeDescription={onChangeDescription}
+              onRemove={onRemove}
+            />
+          ))}
+        </div>
+      ) : null}
+    </div>
+  );
+}
+
+//  Props.
+ComposerUploadForm.propTypes = {
+  intl: PropTypes.object.isRequired,
+  media: ImmutablePropTypes.list,
+  onChangeDescription: PropTypes.func,
+  onRemove: PropTypes.func,
+  progress: PropTypes.number,
+  uploading: PropTypes.bool,
+};
diff --git a/app/javascript/flavours/glitch/features/composer/upload_form/item/index.js b/app/javascript/flavours/glitch/features/composer/upload_form/item/index.js
new file mode 100644
index 000000000..ec67b8ef8
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/composer/upload_form/item/index.js
@@ -0,0 +1,177 @@
+//  Package imports.
+import classNames from 'classnames';
+import PropTypes from 'prop-types';
+import React from 'react';
+import {
+  FormattedMessage,
+  defineMessages,
+} from 'react-intl';
+import spring from 'react-motion/lib/spring';
+
+//  Components.
+import IconButton from 'flavours/glitch/components/icon_button';
+
+//  Utils.
+import Motion from 'flavours/glitch/util/optional_motion';
+import { assignHandlers } from 'flavours/glitch/util/react_helpers';
+
+//  Messages.
+const messages = defineMessages({
+  undo: {
+    defaultMessage: 'Undo',
+    id: 'upload_form.undo',
+  },
+  description: {
+    defaultMessage: 'Describe for the visually impaired',
+    id: 'upload_form.description',
+  },
+});
+
+//  Handlers.
+const handlers = {
+
+  //  On blur, we save the description for the media item.
+  handleBlur () {
+    const {
+      id,
+      onChangeDescription,
+    } = this.props;
+    const { dirtyDescription } = this.state;
+    if (id && onChangeDescription && dirtyDescription !== null) {
+      this.setState({
+        dirtyDescription: null,
+        focused: false,
+      });
+      onChangeDescription(id, dirtyDescription);
+    }
+  },
+
+  //  When the value of our description changes, we store it in the
+  //  temp value `dirtyDescription` in our state.
+  handleChange ({ target: { value } }) {
+    this.setState({ dirtyDescription: value });
+  },
+
+  //  Records focus on the media item.
+  handleFocus () {
+    this.setState({ focused: true });
+  },
+
+  //  Records the start of a hover over the media item.
+  handleMouseEnter () {
+    this.setState({ hovered: true });
+  },
+
+  //  Records the end of a hover over the media item.
+  handleMouseLeave () {
+    this.setState({ hovered: false });
+  },
+
+  //  Removes the media item.
+  handleRemove () {
+    const {
+      id,
+      onRemove,
+    } = this.props;
+    if (id && onRemove) {
+      onRemove(id);
+    }
+  },
+};
+
+//  The component.
+export default class ComposerUploadFormItem extends React.PureComponent {
+
+  //  Constructor.
+  constructor (props) {
+    super(props);
+    assignHandlers(this, handlers);
+    this.state = {
+      hovered: false,
+      focused: false,
+      dirtyDescription: null,
+    };
+  }
+
+  //  Rendering.
+  render () {
+    const {
+      handleBlur,
+      handleChange,
+      handleFocus,
+      handleMouseEnter,
+      handleMouseLeave,
+      handleRemove,
+    } = this.handlers;
+    const {
+      description,
+      intl,
+      preview,
+    } = this.props;
+    const {
+      focused,
+      hovered,
+      dirtyDescription,
+    } = this.state;
+    const computedClass = classNames('composer--upload_form--item', { active: hovered || focused });
+
+    //  The result.
+    return (
+      <div
+        className={computedClass}
+        onMouseEnter={handleMouseEnter}
+        onMouseLeave={handleMouseLeave}
+      >
+        <Motion
+          defaultStyle={{ scale: 0.8 }}
+          style={{
+            scale: spring(1, {
+              stiffness: 180,
+              damping: 12,
+            }),
+          }}
+        >
+          {({ scale }) => (
+            <div
+              style={{
+                transform: `scale(${scale})`,
+                backgroundImage: preview ? `url(${preview})` : null,
+              }}
+            >
+              <IconButton
+                className='close'
+                icon='times'
+                onClick={handleRemove}
+                size={36}
+                title={intl.formatMessage(messages.undo)}
+              />
+              <label>
+                <span style={{ display: 'none' }}><FormattedMessage {...messages.description} /></span>
+                <input
+                  maxLength={420}
+                  onBlur={handleBlur}
+                  onChange={handleChange}
+                  onFocus={handleFocus}
+                  placeholder={intl.formatMessage(messages.description)}
+                  type='text'
+                  value={dirtyDescription || description || ''}
+                />
+              </label>
+            </div>
+          )}
+        </Motion>
+      </div>
+    );
+  }
+
+}
+
+//  Props.
+ComposerUploadFormItem.propTypes = {
+  description: PropTypes.string,
+  id: PropTypes.string,
+  intl: PropTypes.object.isRequired,
+  onChangeDescription: PropTypes.func,
+  onRemove: PropTypes.func,
+  preview: PropTypes.string,
+};
diff --git a/app/javascript/flavours/glitch/features/composer/upload_form/progress/index.js b/app/javascript/flavours/glitch/features/composer/upload_form/progress/index.js
new file mode 100644
index 000000000..9dac6acf9
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/composer/upload_form/progress/index.js
@@ -0,0 +1,52 @@
+//  Package imports.
+import PropTypes from 'prop-types';
+import React from 'react';
+import {
+  defineMessages,
+  FormattedMessage,
+} from 'react-intl';
+import spring from 'react-motion/lib/spring';
+
+//  Components.
+import Icon from 'flavours/glitch/components/icon';
+
+//  Utils.
+import Motion from 'flavours/glitch/util/optional_motion';
+
+//  Messages.
+const messages = defineMessages({
+  upload: {
+    defaultMessage: 'Uploading...',
+    id: 'upload_progress.label',
+  },
+});
+
+//  The component.
+export default function ComposerUploadFormProgress ({ progress }) {
+
+  //  The result.
+  return (
+    <div className='composer--upload_form--progress'>
+      <Icon icon='upload' />
+      <div className='message'>
+        <FormattedMessage {...messages.upload} />
+        <div className='backdrop'>
+          <Motion
+            defaultStyle={{ width: 0 }}
+            style={{ width: spring(progress) }}
+          >
+            {({ width }) =>
+              <div
+                className='tracker'
+                style={{ width: `${width}%` }}
+              />
+            }
+          </Motion>
+        </div>
+      </div>
+    </div>
+  );
+}
+
+//  Props.
+ComposerUploadFormProgress.propTypes = { progress: PropTypes.number };
diff --git a/app/javascript/flavours/glitch/features/composer/warning/index.js b/app/javascript/flavours/glitch/features/composer/warning/index.js
new file mode 100644
index 000000000..c225b50e8
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/composer/warning/index.js
@@ -0,0 +1,54 @@
+import React from 'react';
+import Motion from 'flavours/glitch/util/optional_motion';
+import spring from 'react-motion/lib/spring';
+import { defineMessages, FormattedMessage } from 'react-intl';
+
+//  This is the spring used with our motion.
+const motionSpring = spring(1, { damping: 35, stiffness: 400 });
+
+//  Messages.
+const messages = defineMessages({
+  disclaimer: {
+    defaultMessage: 'Your account is not {locked}. Anyone can follow you to view your follower-only posts.',
+    id: 'compose_form.lock_disclaimer',
+  },
+  locked: {
+    defaultMessage: 'locked',
+    id: 'compose_form.lock_disclaimer.lock',
+  },
+});
+
+//  The component.
+export default function ComposerWarning () {
+  return (
+    <Motion
+      defaultStyle={{
+        opacity: 0,
+        scaleX: 0.85,
+        scaleY: 0.75,
+      }}
+      style={{
+        opacity: motionSpring,
+        scaleX: motionSpring,
+        scaleY: motionSpring,
+      }}
+    >
+      {({ opacity, scaleX, scaleY }) => (
+        <div
+          className='composer--warning'
+          style={{
+            opacity: opacity,
+            transform: `scale(${scaleX}, ${scaleY})`,
+          }}
+        >
+          <FormattedMessage
+            {...messages.disclaimer}
+            values={{ locked: <a href='/settings/profile'><FormattedMessage {...messages.locked} /></a> }}
+          />
+        </div>
+      )}
+    </Motion>
+  );
+}
+
+ComposerWarning.propTypes = {};
diff --git a/app/javascript/flavours/glitch/features/drawer/account/index.js b/app/javascript/flavours/glitch/features/drawer/account/index.js
new file mode 100644
index 000000000..168d0c2cf
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/drawer/account/index.js
@@ -0,0 +1,71 @@
+//  Package imports.
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import {
+  FormattedMessage,
+  defineMessages,
+} from 'react-intl';
+
+//  Components.
+import Avatar from 'flavours/glitch/components/avatar';
+import Permalink from 'flavours/glitch/components/permalink';
+
+//  Utils.
+import { hiddenComponent } from 'flavours/glitch/util/react_helpers';
+
+//  Messages.
+const messages = defineMessages({
+  edit: {
+    defaultMessage: 'Edit profile',
+    id: 'navigation_bar.edit_profile',
+  },
+});
+
+//  The component.
+export default function DrawerAccount ({ account }) {
+
+  //  We need an account to render.
+  if (!account) {
+    return (
+      <div className='drawer--account'>
+        <a
+          className='edit'
+          href='/settings/profile'
+        >
+          <FormattedMessage {...messages.edit} />
+        </a>
+      </div>
+    );
+  }
+
+  //  The result.
+  return (
+    <div className='drawer--account'>
+      <Permalink
+        className='avatar'
+        href={account.get('url')}
+        to={`/accounts/${account.get('id')}`}
+      >
+        <span {...hiddenComponent}>{account.get('acct')}</span>
+        <Avatar
+          account={account}
+          size={40}
+        />
+      </Permalink>
+      <Permalink
+        className='acct'
+        href={account.get('url')}
+        to={`/accounts/${account.get('id')}`}
+      >
+        <strong>@{account.get('acct')}</strong>
+      </Permalink>
+      <a
+        className='edit'
+        href='/settings/profile'
+      ><FormattedMessage {...messages.edit} /></a>
+    </div>
+  );
+}
+
+//  Props.
+DrawerAccount.propTypes = { account: ImmutablePropTypes.map };
diff --git a/app/javascript/flavours/glitch/features/drawer/header/index.js b/app/javascript/flavours/glitch/features/drawer/header/index.js
new file mode 100644
index 000000000..6949cd028
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/drawer/header/index.js
@@ -0,0 +1,118 @@
+//  Package imports.
+import PropTypes from 'prop-types';
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { defineMessages } from 'react-intl';
+import { Link } from 'react-router-dom';
+
+//  Components.
+import Icon from 'flavours/glitch/components/icon';
+
+//  Utils.
+import { conditionalRender } from 'flavours/glitch/util/react_helpers';
+
+//  Messages.
+const messages = defineMessages({
+  community: {
+    defaultMessage: 'Local timeline',
+    id: 'navigation_bar.community_timeline',
+  },
+  home_timeline: {
+    defaultMessage: 'Home',
+    id: 'tabs_bar.home',
+  },
+  logout: {
+    defaultMessage: 'Logout',
+    id: 'navigation_bar.logout',
+  },
+  notifications: {
+    defaultMessage: 'Notifications',
+    id: 'tabs_bar.notifications',
+  },
+  public: {
+    defaultMessage: 'Federated timeline',
+    id: 'navigation_bar.public_timeline',
+  },
+  settings: {
+    defaultMessage: 'App settings',
+    id: 'navigation_bar.app_settings',
+  },
+  start: {
+    defaultMessage: 'Getting started',
+    id: 'getting_started.heading',
+  },
+});
+
+//  The component.
+export default function DrawerHeader ({
+  columns,
+  intl,
+  onSettingsClick,
+}) {
+
+  //  Only renders the component if the column isn't being shown.
+  const renderForColumn = conditionalRender.bind(null,
+    columnId => !columns || !columns.some(
+      column => column.get('id') === columnId
+    )
+  );
+
+  //  The result.
+  return (
+    <nav className='drawer--header'>
+      <Link
+        aria-label={intl.formatMessage(messages.start)}
+        title={intl.formatMessage(messages.start)}
+        to='/getting-started'
+      ><Icon icon='asterisk' /></Link>
+      {renderForColumn('HOME', (
+        <Link
+          aria-label={intl.formatMessage(messages.home_timeline)}
+          title={intl.formatMessage(messages.home_timeline)}
+          to='/timelines/home'
+        ><Icon icon='home' /></Link>
+      ))}
+      {renderForColumn('NOTIFICATIONS', (
+        <Link
+          aria-label={intl.formatMessage(messages.notifications)}
+          title={intl.formatMessage(messages.notifications)}
+          to='/notifications'
+        ><Icon icon='bell' /></Link>
+      ))}
+      {renderForColumn('COMMUNITY', (
+        <Link
+          aria-label={intl.formatMessage(messages.community)}
+          title={intl.formatMessage(messages.community)}
+          to='/timelines/public/local'
+        ><Icon icon='users' /></Link>
+      ))}
+      {renderForColumn('PUBLIC', (
+        <Link
+          aria-label={intl.formatMessage(messages.public)}
+          title={intl.formatMessage(messages.public)}
+          to='/timelines/public'
+        ><Icon icon='globe' /></Link>
+      ))}
+      <a
+        aria-label={intl.formatMessage(messages.settings)}
+        onClick={onSettingsClick}
+        role='button'
+        title={intl.formatMessage(messages.settings)}
+        tabIndex='0'
+      ><Icon icon='cogs' /></a>
+      <a
+        aria-label={intl.formatMessage(messages.logout)}
+        data-method='delete'
+        href='/auth/sign_out'
+        title={intl.formatMessage(messages.logout)}
+      ><Icon icon='sign-out' /></a>
+    </nav>
+  );
+}
+
+//  Props.
+DrawerHeader.propTypes = {
+  columns: ImmutablePropTypes.list,
+  intl: PropTypes.object,
+  onSettingsClick: PropTypes.func,
+};
diff --git a/app/javascript/flavours/glitch/features/drawer/index.js b/app/javascript/flavours/glitch/features/drawer/index.js
new file mode 100644
index 000000000..9ade1f87a
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/drawer/index.js
@@ -0,0 +1,127 @@
+//  Package imports.
+import PropTypes from 'prop-types';
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+
+//  Actions.
+import { openModal } from 'flavours/glitch/actions/modal';
+import {
+  changeSearch,
+  clearSearch,
+  showSearch,
+  submitSearch,
+} from 'flavours/glitch/actions/search';
+
+//  Components.
+import Composer from 'flavours/glitch/features/composer';
+import DrawerAccount from './account';
+import DrawerHeader from './header';
+import DrawerResults from './results';
+import DrawerSearch from './search';
+
+//  Utils.
+import { me } from 'flavours/glitch/util/initial_state';
+import { wrap } from 'flavours/glitch/util/redux_helpers';
+
+//  State mapping.
+const mapStateToProps = state => ({
+  account: state.getIn(['accounts', me]),
+  columns: state.getIn(['settings', 'columns']),
+  results: state.getIn(['search', 'results']),
+  searchHidden: state.getIn(['search', 'hidden']),
+  searchValue: state.getIn(['search', 'value']),
+  submitted: state.getIn(['search', 'submitted']),
+});
+
+//  Dispatch mapping.
+const mapDispatchToProps = {
+  onChange: changeSearch,
+  onClear: clearSearch,
+  onShow: showSearch,
+  onSubmit: submitSearch,
+  onOpenSettings: openModal.bind(null, 'SETTINGS', {}),
+};
+
+//  The component.
+class Drawer extends React.Component {
+
+  //  Constructor.
+  constructor (props) {
+    super(props);
+  }
+
+  //  Rendering.
+  render () {
+    const {
+      account,
+      columns,
+      intl,
+      multiColumn,
+      onChange,
+      onClear,
+      onOpenSettings,
+      onShow,
+      onSubmit,
+      results,
+      searchHidden,
+      searchValue,
+      submitted,
+    } = this.props;
+
+    //  The result.
+    return (
+      <div className='drawer'>
+        {multiColumn ? (
+          <DrawerHeader
+            columns={columns}
+            intl={intl}
+            onSettingsClick={onOpenSettings}
+          />
+        ) : null}
+        <DrawerSearch
+          intl={intl}
+          onChange={onChange}
+          onClear={onClear}
+          onShow={onShow}
+          onSubmit={onSubmit}
+          submitted={submitted}
+          value={searchValue}
+        />
+        <div className='contents'>
+          <DrawerAccount account={account} />
+          <Composer />
+          <DrawerResults
+            results={results}
+            visible={submitted && !searchHidden}
+          />
+        </div>
+      </div>
+    );
+  }
+
+}
+
+//  Props.
+Drawer.propTypes = {
+  intl: PropTypes.object.isRequired,
+  multiColumn: PropTypes.bool,
+
+  //  State props.
+  account: ImmutablePropTypes.map,
+  columns: ImmutablePropTypes.list,
+  results: ImmutablePropTypes.map,
+  searchHidden: PropTypes.bool,
+  searchValue: PropTypes.string,
+  submitted: PropTypes.bool,
+
+  //  Dispatch props.
+  onChange: PropTypes.func,
+  onClear: PropTypes.func,
+  onShow: PropTypes.func,
+  onSubmit: PropTypes.func,
+  onOpenSettings: PropTypes.func,
+};
+
+//  Connecting and export.
+export { Drawer as WrappedComponent };
+export default wrap(Drawer, mapStateToProps, mapDispatchToProps, true);
diff --git a/app/javascript/flavours/glitch/features/drawer/results/index.js b/app/javascript/flavours/glitch/features/drawer/results/index.js
new file mode 100644
index 000000000..f2a79eb59
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/drawer/results/index.js
@@ -0,0 +1,116 @@
+//  Package imports.
+import PropTypes from 'prop-types';
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import {
+  FormattedMessage,
+  defineMessages,
+} from 'react-intl';
+import spring from 'react-motion/lib/spring';
+import { Link } from 'react-router-dom';
+
+//  Components.
+import AccountContainer from 'flavours/glitch/containers/account_container';
+import StatusContainer from 'flavours/glitch/containers/status_container';
+
+//  Utils.
+import Motion from 'flavours/glitch/util/optional_motion';
+
+//  Messages.
+const messages = defineMessages({
+  total: {
+    defaultMessage: '{count, number} {count, plural, one {result} other {results}}',
+    id: 'search_results.total',
+  },
+});
+
+//  The component.
+export default function DrawerResults ({
+  results,
+  visible,
+}) {
+  const accounts = results ? results.get('accounts') : null;
+  const statuses = results ? results.get('statuses') : null;
+  const hashtags = results ? results.get('hashtags') : null;
+
+  //  This gets the total number of items.
+  const count = [accounts, statuses, hashtags].reduce(function (size, item) {
+    if (item && item.size) {
+      return size + item.size;
+    }
+    return size;
+  }, 0);
+
+  //  The result.
+  return (
+    <Motion
+      defaultStyle={{ x: -100 }}
+      style={{
+        x: spring(visible ? 0 : -100, {
+          stiffness: 210,
+          damping: 20,
+        }),
+      }}
+    >
+      {({ x }) => (
+        <div
+          className='drawer--results'
+          style={{
+            transform: `translateX(${x}%)`,
+            visibility: x === -100 ? 'hidden' : 'visible',
+          }}
+        >
+          <header>
+            <FormattedMessage
+              {...messages.total}
+              values={{ count }}
+            />
+          </header>
+          {accounts && accounts.size ? (
+            <section>
+              {accounts.map(
+                accountId => (
+                  <AccountContainer
+                    id={accountId}
+                    key={accountId}
+                  />
+                )
+              )}
+            </section>
+          ) : null}
+          {statuses && statuses.size ? (
+            <section>
+              {statuses.map(
+                statusId => (
+                  <StatusContainer
+                    id={statusId}
+                    key={statusId}
+                  />
+                )
+              )}
+            </section>
+          ) : null}
+          {hashtags && hashtags.size ? (
+            <section>
+              {hashtags.map(
+                hashtag => (
+                  <Link
+                    className='hashtag'
+                    key={hashtag}
+                    to={`/timelines/tag/${hashtag}`}
+                  >#{hashtag}</Link>
+                )
+              )}
+            </section>
+          ) : null}
+        </div>
+      )}
+    </Motion>
+  );
+}
+
+//  Props.
+DrawerResults.propTypes = {
+  results: ImmutablePropTypes.map,
+  visible: PropTypes.bool,
+};
diff --git a/app/javascript/flavours/glitch/features/drawer/search/index.js b/app/javascript/flavours/glitch/features/drawer/search/index.js
new file mode 100644
index 000000000..2d739349c
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/drawer/search/index.js
@@ -0,0 +1,151 @@
+//  Package imports.
+import classNames from 'classnames';
+import PropTypes from 'prop-types';
+import React from 'react';
+import {
+  FormattedMessage,
+  defineMessages,
+} from 'react-intl';
+import Overlay from 'react-overlays/lib/Overlay';
+
+//  Components.
+import Icon from 'flavours/glitch/components/icon';
+import DrawerSearchPopout from './popout';
+
+//  Utils.
+import { focusRoot } from 'flavours/glitch/util/dom_helpers';
+import {
+  assignHandlers,
+  hiddenComponent,
+} from 'flavours/glitch/util/react_helpers';
+
+//  Messages.
+const messages = defineMessages({
+  placeholder: {
+    defaultMessage: 'Search',
+    id: 'search.placeholder',
+  },
+});
+
+//  Handlers.
+const handlers = {
+
+  handleBlur () {
+    this.setState({ expanded: false });
+  },
+
+  handleChange ({ target: { value } }) {
+    const { onChange } = this.props;
+    if (onChange) {
+      onChange(value);
+    }
+  },
+
+  handleClear (e) {
+    const {
+      onClear,
+      submitted,
+      value: { length },
+    } = this.props;
+    e.preventDefault();  //  Prevents focus change ??
+    if (onClear && (submitted || length)) {
+      onClear();
+    }
+  },
+
+  handleFocus () {
+    const { onShow } = this.props;
+    this.setState({ expanded: true });
+    if (onShow) {
+      onShow();
+    }
+  },
+
+  handleKeyUp (e) {
+    const { onSubmit } = this.props;
+    switch (e.key) {
+    case 'Enter':
+      if (onSubmit) {
+        onSubmit();
+      }
+      break;
+    case 'Escape':
+      focusRoot();
+    }
+  },
+};
+
+//  The component.
+export default class DrawerSearch extends React.PureComponent {
+
+  //  Constructor.
+  constructor (props) {
+    super(props);
+    assignHandlers(this, handlers);
+    this.state = { expanded: false };
+  }
+
+  //  Rendering.
+  render () {
+    const {
+      handleBlur,
+      handleChange,
+      handleClear,
+      handleFocus,
+      handleKeyUp,
+    } = this.handlers;
+    const {
+      intl,
+      submitted,
+      value,
+    } = this.props;
+    const { expanded } = this.state;
+    const computedClass = classNames('drawer--search', { active: value.length || submitted });
+
+    return (
+      <div className={computedClass}>
+        <label>
+          <span {...hiddenComponent}>
+            <FormattedMessage {...messages.placeholder} />
+          </span>
+          <input
+            type='text'
+            placeholder={intl.formatMessage(messages.placeholder)}
+            value={value || ''}
+            onChange={handleChange}
+            onKeyUp={handleKeyUp}
+            onFocus={handleFocus}
+            onBlur={handleBlur}
+          />
+        </label>
+        <div
+          aria-label={intl.formatMessage(messages.placeholder)}
+          className='icon'
+          onClick={handleClear}
+          role='button'
+          tabIndex='0'
+        >
+          <Icon icon='search' />
+          <Icon icon='fa-times-circle' />
+        </div>
+        <Overlay
+          placement='bottom'
+          show={expanded && !(value || '').length && !submitted}
+          target={this}
+        ><DrawerSearchPopout /></Overlay>
+      </div>
+    );
+  }
+
+}
+
+//  Props.
+DrawerSearch.propTypes = {
+  value: PropTypes.string,
+  submitted: PropTypes.bool,
+  onChange: PropTypes.func,
+  onSubmit: PropTypes.func,
+  onClear: PropTypes.func,
+  onShow: PropTypes.func,
+  intl: PropTypes.object,
+};
diff --git a/app/javascript/flavours/glitch/features/drawer/search/popout/index.js b/app/javascript/flavours/glitch/features/drawer/search/popout/index.js
new file mode 100644
index 000000000..b5ea86ff1
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/drawer/search/popout/index.js
@@ -0,0 +1,99 @@
+//  Package imports.
+import PropTypes from 'prop-types';
+import React from 'react';
+import {
+  FormattedMessage,
+  defineMessages,
+} from 'react-intl';
+import spring from 'react-motion/lib/spring';
+
+//  Utils.
+import Motion from 'flavours/glitch/util/optional_motion';
+
+//  Messages.
+const messages = defineMessages({
+  format: {
+    defaultMessage: 'Advanced search format',
+    id: 'search_popout.search_format',
+  },
+  hashtag: {
+    defaultMessage: 'hashtag',
+    id: 'search_popout.tips.hashtag',
+  },
+  status: {
+    defaultMessage: 'status',
+    id: 'search_popout.tips.status',
+  },
+  text: {
+    defaultMessage: 'Simple text returns matching display names, usernames and hashtags',
+    id: 'search_popout.tips.text',
+  },
+  user: {
+    defaultMessage: 'user',
+    id: 'search_popout.tips.user',
+  },
+});
+
+//  The spring used by our motion.
+const motionSpring = spring(1, { damping: 35, stiffness: 400 });
+
+//  The component.
+export default function DrawerSearchPopout ({ style }) {
+
+  //  The result.
+  return (
+    <Motion
+      defaultStyle={{
+        opacity: 0,
+        scaleX: 0.85,
+        scaleY: 0.75,
+      }}
+      style={{
+        opacity: motionSpring,
+        scaleX: motionSpring,
+        scaleY: motionSpring,
+      }}
+    >
+      {({ opacity, scaleX, scaleY }) => (
+        <div
+          className='drawer--search--popout'
+          style={{
+            ...style,
+            position: 'absolute',
+            width: 285,
+            opacity: opacity,
+            transform: `scale(${scaleX}, ${scaleY})`,
+          }}
+        >
+          <h4><FormattedMessage {...messages.format} /></h4>
+          <ul>
+            <li>
+              <em>#example</em>
+              {' '}
+              <FormattedMessage {...messages.hashtag} />
+            </li>
+            <li>
+              <em>@username@domain</em>
+              {' '}
+              <FormattedMessage {...messages.user} />
+            </li>
+            <li>
+              <em>URL</em>
+              {' '}
+              <FormattedMessage {...messages.user} />
+            </li>
+            <li>
+              <em>URL</em>
+              {' '}
+              <FormattedMessage {...messages.status} />
+            </li>
+          </ul>
+          <FormattedMessage {...messages.text} />
+        </div>
+      )}
+    </Motion>
+  );
+}
+
+//  Props.
+DrawerSearchPopout.propTypes = { style: PropTypes.object };
diff --git a/app/javascript/flavours/glitch/features/compose/components/emoji_picker_dropdown.js b/app/javascript/flavours/glitch/features/emoji_picker/index.js
index cf89f91d3..4b1ef6c97 100644
--- a/app/javascript/flavours/glitch/features/compose/components/emoji_picker_dropdown.js
+++ b/app/javascript/flavours/glitch/features/emoji_picker/index.js
@@ -1,3 +1,8 @@
+import { connect } from 'react-redux';
+import { changeSetting } from 'flavours/glitch/actions/settings';
+import { createSelector } from 'reselect';
+import { Map as ImmutableMap } from 'immutable';
+import { useEmoji } from 'flavours/glitch/actions/emojis';
 import React from 'react';
 import PropTypes from 'prop-types';
 import { defineMessages, injectIntl } from 'react-intl';
@@ -25,6 +30,80 @@ const messages = defineMessages({
   flags: { id: 'emoji_button.flags', defaultMessage: 'Flags' },
 });
 
+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.filter(e => e.get('visible_in_picker')).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: getCustomEmojis(state),
+  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);
+    }
+  },
+});
+
 const assetHost = process.env.CDN_HOST || '';
 let EmojiPicker, Emoji; // load asynchronously
 
@@ -277,6 +356,7 @@ class EmojiPickerMenu extends React.PureComponent {
 
 }
 
+@connect(mapStateToProps, mapDispatchToProps)
 @injectIntl
 export default class EmojiPickerDropdown extends React.PureComponent {
 
diff --git a/app/javascript/flavours/glitch/features/getting_started/index.js b/app/javascript/flavours/glitch/features/getting_started/index.js
index 89c77b507..1b05c4da1 100644
--- a/app/javascript/flavours/glitch/features/getting_started/index.js
+++ b/app/javascript/flavours/glitch/features/getting_started/index.js
@@ -111,10 +111,10 @@ export default class GettingStarted extends ImmutablePureComponent {
     navItems.push(<ColumnLink key='6' icon='ellipsis-h' text={intl.formatMessage(messages.misc)} to='/getting-started-misc' />);
 
     listItems = listItems.concat([
-      <div>
-        <ColumnLink key='7' icon='bars' text={intl.formatMessage(messages.lists)} to='/lists' />
+      <div key='7'>
+        <ColumnLink key='8' icon='bars' text={intl.formatMessage(messages.lists)} to='/lists' />
         {lists.map(list =>
-          <ColumnLink key={list.get('id')} to={`/timelines/list/${list.get('id')}`} icon='list-ul' text={list.get('title')} />
+          <ColumnLink key={(8 + Number(list.get('id'))).toString()} to={`/timelines/list/${list.get('id')}`} icon='list-ul' text={list.get('title')} />
         )}
       </div>,
     ]);
diff --git a/app/javascript/flavours/glitch/features/standalone/compose/index.js b/app/javascript/flavours/glitch/features/standalone/compose/index.js
index b33c21cb5..a77b59448 100644
--- a/app/javascript/flavours/glitch/features/standalone/compose/index.js
+++ b/app/javascript/flavours/glitch/features/standalone/compose/index.js
@@ -1,5 +1,5 @@
 import React from 'react';
-import ComposeFormContainer from 'flavours/glitch/features/compose/containers/compose_form_container';
+import Composer from 'flavours/glitch/features/composer';
 import NotificationsContainer from 'flavours/glitch/features/ui/containers/notifications_container';
 import LoadingBarContainer from 'flavours/glitch/features/ui/containers/loading_bar_container';
 import ModalContainer from 'flavours/glitch/features/ui/containers/modal_container';
@@ -9,7 +9,7 @@ export default class Compose extends React.PureComponent {
   render () {
     return (
       <div>
-        <ComposeFormContainer />
+        <Composer />
         <NotificationsContainer />
         <ModalContainer />
         <LoadingBarContainer className='loading-bar' />
diff --git a/app/javascript/flavours/glitch/features/ui/components/actions_modal.js b/app/javascript/flavours/glitch/features/ui/components/actions_modal.js
index 0873c282f..c8b040f95 100644
--- a/app/javascript/flavours/glitch/features/ui/components/actions_modal.js
+++ b/app/javascript/flavours/glitch/features/ui/components/actions_modal.js
@@ -6,15 +6,26 @@ import StatusContent from 'flavours/glitch/components/status_content';
 import Avatar from 'flavours/glitch/components/avatar';
 import RelativeTimestamp from 'flavours/glitch/components/relative_timestamp';
 import DisplayName from 'flavours/glitch/components/display_name';
-import IconButton from 'flavours/glitch/components/icon_button';
 import classNames from 'classnames';
+import Icon from 'flavours/glitch/components/icon';
+import Link from 'flavours/glitch/components/link';
+import Toggle from 'react-toggle';
 
 export default class ActionsModal extends ImmutablePureComponent {
 
   static propTypes = {
     status: ImmutablePropTypes.map,
-    actions: PropTypes.array,
-    onClick: PropTypes.func,
+    actions: PropTypes.arrayOf(PropTypes.shape({
+      active: PropTypes.bool,
+      href: PropTypes.string,
+      icon: PropTypes.string,
+      meta: PropTypes.node,
+      name: PropTypes.string,
+      on: PropTypes.bool,
+      onClick: PropTypes.func,
+      onPassiveClick: PropTypes.func,
+      text: PropTypes.node,
+    })),
   };
 
   renderAction = (action, i) => {
@@ -22,17 +33,57 @@ export default class ActionsModal extends ImmutablePureComponent {
       return <li key={`sep-${i}`} className='dropdown-menu__separator' />;
     }
 
-    const { icon = null, text, meta = null, active = false, href = '#' } = action;
+    const {
+      active,
+      href,
+      icon,
+      meta,
+      name,
+      on,
+      onClick,
+      onPassiveClick,
+      text,
+    } = action;
 
     return (
-      <li key={`${text}-${i}`}>
-        <a href={href} target='_blank' rel='noopener' onClick={this.props.onClick} data-index={i} className={classNames({ active })}>
-          {icon && <IconButton title={text} icon={icon} role='presentation' tabIndex='-1' />}
-          <div>
-            <div className={classNames({ 'actions-modal__item-label': !!meta })}>{text}</div>
-            <div>{meta}</div>
-          </div>
-        </a>
+      <li key={name || i}>
+        <Link
+          className={classNames('link', { active })}
+          href={href}
+          onClick={on !== null && typeof on !== 'undefined' && onPassiveClick || onClick}
+          role={onClick ? 'button' : null}
+        >
+          {function () {
+
+            //  We render a `<Toggle>` if we were provided an `on`
+            //  property, and otherwise show an `<Icon>` if available.
+            switch (true) {
+            case on !== null && typeof on !== 'undefined':
+              return (
+                <Toggle
+                  checked={on}
+                  onChange={onPassiveClick || onClick}
+                />
+              );
+            case !!icon:
+              return (
+                <Icon
+                  className='icon'
+                  fullwidth
+                  icon={icon}
+                />
+              );
+            default:
+              return null;
+            }
+          }()}
+          {meta ? (
+            <div>
+              <strong>{text}</strong>
+              {meta}
+            </div>
+          ) : <div>{text}</div>}
+        </Link>
       </li>
     );
   }
diff --git a/app/javascript/flavours/glitch/features/ui/components/columns_area.js b/app/javascript/flavours/glitch/features/ui/components/columns_area.js
index 91d4df93f..e4556899d 100644
--- a/app/javascript/flavours/glitch/features/ui/components/columns_area.js
+++ b/app/javascript/flavours/glitch/features/ui/components/columns_area.js
@@ -11,13 +11,13 @@ import BundleContainer from '../containers/bundle_container';
 import ColumnLoading from './column_loading';
 import DrawerLoading from './drawer_loading';
 import BundleColumnError from './bundle_column_error';
-import { Compose, Notifications, HomeTimeline, CommunityTimeline, PublicTimeline, HashtagTimeline, DirectTimeline, FavouritedStatuses, ListTimeline } from 'flavours/glitch/util/async-components';
+import { Drawer, Notifications, HomeTimeline, CommunityTimeline, PublicTimeline, HashtagTimeline, DirectTimeline, FavouritedStatuses, ListTimeline } from 'flavours/glitch/util/async-components';
 
 import detectPassiveEvents from 'detect-passive-events';
 import { scrollRight } from 'flavours/glitch/util/scroll';
 
 const componentMap = {
-  'COMPOSE': Compose,
+  'COMPOSE': Drawer,
   'HOME': HomeTimeline,
   'NOTIFICATIONS': Notifications,
   'PUBLIC': PublicTimeline,
diff --git a/app/javascript/flavours/glitch/features/ui/components/onboarding_modal.js b/app/javascript/flavours/glitch/features/ui/components/onboarding_modal.js
index 21f1addea..91a83f330 100644
--- a/app/javascript/flavours/glitch/features/ui/components/onboarding_modal.js
+++ b/app/javascript/flavours/glitch/features/ui/components/onboarding_modal.js
@@ -6,18 +6,12 @@ import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
 import ReactSwipeableViews from 'react-swipeable-views';
 import classNames from 'classnames';
 import Permalink from 'flavours/glitch/components/permalink';
-import ComposeForm from 'flavours/glitch/features/compose/components/compose_form';
-import Search from 'flavours/glitch/features/compose/components/search';
-import NavigationBar from 'flavours/glitch/features/compose/components/navigation_bar';
+import { WrappedComponent as RawComposer } from 'flavours/glitch/features/composer';
+import DrawerAccount from 'flavours/glitch/features/drawer/account';
+import DrawerSearch from 'flavours/glitch/features/drawer/search';
 import ColumnHeader from './column_header';
-import {
-  List as ImmutableList,
-  Map as ImmutableMap,
-} from 'immutable';
 import { me } from 'flavours/glitch/util/initial_state';
 
-const noop = () => { };
-
 const messages = defineMessages({
   home_title: { id: 'column.home', defaultMessage: 'Home' },
   notifications_title: { id: 'column.notifications', defaultMessage: 'Notifications' },
@@ -44,29 +38,21 @@ PageOne.propTypes = {
   domain: PropTypes.string.isRequired,
 };
 
-const PageTwo = ({ myAccount }) => (
+const composerState = {
+  showSearch: true,
+  text: 'Awoo! #introductions',
+};
+
+const PageTwo = ({ intl, myAccount }) => (
   <div className='onboarding-modal__page onboarding-modal__page-two'>
     <div className='figure non-interactive'>
       <div className='pseudo-drawer'>
-        <NavigationBar onClose={noop} account={myAccount} />
+        <DrawerAccount account={myAccount} />
+        <RawComposer
+          intl={intl}
+          state={composerState}
+        />
       </div>
-      <ComposeForm
-        text='Awoo! #introductions'
-        suggestions={ImmutableList()}
-        mentionedDomains={[]}
-        spoiler={false}
-        onChange={noop}
-        onSubmit={noop}
-        onPaste={noop}
-        onPickEmoji={noop}
-        onChangeSpoilerText={noop}
-        onClearSuggestions={noop}
-        onFetchSuggestions={noop}
-        onSuggestionSelected={noop}
-        onPrivacyChange={noop}
-        showSearch
-        settings={ImmutableMap.of('side_arm', 'none')}
-      />
     </div>
 
     <p><FormattedMessage id='onboarding.page_two.compose' defaultMessage='Write posts from the compose column. You can upload images, change privacy settings, and add content warnings with the icons below.' /></p>
@@ -74,22 +60,17 @@ const PageTwo = ({ myAccount }) => (
 );
 
 PageTwo.propTypes = {
+  intl: PropTypes.object.isRequired,
   myAccount: ImmutablePropTypes.map.isRequired,
 };
 
-const PageThree = ({ myAccount }) => (
+const PageThree = ({ intl, myAccount }) => (
   <div className='onboarding-modal__page onboarding-modal__page-three'>
     <div className='figure non-interactive'>
-      <Search
-        value=''
-        onChange={noop}
-        onSubmit={noop}
-        onClear={noop}
-        onShow={noop}
-      />
+      <DrawerSearch intl={intl} />
 
       <div className='pseudo-drawer'>
-        <NavigationBar onClose={noop} account={myAccount} />
+        <DrawerAccount account={myAccount} />
       </div>
     </div>
 
@@ -99,6 +80,7 @@ const PageThree = ({ myAccount }) => (
 );
 
 PageThree.propTypes = {
+  intl: PropTypes.object.isRequired,
   myAccount: ImmutablePropTypes.map.isRequired,
 };
 
@@ -192,8 +174,8 @@ export default class OnboardingModal extends React.PureComponent {
     const { myAccount, admin, domain, intl } = this.props;
     this.pages = [
       <PageOne acct={myAccount.get('acct')} domain={domain} />,
-      <PageTwo myAccount={myAccount} />,
-      <PageThree myAccount={myAccount} />,
+      <PageTwo myAccount={myAccount} intl={intl} />,
+      <PageThree myAccount={myAccount} intl={intl} />,
       <PageFour domain={domain} intl={intl} />,
       <PageSix admin={admin} domain={domain} />,
     ];
diff --git a/app/javascript/flavours/glitch/features/ui/index.js b/app/javascript/flavours/glitch/features/ui/index.js
index 5c80ea07b..fae705deb 100644
--- a/app/javascript/flavours/glitch/features/ui/index.js
+++ b/app/javascript/flavours/glitch/features/ui/index.js
@@ -17,7 +17,7 @@ import UploadArea from './components/upload_area';
 import ColumnsAreaContainer from './containers/columns_area_container';
 import classNames from 'classnames';
 import {
-  Compose,
+  Drawer,
   Status,
   GettingStarted,
   KeyboardShortcuts,
@@ -56,7 +56,6 @@ const messages = defineMessages({
 });
 
 const mapStateToProps = state => ({
-  isComposing: state.getIn(['compose', 'is_composing']),
   hasComposingText: state.getIn(['compose', 'text']) !== '',
   layout: state.getIn(['local_settings', 'layout']),
   isWide: state.getIn(['local_settings', 'stretch']),
@@ -120,9 +119,9 @@ export default class UI extends React.Component {
   };
 
   handleBeforeUnload = (e) => {
-    const { intl, isComposing, hasComposingText } = this.props;
+    const { intl, hasComposingText } = this.props;
 
-    if (isComposing && hasComposingText) {
+    if (hasComposingText) {
       // Setting returnValue to any string causes confirmation dialog.
       // Many browsers no longer display this text to users,
       // but we set user-friendly message for other browsers, e.g. Edge.
@@ -227,9 +226,8 @@ export default class UI extends React.Component {
   }
 
   shouldComponentUpdate (nextProps) {
-    if (nextProps.isComposing !== this.props.isComposing) {
+    if (nextProps.navbarUnder !== this.props.navbarUnder) {
       // Avoid expensive update just to toggle a class
-      this.node.classList.toggle('is-composing', nextProps.isComposing);
       this.node.classList.toggle('navbar-under', nextProps.navbarUnder);
 
       return false;
@@ -427,7 +425,7 @@ export default class UI extends React.Component {
               <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/new' component={Drawer} 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} />
diff --git a/app/javascript/flavours/glitch/locales/pl.js b/app/javascript/flavours/glitch/locales/pl.js
index 818436710..c131845b9 100644
--- a/app/javascript/flavours/glitch/locales/pl.js
+++ b/app/javascript/flavours/glitch/locales/pl.js
@@ -28,12 +28,16 @@ const messages = {
   'settings.media': 'Zawartość multimedialna',
   'settings.media_letterbox': 'Letterbox media',
   'settings.media_fullwidth': 'Podgląd zawartości multimedialnej o pełnej szerokości',
-  'settings.preferences': 'Preferencje użyytkownika',
+  'settings.preferences': 'Preferencje użytkownika',
   'settings.wide_view': 'Szeroki widok (tylko w trybie desktopowym)',
   'settings.navbar_under': 'Pasek nawigacji na dole (tylko w trybie mobilnym)',
   'status.collapse': 'Zwiń',
   'status.uncollapse': 'Rozwiń',
 
+  'favourite_modal.combo': 'Możesz nacisnąć {combo}, aby pominąć to następnym razem',
+
+'home.column_settings.show_direct': 'Pokaż wiadomości bezpośrednie',
+
   'notification.markForDeletion': 'Oznacz do usunięcia',
   'notifications.clear': 'Wyczyść wszystkie powiadomienia',
   'notifications.marked_clear_confirmation': 'Czy na pewno chcesz bezpowrtonie usunąć wszystkie powiadomienia?',
@@ -43,6 +47,14 @@ const messages = {
   'notification_purge.btn_none': 'Odznacz\nwszystkie',
   'notification_purge.btn_invert': 'Odwróć\nzaznaczenie',
   'notification_purge.btn_apply': 'Usuń\nzaznaczone',
+
+  'compose.attach.upload': 'Wyślij plik',
+  'compose.attach.doodle': 'Narysuj coś',
+  'compose.attach': 'Załącz coś',
+
+  'advanced-options.local-only.short': 'Tylko lokalnie',
+  'advanced-options.local-only.long': 'Nie wysyłaj na inne instancje',
+  'advanced_options.icon_title': 'Ustawienia zaawansowane',
 };
 
 export default Object.assign({}, inherited, messages);
diff --git a/app/javascript/flavours/glitch/names.yml b/app/javascript/flavours/glitch/names.yml
index ef82abed2..0801c4565 100644
--- a/app/javascript/flavours/glitch/names.yml
+++ b/app/javascript/flavours/glitch/names.yml
@@ -6,3 +6,10 @@ en:
   skins:
     glitch:
       default: Default
+pl:
+  flavours:
+    glitch:
+      description: Domyślny motyw instancji GlitchSoc.
+  skins:
+    glitch:
+      default: Domyślny
diff --git a/app/javascript/flavours/glitch/reducers/compose.js b/app/javascript/flavours/glitch/reducers/compose.js
index aaa36b696..e1f811f6f 100644
--- a/app/javascript/flavours/glitch/reducers/compose.js
+++ b/app/javascript/flavours/glitch/reducers/compose.js
@@ -21,7 +21,6 @@ import {
   COMPOSE_SPOILERNESS_CHANGE,
   COMPOSE_SPOILER_TEXT_CHANGE,
   COMPOSE_VISIBILITY_CHANGE,
-  COMPOSE_COMPOSING_CHANGE,
   COMPOSE_EMOJI_INSERT,
   COMPOSE_UPLOAD_CHANGE_REQUEST,
   COMPOSE_UPLOAD_CHANGE_SUCCESS,
@@ -48,7 +47,6 @@ const initialState = ImmutableMap({
   focusDate: null,
   preselectDate: null,
   in_reply_to: null,
-  is_composing: false,
   is_submitting: false,
   is_uploading: false,
   progress: 0,
@@ -134,7 +132,7 @@ function removeMedia(state, mediaId) {
 
 const insertSuggestion = (state, position, token, completion) => {
   return state.withMutations(map => {
-    map.update('text', oldText => `${oldText.slice(0, position)}${completion}\u200B${oldText.slice(position + token.length)}`);
+    map.update('text', oldText => `${oldText.slice(0, position)}${completion}${completion[0] === ':' ? '\u200B' : ' '}${oldText.slice(position + token.length)}`);
     map.set('suggestion_token', null);
     map.update('suggestions', ImmutableList(), list => list.clear());
     map.set('focusDate', new Date());
@@ -181,9 +179,7 @@ export default function compose(state = initialState, action) {
   case COMPOSE_MOUNT:
     return state.set('mounted', true);
   case COMPOSE_UNMOUNT:
-    return state
-      .set('mounted', false)
-      .set('is_composing', false);
+    return state.set('mounted', false);
   case COMPOSE_ADVANCED_OPTIONS_CHANGE:
     return state
       .set('advanced_options',
@@ -219,8 +215,6 @@ export default function compose(state = initialState, action) {
     return state
       .set('text', action.text)
       .set('idempotencyKey', uuid());
-  case COMPOSE_COMPOSING_CHANGE:
-    return state.set('is_composing', action.value);
   case COMPOSE_REPLY:
     return state.withMutations(map => {
       map.set('in_reply_to', action.status.get('id'));
diff --git a/app/javascript/flavours/glitch/styles/components/composer.scss b/app/javascript/flavours/glitch/styles/components/composer.scss
new file mode 100644
index 000000000..46df79906
--- /dev/null
+++ b/app/javascript/flavours/glitch/styles/components/composer.scss
@@ -0,0 +1,419 @@
+.composer { padding: 10px }
+
+.composer--spoiler {
+  input {
+    display: block;
+    box-sizing: border-box;
+    margin: 0;
+    border: none;
+    border-radius: 4px;
+    padding: 10px;
+    width: 100%;
+    outline: 0;
+    color: $ui-base-color;
+    background: $simple-background-color;
+    font-size: 14px;
+    font-family: inherit;
+    resize: vertical;
+
+    &:focus { outline: 0 }
+    @include single-column('screen and (max-width: 630px)') { font-size: 16px }
+  }
+}
+
+.composer--warning {
+  color: darken($ui-secondary-color, 65%);
+  margin-bottom: 15px;
+  background: $ui-primary-color;
+  box-shadow: 0 2px 6px rgba($base-shadow-color, 0.3);
+  padding: 8px 10px;
+  border-radius: 4px;
+  font-size: 13px;
+  font-weight: 400;
+
+  a {
+    color: darken($ui-primary-color, 33%);
+    font-weight: 500;
+    text-decoration: underline;
+
+    &:active,
+    &:focus,
+    &:hover { text-decoration: none }
+  }
+}
+
+.composer--reply {
+  margin: 0 0 -2px;
+  border-radius: 4px 4px 0 0;
+  padding: 10px;
+  background: $ui-primary-color;
+
+  & > header {
+    margin-bottom: 5px;
+    overflow: hidden;
+
+    & > .account {
+      & > .avatar {
+        float: left;
+        margin-right: 5px;
+      }
+
+      & > .display_name {
+        color: $ui-base-color;
+        display: block;
+        padding-right: 25px;
+        max-width: 100%;
+        line-height: 24px;
+        text-decoration: none;
+        overflow: hidden;
+      }
+    }
+
+    & > .cancel {
+      float: right;
+      line-height: 24px;
+    }
+  }
+
+  & > .content {
+    position: relative;
+    margin: 10px 0;
+    padding: 0 12px;
+    font-size: 14px;
+    line-height: 20px;
+    color: $ui-base-color;
+    word-wrap: break-word;
+    font-weight: 400;
+    overflow: visible;
+    white-space: pre-wrap;
+    padding-top: 5px;
+  }
+
+  .emojione {
+    width: 20px;
+    height: 20px;
+    margin: -5px 0 0;
+  }
+
+  p {
+    margin-bottom: 20px;
+
+    &:last-child { margin-bottom: 0 }
+  }
+
+  a {
+    color: lighten($ui-base-color, 20%);
+    text-decoration: none;
+
+    &:hover { text-decoration: underline }
+
+    &.mention {
+      &:hover {
+        text-decoration: none;
+
+        span { text-decoration: underline }
+      }
+    }
+  }
+}
+
+.composer--textarea {
+  position: relative;
+
+  & > label {
+    .textarea {
+      display: block;
+      box-sizing: border-box;
+      margin: 0;
+      border: none;
+      border-radius: 4px 4px 0 0;
+      padding: 10px 32px 0 10px;
+      width: 100%;
+      min-height: 100px;
+      outline: 0;
+      color: $ui-base-color;
+      background: $simple-background-color;
+      font-size: 14px;
+      font-family: inherit;
+      resize: none;
+
+      &:disabled { background: $ui-secondary-color }
+      &:focus { outline: 0 }
+      @include single-column('screen and (max-width: 630px)') { font-size: 16px }
+
+      @include limited-single-column('screen and (max-width: 600px)') {
+        height: 100px !important; // prevent auto-resize textarea
+        resize: vertical;
+      }
+    }
+  }
+}
+
+.composer--textarea--suggestions {
+  display: block;
+  position: absolute;
+  box-sizing: border-box;
+  top: 100%;
+  border-radius: 0 0 4px 4px;
+  padding: 6px;
+  width: 100%;
+  color: $ui-base-color;
+  background: $ui-secondary-color;
+  box-shadow: 4px 4px 6px rgba($base-shadow-color, 0.4);
+  font-size: 14px;
+  z-index: 99;
+
+  &[hidden] { display: none }
+}
+
+.composer--textarea--suggestions--item {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  justify-content: flex-start;
+  border-radius: 4px;
+  padding: 10px;
+  font-size: 14px;
+  line-height: 18px;
+  cursor: pointer;
+
+  &:hover,
+  &:focus,
+  &:active,
+  &.selected { background: darken($ui-secondary-color, 10%) }
+
+  & > .emoji {
+    img {
+      display: block;
+      float: left;
+      margin-right: 8px;
+      width: 18px;
+      height: 18px;
+    }
+  }
+}
+
+.composer--upload_form {
+  padding: 5px;
+  color: $ui-base-color;
+  background: $simple-background-color;
+  font-size: 14px;
+
+  & > .content {
+    display: flex;
+    flex-direction: row;
+    flex-wrap: wrap;
+    font-family: inherit;
+    overflow: hidden;
+  }
+}
+
+.composer--upload_form--item {
+  flex: 1 1 0;
+  margin: 5px;
+  min-width: 40%;
+
+  & > div {
+    position: relative;
+    border-radius: 4px;
+    height: 100px;
+    width: 100%;
+    background-position: center;
+    background-size: cover;
+    background-repeat: no-repeat;
+
+    input {
+      display: block;
+      position: absolute;
+      box-sizing: border-box;
+      bottom: 0;
+      left: 0;
+      margin: 0;
+      border: 0;
+      padding: 10px;
+      width: 100%;
+      color: $ui-secondary-color;
+      background: linear-gradient(0deg, rgba($base-shadow-color, 0.8) 0, rgba($base-shadow-color, 0.35) 80%, transparent);
+      font-size: 14px;
+      font-family: inherit;
+      font-weight: 500;
+      opacity: 0;
+      z-index: 2;
+      transition: opacity .1s ease;
+
+      &:focus { color: $white }
+
+      &::placeholder {
+        opacity: 0.54;
+        color: $ui-secondary-color;
+      }
+    }
+
+    & > .close { mix-blend-mode: difference }
+  }
+
+  &.active {
+    & > div {
+      input { opacity: 1 }
+    }
+  }
+}
+
+.composer--upload_form--progress {
+  display: flex;
+  padding: 10px;
+  color: $ui-base-lighter-color;
+  overflow: hidden;
+
+  & > .fa {
+    font-size: 34px;
+    margin-right: 10px;
+  }
+
+  & > .message {
+    flex: 1 1 auto;
+
+    & > span {
+      display: block;
+      font-size: 12px;
+      font-weight: 500;
+      text-transform: uppercase;
+    }
+
+    & > .backdrop {
+      position: relative;
+      margin-top: 5px;
+      border-radius: 6px;
+      width: 100%;
+      height: 6px;
+      background: $ui-base-lighter-color;
+
+      & > .tracker {
+        position: absolute;
+        top: 0;
+        left: 0;
+        height: 6px;
+        border-radius: 6px;
+        background: $ui-highlight-color;
+      }
+    }
+  }
+}
+
+.composer--options {
+  padding: 10px;
+  background: darken($simple-background-color, 8%);
+  box-shadow: inset 0 5px 5px rgba($base-shadow-color, 0.05);
+  border-radius: 0 0 4px 4px;
+  height: 27px;
+
+  & > * {
+    display: inline-block;
+    box-sizing: content-box;
+    padding: 0 3px;
+    height: 27px;
+    line-height: 27px;
+    vertical-align: bottom;
+  }
+
+  & > hr {
+    display: inline-block;
+    margin: 0 3px;
+    border-width: 0 0 0 1px;
+    border-style: none none none solid;
+    border-color: transparent transparent transparent darken($simple-background-color, 24%);
+    padding: 0;
+    width: 0;
+    height: 27px;
+    background: transparent;
+  }
+}
+
+.composer--options--dropdown {
+  &.open {
+    & > .value {
+      border-radius: 4px 4px 0 0;
+      box-shadow: 0 -4px 4px rgba($base-shadow-color, 0.1);
+      color: $primary-text-color;
+      background: $ui-highlight-color;
+      transition: none;
+    }
+  }
+}
+
+.composer--options--dropdown--content {
+  position: absolute;
+  border-radius: 4px;
+  box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4);
+  background: $simple-background-color;
+  overflow: hidden;
+  transform-origin: 50% 0;
+}
+
+.composer--options--dropdown--content--item {
+  display: flex;
+  align-items: center;
+  padding: 10px;
+  color: $ui-base-color;
+  cursor: pointer;
+
+  & > .content {
+    flex: 1 1 auto;
+    color: darken($ui-primary-color, 24%);
+
+    &:not(:first-child) { margin-left: 10px }
+
+    strong {
+      display: block;
+      color: $ui-base-color;
+      font-weight: 500;
+    }
+  }
+
+  &:hover,
+  &.active {
+    background: $ui-highlight-color;
+    color: $primary-text-color;
+
+    & > .content {
+      color: $primary-text-color;
+
+      strong { color: $primary-text-color }
+    }
+  }
+
+  &.active:hover { background: lighten($ui-highlight-color, 4%) }
+}
+
+.composer--publisher {
+  padding-top: 10px;
+  text-align: right;
+  white-space: nowrap;
+  overflow: hidden;
+
+  & > .count {
+    display: inline-block;
+    margin: 0 16px 0 8px;
+    font-size: 16px;
+    line-height: 36px;
+  }
+
+  & > .primary {
+    display: inline-block;
+    margin: 0;
+    padding: 0 10px;
+    text-align: center;
+  }
+
+  & > .side_arm {
+    display: inline-block;
+    margin: 0 2px 0 0;
+    padding: 0;
+    width: 36px;
+    text-align: center;
+  }
+
+  &.over {
+    & > .count { color: $warning-red }
+  }
+}
diff --git a/app/javascript/flavours/glitch/styles/components/drawer.scss b/app/javascript/flavours/glitch/styles/components/drawer.scss
new file mode 100644
index 000000000..ebf996907
--- /dev/null
+++ b/app/javascript/flavours/glitch/styles/components/drawer.scss
@@ -0,0 +1,223 @@
+.drawer {
+  display: flex;
+  flex-direction: column;
+  box-sizing: border-box;
+  padding: 10px 5px;
+  width: 300px;
+  flex: none;
+  contain: strict;
+
+  &:first-child {
+    padding-left: 10px;
+  }
+
+  &:last-child {
+    padding-right: 10px;
+  }
+
+  @include single-column('screen and (max-width: 630px)') { flex: auto }
+
+  @include limited-single-column('screen and (max-width: 630px)') {
+    &, &:first-child, &:last-child { padding: 0 }
+  }
+
+  .wide & {
+    min-width: 300px;
+    max-width: 400px;
+    flex: 1 1 200px;
+  }
+
+  @include single-column('screen and (max-width: 630px)') {
+    :root & {  //  Overrides `.wide` for single-column view
+      flex: auto;
+      width: 100%;
+      min-width: 0;
+      max-width: none;
+      padding: 0;
+    }
+  }
+
+  .react-swipeable-view-container & { height: 100% }
+
+  & > .contents {
+    position: relative;
+    padding: 0;
+    width: 100%;
+    height: 100%;
+    background: lighten($ui-base-color, 13%);
+    overflow-x: hidden;
+    overflow-y: auto;
+    contain: strict;
+  }
+}
+
+.drawer--header {
+  display: flex;
+  flex-direction: row;
+  margin-bottom: 10px;
+  flex: none;
+  background: lighten($ui-base-color, 8%);
+  font-size: 16px;
+
+  & > * {
+    display: block;
+    box-sizing: border-box;
+    border-bottom: 2px solid transparent;
+    padding: 15px 5px 13px;
+    height: 48px;
+    flex: 1 1 auto;
+    color: $ui-primary-color;
+    text-align: center;
+    text-decoration: none;
+    cursor: pointer;
+  }
+
+  a {
+    transition: background 100ms ease-in;
+
+    &:focus,
+    &:hover {
+      outline: none;
+      background: lighten($ui-base-color, 3%);
+      transition: background 200ms ease-out;
+    }
+  }
+}
+
+.drawer--search {
+  position: relative;
+  margin-bottom: 10px;
+  flex: none;
+
+  @include limited-single-column('screen and (max-width: 360px)') { margin-bottom: 0 }
+  @include single-column('screen and (max-width: 630px)') { font-size: 16px }
+
+  input {
+    display: block;
+    box-sizing: border-box;
+    margin: 0;
+    border: none;
+    padding: 10px 30px 10px 10px;
+    width: 100%;
+    height: 36px;
+    outline: 0;
+    color: $ui-primary-color;
+    background: $ui-base-color;
+    font-size: 14px;
+    font-family: inherit;
+    line-height: 16px;
+
+    &:focus {
+      outline: 0;
+      background: lighten($ui-base-color, 4%);
+    }
+  }
+
+  & > .icon {
+    .fa {
+      display: inline-block;
+      position: absolute;
+      top: 10px;
+      right: 10px;
+      width: 18px;
+      height: 18px;
+      color: $ui-secondary-color;
+      font-size: 18px;
+      opacity: 0;
+      cursor: default;
+      pointer-events: none;
+      z-index: 2;
+      transition: all 100ms linear;
+    }
+
+    .fa-search {
+      opacity: 0.3;
+      transform: rotate(0deg);
+    }
+
+    .fa-times-circle {
+      top: 11px;
+      transform: rotate(-90deg);
+      cursor: pointer;
+
+      &:hover { color: $primary-text-color }
+    }
+
+    &.active {
+      .fa-search {
+        opacity: 0;
+        transform: rotate(90deg);
+      }
+
+      .fa-times-circle {
+        opacity: 0.3;
+        pointer-events: auto;
+        transform: rotate(0deg);
+      }
+    }
+  }
+}
+
+.drawer--account {
+  padding: 10px;
+  color: $ui-primary-color;
+
+  & > a {
+    color: inherit;
+    text-decoration: none;
+  }
+
+  & > .avatar {
+    float: left;
+    margin-right: 10px;
+  }
+
+  & > .acct {
+    display: block;
+    color: $primary-text-color;
+    font-weight: 500;
+    white-space: nowrap;
+    overflow: hidden;
+    text-overflow: ellipsis;
+  }
+}
+
+.drawer--results {
+  position: absolute;
+  top: 0;
+  bottom: 0;
+  left: 0;
+  right: 0;
+  padding: 0;
+  background: $ui-base-color;
+  overflow-x: hidden;
+  overflow-y: auto;
+  contain: strict;
+
+  & > header {
+    border-bottom: 1px solid darken($ui-base-color, 4%);
+    padding: 15px 10px;
+    color: $ui-base-lighter-color;
+    background: lighten($ui-base-color, 2%);
+    font-size: 14px;
+    font-weight: 500;
+  }
+
+  & > section {
+    background: $ui-base-color;
+
+    & > .hashtag {
+      display: block;
+      padding: 10px;
+      color: $ui-secondary-color;
+      text-decoration: none;
+
+      &:hover,
+      &:active,
+      &:focus {
+        color: lighten($ui-secondary-color, 4%);
+        text-decoration: underline;
+      }
+    }
+  }
+}
diff --git a/app/javascript/flavours/glitch/styles/components/index.scss b/app/javascript/flavours/glitch/styles/components/index.scss
index b947c082d..ab1359108 100644
--- a/app/javascript/flavours/glitch/styles/components/index.scss
+++ b/app/javascript/flavours/glitch/styles/components/index.scss
@@ -264,172 +264,6 @@
   color: $ui-base-color;
 }
 
-.compose-form {
-  padding: 10px;
-}
-
-.compose-form__warning {
-  color: darken($ui-secondary-color, 65%);
-  margin-bottom: 15px;
-  background: $ui-primary-color;
-  box-shadow: 0 2px 6px rgba($base-shadow-color, 0.3);
-  padding: 8px 10px;
-  border-radius: 4px;
-  font-size: 13px;
-  font-weight: 400;
-
-  strong {
-    color: darken($ui-secondary-color, 65%);
-    font-weight: 500;
-  }
-
-  a {
-    color: darken($ui-primary-color, 33%);
-    font-weight: 500;
-    text-decoration: underline;
-
-    &:hover,
-    &:active,
-    &:focus {
-      text-decoration: none;
-    }
-  }
-}
-
-.compose-form__modifiers {
-  color: $ui-base-color;
-  font-family: inherit;
-  font-size: 14px;
-  background: $simple-background-color;
-}
-
-.compose-form__buttons-wrapper {
-  display: flex;
-  justify-content: space-between;
-}
-
-.compose-form__buttons {
-  padding: 10px;
-  background: darken($simple-background-color, 8%);
-  box-shadow: inset 0 5px 5px rgba($base-shadow-color, 0.05);
-  border-radius: 0 0 4px 4px;
-  display: flex;
-
-  .icon-button {
-    box-sizing: content-box;
-    padding: 0 3px;
-  }
-}
-
-.compose-form__buttons-separator {
-  border-left: 1px solid #c3c3c3;
-  margin: 0 3px;
-}
-
-.compose-form__upload-button-icon {
-  line-height: 27px;
-}
-
-.compose-form__sensitive-button {
-  display: none;
-
-  &.compose-form__sensitive-button--visible {
-    display: block;
-  }
-
-  .compose-form__sensitive-button__icon {
-    line-height: 27px;
-  }
-}
-
-.compose-form__upload-wrapper {
-  overflow: hidden;
-}
-
-.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 {
-  border-radius: 4px;
-  background-position: center;
-  background-size: cover;
-  background-repeat: no-repeat;
-  height: 100px;
-  width: 100%;
-}
-
-.compose-form__label {
-  display: block;
-  line-height: 24px;
-  vertical-align: middle;
-
-  &.with-border {
-    border-top: 1px solid $ui-base-color;
-    padding-top: 10px;
-  }
-
-  .compose-form__label__text {
-    display: inline-block;
-    vertical-align: middle;
-    margin-bottom: 14px;
-    margin-left: 8px;
-    color: $ui-primary-color;
-  }
-}
-
-.compose-form__textarea,
 .follow-form__input {
   background: $simple-background-color;
 
@@ -438,49 +272,17 @@
   }
 }
 
-.compose-form__autosuggest-wrapper {
-  position: relative;
-
-  .emoji-picker-dropdown {
-    position: absolute;
-    right: 5px;
-    top: 5px;
-
-    ::-webkit-scrollbar-track:hover,
-    ::-webkit-scrollbar-track:active {
-      background-color: rgba($base-overlay-background, 0.3);
-    }
-  }
-}
-
-.compose-form__publish {
-  display: flex;
-  justify-content: flex-end;
-  min-width: 0;
-}
-
-.compose-form__publish-button-wrapper {
-  overflow: hidden;
-  padding-top: 10px;
-  white-space: nowrap;
-  display: flex;
+.emoji-picker-dropdown {
+  position: absolute;
+  right: 5px;
+  top: 5px;
 
-  button {
-    text-overflow: unset;
+  ::-webkit-scrollbar-track:hover,
+  ::-webkit-scrollbar-track:active {
+    background-color: rgba($base-overlay-background, 0.3);
   }
 }
 
-.compose-form__publish__side-arm {
-  padding: 0 !important;
-  width: 36px;
-  text-align: center;
-  margin-right: 2px;
-}
-
-.compose-form__publish__primary {
-  padding: 0 10px !important;
-}
-
 .emojione {
   display: inline-block;
   font-size: inherit;
@@ -495,46 +297,12 @@
   }
 }
 
-.reply-indicator {
-  border-radius: 4px 4px 0 0;
-  position: relative;
-  bottom: -2px;
-  background: $ui-primary-color;
-  padding: 10px;
-}
-
-.reply-indicator__header {
-  margin-bottom: 5px;
-  overflow: hidden;
-}
-
-.reply-indicator__cancel {
-  float: right;
-  line-height: 24px;
-}
-
-.reply-indicator__display-name {
-  color: $ui-base-color;
-  display: block;
-  max-width: 100%;
-  line-height: 24px;
-  overflow: hidden;
-  padding-right: 25px;
-  text-decoration: none;
-}
-
-.reply-indicator__display-avatar {
-  float: left;
-  margin-right: 5px;
-}
-
 .status__content--with-action {
   cursor: pointer;
 }
 
 .status-check-box {
-  .status__content,
-  .reply-indicator__content {
+  .status__content {
     color: #3a3a3a;
     a {
       color: #005aa9;
@@ -542,8 +310,7 @@
   }
 }
 
-.status__content,
-.reply-indicator__content {
+.status__content {
   position: relative;
   margin: 10px 0;
   padding: 0 12px;
@@ -975,15 +742,6 @@
   margin-left: 6px;
 }
 
-.reply-indicator__content {
-  color: $ui-base-color;
-  font-size: 14px;
-
-  a {
-    color: lighten($ui-base-color, 20%);
-  }
-}
-
 .account {
   padding: 10px;
   border-bottom: 1px solid lighten($ui-base-color, 8%);
@@ -996,6 +754,37 @@
     text-decoration: none;
     font-size: 14px;
   }
+
+  &.small {
+    border: none;
+    padding: 0;
+
+    & > .account__avatar-wrapper { margin: 0 8px 0 0 }
+
+    & > .display-name {
+      display: block;
+      padding: 0;
+      height: auto;
+      text-overflow: ellipsis;
+      overflow: hidden;
+      white-space: nowrap;
+
+      & > strong {
+        display: inline;
+        font-size: inherit;
+        line-height: inherit;
+      }
+
+      & > span {
+        display: inline;
+        color: lighten($ui-base-color, 36%);
+        font-size: inherit;
+        line-height: inherit;
+
+        &::before { content: " " }
+      }
+    }
+  }
 }
 
 .account__wrapper {
@@ -1497,45 +1286,6 @@
   }
 }
 
-.navigation-bar {
-  padding: 10px;
-  display: flex;
-  flex-shrink: 0;
-  cursor: default;
-  color: $ui-primary-color;
-
-  strong {
-    color: $primary-text-color;
-  }
-
-  .permalink {
-    text-decoration: none;
-  }
-
-  .icon-button {
-    pointer-events: none;
-    opacity: 0;
-  }
-}
-
-.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 {
-  color: inherit;
-  text-decoration: none;
-}
-
 .dropdown {
   display: inline-block;
 }
@@ -1718,7 +1468,6 @@
 .react-swipeable-view-container {
   &,
   .columns-area,
-  .drawer,
   .column {
     height: 100%;
   }
@@ -1759,36 +1508,7 @@
   background: darken($ui-base-color, 7%);
 }
 
-.drawer {
-  width: 300px;
-  box-sizing: border-box;
-  display: flex;
-  flex-direction: column;
-  overflow-y: auto;
-
-  .wide & {
-    flex: 1 1 200px;
-    min-width: 300px;
-    max-width: 400px;
-  }
-}
-
-.drawer__tab {
-  display: block;
-  flex: 1 1 auto;
-  padding: 15px 5px 13px;
-  color: $ui-primary-color;
-  text-decoration: none;
-  text-align: center;
-  font-size: 16px;
-  border-bottom: 2px solid transparent;
-  outline: none;
-  cursor: pointer;
-}
-
-.column,
-.drawer {
-  flex: 1 1 100%;
+.column {
   overflow: hidden;
 }
 
@@ -1796,16 +1516,11 @@
   .tabs-bar {
     margin: 0;
   }
-
-  .search {
-    margin-bottom: 0;
-  }
 }
 
 :root {  //  Overrides .wide stylings for mobile view
   @include single-column('screen and (max-width: 630px)', $parent: null) {
-    .column,
-    .drawer {
+    .column {
       flex: auto;
       width: 100%;
       min-width: 0;
@@ -1816,11 +1531,6 @@
     .columns-area {
       flex-direction: column;
     }
-
-    .search__input,
-    .autosuggest-textarea__textarea {
-      font-size: 16px;
-    }
   }
 }
 
@@ -1829,8 +1539,7 @@
     padding: 0;
   }
 
-  .column,
-  .drawer {
+  .column {
     padding: 10px;
     padding-left: 5px;
     padding-right: 5px;
@@ -1845,63 +1554,19 @@
   }
 
   .columns-area > div {
-    .column,
-    .drawer {
+    .column {
       padding-left: 5px;
       padding-right: 5px;
     }
   }
 }
 
-.drawer__pager {
-  box-sizing: border-box;
-  padding: 0;
-  flex: 1 1 auto;
-  position: relative;
-}
-
-.drawer__inner {
-  background: lighten($ui-base-color, 13%);
-  box-sizing: border-box;
-  padding: 0;
-  position: absolute;
-  height: 100%;
-  width: 100%;
-
-  &.darker {
-    position: absolute;
-    top: 0;
-    left: 0;
-    background: $ui-base-color;
-    width: 100%;
-    height: 100%;
-  }
-}
-
 .pseudo-drawer {
   background: lighten($ui-base-color, 13%);
   font-size: 13px;
   text-align: left;
 }
 
-.drawer__header {
-  flex: 0 0 auto;
-  font-size: 16px;
-  background: lighten($ui-base-color, 8%);
-  margin-bottom: 10px;
-  display: flex;
-  flex-direction: row;
-
-  a {
-    transition: background 100ms ease-in;
-
-    &:hover {
-      background: lighten($ui-base-color, 3%);
-      transition: background 200ms ease-out;
-    }
-  }
-}
-
 .tabs-bar {
   display: flex;
   background: lighten($ui-base-color, 8%);
@@ -2176,121 +1841,6 @@
   cursor: default;
 }
 
-.autosuggest-textarea,
-.spoiler-input {
-  position: relative;
-}
-
-.autosuggest-textarea__textarea,
-.spoiler-input__input {
-  display: block;
-  box-sizing: border-box;
-  width: 100%;
-  margin: 0;
-  color: $ui-base-color;
-  background: $simple-background-color;
-  padding: 10px;
-  font-family: inherit;
-  font-size: 14px;
-  resize: vertical;
-  border: 0;
-  outline: 0;
-
-  &:focus {
-    outline: 0;
-  }
-
-  @include limited-single-column('screen and (max-width: 600px)') {
-    font-size: 16px;
-  }
-}
-
-.spoiler-input__input {
-  border-radius: 4px;
-}
-
-.autosuggest-textarea__textarea {
-  min-height: 100px;
-  border-radius: 4px 4px 0 0;
-  padding-bottom: 0;
-  padding-right: 10px + 22px;
-  resize: none;
-
-  @include limited-single-column('screen and (max-width: 600px)') {
-    height: 100px !important; // prevent auto-resize textarea
-    resize: vertical;
-  }
-}
-
-.autosuggest-textarea__suggestions {
-  box-sizing: border-box;
-  display: none;
-  position: absolute;
-  top: 100%;
-  width: 100%;
-  z-index: 99;
-  box-shadow: 4px 4px 6px rgba($base-shadow-color, 0.4);
-  background: $ui-secondary-color;
-  border-radius: 0 0 4px 4px;
-  color: $ui-base-color;
-  font-size: 14px;
-  padding: 6px;
-
-  &.autosuggest-textarea__suggestions--visible {
-    display: block;
-  }
-}
-
-.autosuggest-textarea__suggestions__item {
-  padding: 10px;
-  cursor: pointer;
-  border-radius: 4px;
-
-  &:hover,
-  &:focus,
-  &:active,
-  &.selected {
-    background: darken($ui-secondary-color, 10%);
-  }
-}
-
-.autosuggest-account,
-.autosuggest-emoji {
-  display: flex;
-  flex-direction: row;
-  align-items: center;
-  justify-content: flex-start;
-  line-height: 18px;
-  font-size: 14px;
-}
-
-.autosuggest-account-icon,
-.autosuggest-emoji img {
-  display: block;
-  margin-right: 8px;
-  width: 16px;
-  height: 16px;
-}
-
-.autosuggest-account .display-name__account {
-  color: lighten($ui-base-color, 36%);
-}
-
-.character-counter__wrapper {
-  line-height: 36px;
-  margin: 0 16px 0 8px;
-  padding-top: 10px;
-}
-
-.character-counter {
-  cursor: default;
-  font-size: 16px;
-}
-
-.character-counter--over {
-  color: $warning-red;
-}
-
 .getting-started__wrapper {
   position: relative;
   overflow-y: auto;
@@ -3185,47 +2735,6 @@
   border-radius: 4px;
 }
 
-.upload-progress {
-  padding: 10px;
-  color: $ui-base-lighter-color;
-  overflow: hidden;
-  display: flex;
-
-  .fa {
-    font-size: 34px;
-    margin-right: 10px;
-  }
-
-  span {
-    font-size: 12px;
-    text-transform: uppercase;
-    font-weight: 500;
-    display: block;
-  }
-}
-
-.upload-progess__message {
-  flex: 1 1 auto;
-}
-
-.upload-progress__backdrop {
-  width: 100%;
-  height: 6px;
-  border-radius: 6px;
-  background: $ui-base-lighter-color;
-  position: relative;
-  margin-top: 5px;
-}
-
-.upload-progress__tracker {
-  position: absolute;
-  left: 0;
-  top: 0;
-  height: 6px;
-  background: $ui-highlight-color;
-  border-radius: 6px;
-}
-
 .emoji-button {
   display: block;
   font-size: 24px;
@@ -3265,268 +2774,6 @@
   filter: none;
 }
 
-.privacy-dropdown__dropdown {
-  position: absolute;
-  background: $simple-background-color;
-  box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4);
-  border-radius: 4px;
-  margin-left: 40px;
-  overflow: hidden;
-  transform-origin: 50% 0;
-}
-
-.privacy-dropdown__option {
-  color: $ui-base-color;
-  padding: 10px;
-  cursor: pointer;
-  display: flex;
-
-  &:hover,
-  &.active {
-    background: $ui-highlight-color;
-    color: $primary-text-color;
-
-    .privacy-dropdown__option__content {
-      color: $primary-text-color;
-
-      strong {
-        color: $primary-text-color;
-      }
-    }
-  }
-
-  &.active:hover {
-    background: lighten($ui-highlight-color, 4%);
-  }
-}
-
-.privacy-dropdown__option__icon {
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  margin-right: 10px;
-}
-
-.privacy-dropdown__option__content {
-  flex: 1 1 auto;
-  color: darken($ui-primary-color, 24%);
-
-  strong {
-    font-weight: 500;
-    display: block;
-    color: $ui-base-color;
-  }
-}
-
-.privacy-dropdown.active {
-  .privacy-dropdown__value {
-    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 {
-    display: block;
-    box-shadow: 2px 4px 6px rgba($base-shadow-color, 0.1);
-  }
-}
-
-.advanced-options-dropdown {
-  position: relative;
-}
-
-.advanced-options-dropdown__dropdown {
-  display: none;
-  position: absolute;
-  left: 0;
-  top: 27px;
-  width: 210px;
-  background: $simple-background-color;
-  border-radius: 0 4px 4px;
-  z-index: 2;
-  overflow: hidden;
-}
-
-.advanced-options-dropdown__option {
-  color: $ui-base-color;
-  padding: 10px;
-  cursor: pointer;
-  display: flex;
-
-  &:hover,
-  &.active {
-    background: $ui-highlight-color;
-    color: $primary-text-color;
-
-    .advanced-options-dropdown__option__content {
-      color: $primary-text-color;
-
-      strong {
-        color: $primary-text-color;
-      }
-    }
-  }
-
-  &.active:hover {
-    background: lighten($ui-highlight-color, 4%);
-  }
-}
-
-.advanced-options-dropdown__option__toggle {
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  margin-right: 10px;
-}
-
-.advanced-options-dropdown__option__content {
-  flex: 1 1 auto;
-  color: darken($ui-primary-color, 24%);
-
-  strong {
-    font-weight: 500;
-    display: block;
-    color: $ui-base-color;
-  }
-}
-
-.advanced-options-dropdown.open {
-  .advanced-options-dropdown__value {
-    background: $simple-background-color;
-    border-radius: 4px 4px 0 0;
-    box-shadow: 0 -4px 4px rgba($base-shadow-color, 0.1);
-  }
-
-  .advanced-options-dropdown__dropdown {
-    display: block;
-    box-shadow: 2px 4px 6px rgba($base-shadow-color, 0.1);
-  }
-}
-
-
-.search {
-  position: relative;
-  margin-bottom: 10px;
-}
-
-.search__input {
-  outline: 0;
-  box-sizing: border-box;
-  display: block;
-  width: 100%;
-  border: none;
-  padding: 10px;
-  padding-right: 30px;
-  font-family: inherit;
-  background: $ui-base-color;
-  color: $ui-primary-color;
-  font-size: 14px;
-  margin: 0;
-
-  &::-moz-focus-inner {
-    border: 0;
-  }
-
-  &::-moz-focus-inner,
-  &:focus,
-  &:active {
-    outline: 0 !important;
-  }
-
-  &:focus {
-    background: lighten($ui-base-color, 4%);
-  }
-
-  @include limited-single-column('screen and (max-width: 600px)') {
-    font-size: 16px;
-  }
-}
-
-.search__icon {
-  .fa {
-    position: absolute;
-    top: 10px;
-    right: 10px;
-    z-index: 2;
-    display: inline-block;
-    opacity: 0;
-    transition: all 100ms linear;
-    font-size: 18px;
-    width: 18px;
-    height: 18px;
-    color: $ui-secondary-color;
-    cursor: default;
-    pointer-events: none;
-
-    &.active {
-      pointer-events: auto;
-      opacity: 0.3;
-    }
-  }
-
-  .fa-search {
-    transform: rotate(90deg);
-
-    &.active {
-      pointer-events: none;
-      transform: rotate(0deg);
-    }
-  }
-
-  .fa-times-circle {
-    top: 11px;
-    transform: rotate(0deg);
-    cursor: pointer;
-
-    &.active {
-      transform: rotate(90deg);
-    }
-
-    &:hover {
-      color: $primary-text-color;
-    }
-  }
-}
-
-.search-results__header {
-  color: $ui-base-lighter-color;
-  background: lighten($ui-base-color, 2%);
-  border-bottom: 1px solid darken($ui-base-color, 4%);
-  padding: 15px 10px;
-  font-size: 14px;
-  font-weight: 500;
-}
-
-.search-results__section {
-  background: $ui-base-color;
-}
-
-.search-results__hashtag {
-  display: block;
-  padding: 10px;
-  color: $ui-secondary-color;
-  text-decoration: none;
-
-  &:hover,
-  &:active,
-  &:focus {
-    color: lighten($ui-secondary-color, 4%);
-    text-decoration: underline;
-  }
-}
-
 .modal-root {
   transition: opacity 0.3s linear;
   will-change: opacity;
@@ -4081,7 +3328,8 @@
   max-height: 80vh;
   max-width: 80vw;
 
-  .actions-modal__item-label {
+  strong {
+    display: block;
     font-weight: 500;
   }
 
@@ -4094,31 +3342,25 @@
     }
 
     li:not(:empty) {
-      a {
+      & > .link {
         color: $ui-base-color;
         display: flex;
         padding: 12px 16px;
         font-size: 15px;
         align-items: center;
         text-decoration: none;
-
-        &,
-        button {
-          transition: none;
-        }
+        transition: none;
 
         &.active,
         &:hover,
         &:active,
         &:focus {
-          &,
-          button {
-            background: $ui-highlight-color;
-            color: $primary-text-color;
-          }
+          background: $ui-highlight-color;
+          color: $primary-text-color;
         }
 
-        button:first-child {
+        & > .react-toggle,
+        & > .icon {
           margin-right: 10px;
         }
       }
@@ -4732,80 +3974,6 @@ noscript {
   100% { opacity: 1; }
 }
 
-@media screen and (max-width: 630px) and (max-height: 400px) {
-  $duration: 400ms;
-  $delay: 100ms;
-
-  .tabs-bar,
-  .search {
-    will-change: margin-top;
-    transition: margin-top $duration $delay;
-  }
-
-  .navigation-bar {
-    will-change: padding-bottom;
-    transition: padding-bottom $duration $delay;
-  }
-
-  .navigation-bar {
-    & > a:first-child {
-      will-change: margin-top, margin-left, width;
-      transition: margin-top $duration $delay, margin-left $duration ($duration + $delay);
-    }
-
-    & > .navigation-bar__profile-edit {
-      will-change: margin-top;
-      transition: margin-top $duration $delay;
-    }
-
-    & > .icon-button {
-      will-change: opacity;
-      transition: opacity $duration $delay;
-    }
-  }
-
-  .is-composing {
-    .tabs-bar,
-    .search {
-      margin-top: -50px;
-    }
-
-    .navigation-bar {
-      padding-bottom: 0;
-
-      & > a:first-child {
-        margin-top: -50px;
-        margin-left: -40px;
-      }
-
-      .navigation-bar__profile {
-        padding-top: 2px;
-      }
-
-      .navigation-bar__profile-edit {
-        position: absolute;
-        margin-top: -50px;
-      }
-
-      .icon-button {
-        pointer-events: auto;
-        opacity: 1;
-      }
-    }
-  }
-
-  // fixes for the navbar-under mode
-  .is-composing.navbar-under {
-    .search {
-      margin-top: -20px;
-      margin-bottom: -20px;
-      .search__icon {
-        display: none;
-      }
-    }
-  }
-}
-
 // more fixes for the navbar-under mode
 @mixin fix-margins-for-navbar-under {
   .tabs-bar {
@@ -4984,6 +4152,8 @@ noscript {
   }
 }
 
+@import 'composer';
 @import 'doodle';
+@import 'drawer';
 @import 'emoji_picker';
 @import 'local_settings';
diff --git a/app/javascript/flavours/glitch/theme.yml b/app/javascript/flavours/glitch/theme.yml
index 435fa2329..8ccd8fa65 100644
--- a/app/javascript/flavours/glitch/theme.yml
+++ b/app/javascript/flavours/glitch/theme.yml
@@ -11,8 +11,8 @@ pack:
   home:
     filename: packs/home.js
     preload:
+    - flavours/glitch/async/drawer
     - flavours/glitch/async/getting_started
-    - flavours/glitch/async/compose
     - flavours/glitch/async/home_timeline
     - flavours/glitch/async/notifications
   modal:
diff --git a/app/javascript/flavours/glitch/util/async-components.js b/app/javascript/flavours/glitch/util/async-components.js
index 5d21ccca2..b90f1b8c8 100644
--- a/app/javascript/flavours/glitch/util/async-components.js
+++ b/app/javascript/flavours/glitch/util/async-components.js
@@ -2,8 +2,8 @@ export function EmojiPicker () {
   return import(/* webpackChunkName: "flavours/glitch/async/emoji_picker" */'flavours/glitch/util/emoji/emoji_picker');
 }
 
-export function Compose () {
-  return import(/* webpackChunkName: "flavours/glitch/async/compose" */'flavours/glitch/features/compose');
+export function Drawer () {
+  return import(/* webpackChunkName: "flavours/glitch/async/drawer" */'flavours/glitch/features/drawer');
 }
 
 export function Notifications () {
diff --git a/app/javascript/flavours/glitch/util/dom_helpers.js b/app/javascript/flavours/glitch/util/dom_helpers.js
new file mode 100644
index 000000000..3e1f4a26d
--- /dev/null
+++ b/app/javascript/flavours/glitch/util/dom_helpers.js
@@ -0,0 +1,14 @@
+//  Package imports.
+import detectPassiveEvents from 'detect-passive-events';
+
+//  This will either be a passive lister options object (if passive
+//  events are supported), or `false`.
+export const withPassive = detectPassiveEvents.hasSupport ? { passive: true } : false;
+
+//  Focuses the root element.
+export function focusRoot () {
+  let e;
+  if (document && (e = document.querySelector('.ui')) && (e = e.parentElement)) {
+    e.focus();
+  }
+}
diff --git a/app/javascript/flavours/glitch/util/emoji/index.js b/app/javascript/flavours/glitch/util/emoji/index.js
index 31c3e14ca..c6416db2d 100644
--- a/app/javascript/flavours/glitch/util/emoji/index.js
+++ b/app/javascript/flavours/glitch/util/emoji/index.js
@@ -70,6 +70,7 @@ const emojify = (str, customEmojis = {}) => {
 };
 
 export default emojify;
+export { unicodeMapping };
 
 export const buildCustomEmojis = (customEmojis) => {
   const emojis = [];
diff --git a/app/javascript/flavours/glitch/util/initial_state.js b/app/javascript/flavours/glitch/util/initial_state.js
index 607d6b9b0..530bca7ef 100644
--- a/app/javascript/flavours/glitch/util/initial_state.js
+++ b/app/javascript/flavours/glitch/util/initial_state.js
@@ -18,5 +18,6 @@ export const boostModal = getMeta('boost_modal');
 export const favouriteModal = getMeta('favourite_modal');
 export const deleteModal = getMeta('delete_modal');
 export const me = getMeta('me');
+export const maxChars = getMeta('max_toot_chars') || 500;
 
 export default initialState;
diff --git a/app/javascript/flavours/glitch/util/react_helpers.js b/app/javascript/flavours/glitch/util/react_helpers.js
new file mode 100644
index 000000000..082a58e62
--- /dev/null
+++ b/app/javascript/flavours/glitch/util/react_helpers.js
@@ -0,0 +1,21 @@
+//  This function binds the given `handlers` to the `target`.
+export function assignHandlers (target, handlers) {
+  if (!target || !handlers) {
+    return;
+  }
+
+  //  We just bind each handler to the `target`.
+  const handle = target.handlers = {};
+  Object.keys(handlers).forEach(
+    key => handle[key] = handlers[key].bind(target)
+  );
+}
+
+//  This function only returns the component if the result of calling
+//  `test` with `data` is `true`.  Useful with funciton binding.
+export function conditionalRender (test, data, component) {
+  return test(data) ? component : null;
+}
+
+//  This object provides props to make the component not visible.
+export const hiddenComponent = { style: { display: 'none' } };
diff --git a/app/javascript/flavours/glitch/util/redux_helpers.js b/app/javascript/flavours/glitch/util/redux_helpers.js
new file mode 100644
index 000000000..8eb338da7
--- /dev/null
+++ b/app/javascript/flavours/glitch/util/redux_helpers.js
@@ -0,0 +1,8 @@
+import { injectIntl } from 'react-intl';
+import { connect } from 'react-redux';
+
+//  Connects a component.
+export function wrap (Component, mapStateToProps, mapDispatchToProps, options) {
+  const withIntl = typeof options === 'object' ? options.withIntl : !!options;
+  return (withIntl ? injectIntl : i => i)(connect(mapStateToProps, mapDispatchToProps)(Component));
+}
diff --git a/app/javascript/flavours/vanilla/names.yml b/app/javascript/flavours/vanilla/names.yml
index 94326f6ee..9a3184dee 100644
--- a/app/javascript/flavours/vanilla/names.yml
+++ b/app/javascript/flavours/vanilla/names.yml
@@ -6,3 +6,11 @@ en:
   skins:
     vanilla:
       default: Default
+en:
+  flavours:
+    vanilla:
+      description: Motyw używany przez instancje czystego Mastodona. Może nie obsługiwać wszystkich funkcji GlitchSoc.
+      name: Mastodon Vanilla
+  skins:
+    vanilla:
+      default: Domyślny
diff --git a/app/javascript/images/mastodon-getting-started.png b/app/javascript/images/mastodon-getting-started.png
deleted file mode 100644
index 8fe0df76a..000000000
--- a/app/javascript/images/mastodon-getting-started.png
+++ /dev/null
Binary files differdiff --git a/app/javascript/images/mastodon-ui.png b/app/javascript/images/mastodon-ui.png
new file mode 100644
index 000000000..a1fb642a0
--- /dev/null
+++ b/app/javascript/images/mastodon-ui.png
Binary files differdiff --git a/app/javascript/images/wave-compose-standalone.png b/app/javascript/images/wave-compose-standalone.png
new file mode 100644
index 000000000..287ee639b
--- /dev/null
+++ b/app/javascript/images/wave-compose-standalone.png
Binary files differdiff --git a/app/javascript/images/wave-drawer.png b/app/javascript/images/wave-drawer.png
new file mode 100644
index 000000000..ca9f9e1d8
--- /dev/null
+++ b/app/javascript/images/wave-drawer.png
Binary files differdiff --git a/app/javascript/images/wave-modal.png b/app/javascript/images/wave-modal.png
new file mode 100644
index 000000000..88818a6d7
--- /dev/null
+++ b/app/javascript/images/wave-modal.png
Binary files differdiff --git a/app/javascript/mastodon/actions/notifications.js b/app/javascript/mastodon/actions/notifications.js
index b24ac8b73..502690045 100644
--- a/app/javascript/mastodon/actions/notifications.js
+++ b/app/javascript/mastodon/actions/notifications.js
@@ -31,7 +31,7 @@ const fetchRelatedRelationships = (dispatch, notifications) => {
 
 const unescapeHTML = (html) => {
   const wrapper = document.createElement('div');
-  html = html.replace(/<br \/>|<br>|\n/, ' ');
+  html = html.replace(/<br \/>|<br>|\n/g, ' ');
   wrapper.innerHTML = html;
   return wrapper.textContent;
 };
diff --git a/app/javascript/mastodon/actions/push_notifications/index.js b/app/javascript/mastodon/actions/push_notifications/index.js
index 376b55b62..2ffec500a 100644
--- a/app/javascript/mastodon/actions/push_notifications/index.js
+++ b/app/javascript/mastodon/actions/push_notifications/index.js
@@ -15,9 +15,9 @@ export {
   register,
 };
 
-export function changeAlerts(key, value) {
+export function changeAlerts(path, value) {
   return dispatch => {
-    dispatch(setAlerts(key, value));
+    dispatch(setAlerts(path, value));
     dispatch(saveSettings());
   };
 }
diff --git a/app/javascript/mastodon/actions/push_notifications/registerer.js b/app/javascript/mastodon/actions/push_notifications/registerer.js
index f851c311c..1d040bc8c 100644
--- a/app/javascript/mastodon/actions/push_notifications/registerer.js
+++ b/app/javascript/mastodon/actions/push_notifications/registerer.js
@@ -51,7 +51,7 @@ const sendSubscriptionToBackend = (subscription, me) => {
 // Last one checks for payload support: https://web-push-book.gauntface.com/chapter-06/01-non-standards-browsers/#no-payload
 const supportsPushNotifications = ('serviceWorker' in navigator && 'PushManager' in window && 'getKey' in PushSubscription.prototype);
 
-export default function register () {
+export function register () {
   return (dispatch, getState) => {
     dispatch(setBrowserSupport(supportsPushNotifications));
     const me = getState().getIn(['meta', 'me']);
diff --git a/app/javascript/mastodon/actions/push_notifications/setter.js b/app/javascript/mastodon/actions/push_notifications/setter.js
index a2cc41c5a..5561766bf 100644
--- a/app/javascript/mastodon/actions/push_notifications/setter.js
+++ b/app/javascript/mastodon/actions/push_notifications/setter.js
@@ -23,11 +23,11 @@ export function clearSubscription () {
   };
 }
 
-export function setAlerts (key, value) {
+export function setAlerts (path, value) {
   return dispatch => {
     dispatch({
       type: SET_ALERTS,
-      key,
+      path,
       value,
     });
   };
diff --git a/app/javascript/mastodon/actions/settings.js b/app/javascript/mastodon/actions/settings.js
index 79adca18c..aeef43527 100644
--- a/app/javascript/mastodon/actions/settings.js
+++ b/app/javascript/mastodon/actions/settings.js
@@ -4,11 +4,11 @@ import { debounce } from 'lodash';
 export const SETTING_CHANGE = 'SETTING_CHANGE';
 export const SETTING_SAVE   = 'SETTING_SAVE';
 
-export function changeSetting(key, value) {
+export function changeSetting(path, value) {
   return dispatch => {
     dispatch({
       type: SETTING_CHANGE,
-      key,
+      path,
       value,
     });
 
@@ -21,7 +21,7 @@ const debouncedSave = debounce((dispatch, getState) => {
     return;
   }
 
-  const data = getState().get('settings').filter((_, key) => key !== 'saved').toJS();
+  const data = getState().get('settings').filter((_, path) => path !== 'saved').toJS();
 
   axios.put('/api/web/settings', { data }).then(() => dispatch({ type: SETTING_SAVE }));
 }, 5000, { trailing: true });
diff --git a/app/javascript/mastodon/features/compose/containers/warning_container.js b/app/javascript/mastodon/features/compose/containers/warning_container.js
index d34471a3e..b9f280958 100644
--- a/app/javascript/mastodon/features/compose/containers/warning_container.js
+++ b/app/javascript/mastodon/features/compose/containers/warning_container.js
@@ -5,20 +5,27 @@ import PropTypes from 'prop-types';
 import { FormattedMessage } from 'react-intl';
 import { me } from '../../../initial_state';
 
+const APPROX_HASHTAG_RE = /(?:^|[^\/\)\w])#(\S+)/i;
+
 const mapStateToProps = state => ({
   needsLockWarning: state.getIn(['compose', 'privacy']) === 'private' && !state.getIn(['accounts', me, 'locked']),
+  hashtagWarning: state.getIn(['compose', 'privacy']) !== 'public' && APPROX_HASHTAG_RE.test(state.getIn(['compose', 'text'])),
 });
 
-const WarningWrapper = ({ needsLockWarning }) => {
+const WarningWrapper = ({ needsLockWarning, hashtagWarning }) => {
   if (needsLockWarning) {
     return <Warning message={<FormattedMessage id='compose_form.lock_disclaimer' defaultMessage='Your account is not {locked}. Anyone can follow you to view your follower-only posts.' values={{ locked: <a href='/settings/profile'><FormattedMessage id='compose_form.lock_disclaimer.lock' defaultMessage='locked' /></a> }} />} />;
   }
+  if (hashtagWarning) {
+    return <Warning message={<FormattedMessage id='compose_form.hashtag_warning' defaultMessage="This toot won't be listed under any hashtag as it is unlisted. Only public toots can be searched by hashtag." />} />;
+  }
 
   return null;
 };
 
 WarningWrapper.propTypes = {
   needsLockWarning: PropTypes.bool,
+  hashtagWarning: PropTypes.bool,
 };
 
 export default connect(mapStateToProps)(WarningWrapper);
diff --git a/app/javascript/mastodon/features/compose/index.js b/app/javascript/mastodon/features/compose/index.js
index 0c66585c9..c3e936ab9 100644
--- a/app/javascript/mastodon/features/compose/index.js
+++ b/app/javascript/mastodon/features/compose/index.js
@@ -94,6 +94,7 @@ export default class Compose extends React.PureComponent {
           <div className='drawer__inner' onFocus={this.onFocus}>
             <NavigationContainer onClose={this.onBlur} />
             <ComposeFormContainer />
+            <div className='mastodon' />
           </div>
 
           <Motion defaultStyle={{ x: -100 }} style={{ x: spring(showSearch ? 0 : -100, { stiffness: 210, damping: 20 }) }}>
diff --git a/app/javascript/mastodon/features/getting_started/index.js b/app/javascript/mastodon/features/getting_started/index.js
index 11fb6d365..ee789e180 100644
--- a/app/javascript/mastodon/features/getting_started/index.js
+++ b/app/javascript/mastodon/features/getting_started/index.js
@@ -48,7 +48,7 @@ export default class GettingStarted extends ImmutablePureComponent {
   render () {
     const { intl, myAccount, columns, multiColumn } = this.props;
 
-    let navItems = [];
+    const navItems = [];
 
     if (multiColumn) {
       if (!columns.find(item => item.get('id') === 'HOME')) {
@@ -68,49 +68,45 @@ export default class GettingStarted extends ImmutablePureComponent {
       }
     }
 
-    navItems = navItems.concat([
+    navItems.push(
       <ColumnLink key='4' icon='star' text={intl.formatMessage(messages.favourites)} to='/favourites' />,
-      <ColumnLink key='5' icon='thumb-tack' text={intl.formatMessage(messages.pins)} to='/pinned' />,
-      <ColumnLink key='6' icon='bars' text={intl.formatMessage(messages.lists)} to='/lists' />,
-    ]);
+      <ColumnLink key='5' icon='bars' text={intl.formatMessage(messages.lists)} to='/lists' />
+    );
 
     if (myAccount.get('locked')) {
-      navItems.push(<ColumnLink key='7' icon='users' text={intl.formatMessage(messages.follow_requests)} to='/follow_requests' />);
+      navItems.push(<ColumnLink key='6' icon='users' text={intl.formatMessage(messages.follow_requests)} to='/follow_requests' />);
     }
 
-    navItems = navItems.concat([
-      <ColumnLink key='8' icon='volume-off' text={intl.formatMessage(messages.mutes)} to='/mutes' />,
-      <ColumnLink key='9' icon='ban' text={intl.formatMessage(messages.blocks)} to='/blocks' />,
-    ]);
-
     if (multiColumn) {
-      navItems.push(<ColumnLink key='10' icon='question' text={intl.formatMessage(messages.keyboard_shortcuts)} to='/keyboard-shortcuts' />);
+      navItems.push(<ColumnLink key='7' icon='question' text={intl.formatMessage(messages.keyboard_shortcuts)} to='/keyboard-shortcuts' />);
     }
 
+    navItems.push(<ColumnLink key='8' icon='book' text={intl.formatMessage(messages.info)} href='/about/more' />);
+
     return (
       <Column icon='asterisk' heading={intl.formatMessage(messages.heading)} hideHeadingOnMobile>
         <div className='getting-started__wrapper'>
           <ColumnSubheading text={intl.formatMessage(messages.navigation_subheading)} />
           {navItems}
           <ColumnSubheading text={intl.formatMessage(messages.settings_subheading)} />
-          <ColumnLink icon='book' text={intl.formatMessage(messages.info)} href='/about/more' />
+          <ColumnLink icon='thumb-tack' text={intl.formatMessage(messages.pins)} to='/pinned' />
+          <ColumnLink icon='volume-off' text={intl.formatMessage(messages.mutes)} to='/mutes' />
+          <ColumnLink icon='ban' text={intl.formatMessage(messages.blocks)} to='/blocks' />
           <ColumnLink icon='cog' text={intl.formatMessage(messages.preferences)} href='/settings/preferences' />
           <ColumnLink icon='sign-out' text={intl.formatMessage(messages.sign_out)} href='/auth/sign_out' method='delete' />
         </div>
 
-        <div className='getting-started__footer scrollable optionally-scrollable'>
-          <div className='static-content getting-started'>
-            <p>
-              <a href='https://github.com/tootsuite/documentation/blob/master/Using-Mastodon/FAQ.md' rel='noopener' target='_blank'><FormattedMessage id='getting_started.faq' defaultMessage='FAQ' /></a> • <a href='https://github.com/tootsuite/documentation/blob/master/Using-Mastodon/User-guide.md' rel='noopener' target='_blank'><FormattedMessage id='getting_started.userguide' defaultMessage='User Guide' /></a> • <a href='https://github.com/tootsuite/documentation/blob/master/Using-Mastodon/Apps.md' rel='noopener' target='_blank'><FormattedMessage id='getting_started.appsshort' defaultMessage='Apps' /></a>
-            </p>
-            <p>
-              <FormattedMessage
-                id='getting_started.open_source_notice'
-                defaultMessage='Mastodon is open source software. You can contribute or report issues on GitHub at {github}.'
-                values={{ github: <a href='https://github.com/tootsuite/mastodon' rel='noopener' target='_blank'>tootsuite/mastodon</a> }}
-              />
-            </p>
-          </div>
+        <div className='static-content getting-started'>
+          <p>
+            <a href='https://github.com/tootsuite/documentation/blob/master/Using-Mastodon/FAQ.md' rel='noopener' target='_blank'><FormattedMessage id='getting_started.faq' defaultMessage='FAQ' /></a> • <a href='https://github.com/tootsuite/documentation/blob/master/Using-Mastodon/User-guide.md' rel='noopener' target='_blank'><FormattedMessage id='getting_started.userguide' defaultMessage='User Guide' /></a> • <a href='https://github.com/tootsuite/documentation/blob/master/Using-Mastodon/Apps.md' rel='noopener' target='_blank'><FormattedMessage id='getting_started.appsshort' defaultMessage='Apps' /></a>
+          </p>
+          <p>
+            <FormattedMessage
+              id='getting_started.open_source_notice'
+              defaultMessage='Mastodon is open source software. You can contribute or report issues on GitHub at {github}.'
+              values={{ github: <a href='https://github.com/tootsuite/mastodon' rel='noopener' target='_blank'>tootsuite/mastodon</a> }}
+            />
+          </p>
         </div>
       </Column>
     );
diff --git a/app/javascript/mastodon/features/notifications/components/column_settings.js b/app/javascript/mastodon/features/notifications/components/column_settings.js
index 23545185c..d9638aaf3 100644
--- a/app/javascript/mastodon/features/notifications/components/column_settings.js
+++ b/app/javascript/mastodon/features/notifications/components/column_settings.js
@@ -14,8 +14,8 @@ export default class ColumnSettings extends React.PureComponent {
     onClear: PropTypes.func.isRequired,
   };
 
-  onPushChange = (key, checked) => {
-    this.props.onChange(['push', ...key], checked);
+  onPushChange = (path, checked) => {
+    this.props.onChange(['push', ...path], checked);
   }
 
   render () {
diff --git a/app/javascript/mastodon/features/notifications/containers/column_settings_container.js b/app/javascript/mastodon/features/notifications/containers/column_settings_container.js
index f4c63fee6..e9cef0a7b 100644
--- a/app/javascript/mastodon/features/notifications/containers/column_settings_container.js
+++ b/app/javascript/mastodon/features/notifications/containers/column_settings_container.js
@@ -18,11 +18,11 @@ const mapStateToProps = state => ({
 
 const mapDispatchToProps = (dispatch, { intl }) => ({
 
-  onChange (key, checked) {
-    if (key[0] === 'push') {
-      dispatch(changePushNotifications(key.slice(1), checked));
+  onChange (path, checked) {
+    if (path[0] === 'push') {
+      dispatch(changePushNotifications(path.slice(1), checked));
     } else {
-      dispatch(changeSetting(['notifications', ...key], checked));
+      dispatch(changeSetting(['notifications', ...path], checked));
     }
   },
 
diff --git a/app/javascript/mastodon/locales/ar.json b/app/javascript/mastodon/locales/ar.json
index d699a69df..f1bb465d9 100644
--- a/app/javascript/mastodon/locales/ar.json
+++ b/app/javascript/mastodon/locales/ar.json
@@ -50,6 +50,7 @@
   "column_header.unpin": "فك التدبيس",
   "column_subheading.navigation": "التصفح",
   "column_subheading.settings": "الإعدادات",
+  "compose_form.hashtag_warning": "This toot won't be listed under any hashtag as it is unlisted. Only public toots can be searched by hashtag.",
   "compose_form.lock_disclaimer": "حسابك ليس {locked}. يمكن لأي شخص متابعتك و عرض المنشورات.",
   "compose_form.lock_disclaimer.lock": "مقفل",
   "compose_form.placeholder": "فيمَ تفكّر؟",
@@ -213,6 +214,7 @@
   "search_popout.tips.user": "مستخدِم",
   "search_results.total": "{count, number} {count, plural, one {result} و {results}}",
   "standalone.public_title": "نظرة على ...",
+  "status.block": "Block @{name}",
   "status.cannot_reblog": "تعذرت ترقية هذا المنشور",
   "status.delete": "إحذف",
   "status.embed": "إدماج",
@@ -221,6 +223,7 @@
   "status.media_hidden": "الصورة مستترة",
   "status.mention": "أذكُر @{name}",
   "status.more": "المزيد",
+  "status.mute": "Mute @{name}",
   "status.mute_conversation": "كتم المحادثة",
   "status.open": "وسع هذه المشاركة",
   "status.pin": "تدبيس على الملف الشخصي",
diff --git a/app/javascript/mastodon/locales/bg.json b/app/javascript/mastodon/locales/bg.json
index 1c04b3bfa..c0a24dacb 100644
--- a/app/javascript/mastodon/locales/bg.json
+++ b/app/javascript/mastodon/locales/bg.json
@@ -50,6 +50,7 @@
   "column_header.unpin": "Unpin",
   "column_subheading.navigation": "Navigation",
   "column_subheading.settings": "Settings",
+  "compose_form.hashtag_warning": "This toot won't be listed under any hashtag as it is unlisted. Only public toots can be searched by hashtag.",
   "compose_form.lock_disclaimer": "Your account is not {locked}. Anyone can follow you to view your follower-only posts.",
   "compose_form.lock_disclaimer.lock": "locked",
   "compose_form.placeholder": "Какво си мислиш?",
@@ -213,6 +214,7 @@
   "search_popout.tips.user": "user",
   "search_results.total": "{count, number} {count, plural, one {result} other {results}}",
   "standalone.public_title": "A look inside...",
+  "status.block": "Block @{name}",
   "status.cannot_reblog": "This post cannot be boosted",
   "status.delete": "Изтриване",
   "status.embed": "Embed",
@@ -221,6 +223,7 @@
   "status.media_hidden": "Media hidden",
   "status.mention": "Споменаване",
   "status.more": "More",
+  "status.mute": "Mute @{name}",
   "status.mute_conversation": "Mute conversation",
   "status.open": "Expand this status",
   "status.pin": "Pin on profile",
diff --git a/app/javascript/mastodon/locales/ca.json b/app/javascript/mastodon/locales/ca.json
index 62d85a5e1..3d2fe2839 100644
--- a/app/javascript/mastodon/locales/ca.json
+++ b/app/javascript/mastodon/locales/ca.json
@@ -50,6 +50,7 @@
   "column_header.unpin": "Deslligar",
   "column_subheading.navigation": "Navegació",
   "column_subheading.settings": "Configuració",
+  "compose_form.hashtag_warning": "This toot won't be listed under any hashtag as it is unlisted. Only public toots can be searched by hashtag.",
   "compose_form.lock_disclaimer": "El teu compte no està bloquejat {locked}. Tothom pot seguir-te i veure els teus missatges a seguidors.",
   "compose_form.lock_disclaimer.lock": "bloquejat",
   "compose_form.placeholder": "En què estàs pensant?",
@@ -213,6 +214,7 @@
   "search_popout.tips.user": "usuari",
   "search_results.total": "{count, number} {count, plural, un {result} altres {results}}",
   "standalone.public_title": "Una mirada a l'interior ...",
+  "status.block": "Block @{name}",
   "status.cannot_reblog": "Aquesta publicació no pot ser retootejada",
   "status.delete": "Esborrar",
   "status.embed": "Incrustar",
@@ -221,6 +223,7 @@
   "status.media_hidden": "Multimèdia amagat",
   "status.mention": "Esmentar @{name}",
   "status.more": "Més",
+  "status.mute": "Mute @{name}",
   "status.mute_conversation": "Silenciar conversació",
   "status.open": "Ampliar aquest estat",
   "status.pin": "Fixat en el perfil",
diff --git a/app/javascript/mastodon/locales/de.json b/app/javascript/mastodon/locales/de.json
index 6354f18b6..9b6c857e4 100644
--- a/app/javascript/mastodon/locales/de.json
+++ b/app/javascript/mastodon/locales/de.json
@@ -50,6 +50,7 @@
   "column_header.unpin": "Lösen",
   "column_subheading.navigation": "Navigation",
   "column_subheading.settings": "Einstellungen",
+  "compose_form.hashtag_warning": "This toot won't be listed under any hashtag as it is unlisted. Only public toots can be searched by hashtag.",
   "compose_form.lock_disclaimer": "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?",
@@ -213,6 +214,7 @@
   "search_popout.tips.user": "user",
   "search_results.total": "{count, number} {count, plural, one {Ergebnis} other {Ergebnisse}}",
   "standalone.public_title": "Ein kleiner Einblick …",
+  "status.block": "Block @{name}",
   "status.cannot_reblog": "Dieser Beitrag kann nicht geteilt werden",
   "status.delete": "Löschen",
   "status.embed": "Einbetten",
@@ -221,6 +223,7 @@
   "status.media_hidden": "Medien versteckt",
   "status.mention": "@{name} erwähnen",
   "status.more": "Mehr",
+  "status.mute": "Mute @{name}",
   "status.mute_conversation": "Thread stummschalten",
   "status.open": "Diesen Beitrag öffnen",
   "status.pin": "Im Profil anheften",
diff --git a/app/javascript/mastodon/locales/defaultMessages.json b/app/javascript/mastodon/locales/defaultMessages.json
index 65e20c17a..acf051de5 100644
--- a/app/javascript/mastodon/locales/defaultMessages.json
+++ b/app/javascript/mastodon/locales/defaultMessages.json
@@ -727,6 +727,10 @@
       {
         "defaultMessage": "locked",
         "id": "compose_form.lock_disclaimer.lock"
+      },
+      {
+        "defaultMessage": "This toot won't be listed under any hashtag as it is unlisted. Only public toots can be searched by hashtag.",
+        "id": "compose_form.hashtag_warning"
       }
     ],
     "path": "app/javascript/mastodon/features/compose/containers/warning_container.json"
@@ -1053,7 +1057,7 @@
         "id": "lists.delete"
       },
       {
-        "defaultMessage": "There is nothing in this list yet.",
+        "defaultMessage": "There is nothing in this list yet. When members of this list post new statuses, they will appear here.",
         "id": "empty_column.list"
       }
     ],
@@ -1245,6 +1249,22 @@
         "id": "status.favourite"
       },
       {
+        "defaultMessage": "Mute @{name}",
+        "id": "status.mute"
+      },
+      {
+        "defaultMessage": "Mute conversation",
+        "id": "status.mute_conversation"
+      },
+      {
+        "defaultMessage": "Unmute conversation",
+        "id": "status.unmute_conversation"
+      },
+      {
+        "defaultMessage": "Block @{name}",
+        "id": "status.block"
+      },
+      {
         "defaultMessage": "Report @{name}",
         "id": "status.report"
       },
@@ -1276,6 +1296,14 @@
       {
         "defaultMessage": "Are you sure you want to delete this status?",
         "id": "confirmations.delete.message"
+      },
+      {
+        "defaultMessage": "Block",
+        "id": "confirmations.block.confirm"
+      },
+      {
+        "defaultMessage": "Are you sure you want to block {name}?",
+        "id": "confirmations.block.message"
       }
     ],
     "path": "app/javascript/mastodon/features/status/index.json"
diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json
index 098bebf8f..b1dbaa698 100644
--- a/app/javascript/mastodon/locales/en.json
+++ b/app/javascript/mastodon/locales/en.json
@@ -54,6 +54,7 @@
   "column_subheading.lists": "Lists",
   "column_subheading.navigation": "Navigation",
   "column_subheading.settings": "Settings",
+  "compose_form.hashtag_warning": "This toot won't be listed under any hashtag as it is unlisted. Only public toots can be searched by hashtag.",
   "compose_form.lock_disclaimer": "Your account is not {locked}. Anyone can follow you to view your follower-only posts.",
   "compose_form.lock_disclaimer.lock": "locked",
   "compose_form.placeholder": "What is on your mind?",
@@ -220,6 +221,7 @@
   "search_popout.tips.user": "user",
   "search_results.total": "{count, number} {count, plural, one {result} other {results}}",
   "standalone.public_title": "A look inside...",
+  "status.block": "Block @{name}",
   "status.cannot_reblog": "This post cannot be boosted",
   "status.delete": "Delete",
   "status.embed": "Embed",
@@ -228,6 +230,7 @@
   "status.media_hidden": "Media hidden",
   "status.mention": "Mention @{name}",
   "status.more": "More",
+  "status.mute": "Mute @{name}",
   "status.mute_conversation": "Mute conversation",
   "status.open": "Expand this status",
   "status.pin": "Pin on profile",
diff --git a/app/javascript/mastodon/locales/eo.json b/app/javascript/mastodon/locales/eo.json
index 9e66c379f..eab8c09a6 100644
--- a/app/javascript/mastodon/locales/eo.json
+++ b/app/javascript/mastodon/locales/eo.json
@@ -50,6 +50,7 @@
   "column_header.unpin": "Depingli",
   "column_subheading.navigation": "Navigado",
   "column_subheading.settings": "Agordoj",
+  "compose_form.hashtag_warning": "This toot won't be listed under any hashtag as it is unlisted. Only public toots can be searched by hashtag.",
   "compose_form.lock_disclaimer": "Via konta ne estas ŝlosita. Iu ajn povas sekvi vin por vidi viajn privatajn pepojn.",
   "compose_form.lock_disclaimer.lock": "ŝlosita",
   "compose_form.placeholder": "Pri kio vi pensas?",
@@ -213,6 +214,7 @@
   "search_popout.tips.user": "uzanto",
   "search_results.total": "{count, number} {count, plural, one {rezultato} other {rezultatoj}}",
   "standalone.public_title": "Rigardeti…",
+  "status.block": "Block @{name}",
   "status.cannot_reblog": "Tiun publikaĵon oni ne povas diskonigi",
   "status.delete": "Forigi",
   "status.embed": "Enmeti",
@@ -221,6 +223,7 @@
   "status.media_hidden": "Sonbildaĵo kaŝita",
   "status.mention": "Mencii @{name}",
   "status.more": "Pli",
+  "status.mute": "Mute @{name}",
   "status.mute_conversation": "Silentigi konversacion",
   "status.open": "Disfaldi statkonigon",
   "status.pin": "Pingli al la profilo",
diff --git a/app/javascript/mastodon/locales/es.json b/app/javascript/mastodon/locales/es.json
index 6122a79ab..8a8110b1e 100644
--- a/app/javascript/mastodon/locales/es.json
+++ b/app/javascript/mastodon/locales/es.json
@@ -50,6 +50,7 @@
   "column_header.unpin": "Dejar de fijar",
   "column_subheading.navigation": "Navegación",
   "column_subheading.settings": "Ajustes",
+  "compose_form.hashtag_warning": "This toot won't be listed under any hashtag as it is unlisted. Only public toots can be searched by hashtag.",
   "compose_form.lock_disclaimer": "Tu cuenta no está bloqueada. Todos pueden seguirte para ver tus toots solo para seguidores.",
   "compose_form.lock_disclaimer.lock": "bloqueado",
   "compose_form.placeholder": "¿En qué estás pensando?",
@@ -213,6 +214,7 @@
   "search_popout.tips.user": "usuario",
   "search_results.total": "{count, number} {count, plural, one {resultado} other {resultados}}",
   "standalone.public_title": "Un pequeño vistazo...",
+  "status.block": "Block @{name}",
   "status.cannot_reblog": "Este toot no puede retootearse",
   "status.delete": "Borrar",
   "status.embed": "Incrustado",
@@ -221,6 +223,7 @@
   "status.media_hidden": "Contenido multimedia oculto",
   "status.mention": "Mencionar",
   "status.more": "Más",
+  "status.mute": "Mute @{name}",
   "status.mute_conversation": "Silenciar conversación",
   "status.open": "Expandir estado",
   "status.pin": "Fijar",
diff --git a/app/javascript/mastodon/locales/fa.json b/app/javascript/mastodon/locales/fa.json
index 75057a7dd..f6c6f5ced 100644
--- a/app/javascript/mastodon/locales/fa.json
+++ b/app/javascript/mastodon/locales/fa.json
@@ -50,6 +50,7 @@
   "column_header.unpin": "رهاکردن",
   "column_subheading.navigation": "گشت و گذار",
   "column_subheading.settings": "تنظیمات",
+  "compose_form.hashtag_warning": "This toot won't be listed under any hashtag as it is unlisted. Only public toots can be searched by hashtag.",
   "compose_form.lock_disclaimer": "حساب شما {locked} نیست. هر کسی می‌تواند پیگیر شما شود و نوشته‌های ویژهٔ پیگیران شما را ببیند.",
   "compose_form.lock_disclaimer.lock": "قفل",
   "compose_form.placeholder": "تازه چه خبر؟",
@@ -213,6 +214,7 @@
   "search_popout.tips.user": "کاربر",
   "search_results.total": "{count, number} {count, plural, one {نتیجه} other {نتیجه}}",
   "standalone.public_title": "نگاهی به کاربران این سرور...",
+  "status.block": "Block @{name}",
   "status.cannot_reblog": "این نوشته را نمی‌شود بازبوقید",
   "status.delete": "پاک‌کردن",
   "status.embed": "جاگذاری",
@@ -221,6 +223,7 @@
   "status.media_hidden": "تصویر پنهان شده",
   "status.mention": "نام‌بردن از @{name}",
   "status.more": "More",
+  "status.mute": "Mute @{name}",
   "status.mute_conversation": "بی‌صداکردن گفتگو",
   "status.open": "این نوشته را باز کن",
   "status.pin": "نوشتهٔ ثابت نمایه",
diff --git a/app/javascript/mastodon/locales/fi.json b/app/javascript/mastodon/locales/fi.json
index 4ddc1cca7..74ab699c4 100644
--- a/app/javascript/mastodon/locales/fi.json
+++ b/app/javascript/mastodon/locales/fi.json
@@ -50,6 +50,7 @@
   "column_header.unpin": "Unpin",
   "column_subheading.navigation": "Navigation",
   "column_subheading.settings": "Settings",
+  "compose_form.hashtag_warning": "This toot won't be listed under any hashtag as it is unlisted. Only public toots can be searched by hashtag.",
   "compose_form.lock_disclaimer": "Your account is not {locked}. Anyone can follow you to view your follower-only posts.",
   "compose_form.lock_disclaimer.lock": "locked",
   "compose_form.placeholder": "Mitä sinulla on mielessä?",
@@ -213,6 +214,7 @@
   "search_popout.tips.user": "user",
   "search_results.total": "{count, number} {count, plural, one {result} other {results}}",
   "standalone.public_title": "A look inside...",
+  "status.block": "Block @{name}",
   "status.cannot_reblog": "This post cannot be boosted",
   "status.delete": "Poista",
   "status.embed": "Embed",
@@ -221,6 +223,7 @@
   "status.media_hidden": "Media hidden",
   "status.mention": "Mainitse @{name}",
   "status.more": "More",
+  "status.mute": "Mute @{name}",
   "status.mute_conversation": "Mute conversation",
   "status.open": "Expand this status",
   "status.pin": "Pin on profile",
diff --git a/app/javascript/mastodon/locales/fr.json b/app/javascript/mastodon/locales/fr.json
index ecfff87c8..e77107fc5 100644
--- a/app/javascript/mastodon/locales/fr.json
+++ b/app/javascript/mastodon/locales/fr.json
@@ -50,6 +50,7 @@
   "column_header.unpin": "Retirer",
   "column_subheading.navigation": "Navigation",
   "column_subheading.settings": "Paramètres",
+  "compose_form.hashtag_warning": "This toot won't be listed under any hashtag as it is unlisted. Only public toots can be searched by hashtag.",
   "compose_form.lock_disclaimer": "Votre compte n’est pas {locked}. Tout le monde peut vous suivre et voir vos pouets privés.",
   "compose_form.lock_disclaimer.lock": "verrouillé",
   "compose_form.placeholder": "Qu’avez-vous en tête ?",
@@ -213,6 +214,7 @@
   "search_popout.tips.user": "utilisateur⋅ice",
   "search_results.total": "{count, number} {count, plural, one {résultat} other {résultats}}",
   "standalone.public_title": "Jeter un coup d’œil…",
+  "status.block": "Block @{name}",
   "status.cannot_reblog": "Cette publication ne peut être boostée",
   "status.delete": "Effacer",
   "status.embed": "Intégrer",
@@ -221,6 +223,7 @@
   "status.media_hidden": "Média caché",
   "status.mention": "Mentionner",
   "status.more": "Plus",
+  "status.mute": "Mute @{name}",
   "status.mute_conversation": "Masquer la conversation",
   "status.open": "Déplier ce statut",
   "status.pin": "Épingler sur le profil",
diff --git a/app/javascript/mastodon/locales/gl.json b/app/javascript/mastodon/locales/gl.json
index 6398daa11..523dcc924 100644
--- a/app/javascript/mastodon/locales/gl.json
+++ b/app/javascript/mastodon/locales/gl.json
@@ -50,6 +50,7 @@
   "column_header.unpin": "Soltar",
   "column_subheading.navigation": "Navegación",
   "column_subheading.settings": "Axustes",
+  "compose_form.hashtag_warning": "This toot won't be listed under any hashtag as it is unlisted. Only public toots can be searched by hashtag.",
   "compose_form.lock_disclaimer": "A súa conta non está {locked}. Calquera pode seguila para ver as súas mensaxes só-para-seguidoras.",
   "compose_form.lock_disclaimer.lock": "bloqueado",
   "compose_form.placeholder": "A qué andas?",
@@ -213,6 +214,7 @@
   "search_popout.tips.user": "usuaria",
   "search_results.total": "{count, number} {count,plural,one {result} outros {results}}",
   "standalone.public_title": "Ollada dentro...",
+  "status.block": "Block @{name}",
   "status.cannot_reblog": "Esta mensaxe non pode ser promocionada",
   "status.delete": "Eliminar",
   "status.embed": "Incrustar",
@@ -221,6 +223,7 @@
   "status.media_hidden": "Medios ocultos",
   "status.mention": "Mencionar @{name}",
   "status.more": "Máis",
+  "status.mute": "Mute @{name}",
   "status.mute_conversation": "Acalar conversa",
   "status.open": "Expandir este estado",
   "status.pin": "Fixar no perfil",
diff --git a/app/javascript/mastodon/locales/he.json b/app/javascript/mastodon/locales/he.json
index 5444c8e34..2eb186173 100644
--- a/app/javascript/mastodon/locales/he.json
+++ b/app/javascript/mastodon/locales/he.json
@@ -50,6 +50,7 @@
   "column_header.unpin": "שחרור קיבוע",
   "column_subheading.navigation": "ניווט",
   "column_subheading.settings": "אפשרויות",
+  "compose_form.hashtag_warning": "This toot won't be listed under any hashtag as it is unlisted. Only public toots can be searched by hashtag.",
   "compose_form.lock_disclaimer": "חשבונך אינו {locked}. כל אחד יוכל לעקוב אחריך כדי לקרוא את הודעותיך המיועדות לעוקבים בלבד.",
   "compose_form.lock_disclaimer.lock": "נעול",
   "compose_form.placeholder": "מה עובר לך בראש?",
@@ -213,6 +214,7 @@
   "search_popout.tips.user": "משתמש(ת)",
   "search_results.total": "{count, number} {count, plural, one {תוצאה} other {תוצאות}}",
   "standalone.public_title": "הצצה פנימה...",
+  "status.block": "Block @{name}",
   "status.cannot_reblog": "לא ניתן להדהד הודעה זו",
   "status.delete": "מחיקה",
   "status.embed": "הטמעה",
@@ -221,6 +223,7 @@
   "status.media_hidden": "מדיה מוסתרת",
   "status.mention": "פניה אל @{name}",
   "status.more": "עוד",
+  "status.mute": "Mute @{name}",
   "status.mute_conversation": "השתקת שיחה",
   "status.open": "הרחבת הודעה",
   "status.pin": "לקבע באודות",
diff --git a/app/javascript/mastodon/locales/hr.json b/app/javascript/mastodon/locales/hr.json
index f70c66223..00dea67f7 100644
--- a/app/javascript/mastodon/locales/hr.json
+++ b/app/javascript/mastodon/locales/hr.json
@@ -50,6 +50,7 @@
   "column_header.unpin": "Unpin",
   "column_subheading.navigation": "Navigacija",
   "column_subheading.settings": "Postavke",
+  "compose_form.hashtag_warning": "This toot won't be listed under any hashtag as it is unlisted. Only public toots can be searched by hashtag.",
   "compose_form.lock_disclaimer": "Tvoj račun nije {locked}. Svatko te može slijediti kako bi vidio postove namijenjene samo tvojim sljedbenicima.",
   "compose_form.lock_disclaimer.lock": "zaključan",
   "compose_form.placeholder": "Što ti je na umu?",
@@ -213,6 +214,7 @@
   "search_popout.tips.user": "user",
   "search_results.total": "{count, number} {count, plural, one {result} other {results}}",
   "standalone.public_title": "A look inside...",
+  "status.block": "Block @{name}",
   "status.cannot_reblog": "Ovaj post ne može biti boostan",
   "status.delete": "Obriši",
   "status.embed": "Embed",
@@ -221,6 +223,7 @@
   "status.media_hidden": "Sakriven media sadržaj",
   "status.mention": "Spomeni @{name}",
   "status.more": "More",
+  "status.mute": "Mute @{name}",
   "status.mute_conversation": "Utišaj razgovor",
   "status.open": "Proširi ovaj status",
   "status.pin": "Pin on profile",
diff --git a/app/javascript/mastodon/locales/hu.json b/app/javascript/mastodon/locales/hu.json
index 7cb816fe9..e1048519b 100644
--- a/app/javascript/mastodon/locales/hu.json
+++ b/app/javascript/mastodon/locales/hu.json
@@ -50,6 +50,7 @@
   "column_header.unpin": "Unpin",
   "column_subheading.navigation": "Navigation",
   "column_subheading.settings": "Settings",
+  "compose_form.hashtag_warning": "This toot won't be listed under any hashtag as it is unlisted. Only public toots can be searched by hashtag.",
   "compose_form.lock_disclaimer": "Your account is not {locked}. Anyone can follow you to view your follower-only posts.",
   "compose_form.lock_disclaimer.lock": "locked",
   "compose_form.placeholder": "Mire gondolsz?",
@@ -213,6 +214,7 @@
   "search_popout.tips.user": "user",
   "search_results.total": "{count, number} {count, plural, one {result} other {results}}",
   "standalone.public_title": "A look inside...",
+  "status.block": "Block @{name}",
   "status.cannot_reblog": "This post cannot be boosted",
   "status.delete": "Törlés",
   "status.embed": "Embed",
@@ -221,6 +223,7 @@
   "status.media_hidden": "Media hidden",
   "status.mention": "Említés",
   "status.more": "More",
+  "status.mute": "Mute @{name}",
   "status.mute_conversation": "Mute conversation",
   "status.open": "Expand this status",
   "status.pin": "Pin on profile",
diff --git a/app/javascript/mastodon/locales/id.json b/app/javascript/mastodon/locales/id.json
index 429b77182..0942bc33c 100644
--- a/app/javascript/mastodon/locales/id.json
+++ b/app/javascript/mastodon/locales/id.json
@@ -50,6 +50,7 @@
   "column_header.unpin": "Unpin",
   "column_subheading.navigation": "Navigasi",
   "column_subheading.settings": "Pengaturan",
+  "compose_form.hashtag_warning": "This toot won't be listed under any hashtag as it is unlisted. Only public toots can be searched by hashtag.",
   "compose_form.lock_disclaimer": "Akun anda tidak {locked}. Semua orang dapat mengikuti anda untuk melihat postingan khusus untuk pengikut anda.",
   "compose_form.lock_disclaimer.lock": "dikunci",
   "compose_form.placeholder": "Apa yang ada di pikiran anda?",
@@ -213,6 +214,7 @@
   "search_popout.tips.user": "user",
   "search_results.total": "{count} {count, plural, one {hasil} other {hasil}}",
   "standalone.public_title": "A look inside...",
+  "status.block": "Block @{name}",
   "status.cannot_reblog": "This post cannot be boosted",
   "status.delete": "Hapus",
   "status.embed": "Embed",
@@ -221,6 +223,7 @@
   "status.media_hidden": "Media disembunyikan",
   "status.mention": "Balasan @{name}",
   "status.more": "More",
+  "status.mute": "Mute @{name}",
   "status.mute_conversation": "Mute conversation",
   "status.open": "Tampilkan status ini",
   "status.pin": "Pin on profile",
diff --git a/app/javascript/mastodon/locales/io.json b/app/javascript/mastodon/locales/io.json
index 3e5c8edb9..cfd8e299f 100644
--- a/app/javascript/mastodon/locales/io.json
+++ b/app/javascript/mastodon/locales/io.json
@@ -50,6 +50,7 @@
   "column_header.unpin": "Unpin",
   "column_subheading.navigation": "Navigation",
   "column_subheading.settings": "Settings",
+  "compose_form.hashtag_warning": "This toot won't be listed under any hashtag as it is unlisted. Only public toots can be searched by hashtag.",
   "compose_form.lock_disclaimer": "Your account is not {locked}. Anyone can follow you to view your follower-only posts.",
   "compose_form.lock_disclaimer.lock": "locked",
   "compose_form.placeholder": "Quo esas en tua spirito?",
@@ -213,6 +214,7 @@
   "search_popout.tips.user": "user",
   "search_results.total": "{count, number} {count, plural, one {rezulto} other {rezulti}}",
   "standalone.public_title": "A look inside...",
+  "status.block": "Block @{name}",
   "status.cannot_reblog": "This post cannot be boosted",
   "status.delete": "Efacar",
   "status.embed": "Embed",
@@ -221,6 +223,7 @@
   "status.media_hidden": "Kontenajo celita",
   "status.mention": "Mencionar @{name}",
   "status.more": "More",
+  "status.mute": "Mute @{name}",
   "status.mute_conversation": "Mute conversation",
   "status.open": "Detaligar ca mesajo",
   "status.pin": "Pin on profile",
diff --git a/app/javascript/mastodon/locales/it.json b/app/javascript/mastodon/locales/it.json
index e2ad1632a..e14fa410c 100644
--- a/app/javascript/mastodon/locales/it.json
+++ b/app/javascript/mastodon/locales/it.json
@@ -50,6 +50,7 @@
   "column_header.unpin": "Unpin",
   "column_subheading.navigation": "Navigation",
   "column_subheading.settings": "Settings",
+  "compose_form.hashtag_warning": "This toot won't be listed under any hashtag as it is unlisted. Only public toots can be searched by hashtag.",
   "compose_form.lock_disclaimer": "Your account is not {locked}. Anyone can follow you to view your follower-only posts.",
   "compose_form.lock_disclaimer.lock": "locked",
   "compose_form.placeholder": "A cosa stai pensando?",
@@ -213,6 +214,7 @@
   "search_popout.tips.user": "user",
   "search_results.total": "{count} {count, plural, one {risultato} other {risultati}}",
   "standalone.public_title": "A look inside...",
+  "status.block": "Block @{name}",
   "status.cannot_reblog": "This post cannot be boosted",
   "status.delete": "Elimina",
   "status.embed": "Embed",
@@ -221,6 +223,7 @@
   "status.media_hidden": "Allegato nascosto",
   "status.mention": "Nomina @{name}",
   "status.more": "More",
+  "status.mute": "Mute @{name}",
   "status.mute_conversation": "Mute conversation",
   "status.open": "Espandi questo post",
   "status.pin": "Pin on profile",
diff --git a/app/javascript/mastodon/locales/ja.json b/app/javascript/mastodon/locales/ja.json
index 614ef8815..19885056c 100644
--- a/app/javascript/mastodon/locales/ja.json
+++ b/app/javascript/mastodon/locales/ja.json
@@ -54,6 +54,7 @@
   "column_subheading.lists": "リスト",
   "column_subheading.navigation": "ナビゲーション",
   "column_subheading.settings": "設定",
+  "compose_form.hashtag_warning": "このトゥートは未収載なのでハッシュタグの一覧に表示されません。公開トゥートだけがハッシュタグで検索できます。",
   "compose_form.lock_disclaimer": "あなたのアカウントは{locked}になっていません。誰でもあなたをフォローすることができ、フォロワー限定の投稿を見ることができます。",
   "compose_form.lock_disclaimer.lock": "非公開",
   "compose_form.placeholder": "今なにしてる?",
@@ -96,7 +97,7 @@
   "empty_column.hashtag": "このハッシュタグはまだ使われていません。",
   "empty_column.home": "まだ誰もフォローしていません。{public}を見に行くか、検索を使って他のユーザーを見つけましょう。",
   "empty_column.home.public_timeline": "連合タイムライン",
-  "empty_column.list": "このリストにはまだなにもありません。",
+  "empty_column.list": "このリストにはまだなにもありません。このリストのメンバーが新しいトゥートをするとここに表示されます。",
   "empty_column.notifications": "まだ通知がありません。他の人とふれ合って会話を始めましょう。",
   "empty_column.public": "ここにはまだ何もありません! 公開で何かを投稿したり、他のインスタンスのユーザーをフォローしたりしていっぱいにしましょう",
   "follow_request.authorize": "許可",
@@ -220,6 +221,7 @@
   "search_popout.tips.user": "ユーザー",
   "search_results.total": "{count, number}件の結果",
   "standalone.public_title": "今こんな話をしています...",
+  "status.block": "@{name}をブロック",
   "status.cannot_reblog": "この投稿はブーストできません",
   "status.delete": "削除",
   "status.embed": "埋め込み",
@@ -228,6 +230,7 @@
   "status.media_hidden": "非表示のメディア",
   "status.mention": "返信",
   "status.more": "もっと見る",
+  "status.mute": "@{name}をミュート",
   "status.mute_conversation": "会話をミュート",
   "status.open": "詳細を表示",
   "status.pin": "プロフィールに固定表示",
diff --git a/app/javascript/mastodon/locales/ko.json b/app/javascript/mastodon/locales/ko.json
index 0798fa7cf..321e3ce47 100644
--- a/app/javascript/mastodon/locales/ko.json
+++ b/app/javascript/mastodon/locales/ko.json
@@ -50,6 +50,7 @@
   "column_header.unpin": "고정 해제",
   "column_subheading.navigation": "내비게이션",
   "column_subheading.settings": "설정",
+  "compose_form.hashtag_warning": "This toot won't be listed under any hashtag as it is unlisted. Only public toots can be searched by hashtag.",
   "compose_form.lock_disclaimer": "이 계정은 {locked}로 설정 되어 있지 않습니다. 누구나 이 계정을 팔로우 할 수 있으며, 팔로워 공개의 포스팅을 볼 수 있습니다.",
   "compose_form.lock_disclaimer.lock": "비공개",
   "compose_form.placeholder": "지금 무엇을 하고 있나요?",
@@ -213,6 +214,7 @@
   "search_popout.tips.user": "유저",
   "search_results.total": "{count, number}건의 결과",
   "standalone.public_title": "A look inside...",
+  "status.block": "@{name} 차단",
   "status.cannot_reblog": "이 포스트는 부스트 할 수 없습니다",
   "status.delete": "삭제",
   "status.embed": "공유하기",
@@ -221,6 +223,7 @@
   "status.media_hidden": "미디어 숨겨짐",
   "status.mention": "답장",
   "status.more": "자세히",
+  "status.mute": "@{name} 뮤트",
   "status.mute_conversation": "이 대화를 뮤트",
   "status.open": "상세 정보 표시",
   "status.pin": "고정",
diff --git a/app/javascript/mastodon/locales/nl.json b/app/javascript/mastodon/locales/nl.json
index e154d1ab2..f85cc75c5 100644
--- a/app/javascript/mastodon/locales/nl.json
+++ b/app/javascript/mastodon/locales/nl.json
@@ -50,6 +50,7 @@
   "column_header.unpin": "Losmaken",
   "column_subheading.navigation": "Navigatie",
   "column_subheading.settings": "Instellingen",
+  "compose_form.hashtag_warning": "This toot won't be listed under any hashtag as it is unlisted. Only public toots can be searched by hashtag.",
   "compose_form.lock_disclaimer": "Jouw account is niet {locked}. Iedereen kan jou volgen en toots zien die je alleen aan volgers hebt gericht.",
   "compose_form.lock_disclaimer.lock": "besloten",
   "compose_form.placeholder": "Wat wil je kwijt?",
@@ -213,6 +214,7 @@
   "search_popout.tips.user": "gebruiker",
   "search_results.total": "{count, number} {count, plural, one {resultaat} other {resultaten}}",
   "standalone.public_title": "Een kijkje binnenin...",
+  "status.block": "Block @{name}",
   "status.cannot_reblog": "Deze toot kan niet geboost worden",
   "status.delete": "Verwijderen",
   "status.embed": "Embed",
@@ -221,6 +223,7 @@
   "status.media_hidden": "Media verborgen",
   "status.mention": "Vermeld @{name}",
   "status.more": "Meer",
+  "status.mute": "Mute @{name}",
   "status.mute_conversation": "Negeer conversatie",
   "status.open": "Toot volledig tonen",
   "status.pin": "Aan profielpagina vastmaken",
diff --git a/app/javascript/mastodon/locales/no.json b/app/javascript/mastodon/locales/no.json
index bf2b6259a..5fbc51ff3 100644
--- a/app/javascript/mastodon/locales/no.json
+++ b/app/javascript/mastodon/locales/no.json
@@ -50,6 +50,7 @@
   "column_header.unpin": "Løsne",
   "column_subheading.navigation": "Navigasjon",
   "column_subheading.settings": "Innstillinger",
+  "compose_form.hashtag_warning": "This toot won't be listed under any hashtag as it is unlisted. Only public toots can be searched by hashtag.",
   "compose_form.lock_disclaimer": "Din konto er ikke {locked}. Hvem som helst kan følge deg og se dine private poster.",
   "compose_form.lock_disclaimer.lock": "låst",
   "compose_form.placeholder": "Hva har du på hjertet?",
@@ -213,6 +214,7 @@
   "search_popout.tips.user": "user",
   "search_results.total": "{count, number} {count, plural, one {resultat} other {resultater}}",
   "standalone.public_title": "A look inside...",
+  "status.block": "Block @{name}",
   "status.cannot_reblog": "Denne posten kan ikke fremheves",
   "status.delete": "Slett",
   "status.embed": "Embed",
@@ -221,6 +223,7 @@
   "status.media_hidden": "Media skjult",
   "status.mention": "Nevn @{name}",
   "status.more": "More",
+  "status.mute": "Mute @{name}",
   "status.mute_conversation": "Demp samtale",
   "status.open": "Utvid denne statusen",
   "status.pin": "Pin on profile",
diff --git a/app/javascript/mastodon/locales/oc.json b/app/javascript/mastodon/locales/oc.json
index 0d1f7c971..6ebd40f5b 100644
--- a/app/javascript/mastodon/locales/oc.json
+++ b/app/javascript/mastodon/locales/oc.json
@@ -50,6 +50,7 @@
   "column_header.unpin": "Despenjar",
   "column_subheading.navigation": "Navigacion",
   "column_subheading.settings": "Paramètres",
+  "compose_form.hashtag_warning": "Aqueste tut serà pas ligat a cap etiqueta estant qu’es pas listat. Òm pas cercar que los tuts publics per etiqueta.",
   "compose_form.lock_disclaimer": "Vòstre compte es pas {locked}. Tot lo mond pòt vos sègre e veire los estatuts reservats als seguidors.",
   "compose_form.lock_disclaimer.lock": "clavat",
   "compose_form.placeholder": "A de qué pensatz ?",
@@ -213,6 +214,7 @@
   "search_popout.tips.user": "utilizaire",
   "search_results.total": "{count, number} {count, plural, one {resultat} other {resultats}}",
   "standalone.public_title": "Una ulhada dedins…",
+  "status.block": "Blocar @{name}",
   "status.cannot_reblog": "Aqueste estatut pòt pas èsser partejat",
   "status.delete": "Escafar",
   "status.embed": "Embarcar",
@@ -221,6 +223,7 @@
   "status.media_hidden": "Mèdia rescondut",
   "status.mention": "Mencionar",
   "status.more": "Mai",
+  "status.mute": "Rescondre @{name}",
   "status.mute_conversation": "Rescondre la conversacion",
   "status.open": "Desplegar aqueste estatut",
   "status.pin": "Penjar al perfil",
diff --git a/app/javascript/mastodon/locales/pl.json b/app/javascript/mastodon/locales/pl.json
index 9295ab937..334178e03 100644
--- a/app/javascript/mastodon/locales/pl.json
+++ b/app/javascript/mastodon/locales/pl.json
@@ -50,6 +50,7 @@
   "column_header.unpin": "Cofnij przypięcie",
   "column_subheading.navigation": "Nawigacja",
   "column_subheading.settings": "Ustawienia",
+  "compose_form.hashtag_warning": "Ten wpis nie będzie widoczny pod podanymi hashtagami, ponieważ jest oznaczony jako niewidoczny. Tylko publiczne wpisy mogą zostać znalezione z użyciem hashtagów.",
   "compose_form.lock_disclaimer": "Twoje konto nie jest {locked}. Każdy, kto Cię śledzi, może wyświetlać Twoje wpisy przeznaczone tylko dla śledzących.",
   "compose_form.lock_disclaimer.lock": "zablokowane",
   "compose_form.placeholder": "Co Ci chodzi po głowie?",
@@ -213,6 +214,7 @@
   "search_popout.tips.user": "użytkownik",
   "search_results.total": "{count, number} {count, plural, one {wynik} few {wyniki} many {wyników} more {wyników}}",
   "standalone.public_title": "Spojrzenie w głąb…",
+  "status.block": "Zablokuj @{name}",
   "status.cannot_reblog": "Ten wpis nie może zostać podbity",
   "status.delete": "Usuń",
   "status.embed": "Osadź",
@@ -221,6 +223,7 @@
   "status.media_hidden": "Zawartość multimedialna ukryta",
   "status.mention": "Wspomnij o @{name}",
   "status.more": "Więcej",
+  "status.mute": "Wycisz @{name}",
   "status.mute_conversation": "Wycisz konwersację",
   "status.open": "Rozszerz ten wpis",
   "status.pin": "Przypnij do profilu",
diff --git a/app/javascript/mastodon/locales/pt-BR.json b/app/javascript/mastodon/locales/pt-BR.json
index 70632846c..bc6ae928d 100644
--- a/app/javascript/mastodon/locales/pt-BR.json
+++ b/app/javascript/mastodon/locales/pt-BR.json
@@ -50,6 +50,7 @@
   "column_header.unpin": "Desafixar",
   "column_subheading.navigation": "Navegação",
   "column_subheading.settings": "Configurações",
+  "compose_form.hashtag_warning": "This toot won't be listed under any hashtag as it is unlisted. Only public toots can be searched by hashtag.",
   "compose_form.lock_disclaimer": "A sua conta não está {locked}. Qualquer pessoa pode te seguir e visualizar postagens direcionadas a apenas seguidores.",
   "compose_form.lock_disclaimer.lock": "trancada",
   "compose_form.placeholder": "No que você está pensando?",
@@ -213,6 +214,7 @@
   "search_popout.tips.user": "usuário",
   "search_results.total": "{count, number} {count, plural, one {resultado} other {resultados}}",
   "standalone.public_title": "Dê uma espiada...",
+  "status.block": "Block @{name}",
   "status.cannot_reblog": "Esta postagem não pode ser compartilhada",
   "status.delete": "Excluir",
   "status.embed": "Incorporar",
@@ -221,6 +223,7 @@
   "status.media_hidden": "Mídia escondida",
   "status.mention": "Mencionar @{name}",
   "status.more": "Mais",
+  "status.mute": "Mute @{name}",
   "status.mute_conversation": "Silenciar conversa",
   "status.open": "Expandir",
   "status.pin": "Fixar no perfil",
diff --git a/app/javascript/mastodon/locales/pt.json b/app/javascript/mastodon/locales/pt.json
index 15d5deb93..f9db2ad08 100644
--- a/app/javascript/mastodon/locales/pt.json
+++ b/app/javascript/mastodon/locales/pt.json
@@ -50,6 +50,7 @@
   "column_header.unpin": "Remover fixar",
   "column_subheading.navigation": "Navegação",
   "column_subheading.settings": "Preferências",
+  "compose_form.hashtag_warning": "This toot won't be listed under any hashtag as it is unlisted. Only public toots can be searched by hashtag.",
   "compose_form.lock_disclaimer": "A tua conta não está {locked}. Qualquer pessoa pode seguir-te e ver as publicações direcionadas apenas a seguidores.",
   "compose_form.lock_disclaimer.lock": "bloqueada",
   "compose_form.placeholder": "Em que estás a pensar?",
@@ -213,6 +214,7 @@
   "search_popout.tips.user": "utilizador",
   "search_results.total": "{count, number} {count, plural, one {resultado} other {resultados}}",
   "standalone.public_title": "Espreitar lá dentro...",
+  "status.block": "Block @{name}",
   "status.cannot_reblog": "Este post não pode ser partilhado",
   "status.delete": "Eliminar",
   "status.embed": "Incorporar",
@@ -221,6 +223,7 @@
   "status.media_hidden": "Media escondida",
   "status.mention": "Mencionar @{name}",
   "status.more": "Mais",
+  "status.mute": "Mute @{name}",
   "status.mute_conversation": "Silenciar conversa",
   "status.open": "Expandir",
   "status.pin": "Pin on profile",
diff --git a/app/javascript/mastodon/locales/ru.json b/app/javascript/mastodon/locales/ru.json
index e9925b675..0fec70df0 100644
--- a/app/javascript/mastodon/locales/ru.json
+++ b/app/javascript/mastodon/locales/ru.json
@@ -50,6 +50,7 @@
   "column_header.unpin": "Открепить",
   "column_subheading.navigation": "Навигация",
   "column_subheading.settings": "Настройки",
+  "compose_form.hashtag_warning": "This toot won't be listed under any hashtag as it is unlisted. Only public toots can be searched by hashtag.",
   "compose_form.lock_disclaimer": "Ваш аккаунт не {locked}. Любой человек может подписаться на Вас и просматривать посты для подписчиков.",
   "compose_form.lock_disclaimer.lock": "закрыт",
   "compose_form.placeholder": "О чем Вы думаете?",
@@ -213,6 +214,7 @@
   "search_popout.tips.user": "пользователь",
   "search_results.total": "{count, number} {count, plural, one {результат} few {результата} many {результатов} other {результатов}}",
   "standalone.public_title": "Прямо сейчас",
+  "status.block": "Block @{name}",
   "status.cannot_reblog": "Этот статус не может быть продвинут",
   "status.delete": "Удалить",
   "status.embed": "Встроить",
@@ -221,6 +223,7 @@
   "status.media_hidden": "Медиаконтент скрыт",
   "status.mention": "Упомянуть @{name}",
   "status.more": "Больше",
+  "status.mute": "Mute @{name}",
   "status.mute_conversation": "Заглушить тред",
   "status.open": "Развернуть статус",
   "status.pin": "Закрепить в профиле",
diff --git a/app/javascript/mastodon/locales/sk.json b/app/javascript/mastodon/locales/sk.json
index aacbc076a..41ebfbd0e 100644
--- a/app/javascript/mastodon/locales/sk.json
+++ b/app/javascript/mastodon/locales/sk.json
@@ -1,214 +1,262 @@
 {
-    "account.block": "Blokovať @{name}",
-    "account.block_domain": "Blokovať všetko z {domain}",
-    "account.disclaimer_full": "Inofrmácie nižšie nemusia reflektovať použivateľský účet kompletne.",
-    "account.edit_profile": "Upraviť profil",
-    "account.follow": "Sledovať",
-    "account.followers": "Sledujúci",
-    "account.follows": "Sledovaní",
-    "account.follows_you": "Sleduje teba",
-    "account.media": "Média",
-    "account.mention": "Napísať @{name}",
-    "account.mute": "Ignorovať @{name}",
-    "account.posts": "Správ",
-    "account.report": "Nahlásiť @{name}",
-    "account.requested": "Čaká na schválenie. Klikni na zrušenie žiadosti",
-    "account.share": "Zdieľať @{name} profil",
-    "account.unblock": "Odblokovať @{name}",
-    "account.unblock_domain": "Prestať blokovať {domain}",
-    "account.unfollow": "Prestať nasledovať",
-    "account.unmute": "Prestať ignorovať @{name}",
-    "account.view_full_profile": "Pozri celý profil",
-    "boost_modal.combo": "Nabudúce môžeš kliknúť {combo} a preskočiť",
-    "bundle_column_error.body": "Nastala chyba pri načítaní tohto komponentu.",
-    "bundle_column_error.retry": "Skús znova",
-    "bundle_column_error.title": "Chyba siete",
-    "bundle_modal_error.close": "Zatvoriť",
-    "bundle_modal_error.message": "Nastala chyba pri načítaní tohto komponentu.",
-    "bundle_modal_error.retry": "Skúsiť znova",
-    "column.blocks": "Blokovaní používatelia",
-    "column.community": "Lokálna časová os",
-    "column.favourites": "Obľúbené",
-    "column.follow_requests": "Žiadosti",
-    "column.home": "Moja časová os",
-    "column.mutes": "Ignorovaní používatelia",
-    "column.notifications": "Notifikácie",
-    "column.pins": "Pripnuté toots",
-    "column.public": "Federovaná časová os",
-    "column_back_button.label": "Späť",
-    "column_header.hide_settings": "Skryť nastavenia",
-    "column_header.moveLeft_settings": "Presunúť stĺpec doľava",
-    "column_header.moveRight_settings": "Presunúť stĺpec doprava",
-    "column_header.pin": "Pripnúť",
-    "column_header.show_settings": "Ukázať nastavenia",
-    "column_header.unpin": "Odopnúť",
-    "column_subheading.navigation": "Navigácia",
-    "column_subheading.settings": "Nastavenia",
-    "compose_form.lock_disclaimer": "Tvoj účet nie je zamknutý. Ktokoľvek ťa môže nasledovať a vidieť tvoje správy pre sledujúcich.",
-    "compose_form.lock_disclaimer.lock": "zamknutý",
-    "compose_form.placeholder": "Čo máš na mysli?",
-    "compose_form.publish": "Toot",
-    "compose_form.publish_loud": "{publish}!",
-    "compose_form.sensitive": "Označ súbor ako chúlostivý",
-    "compose_form.spoiler": "Skryť text za varovanie",
-    "compose_form.spoiler_placeholder": "Napíš sem tvoje varovanie",
-    "confirmation_modal.cancel": "Zrušiť",
-    "confirmations.block.confirm": "Blokovať",
-    "confirmations.block.message": "Naozaj chceš blokovať {name}?",
-    "confirmations.delete.confirm": "Zmazať",
-    "confirmations.delete.message": "Naozaj chceš zmazať túto správu?",
-    "confirmations.domain_block.confirm": "Skryť celú doménu",
-    "confirmations.domain_block.message": "Si si naozaj istý, že chceš blokovať celú {domain}? Vo väčšine prípadov stačí blokovať alebo ignorovať daných používateľov.",
-    "confirmations.mute.confirm": "Ignoruj",
-    "confirmations.mute.message": "Naozaj chceš ignorovať {name}?",
-    "confirmations.unfollow.confirm": "Nesledovať",
-    "confirmations.unfollow.message": "Naozaj chceš prestať sledovať {name}?",
-    "embed.instructions": "Skopíruj kód nižšie a ridaj tento status na tvoju web stránku.",
-    "embed.preview": "Tu je ukážka ako to bude vyzerať:",
-    "emoji_button.activity": "Aktivity",
-    "emoji_button.custom": "Vlastné",
-    "emoji_button.flags": "Vlajky",
-    "emoji_button.food": "Jedlá a nápoje",
-    "emoji_button.label": "Vlož emoji",
-    "emoji_button.nature": "Zvieratká",
-    "emoji_button.not_found": "Nenájdené",
-    "emoji_button.objects": "Predmety",
-    "emoji_button.people": "Ľudia",
-    "emoji_button.recent": "Často používané",
-    "emoji_button.search": "Hľadaj...",
-    "emoji_button.search_results": "Nájdené",
-    "emoji_button.symbols": "Symboly",
-    "emoji_button.travel": "Cestovanie a miesta",
-    "empty_column.community": "Lokálna časová os je prázdna. Napíš niečo aby sa to začalo hýbať!",
-    "empty_column.hashtag": "Ešte nič nie je v tomto hashtag-u.",
-    "empty_column.home": "Ešte nesleduješ nikoho. Pre začiatok pozri {public} alebo použi vyhľadávanie aby si našiel ostatných používateľov.",
-    "empty_column.home.inactivity": "Tvoja časová os je prázdna. Ak si bol dlho neaktívny, za krátku chvíľku bude obnovená.",
-    "empty_column.home.public_timeline": "verejnú časovú os",
-    "empty_column.notifications": "Nemáš žiadne notifikácie. Napíš niekomu, nasleduj niekoho alebo komunikuj s ostatnými.",
-    "empty_column.public": "Ešte tu nič nie je. Napíš niečo verejne alebo začni sledovať používateľov z iných Mastodon serverov aby tu niečo bolo",
-    "follow_request.authorize": "Potvrdiť",
-    "follow_request.reject": "Odmietnúť",
-    "getting_started.appsshort": "Aplikácie",
-    "getting_started.faq": "FAQ",
-    "getting_started.heading": "Začíname",
-    "getting_started.open_source_notice": "Mastodon má otvorený kód. Reportovať chyby alebo prispievať vlastným kódom môžeš na GitHube v {github}.",
-    "getting_started.userguide": "Používateľská príručka",
-    "home.column_settings.advanced": "Rozšírené",
-    "home.column_settings.basic": "Základné",
-    "home.column_settings.filter_regex": "Filtrovať použitím regulárnych výrazov",
-    "home.column_settings.show_reblogs": "Zobraziť boosts",
-    "home.column_settings.show_replies": "Zobraziť odpovede",
-    "home.settings": "Nastavenia stĺpcov",
-    "lightbox.close": "Zavrieť",
-    "lightbox.next": "Ďalší",
-    "lightbox.previous": "Predchádzajúci",
-    "loading_indicator.label": "Nahrávam...",
-    "media_gallery.toggle_visible": "Zapnúť/Vypnúť viditeľnosť",
-    "missing_indicator.label": "Nenájdené",
-    "navigation_bar.blocks": "Blokovaní používatelia",
-    "navigation_bar.community_timeline": "Lokálna časová os",
-    "navigation_bar.edit_profile": "Upraviť profil",
-    "navigation_bar.favourites": "Obľúbené",
-    "navigation_bar.follow_requests": "Žiadosti",
-    "navigation_bar.info": "O tomto Mastodon serveri",
-    "navigation_bar.logout": "Odhlásiť",
-    "navigation_bar.mutes": "Ignorovaní používatelia",
-    "navigation_bar.pins": "Pripnuté toots",
-    "navigation_bar.preferences": "Možnosti",
-    "navigation_bar.public_timeline": "Federovaná časová os",
-    "notification.favourite": "{name} sa páči tvoj status",
-    "notification.follow": "{name} ťa začal(a) sledovať",
-    "notification.mention": "{name} ťa zmienil",
-    "notification.reblog": "{name} re-tootol tvoj status",
-    "notifications.clear": "Vymazať notifikácie",
-    "notifications.clear_confirmation": "Naozaj chceš vymazať všetky tvoje notifikácie?",
-    "notifications.column_settings.alert": "Bublinové notifikácie",
-    "notifications.column_settings.favourite": "Obľúbené:",
-    "notifications.column_settings.follow": "Nový nasledujúci:",
-    "notifications.column_settings.mention": "Zmienenia:",
-    "notifications.column_settings.push": "Push notifikácie",
-    "notifications.column_settings.push_meta": "Toto zariadenie",
-    "notifications.column_settings.reblog": "Re-toots:",
-    "notifications.column_settings.show": "Zobraziť v stĺpci",
-    "notifications.column_settings.sound": "Prehrať zvuk",
-    "onboarding.done": "Koniec",
-    "onboarding.next": "Ďalej",
-    "onboarding.page_five.public_timelines": "Lokálna časová os zobrazuje verejné správy od všetkých na {domain}. Federovaná časová os zobrazuje verejné správy od všetkých ľudí ktoré {domain} nasleduje. Tieto sú takzvané Verejné Časové Osi, výborná možnosť ako nájsť a spoznať nových ľudí.",
-    "onboarding.page_four.home": "Domovská časová os zobrazí správy od ľudí ktorých sleduješ.",
-    "onboarding.page_four.notifications": "Stĺpec s notifikáciami zobrazí keď budeš s niekým komunikovať.",
-    "onboarding.page_one.federation": "Mastodon je sieť nezávislých serverov spojením ktorých vzniká jedna veľká federovaná sociálna sieť.",
-    "onboarding.page_one.handle": "Ty si na {domain}, takže tvoje celý nickname je {handle}",
-    "onboarding.page_one.welcome": "Vitajte v Mastodon!",
-    "onboarding.page_six.admin": "Správca tohto servera je {admin}.",
-    "onboarding.page_six.almost_done": "Takmer hotovo...",
-    "onboarding.page_six.appetoot": "Bon Appetoot!",
-    "onboarding.page_six.apps_available": "Aplikácie {apps} sú dostupné na pre iOS, Android and ďalšie platformy.",
-    "onboarding.page_six.github": "Mastodon je free open-source software. Chyby, nové funkcie alebo prispievať svojím kódom mǒžeš na {github}.",
-    "onboarding.page_six.guidelines": "pravidlá komunity",
-    "onboarding.page_six.read_guidelines": "Prosím prečítajte si {domain} pravidlá {guidelines}!",
-    "onboarding.page_six.various_app": "mobilné applikácie",
-    "onboarding.page_three.profile": "Uprav svoj profile a zmeň svoj avatar, bio a meno ktoré bude zobrazené. V nastaveniach nájdeš ďalšie možnosti.",
-    "onboarding.page_three.search": "Použi vyhľadávacie políčko na nájdenie ľudí a hashtagov, ako napríklad {slovensko}, {slovakia} alebo {pivo}. Na nájdenie človeka ktorý je registrovaný na inom Mastodon serveri použi jeho celý nickname.",
-    "onboarding.page_two.compose": "Správy píš zo stĺpca na komponovanie. Môžeš nahrávať obrázky, meniť nastavenia súkromia správ a pridávať varovania ikonkami nižšie.",
-    "onboarding.skip": "Preskočiť",
-    "privacy.change": "Zmeň viditeľnosť statusu",
-    "privacy.direct.long": "Pošli priamo iba spomenutým používateľom",
-    "privacy.direct.short": "Súkromne",
-    "privacy.private.long": "Pošli iba sledujúcim",
-    "privacy.private.short": "Iba sledujúci",
-    "privacy.public.long": "Pošli všetkým",
-    "privacy.public.short": "Verejne",
-    "privacy.unlisted.long": "Neposielať verejne",
-    "privacy.unlisted.short": "Nie je v zozname",
-    "reply_indicator.cancel": "Zrušiť",
-    "report.placeholder": "Ďalšie komentáre",
-    "report.submit": "Poslať",
-    "report.target": "Reportovať {target}",
-    "search.placeholder": "Hľadaj",
-    "search_results.total": "{count, number} nájdených",
-    "standalone.public_title": "Čo tam nájdeš...",
-    "status.cannot_reblog": "Tento príspevok nemôže byť re-tootnutý",
-    "status.delete": "Zmazať",
-    "status.embed": "Embed",
-    "status.favourite": "Páči sa mi",
-    "status.load_more": "Zobraziť viac",
-    "status.media_hidden": "Skryté médiá",
-    "status.mention": "Napísať @{name}",
-    "status.mute_conversation": "Ignorovať konverzáciu",
-    "status.open": "Otvoriť",
-    "status.pin": "Pripnúť na profil",
-    "status.reblog": "Re-toot",
-    "status.reblogged_by": "{name} re-tootol",
-    "status.reply": "Odpovedať",
-    "status.replyAll": "Odpovedať všetkým",
-    "status.report": "Nahlásiť @{name}",
-    "status.sensitive_toggle": "Klikni pre zobrazenie",
-    "status.sensitive_warning": "Chúlostivý obsah",
-    "status.share": "Zdieľať",
-    "status.show_less": "Zobraziť menej",
-    "status.show_more": "Zobraziť viac",
-    "status.unmute_conversation": "Prestať ignorovať konverzáciu",
-    "status.unpin": "Odopnúť z profilu",
-    "tabs_bar.compose": "Napísať",
-    "tabs_bar.federated_timeline": "Federovaná",
-    "tabs_bar.home": "Domov",
-    "tabs_bar.local_timeline": "Local",
-    "tabs_bar.notifications": "Notifikácie",
-    "upload_area.title": "Ťahaj a pusti pre nahratie",
-    "upload_button.label": "Pridať",
-    "upload_form.undo": "Späť",
-    "upload_progress.label": "Nahrávam...",
-    "video.close": "Zavrieť video",
-    "video.exit_fullscreen": "Vpnúť zobrazenie na celú obrazovku",
-    "video.expand": "Zväčšiť video",
-    "video.fullscreen": "Zapnúť zobrazenie na celú obrazovku",
-    "video.hide": "Skryť video",
-    "video.mute": "Vypnúť zvuk",
-    "video.pause": "Pauza",
-    "video.play": "Prehrať",
-    "video.unmute": "Zapnúť zvuk",
-    "video_player.expand": "Zväčšiť video",
-    "video_player.toggle_sound": "Zapnúť/Vypnúť zvuk",
-    "video_player.toggle_visible": "Zapnúť/Vypnúť video",
-    "video_player.video_error": "Video nebolo možné prehrať"
+  "account.block": "Blokovať @{name}",
+  "account.block_domain": "Blokovať všetko z {domain}",
+  "account.disclaimer_full": "Inofrmácie nižšie nemusia reflektovať použivateľský účet kompletne.",
+  "account.edit_profile": "Upraviť profil",
+  "account.follow": "Sledovať",
+  "account.followers": "Sledujúci",
+  "account.follows": "Sledovaní",
+  "account.follows_you": "Sleduje teba",
+  "account.hide_reblogs": "Hide boosts from @{name}",
+  "account.media": "Média",
+  "account.mention": "Napísať @{name}",
+  "account.moved_to": "{name} has moved to:",
+  "account.mute": "Ignorovať @{name}",
+  "account.mute_notifications": "Mute notifications from @{name}",
+  "account.posts": "Správ",
+  "account.report": "Nahlásiť @{name}",
+  "account.requested": "Čaká na schválenie. Klikni na zrušenie žiadosti",
+  "account.share": "Zdieľať @{name} profil",
+  "account.show_reblogs": "Show boosts from @{name}",
+  "account.unblock": "Odblokovať @{name}",
+  "account.unblock_domain": "Prestať blokovať {domain}",
+  "account.unfollow": "Prestať nasledovať",
+  "account.unmute": "Prestať ignorovať @{name}",
+  "account.unmute_notifications": "Unmute notifications from @{name}",
+  "account.view_full_profile": "Pozri celý profil",
+  "boost_modal.combo": "Nabudúce môžeš kliknúť {combo} a preskočiť",
+  "bundle_column_error.body": "Nastala chyba pri načítaní tohto komponentu.",
+  "bundle_column_error.retry": "Skús znova",
+  "bundle_column_error.title": "Chyba siete",
+  "bundle_modal_error.close": "Zatvoriť",
+  "bundle_modal_error.message": "Nastala chyba pri načítaní tohto komponentu.",
+  "bundle_modal_error.retry": "Skúsiť znova",
+  "column.blocks": "Blokovaní používatelia",
+  "column.community": "Lokálna časová os",
+  "column.favourites": "Obľúbené",
+  "column.follow_requests": "Žiadosti",
+  "column.home": "Moja časová os",
+  "column.lists": "Lists",
+  "column.mutes": "Ignorovaní používatelia",
+  "column.notifications": "Notifikácie",
+  "column.pins": "Pripnuté toots",
+  "column.public": "Federovaná časová os",
+  "column_back_button.label": "Späť",
+  "column_header.hide_settings": "Skryť nastavenia",
+  "column_header.moveLeft_settings": "Presunúť stĺpec doľava",
+  "column_header.moveRight_settings": "Presunúť stĺpec doprava",
+  "column_header.pin": "Pripnúť",
+  "column_header.show_settings": "Ukázať nastavenia",
+  "column_header.unpin": "Odopnúť",
+  "column_subheading.navigation": "Navigácia",
+  "column_subheading.settings": "Nastavenia",
+  "compose_form.hashtag_warning": "This toot won't be listed under any hashtag as it is unlisted. Only public toots can be searched by hashtag.",
+  "compose_form.lock_disclaimer": "Tvoj účet nie je zamknutý. Ktokoľvek ťa môže nasledovať a vidieť tvoje správy pre sledujúcich.",
+  "compose_form.lock_disclaimer.lock": "zamknutý",
+  "compose_form.placeholder": "Čo máš na mysli?",
+  "compose_form.publish": "Toot",
+  "compose_form.publish_loud": "{publish}!",
+  "compose_form.sensitive": "Označ súbor ako chúlostivý",
+  "compose_form.spoiler": "Skryť text za varovanie",
+  "compose_form.spoiler_placeholder": "Napíš sem tvoje varovanie",
+  "confirmation_modal.cancel": "Zrušiť",
+  "confirmations.block.confirm": "Blokovať",
+  "confirmations.block.message": "Naozaj chceš blokovať {name}?",
+  "confirmations.delete.confirm": "Zmazať",
+  "confirmations.delete.message": "Naozaj chceš zmazať túto správu?",
+  "confirmations.delete_list.confirm": "Delete",
+  "confirmations.delete_list.message": "Are you sure you want to permanently delete this list?",
+  "confirmations.domain_block.confirm": "Skryť celú doménu",
+  "confirmations.domain_block.message": "Si si naozaj istý, že chceš blokovať celú {domain}? Vo väčšine prípadov stačí blokovať alebo ignorovať daných používateľov.",
+  "confirmations.mute.confirm": "Ignoruj",
+  "confirmations.mute.message": "Naozaj chceš ignorovať {name}?",
+  "confirmations.unfollow.confirm": "Nesledovať",
+  "confirmations.unfollow.message": "Naozaj chceš prestať sledovať {name}?",
+  "embed.instructions": "Skopíruj kód nižšie a ridaj tento status na tvoju web stránku.",
+  "embed.preview": "Tu je ukážka ako to bude vyzerať:",
+  "emoji_button.activity": "Aktivity",
+  "emoji_button.custom": "Vlastné",
+  "emoji_button.flags": "Vlajky",
+  "emoji_button.food": "Jedlá a nápoje",
+  "emoji_button.label": "Vlož emoji",
+  "emoji_button.nature": "Zvieratká",
+  "emoji_button.not_found": "Nenájdené",
+  "emoji_button.objects": "Predmety",
+  "emoji_button.people": "Ľudia",
+  "emoji_button.recent": "Často používané",
+  "emoji_button.search": "Hľadaj...",
+  "emoji_button.search_results": "Nájdené",
+  "emoji_button.symbols": "Symboly",
+  "emoji_button.travel": "Cestovanie a miesta",
+  "empty_column.community": "Lokálna časová os je prázdna. Napíš niečo aby sa to začalo hýbať!",
+  "empty_column.hashtag": "Ešte nič nie je v tomto hashtag-u.",
+  "empty_column.home": "Ešte nesleduješ nikoho. Pre začiatok pozri {public} alebo použi vyhľadávanie aby si našiel ostatných používateľov.",
+  "empty_column.home.public_timeline": "verejnú časovú os",
+  "empty_column.list": "There is nothing in this list yet. When members of this list post new statuses, they will appear here.",
+  "empty_column.notifications": "Nemáš žiadne notifikácie. Napíš niekomu, nasleduj niekoho alebo komunikuj s ostatnými.",
+  "empty_column.public": "Ešte tu nič nie je. Napíš niečo verejne alebo začni sledovať používateľov z iných Mastodon serverov aby tu niečo bolo",
+  "follow_request.authorize": "Potvrdiť",
+  "follow_request.reject": "Odmietnúť",
+  "getting_started.appsshort": "Aplikácie",
+  "getting_started.faq": "FAQ",
+  "getting_started.heading": "Začíname",
+  "getting_started.open_source_notice": "Mastodon má otvorený kód. Reportovať chyby alebo prispievať vlastným kódom môžeš na GitHube v {github}.",
+  "getting_started.userguide": "Používateľská príručka",
+  "home.column_settings.advanced": "Rozšírené",
+  "home.column_settings.basic": "Základné",
+  "home.column_settings.filter_regex": "Filtrovať použitím regulárnych výrazov",
+  "home.column_settings.show_reblogs": "Zobraziť boosts",
+  "home.column_settings.show_replies": "Zobraziť odpovede",
+  "home.settings": "Nastavenia stĺpcov",
+  "keyboard_shortcuts.back": "to navigate back",
+  "keyboard_shortcuts.boost": "to boost",
+  "keyboard_shortcuts.column": "to focus a status in one of the columns",
+  "keyboard_shortcuts.compose": "to focus the compose textarea",
+  "keyboard_shortcuts.description": "Description",
+  "keyboard_shortcuts.down": "to move down in the list",
+  "keyboard_shortcuts.enter": "to open status",
+  "keyboard_shortcuts.favourite": "to favourite",
+  "keyboard_shortcuts.heading": "Keyboard Shortcuts",
+  "keyboard_shortcuts.hotkey": "Hotkey",
+  "keyboard_shortcuts.legend": "to display this legend",
+  "keyboard_shortcuts.mention": "to mention author",
+  "keyboard_shortcuts.reply": "to reply",
+  "keyboard_shortcuts.search": "to focus search",
+  "keyboard_shortcuts.toot": "to start a brand new toot",
+  "keyboard_shortcuts.unfocus": "to un-focus compose textarea/search",
+  "keyboard_shortcuts.up": "to move up in the list",
+  "lightbox.close": "Zavrieť",
+  "lightbox.next": "Ďalší",
+  "lightbox.previous": "Predchádzajúci",
+  "lists.account.add": "Add to list",
+  "lists.account.remove": "Remove from list",
+  "lists.delete": "Delete list",
+  "lists.edit": "Edit list",
+  "lists.new.create": "Add list",
+  "lists.new.title_placeholder": "New list title",
+  "lists.search": "Search among people you follow",
+  "lists.subheading": "Your lists",
+  "loading_indicator.label": "Nahrávam...",
+  "media_gallery.toggle_visible": "Zapnúť/Vypnúť viditeľnosť",
+  "missing_indicator.label": "Nenájdené",
+  "mute_modal.hide_notifications": "Hide notifications from this user?",
+  "navigation_bar.blocks": "Blokovaní používatelia",
+  "navigation_bar.community_timeline": "Lokálna časová os",
+  "navigation_bar.edit_profile": "Upraviť profil",
+  "navigation_bar.favourites": "Obľúbené",
+  "navigation_bar.follow_requests": "Žiadosti",
+  "navigation_bar.info": "O tomto Mastodon serveri",
+  "navigation_bar.keyboard_shortcuts": "Keyboard shortcuts",
+  "navigation_bar.lists": "Lists",
+  "navigation_bar.logout": "Odhlásiť",
+  "navigation_bar.mutes": "Ignorovaní používatelia",
+  "navigation_bar.pins": "Pripnuté toots",
+  "navigation_bar.preferences": "Možnosti",
+  "navigation_bar.public_timeline": "Federovaná časová os",
+  "notification.favourite": "{name} sa páči tvoj status",
+  "notification.follow": "{name} ťa začal(a) sledovať",
+  "notification.mention": "{name} ťa zmienil",
+  "notification.reblog": "{name} re-tootol tvoj status",
+  "notifications.clear": "Vymazať notifikácie",
+  "notifications.clear_confirmation": "Naozaj chceš vymazať všetky tvoje notifikácie?",
+  "notifications.column_settings.alert": "Bublinové notifikácie",
+  "notifications.column_settings.favourite": "Obľúbené:",
+  "notifications.column_settings.follow": "Nový nasledujúci:",
+  "notifications.column_settings.mention": "Zmienenia:",
+  "notifications.column_settings.push": "Push notifikácie",
+  "notifications.column_settings.push_meta": "Toto zariadenie",
+  "notifications.column_settings.reblog": "Re-toots:",
+  "notifications.column_settings.show": "Zobraziť v stĺpci",
+  "notifications.column_settings.sound": "Prehrať zvuk",
+  "onboarding.done": "Koniec",
+  "onboarding.next": "Ďalej",
+  "onboarding.page_five.public_timelines": "Lokálna časová os zobrazuje verejné správy od všetkých na {domain}. Federovaná časová os zobrazuje verejné správy od všetkých ľudí ktoré {domain} nasleduje. Tieto sú takzvané Verejné Časové Osi, výborná možnosť ako nájsť a spoznať nových ľudí.",
+  "onboarding.page_four.home": "Domovská časová os zobrazí správy od ľudí ktorých sleduješ.",
+  "onboarding.page_four.notifications": "Stĺpec s notifikáciami zobrazí keď budeš s niekým komunikovať.",
+  "onboarding.page_one.federation": "Mastodon je sieť nezávislých serverov spojením ktorých vzniká jedna veľká federovaná sociálna sieť.",
+  "onboarding.page_one.handle": "Ty si na {domain}, takže tvoje celý nickname je {handle}",
+  "onboarding.page_one.welcome": "Vitajte v Mastodon!",
+  "onboarding.page_six.admin": "Správca tohto servera je {admin}.",
+  "onboarding.page_six.almost_done": "Takmer hotovo...",
+  "onboarding.page_six.appetoot": "Bon Appetoot!",
+  "onboarding.page_six.apps_available": "Aplikácie {apps} sú dostupné na pre iOS, Android and ďalšie platformy.",
+  "onboarding.page_six.github": "Mastodon je free open-source software. Chyby, nové funkcie alebo prispievať svojím kódom mǒžeš na {github}.",
+  "onboarding.page_six.guidelines": "pravidlá komunity",
+  "onboarding.page_six.read_guidelines": "Prosím prečítajte si {domain} pravidlá {guidelines}!",
+  "onboarding.page_six.various_app": "mobilné applikácie",
+  "onboarding.page_three.profile": "Uprav svoj profile a zmeň svoj avatar, bio a meno ktoré bude zobrazené. V nastaveniach nájdeš ďalšie možnosti.",
+  "onboarding.page_three.search": "Použi vyhľadávacie políčko na nájdenie ľudí a hashtagov, ako napríklad {slovensko}, {slovakia} alebo {pivo}. Na nájdenie človeka ktorý je registrovaný na inom Mastodon serveri použi jeho celý nickname.",
+  "onboarding.page_two.compose": "Správy píš zo stĺpca na komponovanie. Môžeš nahrávať obrázky, meniť nastavenia súkromia správ a pridávať varovania ikonkami nižšie.",
+  "onboarding.skip": "Preskočiť",
+  "privacy.change": "Zmeň viditeľnosť statusu",
+  "privacy.direct.long": "Pošli priamo iba spomenutým používateľom",
+  "privacy.direct.short": "Súkromne",
+  "privacy.private.long": "Pošli iba sledujúcim",
+  "privacy.private.short": "Iba sledujúci",
+  "privacy.public.long": "Pošli všetkým",
+  "privacy.public.short": "Verejne",
+  "privacy.unlisted.long": "Neposielať verejne",
+  "privacy.unlisted.short": "Nie je v zozname",
+  "relative_time.days": "{number}d",
+  "relative_time.hours": "{number}h",
+  "relative_time.just_now": "now",
+  "relative_time.minutes": "{number}m",
+  "relative_time.seconds": "{number}s",
+  "reply_indicator.cancel": "Zrušiť",
+  "report.placeholder": "Ďalšie komentáre",
+  "report.submit": "Poslať",
+  "report.target": "Reportovať {target}",
+  "search.placeholder": "Hľadaj",
+  "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} nájdených",
+  "standalone.public_title": "Čo tam nájdeš...",
+  "status.block": "Block @{name}",
+  "status.cannot_reblog": "Tento príspevok nemôže byť re-tootnutý",
+  "status.delete": "Zmazať",
+  "status.embed": "Embed",
+  "status.favourite": "Páči sa mi",
+  "status.load_more": "Zobraziť viac",
+  "status.media_hidden": "Skryté médiá",
+  "status.mention": "Napísať @{name}",
+  "status.more": "More",
+  "status.mute": "Mute @{name}",
+  "status.mute_conversation": "Ignorovať konverzáciu",
+  "status.open": "Otvoriť",
+  "status.pin": "Pripnúť na profil",
+  "status.reblog": "Re-toot",
+  "status.reblogged_by": "{name} re-tootol",
+  "status.reply": "Odpovedať",
+  "status.replyAll": "Odpovedať všetkým",
+  "status.report": "Nahlásiť @{name}",
+  "status.sensitive_toggle": "Klikni pre zobrazenie",
+  "status.sensitive_warning": "Chúlostivý obsah",
+  "status.share": "Zdieľať",
+  "status.show_less": "Zobraziť menej",
+  "status.show_more": "Zobraziť viac",
+  "status.unmute_conversation": "Prestať ignorovať konverzáciu",
+  "status.unpin": "Odopnúť z profilu",
+  "tabs_bar.compose": "Napísať",
+  "tabs_bar.federated_timeline": "Federovaná",
+  "tabs_bar.home": "Domov",
+  "tabs_bar.local_timeline": "Local",
+  "tabs_bar.notifications": "Notifikácie",
+  "ui.beforeunload": "Your draft will be lost if you leave Mastodon.",
+  "upload_area.title": "Ťahaj a pusti pre nahratie",
+  "upload_button.label": "Pridať",
+  "upload_form.description": "Describe for the visually impaired",
+  "upload_form.undo": "Späť",
+  "upload_progress.label": "Nahrávam...",
+  "video.close": "Zavrieť video",
+  "video.exit_fullscreen": "Vpnúť zobrazenie na celú obrazovku",
+  "video.expand": "Zväčšiť video",
+  "video.fullscreen": "Zapnúť zobrazenie na celú obrazovku",
+  "video.hide": "Skryť video",
+  "video.mute": "Vypnúť zvuk",
+  "video.pause": "Pauza",
+  "video.play": "Prehrať",
+  "video.unmute": "Zapnúť zvuk"
 }
diff --git a/app/javascript/mastodon/locales/sr-Latn.json b/app/javascript/mastodon/locales/sr-Latn.json
new file mode 100644
index 000000000..88631e332
--- /dev/null
+++ b/app/javascript/mastodon/locales/sr-Latn.json
@@ -0,0 +1,262 @@
+{
+  "account.block": "Blokiraj korisnika @{name}",
+  "account.block_domain": "Sakrij sve sa domena {domain}",
+  "account.disclaimer_full": "Navedene informacije možda ne odslikavaju korisnički profil u potpunosti.",
+  "account.edit_profile": "Izmeni profil",
+  "account.follow": "Zaprati",
+  "account.followers": "Pratioca",
+  "account.follows": "Prati",
+  "account.follows_you": "Prati Vas",
+  "account.hide_reblogs": "Sakrij podrške koje daje korisnika @{name}",
+  "account.media": "Mediji",
+  "account.mention": "Pomeni korisnika @{name}",
+  "account.moved_to": "{name} se pomerio na:",
+  "account.mute": "Ućutkaj korisnika @{name}",
+  "account.mute_notifications": "Isključi obaveštenja od korisnika @{name}",
+  "account.posts": "Statusa",
+  "account.report": "Prijavi @{name}",
+  "account.requested": "Čekam odobrenje. Kliknite da poništite zahtev za praćenje",
+  "account.share": "Podeli profil korisnika @{name}",
+  "account.show_reblogs": "Prikaži podrške od korisnika @{name}",
+  "account.unblock": "Odblokiraj korisnika @{name}",
+  "account.unblock_domain": "Odblokiraj domen {domain}",
+  "account.unfollow": "Otprati",
+  "account.unmute": "Ukloni ućutkavanje korisniku @{name}",
+  "account.unmute_notifications": "Uključi nazad obaveštenja od korisnika @{name}",
+  "account.view_full_profile": "Vidi ceo profil",
+  "boost_modal.combo": "Možete pritisnuti {combo} da preskočite ovo sledeći put",
+  "bundle_column_error.body": "Nešto je pošlo po zlu prilikom učitavanja ove komponente.",
+  "bundle_column_error.retry": "Pokušajte ponovo",
+  "bundle_column_error.title": "Mrežna greška",
+  "bundle_modal_error.close": "Zatvori",
+  "bundle_modal_error.message": "Nešto nije bilo u redu pri učitavanju ove komponente.",
+  "bundle_modal_error.retry": "Pokušajte ponovo",
+  "column.blocks": "Blokirani korisnici",
+  "column.community": "Lokalna lajna",
+  "column.favourites": "Omiljeni",
+  "column.follow_requests": "Zahtevi za praćenje",
+  "column.home": "Početna",
+  "column.lists": "Liste",
+  "column.mutes": "Ućutkani korisnici",
+  "column.notifications": "Obaveštenja",
+  "column.pins": "Prikačeni tutovi",
+  "column.public": "Federisana lajna",
+  "column_back_button.label": "Nazad",
+  "column_header.hide_settings": "Sakrij postavke",
+  "column_header.moveLeft_settings": "Pomeri kolonu ulevo",
+  "column_header.moveRight_settings": "Pomeri kolonu udesno",
+  "column_header.pin": "Prikači",
+  "column_header.show_settings": "Prikaži postavke",
+  "column_header.unpin": "Otkači",
+  "column_subheading.navigation": "Navigacija",
+  "column_subheading.settings": "Postavke",
+  "compose_form.hashtag_warning": "This toot won't be listed under any hashtag as it is unlisted. Only public toots can be searched by hashtag.",
+  "compose_form.lock_disclaimer": "Vaš nalog nije {locked}. Svako može da Vas zaprati i da vidi objave namenjene samo Vašim pratiocima.",
+  "compose_form.lock_disclaimer.lock": "zaključan",
+  "compose_form.placeholder": "Šta Vam je na umu?",
+  "compose_form.publish": "Tutni",
+  "compose_form.publish_loud": "{publish}!",
+  "compose_form.sensitive": "Obeleži multimediju kao osetljivu",
+  "compose_form.spoiler": "Sakrij tekst ispod upozorenja",
+  "compose_form.spoiler_placeholder": "Ovde upišite upozorenje",
+  "confirmation_modal.cancel": "Poništi",
+  "confirmations.block.confirm": "Blokiraj",
+  "confirmations.block.message": "Da li ste sigurni da želite da blokirate korisnika {name}?",
+  "confirmations.delete.confirm": "Obriši",
+  "confirmations.delete.message": "Da li ste sigurni da želite obrišete ovaj status?",
+  "confirmations.delete_list.confirm": "Obriši",
+  "confirmations.delete_list.message": "Da li ste sigurni da želite da bespovratno obrišete ovu listu?",
+  "confirmations.domain_block.confirm": "Sakrij ceo domen",
+  "confirmations.domain_block.message": "Da li ste stvarno, stvarno sigurno da želite da blokirate ceo domen {domain}? U većini slučajeva, par dobrih blokiranja ili ućutkavanja su dovoljna i preporučljiva.",
+  "confirmations.mute.confirm": "Ućutkaj",
+  "confirmations.mute.message": "Da li stvarno želite da ućutkate korisnika {name}?",
+  "confirmations.unfollow.confirm": "Otprati",
+  "confirmations.unfollow.message": "Da li ste sigurni da želite da otpratite korisnika {name}?",
+  "embed.instructions": "Ugradi ovaj status na Vaš veb sajt kopiranjem koda ispod.",
+  "embed.preview": "Ovako će da izgleda:",
+  "emoji_button.activity": "Aktivnost",
+  "emoji_button.custom": "Proizvoljno",
+  "emoji_button.flags": "Zastave",
+  "emoji_button.food": "Hrana & piće",
+  "emoji_button.label": "Ubaci smajli",
+  "emoji_button.nature": "Priroda",
+  "emoji_button.not_found": "Nema smajlija!! (╯°□°)╯︵ ┻━┻",
+  "emoji_button.objects": "Objekti",
+  "emoji_button.people": "Ljudi",
+  "emoji_button.recent": "Najčešće korišćeni",
+  "emoji_button.search": "Pretraga...",
+  "emoji_button.search_results": "Rezultati pretrage",
+  "emoji_button.symbols": "Simboli",
+  "emoji_button.travel": "Putovanja & mesta",
+  "empty_column.community": "Lokalna lajna je prazna. Napišite nešto javno da lajna produva!",
+  "empty_column.hashtag": "Trenutno nema ništa na ovom heštegu.",
+  "empty_column.home": "Vaša lajna je prazna! Posetite {public} ili koristite pretragu da počnete i upoznajete nove ljude.",
+  "empty_column.home.public_timeline": "javna lajna",
+  "empty_column.list": "U ovoj listi još nema ničega. Kada članovi liste objave nove statuse, oni će se pojavljivati ovde.",
+  "empty_column.notifications": "Trenutno nemate obaveštenja. Družite se malo da započnete razgovore.",
+  "empty_column.public": "Ovde nema ničega! Napišite nešto javno, ili nađite korisnike sa drugih instanci koje ćete zapratiti da popunite ovu prazninu",
+  "follow_request.authorize": "Odobri",
+  "follow_request.reject": "Odbij",
+  "getting_started.appsshort": "Aplikacije",
+  "getting_started.faq": "ČPP",
+  "getting_started.heading": "Da počnete",
+  "getting_started.open_source_notice": "Mastodont je softver otvorenog koda. Možete mu doprineti ili prijaviti probleme preko GitHub-a na {github}.",
+  "getting_started.userguide": "Korisničko uputstvo",
+  "home.column_settings.advanced": "Napredno",
+  "home.column_settings.basic": "Osnovno",
+  "home.column_settings.filter_regex": "Filtriraj regularnim izrazima",
+  "home.column_settings.show_reblogs": "Prikaži i podržavanja",
+  "home.column_settings.show_replies": "Prikaži odgovore",
+  "home.settings": "Postavke kolone",
+  "keyboard_shortcuts.back": "da odete nazad",
+  "keyboard_shortcuts.boost": "da podržite",
+  "keyboard_shortcuts.column": "da se prebacite na status u jednoj od kolona",
+  "keyboard_shortcuts.compose": "da se prebacite na pisanje novog tuta",
+  "keyboard_shortcuts.description": "Opis",
+  "keyboard_shortcuts.down": "da se pomerite na dole u listi",
+  "keyboard_shortcuts.enter": "da otvorite status",
+  "keyboard_shortcuts.favourite": "da označite kao omiljeno",
+  "keyboard_shortcuts.heading": "Prečice na tastaturi",
+  "keyboard_shortcuts.hotkey": "Prečica",
+  "keyboard_shortcuts.legend": "da prikažete ovaj podsetnik",
+  "keyboard_shortcuts.mention": "da pomenete autora",
+  "keyboard_shortcuts.reply": "da odgovorite",
+  "keyboard_shortcuts.search": "da se prebacite na pretragu",
+  "keyboard_shortcuts.toot": "da započnete skroz novi tut",
+  "keyboard_shortcuts.unfocus": "da ne budete više na pretrazi/pravljenju novog tuta",
+  "keyboard_shortcuts.up": "da se pomerite na gore u listi",
+  "lightbox.close": "Zatvori",
+  "lightbox.next": "Sledeći",
+  "lightbox.previous": "Prethodni",
+  "lists.account.add": "Dodaj na listu",
+  "lists.account.remove": "Ukloni sa liste",
+  "lists.delete": "Obriši listu",
+  "lists.edit": "Izmeni listu",
+  "lists.new.create": "Dodaj listu",
+  "lists.new.title_placeholder": "Naslov nove liste",
+  "lists.search": "Pretraži među ljudima koje pratite",
+  "lists.subheading": "Vaše liste",
+  "loading_indicator.label": "Učitavam...",
+  "media_gallery.toggle_visible": "Uključi/isključi vidljivost",
+  "missing_indicator.label": "Nije pronađeno",
+  "mute_modal.hide_notifications": "Sakrij obaveštenja od ovog korisnika?",
+  "navigation_bar.blocks": "Blokirani korisnici",
+  "navigation_bar.community_timeline": "Lokalna lajna",
+  "navigation_bar.edit_profile": "Izmeni profil",
+  "navigation_bar.favourites": "Omiljeni",
+  "navigation_bar.follow_requests": "Zahtevi za praćenje",
+  "navigation_bar.info": "O ovoj instanci",
+  "navigation_bar.keyboard_shortcuts": "Prečice na tastaturi",
+  "navigation_bar.lists": "Liste",
+  "navigation_bar.logout": "Odjava",
+  "navigation_bar.mutes": "Ućutkani korisnici",
+  "navigation_bar.pins": "Prikačeni tutovi",
+  "navigation_bar.preferences": "Podešavanja",
+  "navigation_bar.public_timeline": "Federisana lajna",
+  "notification.favourite": "{name} je stavio Vaš status kao omiljeni",
+  "notification.follow": "{name} Vas je zapratio",
+  "notification.mention": "{name} Vas je pomenuo",
+  "notification.reblog": "{name} je podržao(la) Vaš status",
+  "notifications.clear": "Očisti obaveštenja",
+  "notifications.clear_confirmation": "Da li ste sigurno da trajno želite da očistite Vaša obaveštenja?",
+  "notifications.column_settings.alert": "Obaveštenja na radnoj površini",
+  "notifications.column_settings.favourite": "Omiljeni:",
+  "notifications.column_settings.follow": "Novi pratioci:",
+  "notifications.column_settings.mention": "Pominjanja:",
+  "notifications.column_settings.push": "Guraj obaveštenja",
+  "notifications.column_settings.push_meta": "Ovaj uređaj",
+  "notifications.column_settings.reblog": "Podrški:",
+  "notifications.column_settings.show": "Prikaži u koloni",
+  "notifications.column_settings.sound": "Puštaj zvuk",
+  "onboarding.done": "Gotovo",
+  "onboarding.next": "Sledeće",
+  "onboarding.page_five.public_timelines": "Lokalna lajna prikazuje sve javne statuse od svih na domenu {domain}. Federisana lajna prikazuje javne statuse od svih ljudi koje prate korisnici sa domena {domain}. Ovo su javne lajne, sjajan način da otkrijete nove ljude.",
+  "onboarding.page_four.home": "Početna lajna prikazuje statuse ljudi koje Vi pratite.",
+  "onboarding.page_four.notifications": "Kolona sa obaveštenjima Vam prikazuje kada neko priča sa Vama.",
+  "onboarding.page_one.federation": "Mastodont je mreža nezavisnih servera koji se uvezuju da naprave jednu veću društvenu mrežu. Ove servere zovemo instancama.",
+  "onboarding.page_one.handle": "Vi ste na domenu {domain}, pa je Vaša puna identifikacija {handle}",
+  "onboarding.page_one.welcome": "Dobrodošli na Mastodont!",
+  "onboarding.page_six.admin": "Administrator Vaše instance je {admin}.",
+  "onboarding.page_six.almost_done": "Još malo, pa gotovo...",
+  "onboarding.page_six.appetoot": "Prijatutno!",
+  "onboarding.page_six.apps_available": "Postoje {apps} dostupne za iOS, Android i druge platforme.",
+  "onboarding.page_six.github": "Mastodont je slobodan softver otvorenog koda. Možete prijavljivati greške, potraživati nove funckionalnosti, ili učestvujući u programiranju. Naš izvorni kod je ovde: {github}.",
+  "onboarding.page_six.guidelines": "smernice zajednice",
+  "onboarding.page_six.read_guidelines": "Pročitejte {guidelines} domena {domain}!",
+  "onboarding.page_six.various_app": "mobilne aplikacije",
+  "onboarding.page_three.profile": "Izmenite profil da promenite avatar, biografiju i ime za prikaz. Tamo ćete naći i ostala podešavanja.",
+  "onboarding.page_three.search": "Korisite pretragu da nađete ljude i gledate heštegove, kao što su {illustration} i {introductions}. Da nađete osobu koja nije na ovoj instanci, koristite njenu punu identifikaciju.",
+  "onboarding.page_two.compose": "Pišite statuse iz prve kolone. Možete otpremati slike, menjati podešavanja privatnosti, i dodavati upozorenja za osetljiv sadržaj preko ikonica ispod.",
+  "onboarding.skip": "Preskoči",
+  "privacy.change": "Podesi status privatnosti",
+  "privacy.direct.long": "Objavi samo korisnicima koji su pomenuti",
+  "privacy.direct.short": "Direktno",
+  "privacy.private.long": "Objavi samo pratiocima",
+  "privacy.private.short": "Samo za pratioce",
+  "privacy.public.long": "Objavi na javnoj lajni",
+  "privacy.public.short": "Javno",
+  "privacy.unlisted.long": "Ne objavljuj na javnim lajnama",
+  "privacy.unlisted.short": "Neizlistano",
+  "relative_time.days": "{number}d",
+  "relative_time.hours": "{number}h",
+  "relative_time.just_now": "sada",
+  "relative_time.minutes": "{number}m",
+  "relative_time.seconds": "{number}s",
+  "reply_indicator.cancel": "Poništi",
+  "report.placeholder": "Dodatni komentari",
+  "report.submit": "Pošalji",
+  "report.target": "Prijavljujem {target}",
+  "search.placeholder": "Pretraga",
+  "search_popout.search_format": "Napredni format pretrage",
+  "search_popout.tips.hashtag": "hešteg",
+  "search_popout.tips.status": "status",
+  "search_popout.tips.text": "Traženjem običnog teksta ćete dobiti sva pronađena imena, sva korisnička imena i sve nađene heštegove",
+  "search_popout.tips.user": "korisnik",
+  "search_results.total": "{count, number} {count, plural, one {rezultat} few {rezultata} other {rezultata}}",
+  "standalone.public_title": "Pogled iznutra...",
+  "status.block": "Block @{name}",
+  "status.cannot_reblog": "Ovaj status ne može da se podrži",
+  "status.delete": "Obriši",
+  "status.embed": "Ugradi na sajt",
+  "status.favourite": "Omiljeno",
+  "status.load_more": "Učitaj još",
+  "status.media_hidden": "Multimedija sakrivena",
+  "status.mention": "Pomeni korisnika @{name}",
+  "status.more": "Još",
+  "status.mute": "Mute @{name}",
+  "status.mute_conversation": "Ućutkaj prepisku",
+  "status.open": "Proširi ovaj status",
+  "status.pin": "Prikači na profil",
+  "status.reblog": "Podrži",
+  "status.reblogged_by": "{name} podržao(la)",
+  "status.reply": "Odgovori",
+  "status.replyAll": "Odgovori na diskusiju",
+  "status.report": "Prijavi korisnika @{name}",
+  "status.sensitive_toggle": "Kliknite da vidite",
+  "status.sensitive_warning": "Osetljiv sadržaj",
+  "status.share": "Podeli",
+  "status.show_less": "Prikaži manje",
+  "status.show_more": "Prikaži više",
+  "status.unmute_conversation": "Uključi prepisku",
+  "status.unpin": "Otkači sa profila",
+  "tabs_bar.compose": "Napiši",
+  "tabs_bar.federated_timeline": "Federisano",
+  "tabs_bar.home": "Početna",
+  "tabs_bar.local_timeline": "Lokalno",
+  "tabs_bar.notifications": "Obaveštenja",
+  "ui.beforeunload": "Ako napustite Mastodont, izgubićete napisani nacrt.",
+  "upload_area.title": "Prevucite ovde da otpremite",
+  "upload_button.label": "Dodaj multimediju",
+  "upload_form.description": "Opiši za slabovide osobe",
+  "upload_form.undo": "Opozovi",
+  "upload_progress.label": "Otpremam...",
+  "video.close": "Zatvori video",
+  "video.exit_fullscreen": "Napusti ceo ekran",
+  "video.expand": "Proširi video",
+  "video.fullscreen": "Ceo ekran",
+  "video.hide": "Sakrij video",
+  "video.mute": "Ugasi zvuk",
+  "video.pause": "Pauziraj",
+  "video.play": "Pusti",
+  "video.unmute": "Vrati zvuk"
+}
diff --git a/app/javascript/mastodon/locales/sr.json b/app/javascript/mastodon/locales/sr.json
new file mode 100644
index 000000000..e65c02ab7
--- /dev/null
+++ b/app/javascript/mastodon/locales/sr.json
@@ -0,0 +1,262 @@
+{
+  "account.block": "Блокирај корисника @{name}",
+  "account.block_domain": "Сакриј све са домена {domain}",
+  "account.disclaimer_full": "Наведене информације можда не одсликавају кориснички профил у потпуности.",
+  "account.edit_profile": "Измени профил",
+  "account.follow": "Запрати",
+  "account.followers": "Пратиоца",
+  "account.follows": "Прати",
+  "account.follows_you": "Прати Вас",
+  "account.hide_reblogs": "Сакриј подршке које даје корисника @{name}",
+  "account.media": "Медији",
+  "account.mention": "Помени корисника @{name}",
+  "account.moved_to": "{name} се померио на:",
+  "account.mute": "Ућуткај корисника @{name}",
+  "account.mute_notifications": "Искључи обавештења од корисника @{name}",
+  "account.posts": "Статуса",
+  "account.report": "Пријави @{name}",
+  "account.requested": "Чекам одобрење. Кликните да поништите захтев за праћење",
+  "account.share": "Подели профил корисника @{name}",
+  "account.show_reblogs": "Прикажи подршке од корисника @{name}",
+  "account.unblock": "Одблокирај корисника @{name}",
+  "account.unblock_domain": "Одблокирај домен {domain}",
+  "account.unfollow": "Отпрати",
+  "account.unmute": "Уклони ућуткавање кориснику @{name}",
+  "account.unmute_notifications": "Укључи назад обавештења од корисника @{name}",
+  "account.view_full_profile": "Види цео профил",
+  "boost_modal.combo": "Можете притиснути {combo} да прескочите ово следећи пут",
+  "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": "Омиљени",
+  "column.follow_requests": "Захтеви за праћење",
+  "column.home": "Почетна",
+  "column.lists": "Листе",
+  "column.mutes": "Ућуткани корисници",
+  "column.notifications": "Обавештења",
+  "column.pins": "Прикачени тутови",
+  "column.public": "Федерисана лајна",
+  "column_back_button.label": "Назад",
+  "column_header.hide_settings": "Сакриј поставке",
+  "column_header.moveLeft_settings": "Помери колону улево",
+  "column_header.moveRight_settings": "Помери колону удесно",
+  "column_header.pin": "Прикачи",
+  "column_header.show_settings": "Прикажи поставке",
+  "column_header.unpin": "Откачи",
+  "column_subheading.navigation": "Навигација",
+  "column_subheading.settings": "Поставке",
+  "compose_form.hashtag_warning": "This toot won't be listed under any hashtag as it is unlisted. Only public toots can be searched by hashtag.",
+  "compose_form.lock_disclaimer": "Ваш налог није {locked}. Свако може да Вас запрати и да види објаве намењене само Вашим пратиоцима.",
+  "compose_form.lock_disclaimer.lock": "закључан",
+  "compose_form.placeholder": "Шта Вам је на уму?",
+  "compose_form.publish": "Тутни",
+  "compose_form.publish_loud": "{publish}!",
+  "compose_form.sensitive": "Обележи мултимедију као осетљиву",
+  "compose_form.spoiler": "Сакриј текст испод упозорења",
+  "compose_form.spoiler_placeholder": "Овде упишите упозорење",
+  "confirmation_modal.cancel": "Поништи",
+  "confirmations.block.confirm": "Блокирај",
+  "confirmations.block.message": "Да ли сте сигурни да желите да блокирате корисника {name}?",
+  "confirmations.delete.confirm": "Обриши",
+  "confirmations.delete.message": "Да ли сте сигурни да желите обришете овај статус?",
+  "confirmations.delete_list.confirm": "Обриши",
+  "confirmations.delete_list.message": "Да ли сте сигурни да желите да бесповратно обришете ову листу?",
+  "confirmations.domain_block.confirm": "Сакриј цео домен",
+  "confirmations.domain_block.message": "Да ли сте стварно, стварно сигурно да желите да блокирате цео домен {domain}? У већини случајева, пар добрих блокирања или ућуткавања су довољна и препоручљива.",
+  "confirmations.mute.confirm": "Ућуткај",
+  "confirmations.mute.message": "Да ли стварно желите да ућуткате корисника {name}?",
+  "confirmations.unfollow.confirm": "Отпрати",
+  "confirmations.unfollow.message": "Да ли сте сигурни да желите да отпратите корисника {name}?",
+  "embed.instructions": "Угради овај статус на Ваш веб сајт копирањем кода испод.",
+  "embed.preview": "Овако ће да изгледа:",
+  "emoji_button.activity": "Активност",
+  "emoji_button.custom": "Произвољно",
+  "emoji_button.flags": "Заставе",
+  "emoji_button.food": "Храна & пиће",
+  "emoji_button.label": "Убаци смајли",
+  "emoji_button.nature": "Природа",
+  "emoji_button.not_found": "Нема смајлија!! (╯°□°)╯︵ ┻━┻",
+  "emoji_button.objects": "Објекти",
+  "emoji_button.people": "Људи",
+  "emoji_button.recent": "Најчешће коришћени",
+  "emoji_button.search": "Претрага...",
+  "emoji_button.search_results": "Резултати претраге",
+  "emoji_button.symbols": "Симболи",
+  "emoji_button.travel": "Путовања & места",
+  "empty_column.community": "Локална лајна је празна. Напишите нешто јавно да лајна продува!",
+  "empty_column.hashtag": "Тренутно нема ништа на овом хештегу.",
+  "empty_column.home": "Ваша лајна је празна! Посетите {public} или користите претрагу да почнете и упознајете нове људе.",
+  "empty_column.home.public_timeline": "јавна лајна",
+  "empty_column.list": "У овој листи још нема ничега. Када чланови листе објаве нове статусе, они ће се појављивати овде.",
+  "empty_column.notifications": "Тренутно немате обавештења. Дружите се мало да започнете разговоре.",
+  "empty_column.public": "Овде нема ничега! Напишите нешто јавно, или нађите кориснике са других инстанци које ћете запратити да попуните ову празнину",
+  "follow_request.authorize": "Одобри",
+  "follow_request.reject": "Одбиј",
+  "getting_started.appsshort": "Апликације",
+  "getting_started.faq": "ЧПП",
+  "getting_started.heading": "Да почнете",
+  "getting_started.open_source_notice": "Мастoдонт је софтвер отвореног кода. Можете му допринети или пријавити проблеме преко GitHub-а на {github}.",
+  "getting_started.userguide": "Корисничко упутство",
+  "home.column_settings.advanced": "Напредно",
+  "home.column_settings.basic": "Основно",
+  "home.column_settings.filter_regex": "Филтрирај регуларним изразима",
+  "home.column_settings.show_reblogs": "Прикажи и подржавања",
+  "home.column_settings.show_replies": "Прикажи одговоре",
+  "home.settings": "Поставке колоне",
+  "keyboard_shortcuts.back": "да одете назад",
+  "keyboard_shortcuts.boost": "да подржите",
+  "keyboard_shortcuts.column": "да се пребаците на статус у једној од колона",
+  "keyboard_shortcuts.compose": "да се пребаците на писање новог тута",
+  "keyboard_shortcuts.description": "Опис",
+  "keyboard_shortcuts.down": "да се померите на доле у листи",
+  "keyboard_shortcuts.enter": "да отворите статус",
+  "keyboard_shortcuts.favourite": "да означите као омиљено",
+  "keyboard_shortcuts.heading": "Пречице на тастатури",
+  "keyboard_shortcuts.hotkey": "Пречица",
+  "keyboard_shortcuts.legend": "да прикажете овај подсетник",
+  "keyboard_shortcuts.mention": "да поменете аутора",
+  "keyboard_shortcuts.reply": "да одговорите",
+  "keyboard_shortcuts.search": "да се пребаците на претрагу",
+  "keyboard_shortcuts.toot": "да започнете скроз нови тут",
+  "keyboard_shortcuts.unfocus": "да не будете више на претрази/прављењу новог тута",
+  "keyboard_shortcuts.up": "да се померите на горе у листи",
+  "lightbox.close": "Затвори",
+  "lightbox.next": "Следећи",
+  "lightbox.previous": "Претходни",
+  "lists.account.add": "Додај на листу",
+  "lists.account.remove": "Уклони са листе",
+  "lists.delete": "Обриши листу",
+  "lists.edit": "Измени листу",
+  "lists.new.create": "Додај листу",
+  "lists.new.title_placeholder": "Наслов нове листе",
+  "lists.search": "Претражи међу људима које пратите",
+  "lists.subheading": "Ваше листе",
+  "loading_indicator.label": "Учитавам...",
+  "media_gallery.toggle_visible": "Укључи/искључи видљивост",
+  "missing_indicator.label": "Није пронађено",
+  "mute_modal.hide_notifications": "Сакриј обавештења од овог корисника?",
+  "navigation_bar.blocks": "Блокирани корисници",
+  "navigation_bar.community_timeline": "Локална лајна",
+  "navigation_bar.edit_profile": "Измени профил",
+  "navigation_bar.favourites": "Омиљени",
+  "navigation_bar.follow_requests": "Захтеви за праћење",
+  "navigation_bar.info": "О овој инстанци",
+  "navigation_bar.keyboard_shortcuts": "Пречице на тастатури",
+  "navigation_bar.lists": "Листе",
+  "navigation_bar.logout": "Одјава",
+  "navigation_bar.mutes": "Ућуткани корисници",
+  "navigation_bar.pins": "Прикачени тутови",
+  "navigation_bar.preferences": "Подешавања",
+  "navigation_bar.public_timeline": "Федерисана лајна",
+  "notification.favourite": "{name} је ставио Ваш статус као омиљени",
+  "notification.follow": "{name} Вас је запратио",
+  "notification.mention": "{name} Вас је поменуо",
+  "notification.reblog": "{name} је подржао(ла) Ваш статус",
+  "notifications.clear": "Очисти обавештења",
+  "notifications.clear_confirmation": "Да ли сте сигурно да трајно желите да очистите Ваша обавештења?",
+  "notifications.column_settings.alert": "Обавештења на радној површини",
+  "notifications.column_settings.favourite": "Омиљени:",
+  "notifications.column_settings.follow": "Нови пратиоци:",
+  "notifications.column_settings.mention": "Помињања:",
+  "notifications.column_settings.push": "Гурај обавештења",
+  "notifications.column_settings.push_meta": "Овај уређај",
+  "notifications.column_settings.reblog": "Подршки:",
+  "notifications.column_settings.show": "Прикажи у колони",
+  "notifications.column_settings.sound": "Пуштај звук",
+  "onboarding.done": "Готово",
+  "onboarding.next": "Следеће",
+  "onboarding.page_five.public_timelines": "Локална лајна приказује све јавне статусе од свих на домену {domain}. Федерисана лајна приказује јавне статусе од свих људи које прате корисници са домена {domain}. Ово су јавне лајне, сјајан начин да откријете нове људе.",
+  "onboarding.page_four.home": "Почетна лајна приказује статусе људи које Ви пратите.",
+  "onboarding.page_four.notifications": "Колона са обавештењима Вам приказује када неко прича са Вама.",
+  "onboarding.page_one.federation": "Мастодонт је мрежа независних сервера који се увезују да направе једну већу друштвену мрежу. Ове сервере зовемо инстанцама.",
+  "onboarding.page_one.handle": "Ви сте на домену {domain}, па је Ваша пуна идентификација {handle}",
+  "onboarding.page_one.welcome": "Добродошли на Мастодонт!",
+  "onboarding.page_six.admin": "Администратор Ваше инстанце је {admin}.",
+  "onboarding.page_six.almost_done": "Још мало, па готово...",
+  "onboarding.page_six.appetoot": "Пријатутно!",
+  "onboarding.page_six.apps_available": "Постоје {apps} доступне за iOS, Андроид и друге платформе.",
+  "onboarding.page_six.github": "Мастодонт је слободан софтвер отвореног кода. Можете пријављивати грешке, потраживати нове фунцкионалности, или учествујући у програмирању. Наш изворни код је овде: {github}.",
+  "onboarding.page_six.guidelines": "смернице заједнице",
+  "onboarding.page_six.read_guidelines": "Прочитејте {guidelines} домена {domain}!",
+  "onboarding.page_six.various_app": "мобилне апликације",
+  "onboarding.page_three.profile": "Измените профил да промените аватар, биографију и име за приказ. Тамо ћете наћи и остала подешавања.",
+  "onboarding.page_three.search": "Корисите претрагу да нађете људе и гледате хештегове, као што су {illustration} и {introductions}. Да нађете особу која није на овој инстанци, користите њену пуну идентификацију.",
+  "onboarding.page_two.compose": "Пишите статусе из прве колоне. Можете отпремати слике, мењати подешавања приватности, и додавати упозорења за осетљив садржај преко иконица испод.",
+  "onboarding.skip": "Прескочи",
+  "privacy.change": "Подеси статус приватности",
+  "privacy.direct.long": "Објави само корисницима који су поменути",
+  "privacy.direct.short": "Директно",
+  "privacy.private.long": "Објави само пратиоцима",
+  "privacy.private.short": "Само за пратиоце",
+  "privacy.public.long": "Објави на јавној лајни",
+  "privacy.public.short": "Јавно",
+  "privacy.unlisted.long": "Не објављуј на јавним лајнама",
+  "privacy.unlisted.short": "Неизлистано",
+  "relative_time.days": "{number}d",
+  "relative_time.hours": "{number}h",
+  "relative_time.just_now": "сада",
+  "relative_time.minutes": "{number}m",
+  "relative_time.seconds": "{number}s",
+  "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} {count, plural, one {резултат} few {резултата} other {резултата}}",
+  "standalone.public_title": "Поглед изнутра...",
+  "status.block": "Block @{name}",
+  "status.cannot_reblog": "Овај статус не може да се подржи",
+  "status.delete": "Обриши",
+  "status.embed": "Угради на сајт",
+  "status.favourite": "Омиљено",
+  "status.load_more": "Учитај још",
+  "status.media_hidden": "Мултимедија сакривена",
+  "status.mention": "Помени корисника @{name}",
+  "status.more": "Још",
+  "status.mute": "Mute @{name}",
+  "status.mute_conversation": "Ућуткај преписку",
+  "status.open": "Прошири овај статус",
+  "status.pin": "Прикачи на профил",
+  "status.reblog": "Подржи",
+  "status.reblogged_by": "{name} подржао(ла)",
+  "status.reply": "Одговори",
+  "status.replyAll": "Одговори на дискусију",
+  "status.report": "Пријави корисника @{name}",
+  "status.sensitive_toggle": "Кликните да видите",
+  "status.sensitive_warning": "Осетљив садржај",
+  "status.share": "Подели",
+  "status.show_less": "Прикажи мање",
+  "status.show_more": "Прикажи више",
+  "status.unmute_conversation": "Укључи преписку",
+  "status.unpin": "Откачи са профила",
+  "tabs_bar.compose": "Напиши",
+  "tabs_bar.federated_timeline": "Федерисано",
+  "tabs_bar.home": "Почетна",
+  "tabs_bar.local_timeline": "Локално",
+  "tabs_bar.notifications": "Обавештења",
+  "ui.beforeunload": "Ако напустите Мастодонт, изгубићете написани нацрт.",
+  "upload_area.title": "Превуците овде да отпремите",
+  "upload_button.label": "Додај мултимедију",
+  "upload_form.description": "Опиши за слабовиде особе",
+  "upload_form.undo": "Опозови",
+  "upload_progress.label": "Отпремам...",
+  "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/sv.json b/app/javascript/mastodon/locales/sv.json
index 9d9646509..edfa9b8c2 100644
--- a/app/javascript/mastodon/locales/sv.json
+++ b/app/javascript/mastodon/locales/sv.json
@@ -50,6 +50,7 @@
   "column_header.unpin": "Ångra fäst",
   "column_subheading.navigation": "Navigation",
   "column_subheading.settings": "Inställningar",
+  "compose_form.hashtag_warning": "This toot won't be listed under any hashtag as it is unlisted. Only public toots can be searched by hashtag.",
   "compose_form.lock_disclaimer": "Ditt konto är inte {locked}. Vemsomhelst kan följa dig och även se dina inlägg skrivna för endast dina följare.",
   "compose_form.lock_disclaimer.lock": "låst",
   "compose_form.placeholder": "Vad funderar du på?",
@@ -213,6 +214,7 @@
   "search_popout.tips.user": "användare",
   "search_results.total": "{count, number} {count, plural, ett {result} andra {results}}",
   "standalone.public_title": "En titt inuti...",
+  "status.block": "Block @{name}",
   "status.cannot_reblog": "Detta inlägg kan inte knuffas",
   "status.delete": "Ta bort",
   "status.embed": "Bädda in",
@@ -221,6 +223,7 @@
   "status.media_hidden": "Media dold",
   "status.mention": "Omnämn @{name}",
   "status.more": "More",
+  "status.mute": "Mute @{name}",
   "status.mute_conversation": "Tysta konversation",
   "status.open": "Utvidga denna status",
   "status.pin": "Fäst i profil",
diff --git a/app/javascript/mastodon/locales/th.json b/app/javascript/mastodon/locales/th.json
index cc18a6096..06323ebfc 100644
--- a/app/javascript/mastodon/locales/th.json
+++ b/app/javascript/mastodon/locales/th.json
@@ -50,6 +50,7 @@
   "column_header.unpin": "Unpin",
   "column_subheading.navigation": "Navigation",
   "column_subheading.settings": "Settings",
+  "compose_form.hashtag_warning": "This toot won't be listed under any hashtag as it is unlisted. Only public toots can be searched by hashtag.",
   "compose_form.lock_disclaimer": "Your account is not {locked}. Anyone can follow you to view your follower-only posts.",
   "compose_form.lock_disclaimer.lock": "locked",
   "compose_form.placeholder": "What is on your mind?",
@@ -213,6 +214,7 @@
   "search_popout.tips.user": "user",
   "search_results.total": "{count, number} {count, plural, one {result} other {results}}",
   "standalone.public_title": "A look inside...",
+  "status.block": "Block @{name}",
   "status.cannot_reblog": "This post cannot be boosted",
   "status.delete": "Delete",
   "status.embed": "Embed",
@@ -221,6 +223,7 @@
   "status.media_hidden": "Media hidden",
   "status.mention": "Mention @{name}",
   "status.more": "More",
+  "status.mute": "Mute @{name}",
   "status.mute_conversation": "Mute conversation",
   "status.open": "Expand this status",
   "status.pin": "Pin on profile",
diff --git a/app/javascript/mastodon/locales/tr.json b/app/javascript/mastodon/locales/tr.json
index c51f3e417..ce6434ca6 100644
--- a/app/javascript/mastodon/locales/tr.json
+++ b/app/javascript/mastodon/locales/tr.json
@@ -50,6 +50,7 @@
   "column_header.unpin": "Unpin",
   "column_subheading.navigation": "Navigasyon",
   "column_subheading.settings": "Ayarlar",
+  "compose_form.hashtag_warning": "This toot won't be listed under any hashtag as it is unlisted. Only public toots can be searched by hashtag.",
   "compose_form.lock_disclaimer": "Hesabınız {locked} değil. Sadece takipçilerle paylaştığınız gönderileri görebilmek için sizi herhangi bir kullanıcı takip edebilir.",
   "compose_form.lock_disclaimer.lock": "kilitli",
   "compose_form.placeholder": "Ne düşünüyorsun?",
@@ -213,6 +214,7 @@
   "search_popout.tips.user": "user",
   "search_results.total": "{count, number} {count, plural, one {sonuç} other {sonuçlar}}",
   "standalone.public_title": "A look inside...",
+  "status.block": "Block @{name}",
   "status.cannot_reblog": "Bu gönderi boost edilemez",
   "status.delete": "Sil",
   "status.embed": "Embed",
@@ -221,6 +223,7 @@
   "status.media_hidden": "Gizli görsel",
   "status.mention": "Bahset @{name}",
   "status.more": "More",
+  "status.mute": "Mute @{name}",
   "status.mute_conversation": "Mute conversation",
   "status.open": "Bu gönderiyi genişlet",
   "status.pin": "Pin on profile",
diff --git a/app/javascript/mastodon/locales/uk.json b/app/javascript/mastodon/locales/uk.json
index 86c0ce76d..46d22ac83 100644
--- a/app/javascript/mastodon/locales/uk.json
+++ b/app/javascript/mastodon/locales/uk.json
@@ -50,6 +50,7 @@
   "column_header.unpin": "Unpin",
   "column_subheading.navigation": "Навігація",
   "column_subheading.settings": "Налаштування",
+  "compose_form.hashtag_warning": "This toot won't be listed under any hashtag as it is unlisted. Only public toots can be searched by hashtag.",
   "compose_form.lock_disclaimer": "Ваш акаунт не {locked}. Кожен може підписатися на Вас та бачити Ваші приватні пости.",
   "compose_form.lock_disclaimer.lock": "приватний",
   "compose_form.placeholder": "Що у Вас на думці?",
@@ -213,6 +214,7 @@
   "search_popout.tips.user": "user",
   "search_results.total": "{count, number} {count, plural, one {результат} few {результати} many {результатів} other {результатів}}",
   "standalone.public_title": "A look inside...",
+  "status.block": "Block @{name}",
   "status.cannot_reblog": "Цей допис не може бути передмухнутий",
   "status.delete": "Видалити",
   "status.embed": "Embed",
@@ -221,6 +223,7 @@
   "status.media_hidden": "Медіаконтент приховано",
   "status.mention": "Згадати",
   "status.more": "More",
+  "status.mute": "Mute @{name}",
   "status.mute_conversation": "Заглушити діалог",
   "status.open": "Розгорнути допис",
   "status.pin": "Pin on profile",
diff --git a/app/javascript/mastodon/locales/whitelist_sk.json b/app/javascript/mastodon/locales/whitelist_sk.json
new file mode 100644
index 000000000..0d4f101c7
--- /dev/null
+++ b/app/javascript/mastodon/locales/whitelist_sk.json
@@ -0,0 +1,2 @@
+[
+]
diff --git a/app/javascript/mastodon/locales/whitelist_sr.json b/app/javascript/mastodon/locales/whitelist_sr.json
new file mode 100644
index 000000000..0d4f101c7
--- /dev/null
+++ b/app/javascript/mastodon/locales/whitelist_sr.json
@@ -0,0 +1,2 @@
+[
+]
diff --git a/app/javascript/mastodon/locales/zh-CN.json b/app/javascript/mastodon/locales/zh-CN.json
index 9be6a9f73..ce16dcd8e 100644
--- a/app/javascript/mastodon/locales/zh-CN.json
+++ b/app/javascript/mastodon/locales/zh-CN.json
@@ -50,6 +50,7 @@
   "column_header.unpin": "取消固定",
   "column_subheading.navigation": "导航",
   "column_subheading.settings": "设置",
+  "compose_form.hashtag_warning": "This toot won't be listed under any hashtag as it is unlisted. Only public toots can be searched by hashtag.",
   "compose_form.lock_disclaimer": "你的帐户没有{locked}。任何人都可以在关注你后立即查看仅关注者可见的嘟文。",
   "compose_form.lock_disclaimer.lock": "开启保护",
   "compose_form.placeholder": "在想啥?",
@@ -213,6 +214,7 @@
   "search_popout.tips.user": "用户",
   "search_results.total": "共 {count, number} 个结果",
   "standalone.public_title": "大家都在干啥?",
+  "status.block": "Block @{name}",
   "status.cannot_reblog": "无法转嘟这条嘟文",
   "status.delete": "删除",
   "status.embed": "嵌入",
@@ -221,6 +223,7 @@
   "status.media_hidden": "隐藏媒体内容",
   "status.mention": "提及 @{name}",
   "status.more": "更多",
+  "status.mute": "Mute @{name}",
   "status.mute_conversation": "隐藏此对话",
   "status.open": "展开嘟文",
   "status.pin": "在个人资料页面置顶",
diff --git a/app/javascript/mastodon/locales/zh-HK.json b/app/javascript/mastodon/locales/zh-HK.json
index 15a68c915..7745da622 100644
--- a/app/javascript/mastodon/locales/zh-HK.json
+++ b/app/javascript/mastodon/locales/zh-HK.json
@@ -50,6 +50,7 @@
   "column_header.unpin": "取下",
   "column_subheading.navigation": "瀏覽",
   "column_subheading.settings": "設定",
+  "compose_form.hashtag_warning": "This toot won't be listed under any hashtag as it is unlisted. Only public toots can be searched by hashtag.",
   "compose_form.lock_disclaimer": "你的用戶狀態為「{locked}」,任何人都能立即關注你,然後看到「只有關注者能看」的文章。",
   "compose_form.lock_disclaimer.lock": "公共",
   "compose_form.placeholder": "你在想甚麼?",
@@ -213,6 +214,7 @@
   "search_popout.tips.user": "user",
   "search_results.total": "{count, number} 項結果",
   "standalone.public_title": "站點一瞥…",
+  "status.block": "Block @{name}",
   "status.cannot_reblog": "這篇文章無法被轉推",
   "status.delete": "刪除",
   "status.embed": "鑲嵌",
@@ -221,6 +223,7 @@
   "status.media_hidden": "隱藏媒體內容",
   "status.mention": "提及 @{name}",
   "status.more": "More",
+  "status.mute": "Mute @{name}",
   "status.mute_conversation": "靜音對話",
   "status.open": "展開文章",
   "status.pin": "置頂到資料頁",
diff --git a/app/javascript/mastodon/locales/zh-TW.json b/app/javascript/mastodon/locales/zh-TW.json
index 1bdc883a8..65b174ab5 100644
--- a/app/javascript/mastodon/locales/zh-TW.json
+++ b/app/javascript/mastodon/locales/zh-TW.json
@@ -50,6 +50,7 @@
   "column_header.unpin": "取下",
   "column_subheading.navigation": "瀏覽",
   "column_subheading.settings": "設定",
+  "compose_form.hashtag_warning": "This toot won't be listed under any hashtag as it is unlisted. Only public toots can be searched by hashtag.",
   "compose_form.lock_disclaimer": "你的帳號沒有{locked}。任何人都可以關注你,看到發給關注者的貼文。",
   "compose_form.lock_disclaimer.lock": "上鎖",
   "compose_form.placeholder": "在想些什麼?",
@@ -63,8 +64,8 @@
   "confirmations.block.message": "你確定要封鎖 {name} ?",
   "confirmations.delete.confirm": "刪除",
   "confirmations.delete.message": "你確定要刪除這個狀態?",
-  "confirmations.delete_list.confirm": "Delete",
-  "confirmations.delete_list.message": "Are you sure you want to permanently delete this list?",
+  "confirmations.delete_list.confirm": "刪除",
+  "confirmations.delete_list.message": "確定要永久性地刪除這個名單嗎?",
   "confirmations.domain_block.confirm": "隱藏整個網域",
   "confirmations.domain_block.message": "你真的真的確定要隱藏整個 {domain} ?多數情況下,比較推薦封鎖或消音幾個特定目標就好。",
   "confirmations.mute.confirm": "消音",
@@ -127,14 +128,14 @@
   "lightbox.close": "關閉",
   "lightbox.next": "繼續",
   "lightbox.previous": "回退",
-  "lists.account.add": "Add to list",
-  "lists.account.remove": "Remove from list",
-  "lists.delete": "Delete list",
-  "lists.edit": "Edit list",
-  "lists.new.create": "Add list",
-  "lists.new.title_placeholder": "New list title",
-  "lists.search": "Search among people you follow",
-  "lists.subheading": "Your lists",
+  "lists.account.add": "加到名單裡",
+  "lists.account.remove": "從名單中移除",
+  "lists.delete": "刪除名單",
+  "lists.edit": "修改名單",
+  "lists.new.create": "新增名單",
+  "lists.new.title_placeholder": "名單名稱",
+  "lists.search": "搜尋您關注的使用者",
+  "lists.subheading": "您的名單",
   "loading_indicator.label": "讀取中...",
   "media_gallery.toggle_visible": "切換可見性",
   "missing_indicator.label": "找不到",
@@ -145,8 +146,8 @@
   "navigation_bar.favourites": "最愛",
   "navigation_bar.follow_requests": "關注請求",
   "navigation_bar.info": "關於本站",
-  "navigation_bar.keyboard_shortcuts": "Keyboard shortcuts",
-  "navigation_bar.lists": "Lists",
+  "navigation_bar.keyboard_shortcuts": "快速鍵",
+  "navigation_bar.lists": "名單",
   "navigation_bar.logout": "登出",
   "navigation_bar.mutes": "消音的使用者",
   "navigation_bar.pins": "置頂貼文",
@@ -213,6 +214,7 @@
   "search_popout.tips.user": "user",
   "search_results.total": "{count, number} 項結果",
   "standalone.public_title": "站點一瞥…",
+  "status.block": "Block @{name}",
   "status.cannot_reblog": "此貼文無法轉推",
   "status.delete": "刪除",
   "status.embed": "Embed",
@@ -221,6 +223,7 @@
   "status.media_hidden": "媒體已隱藏",
   "status.mention": "提到 @{name}",
   "status.more": "More",
+  "status.mute": "Mute @{name}",
   "status.mute_conversation": "消音對話",
   "status.open": "展開這個狀態",
   "status.pin": "置頂到個人資訊頁",
diff --git a/app/javascript/mastodon/main.js b/app/javascript/mastodon/main.js
index 9b18465f5..5d73caa10 100644
--- a/app/javascript/mastodon/main.js
+++ b/app/javascript/mastodon/main.js
@@ -1,4 +1,4 @@
-import { register as registerPushNotifications } from './actions/push_notifications';
+import * as registerPushNotifications from './actions/push_notifications';
 import { default as Mastodon, store } from './containers/mastodon';
 import React from 'react';
 import ReactDOM from 'react-dom';
diff --git a/app/javascript/mastodon/reducers/push_notifications.js b/app/javascript/mastodon/reducers/push_notifications.js
index c15b38fe4..85628c6b1 100644
--- a/app/javascript/mastodon/reducers/push_notifications.js
+++ b/app/javascript/mastodon/reducers/push_notifications.js
@@ -44,7 +44,7 @@ export default function push_subscriptions(state = initialState, action) {
   case CLEAR_SUBSCRIPTION:
     return initialState;
   case SET_ALERTS:
-    return state.setIn(action.key, action.value);
+    return state.setIn(action.path, action.value);
   default:
     return state;
   }
diff --git a/app/javascript/mastodon/reducers/settings.js b/app/javascript/mastodon/reducers/settings.js
index 5817cf49b..390b2a13a 100644
--- a/app/javascript/mastodon/reducers/settings.js
+++ b/app/javascript/mastodon/reducers/settings.js
@@ -93,7 +93,7 @@ export default function settings(state = initialState, action) {
     return hydrate(state, action.state.get('settings'));
   case SETTING_CHANGE:
     return state
-      .setIn(action.key, action.value)
+      .setIn(action.path, action.value)
       .set('saved', false);
   case COLUMN_ADD:
     return state
diff --git a/app/javascript/styles/application.scss b/app/javascript/styles/application.scss
index 44aa10564..fd6665f65 100644
--- a/app/javascript/styles/application.scss
+++ b/app/javascript/styles/application.scss
@@ -6,6 +6,7 @@
 
 @import 'mastodon/reset';
 @import 'mastodon/basics';
+@import 'mastodon/modal';
 @import 'mastodon/containers';
 @import 'mastodon/lists';
 @import 'mastodon/footer';
diff --git a/app/javascript/styles/mastodon/admin.scss b/app/javascript/styles/mastodon/admin.scss
index bddea557b..0c343e1df 100644
--- a/app/javascript/styles/mastodon/admin.scss
+++ b/app/javascript/styles/mastodon/admin.scss
@@ -398,10 +398,12 @@
     }
   }
 
+  &__content {
+    max-width: calc(100% - 90px);
+  }
+
   &__title {
-    overflow: hidden;
-    text-overflow: ellipsis;
-    white-space: nowrap;
+    word-wrap: break-word;
   }
 
   &__timestamp {
@@ -415,7 +417,7 @@
     color: $ui-primary-color;
     font-family: 'mastodon-font-monospace', monospace;
     font-size: 12px;
-    white-space: nowrap;
+    word-wrap: break-word;
     min-height: 20px;
   }
 
diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss
index 71d0b91e9..a09a766d0 100644
--- a/app/javascript/styles/mastodon/components.scss
+++ b/app/javascript/styles/mastodon/components.scss
@@ -1758,7 +1758,7 @@
   position: absolute;
   top: 0;
   left: 0;
-  background: lighten($ui-base-color, 13%);
+  background: lighten($ui-base-color, 13%) url('~images/wave-drawer.png') no-repeat bottom / 100% auto;
   box-sizing: border-box;
   padding: 0;
   display: flex;
@@ -1771,6 +1771,11 @@
   &.darker {
     background: $ui-base-color;
   }
+
+  > .mastodon {
+    background: url('~images/mastodon-ui.png') no-repeat left bottom / contain;
+    flex: 1;
+  }
 }
 
 .pseudo-drawer {
@@ -2067,20 +2072,18 @@
   cursor: default;
 }
 
+.getting-started__wrapper,
+.getting_started {
+  background: $ui-base-color;
+}
+
 .getting-started__wrapper {
   position: relative;
   overflow-y: auto;
 }
 
-.getting-started__footer {
-  display: flex;
-  flex-direction: column;
-}
-
 .getting-started {
-  box-sizing: border-box;
-  padding-bottom: 235px;
-  background: url('~images/mastodon-getting-started.png') no-repeat 0 100%;
+  background: $ui-base-color;
   flex: 1 0 auto;
 
   p {
diff --git a/app/javascript/styles/mastodon/modal.scss b/app/javascript/styles/mastodon/modal.scss
new file mode 100644
index 000000000..a17476ccb
--- /dev/null
+++ b/app/javascript/styles/mastodon/modal.scss
@@ -0,0 +1,20 @@
+.modal-layout {
+  background: $ui-base-color url('~images/wave-modal.png') repeat-x bottom fixed;
+  display: flex;
+  flex-direction: column;
+  height: 100vh;
+  padding: 0;
+}
+
+.modal-layout__mastodon {
+  display: flex;
+  flex: 1;
+  flex-direction: column;
+  justify-content: flex-end;
+
+  > * {
+    flex: 1;
+    max-height: 235px;
+    background: url('~images/mastodon-ui.png') no-repeat left bottom / contain;
+  }
+}
diff --git a/app/lib/activity_tracker.rb b/app/lib/activity_tracker.rb
new file mode 100644
index 000000000..5b4972674
--- /dev/null
+++ b/app/lib/activity_tracker.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+class ActivityTracker
+  EXPIRE_AFTER = 90.days.seconds
+
+  class << self
+    def increment(prefix)
+      key = [prefix, current_week].join(':')
+
+      redis.incrby(key, 1)
+      redis.expire(key, EXPIRE_AFTER)
+    end
+
+    def record(prefix, value)
+      key = [prefix, current_week].join(':')
+
+      redis.pfadd(key, value)
+      redis.expire(key, EXPIRE_AFTER)
+    end
+
+    private
+
+    def redis
+      Redis.current
+    end
+
+    def current_week
+      Time.zone.today.cweek
+    end
+  end
+end
diff --git a/app/lib/activitypub/activity/accept.rb b/app/lib/activitypub/activity/accept.rb
index bd90c9019..d0082483c 100644
--- a/app/lib/activitypub/activity/accept.rb
+++ b/app/lib/activitypub/activity/accept.rb
@@ -2,16 +2,18 @@
 
 class ActivityPub::Activity::Accept < ActivityPub::Activity
   def perform
-    case @object['type']
-    when 'Follow'
-      accept_follow
+    if @object.respond_to?(:[]) &&
+       @object['type'] == 'Follow' && @object['actor'].present?
+      accept_follow_from @object['actor']
+    else
+      accept_follow_object @object
     end
   end
 
   private
 
-  def accept_follow
-    target_account = account_from_uri(target_uri)
+  def accept_follow_from(actor)
+    target_account = account_from_uri(value_or_id(actor))
 
     return if target_account.nil? || !target_account.local?
 
@@ -19,7 +21,8 @@ class ActivityPub::Activity::Accept < ActivityPub::Activity
     follow_request&.authorize!
   end
 
-  def target_uri
-    @target_uri ||= value_or_id(@object['actor'])
+  def accept_follow_object(object)
+    follow_request = ActivityPub::TagManager.instance.uri_to_resource(value_or_id(object), FollowRequest)
+    follow_request&.authorize!
   end
 end
diff --git a/app/lib/activitypub/activity/delete.rb b/app/lib/activitypub/activity/delete.rb
index d0fb49342..5fa60a81c 100644
--- a/app/lib/activitypub/activity/delete.rb
+++ b/app/lib/activitypub/activity/delete.rb
@@ -13,6 +13,7 @@ class ActivityPub::Activity::Delete < ActivityPub::Activity
 
   def delete_person
     SuspendAccountService.new.call(@account)
+    @account.destroy!
   end
 
   def delete_note
diff --git a/app/lib/activitypub/tag_manager.rb b/app/lib/activitypub/tag_manager.rb
index 0708713e6..1c35e1672 100644
--- a/app/lib/activitypub/tag_manager.rb
+++ b/app/lib/activitypub/tag_manager.rb
@@ -28,6 +28,8 @@ class ActivityPub::TagManager
     return target.uri if target.respond_to?(:local?) && !target.local?
 
     case target.object_type
+    when :follow
+      account_follow_url(target.account.username, target)
     when :person
       account_url(target)
     when :note, :comment, :activity
@@ -97,6 +99,12 @@ class ActivityPub::TagManager
       case klass.name
       when 'Account'
         klass.find_local(uri_to_local_id(uri, :username))
+      when 'FollowRequest'
+        params = Rails.application.routes.recognize_path(uri)
+        klass.joins(:account).find_by!(
+          accounts: { domain: nil, username: params[:account_username] },
+          id: params[:id]
+        )
       else
         StatusFinder.new(uri).status
       end
diff --git a/app/lib/formatter.rb b/app/lib/formatter.rb
index f5bf64cc7..8c0f8cebc 100644
--- a/app/lib/formatter.rb
+++ b/app/lib/formatter.rb
@@ -171,10 +171,10 @@ class Formatter
   end
 
   def link_to_url(entity)
-    normalized_url = Addressable::URI.parse(entity[:url]).normalize
-    html_attrs     = { target: '_blank', rel: 'nofollow noopener' }
+    url        = Addressable::URI.parse(entity[:url])
+    html_attrs = { target: '_blank', rel: 'nofollow noopener' }
 
-    Twitter::Autolink.send(:link_to_text, entity, link_html(entity[:url]), normalized_url, html_attrs)
+    Twitter::Autolink.send(:link_to_text, entity, link_html(entity[:url]), url, html_attrs)
   rescue Addressable::URI::InvalidURIError, IDN::Idna::IdnaError
     encode(entity[:url])
   end
diff --git a/app/lib/provider_discovery.rb b/app/lib/provider_discovery.rb
index 04ba38101..5732e4fcb 100644
--- a/app/lib/provider_discovery.rb
+++ b/app/lib/provider_discovery.rb
@@ -29,7 +29,7 @@ class ProviderDiscovery < OEmbed::ProviderDiscovery
       end
 
       if format.nil? || format == :xml
-        provider_endpoint ||= html.at_xpath('//link[@type="application/xml+oembed"]')&.attribute('href')&.value
+        provider_endpoint ||= html.at_xpath('//link[@type="text/xml+oembed"]')&.attribute('href')&.value
         format ||= :xml if provider_endpoint
       end
 
diff --git a/app/lib/sanitize_config.rb b/app/lib/sanitize_config.rb
index f09288fcd..c2b466924 100644
--- a/app/lib/sanitize_config.rb
+++ b/app/lib/sanitize_config.rb
@@ -6,14 +6,14 @@ class Sanitize
 
     CLASS_WHITELIST_TRANSFORMER = lambda do |env|
       node = env[:node]
-      class_list = node['class']&.split(' ')
+      class_list = node['class']&.split(/[\t\n\f\r ]/)
 
       return unless class_list
 
       class_list.keep_if do |e|
-        return true if e =~ /^(h|p|u|dt|e)-/ # microformats classes
-        return true if e =~ /^(mention|hashtag)$/ # semantic classes
-        return true if e =~ /^(ellipsis|invisible)$/ # link formatting classes
+        next true if e =~ /^(h|p|u|dt|e)-/ # microformats classes
+        next true if e =~ /^(mention|hashtag)$/ # semantic classes
+        next true if e =~ /^(ellipsis|invisible)$/ # link formatting classes
       end
 
       node['class'] = class_list.join(' ')
diff --git a/app/mailers/user_mailer.rb b/app/mailers/user_mailer.rb
index 5a062dc25..7821be32b 100644
--- a/app/mailers/user_mailer.rb
+++ b/app/mailers/user_mailer.rb
@@ -13,7 +13,9 @@ class UserMailer < Devise::Mailer
     return if @resource.disabled?
 
     I18n.with_locale(@resource.locale || I18n.default_locale) do
-      mail to: @resource.unconfirmed_email.blank? ? @resource.email : @resource.unconfirmed_email, subject: I18n.t('devise.mailer.confirmation_instructions.subject', instance: @instance)
+      mail to: @resource.unconfirmed_email.blank? ? @resource.email : @resource.unconfirmed_email,
+           subject: I18n.t(@resource.pending_reconfirmation? ? 'devise.mailer.reconfirmation_instructions.subject' : 'devise.mailer.confirmation_instructions.subject', instance: @instance),
+           template_name: @resource.pending_reconfirmation? ? 'reconfirmation_instructions' : 'confirmation_instructions'
     end
   end
 
@@ -39,4 +41,15 @@ class UserMailer < Devise::Mailer
       mail to: @resource.email, subject: I18n.t('devise.mailer.password_change.subject')
     end
   end
+
+  def email_changed(user, **)
+    @resource = user
+    @instance = Rails.configuration.x.local_domain
+
+    return if @resource.disabled?
+
+    I18n.with_locale(@resource.locale || I18n.default_locale) do
+      mail to: @resource.email, subject: I18n.t('devise.mailer.email_changed.subject')
+    end
+  end
 end
diff --git a/app/models/follow_request.rb b/app/models/follow_request.rb
index ebf6959ce..33e5fec12 100644
--- a/app/models/follow_request.rb
+++ b/app/models/follow_request.rb
@@ -21,6 +21,10 @@ class FollowRequest < ApplicationRecord
 
   validates :account_id, uniqueness: { scope: :target_account_id }
 
+  def object_type
+    :follow
+  end
+
   def authorize!
     account.follow!(target_account, reblogs: show_reblogs)
     MergeWorker.perform_async(target_account.id, account.id)
diff --git a/app/models/form/admin_settings.rb b/app/models/form/admin_settings.rb
index c1d2cf420..dd629279c 100644
--- a/app/models/form/admin_settings.rb
+++ b/app/models/form/admin_settings.rb
@@ -30,6 +30,10 @@ class Form::AdminSettings
     :bootstrap_timeline_accounts=,
     :min_invite_role,
     :min_invite_role=,
+    :activity_api_enabled,
+    :activity_api_enabled=,
+    :peers_api_enabled,
+    :peers_api_enabled=,
     to: Setting
   )
 end
diff --git a/app/models/status.rb b/app/models/status.rb
index db3072571..cb18b0705 100644
--- a/app/models/status.rb
+++ b/app/models/status.rb
@@ -138,6 +138,7 @@ class Status < ApplicationRecord
   end
 
   after_create_commit :store_uri, if: :local?
+  after_create_commit :update_statistics, if: :local?
 
   around_create Mastodon::Snowflake::Callbacks
 
@@ -336,4 +337,9 @@ class Status < ApplicationRecord
   def set_local
     self.local = account.local?
   end
+
+  def update_statistics
+    return unless public_visibility? || unlisted_visibility?
+    ActivityTracker.increment('activity:statuses:local')
+  end
 end
diff --git a/app/models/user.rb b/app/models/user.rb
index 47bf22e17..855ae908d 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -41,12 +41,15 @@ class User < ApplicationRecord
 
   ACTIVE_DURATION = 14.days
 
-  devise :registerable, :recoverable,
-         :rememberable, :trackable, :validatable, :confirmable,
-         :two_factor_authenticatable, :two_factor_backupable,
-         otp_secret_encryption_key: ENV['OTP_SECRET'],
+  devise :two_factor_authenticatable,
+         otp_secret_encryption_key: ENV['OTP_SECRET']
+
+  devise :two_factor_backupable,
          otp_number_of_backup_codes: 10
 
+  devise :registerable, :recoverable, :rememberable, :trackable, :validatable,
+         :confirmable
+
   belongs_to :account, inverse_of: :user, required: true
   belongs_to :invite, counter_cache: :uses
   accepts_nested_attributes_for :account
@@ -122,9 +125,19 @@ class User < ApplicationRecord
     update!(disabled: false)
   end
 
+  def confirm
+    new_user = !confirmed?
+
+    super
+    update_statistics! if new_user
+  end
+
   def confirm!
+    new_user = !confirmed?
+
     skip_confirmation!
     save!
+    update_statistics! if new_user
   end
 
   def promote!
@@ -202,4 +215,9 @@ class User < ApplicationRecord
   def sanitize_languages
     filtered_languages.reject!(&:blank?)
   end
+
+  def update_statistics!
+    BootstrapTimelineWorker.perform_async(account_id)
+    ActivityTracker.increment('activity:accounts:local')
+  end
 end
diff --git a/app/serializers/activitypub/delete_actor_serializer.rb b/app/serializers/activitypub/delete_actor_serializer.rb
new file mode 100644
index 000000000..dfea9db4a
--- /dev/null
+++ b/app/serializers/activitypub/delete_actor_serializer.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+class ActivityPub::DeleteActorSerializer < ActiveModel::Serializer
+  attributes :id, :type, :actor
+  attribute :virtual_object, key: :object
+
+  def id
+    [ActivityPub::TagManager.instance.uri_for(object), '#delete'].join
+  end
+
+  def type
+    'Delete'
+  end
+
+  def actor
+    ActivityPub::TagManager.instance.uri_for(object)
+  end
+
+  def virtual_object
+    actor
+  end
+end
diff --git a/app/serializers/activitypub/follow_serializer.rb b/app/serializers/activitypub/follow_serializer.rb
index 86c9992fe..eecd64701 100644
--- a/app/serializers/activitypub/follow_serializer.rb
+++ b/app/serializers/activitypub/follow_serializer.rb
@@ -1,11 +1,12 @@
 # frozen_string_literal: true
 
 class ActivityPub::FollowSerializer < ActiveModel::Serializer
-  attributes :id, :type, :actor
+  attributes :type, :actor
+  attribute :id, if: :dereferencable?
   attribute :virtual_object, key: :object
 
   def id
-    [ActivityPub::TagManager.instance.uri_for(object.account), '#follows/', object.id].join
+    ActivityPub::TagManager.instance.uri_for(object)
   end
 
   def type
@@ -19,4 +20,8 @@ class ActivityPub::FollowSerializer < ActiveModel::Serializer
   def virtual_object
     ActivityPub::TagManager.instance.uri_for(object.target_account)
   end
+
+  def dereferencable?
+    object.respond_to?(:object_type)
+  end
 end
diff --git a/app/serializers/rest/instance_serializer.rb b/app/serializers/rest/instance_serializer.rb
index abbacc374..65907dad2 100644
--- a/app/serializers/rest/instance_serializer.rb
+++ b/app/serializers/rest/instance_serializer.rb
@@ -27,7 +27,7 @@ class REST::InstanceSerializer < ActiveModel::Serializer
   end
 
   def thumbnail
-    full_asset_url(instance_presenter.thumbnail.file.url) if instance_presenter.thumbnail
+    instance_presenter.thumbnail ? full_asset_url(instance_presenter.thumbnail.file.url) : full_pack_url('preview.jpg')
   end
 
   def max_toot_chars
diff --git a/app/services/activitypub/process_account_service.rb b/app/services/activitypub/process_account_service.rb
index 06ca75563..0fbf18c00 100644
--- a/app/services/activitypub/process_account_service.rb
+++ b/app/services/activitypub/process_account_service.rb
@@ -74,7 +74,7 @@ class ActivityPub::ProcessAccountService < BaseService
     @account.statuses_count    = outbox_total_items    if outbox_total_items.present?
     @account.following_count   = following_total_items if following_total_items.present?
     @account.followers_count   = followers_total_items if followers_total_items.present?
-    @account.moved_to_account  = moved_account         if @json['movedTo'].present?
+    @account.moved_to_account  = @json['movedTo'].present? ? moved_account : nil
   end
 
   def after_protocol_change!
diff --git a/app/services/batched_remove_status_service.rb b/app/services/batched_remove_status_service.rb
index 21c775208..cb65a2256 100644
--- a/app/services/batched_remove_status_service.rb
+++ b/app/services/batched_remove_status_service.rb
@@ -17,9 +17,7 @@ class BatchedRemoveStatusService < BaseService
 
     @stream_entry_batches  = []
     @salmon_batches        = []
-    @activity_json_batches = []
     @json_payloads         = statuses.map { |s| [s.id, Oj.dump(event: :delete, payload: s.id.to_s)] }.to_h
-    @activity_json         = {}
     @activity_xml          = {}
 
     # Ensure that rendered XML reflects destroyed state
@@ -32,10 +30,7 @@ class BatchedRemoveStatusService < BaseService
       unpush_from_home_timelines(account, account_statuses)
       unpush_from_list_timelines(account, account_statuses)
 
-      if account.local?
-        batch_stream_entries(account, account_statuses)
-        batch_activity_json(account, account_statuses)
-      end
+      batch_stream_entries(account, account_statuses) if account.local?
     end
 
     # Cannot be batched
@@ -47,7 +42,6 @@ class BatchedRemoveStatusService < BaseService
 
     Pubsubhubbub::RawDistributionWorker.push_bulk(@stream_entry_batches) { |batch| batch }
     NotificationWorker.push_bulk(@salmon_batches) { |batch| batch }
-    ActivityPub::DeliveryWorker.push_bulk(@activity_json_batches) { |batch| batch }
   end
 
   private
@@ -58,22 +52,6 @@ class BatchedRemoveStatusService < BaseService
     end
   end
 
-  def batch_activity_json(account, statuses)
-    account.followers.inboxes.each do |inbox_url|
-      statuses.each do |status|
-        @activity_json_batches << [build_json(status), account.id, inbox_url]
-      end
-    end
-
-    statuses.each do |status|
-      other_recipients = (status.mentions + status.reblogs).map(&:account).reject(&:local?).select(&:activitypub?).uniq(&:id)
-
-      other_recipients.each do |target_account|
-        @activity_json_batches << [build_json(status), account.id, target_account.inbox_url]
-      end
-    end
-  end
-
   def unpush_from_home_timelines(account, statuses)
     recipients = account.followers.local.to_a
 
@@ -134,23 +112,9 @@ class BatchedRemoveStatusService < BaseService
     Redis.current
   end
 
-  def build_json(status)
-    return @activity_json[status.id] if @activity_json.key?(status.id)
-
-    @activity_json[status.id] = sign_json(status, ActiveModelSerializers::SerializableResource.new(
-      status,
-      serializer: status.reblog? ? ActivityPub::UndoAnnounceSerializer : ActivityPub::DeleteSerializer,
-      adapter: ActivityPub::Adapter
-    ).as_json)
-  end
-
   def build_xml(stream_entry)
     return @activity_xml[stream_entry.id] if @activity_xml.key?(stream_entry.id)
 
     @activity_xml[stream_entry.id] = stream_entry_to_xml(stream_entry)
   end
-
-  def sign_json(status, json)
-    Oj.dump(ActivityPub::LinkedDataSignature.new(json).sign!(status.account))
-  end
 end
diff --git a/app/services/fetch_atom_service.rb b/app/services/fetch_atom_service.rb
index 1c47a22da..c01e8d071 100644
--- a/app/services/fetch_atom_service.rb
+++ b/app/services/fetch_atom_service.rb
@@ -50,7 +50,7 @@ class FetchAtomService < BaseService
         @unsupported_activity = true
         nil
       end
-    elsif @response['Link'] && !terminal
+    elsif @response['Link'] && !terminal && link_header.find_link(%w(rel alternate))
       process_headers
     elsif @response.mime_type == 'text/html' && !terminal
       process_html
@@ -70,8 +70,6 @@ class FetchAtomService < BaseService
   end
 
   def process_headers
-    link_header = LinkHeader.parse(@response['Link'].is_a?(Array) ? @response['Link'].first : @response['Link'])
-
     json_link = link_header.find_link(%w(rel alternate), %w(type application/activity+json)) || link_header.find_link(%w(rel alternate), ['type', 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"'])
     atom_link = link_header.find_link(%w(rel alternate), %w(type application/atom+xml))
 
@@ -80,4 +78,8 @@ class FetchAtomService < BaseService
 
     result
   end
+
+  def link_header
+    @link_header ||= LinkHeader.parse(@response['Link'].is_a?(Array) ? @response['Link'].first : @response['Link'])
+  end
 end
diff --git a/app/services/suspend_account_service.rb b/app/services/suspend_account_service.rb
index 958b28cdc..56fa2d8dd 100644
--- a/app/services/suspend_account_service.rb
+++ b/app/services/suspend_account_service.rb
@@ -22,6 +22,8 @@ class SuspendAccountService < BaseService
   end
 
   def purge_content!
+    ActivityPub::RawDistributionWorker.perform_async(delete_actor_json, @account.id) if @account.local?
+
     @account.statuses.reorder(nil).find_in_batches do |statuses|
       BatchedRemoveStatusService.new.call(statuses)
     end
@@ -54,4 +56,14 @@ class SuspendAccountService < BaseService
   def destroy_all(association)
     association.in_batches.destroy_all
   end
+
+  def delete_actor_json
+    payload = ActiveModelSerializers::SerializableResource.new(
+      @account,
+      serializer: ActivityPub::DeleteActorSerializer,
+      adapter: ActivityPub::Adapter
+    ).as_json
+
+    Oj.dump(ActivityPub::LinkedDataSignature.new(payload).sign!(@account))
+  end
 end
diff --git a/app/views/accounts/_header.html.haml b/app/views/accounts/_header.html.haml
index b0062752c..aae01c311 100644
--- a/app/views/accounts/_header.html.haml
+++ b/app/views/accounts/_header.html.haml
@@ -9,7 +9,7 @@
               = fa_icon 'user-times'
               = t('accounts.unfollow')
           - else
-            = link_to account_follow_path(account), data: { method: :post }, class: 'icon-button' do
+            = link_to account_follows_path(account), data: { method: :post }, class: 'icon-button' do
               = fa_icon 'user-plus'
               = t('accounts.follow')
       - elsif !user_signed_in?
diff --git a/app/views/admin/custom_emojis/_custom_emoji.html.haml b/app/views/admin/custom_emojis/_custom_emoji.html.haml
index f7fd2538c..fbaa9a174 100644
--- a/app/views/admin/custom_emojis/_custom_emoji.html.haml
+++ b/app/views/admin/custom_emojis/_custom_emoji.html.haml
@@ -11,18 +11,18 @@
   %td
     - if custom_emoji.local?
       - if custom_emoji.visible_in_picker
-        = table_link_to 'eye', t('admin.custom_emojis.listed'), admin_custom_emoji_path(custom_emoji, custom_emoji: { visible_in_picker: false }), method: :patch
+        = table_link_to 'eye', t('admin.custom_emojis.listed'), admin_custom_emoji_path(custom_emoji, custom_emoji: { visible_in_picker: false }, page: params[:page], **@filter_params), method: :patch
       - else
-        = table_link_to 'eye-slash', t('admin.custom_emojis.unlisted'), admin_custom_emoji_path(custom_emoji, custom_emoji: { visible_in_picker: true }), method: :patch
+        = table_link_to 'eye-slash', t('admin.custom_emojis.unlisted'), admin_custom_emoji_path(custom_emoji, custom_emoji: { visible_in_picker: true }, page: params[:page], **@filter_params), method: :patch
     - else
       - if custom_emoji.local_counterpart.present?
-        = link_to safe_join([custom_emoji_tag(custom_emoji.local_counterpart), t('admin.custom_emojis.overwrite')]), copy_admin_custom_emoji_path(custom_emoji, page: params[:page]), method: :post, class: 'table-action-link'
+        = link_to safe_join([custom_emoji_tag(custom_emoji.local_counterpart), t('admin.custom_emojis.overwrite')]), copy_admin_custom_emoji_path(custom_emoji, page: params[:page], **@filter_params), method: :post, class: 'table-action-link'
       - else
-        = table_link_to 'copy', t('admin.custom_emojis.copy'), copy_admin_custom_emoji_path(custom_emoji, page: params[:page]), method: :post
+        = table_link_to 'copy', t('admin.custom_emojis.copy'), copy_admin_custom_emoji_path(custom_emoji, page: params[:page], **@filter_params), 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') }
+      = table_link_to 'power-off', t('admin.custom_emojis.enable'), enable_admin_custom_emoji_path(custom_emoji, page: params[:page], **@filter_params), 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') }
+      = table_link_to 'power-off', t('admin.custom_emojis.disable'), disable_admin_custom_emoji_path(custom_emoji, page: params[:page], **@filter_params), 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') }
+    = table_link_to 'times', t('admin.custom_emojis.delete'), admin_custom_emoji_path(custom_emoji, page: params[:page], **@filter_params), 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 89ea3a6fe..3a119276c 100644
--- a/app/views/admin/custom_emojis/index.html.haml
+++ b/app/views/admin/custom_emojis/index.html.haml
@@ -29,7 +29,7 @@
 
     .actions
       %button= t('admin.accounts.search')
-      = link_to t('admin.accounts.reset'), admin_accounts_path, class: 'button negative'
+      = link_to t('admin.accounts.reset'), admin_custom_emojis_path, class: 'button negative'
 
 .table-wrapper
   %table.table
diff --git a/app/views/admin/settings/edit.html.haml b/app/views/admin/settings/edit.html.haml
index c7c25f528..4f9115ed2 100644
--- a/app/views/admin/settings/edit.html.haml
+++ b/app/views/admin/settings/edit.html.haml
@@ -46,5 +46,13 @@
   .fields-group
     = f.input :bootstrap_timeline_accounts, wrapper: :with_block_label, label: t('admin.settings.bootstrap_timeline_accounts.title'), hint: t('admin.settings.bootstrap_timeline_accounts.desc_html')
 
+  %hr/
+
+  .fields-group
+    = f.input :activity_api_enabled, as: :boolean, wrapper: :with_label, label: t('admin.settings.activity_api_enabled.title'), hint: t('admin.settings.activity_api_enabled.desc_html')
+
+  .fields-group
+    = f.input :peers_api_enabled, as: :boolean, wrapper: :with_label, label: t('admin.settings.peers_api_enabled.title'), hint: t('admin.settings.peers_api_enabled.desc_html')
+
   .actions
     = f.button :button, t('generic.save_changes'), type: :submit
diff --git a/app/views/layouts/application.html.haml b/app/views/layouts/application.html.haml
index 5b9e652cb..322d7403e 100755
--- a/app/views/layouts/application.html.haml
+++ b/app/views/layouts/application.html.haml
@@ -2,8 +2,7 @@
 %html{ lang: I18n.locale }
   %head
     %meta{ charset: 'utf-8' }/
-    %meta{ name: 'viewport', content: 'width=device-width, initial-scale=1' }/
-    %meta{ 'http-equiv' => 'X-UA-Compatible', content: 'IE=edge' }/
+    %meta{ name: 'viewport', content: 'width=device-width, initial-scale=1' }/   
     %link{ rel: 'icon', href: favicon_path, type: 'image/x-icon' }/
     %link{ rel: 'apple-touch-icon', sizes: '180x180', href: '/apple-touch-icon.png' }/
     %link{ rel: 'mask-icon', href: '/mask-icon.svg', color: '#2B90D9' }/
diff --git a/app/views/layouts/modal.html.haml b/app/views/layouts/modal.html.haml
index d3519f032..a5d79f5c0 100644
--- a/app/views/layouts/modal.html.haml
+++ b/app/views/layouts/modal.html.haml
@@ -9,5 +9,7 @@
         = fa_icon 'sign-out'
 
   .container= yield
+  .modal-layout__mastodon
+    %div
 
 = render template: 'layouts/application'
diff --git a/app/views/user_mailer/confirmation_instructions.pl.html.erb b/app/views/user_mailer/confirmation_instructions.pl.html.erb
index 973950184..2285b5c6e 100644
--- a/app/views/user_mailer/confirmation_instructions.pl.html.erb
+++ b/app/views/user_mailer/confirmation_instructions.pl.html.erb
@@ -1,4 +1,4 @@
-<p>Witaj, <%= @resource.email %> !</p>
+<p>Witaj, <%= @resource.email %>!</p>
 
 <p>Właśnie utworzyłeś konto na instancji <%= @instance %>.</p>
 
diff --git a/app/views/user_mailer/confirmation_instructions.pl.text.erb b/app/views/user_mailer/confirmation_instructions.pl.text.erb
index 9640d8ddd..f20082e16 100644
--- a/app/views/user_mailer/confirmation_instructions.pl.text.erb
+++ b/app/views/user_mailer/confirmation_instructions.pl.text.erb
@@ -1,4 +1,4 @@
-Witaj, <%= @resource.email %> !
+Witaj, <%= @resource.email %>!
 
 Właśnie utworzyłeś konto na instancji <%= @instance %>.
 
diff --git a/app/views/user_mailer/confirmation_instructions.sr-Latn.html.erb b/app/views/user_mailer/confirmation_instructions.sr-Latn.html.erb
new file mode 100644
index 000000000..a16008250
--- /dev/null
+++ b/app/views/user_mailer/confirmation_instructions.sr-Latn.html.erb
@@ -0,0 +1,15 @@
+<p>Dobrodošao <%= @resource.email %> !</p>
+
+<p>Upravo ste napravili nalog na instanci <%= @instance %>.</p>
+
+<p>Da potvrdite Vašu registraciju, molimo Vas kliknite na sledeći link: <br>
+<%= link_to 'Potvrdi moj nalog', confirmation_url(@resource, confirmation_token: @token) %></p>
+
+<p>Ako link iznad ne radi, kopirajte i nalepite ovu adresu u adresnu traku: <br>
+<span><%= confirmation_url(@resource, confirmation_token: @token) %></span>
+
+<p>Takođe pogledajte i <%= link_to 'pravila i uslove korišćenja', terms_url %>.</p>
+
+<p>S poštovanjem,<p>
+
+<p><%= @instance %> tim</p>
diff --git a/app/views/user_mailer/confirmation_instructions.sr-Latn.text.erb b/app/views/user_mailer/confirmation_instructions.sr-Latn.text.erb
new file mode 100644
index 000000000..60fe9db0d
--- /dev/null
+++ b/app/views/user_mailer/confirmation_instructions.sr-Latn.text.erb
@@ -0,0 +1,12 @@
+Dobrodošao <%= @resource.email %> !
+
+Upravo ste napravili nalog na instanci <%= @instance %>.
+
+Da potvrdite Vašu registraciju, molimo Vas kliknite na sledeći link:
+<%= confirmation_url(@resource, confirmation_token: @token) %>
+
+Takođe pogledajte i pravila i uslove korišćenja <%= terms_url %>
+
+S poštovanjem,
+
+<%= @instance %> tim
diff --git a/app/views/user_mailer/confirmation_instructions.sr.html.erb b/app/views/user_mailer/confirmation_instructions.sr.html.erb
new file mode 100644
index 000000000..09203cc9a
--- /dev/null
+++ b/app/views/user_mailer/confirmation_instructions.sr.html.erb
@@ -0,0 +1,15 @@
+<p>Добродошао <%= @resource.email %> !</p>
+
+<p>Управо сте направили налог на инстанци <%= @instance %>.</p>
+
+<p>Да потврдите Вашу регистрацију, молимо Вас кликните на следећи линк: <br>
+<%= link_to 'Потврди мој налог', confirmation_url(@resource, confirmation_token: @token) %></p>
+
+<p>Ако линк изнад не ради, копирајте и налепите ову адресу у адресну траку: <br>
+<span><%= confirmation_url(@resource, confirmation_token: @token) %></span>
+
+<p>Такође погледајте и <%= link_to 'правила и услове коришћења', terms_url %>.</p>
+
+<p>С поштовањем,<p>
+
+<p><%= @instance %> тим</p>
diff --git a/app/views/user_mailer/confirmation_instructions.sr.text.erb b/app/views/user_mailer/confirmation_instructions.sr.text.erb
new file mode 100644
index 000000000..e7cb7e188
--- /dev/null
+++ b/app/views/user_mailer/confirmation_instructions.sr.text.erb
@@ -0,0 +1,12 @@
+Добродошао <%= @resource.email %> !
+
+Управо сте направили налог на инстанци <%= @instance %>.
+
+Да потврдите Вашу регистрацију, молимо Вас кликните на следећи линк:
+<%= confirmation_url(@resource, confirmation_token: @token) %>
+
+Такође погледајте и правила и услове коришћења <%= terms_url %>
+
+С поштовањем,
+
+<%= @instance %> тим
diff --git a/app/views/user_mailer/email_changed.en.html.erb b/app/views/user_mailer/email_changed.en.html.erb
new file mode 100644
index 000000000..c10680086
--- /dev/null
+++ b/app/views/user_mailer/email_changed.en.html.erb
@@ -0,0 +1,15 @@
+<p>Hello <%= @resource.email %>!</p>
+
+<% if @resource&.unconfirmed_email? %>
+  <p>We're contacting you to notify you that the email you use on <%= @instance %> is being changed to <%= @resource.unconfirmed_email %>.</p>
+<% else %>
+  <p>We're contacting you to notify you that the email you use on <%= @instance %> has been changed to <%= @resource.email %>.</p>
+<% end %>
+
+<p>
+  If you did not change your email, it is likely that someone has gained access to your account. Please change your password immediately or contact the instance admin if you're locked out of your account.
+</p>
+
+<p>Sincerely,<p>
+
+<p>The <%= @instance %> team</p>
diff --git a/app/views/user_mailer/email_changed.en.text.erb b/app/views/user_mailer/email_changed.en.text.erb
new file mode 100644
index 000000000..971972461
--- /dev/null
+++ b/app/views/user_mailer/email_changed.en.text.erb
@@ -0,0 +1,13 @@
+Hello <%= @resource.email %>!
+
+<% if @resource&.unconfirmed_email? %>
+We're contacting you to notify you that the email you use on <%= @instance %> is being changed to <%= @resource.unconfirmed_email %>.
+<% else %>
+We're contacting you to notify you that the email you use on <%= @instance %> has been changed to <%= @resource.email %>.
+<% end %>
+
+If you did not change your email, it is likely that someone has gained access to your account. Please change your password immediately or contact the instance admin if you're locked out of your account.
+
+Sincerely,
+
+The <%= @instance %> team
diff --git a/app/views/user_mailer/email_changed.ja.html.erb b/app/views/user_mailer/email_changed.ja.html.erb
new file mode 100644
index 000000000..c66f409c6
--- /dev/null
+++ b/app/views/user_mailer/email_changed.ja.html.erb
@@ -0,0 +1,13 @@
+<p>こんにちは<%= @resource.email %>さん</p>
+
+<% if @resource&.unconfirmed_email? %>
+  <p><%= @instance %>で使っているメールアドレスが<%= @resource.unconfirmed_email %>に変更されようとしています。</p>
+<% else %>
+  <p><%= @instance %>で使っているメールアドレスが<%= @resource.email %>に変更されました。</p>
+<% end %>
+
+<p>
+  メールアドレスを変更した覚えがない場合、誰かがあなたのアカウントにアクセスしたおそれがあります。すぐにパスワードを変更するか、アカウントにアクセスできない場合はインスタンスの管理者に連絡してください。
+</p>
+
+<p><%= @instance %>チームより</p>
diff --git a/app/views/user_mailer/email_changed.ja.text.erb b/app/views/user_mailer/email_changed.ja.text.erb
new file mode 100644
index 000000000..33ee6d10b
--- /dev/null
+++ b/app/views/user_mailer/email_changed.ja.text.erb
@@ -0,0 +1,11 @@
+Hello <%= @resource.email %>!
+
+<% if @resource&.unconfirmed_email? %>
+<%= @instance %>で使っているメールアドレスが<%= @resource.unconfirmed_email %>に変更されようとしています。
+<% else %>
+<%= @instance %>で使っているメールアドレスが<%= @resource.email %>に変更されました。
+<% end %>
+
+メールアドレスを変更した覚えがない場合、誰かがあなたのアカウントにアクセスしたおそれがあります。すぐにパスワードを変更するか、アカウントにアクセスできない場合はインスタンスの管理者に連絡してください。
+
+<%= @instance %>チームより
diff --git a/app/views/user_mailer/email_changed.pl.html.erb b/app/views/user_mailer/email_changed.pl.html.erb
new file mode 100644
index 000000000..9ed122b0f
--- /dev/null
+++ b/app/views/user_mailer/email_changed.pl.html.erb
@@ -0,0 +1,15 @@
+<p>Witaj, <%= @resource.email %>!</p>
+
+<% if @resource&.unconfirmed_email? %>
+  <p>Informujemy, że e-mail używany przez Ciebie na <%= @instance %> został zmieniony na <%= @resource.unconfirmed_email %>.</p>
+<% else %>
+  <p>Informujemy, że e-mail używany przez Ciebie na <%= @instance %> został zmieniony na <%= @resource.email %>.</p>
+<% end %>
+
+<p>
+  Jeżeli to nie Ty, prawdopodobnie ktoś uzyskał dostęp do Twojego konta. Zalecana jest natychmiastowa zmiana hasła lub skontaktowanie się z administratorem, jeżeli nie masz dostępu do swojego konta.
+</p>
+
+<p>Z pozdrowieniami,<p>
+
+<p>Zespół <%= @instance %></p>
diff --git a/app/views/user_mailer/email_changed.pl.text.erb b/app/views/user_mailer/email_changed.pl.text.erb
new file mode 100644
index 000000000..134a79e95
--- /dev/null
+++ b/app/views/user_mailer/email_changed.pl.text.erb
@@ -0,0 +1,13 @@
+Witaj, <%= @resource.email %>!
+
+<% if @resource&.unconfirmed_email? %>
+Informujemy, że e-mail używany przez Ciebie na <%= @instance %> został zmieniony na <%= @resource.unconfirmed_email %>.
+<% else %>
+Informujemy, że e-mail używany przez Ciebie na <%= @instance %> został zmieniony na <%= @resource.email %>.
+<% end %>
+
+Jeżeli to nie Ty, prawdopodobnie ktoś uzyskał dostęp do Twojego konta. Zalecana jest natychmiastowa zmiana hasła lub skontaktowanie się z administratorem, jeżeli nie masz dostępu do swojego konta.
+
+Z pozdrowieniami,
+
+Zespół <%= @instance %>
diff --git a/app/views/user_mailer/password_change.sr-Latn.html.erb b/app/views/user_mailer/password_change.sr-Latn.html.erb
new file mode 100644
index 000000000..ab4e23bdf
--- /dev/null
+++ b/app/views/user_mailer/password_change.sr-Latn.html.erb
@@ -0,0 +1,3 @@
+<p>Zdravo <%= @resource.email %>!</p>
+
+<p>Želimo samo da Vas obavestimo da je Vaša lozinka na Mastodont instanci <%= @instance %> promenjena.</p>
diff --git a/app/views/user_mailer/password_change.sr-Latn.text.erb b/app/views/user_mailer/password_change.sr-Latn.text.erb
new file mode 100644
index 000000000..6e0666d8d
--- /dev/null
+++ b/app/views/user_mailer/password_change.sr-Latn.text.erb
@@ -0,0 +1,3 @@
+Zdravo <%= @resource.email %>!
+
+Želimo samo da Vas obavestimo da je Vaša lozinka na Mastodont instanci <%= @instance %> promenjena.
diff --git a/app/views/user_mailer/password_change.sr.html.erb b/app/views/user_mailer/password_change.sr.html.erb
new file mode 100644
index 000000000..4bb61b74f
--- /dev/null
+++ b/app/views/user_mailer/password_change.sr.html.erb
@@ -0,0 +1,3 @@
+<p>Здраво <%= @resource.email %>!</p>
+
+<p>Желимо само да Вас обавестимо да је Ваша лозинка на Мастодонт инстанци <%= @instance %> промењена.</p>
diff --git a/app/views/user_mailer/password_change.sr.text.erb b/app/views/user_mailer/password_change.sr.text.erb
new file mode 100644
index 000000000..9082201c0
--- /dev/null
+++ b/app/views/user_mailer/password_change.sr.text.erb
@@ -0,0 +1,3 @@
+Здраво <%= @resource.email %>!
+
+Желимо само да Вас обавестимо да је Ваша лозинка на Мастодонт инстанци <%= @instance %> промењена.
diff --git a/app/views/user_mailer/reconfirmation_instructions.en.html.erb b/app/views/user_mailer/reconfirmation_instructions.en.html.erb
new file mode 100644
index 000000000..31866a3c8
--- /dev/null
+++ b/app/views/user_mailer/reconfirmation_instructions.en.html.erb
@@ -0,0 +1,15 @@
+<p>Hello <%= @resource.unconfirmed_email %>!</p>
+
+<p>You requested a change to the email address you use on <%= @instance %>.</p>
+
+<p>To confirm your new email, please click on the following link:<br>
+<%= link_to 'Confirm my email address', 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>
diff --git a/app/views/user_mailer/reconfirmation_instructions.en.text.erb b/app/views/user_mailer/reconfirmation_instructions.en.text.erb
new file mode 100644
index 000000000..c1c735b3a
--- /dev/null
+++ b/app/views/user_mailer/reconfirmation_instructions.en.text.erb
@@ -0,0 +1,12 @@
+Hello <%= @resource.unconfirmed_email %>!
+
+You requested a change to the email address you use on <%= @instance %>.
+
+To confirm your new email, please click on the following link:
+<%= confirmation_url(@resource, confirmation_token: @token) %>
+
+Please also check out our terms and conditions <%= terms_url %>
+
+Sincerely,
+
+The <%= @instance %> team
diff --git a/app/views/user_mailer/reconfirmation_instructions.ja.html.erb b/app/views/user_mailer/reconfirmation_instructions.ja.html.erb
new file mode 100644
index 000000000..caa53032a
--- /dev/null
+++ b/app/views/user_mailer/reconfirmation_instructions.ja.html.erb
@@ -0,0 +1,13 @@
+<p>こんにちは<%= @resource.unconfirmed_email %>さん</p>
+
+<p><%= @instance %>で使っているメールアドレスの変更をあなたがリクエストしました。</p>
+
+<p>新しいメールアドレスを確認するには次のリンクをクリックしてください:<br>
+<%= link_to 'わたしのメールアドレスを確認する', confirmation_url(@resource, confirmation_token: @token) %></p>
+
+<p>上記のリンクがうまくいかなかった場合はこのURLをコピーしてアドレスバーに貼り付けてください:<br>
+<span><%= confirmation_url(@resource, confirmation_token: @token) %></span>
+
+<p>また<%= link_to '利用規約', terms_url %>もご確認ください。</p>
+
+<p><%= @instance %>チームより</p>
diff --git a/app/views/user_mailer/reconfirmation_instructions.ja.text.erb b/app/views/user_mailer/reconfirmation_instructions.ja.text.erb
new file mode 100644
index 000000000..5326e4512
--- /dev/null
+++ b/app/views/user_mailer/reconfirmation_instructions.ja.text.erb
@@ -0,0 +1,10 @@
+こんにちは<%= @resource.unconfirmed_email %>さん
+
+<%= @instance %>で使っているメールアドレスの変更をあなたがリクエストしました。
+
+新しいメールアドレスを確認するには次のリンクをクリックしてください:
+<%= confirmation_url(@resource, confirmation_token: @token) %>
+
+また利用規約もご確認ください <%= terms_url %>
+
+<%= @instance %>チームより
diff --git a/app/views/user_mailer/reconfirmation_instructions.pl.html.erb b/app/views/user_mailer/reconfirmation_instructions.pl.html.erb
new file mode 100644
index 000000000..57cdc42e1
--- /dev/null
+++ b/app/views/user_mailer/reconfirmation_instructions.pl.html.erb
@@ -0,0 +1,15 @@
+<p>Witaj, <%= @resource.unconfirmed_email %>!</p>
+
+<p>Dokonano próby zmiany adresu e-mail, którego używasz na <%= @instance %>.</p>
+
+<p>Aby potwierdzić posiadanie tego adresu e-mail, kliknij na poniższy odnośnik:<br>
+<%= link_to 'Potwierdź mój adres e-mail', confirmation_url(@resource, confirmation_token: @token) %></p>
+
+<p>Jeżeli ten odnośnik nie działa, wklej następujący adres w pasek adresu Twojej przeglądarki: <br>
+<span><%= confirmation_url(@resource, confirmation_token: @token) %></span>
+
+<p>Pamiętaj o przeczytaniu naszych <%= link_to 'zasad użytkowania', terms_url %>.</p>
+
+<p>Z pozdrowieniami,<p>
+
+<p>Zespół <%= @instance %></p>
diff --git a/app/views/user_mailer/reconfirmation_instructions.pl.text.erb b/app/views/user_mailer/reconfirmation_instructions.pl.text.erb
new file mode 100644
index 000000000..032718f81
--- /dev/null
+++ b/app/views/user_mailer/reconfirmation_instructions.pl.text.erb
@@ -0,0 +1,12 @@
+Witaj, <%= @resource.unconfirmed_email %>!
+
+Dokonano próby zmiany adresu e-mail, którego używasz na <%= @instance %>.
+
+Aby potwierdzić posiadanie tego adresu e-mail, kliknij na poniższy odnośnik:
+<%= confirmation_url(@resource, confirmation_token: @token) %>
+
+Pamiętaj o przeczytaniu naszych zasad użytkowania: <%= terms_url %>
+
+Z pozdrowieniami,
+
+Zespół <%= @instance %>
diff --git a/app/views/user_mailer/reset_password_instructions.sr-Latn.html.erb b/app/views/user_mailer/reset_password_instructions.sr-Latn.html.erb
new file mode 100644
index 000000000..7dede16b2
--- /dev/null
+++ b/app/views/user_mailer/reset_password_instructions.sr-Latn.html.erb
@@ -0,0 +1,8 @@
+<p>Zdravo <%= @resource.email %>!</p>
+
+<p>Neko je zatražio link za promenu lozinke na instanci <%= @instance %>. Ovo možete uraditi klikom na link ispod.</p>
+
+<p><%= link_to 'Promeni moju lozinku', edit_password_url(@resource, reset_password_token: @token) %></p>
+
+<p>Ignorišite ovu poruku, ako niste Vi bili ti koji ste zatražili promenu lozinke.</p>
+<p>Lozinka se neće promeniti sve dok ne kliknete link iznad i ne napravite novu lozinku.</p>
diff --git a/app/views/user_mailer/reset_password_instructions.sr-Latn.text.erb b/app/views/user_mailer/reset_password_instructions.sr-Latn.text.erb
new file mode 100644
index 000000000..31707dee1
--- /dev/null
+++ b/app/views/user_mailer/reset_password_instructions.sr-Latn.text.erb
@@ -0,0 +1,8 @@
+Zdravo <%= @resource.email %>!
+
+Neko je zatražio link za promenu lozinke na instanci <%= @instance %>. Ovo možete uraditi preko linka ispod.
+
+<%= edit_password_url(@resource, reset_password_token: @token) %>
+
+Ignorišite ovu poruku, ako niste Vi bili ti koji ste zatražili promenu lozinke.
+Lozinka se neće promeniti sve dok ne kliknete link iznad i ne napravite novu lozinku.
diff --git a/app/views/user_mailer/reset_password_instructions.sr.html.erb b/app/views/user_mailer/reset_password_instructions.sr.html.erb
new file mode 100644
index 000000000..be8d0c3ed
--- /dev/null
+++ b/app/views/user_mailer/reset_password_instructions.sr.html.erb
@@ -0,0 +1,8 @@
+<p>Здраво <%= @resource.email %>!</p>
+
+<p>Неко је затражио линк за промену лозинке на инстанци <%= @instance %>. Ово можете урадити кликом на линк испод.</p>
+
+<p><%= link_to 'Промени моју лозинку', edit_password_url(@resource, reset_password_token: @token) %></p>
+
+<p>Игноришите ову поруку, ако нисте Ви били ти који сте затражили промену лозинке.</p>
+<p>Лозинка се неће променити све док не кликнете линк изнад и не направите нову лозинку.</p>
diff --git a/app/views/user_mailer/reset_password_instructions.sr.text.erb b/app/views/user_mailer/reset_password_instructions.sr.text.erb
new file mode 100644
index 000000000..86ea32b05
--- /dev/null
+++ b/app/views/user_mailer/reset_password_instructions.sr.text.erb
@@ -0,0 +1,8 @@
+Здраво <%= @resource.email %>!
+
+Неко је затражио линк за промену лозинке на инстанци <%= @instance %>. Ово можете урадити преко линка испод.
+
+<%= edit_password_url(@resource, reset_password_token: @token) %>
+
+Игноришите ову поруку, ако нисте Ви били ти који сте затражили промену лозинке.
+Лозинка се неће променити све док не кликнете линк изнад и не направите нову лозинку.