about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--Gemfile2
-rw-r--r--Gemfile.lock6
-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
-rw-r--r--config/application.rb2
-rw-r--r--config/initializers/devise.rb5
-rw-r--r--config/locales/activerecord.sr-Latn.yml13
-rw-r--r--config/locales/activerecord.sr.yml13
-rw-r--r--config/locales/devise.en.yml4
-rw-r--r--config/locales/devise.ja.yml4
-rw-r--r--config/locales/devise.oc.yml4
-rw-r--r--config/locales/devise.pl.yml6
-rw-r--r--config/locales/devise.sr-Latn.yml63
-rw-r--r--config/locales/devise.sr.yml63
-rw-r--r--config/locales/doorkeeper.sr-Latn.yml119
-rw-r--r--config/locales/doorkeeper.sr.yml119
-rw-r--r--config/locales/en.yml6
-rw-r--r--config/locales/ja.yml8
-rw-r--r--config/locales/ko.yml6
-rw-r--r--config/locales/oc.yml8
-rw-r--r--config/locales/pl.yml12
-rw-r--r--config/locales/simple_form.sr-Latn.yml75
-rw-r--r--config/locales/simple_form.sr.yml75
-rw-r--r--config/locales/simple_form.zh-TW.yml11
-rw-r--r--config/locales/sr-Latn.yml723
-rw-r--r--config/locales/sr.yml723
-rw-r--r--config/locales/zh-TW.yml37
-rw-r--r--config/routes.rb9
-rw-r--r--config/settings.yml3
-rw-r--r--db/migrate/20171129172043_add_index_on_stream_entries.rb3
-rw-r--r--db/migrate/20171226094803_more_faster_index_on_notifications.rb3
-rw-r--r--lib/mastodon/version.rb2
-rw-r--r--spec/controllers/activitypub/follows_controller_spec.rb43
-rw-r--r--spec/controllers/auth/confirmations_controller_spec.rb40
-rw-r--r--spec/fixtures/requests/oembed_json_xml.html8
-rw-r--r--spec/fixtures/requests/oembed_xml.html8
-rw-r--r--spec/lib/activitypub/activity/accept_spec.rb53
-rw-r--r--spec/lib/formatter_spec.rb20
-rw-r--r--spec/mailers/user_mailer_spec.rb26
-rw-r--r--spec/models/follow_request_spec.rb8
-rw-r--r--spec/models/user_spec.rb8
257 files changed, 8056 insertions, 3727 deletions
diff --git a/Gemfile b/Gemfile
index bea840041..b9bcf6a43 100644
--- a/Gemfile
+++ b/Gemfile
@@ -29,7 +29,7 @@ gem 'browser'
 gem 'charlock_holmes', '~> 0.7.5'
 gem 'iso-639'
 gem 'cld3', '~> 3.2.0'
-gem 'devise', '~> 4.2'
+gem 'devise', '~> 4.3'
 gem 'devise-two-factor', '~> 3.0'
 gem 'doorkeeper', '~> 4.2'
 gem 'fast_blank', '~> 1.0'
diff --git a/Gemfile.lock b/Gemfile.lock
index 8af7872af..f6986cb4d 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -299,13 +299,11 @@ GEM
       sidekiq (>= 3.5.0)
       statsd-ruby (~> 1.2.0)
     oj (3.3.9)
-    openssl (2.0.6)
     orm_adapter (0.5.0)
-    ostatus2 (2.0.1)
+    ostatus2 (2.0.2)
       addressable (~> 2.4)
       http (~> 2.0)
       nokogiri (~> 1.6)
-      openssl (~> 2.0)
     ox (2.8.2)
     paperclip (5.1.0)
       activemodel (>= 4.2.0)
@@ -562,7 +560,7 @@ DEPENDENCIES
   charlock_holmes (~> 0.7.5)
   cld3 (~> 3.2.0)
   climate_control (~> 0.2)
-  devise (~> 4.2)
+  devise (~> 4.3)
   devise-two-factor (~> 3.0)
   doorkeeper (~> 4.2)
   dotenv-rails (~> 2.2)
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) %>
+
+Игноришите ову поруку, ако нисте Ви били ти који сте затражили промену лозинке.
+Лозинка се неће променити све док не кликнете линк изнад и не направите нову лозинку.
diff --git a/config/application.rb b/config/application.rb
index 60c283dc4..c1bf3cef2 100644
--- a/config/application.rb
+++ b/config/application.rb
@@ -57,6 +57,8 @@ module Mastodon
       :'pt-BR',
       :ru,
       :sk,
+      :sr,
+      :'sr-Latn',
       :sv,
       :th,
       :tr,
diff --git a/config/initializers/devise.rb b/config/initializers/devise.rb
index 64c4e12ff..07912c28b 100644
--- a/config/initializers/devise.rb
+++ b/config/initializers/devise.rb
@@ -137,6 +137,9 @@ Devise.setup do |config|
   # Setup a pepper to generate the encrypted password.
   # config.pepper = '104d16705f794923e77c5e5167b52452d00646dc952a2d30b541c24086e647012c7b9625f253c51912e455981e503446772973d5f1638631196c819d7137fad4'
 
+  # Send a notification to the original email when the user's email is changed.
+  config.send_email_changed_notification = true
+
   # Send a notification email when the user's password is changed
   config.send_password_change_notification = true
 
@@ -160,7 +163,7 @@ Devise.setup do |config|
   # initial account confirmation) to be applied. Requires additional unconfirmed_email
   # db field (see migrations). Until confirmed, new email is stored in
   # unconfirmed_email column, and copied to email column on successful confirmation.
-  config.reconfirmable = false
+  config.reconfirmable = true
 
   # Defines which key will be used when confirming an account
   # config.confirmation_keys = [:email]
diff --git a/config/locales/activerecord.sr-Latn.yml b/config/locales/activerecord.sr-Latn.yml
new file mode 100644
index 000000000..1527a0d8c
--- /dev/null
+++ b/config/locales/activerecord.sr-Latn.yml
@@ -0,0 +1,13 @@
+---
+sr-Latn:
+  activerecord:
+    errors:
+      models:
+        account:
+          attributes:
+            username:
+              invalid: samo slova, brojevi i donje crte
+        status:
+          attributes:
+            reblog:
+              taken: statusa već postoji
diff --git a/config/locales/activerecord.sr.yml b/config/locales/activerecord.sr.yml
new file mode 100644
index 000000000..b4d929634
--- /dev/null
+++ b/config/locales/activerecord.sr.yml
@@ -0,0 +1,13 @@
+---
+sr:
+  activerecord:
+    errors:
+      models:
+        account:
+          attributes:
+            username:
+              invalid: само слова, бројеви и доње црте
+        status:
+          attributes:
+            reblog:
+              taken: статуса већ постоји
diff --git a/config/locales/devise.en.yml b/config/locales/devise.en.yml
index 586c5349d..c5ae583ff 100644
--- a/config/locales/devise.en.yml
+++ b/config/locales/devise.en.yml
@@ -18,8 +18,12 @@ en:
     mailer:
       confirmation_instructions:
         subject: 'Mastodon: Confirmation instructions for %{instance}'
+      email_changed:
+        subject: 'Mastodon: Email changed'
       password_change:
         subject: 'Mastodon: Password changed'
+      reconfirmation_instructions:
+        subject: 'Mastodon: Confirm email for %{instance}'
       reset_password_instructions:
         subject: 'Mastodon: Reset password instructions'
       unlock_instructions:
diff --git a/config/locales/devise.ja.yml b/config/locales/devise.ja.yml
index 1a46b80b5..118186877 100644
--- a/config/locales/devise.ja.yml
+++ b/config/locales/devise.ja.yml
@@ -18,8 +18,12 @@ ja:
     mailer:
       confirmation_instructions:
         subject: 'Mastodon: メールアドレスの確認'
+      email_changed:
+        subject: 'Mastodon: メールアドレスの変更'
       password_change:
         subject: 'Mastodon: パスワードが変更されました'
+      reconfirmation_instructions:
+        subject: 'Mastodon: %{instance}のメールを確認する'
       reset_password_instructions:
         subject: 'Mastodon: パスワード再発行'
       unlock_instructions:
diff --git a/config/locales/devise.oc.yml b/config/locales/devise.oc.yml
index 266f87373..de87ac1e2 100644
--- a/config/locales/devise.oc.yml
+++ b/config/locales/devise.oc.yml
@@ -18,8 +18,12 @@ oc:
     mailer:
       confirmation_instructions:
         subject: Mercés de confirmar vòstra inscripcion sus %{instance}
+      email_changed:
+        subject: 'Mastodon : corrièl cambiat'
       password_change:
         subject: Mastodon : senhal cambiat
+      reconfirmation_instructions:
+        subject: 'Mastodon : Confirmatz l’adreça per %{instance}'
       reset_password_instructions:
         subject: Mastodon : instruccions per reïnicializar lo senhal
       unlock_instructions:
diff --git a/config/locales/devise.pl.yml b/config/locales/devise.pl.yml
index 4b1eb2c60..6a2960463 100644
--- a/config/locales/devise.pl.yml
+++ b/config/locales/devise.pl.yml
@@ -18,8 +18,12 @@ pl:
     mailer:
       confirmation_instructions:
         subject: 'Mastodon: Instrukcje weryfikacji adresu e-mail'
+      email_changed:
+        subject: 'Mastodon: Zmieniono adres e-mail'
       password_change:
-        subject: 'Mastodon: Hasło zmienione'
+        subject: 'Mastodon: Zmieniono hasło'
+      reconfirmation_instructions:
+        subject: 'Mastodon: Potwierdź adres e-mail na &{instance}'
       reset_password_instructions:
         subject: 'Mastodon: Instrukcje ustawienia nowego hasła'
       unlock_instructions:
diff --git a/config/locales/devise.sr-Latn.yml b/config/locales/devise.sr-Latn.yml
new file mode 100644
index 000000000..21ddbd726
--- /dev/null
+++ b/config/locales/devise.sr-Latn.yml
@@ -0,0 +1,63 @@
+---
+sr-Latn:
+  devise:
+    confirmations:
+      confirmed: Adresa Vaše e-pošte je uspešno potvrđena.
+      send_instructions: U roku od nekoliko minuta primićete e-poštu sa uputstvom za potvrdu Vašeg naloga. Molimo proverite i spam fasciklu ako niste primili poruku.
+      send_paranoid_instructions: Ukoliko se adresa Vaše e-pošte nalazi u našoj bazi, u roku od nekoliko minuta primićete poruku sa uputstvom kako da potvrdite Vaš nalog. Molimo proverite i spam fasciklu ako niste primili poruku.
+    failure:
+      already_authenticated: Već ste prijavljeni.
+      inactive: Vaš nalog još nije aktiviran.
+      invalid: Neispravan %{authentication_keys} ili lozinka.
+      last_attempt: Imate još jedan pokušaj pre nego što Vaš nalog bude zaključan.
+      locked: Vaš nalog je zaključan.
+      not_found_in_database: Neispravan %{authentication_keys} ili lozinka.
+      timeout: Vreme trajanja Vaše sesije je isteklo. Za nastavak prijavite se ponovo.
+      unauthenticated: Za nastavak se morate prijaviti ili registrovati.
+      unconfirmed: Pre nastavka morate potvrditi svoj nalog.
+    mailer:
+      confirmation_instructions:
+        subject: 'Mastodont: Uputstvo za potvrdu korisničkog naloga na instanci %{instance}'
+      password_change:
+        subject: 'Mastodont: Lozinka promenjena'
+      reset_password_instructions:
+        subject: 'Mastodont: Uputstvo za resetovanje lozinke'
+      unlock_instructions:
+        subject: 'Mastodont: Uputstvo za otključavanje korisničkog naloga'
+    omniauth_callbacks:
+      failure: Nismo u mogućnosti autorizovati Vas sa %{kind} nalogom zbog "%{reason}".
+      success: Uspešna autorizacija sa %{kind} naloga.
+    passwords:
+      no_token: Ne možete pristupiti ovoj stranici ako niste pratili vezu u imejlu za resetovanje lozinke. Ukoliko ste pratili vezu za resetovanje lozinke u poruci, molimo Vas da proverite da li ste koristili punu adresu.
+      send_instructions: U roku od nekoliko minuta primitićete poruku sa uputstvom za promenu Vaše lozinke. Molimo proverite i spam fasciklu ako niste primili poruku.
+      send_paranoid_instructions: Ukoliko se adresa Vaše e-pošte nalazi u našoj bazi, u roku od nekoliko minuta primićete poruku sa uputstvom za promenu Vaše lozinke. Molimo proverite i spam fasciklu ako niste primili poruku.
+      updated: Vaša lozinka je uspešno promenjena. Sada ste prijavljeni.
+      updated_not_active: Vaša lozinka nije uspešno promenjena.
+    registrations:
+      destroyed: Ćao! Vaš nalog je uspešno obrisan. Nadamo se da ćete se uskoro vratiti.
+      signed_up: Dobrodošli! Uspešno ste se registrovali.
+      signed_up_but_inactive: Uspešno ste se registrovali. Nažalost ne možete se prijaviti zato što Vaš nalog još nije aktiviran.
+      signed_up_but_locked: Uspešno ste se registrovali. Nažalost ne možete se prijaviti zato što je Vaš nalog zaključan.
+      signed_up_but_unconfirmed: Poruka za potvrdu Vašeg naloga je poslata na Vašu imejl adresu. Kliknite na vezu u imejlu da potvrdite svoj nalog. Molimo proverite i spam fasciklu ako niste primili poruku.
+      update_needs_confirmation: Uspešno ste ažurirali svoj nalog, ali treba da potvrdimo novu adresu Vaše e-pošte. Molimo Vas da proverite e-poštu i pratite link za potvrdu nove adrese Vaše e-pošte.
+      updated: Vaš nalog je uspešno ažuriran.
+    sessions:
+      already_signed_out: Uspešno ste se odjavili.
+      signed_in: Uspešno ste se prijavili.
+      signed_out: Uspešno ste se odjavili.
+    unlocks:
+      send_instructions: U roku od nekoliko minuta primićete imejl sa uputstvom za otključavanje Vašeg naloga. Molimo proverite i spam fasciklu ako niste primili poruku.
+      send_paranoid_instructions: koliko se adresa Vaše e-pošte nalazi u našoj bazi, u roku od nekoliko minuta primićete poruku sa uputstvom kako da otključate Vaš nalog. Molimo proverite i spam fasciklu ako niste primili poruku.
+      unlocked: Vaš nalog je uspešno otključan. Molimo Vas da se prijavite da biste nastavili.
+  errors:
+    messages:
+      already_confirmed: je već potvrđen, molimo Vas da se prijavite
+      confirmation_period_expired: je trebao biti potvrđen tokom perioda %{period}, molimo Vas da zatražite novi
+      expired: je istekao, molimo Vas da zatražite novi
+      not_found: nije pronađeno
+      not_locked: nije zaključan
+      not_saved:
+        few: "%{count} greške sprečavaju %{resource}a:"
+        many: "%{count} grešaka sprečavaju %{resource}a:"
+        one: '1 greška sprečava %{resource}a:'
+        other: "%{count} grešaka sprečavaju %{resource}a:"
diff --git a/config/locales/devise.sr.yml b/config/locales/devise.sr.yml
new file mode 100644
index 000000000..9d1359695
--- /dev/null
+++ b/config/locales/devise.sr.yml
@@ -0,0 +1,63 @@
+---
+sr:
+  devise:
+    confirmations:
+      confirmed: Адреса Ваше е-поште је успешно потврђена.
+      send_instructions: У року од неколико минута примићете е-пошту са упутством за потврду Вашег налога. Молимо проверите и спам фасциклу ако нисте примили поруку.
+      send_paranoid_instructions: Уколико се адреса Ваше е-поште налази у нашој бази, у року од неколико минута примићете поруку са упутством како да потврдите Ваш налог. Молимо проверите и спам фасциклу ако нисте примили поруку.
+    failure:
+      already_authenticated: Већ сте пријављени.
+      inactive: Ваш налог још није активиран.
+      invalid: Неисправан %{authentication_keys} или лозинка.
+      last_attempt: Имате још један покушај пре него што Ваш налог буде закључан.
+      locked: Ваш налог је закључан.
+      not_found_in_database: Неисправан %{authentication_keys} или лозинка.
+      timeout: Време трајања Ваше сесије је истекло. За наставак пријавите се поново.
+      unauthenticated: За наставак се морате пријавити или регистровати.
+      unconfirmed: Пре наставка морате потврдити свој налог.
+    mailer:
+      confirmation_instructions:
+        subject: 'Мастодонт: Упутство за потврду корисничког налога на инстанци %{instance}'
+      password_change:
+        subject: 'Мастодонт: Лозинка промењена'
+      reset_password_instructions:
+        subject: 'Мастодонт: Упутство за ресетовање лозинке'
+      unlock_instructions:
+        subject: 'Мастодонт: Упутство за откључавање корисничког налога'
+    omniauth_callbacks:
+      failure: Нисмо у могућности ауторизовати Вас са %{kind} налогом због "%{reason}".
+      success: Успешна ауторизација са %{kind} налога.
+    passwords:
+      no_token: Не можете приступити овој страници ако нисте пратили везу у имејлу за ресетовање лозинке. Уколико сте пратили везу за ресетовање лозинке у поруци, молимо Вас да проверите да ли сте користили пуну адресу.
+      send_instructions: У року од неколико минута примитићете поруку са упутством за промену Ваше лозинке. Молимо проверите и спам фасциклу ако нисте примили поруку.
+      send_paranoid_instructions: Уколико се адреса Ваше е-поште налази у нашој бази, у року од неколико минута примићете поруку са упутством за промену Ваше лозинке. Молимо проверите и спам фасциклу ако нисте примили поруку.
+      updated: Ваша лозинка је успешно промењена. Сада сте пријављени.
+      updated_not_active: Ваша лозинка није успешно промењена.
+    registrations:
+      destroyed: Ћао! Ваш налог је успешно обрисан. Надамо се да ћете се ускоро вратити.
+      signed_up: Добродошли! Успешно сте се регистровали.
+      signed_up_but_inactive: Успешно сте се регистровали. Нажалост не можете се пријавити зато што Ваш налог још није активиран.
+      signed_up_but_locked: Успешно сте се регистровали. Нажалост не можете се пријавити зато што је Ваш налог закључан.
+      signed_up_but_unconfirmed: Порука за потврду Вашег налога је послата на Вашу имејл адресу. Кликните на везу у имејлу да потврдите свој налог. Молимо проверите и спам фасциклу ако нисте примили поруку.
+      update_needs_confirmation: Uспешно сте ажурирали свој налог, али треба да потврдимо нову адресу Ваше е-поште. Молимо Вас да проверите е-пошту и пратите линк за потврду нове адресе Ваше е-поште.
+      updated: Ваш налог је успешно ажуриран.
+    sessions:
+      already_signed_out: Успешно сте се одјавили.
+      signed_in: Успешно сте се пријавили.
+      signed_out: Успешно сте се одјавили.
+    unlocks:
+      send_instructions: У року од неколико минута примићете имејл са упутством за откључавање Вашег налога. Молимо проверите и спам фасциклу ако нисте примили поруку.
+      send_paranoid_instructions: колико се адреса Ваше е-поште налази у нашој бази, у року од неколико минута примићете поруку са упутством како да откључате Ваш налог. Молимо проверите и спам фасциклу ако нисте примили поруку.
+      unlocked: Ваш налог је успешно откључан. Молимо Вас да се пријавите да бисте наставили.
+  errors:
+    messages:
+      already_confirmed: је већ потврђен, молимо Вас да се пријавите
+      confirmation_period_expired: је требао бити потврђен током периода %{period}, молимо Вас да затражите нови
+      expired: је истекао, молимо Вас да затражите нови
+      not_found: није пронађено
+      not_locked: није закључан
+      not_saved:
+        few: "%{count} грешке спречавају %{resource}a:"
+        many: "%{count} грешака спречавају %{resource}a:"
+        one: '1 грешка спречава %{resource}а:'
+        other: "%{count} грешака спречавају %{resource}a:"
diff --git a/config/locales/doorkeeper.sr-Latn.yml b/config/locales/doorkeeper.sr-Latn.yml
new file mode 100644
index 000000000..8e96f8b93
--- /dev/null
+++ b/config/locales/doorkeeper.sr-Latn.yml
@@ -0,0 +1,119 @@
+---
+sr-Latn:
+  activerecord:
+    attributes:
+      doorkeeper/application:
+        name: Ime aplikacije
+        redirect_uri: Adresa za preusmeravanje
+        scopes: Opseg važenja
+        website: Veb sajt aplikacije
+    errors:
+      models:
+        doorkeeper/application:
+          attributes:
+            redirect_uri:
+              fragment_present: ne može da sadrži fragment.
+              invalid_uri: mora biti ispravan URI.
+              relative_uri: mora biti apsolutni URI.
+              secured_uri: mora biti HTTPS/SSL URI.
+  doorkeeper:
+    applications:
+      buttons:
+        authorize: Autorizuj
+        cancel: Poništi
+        destroy: Uništi
+        edit: Izmeni
+        submit: Pošalji
+      confirmations:
+        destroy: Da li ste sigurni?
+      edit:
+        title: Izmeni aplikaciju
+      form:
+        error: Ops! Proverite formular za eventualne greške
+      help:
+        native_redirect_uri: Koristite %{native_redirect_uri} za lokalno testiranje
+        redirect_uri: Koristite jednu liniju po URI-ju
+        scopes: Odvojite opsege važenja sa belinama. Ostavite prazno za podrazumevane opsege važenja.
+      index:
+        application: Aplikacija
+        callback_url: Adresa za povratni poziv
+        delete: Obriši
+        name: Ime
+        new: Nova aplikacija
+        scopes: Opsezi važenja
+        show: Prikaži
+        title: Vaše aplikacije
+      new:
+        title: Nova aplikacija
+      show:
+        actions: Akcije
+        application_id: Klijentski ključ
+        callback_urls: Adrese za povratne pozive
+        scopes: Opseg važenja
+        secret: Klijentska tajna
+        title: 'Aplikacija: %{name}'
+    authorizations:
+      buttons:
+        authorize: Autorizuj se
+        deny: Odbij
+      error:
+        title: Dogodila se greška
+      new:
+        able_to: Biće u mogućnosti da
+        prompt: Aplikacija %{client_name} zahteva pristup Vašem nalogu
+        title: Potrebna autorizacija
+      show:
+        title: Kopirajte ovaj autorizacioni kod i nalepite ga u aplikaciju.
+    authorized_applications:
+      buttons:
+        revoke: Opozovi
+      confirmations:
+        revoke: Da li ste sigurni?
+      index:
+        application: Aplikacija
+        created_at: Autorizovana
+        date_format: "%d.%m.%Y %H:%M:%S"
+        scopes: Opsezi važenja
+        title: Vaše autorizovane aplikacije
+    errors:
+      messages:
+        access_denied: Vlasnik resursa ili autorizacioni server su odbili zahtev.
+        credential_flow_not_configured: Tok Resource Owner Password Credentials nije uspeo pošto je Doorkeeper.configure.resource_owner_from_credentials neiskonfigurisan.
+        invalid_client: Klijentska identifikacija nije uspela zbog nepoznatog klijenta, zato što klijent nije uključio identifikaciju ili zato što je iskorišćen nepodržani identifikacioni metod.
+        invalid_grant: Zadata identifikaciona dozvola je neispravna, istekla, opozvana, ne poklapa se sa adresom preusmeravanja ili je izdata nekog drugom klijentu.
+        invalid_redirect_uri: Uključena adresa preusmeravanja nije ispravna.
+        invalid_request: Obavezni parametar fali u zahtevu, zahtev uključuje nepodržanu vrednost parametra ili je parametar na neki drugi način pogrešan.
+        invalid_resource_owner: Zadati kredencijali vlasnika resursa nisu ispravni ili vlasnik resursa ne može biti nađen
+        invalid_scope: Zahtevani opseg važenja nije ispravan, nepoznat je ili je na neki drugi način pogrešan.
+        invalid_token:
+          expired: Pristupni token je istekao
+          revoked: Pristupni token je opozvan
+          unknown: Pristupni token nije ispravan
+        resource_owner_authenticator_not_configured: Greška u pronalaženju vlasnika resursa pošto Doorkeeper.configure.resource_owner_authenticator nije konfigurisan.
+        server_error: Identifikacioni server je naišao na neočekivanu situaciju zbog koje nije ispunio upućeni zahtev.
+        temporarily_unavailable: Identifikacioni server trenutno ne može da obradi zahtev jer je privremeno preopterećen ili je u režimu održavanja.
+        unauthorized_client: Klijent nije ovlašćen da izvrši ovaj zahtev ovim metodom.
+        unsupported_grant_type: Tip autorizacione dozvole nije podržan od strane autorizacionog servera.
+        unsupported_response_type: Autorizacioni server ne podržava ovaj tip odgovora.
+    flash:
+      applications:
+        create:
+          notice: Aplikacija napravljena.
+        destroy:
+          notice: Aplikacija obrisana.
+        update:
+          notice: Aplikacija ažurirana.
+      authorized_applications:
+        destroy:
+          notice: Aplikacija opozvana.
+    layouts:
+      admin:
+        nav:
+          applications: Aplikacije
+          oauth2_provider: OAuth2 provajder
+      application:
+        title: OAuth autorizacija potrebna
+    scopes:
+      follow: prati, blokira, odblokira i otprati naloge
+      read: čita podatke Vašeg naloga
+      write: objavljuje statuse u Vaše ime
diff --git a/config/locales/doorkeeper.sr.yml b/config/locales/doorkeeper.sr.yml
new file mode 100644
index 000000000..723c02d09
--- /dev/null
+++ b/config/locales/doorkeeper.sr.yml
@@ -0,0 +1,119 @@
+---
+sr:
+  activerecord:
+    attributes:
+      doorkeeper/application:
+        name: Име апликације
+        redirect_uri: Адреса за преусмеравање
+        scopes: Опсег важења
+        website: Веб сајт апликације
+    errors:
+      models:
+        doorkeeper/application:
+          attributes:
+            redirect_uri:
+              fragment_present: не може да садржи фрагмент.
+              invalid_uri: мора бити исправан URI.
+              relative_uri: мора бити апсолутни URI.
+              secured_uri: мора бити HTTPS/SSL URI.
+  doorkeeper:
+    applications:
+      buttons:
+        authorize: Ауторизуј
+        cancel: Поништи
+        destroy: Уништи
+        edit: Измени
+        submit: Пошаљи
+      confirmations:
+        destroy: Да ли сте сигурни?
+      edit:
+        title: Измени апликацију
+      form:
+        error: Опс! Проверите формулар за евентуалне грешке
+      help:
+        native_redirect_uri: Користите %{native_redirect_uri} за локално тестирање
+        redirect_uri: Користите једну линију по URI-ју
+        scopes: Одвојите опсеге важења са белинама. Оставите празно за подразумеване опсеге важења.
+      index:
+        application: Апликација
+        callback_url: Адреса за повратни позив
+        delete: Обриши
+        name: Име
+        new: Нова апликација
+        scopes: Опсези важења
+        show: Прикажи
+        title: Ваше апликације
+      new:
+        title: Нова апликација
+      show:
+        actions: Акције
+        application_id: Клијентски кључ
+        callback_urls: Адресе за повратне позиве
+        scopes: Опсег важења
+        secret: Клијентска тајна
+        title: 'Апликација: %{name}'
+    authorizations:
+      buttons:
+        authorize: Ауторизуј се
+        deny: Одбиј
+      error:
+        title: Догодила се грешка
+      new:
+        able_to: Биће у могућности да
+        prompt: Апликација %{client_name} захтева приступ Вашем налогу
+        title: Потребна ауторизација
+      show:
+        title: Копирајте овај ауторизациони код и налепите га у апликацију.
+    authorized_applications:
+      buttons:
+        revoke: Опозови
+      confirmations:
+        revoke: Да ли сте сигурни?
+      index:
+        application: Апликација
+        created_at: Ауторизована
+        date_format: "%d.%m.%Y %H:%M:%S"
+        scopes: Опсези важења
+        title: Ваше ауторизоване апликације
+    errors:
+      messages:
+        access_denied: Власник ресурса или ауторизациони сервер су одбили захтев.
+        credential_flow_not_configured: Ток Resource Owner Password Credentials није успео пошто је Doorkeeper.configure.resource_owner_from_credentials неисконфигурисан.
+        invalid_client: Клијентска идентификација није успела због непознатог клијента, зато што клијент није укључио идентификацију или зато што је искоришћен неподржани идентификациони метод.
+        invalid_grant: Задата идентификациона дозвола је неисправна, истекла, опозвана, не поклапа се са адресом преусмеравања или је издата неког другом клијенту.
+        invalid_redirect_uri: Укључена адреса преусмеравања није исправна.
+        invalid_request: Обавезни параметар фали у захтеву, захтев укључује неподржану вредност параметра или је параметар на неки други начин погрешан.
+        invalid_resource_owner: Задати креденцијали власника ресурса нису исправни или власник ресурса не може бити нађен
+        invalid_scope: Захтевани опсег важења није исправан, непознат је или је на неки други начин погрешан.
+        invalid_token:
+          expired: Приступни токен је истекао
+          revoked: Приступни токен је опозван
+          unknown: Приступни токен није исправан
+        resource_owner_authenticator_not_configured: Грешка у проналажењу власника ресурса пошто Doorkeeper.configure.resource_owner_authenticator није конфигурисан.
+        server_error: Идентификациони сервер је наишао на неочекивану ситуацију због које није испунио упућени захтев.
+        temporarily_unavailable: Идентификациони сервер тренутно не може да обради захтев јер је привремено преоптерећен или је у режиму одржавања.
+        unauthorized_client: Клијент није овлашћен да изврши овај захтев овим методом.
+        unsupported_grant_type: Тип ауторизационе дозволе није подржан од стране ауторизационог сервера.
+        unsupported_response_type: Ауторизациони сервер не подржава овај тип одговора.
+    flash:
+      applications:
+        create:
+          notice: Апликација направљена.
+        destroy:
+          notice: Апликација обрисана.
+        update:
+          notice: Апликација ажурирана.
+      authorized_applications:
+        destroy:
+          notice: Апликација опозвана.
+    layouts:
+      admin:
+        nav:
+          applications: Апликације
+          oauth2_provider: OAuth2 провајдер
+      application:
+        title: OAuth ауторизација потребна
+    scopes:
+      follow: прати, блокира, одблокира и отпрати налоге
+      read: чита податке Вашег налога
+      write: објављује статусе у Ваше име
diff --git a/config/locales/en.yml b/config/locales/en.yml
index b283f94f0..2691b9ed9 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -265,12 +265,18 @@ en:
       unresolved: Unresolved
       view: View
     settings:
+      activity_api_enabled:
+        desc_html: Counts of locally posted statuses, active users, and new registrations in weekly buckets
+        title: Publish aggregate statistics about user activity
       bootstrap_timeline_accounts:
         desc_html: Separate multiple usernames by comma. Only local and unlocked accounts will work. Default when empty is all local admins.
         title: Default follows for new users
       contact_information:
         email: Business e-mail
         username: Contact username
+      peers_api_enabled:
+        desc_html: Domain names this instance has encountered in the fediverse
+        title: Publish list of discovered instances
       registrations:
         closed_message:
           desc_html: Displayed on frontpage when registrations are closed. You can use HTML tags
diff --git a/config/locales/ja.yml b/config/locales/ja.yml
index 8974a8f82..b3898c1f1 100644
--- a/config/locales/ja.yml
+++ b/config/locales/ja.yml
@@ -116,6 +116,7 @@ ja:
       roles:
         admin: 管理者
         moderator: モデレーター
+        staff: スタッフ
         user: ユーザー
       salmon_url: Salmon URL
       search: 検索
@@ -160,6 +161,7 @@ ja:
         update_status: "%{name} さんが %{target} さんの投稿を更新しました"
       title: 操作履歴
     custom_emojis:
+      by_domain: ドメイン
       copied_msg: 絵文字のコピーをローカルに作成しました
       copy: コピー
       copy_failed_msg: 絵文字のコピーをローカルに作成できませんでした
@@ -263,12 +265,18 @@ ja:
       unresolved: 未解決
       view: 表示
     settings:
+      activity_api_enabled:
+        desc_html: ローカルに投稿されたトゥート数、アクティブなユーザー数、週ごとの新規登録者数
+        title: ユーザーアクティビティに関する統計を公開する
       bootstrap_timeline_accounts:
         desc_html: 複数のユーザー名はコンマで区切ります。ローカルの公開アカウントのみ有効です。指定しない場合は管理者がデフォルトで指定されます。
         title: 新規ユーザーが自動フォローするアカウント
       contact_information:
         email: ビジネスメールアドレス
         username: 連絡先のユーザー名
+      peers_api_enabled:
+        desc_html: 連合内でこのインスタンスが遭遇したドメインの名前
+        title: 接続しているインスタンスのリストを公開する
       registrations:
         closed_message:
           desc_html: 新規登録を停止しているときにフロントページに表示されます。HTMLタグが使えます
diff --git a/config/locales/ko.yml b/config/locales/ko.yml
index 31c946c00..2edb7ffd7 100644
--- a/config/locales/ko.yml
+++ b/config/locales/ko.yml
@@ -265,12 +265,18 @@ ko:
       unresolved: 미해결
       view: 표시
     settings:
+      activity_api_enabled:
+        desc_html: 주별 로컬에 게시 된 글, 활성 사용자 및 새로운 가입자 수
+        title: 유저 활동에 대한 통계 발행
       bootstrap_timeline_accounts:
         desc_html: 콤마로 여러 유저명을 구분. 로컬의 잠기지 않은 계정만 가능합니다. 비워 둘 경우 모든 로컬 관리자가 기본으로 사용 됩니다.
         title: 새 유저가 팔로우 할 계정들
       contact_information:
         email: 공개할 메일 주소를 입력
         username: 아이디를 입력
+      peers_api_enabled:
+        desc_html: 이 인스턴스가 페디버스에서 만났던 도메인 네임들
+        title: 발견 된 인스턴스들의 리스트 발행
       registrations:
         closed_message:
           desc_html: 신규 등록을 받지 않을 때 프론트 페이지에 표시됩니다. <br>HTML 태그를 사용할 수 있습니다.
diff --git a/config/locales/oc.yml b/config/locales/oc.yml
index 97d20bdf8..40387de70 100644
--- a/config/locales/oc.yml
+++ b/config/locales/oc.yml
@@ -265,12 +265,18 @@ oc:
       unresolved: Pas resolguts
       view: Veire
     settings:
+      activity_api_enabled:
+        desc_html: Nombre d’estatuts publicats, utilizaires actius e novèlas inscripcions en rapòrt setmanièr
+        title: Publica las estatisticas totalas de l’activitat dels utilizaires
       bootstrap_timeline_accounts:
         desc_html: Separatz los noms d’utilizaire amb de virgula. Pas que los comptes locals e pas clavats foncionaràn. Se lo camp es void los admins seràn selecionats.
-        title: Per defaut los nouvenguts sègon
+        title: Per defaut los nòuvenguts sègon
       contact_information:
         email: Picatz una adreça de corrièl
         username: Picatz un nom d’utilizaire
+      peers_api_enabled:
+        desc_html: Noms de domeni qu’aquesta instància a trobats pel fediverse
+        title: Publica la lista de las instàncias conegudas
       registrations:
         closed_message:
           desc_html: Afichat sus las pagina d’acuèlh quand las inscripcions son tampadas.<br>Podètz utilizar de balisas HTML
diff --git a/config/locales/pl.yml b/config/locales/pl.yml
index 905557a25..b2c5c8e97 100644
--- a/config/locales/pl.yml
+++ b/config/locales/pl.yml
@@ -160,6 +160,7 @@ pl:
         update_status: "%{name} zaktualizował wpis użytkownika %{target}"
       title: Dziennik działań administracyjnych
     custom_emojis:
+      by_domain: Według domeny
       copied_msg: Pomyślnie utworzono lokalną kopię emoji
       copy: Kopiuj
       copy_failed_msg: Nie udało się utworzyć lokalnej kopii emoji
@@ -264,12 +265,18 @@ pl:
       unresolved: Nierozwiązane
       view: Wyświetl
     settings:
+      activity_api_enabled:
+        desc_html: Liczy publikowane lokalnie wpisy, aktywnych użytkowników i nowe rejestracje w ciągu danego tygodnia
+        title: Publikuj zbiorowe statystyki o aktywności użytkowników
       bootstrap_timeline_accounts:
         desc_html: Oddzielaj nazwy użytkowników przecinkami. Działa tylko dla niezablokowanych kont w obrębie instancji. Jeżeli puste, zostaną użyte konta administratorów instancji.
         title: Domyślnie obserwowani użytkownicy
       contact_information:
         email: Służbowy adres e-mail
         username: Nazwa użytkownika do kontaktu
+      peers_api_enabled:
+        desc_html: Nazwy domen, z którymi ta instancja wchodziła w interakcje
+        title: Publikuj listę znanych instancji
       registrations:
         closed_message:
           desc_html: Wyświetlana na stronie głównej, gdy możliwość otwarej rejestracji nie jest dostępna. Możesz korzystać z tagów HTML
@@ -480,7 +487,7 @@ pl:
     acct: nazwa@domena nowego konta
     currently_redirecting: 'Obecnie Twoje konto przekierowuje do:'
     proceed: Zapisz
-    updated_msg: Pomyślnie zaktualizowano ustawienia i migracji Twojego konta!
+    updated_msg: Pomyślnie zaktualizowano ustawienia migracji Twojego konta!
   moderation:
     title: Moderacja
   notification_mailer:
@@ -597,8 +604,10 @@ pl:
     development: Tworzenie aplikacji
     edit_profile: Edytuj profil
     export: Eksportowanie danych
+    flavours: Motywy
     followers: Autoryzowani śledzący
     import: Importowanie danych
+    keyword_mutes: Wyciszone słowa
     migrate: Migracja konta
     notifications: Powiadomienia
     preferences: Preferencje
@@ -614,6 +623,7 @@ pl:
       private: Nie możesz przypiąć niepublicznego wpisu
       reblog: Nie możesz przypiąć podbicia wpisu
     show_more: Pokaż więcej
+    title: '%{name}: "%{quote}"'
     visibilities:
       private: Tylko dla śledzących
       private_long: Widoczne tylko dla osób, które Cię śledzą
diff --git a/config/locales/simple_form.sr-Latn.yml b/config/locales/simple_form.sr-Latn.yml
new file mode 100644
index 000000000..d811377c3
--- /dev/null
+++ b/config/locales/simple_form.sr-Latn.yml
@@ -0,0 +1,75 @@
+---
+sr-Latn:
+  simple_form:
+    hints:
+      defaults:
+        avatar: PNG, GIF ili JPG. Najviše 2MB. Biće smanjena na 120x120px
+        digest: Poslato posle dužeg perioda neaktivnosti sa pregledom svih bitnih stvari koje ste dobili dok ste bili odsutni
+        display_name:
+          few: <span class="name-counter">%{count}</span> karaktera preostala
+          many: <span class="name-counter">%{count}</span> karaktera preostalo
+          one: <span class="name-counter">1</span> karakter preostao
+          other: <span class="name-counter">%{count}</span> karaktera preostalo
+        header: PNG, GIF ili JPG. Najviše 2MB. Biće smanjena na 700x335px
+        locked: Zahteva da pojedinačno odobrite pratioce
+        note:
+          few: <span class="note-counter">%{count}</span> karaktera preostal
+          many: <span class="note-counter">%{count}</span> karaktera preostalo
+          one: <span class="note-counter">1</span> karakter preostao
+          other: <span class="note-counter">%{count}</span> karaktera preostalo
+        setting_noindex: Utiče na Vaš javni profil i statusne strane
+        setting_theme: Utiče kako će Mastodont izgledati kada ste prijavljeni sa bilo kog uređaja.
+      imports:
+        data: CSV fajl izvezen sa druge Mastodont instance
+      sessions:
+        otp: Unesite dvofaktorski kod sa Vašeg telefona ili koristite jedan od kodova za oporavak.
+      user:
+        filtered_languages: Označeni jezici će za Vas biti isfiltrirani sa javnih lajni
+    labels:
+      defaults:
+        avatar: Avatar
+        confirm_new_password: Potvrdite novu lozinku
+        confirm_password: Potvrdite lozinku
+        current_password: Trenutna lozinka
+        data: Podaci
+        display_name: Ime za prikaz
+        email: Adresa e-pošte
+        expires_in: Ističe nakon
+        filtered_languages: Filtrirani jezici
+        header: Zaglavlje
+        locale: Jezik
+        locked: Zaključaj nalog
+        max_uses: Maksimalni broj korišćenja
+        new_password: Nova lozinka
+        note: Biografija
+        otp_attempt: Dvofaktorski kod
+        password: Lozinka
+        setting_auto_play_gif: Automatski puštaj animirane GIF-ove
+        setting_boost_modal: Prikaži dijalog za potvrdu pre davanja podrške
+        setting_default_privacy: Privatnost objava
+        setting_default_sensitive: Uvek označi multimediju kao osetljivu
+        setting_delete_modal: Prikaži dijalog za potvrdu pre brisanja tuta
+        setting_noindex: Odjavi se od indeksiranja search engine-a
+        setting_reduce_motion: Smanji pokrete u animacijama
+        setting_system_font_ui: Koristi sistemski font
+        setting_theme: Tema sajta
+        setting_unfollow_modal: Prikaži dijalog za potvrdu pre nego što otpratite nekoga
+        severity: Oštrina
+        type: Tip uvoza
+        username: Korisničko ime
+      interactions:
+        must_be_follower: Blokiraj obaveštenja od korisnika koji me ne prate
+        must_be_following: Blokiraj obaveštenja od ljudi koje ne pratim
+        must_be_following_dm: Blokiraj direktne poruke od ljudi koje ne pratim
+      notification_emails:
+        digest: Šalji e-poštu sa sažetkom
+        favourite: Šalji e-poštu kada neko stavi da mu je Vaš status omiljen
+        follow: Šalji e-poštu kada Vas neko zaprati
+        follow_request: Šalji e-poštu kada neko zatraži da Vas zaprati
+        mention: Šalji e-poštu kada Vas neko pomene
+        reblog: Šalji e-poštu kada neko podrži Vaš status
+    'no': Ne
+    required:
+      mark: "*"
+      text: obavezno
+    'yes': Da
diff --git a/config/locales/simple_form.sr.yml b/config/locales/simple_form.sr.yml
new file mode 100644
index 000000000..4e3599e5f
--- /dev/null
+++ b/config/locales/simple_form.sr.yml
@@ -0,0 +1,75 @@
+---
+sr:
+  simple_form:
+    hints:
+      defaults:
+        avatar: PNG, GIF или JPG. Највише 2MB. Биће смањена на 120x120px
+        digest: Послато после дужег периода неактивности са прегледом свих битних ствари које сте добили док сте били одсутни
+        display_name:
+          few: <span class="name-counter">%{count}</span> карактера преостала
+          many: <span class="name-counter">%{count}</span> карактера преостало
+          one: <span class="name-counter">1</span> карактер преостао
+          other: <span class="name-counter">%{count}</span> карактера преостало
+        header: PNG, GIF или JPG. Највише 2MB. Биће смањена на 700x335px
+        locked: Захтева да појединачно одобрите пратиоце
+        note:
+          few: <span class="note-counter">%{count}</span> карактера преостал
+          many: <span class="note-counter">%{count}</span> карактера преостало
+          one: <span class="note-counter">1</span> карактер преостао
+          other: <span class="note-counter">%{count}</span> карактера преостало
+        setting_noindex: Утиче на Ваш јавни профил и статусне стране
+        setting_theme: Утиче како ће Мастодонт изгледати када сте пријављени са било ког уређаја.
+      imports:
+        data: CSV фајл извезен са друге Мастодонт инстанце
+      sessions:
+        otp: Унесите двофакторски код са Вашег телефона или користите један од кодова за опоравак.
+      user:
+        filtered_languages: Означени језици ће за Вас бити исфилтрирани са јавних лајни
+    labels:
+      defaults:
+        avatar: Аватар
+        confirm_new_password: Потврдите нову лозинку
+        confirm_password: Потврдите лозинку
+        current_password: Тренутна лозинка
+        data: Подаци
+        display_name: Име за приказ
+        email: Адреса е-поште
+        expires_in: Истиче након
+        filtered_languages: Филтрирани језици
+        header: Заглавље
+        locale: Језик
+        locked: Закључај налог
+        max_uses: Максимални број коришћења
+        new_password: Нова лозинка
+        note: Биографија
+        otp_attempt: Двофакторски код
+        password: Лозинка
+        setting_auto_play_gif: Аутоматски пуштај анимиране GIF-ове
+        setting_boost_modal: Прикажи дијалог за потврду пре давања подршке
+        setting_default_privacy: Приватност објава
+        setting_default_sensitive: Увек означи мултимедију као осетљиву
+        setting_delete_modal: Прикажи дијалог за потврду пре брисања тута
+        setting_noindex: Одјави се од индексирања search engine-а
+        setting_reduce_motion: Смањи покрете у анимацијама
+        setting_system_font_ui: Користи системски фонт
+        setting_theme: Тема сајта
+        setting_unfollow_modal: Прикажи дијалог за потврду пре него што отпратите некога
+        severity: Оштрина
+        type: Тип увоза
+        username: Корисничко име
+      interactions:
+        must_be_follower: Блокирај обавештења од корисника који ме не прате
+        must_be_following: Блокирај обавештења од људи које не пратим
+        must_be_following_dm: Блокирај директне поруке од људи које не пратим
+      notification_emails:
+        digest: Шаљи е-пошту са сажетком
+        favourite: Шаљи е-пошту када неко стави да му је Ваш статус омиљен
+        follow: Шаљи е-пошту када Вас неко запрати
+        follow_request: Шаљи е-пошту када неко затражи да Вас запрати
+        mention: Шаљи е-пошту када Вас неко помене
+        reblog: Шаљи е-пошту када неко подржи Ваш статус
+    'no': Не
+    required:
+      mark: "*"
+      text: обавезно
+    'yes': Да
diff --git a/config/locales/simple_form.zh-TW.yml b/config/locales/simple_form.zh-TW.yml
index c82f07e2d..b5edaea72 100644
--- a/config/locales/simple_form.zh-TW.yml
+++ b/config/locales/simple_form.zh-TW.yml
@@ -21,7 +21,7 @@ zh-TW:
         data: 資料
         display_name: 顯示名稱
         email: 電子信箱
-        filtered_languages: 封鎖下面语言的文章
+        filtered_languages: 封鎖下面語言的文章
         header: 個人頁面頂部
         locale: 語言
         locked: 將帳號轉為「私密」
@@ -29,7 +29,16 @@ zh-TW:
         note: 簡介
         otp_attempt: 雙因子驗證碼
         password: 密碼
+        setting_auto_play_gif: 自動播放 GIFs
+        setting_boost_modal: 轉推前跳出確認視窗
         setting_default_privacy: 文章預設隱私度
+        setting_default_sensitive: 預設我的內容為敏感內容
+        setting_delete_modal: 刪推前跳出確認視窗
+        setting_noindex: 不被搜尋引擎檢索
+        setting_reduce_motion: 減低動畫效果
+        setting_system_font_ui: 使用系統預設字體
+        setting_theme: 網站主題
+        setting_unfollow_modal: 取消關注前跳出確認視窗
         type: 匯入資料類型
         username: 使用者名稱
       interactions:
diff --git a/config/locales/sr-Latn.yml b/config/locales/sr-Latn.yml
new file mode 100644
index 000000000..964a82d64
--- /dev/null
+++ b/config/locales/sr-Latn.yml
@@ -0,0 +1,723 @@
+---
+sr-Latn:
+  about:
+    about_hashtag_html: Ovo su javni statusi tagovani sa <strong>#%{hashtag}</strong>. Možete odgovarati na njih ako imate nalog bilo gde u fediversu.
+    about_mastodon_html: Mastodont je društvena mreža bazirana na otvorenim protokolima i slobodnom softveru otvorenog koda. Decentralizovana je kao što je decentralizovana e-pošta.
+    about_this: O instanci
+    closed_registrations: Registracije su trenutno zatvorene na ovoj instanci. Ipak! Možete naći drugu instancu na kojoj ćete napraviti nalog i odatle dobiti pristup istoj ovoj mreži.
+    contact: Kontakt
+    contact_missing: Nije postavljeno
+    contact_unavailable: N/A
+    description_headline: Šta je %{domain}?
+    domain_count_after: ostale instance
+    domain_count_before: Povezan na
+    extended_description_html: |
+      <h3>Dobro mesto za pravila</h3>
+      <p>Prošireni opis koji još nije postavljen.</p>
+    features:
+      humane_approach_body: Učeći od grešaka sa ostalih mreža, a da bi se borio protiv zloupotreba na društvenim mrežama, Mastodont pokušava da pravi što etičkije odluke prilikom razvoja.
+      humane_approach_title: Humaniji pristup
+      not_a_product_body: Mastodont nije komercijalna mreža. Nema reklama, nema skupljanja privatnih podataka, nema zaštićenih delova. Nema centralnog autoriteta.
+      not_a_product_title: Vi ste osoba, ne proizvod
+      real_conversation_body: Sa 500 karaktera na raspolaganju i podrškom za granularniji sadržaj i upozorenja na osetljiviji sadržaj, možete se izraziti kako god želite.
+      real_conversation_title: Pravljen za pravi razgovor
+      within_reach_body: Više aplikacija za iOS, Android, kao i druge platforme zahvaljujući ekosistemu dobrih API-ja će Vam omogućiti da ostanete u kontaktu sa prijateljima svuda.
+      within_reach_title: Uvek u kontaktu
+    find_another_instance: Nađite drugu instancu
+    generic_description: "%{domain} je server na mreži"
+    hosted_on: Mastodont hostovan na %{domain}
+    learn_more: Saznajte više
+    other_instances: Lista instanci
+    source_code: Izvorni kod
+    status_count_after: statusa
+    status_count_before: Koji su napisali
+    user_count_after: korisnika
+    user_count_before: Dom za
+    what_is_mastodon: Šta je Mastodont?
+  accounts:
+    follow: Follow
+    followers: Followers
+    following: Following
+    media: Multimedija
+    moved_html: "%{name} je pomeren na %{new_profile_link}:"
+    nothing_here: Ovde nema ništa!
+    people_followed_by: Ljudi koje %{name} prati
+    people_who_follow: Ljudi koji prate %{name}
+    posts: Tutovi
+    posts_with_replies: Tutovi i odgovori
+    remote_follow: Udaljena praćenja
+    reserved_username: Korisničko ime je rezervisano
+    roles:
+      admin: Administrator
+      moderator: Moderator
+    unfollow: Otprati
+  admin:
+    account_moderation_notes:
+      account: Moderator
+      create: Napravi
+      created_at: Datum
+      created_msg: Moderatorska beleška uspešno napravljena!
+      delete: Obriši
+      destroyed_msg: Moderatorska beleška uspešno obrisana!
+    accounts:
+      are_you_sure: Da li ste sigurni?
+      by_domain: Domen
+      confirm: Potvrdi
+      confirmed: Potvrđeno
+      demote: Ražaluj
+      disable: Isključi
+      disable_two_factor_authentication: Isključi 2FA
+      disabled: Isključena
+      display_name: Prikazano ime
+      domain: Domen
+      edit: Izmeni
+      email: E-pošta
+      enable: Uključi
+      enabled: Uključeno
+      feed_url: Adresa dovoda
+      followers: Pratioci
+      followers_url: Adresa pratioca
+      follows: Praćeni
+      inbox_url: Adresa sandučeta
+      ip: IP
+      location:
+        all: Sve
+        local: Lokalne
+        remote: Udaljene
+        title: Lokacija
+      login_status: Status prijave
+      media_attachments: Multimedijalni prilozi
+      memorialize: Prebaci u in memoriam
+      moderation:
+        all: Svi
+        silenced: Ućutkani
+        suspended: Suspendovani
+        title: Moderacija
+      moderation_notes: Moderatorske beleške
+      most_recent_activity: Najskorija aktivnost
+      most_recent_ip: Najskorija IP adresa
+      not_subscribed: Nije pretplaćen
+      order:
+        alphabetic: Abecedni
+        most_recent: Najskoriji
+        title: Redosled
+      outbox_url: Odlazno sanduče
+      perform_full_suspension: Izvrši kompletno isključenje
+      profile_url: Adresa profila
+      promote: Unapredi
+      protocol: Protokol
+      public: Javno
+      push_subscription_expires: PuSH subscription expires
+      redownload: Osveži avatar
+      reset: Resetuj
+      reset_password: Resetuj lozinku
+      resubscribe: Ponovo se pretplati
+      role: Ovlašćenja
+      roles:
+        admin: Administrator
+        moderator: Moderator
+        staff: Osoblje
+        user: Korisnik
+      salmon_url: Salmon adresa
+      search: Pretraga
+      shared_inbox_url: Adresa deljenog sandučeta
+      show:
+        created_reports: Prijave koje je napravio ovaj nalog
+        report: prijava
+        targeted_reports: Prijave napravljene o ovom nalogu
+      silence: Ućutkaj
+      statuses: Statusi
+      subscribe: Pretplati se
+      title: Nalozi
+      undo_silenced: Ukini ćutanje
+      undo_suspension: Ukini suspenziju
+      unsubscribe: Ukini pretplatu
+      username: Korisničko ime
+      web: Veb
+    action_logs:
+      actions:
+        confirm_user: "%{name} je potvrdio adresu e-pošte korisnika %{target}"
+        create_custom_emoji: "%{name} je otpremio novi emotikon %{target}"
+        create_domain_block: "%{name} je blokirao domen %{target}"
+        create_email_domain_block: "%{name} je stavio na crnu listu domen e-pošte %{target}"
+        demote_user: "%{name} je ražalovao korisnika %{target}"
+        destroy_domain_block: "%{name} je odblokirao domen %{target}"
+        destroy_email_domain_block: "%{name} je stavio na belu listu domen e-pošte %{target}"
+        destroy_status: "%{name} je uklonio status korisnika %{target}"
+        disable_2fa_user: "%{name} je isključio obaveznu dvofaktorsku identifikaciju za korisnika %{target}"
+        disable_custom_emoji: "%{name} je onemogućio emotikon %{target}"
+        disable_user: "%{name} je onemogućio prijavljivanje korisniku %{target}"
+        enable_custom_emoji: "%{name} je omogućio emotikon %{target}"
+        enable_user: "%{name} je omogućio prijavljivanje za korisnika %{target}"
+        memorialize_account: "%{name} je pretvorio stranu naloga %{target} kao in memoriam stranu"
+        promote_user: "%{name} je unapredio korisnika %{target}"
+        reset_password_user: "%{name} je resetovao lozinku korisniku %{target}"
+        resolve_report: "%{name} je odbacio prijavu %{target}"
+        silence_account: "%{name} je ućutkao nalog %{target}"
+        suspend_account: "%{name} je suspendovao nalog %{target}"
+        unsilence_account: "%{name} je ukinuo ćutanje nalogu %{target}"
+        unsuspend_account: "%{name} je ukinuo suspenziju nalogu %{target}"
+        update_custom_emoji: "%{name} je izmenio emotikon %{target}"
+        update_status: "%{name} je izmenio status korisnika %{target}"
+      title: Zapisnik
+    custom_emojis:
+      by_domain: Domen
+      copied_msg: Uspešno napravljena lokalna kopija emotikona
+      copy: Kopiraj
+      copy_failed_msg: Ne mogu da napravim lokalnu kopiju tog emotikona
+      created_msg: Emotikon uspešno napravljen!
+      delete: Obriši
+      destroyed_msg: Emotikon uspešno obrisan!
+      disable: Onemogući
+      disabled_msg: Emotikon uspešno onemogućen
+      emoji: Emotikon
+      enable: Omogući
+      enabled_msg: Emotikon uspešno omogućen
+      image_hint: PNG do 50KB
+      listed: Izlistan
+      new:
+        title: Dodaj novi proizvoljni emotikon
+      overwrite: Prepiši
+      shortcode: Prečica
+      shortcode_hint: Najmanje 2 karaktera, dozvoljeni su samo slova, brojevi i donje crte
+      title: Proizvoljni emotikoni
+      unlisted: Neizlistan
+      update_failed_msg: Ne mogu da ažuriram ovaj emotikon
+      updated_msg: emotikon uspešno ažuriran!
+      upload: Otpremi
+    domain_blocks:
+      add_new: Dodaj novi
+      created_msg: Blokiranje domena se obrađuje
+      destroyed_msg: Blokiranje domena je opozvano
+      domain: Domen
+      new:
+        create: Napravi blokadu
+        hint: Blokiranje domena neće sprečiti pravljenje naloga u bazi, ali će retroaktivno i automatski primeniti određene moderatorske metode nad tim nalozima.
+        severity:
+          desc_html: "<strong>Ućutkavanje</strong> će sve statuse ovog naloga učiniti nevidiljivim za sve, osim za one koji nalog već prate. <strong>Suspenzija</strong> će ukloniti sav sadržaj naloga, svu multimediju, i profilne podatke. Koristite <strong>Ništa</strong> ako samo želite da odbacite multimedijalne fajlove."
+          noop: Ništa
+          silence: Ućutkavanje
+          suspend: Suspenzija
+        title: Novo blokiranje domena
+      reject_media: Odbaci multimediju
+      reject_media_hint: Uklanja lokalno uskladištene multimedijske fajlove i odbija da ih skida na dalje. Nebitno je za suspenziju.
+      severities:
+        noop: Ništa
+        silence: Ućutkavanje
+        suspend: Suspenzija
+      severity: Oštrina
+      show:
+        affected_accounts:
+          few: Utiče na %{count} naloga u bazi
+          many: Utiče na %{count} naloga u bazi
+          one: Utiče na jedan nalog u bazi
+          other: Utiče na %{count} naloga u bazi
+        retroactive:
+          silence: Ugasi ućutkivanje za sve postojeće naloge sa ovog domena
+          suspend: Ugasi suspenzije za sve postojeće naloge sa ovog domena
+        title: Poništi blokadu domena za domen %{domain}
+        undo: Poništi
+      title: Blokade domena
+      undo: Poništi
+    email_domain_blocks:
+      add_new: Dodaj novuAdd new
+      created_msg: Uspešno dodao domen e-pošte na crnu listu
+      delete: Ukloni
+      destroyed_msg: Uspešno uklonjen domen e-pošte sa crne liste
+      domain: Domen
+      new:
+        create: Dodaj domen
+        title: Nova stavka u crnoj listi e-pošti
+      title: Crna lista adresa e-pošte
+    instances:
+      account_count: Poznati nalozi
+      domain_name: Domen
+      reset: Resetuj
+      search: Pretraga
+      title: Poznate instance
+    invites:
+      filter:
+        all: Sve
+        available: Aktivne
+        expired: Istekle
+        title: Filter
+      title: Pozivnice
+    reports:
+      action_taken_by: Akciju izveo
+      are_you_sure: Da li ste sigurni?
+      comment:
+        label: Komentar
+        none: Ništa
+      delete: Obriši
+      id: ID
+      mark_as_resolved: Označi kao rešen
+      nsfw:
+        'false': Otkrij medijske priloge
+        'true': Sakrij medijske priloge
+      report: 'Prijava #%{id}'
+      report_contents: Sadržaj
+      reported_account: Prijavljeni nalog
+      reported_by: Prijavio
+      resolved: Rešeni
+      silence_account: Ućutkaj nalog
+      status: Status
+      suspend_account: Suspenduj nalog
+      target: Cilj
+      title: Prijave
+      unresolved: Nerešeni
+      view: Pogledaj
+    settings:
+      bootstrap_timeline_accounts:
+        desc_html: Odvojite više korisničkih imena zarezom. Radi samo za lokalne i otključane naloge. Ako je prazno, onda se odnosi na sve lokalne administratore.
+        title: Nalozi za automatsko zapraćivanje za nove korisnike
+      contact_information:
+        email: Poslovna e-pošta
+        username: Kontakt korisničko ime
+      registrations:
+        closed_message:
+          desc_html: Prikazuje se na glavnoj strani kada je instanca zatvorena za registracije. Možete koristiti HTML tagove
+          title: Poruka o zatvorenoj registraciji
+        deletion:
+          desc_html: Dozvoli svima da mogu da obrišu svoj nalog
+          title: Otvori brisanje naloga
+        min_invite_role:
+          disabled: Niko
+          title: Samo preko pozivnice
+        open:
+          desc_html: Dozvoli svakome da kreira nalog
+          title: Otvorena registracija
+      show_staff_badge:
+        desc_html: Prikaži bedž osoblja na korisničkoj strani
+        title: Prikaži bedž osoblja
+      site_description:
+        desc_html: Uvodni pasus na naslovnoj strani i u meta HTML tagovima. Možete koristiti HTML tagove, konkretno <code>&lt;a&gt;</code> i <code>&lt;em&gt;</code>.
+        title: Opis instance
+      site_description_extended:
+        desc_html: Dobro mesto za vaš kod ponašanja, pravila, smernice i druge stvari po kojima se Vaša instanca razlikuje. Možete koristiti HTML tagove
+        title: Proizvoljne dodatne informacije
+      site_terms:
+        desc_html: Možete pisati Vašu politiku privatnosti, uslove korišćenja i ostale legalne stvari. Možete koristiti HTML tagove
+        title: Proizvoljni uslovi korišćenja
+      site_title: Ime instance
+      thumbnail:
+        desc_html: Koristi se za preglede kroz OpenGraph i API. Preporučuje se 1200x630px
+        title: Sličica instance
+      timeline_preview:
+        desc_html: Prikaži javnu lajnu na početnoj strani
+        title: Pregled lajne
+      title: Postavke sajta
+    statuses:
+      back_to_account: Nazad na stranu naloga
+      batch:
+        delete: Obriši
+        nsfw_off: NSFW isključen
+        nsfw_on: NSFW uključen
+      execute: Izvrši
+      failed_to_execute: Neuspelo izvršavanje
+      media:
+        hide: Sakrij multimediju
+        show: Prikaži multimediju
+        title: Multimedija
+      no_media: Bez multimedije
+      title: Statusi naloga
+      with_media: Sa multimedijom
+    subscriptions:
+      callback_url: Callback URL
+      confirmed: Potvrđeno
+      expires_in: Ističe za
+      last_delivery: Poslednja dostava
+      title: WebSub
+      topic: Topic
+    title: Administracija
+  admin_mailer:
+    new_report:
+      body: "%{reporter} je prijavio %{target}"
+      subject: Nova prijava za %{instance} (#%{id})
+  application_mailer:
+    salutation: "%{name},"
+    settings: 'Promeni podešavanja e-pošte: %{link}'
+    signature: Mastodont obaveštenje sa instance %{instance}
+    view: 'Pogledaj:'
+  applications:
+    created: Aplikacija uspešno napravljena
+    destroyed: Aplikacija uspešno obrisana
+    invalid_url: Data adresa nije ispravna
+    regenerate_token: Rekreiraj pristupni token
+    token_regenerated: Pristupni token uspešno rekreiran
+    warning: Oprezno sa ovim podacima. Nikad je ne delite ni sa kim!
+    your_token: Vaš pristupni token
+  auth:
+    agreement_html: Pristupanjem instanci se slažete sa <a href="%{rules_path}">pravilima instance</a> i <a href="%{terms_path}">uslovima korišćenja</a>.
+    change_password: Bezbednost
+    delete_account: Obriši nalog
+    delete_account_html: Ako želite da obrišete Vaš nalog, možete <a href="%{path}">nastaviti ovde</a>. Bićete upitani da potvrdite.
+    didnt_get_confirmation: Niste dobili poruku sa uputstvima za potvrdu naloga?
+    forgot_password: Zaboravili ste lozinku?
+    invalid_reset_password_token: Token za resetovanje lozinke je neispravan ili je istekao. Zatražite novi.
+    login: Prijavi se
+    logout: Odjava
+    migrate_account: Pomeri u drugi nalog
+    migrate_account_html: Ako želite da preusmerite ovaj nalog na neki drugi, možete to <a href="%{path}">podesiti ovde</a>.
+    register: Registruj se
+    resend_confirmation: Pošalji poruku sa uputstvima o potvrdi naloga ponovo
+    reset_password: Resetuj lozinku
+    set_new_password: Postavi novu lozinku
+  authorize_follow:
+    error: Nažalost, desila se greška pri traženju udaljenog naloga
+    follow: Zaprati
+    follow_request: 'Poslali ste zahtev za praćenjen za:'
+    following: 'Sjajno! Sada pratite:'
+    post_follow:
+      close: Ili možete zatvoriti ovaj prozor.
+      return: Vrati se na profil ovog korisnika
+      web: Idi na veb
+    title: Zaprati %{acct}
+  datetime:
+    distance_in_words:
+      about_x_hours: "%{count}h"
+      about_x_months: "%{count}mesec"
+      about_x_years: "%{count}god"
+      almost_x_years: "%{count}god"
+      half_a_minute: Upravo sad
+      less_than_x_minutes: "%{count}m"
+      less_than_x_seconds: Upravo sad
+      over_x_years: "%{count}god"
+      x_days: "%{count}d"
+      x_minutes: "%{count}m"
+      x_months: "%{count}mesec"
+      x_seconds: "%{count}s"
+  deletes:
+    bad_password_msg: Dobar pokušaj, hakeri! Neispravna lozinka
+    confirm_password: Unesite trenutnu lozinku da bismo proverili Vaš identitet
+    description_html: Ovo će <strong>trajno, bespovratno</strong> ukloniti sadržaj sa Vašef naloga i deaktivirati ga. Vaše korisničko ime će ostati rezervisano da se spreči da se neko ne predstavlja kao Vi sutra.
+    proceed: Obriši nalog
+    success_msg: Vaš nalog je uspešno obrisan
+    warning_html: Garantovano je samo brisanje sadržaja sa ove instance. Sadržaj koji je deljen dalje će verovatno da ostavi neke tragove. Nedostupni i ugašeni serveri, kao i serveri koji su odjavljeni od primanja statusa od Vas, neće ažurirati svoje baze.
+    warning_title: Dostupnost rasejanog sadržaja
+  errors:
+    '403': Nemate dozvola da vidite ovu stranu.
+    '404': Strana koju ste tražili ne postoji.
+    '410': Strana koju ste tražili više ne postoji.
+    '422':
+      content: Security verification failed. Are you blocking cookies?
+      title: Security verification failed
+    '429': Uspored
+    '500':
+      content: Izvinjavamo se, nešto je pošlo po zlu sa ove strane.
+      title: Strana nije ispravna
+    noscript_html: Da biste koristili Mastodont veb aplikaciju, omogućite JavaScript. U suprotnom, probajte neku od <a href="https://github.com/tootsuite/documentation/blob/master/Using-Mastodon/Apps.md">originalnih aplikacija</a> za Mastodont za Vašu platformu.
+  exports:
+    blocks: Blokirali ste
+    csv: CSV
+    follows: Pratite
+    mutes: Ućutkali ste
+    storage: Multimedijalno skladište
+  followers:
+    domain: Domen
+    explanation_html: Ako želite da osigurate privatnost Vaših statusa, morate biti svesni ko Vas prati. <strong>Vaši privatni statusi se šalju na sve instance na kojima imate pratioce</strong>. Možda želite da ih pregledate i da uklonite one pratioce na onim instancama za koje nemate poverenja da će poštovati Vašu privatnost.
+    followers_count: Broj pratilaca
+    lock_link: Zaključajte nalog
+    purge: Ukloni iz pratioca
+    success:
+      few: U procesu blokiranja pratioca sa %{count} domena...
+      many: U procesu blokiranja pratioca sa %{count} domena...
+      one: U procesu blokiranja pratioca sa jednog domena...
+      other: U procesu blokiranja pratioca sa %{count} domena...
+    true_privacy_html: Zapamtite da se <strong>prava privatnost može postići samo šifrovanjem sa kraja na kraj</strong>.
+    unlocked_warning_html: Svako može da Vas zaprati da odmah vidi Vaše privatne statuse. %{lock_link} da biste pregledali i odbacili pratioce.
+    unlocked_warning_title: Vaš nalog nije zaključan
+  generic:
+    changes_saved_msg: Izmene uspešno sačuvane!
+    powered_by: omogućio %{link}
+    save_changes: Snimi izmene
+    validation_errors:
+      few: Nešto nije baš kako treba! Pregledajte %{count} greške ispod
+      many: Nešto nije baš kako treba! Pregledajte %{count} grešaka ispod
+      one: Nešto nije baš kako treba! Pregledajte greške ispod
+      other: Nešto nije baš kako treba! Pregledajte %{count} grešaka ispod
+  imports:
+    preface: Možete uvesti podatke koje ste izvezli sa druge instance, kao što su liste ljudi koje ste pratili ili blokirali.
+    success: Vaši podaci su uspešno otpremljeni i biće obrađeni uskoro
+    types:
+      blocking: Lista blokiranja
+      following: Lista pratilaca
+      muting: Lista ućutkanih
+    upload: Otpremi
+  in_memoriam_html: In Memoriam.
+  invites:
+    delete: Deaktiviraj
+    expired: Isteklo
+    expires_in:
+      '1800': 30 minuta
+      '21600': 6 sati
+      '3600': 1 sad
+      '43200': 12 sati
+      '86400': 1 dan
+    expires_in_prompt: Nikad
+    generate: Generiši
+    max_uses:
+      few: "%{count} korišćenja"
+      many: "%{count} korišćenja"
+      one: 1 korišćenje
+      other: "%{count} korišćenja"
+    max_uses_prompt: Bez ograničenja
+    prompt: Generiši i podeli linkove sa drugima da im odobrite pristup ovoj instanci
+    table:
+      expires_at: Ističe
+      uses: Korišćenja
+    title: Pozovi ljude
+  landing_strip_html: "<strong>%{name}</strong> je korisnik na %{link_to_root_path}. Možete ga zapratiti ili komunicirati sa njim ako imte nalog bilo gde u fediversu."
+  landing_strip_signup_html: Ako nemate, možete se <a href="%{sign_up_path}">registrovati ovde</a>.
+  lists:
+    errors:
+      limit: Dostigli ste limit broja listi
+  media_attachments:
+    validations:
+      images_and_video: Ne može da se prikači video na status koji već ima slike
+      too_many: Ne može se prikačiti više od 4 fajla
+  migrations:
+    acct: korisnik@domen novog naloga
+    currently_redirecting: 'Profil Vam je podešen da preusmerava na :'
+    proceed: Sačuvaj
+    updated_msg: Prebacivanje postavki Vašeg naloga uspešno izmenjeno!
+  moderation:
+    title: Moderacija
+  notification_mailer:
+    digest:
+      body: 'Evo kratak pregled šta ste propustili na instanci %{instance} od poslednje posete od %{since}:'
+      mention: "%{name} Vas je pomenuo u:"
+      new_followers_summary:
+        few: Dobili ste %{count} nova pratioca! Sjajno!
+        many: Dobili ste %{count} novih pratioca! Sjajno!
+        one: Dobili ste jednog novog pratioca! Jeee!
+        other: Dobili ste %{count} novih pratioca! Sjajno!
+      subject:
+        few: "%{count} nova obaveštenja od poslednje posete \U0001F418"
+        many: "%{count} novih obaveštenja od poslednje posete \U0001F418"
+        one: "1 novo obaveštenje od poslednje posete \U0001F418"
+        other: "%{count} novih obaveštenja od poslednje posete \U0001F418"
+    favourite:
+      body: "%{name} je postavio kao omiljen Vaš status:"
+      subject: "%{name} je postavio kao omiljen Vaš status"
+    follow:
+      body: "%{name} Vas je zapratio!"
+      subject: "%{name} Vas je zapratio"
+    follow_request:
+      body: "%{name} je zatražio da Vas zaprati"
+      subject: 'Pratioci na čekanju: %{name}'
+    mention:
+      body: "%{name} Vas je pomenuo u:"
+      subject: "%{name} Vas je pomenuo"
+    reblog:
+      body: "%{name} Vam je podržao(la) status:"
+      subject: "%{name} je podržao(la) Vaš status"
+  number:
+    human:
+      decimal_units:
+        format: "%n%u"
+        units:
+          billion: B
+          million: M
+          quadrillion: Q
+          thousand: K
+          trillion: T
+          unit: ''
+  pagination:
+    next: Sledeći
+    prev: Prethodni
+    truncate: "&hellip;"
+  preferences:
+    languages: Jezici
+    other: Ostali
+    publishing: Objavljivanje
+    web: Veb
+  push_notifications:
+    favourite:
+      title: "%{name} je stavio Vaš status za omiljeni"
+    follow:
+      title: "%{name} Vas je zapratio"
+    group:
+      title: "%{count} obaveštenja"
+    mention:
+      action_boost: Podrži
+      action_expand: Prikaži još
+      action_favourite: Omiljeni
+      title: "%{name} Vas je pomenuo"
+    reblog:
+      title: "%{name} je podržao(la) Vaš status"
+  remote_follow:
+    acct: Unesite Vaš korisnik@domen sa koga želite da pratite
+    missing_resource: Ne mogu da nađem zahtevanu adresu preusmeravanja za Vaš nalog
+    proceed: Nastavite da zapratite
+    prompt: 'Zapratite će:'
+  sessions:
+    activity: Poslednja aktivnost
+    browser: Veb čitač
+    browsers:
+      alipay: Alipay
+      blackberry: Blekberi
+      chrome: Hrom
+      edge: Microsoft Edge
+      firefox: Firefox
+      generic: Nepoznati veb čitač
+      ie: Internet Explorer
+      micro_messenger: MicroMessenger
+      nokia: Nokia S40 Ovi Browser
+      opera: Opera
+      phantom_js: PhantomJS
+      qq: QQ Browser
+      safari: Safari
+      uc_browser: UCBrowser
+      weibo: Weibo
+    current_session: Trenutna sesija
+    description: "%{browser} sa %{platform}"
+    explanation: Ovo su trenutno prijavljeni veb čitači na Vaš Mastodont nalog.
+    ip: IP
+    platforms:
+      adobe_air: Adobe Air-a
+      android: Androida
+      blackberry: Blekberija
+      chrome_os: Hrom OS-a
+      firefox_os: Fajerfoks OS-a
+      ios: iOS
+      linux: Linuksa
+      mac: Mac-a
+      other: nepoznate platforme
+      windows: Vindouza
+      windows_mobile: Vindouz mobilnog
+      windows_phone: Vindouz telefona
+    revoke: Opozovi
+    revoke_success: Sesija uspešno opozvana
+    title: Sesije
+  settings:
+    authorized_apps: Autorizovane aplikacije
+    back: Nazad na Mastodonta
+    delete: Brisanje naloga
+    development: Razvoj
+    edit_profile: Izmena profila
+    export: Izvoz podataka
+    followers: Autorizovani pratioci
+    import: Uvoz
+    migrate: Prebacivanje naloga
+    notifications: Obaveštenja
+    preferences: Podešavanja
+    settings: Postavke
+    two_factor_authentication: Dvofaktorska identifikacija
+    your_apps: Vaše aplikacije
+  statuses:
+    open_in_web: Otvori u vebu
+    over_character_limit: ograničenje od %{max} karaktera prekoračeno
+    pin_errors:
+      limit: Već imate prikačen najveći broj tutova
+      ownership: Tuđi tutovi ne mogu da se prikače
+      private: Tutovi koji nisu javni ne mogu da se prikače
+      reblog: Podrška ne može da se prikači
+    show_more: Prikaži još
+    title: '%{name}: "%{quote}"'
+    visibilities:
+      private: Samo pratioci
+      private_long: Samo prikaži pratiocima
+      public: Javno
+      public_long: Svako može da vidi
+      unlisted: Neizlistano
+      unlisted_long: Svako može da vidi, ali nije izlistano na javnim lajnama
+  stream_entries:
+    click_to_show: Klikni da vidiš
+    pinned: Prikačeni tut
+    reblogged: podržano
+    sensitive_content: Osetljiv sadržaj
+  terms:
+    body_html: |
+      <h2>Privacy Policy</h2>
+
+      <h3 id="collect">What information do we collect?</h3>
+
+      <p>We collect information from you when you register on our site and gather data when you participate in the forum by reading, writing, and evaluating the content shared here.</p>
+
+      <p>When registering on our site, you may be asked to enter your name and e-mail address. You may, however, visit our site without registering. Your e-mail address will be verified by an email containing a unique link. If that link is visited, we know that you control the e-mail address.</p>
+
+      <p>When registered and posting, we record the IP address that the post originated from. We also may retain server logs which include the IP address of every request to our server.</p>
+
+      <h3 id="use">What do we use your information for?</h3>
+
+      <p>Any of the information we collect from you may be used in one of the following ways:</p>
+
+      <ul>
+        <li>To personalize your experience &mdash; your information helps us to better respond to your individual needs.</li>
+        <li>To improve our site &mdash; we continually strive to improve our site offerings based on the information and feedback we receive from you.</li>
+        <li>To improve customer service &mdash; your information helps us to more effectively respond to your customer service requests and support needs.</li>
+        <li>To send periodic emails &mdash; The email address you provide may be used to send you information, notifications that you request about changes to topics or in response to your user name, respond to inquiries, and/or other requests or questions.</li>
+      </ul>
+
+      <h3 id="protect">How do we protect your information?</h3>
+
+      <p>We implement a variety of security measures to maintain the safety of your personal information when you enter, submit, or access your personal information.</p>
+
+      <h3 id="data-retention">What is your data retention policy?</h3>
+
+      <p>We will make a good faith effort to:</p>
+
+      <ul>
+        <li>Retain server logs containing the IP address of all requests to this server no more than 90 days.</li>
+        <li>Retain the IP addresses associated with registered users and their posts no more than 5 years.</li>
+      </ul>
+
+      <h3 id="cookies">Do we use cookies?</h3>
+
+      <p>Yes. Cookies are small files that a site or its service provider transfers to your computer's hard drive through your Web browser (if you allow). These cookies enable the site to recognize your browser and, if you have a registered account, associate it with your registered account.</p>
+
+      <p>We use cookies to understand and save your preferences for future visits and compile aggregate data about site traffic and site interaction so that we can offer better site experiences and tools in the future. We may contract with third-party service providers to assist us in better understanding our site visitors. These service providers are not permitted to use the information collected on our behalf except to help us conduct and improve our business.</p>
+
+      <h3 id="disclose">Do we disclose any information to outside parties?</h3>
+
+      <p>We do not sell, trade, or otherwise transfer to outside parties your personally identifiable information. This does not include trusted third parties who assist us in operating our site, conducting our business, or servicing you, so long as those parties agree to keep this information confidential. We may also release your information when we believe release is appropriate to comply with the law, enforce our site policies, or protect ours or others rights, property, or safety. However, non-personally identifiable visitor information may be provided to other parties for marketing, advertising, or other uses.</p>
+
+      <h3 id="third-party">Third party links</h3>
+
+      <p>Occasionally, at our discretion, we may include or offer third party products or services on our site. These third party sites have separate and independent privacy policies. We therefore have no responsibility or liability for the content and activities of these linked sites. Nonetheless, we seek to protect the integrity of our site and welcome any feedback about these sites.</p>
+
+      <h3 id="coppa">Children's Online Privacy Protection Act Compliance</h3>
+
+      <p>Our site, products and services are all directed to people who are at least 13 years old. If this server is in the USA, and you are under the age of 13, per the requirements of COPPA (<a href="https://en.wikipedia.org/wiki/Children%27s_Online_Privacy_Protection_Act">Children's Online Privacy Protection Act</a>) do not use this site.</p>
+
+      <h3 id="online">Online Privacy Policy Only</h3>
+
+      <p>This online privacy policy applies only to information collected through our site and not to information collected offline.</p>
+
+      <h3 id="consent">Your Consent</h3>
+
+      <p>By using our site, you consent to our web site privacy policy.</p>
+
+      <h3 id="changes">Changes to our Privacy Policy</h3>
+
+      <p>If we decide to change our privacy policy, we will post those changes on this page.</p>
+
+      <p>This document is CC-BY-SA. It was last updated May 31, 2013.</p>
+
+      <p>Originally adapted from the <a href="https://github.com/discourse/discourse">Discourse privacy policy</a>.</p>
+    title: Uslovi korišćenja i politika privatnosti instance %{instance}
+  themes:
+    default: Mastodont
+  time:
+    formats:
+      default: "%b %d, %Y, %H:%M"
+  two_factor_authentication:
+    code_hint: Unesite kod sa Vaše aplikacije za proveru identiteta da potvrdite
+    description_html: Ako uključite <strong>dvofaktorsku identifikaciju</strong>, moraćete da imate telefon sa sobom da biste mogli da se prijavite. Telefon će onda generisati tokene za Vašu prijavu.
+    disable: Isključi
+    enable: Uključi
+    enabled: Dvofaktorska identifikacija je uključena
+    enabled_success: Dvofaktorska identifikacija je uspešno uključena
+    generate_recovery_codes: Generiši kodove za oporavak
+    instructions_html: "<strong>Skenirajte ovaj QR kod u Google Authenticator ili nekoj sličnoj TOTP aplikaciji na Vašem telefonu</strong>. Od sada, ta aplikacija će Vam generisati tokene koje morate uneti da biste se prijavili."
+    lost_recovery_codes: Kodovi za oporavak Vam omogućavaju da povratite pristup nalogu ako izgubite telefon. Ako izgubite kodove za oporavak, možete ih regenerisati ovde. Od tog trenutka, stari kodovi za oporavak više ne važe.
+    manual_instructions: 'Ukoliko ne možete da skenirate QR kod i morate ga unesete ručno, evo je ogoljena šifra:'
+    recovery_codes: Napravite rezervu kodova za oporavak
+    recovery_codes_regenerated: Kodovi za oporavak uspešno regenerisani
+    recovery_instructions_html: Ako ikada izgubite pristup telefonu, možete iskoristiti kodove za oporavak date ispod da povratite pristup nalogu. <strong>Držite kodove za oporavak na sigurnom</strong>. Na primer, odštampajte ih i čuvajte ih sa ostalim važnim dokumentima.
+    setup: Nameštanje
+    wrong_code: Uneseni kod nije ispravan! Da li su vremena na serveru i na uređaju ispravna?
+  users:
+    invalid_email: Adresa e-pošte nije ispravna
+    invalid_otp_token: Neispravni dvofaktorski kod
+    signed_in_as: 'Prijavljen kao:'
diff --git a/config/locales/sr.yml b/config/locales/sr.yml
new file mode 100644
index 000000000..57ccf2008
--- /dev/null
+++ b/config/locales/sr.yml
@@ -0,0 +1,723 @@
+---
+sr:
+  about:
+    about_hashtag_html: Ово су јавни статуси таговани са <strong>#%{hashtag}</strong>. Можете одговарати на њих ако имате налог било где у федиверсу.
+    about_mastodon_html: Мастодонт је друштвена мрежа базирана на отвореним протоколима и слободном софтверу отвореног кода. Децентрализована је као што је децентрализована е-пошта.
+    about_this: О инстанци
+    closed_registrations: Регистрације су тренутно затворене на овој инстанци. Ипак! Можете наћи другу инстанцу на којој ћете направити налог и одатле добити приступ истој овој мрежи.
+    contact: Контакт
+    contact_missing: Није постављено
+    contact_unavailable: N/A
+    description_headline: Шта је %{domain}?
+    domain_count_after: остале инстанце
+    domain_count_before: Повезан на
+    extended_description_html: |
+      <h3>Добро место за правила</h3>
+      <p>Проширени опис који још није постављен.</p>
+    features:
+      humane_approach_body: Учећи од грешака са осталих мрежа, а да би се борио против злоупотреба на друштвеним мрежама, Мастодонт покушава да прави што етичкије одлуке приликом развоја.
+      humane_approach_title: Хуманији приступ
+      not_a_product_body: Мастодонт није комерцијална мрежа. Нема реклама, нема скупљања приватних података, нема заштићених делова. Нема централног ауторитета.
+      not_a_product_title: Ви сте особа, не производ
+      real_conversation_body: Са 500 карактера на располагању и подршком за грануларнији садржај и упозорења на осетљивији садржај, можете се изразити како год желите.
+      real_conversation_title: Прављен за прави разговор
+      within_reach_body: Више апликација за iOS, Андроид, као и друге платформе захваљујући екосистему добрих API-ја ће Вам омогућити да останете у контакту са пријатељима свуда.
+      within_reach_title: Увек у контакту
+    find_another_instance: Нађите другу инстанцу
+    generic_description: "%{domain} је сервер на мрежи"
+    hosted_on: Мастодонт хостован на %{domain}
+    learn_more: Сазнајте више
+    other_instances: Листа инстанци
+    source_code: Изворни код
+    status_count_after: статуса
+    status_count_before: Који су написали
+    user_count_after: корисника
+    user_count_before: Дом за
+    what_is_mastodon: Шта је Мастодонт?
+  accounts:
+    follow: Follow
+    followers: Followers
+    following: Following
+    media: Мултимедија
+    moved_html: "%{name} је померен на %{new_profile_link}:"
+    nothing_here: Овде нема ништа!
+    people_followed_by: Људи које %{name} прати
+    people_who_follow: Људи који прате %{name}
+    posts: Тутови
+    posts_with_replies: Тутови и одговори
+    remote_follow: Удаљена праћења
+    reserved_username: Корисничко име је резервисано
+    roles:
+      admin: Администратор
+      moderator: Модератор
+    unfollow: Отпрати
+  admin:
+    account_moderation_notes:
+      account: Модератор
+      create: Направи
+      created_at: Датум
+      created_msg: Модераторска белешка успешно направљена!
+      delete: Обриши
+      destroyed_msg: Модераторска белешка успешно обрисана!
+    accounts:
+      are_you_sure: Да ли сте сигурни?
+      by_domain: Домен
+      confirm: Потврди
+      confirmed: Потврђено
+      demote: Ражалуј
+      disable: Искључи
+      disable_two_factor_authentication: Искључи 2FA
+      disabled: Искључена
+      display_name: Приказано име
+      domain: Домен
+      edit: Измени
+      email: Е-пошта
+      enable: Укључи
+      enabled: Укључено
+      feed_url: Адреса довода
+      followers: Пратиоци
+      followers_url: Адреса пратиоца
+      follows: Праћени
+      inbox_url: Адреса сандучета
+      ip: IP
+      location:
+        all: Све
+        local: Локалне
+        remote: Удаљене
+        title: Локација
+      login_status: Статус пријаве
+      media_attachments: Мултимедијални прилози
+      memorialize: Пребаци у in memoriam
+      moderation:
+        all: Сви
+        silenced: Ућуткани
+        suspended: Суспендовани
+        title: Модерација
+      moderation_notes: Модераторске белешке
+      most_recent_activity: Најскорија активност
+      most_recent_ip: Најскорија IP адреса
+      not_subscribed: Није претплаћен
+      order:
+        alphabetic: Абецедни
+        most_recent: Најскорији
+        title: Редослед
+      outbox_url: Одлазно сандуче
+      perform_full_suspension: Изврши комплетно искључење
+      profile_url: Адреса профила
+      promote: Унапреди
+      protocol: Протокол
+      public: Јавно
+      push_subscription_expires: PuSH subscription expires
+      redownload: Освежи аватар
+      reset: Ресетуј
+      reset_password: Ресетуј лозинку
+      resubscribe: Поново се претплати
+      role: Овлашћења
+      roles:
+        admin: Администратор
+        moderator: Модератор
+        staff: Особље
+        user: Корисник
+      salmon_url: Salmon адреса
+      search: Претрага
+      shared_inbox_url: Адреса дељеног сандучета
+      show:
+        created_reports: Пријаве које је направио овај налог
+        report: пријава
+        targeted_reports: Пријаве направљене о овом налогу
+      silence: Ућуткај
+      statuses: Статуси
+      subscribe: Претплати се
+      title: Налози
+      undo_silenced: Укини ћутање
+      undo_suspension: Укини суспензију
+      unsubscribe: Укини претплату
+      username: Корисничко име
+      web: Веб
+    action_logs:
+      actions:
+        confirm_user: "%{name} је потврдио адресу е-поште корисника %{target}"
+        create_custom_emoji: "%{name} је отпремио нови емотикон %{target}"
+        create_domain_block: "%{name} је блокирао домен %{target}"
+        create_email_domain_block: "%{name} је ставио на црну листу домен е-поште %{target}"
+        demote_user: "%{name} је ражаловао корисника %{target}"
+        destroy_domain_block: "%{name} је одблокирао домен %{target}"
+        destroy_email_domain_block: "%{name} је ставио на белу листу домен е-поште %{target}"
+        destroy_status: "%{name} је уклонио статус корисника %{target}"
+        disable_2fa_user: "%{name} је искључио обавезну двофакторску идентификацију за корисника %{target}"
+        disable_custom_emoji: "%{name} је онемогућио емотикон %{target}"
+        disable_user: "%{name} је онемогућио пријављивање кориснику %{target}"
+        enable_custom_emoji: "%{name} је омогућио емотикон %{target}"
+        enable_user: "%{name} је омогућио пријављивање за корисника %{target}"
+        memorialize_account: "%{name} је претворио страну налога %{target} као in memoriam страну"
+        promote_user: "%{name} је унапредио корисника %{target}"
+        reset_password_user: "%{name} је ресетовао лозинку кориснику %{target}"
+        resolve_report: "%{name} је одбацио пријаву %{target}"
+        silence_account: "%{name} је ућуткао налог %{target}"
+        suspend_account: "%{name} је суспендовао налог %{target}"
+        unsilence_account: "%{name} је укинуо ћутање налогу %{target}"
+        unsuspend_account: "%{name} је укинуо суспензију налогу %{target}"
+        update_custom_emoji: "%{name} је изменио емотикон %{target}"
+        update_status: "%{name} је изменио статус корисника %{target}"
+      title: Записник
+    custom_emojis:
+      by_domain: Домен
+      copied_msg: Успешно направљена локална копија емотикона
+      copy: Копирај
+      copy_failed_msg: Не могу да направим локалну копију тог емотикона
+      created_msg: Емотикон успешно направљен!
+      delete: Обриши
+      destroyed_msg: Емотикон успешно обрисан!
+      disable: Онемогући
+      disabled_msg: Емотикон успешно онемогућен
+      emoji: Емотикон
+      enable: Омогући
+      enabled_msg: Емотикон успешно омогућен
+      image_hint: PNG до 50KB
+      listed: Излистан
+      new:
+        title: Додај нови произвољни емотикон
+      overwrite: Препиши
+      shortcode: Пречица
+      shortcode_hint: Најмање 2 карактера, дозвољени су само слова, бројеви и доње црте
+      title: Произвољни емотикони
+      unlisted: Неизлистан
+      update_failed_msg: Не могу да ажурирам овај емотикон
+      updated_msg: емотикон успешно ажуриран!
+      upload: Отпреми
+    domain_blocks:
+      add_new: Додај нови
+      created_msg: Блокирање домена се обрађује
+      destroyed_msg: Блокирање домена је опозвано
+      domain: Домен
+      new:
+        create: Направи блокаду
+        hint: Блокирање домена неће спречити прављење налога у бази, али ће ретроактивно и аутоматски применити одређене модераторске методе над тим налозима.
+        severity:
+          desc_html: "<strong>Ућуткавање</strong> ће све статусе овог налога учинити невидиљивим за све, осим за оне који налог већ прате. <strong>Суспензија</strong> ће уклонити сав садржај налога, сву мултимедију, и профилне податке. Користите <strong>Ништа</strong> ако само желите да одбаците мултимедијалне фајлове."
+          noop: Ништа
+          silence: Ућуткавање
+          suspend: Суспензија
+        title: Ново блокирање домена
+      reject_media: Одбаци мултимедију
+      reject_media_hint: Уклања локално ускладиштене мултимедијске фајлове и одбија да их скида на даље. Небитно је за суспензију.
+      severities:
+        noop: Ништа
+        silence: Ућуткавање
+        suspend: Суспензија
+      severity: Оштрина
+      show:
+        affected_accounts:
+          few: Утиче на %{count} налога у бази
+          many: Утиче на %{count} налога у бази
+          one: Утиче на један налог у бази
+          other: Утиче на %{count} налога у бази
+        retroactive:
+          silence: Угаси ућуткивање за све постојеће налоге са овог домена
+          suspend: Угаси суспензије за све постојеће налоге са овог домена
+        title: Поништи блокаду домена за домен %{domain}
+        undo: Поништи
+      title: Блокаде домена
+      undo: Поништи
+    email_domain_blocks:
+      add_new: Додај новуAdd new
+      created_msg: Успешно додао домен е-поште на црну листу
+      delete: Уклони
+      destroyed_msg: Успешно уклоњен домен е-поште са црне листе
+      domain: Домен
+      new:
+        create: Додај домен
+        title: Нова ставка у црној листи е-пошти
+      title: Црна листа адреса е-поште
+    instances:
+      account_count: Познати налози
+      domain_name: Домен
+      reset: Ресетуј
+      search: Претрага
+      title: Познате инстанце
+    invites:
+      filter:
+        all: Све
+        available: Активне
+        expired: Истекле
+        title: Филтер
+      title: Позивнице
+    reports:
+      action_taken_by: Акцију извео
+      are_you_sure: Да ли сте сигурни?
+      comment:
+        label: Коментар
+        none: Ништа
+      delete: Обриши
+      id: ID
+      mark_as_resolved: Означи као решен
+      nsfw:
+        'false': Откриј медијске прилоге
+        'true': Сакриј медијске прилоге
+      report: 'Пријава #%{id}'
+      report_contents: Садржај
+      reported_account: Пријављени налог
+      reported_by: Пријавио
+      resolved: Решени
+      silence_account: Ућуткај налог
+      status: Статус
+      suspend_account: Суспендуј налог
+      target: Циљ
+      title: Пријаве
+      unresolved: Нерешени
+      view: Погледај
+    settings:
+      bootstrap_timeline_accounts:
+        desc_html: Одвојите више корисничких имена зарезом. Ради само за локалне и откључане налоге. Ако је празно, онда се односи на све локалне администраторе.
+        title: Налози за аутоматско запраћивање за нове кориснике
+      contact_information:
+        email: Пословна е-пошта
+        username: Контакт корисничко име
+      registrations:
+        closed_message:
+          desc_html: Приказује се на главној страни када је инстанца затворена за регистрације. Можете користити HTML тагове
+          title: Порука о затвореној регистрацији
+        deletion:
+          desc_html: Дозволи свима да могу да обришу свој налог
+          title: Отвори брисање налога
+        min_invite_role:
+          disabled: Нико
+          title: Само преко позивнице
+        open:
+          desc_html: Дозволи свакоме да креира налог
+          title: Отворена регистрација
+      show_staff_badge:
+        desc_html: Прикажи беџ особља на корисничкој страни
+        title: Прикажи беџ особља
+      site_description:
+        desc_html: Уводни пасус на насловној страни и у meta HTML таговима. Можете користити HTML тагове, конкретно <code>&lt;a&gt;</code> и <code>&lt;em&gt;</code>.
+        title: Опис инстанце
+      site_description_extended:
+        desc_html: Добро место за ваш код понашања, правила, смернице и друге ствари по којима се Ваша инстанца разликује. Можете користити HTML тагове
+        title: Произвољне додатне информације
+      site_terms:
+        desc_html: Можете писати Вашу политику приватности, услове коришћења и остале легалне ствари. Можете користити HTML тагове
+        title: Произвољни услови коришћења
+      site_title: Име инстанце
+      thumbnail:
+        desc_html: Користи се за прегледе кроз OpenGraph и API. Препоручује се 1200x630px
+        title: Сличица инстанце
+      timeline_preview:
+        desc_html: Прикажи јавну лајну на почетној страни
+        title: Преглед лајне
+      title: Поставке сајта
+    statuses:
+      back_to_account: Назад на страну налога
+      batch:
+        delete: Обриши
+        nsfw_off: NSFW искључен
+        nsfw_on: NSFW укључен
+      execute: Изврши
+      failed_to_execute: Неуспело извршавање
+      media:
+        hide: Сакриј мултимедију
+        show: Прикажи мултимедију
+        title: Мултимедија
+      no_media: Без мултимедије
+      title: Статуси налога
+      with_media: Са мултимедијом
+    subscriptions:
+      callback_url: Callback URL
+      confirmed: Потврђено
+      expires_in: Истиче за
+      last_delivery: Последња достава
+      title: WebSub
+      topic: Topic
+    title: Администрација
+  admin_mailer:
+    new_report:
+      body: "%{reporter} је пријавио %{target}"
+      subject: Нова пријава за %{instance} (#%{id})
+  application_mailer:
+    salutation: "%{name},"
+    settings: 'Промени подешавања е-поште: %{link}'
+    signature: Мастодонт обавештење са инстанце %{instance}
+    view: 'Погледај:'
+  applications:
+    created: Апликација успешно направљена
+    destroyed: Апликација успешно обрисана
+    invalid_url: Дата адреса није исправна
+    regenerate_token: Рекреирај приступни токен
+    token_regenerated: Приступни токен успешно рекреиран
+    warning: Опрезно са овим подацима. Никад је не делите ни са ким!
+    your_token: Ваш приступни токен
+  auth:
+    agreement_html: Приступањем инстанци се слажете са <a href="%{rules_path}">правилима инстанце</a> и <a href="%{terms_path}">условима коришћења</a>.
+    change_password: Безбедност
+    delete_account: Обриши налог
+    delete_account_html: Ако желите да обришете Ваш налог, можете <a href="%{path}">наставити овде</a>. Бићете упитани да потврдите.
+    didnt_get_confirmation: Нисте добили поруку са упутствима за потврду налога?
+    forgot_password: Заборавили сте лозинку?
+    invalid_reset_password_token: Токен за ресетовање лозинке је неисправан или је истекао. Затражите нови.
+    login: Пријави се
+    logout: Одјава
+    migrate_account: Помери у други налог
+    migrate_account_html: Ако желите да преусмерите овај налог на неки други, можете то <a href="%{path}">подесити овде</a>.
+    register: Региструј се
+    resend_confirmation: Пошаљи поруку са упутствима о потврди налога поново
+    reset_password: Ресетуј лозинку
+    set_new_password: Постави нову лозинку
+  authorize_follow:
+    error: Нажалост, десила се грешка при тражењу удаљеног налога
+    follow: Запрати
+    follow_request: 'Послали сте захтев за праћењен за:'
+    following: 'Сјајно! Сада пратите:'
+    post_follow:
+      close: Или можете затворити овај прозор.
+      return: Врати се на профил овог корисника
+      web: Иди на веб
+    title: Запрати %{acct}
+  datetime:
+    distance_in_words:
+      about_x_hours: "%{count}h"
+      about_x_months: "%{count}месец"
+      about_x_years: "%{count}год"
+      almost_x_years: "%{count}год"
+      half_a_minute: Управо сад
+      less_than_x_minutes: "%{count}m"
+      less_than_x_seconds: Управо сад
+      over_x_years: "%{count}год"
+      x_days: "%{count}д"
+      x_minutes: "%{count}m"
+      x_months: "%{count}месец"
+      x_seconds: "%{count}s"
+  deletes:
+    bad_password_msg: Добар покушај, хакери! Неисправна лозинка
+    confirm_password: Унесите тренутну лозинку да бисмо проверили Ваш идентитет
+    description_html: Ово ће <strong>трајно, бесповратно</strong> уклонити садржај са Вашеф налога и деактивирати га. Ваше корисничко име ће остати резервисано да се спречи да се неко не представља као Ви сутра.
+    proceed: Обриши налог
+    success_msg: Ваш налог је успешно обрисан
+    warning_html: Гарантовано је само брисање садржаја са ове инстанце. Садржај који је дељен даље ће вероватно да остави неке трагове. Недоступни и угашени сервери, као и сервери који су одјављени од примања статуса од Вас, неће ажурирати своје базе.
+    warning_title: Доступност расејаног садржаја
+  errors:
+    '403': Немате дозвола да видите ову страну.
+    '404': Страна коју сте тражили не постоји.
+    '410': Страна коју сте тражили више не постоји.
+    '422':
+      content: Security verification failed. Are you blocking cookies?
+      title: Security verification failed
+    '429': Успоред
+    '500':
+      content: Извињавамо се, нешто је пошло по злу са ове стране.
+      title: Страна није исправна
+    noscript_html: Да бисте користили Мастодонт веб апликацију, омогућите JavaScript. У супротном, пробајте неку од <a href="https://github.com/tootsuite/documentation/blob/master/Using-Mastodon/Apps.md">оригиналних апликација</a> за Мастодонт за Вашу платформу.
+  exports:
+    blocks: Блокирали сте
+    csv: CSV
+    follows: Пратите
+    mutes: Ућуткали сте
+    storage: Мултимедијално складиште
+  followers:
+    domain: Домен
+    explanation_html: Ако желите да осигурате приватност Ваших статуса, морате бити свесни ко Вас прати. <strong>Ваши приватни статуси се шаљу на све инстанце на којима имате пратиоце</strong>. Можда желите да их прегледате и да уклоните оне пратиоце на оним инстанцама за које немате поверења да ће поштовати Вашу приватност.
+    followers_count: Број пратилаца
+    lock_link: Закључајте налог
+    purge: Уклони из пратиоца
+    success:
+      few: У процесу блокирања пратиоца са %{count} домена...
+      many: У процесу блокирања пратиоца са %{count} домена...
+      one: У процесу блокирања пратиоца са једног домена...
+      other: У процесу блокирања пратиоца са %{count} домена...
+    true_privacy_html: Запамтите да се <strong>права приватност може постићи само шифровањем са краја на крај</strong>.
+    unlocked_warning_html: Свако може да Вас запрати да одмах види Ваше приватне статусе. %{lock_link} да бисте прегледали и одбацили пратиоце.
+    unlocked_warning_title: Ваш налог није закључан
+  generic:
+    changes_saved_msg: Измене успешно сачуване!
+    powered_by: омогућио %{link}
+    save_changes: Сними измене
+    validation_errors:
+      few: Нешто није баш како треба! Прегледајте %{count} грешке испод
+      many: Нешто није баш како треба! Прегледајте %{count} грешака испод
+      one: Нешто није баш како треба! Прегледајте грешке испод
+      other: Нешто није баш како треба! Прегледајте %{count} грешака испод
+  imports:
+    preface: Можете увести податке које сте извезли са друге инстанце, као што су листе људи које сте пратили или блокирали.
+    success: Ваши подаци су успешно отпремљени и биће обрађени ускоро
+    types:
+      blocking: Листа блокирања
+      following: Листа пратилаца
+      muting: Листа ућутканих
+    upload: Отпреми
+  in_memoriam_html: In Memoriam.
+  invites:
+    delete: Деактивирај
+    expired: Истекло
+    expires_in:
+      '1800': 30 минута
+      '21600': 6 сати
+      '3600': 1 сад
+      '43200': 12 сати
+      '86400': 1 дан
+    expires_in_prompt: Никад
+    generate: Генериши
+    max_uses:
+      few: "%{count} коришћења"
+      many: "%{count} коришћења"
+      one: 1 коришћење
+      other: "%{count} коришћења"
+    max_uses_prompt: Без ограничења
+    prompt: Генериши и подели линкове са другима да им одобрите приступ овој инстанци
+    table:
+      expires_at: Истиче
+      uses: Коришћења
+    title: Позови људе
+  landing_strip_html: "<strong>%{name}</strong> је корисник на %{link_to_root_path}. Можете га запратити или комуницирати са њим ако имте налог било где у федиверсу."
+  landing_strip_signup_html: Ако немате, можете се <a href="%{sign_up_path}">регистровати овде</a>.
+  lists:
+    errors:
+      limit: Достигли сте лимит броја листи
+  media_attachments:
+    validations:
+      images_and_video: Не може да се прикачи видео на статус који већ има слике
+      too_many: Не може се прикачити више од 4 фајла
+  migrations:
+    acct: корисник@домен новог налога
+    currently_redirecting: 'Профил Вам је подешен да преусмерава на :'
+    proceed: Сачувај
+    updated_msg: Пребацивање поставки Вашег налога успешно измењено!
+  moderation:
+    title: Модерација
+  notification_mailer:
+    digest:
+      body: 'Ево кратак преглед шта сте пропустили на инстанци %{instance} од последње посете од %{since}:'
+      mention: "%{name} Вас је поменуо у:"
+      new_followers_summary:
+        few: Добили сте %{count} нова пратиоца! Сјајно!
+        many: Добили сте %{count} нових пратиоца! Сјајно!
+        one: Добили сте једног новог пратиоца! Јеее!
+        other: Добили сте %{count} нових пратиоца! Сјајно!
+      subject:
+        few: "%{count} нова обавештења од последње посете \U0001F418"
+        many: "%{count} нових обавештења од последње посете \U0001F418"
+        one: "1 ново обавештење од последње посете \U0001F418"
+        other: "%{count} нових обавештења од последње посете \U0001F418"
+    favourite:
+      body: "%{name} је поставио као омиљен Ваш статус:"
+      subject: "%{name} је поставио као омиљен Ваш статус"
+    follow:
+      body: "%{name} Вас је запратио!"
+      subject: "%{name} Вас је запратио"
+    follow_request:
+      body: "%{name} је затражио да Вас запрати"
+      subject: 'Пратиоци на чекању: %{name}'
+    mention:
+      body: "%{name} Вас је поменуо у:"
+      subject: "%{name} Вас је поменуо"
+    reblog:
+      body: "%{name} Вам је подржао(ла) статус:"
+      subject: "%{name} је подржао(ла) Ваш статус"
+  number:
+    human:
+      decimal_units:
+        format: "%n%u"
+        units:
+          billion: B
+          million: M
+          quadrillion: Q
+          thousand: K
+          trillion: T
+          unit: ''
+  pagination:
+    next: Следећи
+    prev: Претходни
+    truncate: "&hellip;"
+  preferences:
+    languages: Језици
+    other: Остали
+    publishing: Објављивање
+    web: Веб
+  push_notifications:
+    favourite:
+      title: "%{name} је ставио Ваш статус за омиљени"
+    follow:
+      title: "%{name} Вас је запратио"
+    group:
+      title: "%{count} обавештења"
+    mention:
+      action_boost: Подржи
+      action_expand: Прикажи још
+      action_favourite: Омиљени
+      title: "%{name} Вас је поменуо"
+    reblog:
+      title: "%{name} је подржао(ла) Ваш статус"
+  remote_follow:
+    acct: Унесите Ваш корисник@домен са кога желите да пратите
+    missing_resource: Не могу да нађем захтевану адресу преусмеравања за Ваш налог
+    proceed: Наставите да запратите
+    prompt: 'Запратите ће:'
+  sessions:
+    activity: Последња активност
+    browser: Веб читач
+    browsers:
+      alipay: Alipay
+      blackberry: Блекбери
+      chrome: Хром
+      edge: Microsoft Edge
+      firefox: Firefox
+      generic: Непознати веб читач
+      ie: Internet Explorer
+      micro_messenger: MicroMessenger
+      nokia: Nokia S40 Ovi Browser
+      opera: Опера
+      phantom_js: PhantomJS
+      qq: QQ Browser
+      safari: Сафари
+      uc_browser: UCBrowser
+      weibo: Weibo
+    current_session: Тренутна сесија
+    description: "%{browser} са %{platform}"
+    explanation: Ово су тренутно пријављени веб читачи на Ваш Мастодонт налог.
+    ip: IP
+    platforms:
+      adobe_air: Adobe Air-а
+      android: Андроида
+      blackberry: Блекберија
+      chrome_os: Хром ОС-а
+      firefox_os: Фајерфокс ОС-а
+      ios: iOS
+      linux: Линукса
+      mac: Mac-а
+      other: непознате платформе
+      windows: Виндоуза
+      windows_mobile: Виндоуз мобилног
+      windows_phone: Виндоуз телефона
+    revoke: Опозови
+    revoke_success: Сесија успешно опозвана
+    title: Сесије
+  settings:
+    authorized_apps: Ауторизоване апликације
+    back: Назад на Мастодонта
+    delete: Брисање налога
+    development: Развој
+    edit_profile: Измена профила
+    export: Извоз података
+    followers: Ауторизовани пратиоци
+    import: Увоз
+    migrate: Пребацивање налога
+    notifications: Обавештења
+    preferences: Подешавања
+    settings: Поставке
+    two_factor_authentication: Двофакторска идентификација
+    your_apps: Ваше апликације
+  statuses:
+    open_in_web: Отвори у вебу
+    over_character_limit: ограничење од %{max} карактера прекорачено
+    pin_errors:
+      limit: Већ имате прикачен највећи број тутова
+      ownership: Туђи тутови не могу да се прикаче
+      private: Тутови који нису јавни не могу да се прикаче
+      reblog: Подршка не може да се прикачи
+    show_more: Прикажи још
+    title: '%{name}: "%{quote}"'
+    visibilities:
+      private: Само пратиоци
+      private_long: Само прикажи пратиоцима
+      public: Јавно
+      public_long: Свако може да види
+      unlisted: Неизлистано
+      unlisted_long: Свако може да види, али није излистано на јавним лајнама
+  stream_entries:
+    click_to_show: Кликни да видиш
+    pinned: Прикачени тут
+    reblogged: подржано
+    sensitive_content: Осетљив садржај
+  terms:
+    body_html: |
+      <h2>Privacy Policy</h2>
+
+      <h3 id="collect">What information do we collect?</h3>
+
+      <p>We collect information from you when you register on our site and gather data when you participate in the forum by reading, writing, and evaluating the content shared here.</p>
+
+      <p>When registering on our site, you may be asked to enter your name and e-mail address. You may, however, visit our site without registering. Your e-mail address will be verified by an email containing a unique link. If that link is visited, we know that you control the e-mail address.</p>
+
+      <p>When registered and posting, we record the IP address that the post originated from. We also may retain server logs which include the IP address of every request to our server.</p>
+
+      <h3 id="use">What do we use your information for?</h3>
+
+      <p>Any of the information we collect from you may be used in one of the following ways:</p>
+
+      <ul>
+        <li>To personalize your experience &mdash; your information helps us to better respond to your individual needs.</li>
+        <li>To improve our site &mdash; we continually strive to improve our site offerings based on the information and feedback we receive from you.</li>
+        <li>To improve customer service &mdash; your information helps us to more effectively respond to your customer service requests and support needs.</li>
+        <li>To send periodic emails &mdash; The email address you provide may be used to send you information, notifications that you request about changes to topics or in response to your user name, respond to inquiries, and/or other requests or questions.</li>
+      </ul>
+
+      <h3 id="protect">How do we protect your information?</h3>
+
+      <p>We implement a variety of security measures to maintain the safety of your personal information when you enter, submit, or access your personal information.</p>
+
+      <h3 id="data-retention">What is your data retention policy?</h3>
+
+      <p>We will make a good faith effort to:</p>
+
+      <ul>
+        <li>Retain server logs containing the IP address of all requests to this server no more than 90 days.</li>
+        <li>Retain the IP addresses associated with registered users and their posts no more than 5 years.</li>
+      </ul>
+
+      <h3 id="cookies">Do we use cookies?</h3>
+
+      <p>Yes. Cookies are small files that a site or its service provider transfers to your computer's hard drive through your Web browser (if you allow). These cookies enable the site to recognize your browser and, if you have a registered account, associate it with your registered account.</p>
+
+      <p>We use cookies to understand and save your preferences for future visits and compile aggregate data about site traffic and site interaction so that we can offer better site experiences and tools in the future. We may contract with third-party service providers to assist us in better understanding our site visitors. These service providers are not permitted to use the information collected on our behalf except to help us conduct and improve our business.</p>
+
+      <h3 id="disclose">Do we disclose any information to outside parties?</h3>
+
+      <p>We do not sell, trade, or otherwise transfer to outside parties your personally identifiable information. This does not include trusted third parties who assist us in operating our site, conducting our business, or servicing you, so long as those parties agree to keep this information confidential. We may also release your information when we believe release is appropriate to comply with the law, enforce our site policies, or protect ours or others rights, property, or safety. However, non-personally identifiable visitor information may be provided to other parties for marketing, advertising, or other uses.</p>
+
+      <h3 id="third-party">Third party links</h3>
+
+      <p>Occasionally, at our discretion, we may include or offer third party products or services on our site. These third party sites have separate and independent privacy policies. We therefore have no responsibility or liability for the content and activities of these linked sites. Nonetheless, we seek to protect the integrity of our site and welcome any feedback about these sites.</p>
+
+      <h3 id="coppa">Children's Online Privacy Protection Act Compliance</h3>
+
+      <p>Our site, products and services are all directed to people who are at least 13 years old. If this server is in the USA, and you are under the age of 13, per the requirements of COPPA (<a href="https://en.wikipedia.org/wiki/Children%27s_Online_Privacy_Protection_Act">Children's Online Privacy Protection Act</a>) do not use this site.</p>
+
+      <h3 id="online">Online Privacy Policy Only</h3>
+
+      <p>This online privacy policy applies only to information collected through our site and not to information collected offline.</p>
+
+      <h3 id="consent">Your Consent</h3>
+
+      <p>By using our site, you consent to our web site privacy policy.</p>
+
+      <h3 id="changes">Changes to our Privacy Policy</h3>
+
+      <p>If we decide to change our privacy policy, we will post those changes on this page.</p>
+
+      <p>This document is CC-BY-SA. It was last updated May 31, 2013.</p>
+
+      <p>Originally adapted from the <a href="https://github.com/discourse/discourse">Discourse privacy policy</a>.</p>
+    title: Услови коришћења и политика приватности инстанце %{instance}
+  themes:
+    default: Мастодонт
+  time:
+    formats:
+      default: "%b %d, %Y, %H:%M"
+  two_factor_authentication:
+    code_hint: Унесите код са Ваше апликације за проверу идентитета да потврдите
+    description_html: Ако укључите <strong>двофакторску идентификацију</strong>, мораћете да имате телефон са собом да бисте могли да се пријавите. Телефон ће онда генерисати токене за Вашу пријаву.
+    disable: Искључи
+    enable: Укључи
+    enabled: Двофакторска идентификација је укључена
+    enabled_success: Двофакторска идентификација је успешно укључена
+    generate_recovery_codes: Генериши кодове за опоравак
+    instructions_html: "<strong>Скенирајте овај QR код у Google Authenticator или некој сличној TOTP апликацији на Вашем телефону</strong>. Од сада, та апликација ће Вам генерисати токене које морате унети да бисте се пријавили."
+    lost_recovery_codes: Кодови за опоравак Вам омогућавају да повратите приступ налогу ако изгубите телефон. Ако изгубите кодове за опоравак, можете их регенерисати овде. Од тог тренутка, стари кодови за опоравак више не важе.
+    manual_instructions: 'Уколико не можете да скенирате QR код и морате га унесете ручно, ево је огољена шифра:'
+    recovery_codes: Направите резерву кодова за опоравак
+    recovery_codes_regenerated: Кодови за опоравак успешно регенерисани
+    recovery_instructions_html: Ако икада изгубите приступ телефону, можете искористити кодове за опоравак дате испод да повратите приступ налогу. <strong>Држите кодове за опоравак на сигурном</strong>. На пример, одштампајте их и чувајте их са осталим важним документима.
+    setup: Намештање
+    wrong_code: Унесени код није исправан! Да ли су времена на серверу и на уређају исправна?
+  users:
+    invalid_email: Адреса е-поште није исправна
+    invalid_otp_token: Неисправни двофакторски код
+    signed_in_as: 'Пријављен као:'
diff --git a/config/locales/zh-TW.yml b/config/locales/zh-TW.yml
index 7a66a64ca..e73dbf9cc 100644
--- a/config/locales/zh-TW.yml
+++ b/config/locales/zh-TW.yml
@@ -55,7 +55,7 @@ zh-TW:
       perform_full_suspension: 進行停權
       profile_url: 個人檔案網址
       public: 公開
-      push_subscription_expires: PuSH 訂閱逾期
+      push_subscription_expires: 推播訂閱過期
       salmon_url: Salmon URL
       silence: 靜音
       statuses: 狀態
@@ -133,12 +133,14 @@ zh-TW:
     forgot_password: 忘記密碼?
     login: 登入
     logout: 登出
+    migrate_account: 轉移到另一個帳號
+    migrate_account_html: 想要將這個帳號指向另一個帳號可到<a href="%{path}">到這裡設定</a>。
     register: 註冊
     resend_confirmation: 重寄驗證信
     reset_password: 重設密碼
     set_new_password: 設定新密碼
   authorize_follow:
-    error: 對不起,尋找這個跨站使用者的過程發生錯誤
+    error: 對不起,搜尋遠端使用者出現錯誤
     follow: 關注
     title: 關注 %{acct}
   datetime:
@@ -165,7 +167,16 @@ zh-TW:
     blocks: 您封鎖的使用者
     csv: CSV
     follows: 您關注的使用者
+    mutes: 您靜音的使用者
     storage: 儲存空間大小
+  followers:
+    domain: 網域
+    explanation_html: 為確保個人隱私,您必須知道有哪些使用者正關注你。<strong>您的私密內容會被發送到所有您有被關注的服務站上</strong>。如果您不信任這些服務站的管理者,您可以選擇檢查或刪除您的關注者。
+    followers_count: 關注者數
+    lock_link: 鎖住你的帳號
+    purge: 移除關注者
+    unlocked_warning_html: 所有人都可以關注並檢索你的隱藏狀態。%{lock_link}以檢查或拒絕關注。
+    unlocked_warning_title: 你的帳號是公開的
   generic:
     changes_saved_msg: 已成功儲存修改
     powered_by: 網站由 %{link} 開發
@@ -179,6 +190,7 @@ zh-TW:
     types:
       blocking: 您封鎖的使用者名單
       following: 您關注的使用者名單
+      muting: 您靜音的使用者名單
     upload: 上傳
   landing_strip_html: "<strong>%{name}</strong> 是一個在 %{link_to_root_path} 的使用者。只要您有任何 Mastodon 服務站、或者聯盟網站的帳號,便可以跨站關注此站使用者,或者與他們互動。"
   landing_strip_signup_html: 如果您沒有這些帳號,歡迎在<a href="%{sign_up_path}">這裡註冊</a>。
@@ -231,15 +243,26 @@ zh-TW:
     missing_resource: 無法找到資源
     proceed: 下一步
     prompt: 您希望關注︰
+  sessions:
+    activity: 最近活動
+    browser: 瀏覽器
+    current_session: 目前的 session
+    description: "%{platform} 上的 %{browser}"
+    explanation: 這些是現在正登入於你的 Mastodon 帳號的瀏覽器。
+    revoke: 取消
+    revoke_success: Session 取消成功。
   settings:
     authorized_apps: 已授權應用程式
     back: 回到 Mastodon
+    development: 開發
     edit_profile: 修改個人資料
     export: 匯出
+    followers: 授權追蹤者
     import: 匯入
+    notifications: 通知
     preferences: 偏好設定
     settings: 設定
-    two_factor_authentication: 雙因子認證
+    two_factor_authentication: 兩階段認證
   statuses:
     open_in_web: 以網頁開啟
     over_character_limit: 超過了 %{max} 字的限制
@@ -257,14 +280,14 @@ zh-TW:
       default: "%Y年%-m月%d日 %H:%M"
   two_factor_authentication:
     code_hint: 請輸入您認證器產生的代碼,以進行認證
-    description_html: 當您啟用<strong>雙因子認證</strong>後,您登入時將需要使您手機、或其他種類認證器產生的代碼。
+    description_html: 啟用<strong>兩階段認證</strong>後,登入時將需要使手機、或其他種類認證器產生的代碼。
     disable: 停用
     enable: 啟用
-    enabled_success: 已成功啟用雙因子認證
-    instructions_html: "<strong>請用您手機的認證器應用程式(如 Google Authenticator、Authy),掃描這裡的 QR 圖形碼</strong>。在雙因子認證啟用後,您登入時將須要使用此應用程式產生的認證碼。"
+    enabled_success: 已成功啟用兩階段認證
+    instructions_html: "<strong>請用您手機的認證器應用程式(如 Google Authenticator、Authy),掃描這裡的 QR 圖形碼</strong>。在兩階段認證啟用後,您登入時將須要使用此應用程式產生的認證碼。"
     manual_instructions: 如果您無法掃描 QR 圖形碼,請手動輸入︰
     setup: 設定
     wrong_code: 您輸入的認證碼並不正確!可能伺服器時間和您手機不一致,請檢查您手機的時間,或與本站管理員聯絡。
   users:
     invalid_email: 信箱地址格式不正確
-    invalid_otp_token: 雙因子認證碼不正確
+    invalid_otp_token: 兩階段認證碼不正確
diff --git a/config/routes.rb b/config/routes.rb
index 5b0ee9324..075df8225 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -54,7 +54,8 @@ Rails.application.routes.draw do
 
     resources :followers, only: [:index], controller: :follower_accounts
     resources :following, only: [:index], controller: :following_accounts
-    resource :follow, only: [:create], controller: :account_follow
+    resources :follows, only: [:show], module: :activitypub
+    resource :follow, only: [:create], controller: :account_follow, as: :follows
     resource :unfollow, only: [:create], controller: :account_unfollow
     resource :outbox, only: [:show], module: :activitypub
     resource :inbox, only: [:create], module: :activitypub
@@ -255,7 +256,11 @@ Rails.application.routes.draw do
 
       resources :apps, only: [:create]
 
-      resource :instance,      only: [:show]
+      resource :instance, only: [:show] do
+        resources :peers, only: [:index], controller: 'instances/peers'
+        resource :activity, only: [:show], controller: 'instances/activity'
+      end
+
       resource :domain_blocks, only: [:show, :create, :destroy]
 
       resources :follow_requests, only: [:index] do
diff --git a/config/settings.yml b/config/settings.yml
index dbc5f6a26..507b7c066 100644
--- a/config/settings.yml
+++ b/config/settings.yml
@@ -49,7 +49,8 @@ defaults: &defaults
     - webmaster
     - administrator
   bootstrap_timeline_accounts: ''
-
+  activity_api_enabled: true
+  peers_api_enabled: true
 development:
   <<: *defaults
 
diff --git a/db/migrate/20171129172043_add_index_on_stream_entries.rb b/db/migrate/20171129172043_add_index_on_stream_entries.rb
index 478530c7f..181c4f288 100644
--- a/db/migrate/20171129172043_add_index_on_stream_entries.rb
+++ b/db/migrate/20171129172043_add_index_on_stream_entries.rb
@@ -1,6 +1,7 @@
 class AddIndexOnStreamEntries < ActiveRecord::Migration[5.1]
+  disable_ddl_transaction!
+
   def change
-    commit_db_transaction
     add_index :stream_entries, [:account_id, :activity_type, :id], algorithm: :concurrently
     remove_index :stream_entries, name: :index_stream_entries_on_account_id
   end
diff --git a/db/migrate/20171226094803_more_faster_index_on_notifications.rb b/db/migrate/20171226094803_more_faster_index_on_notifications.rb
index b2e53b82d..0273a4e7c 100644
--- a/db/migrate/20171226094803_more_faster_index_on_notifications.rb
+++ b/db/migrate/20171226094803_more_faster_index_on_notifications.rb
@@ -1,6 +1,7 @@
 class MoreFasterIndexOnNotifications < ActiveRecord::Migration[5.1]
+  disable_ddl_transaction!
+
   def change
-    commit_db_transaction
     add_index :notifications, [:account_id, :id], order: { id: :desc }, algorithm: :concurrently
     remove_index :notifications, name: :index_notifications_on_id_and_account_id_and_activity_type
   end
diff --git a/lib/mastodon/version.rb b/lib/mastodon/version.rb
index ca129da80..007768769 100644
--- a/lib/mastodon/version.rb
+++ b/lib/mastodon/version.rb
@@ -13,7 +13,7 @@ module Mastodon
     end
 
     def patch
-      0
+      1
     end
 
     def pre
diff --git a/spec/controllers/activitypub/follows_controller_spec.rb b/spec/controllers/activitypub/follows_controller_spec.rb
new file mode 100644
index 000000000..6026cd353
--- /dev/null
+++ b/spec/controllers/activitypub/follows_controller_spec.rb
@@ -0,0 +1,43 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+describe ActivityPub::FollowsController, type: :controller do
+  let(:follow_request) { Fabricate(:follow_request, account: account) }
+
+  render_views
+
+  context 'with local account' do
+    let(:account) { Fabricate(:account, domain: nil) }
+
+    it 'returns follow request' do
+      signed_request = Request.new(:get, account_follow_url(account, follow_request))
+      signed_request.on_behalf_of(follow_request.target_account)
+      request.headers.merge! signed_request.headers
+
+      get :show, params: { id: follow_request, account_username: account.username }
+
+      expect(body_as_json[:id]).to eq ActivityPub::TagManager.instance.uri_for(follow_request)
+      expect(response).to have_http_status :success
+    end
+
+    it 'returns http 404 without signature' do
+      get :show, params: { id: follow_request, account_username: account.username }
+      expect(response).to have_http_status 404
+    end
+  end
+
+  context 'with remote account' do
+    let(:account) { Fabricate(:account, domain: Faker::Internet.domain_name) }
+
+    it 'returns http 404' do
+      signed_request = Request.new(:get, account_follow_url(account, follow_request))
+      signed_request.on_behalf_of(follow_request.target_account)
+      request.headers.merge! signed_request.headers
+
+      get :show, params: { id: follow_request, account_username: account.username }
+
+      expect(response).to have_http_status 404
+    end
+  end
+end
diff --git a/spec/controllers/auth/confirmations_controller_spec.rb b/spec/controllers/auth/confirmations_controller_spec.rb
index 2ec36c060..80a06c43a 100644
--- a/spec/controllers/auth/confirmations_controller_spec.rb
+++ b/spec/controllers/auth/confirmations_controller_spec.rb
@@ -12,20 +12,40 @@ describe Auth::ConfirmationsController, type: :controller do
   end
 
   describe 'GET #show' do
-    let!(:user) { Fabricate(:user, confirmation_token: 'foobar', confirmed_at: nil) }
+    context 'when user is unconfirmed' do
+      let!(:user) { Fabricate(:user, confirmation_token: 'foobar', confirmed_at: nil) }
 
-    before do
-      allow(BootstrapTimelineWorker).to receive(:perform_async)
-      @request.env['devise.mapping'] = Devise.mappings[:user]
-      get :show, params: { confirmation_token: 'foobar' }
-    end
+      before do
+        allow(BootstrapTimelineWorker).to receive(:perform_async)
+        @request.env['devise.mapping'] = Devise.mappings[:user]
+        get :show, params: { confirmation_token: 'foobar' }
+      end
+
+      it 'redirects to login' do
+        expect(response).to redirect_to(new_user_session_path)
+      end
 
-    it 'redirects to login' do
-      expect(response).to redirect_to(new_user_session_path)
+      it 'queues up bootstrapping of home timeline' do
+        expect(BootstrapTimelineWorker).to have_received(:perform_async).with(user.account_id)
+      end
     end
 
-    it 'queues up bootstrapping of home timeline' do
-      expect(BootstrapTimelineWorker).to have_received(:perform_async).with(user.account_id)
+    context 'when user is updating email' do
+      let!(:user) { Fabricate(:user, confirmation_token: 'foobar', unconfirmed_email: 'new-email@example.com') }
+
+      before do
+        allow(BootstrapTimelineWorker).to receive(:perform_async)
+        @request.env['devise.mapping'] = Devise.mappings[:user]
+        get :show, params: { confirmation_token: 'foobar' }
+      end
+
+      it 'redirects to login' do
+        expect(response).to redirect_to(new_user_session_path)
+      end
+
+      it 'does not queue up bootstrapping of home timeline' do
+        expect(BootstrapTimelineWorker).to_not have_received(:perform_async)
+      end
     end
   end
 end
diff --git a/spec/fixtures/requests/oembed_json_xml.html b/spec/fixtures/requests/oembed_json_xml.html
index b5fc9bed0..8afd8e997 100644
--- a/spec/fixtures/requests/oembed_json_xml.html
+++ b/spec/fixtures/requests/oembed_json_xml.html
@@ -1,8 +1,14 @@
 <!DOCTYPE html>
 <html>
   <head>
+    <!--
+      oEmbed
+      https://oembed.com/
+      > The type attribute must contain either application/json+oembed for JSON
+      > responses, or text/xml+oembed for XML.
+    -->
     <link href='https://host/provider.json' rel='alternate' type='application/json+oembed'>
-    <link href='https://host/provider.xml' rel='alternate' type='application/xml+oembed'>
+    <link href='https://host/provider.xml' rel='alternate' type='text/xml+oembed'>
   </head>
   <body></body>
 </html>
diff --git a/spec/fixtures/requests/oembed_xml.html b/spec/fixtures/requests/oembed_xml.html
index 5d7633e71..bdfcca170 100644
--- a/spec/fixtures/requests/oembed_xml.html
+++ b/spec/fixtures/requests/oembed_xml.html
@@ -1,7 +1,13 @@
 <!DOCTYPE html>
 <html>
   <head>
-    <link href='https://host/provider.xml' rel='alternate' type='application/xml+oembed'>
+    <!--
+      oEmbed
+      https://oembed.com/
+      > The type attribute must contain either application/json+oembed for JSON
+      > responses, or text/xml+oembed for XML.
+    -->
+    <link href='https://host/provider.xml' rel='alternate' type='text/xml+oembed'>
   </head>
   <body></body>
 </html>
diff --git a/spec/lib/activitypub/activity/accept_spec.rb b/spec/lib/activitypub/activity/accept_spec.rb
index 6503c83e3..9f43be35d 100644
--- a/spec/lib/activitypub/activity/accept_spec.rb
+++ b/spec/lib/activitypub/activity/accept_spec.rb
@@ -3,36 +3,49 @@ require 'rails_helper'
 RSpec.describe ActivityPub::Activity::Accept do
   let(:sender)    { Fabricate(:account) }
   let(:recipient) { Fabricate(:account) }
-
-  let(:json) do
-    {
-      '@context': 'https://www.w3.org/ns/activitystreams',
-      id: 'foo',
-      type: 'Accept',
-      actor: ActivityPub::TagManager.instance.uri_for(sender),
-      object: {
-        id: 'bar',
-        type: 'Follow',
-        actor: ActivityPub::TagManager.instance.uri_for(recipient),
-        object: ActivityPub::TagManager.instance.uri_for(sender),
-      },
-    }.with_indifferent_access
-  end
+  let!(:follow_request) { Fabricate(:follow_request, account: recipient, target_account: sender) }
 
   describe '#perform' do
     subject { described_class.new(json, sender) }
 
     before do
-      Fabricate(:follow_request, account: recipient, target_account: sender)
       subject.perform
     end
 
-    it 'creates a follow relationship' do
-      expect(recipient.following?(sender)).to be true
+    context 'with concerete object representation' do
+      let(:json) do
+        {
+          '@context': 'https://www.w3.org/ns/activitystreams',
+          id: 'foo',
+          type: 'Accept',
+          actor: ActivityPub::TagManager.instance.uri_for(sender),
+          object: {
+            type: 'Follow',
+            actor: ActivityPub::TagManager.instance.uri_for(recipient),
+            object: ActivityPub::TagManager.instance.uri_for(sender),
+          },
+        }.with_indifferent_access
+      end
+
+      it 'creates a follow relationship' do
+        expect(recipient.following?(sender)).to be true
+      end
     end
 
-    it 'removes the follow request' do
-      expect(recipient.requested?(sender)).to be false
+    context 'with object represented by id' do
+      let(:json) do
+        {
+          '@context': 'https://www.w3.org/ns/activitystreams',
+          id: 'foo',
+          type: 'Accept',
+          actor: ActivityPub::TagManager.instance.uri_for(sender),
+          object: ActivityPub::TagManager.instance.uri_for(follow_request),
+        }.with_indifferent_access
+      end
+
+      it 'creates a follow relationship' do
+        expect(recipient.following?(sender)).to be true
+      end
     end
   end
 end
diff --git a/spec/lib/formatter_spec.rb b/spec/lib/formatter_spec.rb
index 71b6b78d2..67fbfe92d 100644
--- a/spec/lib/formatter_spec.rb
+++ b/spec/lib/formatter_spec.rb
@@ -17,7 +17,7 @@ RSpec.describe Formatter do
       let(:text) { 'http://google.com' }
 
       it 'has valid URL' do
-        is_expected.to include 'href="http://google.com/"'
+        is_expected.to include 'href="http://google.com"'
       end
     end
 
@@ -25,7 +25,7 @@ RSpec.describe Formatter do
       let(:text) { 'https://nic.みんな/' }
 
       it 'has valid URL' do
-        is_expected.to include 'href="https://nic.xn--q9jyb4c/"'
+        is_expected.to include 'href="https://nic.みんな/"'
       end
 
       it 'has display URL' do
@@ -53,7 +53,7 @@ RSpec.describe Formatter do
       let(:text) { 'http://www.google.com!' }
 
       it 'has valid URL' do
-        is_expected.to include 'href="http://www.google.com/"'
+        is_expected.to include 'href="http://www.google.com"'
       end
     end
 
@@ -61,7 +61,7 @@ RSpec.describe Formatter do
       let(:text) { "http://www.google.com'" }
 
       it 'has valid URL' do
-        is_expected.to include 'href="http://www.google.com/"'
+        is_expected.to include 'href="http://www.google.com"'
       end
     end
 
@@ -69,7 +69,7 @@ RSpec.describe Formatter do
       let(:text) { 'http://www.google.com>' }
 
       it 'has valid URL' do
-        is_expected.to include 'href="http://www.google.com/"'
+        is_expected.to include 'href="http://www.google.com"'
       end
     end
 
@@ -93,7 +93,7 @@ RSpec.describe Formatter do
       let(:text) { 'https://ja.wikipedia.org/wiki/日本' }
 
       it 'has valid URL' do
-        is_expected.to include 'href="https://ja.wikipedia.org/wiki/%E6%97%A5%E6%9C%AC"'
+        is_expected.to include 'href="https://ja.wikipedia.org/wiki/日本"'
       end
     end
 
@@ -101,7 +101,7 @@ RSpec.describe Formatter do
       let(:text) { 'https://ko.wikipedia.org/wiki/대한민국' }
 
       it 'has valid URL' do
-        is_expected.to include 'href="https://ko.wikipedia.org/wiki/%EB%8C%80%ED%95%9C%EB%AF%BC%EA%B5%AD"'
+        is_expected.to include 'href="https://ko.wikipedia.org/wiki/대한민국"'
       end
     end
 
@@ -109,7 +109,7 @@ RSpec.describe Formatter do
       let(:text) { 'https://baike.baidu.com/item/中华人民共和国' }
 
       it 'has valid URL' do
-        is_expected.to include 'href="https://baike.baidu.com/item/%E4%B8%AD%E5%8D%8E%E4%BA%BA%E6%B0%91%E5%85%B1%E5%92%8C%E5%9B%BD"'
+        is_expected.to include 'href="https://baike.baidu.com/item/中华人民共和国"'
       end
     end
 
@@ -117,7 +117,7 @@ RSpec.describe Formatter do
       let(:text) { 'https://zh.wikipedia.org/wiki/臺灣' }
 
       it 'has valid URL' do
-        is_expected.to include 'href="https://zh.wikipedia.org/wiki/%E8%87%BA%E7%81%A3"'
+        is_expected.to include 'href="https://zh.wikipedia.org/wiki/臺灣"'
       end
     end
 
@@ -332,7 +332,7 @@ RSpec.describe Formatter do
     end
 
     context 'contains malicious classes' do
-      let(:text) { '<span class="status__content__spoiler-link">Show more</span>' }
+      let(:text) { '<span class="mention	status__content__spoiler-link">Show more</span>' }
 
       it 'strips malicious classes' do
         is_expected.to_not include 'status__content__spoiler-link'
diff --git a/spec/mailers/user_mailer_spec.rb b/spec/mailers/user_mailer_spec.rb
index 1f6d44015..9f17993e0 100644
--- a/spec/mailers/user_mailer_spec.rb
+++ b/spec/mailers/user_mailer_spec.rb
@@ -33,6 +33,20 @@ describe UserMailer, type: :mailer do
                      instance: Rails.configuration.x.local_domain
   end
 
+  describe 'reconfirmation_instructions' do
+    let(:mail) { UserMailer.confirmation_instructions(receiver, 'spec') }
+
+    it 'renders reconfirmation instructions' do
+      receiver.update!(email: 'new-email@example.com', locale: nil)
+      expect(mail.body.encoded).to include 'new-email@example.com'
+      expect(mail.body.encoded).to include 'spec'
+      expect(mail.body.encoded).to include Rails.configuration.x.local_domain
+      expect(mail.subject).to eq I18n.t('devise.mailer.reconfirmation_instructions.subject',
+                                        instance: Rails.configuration.x.local_domain,
+                                        locale: I18n.default_locale)
+    end
+  end
+
   describe 'reset_password_instructions' do
     let(:mail) { UserMailer.reset_password_instructions(receiver, 'spec') }
 
@@ -57,4 +71,16 @@ describe UserMailer, type: :mailer do
     include_examples 'localized subject',
                      'devise.mailer.password_change.subject'
   end
+
+  describe 'email_changed' do
+    let(:mail) { UserMailer.email_changed(receiver) }
+
+    it 'renders email change notification' do
+      receiver.update!(locale: nil)
+      expect(mail.body.encoded).to include receiver.email
+    end
+
+    include_examples 'localized subject',
+                     'devise.mailer.email_changed.subject'
+  end
 end
diff --git a/spec/models/follow_request_spec.rb b/spec/models/follow_request_spec.rb
index 7bc93a2aa..18f61e7cb 100644
--- a/spec/models/follow_request_spec.rb
+++ b/spec/models/follow_request_spec.rb
@@ -34,4 +34,12 @@ RSpec.describe FollowRequest, type: :model do
       expect(follow_request.account.muting_reblogs?(target)).to be true
     end
   end
+
+  describe '#object_type' do
+    let(:follow_request) { Fabricate(:follow_request) }
+
+    it 'equals to :follow' do
+      expect(follow_request.object_type).to eq :follow
+    end
+  end
 end
diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb
index 5ed7ed88b..8171c939a 100644
--- a/spec/models/user_spec.rb
+++ b/spec/models/user_spec.rb
@@ -148,6 +148,14 @@ RSpec.describe User, type: :model do
     end
   end
 
+  describe '#confirm' do
+    it 'sets email to unconfirmed_email' do
+      user = Fabricate.build(:user, confirmed_at: Time.now.utc, unconfirmed_email: 'new-email@example.com')
+      user.confirm
+      expect(user.email).to eq 'new-email@example.com'
+    end
+  end
+
   describe '#disable_two_factor!' do
     it 'saves false for otp_required_for_login' do
       user = Fabricate.build(:user, otp_required_for_login: true)