about summary refs log tree commit diff
path: root/app
diff options
context:
space:
mode:
authorThibG <thib@sitedethib.com>2020-01-24 17:28:22 +0100
committerGitHub <noreply@github.com>2020-01-24 17:28:22 +0100
commit0be67df4f0a64367d9e376b06bd3fd2fb9ca8195 (patch)
tree2eaa85c899033aab9bd1b2f9da2d727ce5788e16 /app
parenta8c109baca4d02cc8aed454e231518c1f8ec1844 (diff)
parentbdc1581556b86ba25a385c9309db714eeaba1ca1 (diff)
Merge pull request #1265 from ThibG/glitch-soc/merge-upstream
Merge upstream changes
Diffstat (limited to 'app')
-rw-r--r--app/controllers/admin/announcements_controller.rb69
-rw-r--r--app/controllers/admin/followers_controller.rb18
-rw-r--r--app/controllers/admin/relationships_controller.rb25
-rw-r--r--app/controllers/api/base_controller.rb2
-rw-r--r--app/controllers/api/oembed_controller.rb14
-rw-r--r--app/controllers/api/v1/announcements/reactions_controller.rb29
-rw-r--r--app/controllers/api/v1/announcements_controller.rb33
-rw-r--r--app/controllers/auth/passwords_controller.rb6
-rw-r--r--app/controllers/auth/registrations_controller.rb7
-rw-r--r--app/controllers/relationships_controller.rb46
-rw-r--r--app/controllers/statuses_controller.rb4
-rw-r--r--app/helpers/admin/action_logs_helper.rb8
-rw-r--r--app/helpers/admin/announcements_helper.rb11
-rw-r--r--app/helpers/admin/filter_helper.rb1
-rw-r--r--app/javascript/flavours/glitch/actions/announcements.js133
-rw-r--r--app/javascript/flavours/glitch/actions/importer/normalizer.js10
-rw-r--r--app/javascript/flavours/glitch/actions/notifications.js3
-rw-r--r--app/javascript/flavours/glitch/actions/streaming.js11
-rw-r--r--app/javascript/flavours/glitch/actions/timelines.js2
-rw-r--r--app/javascript/flavours/glitch/features/account_timeline/index.js1
-rw-r--r--app/javascript/flavours/glitch/features/directory/components/account_card.js1
-rw-r--r--app/javascript/flavours/glitch/features/emoji_picker/index.js7
-rw-r--r--app/javascript/flavours/glitch/features/getting_started/components/announcements.js395
-rw-r--r--app/javascript/flavours/glitch/features/getting_started/containers/announcements_container.js21
-rw-r--r--app/javascript/flavours/glitch/features/getting_started/containers/trends_container.js2
-rw-r--r--app/javascript/flavours/glitch/features/home_timeline/index.js3
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/media_modal.js1
-rw-r--r--app/javascript/flavours/glitch/reducers/announcements.js72
-rw-r--r--app/javascript/flavours/glitch/reducers/index.js2
-rw-r--r--app/javascript/flavours/glitch/selectors/index.js1
-rw-r--r--app/javascript/flavours/glitch/styles/components/announcements.scss212
-rw-r--r--app/javascript/flavours/glitch/styles/components/composer.scss22
-rw-r--r--app/javascript/flavours/glitch/styles/components/index.scss1
-rw-r--r--app/javascript/flavours/glitch/styles/forms.scss6
-rw-r--r--app/javascript/flavours/glitch/util/stream.js54
-rw-r--r--app/javascript/images/elephant_ui_plane.svg2
-rw-r--r--app/javascript/mastodon/actions/announcements.js133
-rw-r--r--app/javascript/mastodon/actions/importer/normalizer.js10
-rw-r--r--app/javascript/mastodon/actions/notifications.js3
-rw-r--r--app/javascript/mastodon/actions/streaming.js11
-rw-r--r--app/javascript/mastodon/actions/timelines.js2
-rw-r--r--app/javascript/mastodon/components/error_boundary.js2
-rw-r--r--app/javascript/mastodon/features/account_timeline/index.js1
-rw-r--r--app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js7
-rw-r--r--app/javascript/mastodon/features/directory/components/account_card.js1
-rw-r--r--app/javascript/mastodon/features/getting_started/components/announcements.js395
-rw-r--r--app/javascript/mastodon/features/getting_started/containers/announcements_container.js21
-rw-r--r--app/javascript/mastodon/features/getting_started/containers/trends_container.js2
-rw-r--r--app/javascript/mastodon/features/home_timeline/index.js3
-rw-r--r--app/javascript/mastodon/features/ui/components/media_modal.js1
-rw-r--r--app/javascript/mastodon/locales/ar.json7
-rw-r--r--app/javascript/mastodon/locales/ast.json3
-rw-r--r--app/javascript/mastodon/locales/bg.json1
-rw-r--r--app/javascript/mastodon/locales/bn.json1
-rw-r--r--app/javascript/mastodon/locales/br.json1
-rw-r--r--app/javascript/mastodon/locales/ca.json5
-rw-r--r--app/javascript/mastodon/locales/co.json1
-rw-r--r--app/javascript/mastodon/locales/cs.json5
-rw-r--r--app/javascript/mastodon/locales/cy.json7
-rw-r--r--app/javascript/mastodon/locales/da.json1
-rw-r--r--app/javascript/mastodon/locales/de.json7
-rw-r--r--app/javascript/mastodon/locales/defaultMessages.json8
-rw-r--r--app/javascript/mastodon/locales/el.json9
-rw-r--r--app/javascript/mastodon/locales/en.json1
-rw-r--r--app/javascript/mastodon/locales/eo.json1
-rw-r--r--app/javascript/mastodon/locales/es-AR.json1
-rw-r--r--app/javascript/mastodon/locales/es.json13
-rw-r--r--app/javascript/mastodon/locales/et.json1
-rw-r--r--app/javascript/mastodon/locales/eu.json7
-rw-r--r--app/javascript/mastodon/locales/fa.json7
-rw-r--r--app/javascript/mastodon/locales/fi.json1
-rw-r--r--app/javascript/mastodon/locales/fr.json1
-rw-r--r--app/javascript/mastodon/locales/ga.json1
-rw-r--r--app/javascript/mastodon/locales/gl.json81
-rw-r--r--app/javascript/mastodon/locales/he.json1
-rw-r--r--app/javascript/mastodon/locales/hi.json1
-rw-r--r--app/javascript/mastodon/locales/hr.json1
-rw-r--r--app/javascript/mastodon/locales/hu.json7
-rw-r--r--app/javascript/mastodon/locales/hy.json19
-rw-r--r--app/javascript/mastodon/locales/id.json1
-rw-r--r--app/javascript/mastodon/locales/io.json1
-rw-r--r--app/javascript/mastodon/locales/is.json9
-rw-r--r--app/javascript/mastodon/locales/it.json3
-rw-r--r--app/javascript/mastodon/locales/ja.json1
-rw-r--r--app/javascript/mastodon/locales/ka.json1
-rw-r--r--app/javascript/mastodon/locales/kab.json193
-rw-r--r--app/javascript/mastodon/locales/kk.json1
-rw-r--r--app/javascript/mastodon/locales/kn.json1
-rw-r--r--app/javascript/mastodon/locales/ko.json1
-rw-r--r--app/javascript/mastodon/locales/lt.json1
-rw-r--r--app/javascript/mastodon/locales/lv.json1
-rw-r--r--app/javascript/mastodon/locales/mk.json1
-rw-r--r--app/javascript/mastodon/locales/ml.json1
-rw-r--r--app/javascript/mastodon/locales/mr.json1
-rw-r--r--app/javascript/mastodon/locales/ms.json1
-rw-r--r--app/javascript/mastodon/locales/nl.json1
-rw-r--r--app/javascript/mastodon/locales/nn.json5
-rw-r--r--app/javascript/mastodon/locales/no.json1
-rw-r--r--app/javascript/mastodon/locales/oc.json1
-rw-r--r--app/javascript/mastodon/locales/pl.json1
-rw-r--r--app/javascript/mastodon/locales/pt-BR.json1
-rw-r--r--app/javascript/mastodon/locales/pt-PT.json1
-rw-r--r--app/javascript/mastodon/locales/ro.json1
-rw-r--r--app/javascript/mastodon/locales/ru.json9
-rw-r--r--app/javascript/mastodon/locales/sk.json7
-rw-r--r--app/javascript/mastodon/locales/sl.json1
-rw-r--r--app/javascript/mastodon/locales/sq.json1
-rw-r--r--app/javascript/mastodon/locales/sr-Latn.json1
-rw-r--r--app/javascript/mastodon/locales/sr.json1
-rw-r--r--app/javascript/mastodon/locales/sv.json7
-rw-r--r--app/javascript/mastodon/locales/ta.json3
-rw-r--r--app/javascript/mastodon/locales/te.json1
-rw-r--r--app/javascript/mastodon/locales/th.json5
-rw-r--r--app/javascript/mastodon/locales/tr.json1
-rw-r--r--app/javascript/mastodon/locales/uk.json1
-rw-r--r--app/javascript/mastodon/locales/ur.json1
-rw-r--r--app/javascript/mastodon/locales/vi.json1
-rw-r--r--app/javascript/mastodon/locales/zh-CN.json1
-rw-r--r--app/javascript/mastodon/locales/zh-HK.json1
-rw-r--r--app/javascript/mastodon/locales/zh-TW.json1
-rw-r--r--app/javascript/mastodon/reducers/announcements.js72
-rw-r--r--app/javascript/mastodon/reducers/index.js2
-rw-r--r--app/javascript/mastodon/selectors/index.js1
-rw-r--r--app/javascript/mastodon/stream.js54
-rw-r--r--app/javascript/styles/mastodon/components.scss213
-rw-r--r--app/javascript/styles/mastodon/forms.scss6
-rw-r--r--app/lib/entity_cache.rb2
-rw-r--r--app/lib/formatter.rb4
-rw-r--r--app/lib/inline_renderer.rb4
-rw-r--r--app/lib/sanitize_config.rb2
-rw-r--r--app/middleware/handle_bad_encoding_middleware.rb18
-rw-r--r--app/models/account.rb6
-rw-r--r--app/models/announcement.rb85
-rw-r--r--app/models/announcement_filter.rb39
-rw-r--r--app/models/announcement_mute.rb19
-rw-r--r--app/models/announcement_reaction.rb37
-rw-r--r--app/models/backup.rb2
-rw-r--r--app/models/bookmark.rb6
-rw-r--r--app/models/concerns/account_interactions.rb1
-rw-r--r--app/models/custom_emoji.rb2
-rw-r--r--app/models/custom_filter.rb1
-rw-r--r--app/models/media_attachment.rb1
-rw-r--r--app/models/relationship_filter.rb109
-rw-r--r--app/models/user.rb2
-rw-r--r--app/policies/announcement_policy.rb19
-rw-r--r--app/serializers/rest/announcement_serializer.rb34
-rw-r--r--app/serializers/rest/reaction_serializer.rb31
-rw-r--r--app/validators/reaction_validator.rb17
-rw-r--r--app/views/accounts/show.html.haml2
-rw-r--r--app/views/admin/accounts/show.html.haml2
-rw-r--r--app/views/admin/announcements/_announcement.html.haml14
-rw-r--r--app/views/admin/announcements/edit.html.haml22
-rw-r--r--app/views/admin/announcements/index.html.haml30
-rw-r--r--app/views/admin/announcements/new.html.haml21
-rw-r--r--app/views/admin/followers/index.html.haml28
-rw-r--r--app/views/admin/relationships/index.html.haml39
-rw-r--r--app/workers/publish_announcement_reaction_worker.rb22
-rw-r--r--app/workers/publish_scheduled_announcement_worker.rb18
-rw-r--r--app/workers/scheduler/scheduled_statuses_scheduler.rb28
159 files changed, 2940 insertions, 375 deletions
diff --git a/app/controllers/admin/announcements_controller.rb b/app/controllers/admin/announcements_controller.rb
new file mode 100644
index 000000000..02198f0b5
--- /dev/null
+++ b/app/controllers/admin/announcements_controller.rb
@@ -0,0 +1,69 @@
+# frozen_string_literal: true
+
+class Admin::AnnouncementsController < Admin::BaseController
+  before_action :set_announcements, only: :index
+  before_action :set_announcement, except: [:index, :new, :create]
+
+  def index
+    authorize :announcement, :index?
+  end
+
+  def new
+    authorize :announcement, :create?
+
+    @announcement = Announcement.new
+  end
+
+  def create
+    authorize :announcement, :create?
+
+    @announcement = Announcement.new(resource_params)
+
+    if @announcement.save
+      log_action :create, @announcement
+      redirect_to admin_announcements_path
+    else
+      render :new
+    end
+  end
+
+  def edit
+    authorize :announcement, :update?
+  end
+
+  def update
+    authorize :announcement, :update?
+
+    if @announcement.update(resource_params)
+      log_action :update, @announcement
+      redirect_to admin_announcements_path
+    else
+      render :edit
+    end
+  end
+
+  def destroy
+    authorize :announcement, :destroy?
+    @announcement.destroy!
+    log_action :destroy, @announcement
+    redirect_to admin_announcements_path
+  end
+
+  private
+
+  def set_announcements
+    @announcements = AnnouncementFilter.new(filter_params).results.page(params[:page])
+  end
+
+  def set_announcement
+    @announcement = Announcement.find(params[:id])
+  end
+
+  def filter_params
+    params.slice(*AnnouncementFilter::KEYS).permit(*AnnouncementFilter::KEYS)
+  end
+
+  def resource_params
+    params.require(:announcement).permit(:text, :scheduled_at, :starts_at, :ends_at, :all_day)
+  end
+end
diff --git a/app/controllers/admin/followers_controller.rb b/app/controllers/admin/followers_controller.rb
deleted file mode 100644
index d826f47c5..000000000
--- a/app/controllers/admin/followers_controller.rb
+++ /dev/null
@@ -1,18 +0,0 @@
-# frozen_string_literal: true
-
-module Admin
-  class FollowersController < BaseController
-    before_action :set_account
-
-    PER_PAGE = 40
-
-    def index
-      authorize :account, :index?
-      @followers = @account.followers.local.recent.page(params[:page]).per(PER_PAGE)
-    end
-
-    def set_account
-      @account = Account.find(params[:account_id])
-    end
-  end
-end
diff --git a/app/controllers/admin/relationships_controller.rb b/app/controllers/admin/relationships_controller.rb
new file mode 100644
index 000000000..f8a95cfc8
--- /dev/null
+++ b/app/controllers/admin/relationships_controller.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+module Admin
+  class RelationshipsController < BaseController
+    before_action :set_account
+
+    PER_PAGE = 40
+
+    def index
+      authorize :account, :index?
+
+      @accounts = RelationshipFilter.new(@account, filter_params).results.page(params[:page]).per(PER_PAGE)
+    end
+
+    private
+
+    def set_account
+      @account = Account.find(params[:account_id])
+    end
+
+    def filter_params
+      params.slice(*RelationshipFilter::KEYS).permit(*RelationshipFilter::KEYS)
+    end
+  end
+end
diff --git a/app/controllers/api/base_controller.rb b/app/controllers/api/base_controller.rb
index 144fdd6ac..68bf425f4 100644
--- a/app/controllers/api/base_controller.rb
+++ b/app/controllers/api/base_controller.rb
@@ -85,7 +85,7 @@ class Api::BaseController < ApplicationController
   end
 
   def require_authenticated_user!
-    render json: { error: 'This API requires an authenticated user' }, status: 401 unless current_user
+    render json: { error: 'This method requires an authenticated user' }, status: 401 unless current_user
   end
 
   def require_user!
diff --git a/app/controllers/api/oembed_controller.rb b/app/controllers/api/oembed_controller.rb
index c8c60b1cf..66da65bed 100644
--- a/app/controllers/api/oembed_controller.rb
+++ b/app/controllers/api/oembed_controller.rb
@@ -1,17 +1,25 @@
 # frozen_string_literal: true
 
 class Api::OEmbedController < Api::BaseController
-  respond_to :json
-
   skip_before_action :require_authenticated_user!
 
+  before_action :set_status
+  before_action :require_public_status!
+
   def show
-    @status = status_finder.status
     render json: @status, serializer: OEmbedSerializer, width: maxwidth_or_default, height: maxheight_or_default
   end
 
   private
 
+  def set_status
+    @status = status_finder.status
+  end
+
+  def require_public_status!
+    not_found if @status.hidden?
+  end
+
   def status_finder
     StatusFinder.new(params[:url])
   end
diff --git a/app/controllers/api/v1/announcements/reactions_controller.rb b/app/controllers/api/v1/announcements/reactions_controller.rb
new file mode 100644
index 000000000..e4a72e595
--- /dev/null
+++ b/app/controllers/api/v1/announcements/reactions_controller.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+class Api::V1::Announcements::ReactionsController < Api::BaseController
+  before_action -> { doorkeeper_authorize! :write, :'write:favourites' }
+  before_action :require_user!
+
+  before_action :set_announcement
+  before_action :set_reaction, except: :update
+
+  def update
+    @announcement.announcement_reactions.create!(account: current_account, name: params[:id])
+    render_empty
+  end
+
+  def destroy
+    @reaction.destroy!
+    render_empty
+  end
+
+  private
+
+  def set_reaction
+    @reaction = @announcement.announcement_reactions.where(account: current_account).find_by!(name: params[:id])
+  end
+
+  def set_announcement
+    @announcement = Announcement.published.find(params[:announcement_id])
+  end
+end
diff --git a/app/controllers/api/v1/announcements_controller.rb b/app/controllers/api/v1/announcements_controller.rb
new file mode 100644
index 000000000..6724fac2e
--- /dev/null
+++ b/app/controllers/api/v1/announcements_controller.rb
@@ -0,0 +1,33 @@
+# frozen_string_literal: true
+
+class Api::V1::AnnouncementsController < Api::BaseController
+  before_action -> { doorkeeper_authorize! :write, :'write:accounts' }, only: :dismiss
+  before_action :require_user!
+  before_action :set_announcements, only: :index
+  before_action :set_announcement, except: :index
+
+  def index
+    render json: @announcements, each_serializer: REST::AnnouncementSerializer
+  end
+
+  def dismiss
+    AnnouncementMute.create!(account: current_account, announcement: @announcement)
+    render_empty
+  end
+
+  private
+
+  def set_announcements
+    @announcements = begin
+      scope = Announcement.published
+
+      scope.merge!(Announcement.without_muted(current_account)) unless truthy_param?(:with_dismissed)
+
+      scope.chronological
+    end
+  end
+
+  def set_announcement
+    @announcement = Announcement.published.find(params[:id])
+  end
+end
diff --git a/app/controllers/auth/passwords_controller.rb b/app/controllers/auth/passwords_controller.rb
index a59806f0d..c224e1a03 100644
--- a/app/controllers/auth/passwords_controller.rb
+++ b/app/controllers/auth/passwords_controller.rb
@@ -7,6 +7,12 @@ class Auth::PasswordsController < Devise::PasswordsController
 
   layout 'auth'
 
+  def update
+    super do |resource|
+      resource.session_activations.destroy_all if resource.errors.empty?
+    end
+  end
+
   private
 
   def check_validity_of_reset_password_token
diff --git a/app/controllers/auth/registrations_controller.rb b/app/controllers/auth/registrations_controller.rb
index a9d075a45..531df7751 100644
--- a/app/controllers/auth/registrations_controller.rb
+++ b/app/controllers/auth/registrations_controller.rb
@@ -23,10 +23,17 @@ class Auth::RegistrationsController < Devise::RegistrationsController
     not_found
   end
 
+  def update
+    super do |resource|
+      resource.clear_other_sessions(current_session.session_id) if resource.saved_change_to_encrypted_password?
+    end
+  end
+
   protected
 
   def update_resource(resource, params)
     params[:password] = nil if Devise.pam_authentication && resource.encrypted_password.blank?
+
     super
   end
 
diff --git a/app/controllers/relationships_controller.rb b/app/controllers/relationships_controller.rb
index 25dd0d2ad..f1ab980c8 100644
--- a/app/controllers/relationships_controller.rb
+++ b/app/controllers/relationships_controller.rb
@@ -20,53 +20,13 @@ class RelationshipsController < ApplicationController
   rescue ActionController::ParameterMissing
     # Do nothing
   ensure
-    redirect_to relationships_path(current_params)
+    redirect_to relationships_path(filter_params)
   end
 
   private
 
   def set_accounts
-    @accounts = relationships_scope.page(params[:page]).per(40)
-  end
-
-  def relationships_scope
-    scope = begin
-      if following_relationship?
-        current_account.following.eager_load(:account_stat).reorder(nil)
-      else
-        current_account.followers.eager_load(:account_stat).reorder(nil)
-      end
-    end
-
-    scope.merge!(Follow.recent)             if params[:order].blank? || params[:order] == 'recent'
-    scope.merge!(Account.by_recent_status)  if params[:order] == 'active'
-    scope.merge!(mutual_relationship_scope) if mutual_relationship?
-    scope.merge!(moved_account_scope)       if params[:status] == 'moved'
-    scope.merge!(primary_account_scope)     if params[:status] == 'primary'
-    scope.merge!(by_domain_scope)           if params[:by_domain].present?
-    scope.merge!(dormant_account_scope)     if params[:activity] == 'dormant'
-
-    scope
-  end
-
-  def mutual_relationship_scope
-    Account.where(id: current_account.following)
-  end
-
-  def moved_account_scope
-    Account.where.not(moved_to_account_id: nil)
-  end
-
-  def primary_account_scope
-    Account.where(moved_to_account_id: nil)
-  end
-
-  def dormant_account_scope
-    AccountStat.where(last_status_at: nil).or(AccountStat.where(AccountStat.arel_table[:last_status_at].lt(1.month.ago)))
-  end
-
-  def by_domain_scope
-    Account.where(domain: params[:by_domain])
+    @accounts = RelationshipFilter.new(current_account, filter_params).results.page(params[:page]).per(40)
   end
 
   def form_account_batch_params
@@ -85,7 +45,7 @@ class RelationshipsController < ApplicationController
     params[:relationship] == 'followed_by'
   end
 
-  def current_params
+  def filter_params
     params.slice(:page, *RelationshipFilter::KEYS).permit(:page, *RelationshipFilter::KEYS)
   end
 
diff --git a/app/controllers/statuses_controller.rb b/app/controllers/statuses_controller.rb
index 1b00d38c9..588063d01 100644
--- a/app/controllers/statuses_controller.rb
+++ b/app/controllers/statuses_controller.rb
@@ -49,7 +49,7 @@ class StatusesController < ApplicationController
 
   def embed
     use_pack 'embed'
-    raise ActiveRecord::RecordNotFound if @status.hidden?
+    return not_found if @status.hidden?
 
     expires_in 180, public: true
     response.headers['X-Frame-Options'] = 'ALLOWALL'
@@ -71,7 +71,7 @@ class StatusesController < ApplicationController
     @status = @account.statuses.find(params[:id])
     authorize @status, :show?
   rescue Mastodon::NotPermittedError
-    raise ActiveRecord::RecordNotFound
+    not_found
   end
 
   def set_instance_presenter
diff --git a/app/helpers/admin/action_logs_helper.rb b/app/helpers/admin/action_logs_helper.rb
index 608a99dd5..6bc75aa56 100644
--- a/app/helpers/admin/action_logs_helper.rb
+++ b/app/helpers/admin/action_logs_helper.rb
@@ -22,6 +22,8 @@ module Admin::ActionLogsHelper
       log.recorded_changes.slice('severity', 'reject_media')
     elsif log.target_type == 'Status' && log.action == :update
       log.recorded_changes.slice('sensitive')
+    elsif log.target_type == 'Announcement' && log.action == :update
+      log.recorded_changes.slice('text', 'starts_at', 'ends_at', 'all_day')
     end
   end
 
@@ -52,6 +54,8 @@ module Admin::ActionLogsHelper
       'pencil'
     when 'AccountWarning'
       'warning'
+    when 'Announcement'
+      'bullhorn'
     end
   end
 
@@ -94,6 +98,8 @@ module Admin::ActionLogsHelper
       link_to record.account.acct, ActivityPub::TagManager.instance.url_for(record)
     when 'AccountWarning'
       link_to record.target_account.acct, admin_account_path(record.target_account_id)
+    when 'Announcement'
+      link_to "##{record.id}", edit_admin_announcement_path(record.id)
     end
   end
 
@@ -111,6 +117,8 @@ module Admin::ActionLogsHelper
       else
         I18n.t('admin.action_logs.deleted_status')
       end
+    when 'Announcement'
+      "##{attributes['id']}"
     end
   end
 end
diff --git a/app/helpers/admin/announcements_helper.rb b/app/helpers/admin/announcements_helper.rb
new file mode 100644
index 000000000..0c053ddec
--- /dev/null
+++ b/app/helpers/admin/announcements_helper.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+module Admin::AnnouncementsHelper
+  def time_range(announcement)
+    if announcement.all_day?
+      safe_join([l(announcement.starts_at.to_date), ' - ', l(announcement.ends_at.to_date)])
+    else
+      safe_join([l(announcement.starts_at), ' - ', l(announcement.ends_at)])
+    end
+  end
+end
diff --git a/app/helpers/admin/filter_helper.rb b/app/helpers/admin/filter_helper.rb
index 130686a02..6ab92939d 100644
--- a/app/helpers/admin/filter_helper.rb
+++ b/app/helpers/admin/filter_helper.rb
@@ -9,6 +9,7 @@ module Admin::FilterHelper
     InstanceFilter::KEYS,
     InviteFilter::KEYS,
     RelationshipFilter::KEYS,
+    AnnouncementFilter::KEYS,
   ].flatten.freeze
 
   def filter_link_to(text, link_to_params, link_class_params = link_to_params)
diff --git a/app/javascript/flavours/glitch/actions/announcements.js b/app/javascript/flavours/glitch/actions/announcements.js
new file mode 100644
index 000000000..d0e5ee176
--- /dev/null
+++ b/app/javascript/flavours/glitch/actions/announcements.js
@@ -0,0 +1,133 @@
+import api from 'flavours/glitch/util/api';
+import { normalizeAnnouncement } from './importer/normalizer';
+
+export const ANNOUNCEMENTS_FETCH_REQUEST = 'ANNOUNCEMENTS_FETCH_REQUEST';
+export const ANNOUNCEMENTS_FETCH_SUCCESS = 'ANNOUNCEMENTS_FETCH_SUCCESS';
+export const ANNOUNCEMENTS_FETCH_FAIL    = 'ANNOUNCEMENTS_FETCH_FAIL';
+export const ANNOUNCEMENTS_UPDATE        = 'ANNOUNCEMENTS_UPDATE';
+export const ANNOUNCEMENTS_DISMISS       = 'ANNOUNCEMENTS_DISMISS';
+
+export const ANNOUNCEMENTS_REACTION_ADD_REQUEST = 'ANNOUNCEMENTS_REACTION_ADD_REQUEST';
+export const ANNOUNCEMENTS_REACTION_ADD_SUCCESS = 'ANNOUNCEMENTS_REACTION_ADD_SUCCESS';
+export const ANNOUNCEMENTS_REACTION_ADD_FAIL    = 'ANNOUNCEMENTS_REACTION_ADD_FAIL';
+
+export const ANNOUNCEMENTS_REACTION_REMOVE_REQUEST = 'ANNOUNCEMENTS_REACTION_REMOVE_REQUEST';
+export const ANNOUNCEMENTS_REACTION_REMOVE_SUCCESS = 'ANNOUNCEMENTS_REACTION_REMOVE_SUCCESS';
+export const ANNOUNCEMENTS_REACTION_REMOVE_FAIL    = 'ANNOUNCEMENTS_REACTION_REMOVE_FAIL';
+
+export const ANNOUNCEMENTS_REACTION_UPDATE = 'ANNOUNCEMENTS_REACTION_UPDATE';
+
+const noOp = () => {};
+
+export const fetchAnnouncements = (done = noOp) => (dispatch, getState) => {
+  dispatch(fetchAnnouncementsRequest());
+
+  api(getState).get('/api/v1/announcements').then(response => {
+    dispatch(fetchAnnouncementsSuccess(response.data.map(x => normalizeAnnouncement(x))));
+  }).catch(error => {
+    dispatch(fetchAnnouncementsFail(error));
+  }).finally(() => {
+    done();
+  });
+};
+
+export const fetchAnnouncementsRequest = () => ({
+  type: ANNOUNCEMENTS_FETCH_REQUEST,
+  skipLoading: true,
+});
+
+export const fetchAnnouncementsSuccess = announcements => ({
+  type: ANNOUNCEMENTS_FETCH_SUCCESS,
+  announcements,
+  skipLoading: true,
+});
+
+export const fetchAnnouncementsFail= error => ({
+  type: ANNOUNCEMENTS_FETCH_FAIL,
+  error,
+  skipLoading: true,
+  skipAlert: true,
+});
+
+export const updateAnnouncements = announcement => ({
+  type: ANNOUNCEMENTS_UPDATE,
+  announcement: normalizeAnnouncement(announcement),
+});
+
+export const dismissAnnouncement = announcementId => (dispatch, getState) => {
+  dispatch({
+    type: ANNOUNCEMENTS_DISMISS,
+    id: announcementId,
+  });
+
+  api(getState).post(`/api/v1/announcements/${announcementId}/dismiss`);
+};
+
+export const addReaction = (announcementId, name) => (dispatch, getState) => {
+  dispatch(addReactionRequest(announcementId, name));
+
+  api(getState).put(`/api/v1/announcements/${announcementId}/reactions/${name}`).then(() => {
+    dispatch(addReactionSuccess(announcementId, name));
+  }).catch(err => {
+    dispatch(addReactionFail(announcementId, name, err));
+  });
+};
+
+export const addReactionRequest = (announcementId, name) => ({
+  type: ANNOUNCEMENTS_REACTION_ADD_REQUEST,
+  id: announcementId,
+  name,
+  skipLoading: true,
+});
+
+export const addReactionSuccess = (announcementId, name) => ({
+  type: ANNOUNCEMENTS_REACTION_ADD_SUCCESS,
+  id: announcementId,
+  name,
+  skipLoading: true,
+});
+
+export const addReactionFail = (announcementId, name, error) => ({
+  type: ANNOUNCEMENTS_REACTION_ADD_FAIL,
+  id: announcementId,
+  name,
+  error,
+  skipLoading: true,
+});
+
+export const removeReaction = (announcementId, name) => (dispatch, getState) => {
+  dispatch(removeReactionRequest(announcementId, name));
+
+  api(getState).delete(`/api/v1/announcements/${announcementId}/reactions/${name}`).then(() => {
+    dispatch(removeReactionSuccess(announcementId, name));
+  }).catch(err => {
+    dispatch(removeReactionFail(announcementId, name, err));
+  });
+};
+
+export const removeReactionRequest = (announcementId, name) => ({
+  type: ANNOUNCEMENTS_REACTION_REMOVE_REQUEST,
+  id: announcementId,
+  name,
+  skipLoading: true,
+});
+
+export const removeReactionSuccess = (announcementId, name) => ({
+  type: ANNOUNCEMENTS_REACTION_REMOVE_SUCCESS,
+  id: announcementId,
+  name,
+  skipLoading: true,
+});
+
+export const removeReactionFail = (announcementId, name, error) => ({
+  type: ANNOUNCEMENTS_REACTION_REMOVE_FAIL,
+  id: announcementId,
+  name,
+  error,
+  skipLoading: true,
+});
+
+export const updateReaction = reaction => ({
+  type: ANNOUNCEMENTS_REACTION_UPDATE,
+  reaction,
+});
diff --git a/app/javascript/flavours/glitch/actions/importer/normalizer.js b/app/javascript/flavours/glitch/actions/importer/normalizer.js
index 2bc603930..52ad17779 100644
--- a/app/javascript/flavours/glitch/actions/importer/normalizer.js
+++ b/app/javascript/flavours/glitch/actions/importer/normalizer.js
@@ -74,7 +74,6 @@ export function normalizeStatus(status, normalOldStatus) {
 
 export function normalizePoll(poll) {
   const normalPoll = { ...poll };
-
   const emojiMap = makeEmojiMap(normalPoll);
 
   normalPoll.options = poll.options.map((option, index) => ({
@@ -85,3 +84,12 @@ export function normalizePoll(poll) {
 
   return normalPoll;
 }
+
+export function normalizeAnnouncement(announcement) {
+  const normalAnnouncement = { ...announcement };
+  const emojiMap = makeEmojiMap(normalAnnouncement);
+
+  normalAnnouncement.contentHtml = emojify(normalAnnouncement.content, emojiMap);
+
+  return normalAnnouncement;
+}
diff --git a/app/javascript/flavours/glitch/actions/notifications.js b/app/javascript/flavours/glitch/actions/notifications.js
index 940f3c3d4..b3de7b5bf 100644
--- a/app/javascript/flavours/glitch/actions/notifications.js
+++ b/app/javascript/flavours/glitch/actions/notifications.js
@@ -168,9 +168,9 @@ export function expandNotifications({ maxId } = {}, done = noOp) {
 
       dispatch(expandNotificationsSuccess(response.data, next ? next.uri : null, isLoadingMore, isLoadingRecent, isLoadingRecent && preferPendingItems));
       fetchRelatedRelationships(dispatch, response.data);
-      done();
     }).catch(error => {
       dispatch(expandNotificationsFail(error, isLoadingMore));
+    }).finally(() => {
       done();
     });
   };
@@ -199,6 +199,7 @@ export function expandNotificationsFail(error, isLoadingMore) {
     type: NOTIFICATIONS_EXPAND_FAIL,
     error,
     skipLoading: !isLoadingMore,
+    skipAlert: !isLoadingMore,
   };
 };
 
diff --git a/app/javascript/flavours/glitch/actions/streaming.js b/app/javascript/flavours/glitch/actions/streaming.js
index 21379f492..8294fbf36 100644
--- a/app/javascript/flavours/glitch/actions/streaming.js
+++ b/app/javascript/flavours/glitch/actions/streaming.js
@@ -8,6 +8,7 @@ import {
 } from './timelines';
 import { updateNotifications, expandNotifications } from './notifications';
 import { updateConversations } from './conversations';
+import { fetchAnnouncements, updateAnnouncements, updateReaction as updateAnnouncementsReaction } from './announcements';
 import { fetchFilters } from './filters';
 import { getLocale } from 'mastodon/locales';
 
@@ -44,6 +45,12 @@ export function connectTimelineStream (timelineId, path, pollingRefresh = null,
         case 'filters_changed':
           dispatch(fetchFilters());
           break;
+        case 'announcement':
+          dispatch(updateAnnouncements(JSON.parse(data.payload)));
+          break;
+        case 'announcement.reaction':
+          dispatch(updateAnnouncementsReaction(JSON.parse(data.payload)));
+          break;
         }
       },
     };
@@ -51,7 +58,9 @@ export function connectTimelineStream (timelineId, path, pollingRefresh = null,
 }
 
 const refreshHomeTimelineAndNotification = (dispatch, done) => {
-  dispatch(expandHomeTimeline({}, () => dispatch(expandNotifications({}, done))));
+  dispatch(expandHomeTimeline({}, () =>
+    dispatch(expandNotifications({}, () =>
+      dispatch(fetchAnnouncements(done))))));
 };
 
 export const connectUserStream      = () => connectTimelineStream('home', 'user', refreshHomeTimelineAndNotification);
diff --git a/app/javascript/flavours/glitch/actions/timelines.js b/app/javascript/flavours/glitch/actions/timelines.js
index 097878c3b..2ef78025e 100644
--- a/app/javascript/flavours/glitch/actions/timelines.js
+++ b/app/javascript/flavours/glitch/actions/timelines.js
@@ -112,9 +112,9 @@ export function expandTimeline(timelineId, path, params = {}, done = noOp) {
 
       dispatch(importFetchedStatuses(response.data));
       dispatch(expandTimelineSuccess(timelineId, response.data, next ? next.uri : null, response.status === 206, isLoadingRecent, isLoadingMore, isLoadingRecent && preferPendingItems));
-      done();
     }).catch(error => {
       dispatch(expandTimelineFail(timelineId, error, isLoadingMore));
+    }).finally(() => {
       done();
     });
   };
diff --git a/app/javascript/flavours/glitch/features/account_timeline/index.js b/app/javascript/flavours/glitch/features/account_timeline/index.js
index 2ef4ff602..f25c82a00 100644
--- a/app/javascript/flavours/glitch/features/account_timeline/index.js
+++ b/app/javascript/flavours/glitch/features/account_timeline/index.js
@@ -112,6 +112,7 @@ class AccountTimeline extends ImmutablePureComponent {
           onLoadMore={this.handleLoadMore}
           emptyMessage={<FormattedMessage id='empty_column.account_timeline' defaultMessage='No toots here!' />}
           bindToDocument={!multiColumn}
+          timelineId='account'
         />
       </Column>
     );
diff --git a/app/javascript/flavours/glitch/features/directory/components/account_card.js b/app/javascript/flavours/glitch/features/directory/components/account_card.js
index d1c406933..557120960 100644
--- a/app/javascript/flavours/glitch/features/directory/components/account_card.js
+++ b/app/javascript/flavours/glitch/features/directory/components/account_card.js
@@ -22,6 +22,7 @@ const messages = defineMessages({
   requested: { id: 'account.requested', defaultMessage: 'Awaiting approval' },
   unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' },
   unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' },
+  unfollowConfirm: { id: 'confirmations.unfollow.confirm', defaultMessage: 'Unfollow' },
 });
 
 const makeMapStateToProps = () => {
diff --git a/app/javascript/flavours/glitch/features/emoji_picker/index.js b/app/javascript/flavours/glitch/features/emoji_picker/index.js
index 6e5518b0c..3717fcd82 100644
--- a/app/javascript/flavours/glitch/features/emoji_picker/index.js
+++ b/app/javascript/flavours/glitch/features/emoji_picker/index.js
@@ -372,6 +372,7 @@ class EmojiPickerDropdown extends React.PureComponent {
     onPickEmoji: PropTypes.func.isRequired,
     onSkinTone: PropTypes.func.isRequired,
     skinTone: PropTypes.number.isRequired,
+    button: PropTypes.node,
   };
 
   state = {
@@ -432,18 +433,18 @@ class EmojiPickerDropdown extends React.PureComponent {
   }
 
   render () {
-    const { intl, onPickEmoji, onSkinTone, skinTone, frequentlyUsedEmojis } = this.props;
+    const { intl, onPickEmoji, onSkinTone, skinTone, frequentlyUsedEmojis, button } = this.props;
     const title = intl.formatMessage(messages.emoji);
     const { active, loading, placement } = this.state;
 
     return (
       <div className='emoji-picker-dropdown' onKeyDown={this.handleKeyDown}>
         <div ref={this.setTargetRef} className='emoji-button' title={title} aria-label={title} aria-expanded={active} role='button' onClick={this.onToggle} onKeyDown={this.onToggle} tabIndex={0}>
-          <img
+          {button || <img
             className={classNames('emojione', { 'pulse-loading': active && loading })}
             alt='🙂'
             src={`${assetHost}/emoji/1f602.svg`}
-          />
+          />}
         </div>
 
         <Overlay show={active} placement={placement} target={this.findTarget}>
diff --git a/app/javascript/flavours/glitch/features/getting_started/components/announcements.js b/app/javascript/flavours/glitch/features/getting_started/components/announcements.js
new file mode 100644
index 000000000..010778727
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/getting_started/components/announcements.js
@@ -0,0 +1,395 @@
+import React from 'react';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import ReactSwipeableViews from 'react-swipeable-views';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import IconButton from 'flavours/glitch/components/icon_button';
+import Icon from 'flavours/glitch/components/icon';
+import { defineMessages, injectIntl, FormattedMessage, FormattedDate, FormattedNumber } from 'react-intl';
+import { autoPlayGif } from 'flavours/glitch/util/initial_state';
+import elephantUIPlane from 'mastodon/../images/elephant_ui_plane.svg';
+import { mascot } from 'flavours/glitch/util/initial_state';
+import unicodeMapping from 'flavours/glitch/util/emoji/emoji_unicode_mapping_light';
+import classNames from 'classnames';
+import EmojiPickerDropdown from 'flavours/glitch/features/emoji_picker';
+
+const messages = defineMessages({
+  close: { id: 'lightbox.close', defaultMessage: 'Close' },
+  previous: { id: 'lightbox.previous', defaultMessage: 'Previous' },
+  next: { id: 'lightbox.next', defaultMessage: 'Next' },
+});
+
+class Content extends ImmutablePureComponent {
+
+  static contextTypes = {
+    router: PropTypes.object,
+  };
+
+  static propTypes = {
+    announcement: ImmutablePropTypes.map.isRequired,
+  };
+
+  setRef = c => {
+    this.node = c;
+  }
+
+  componentDidMount () {
+    this._updateLinks();
+    this._updateEmojis();
+  }
+
+  componentDidUpdate () {
+    this._updateLinks();
+    this._updateEmojis();
+  }
+
+  _updateEmojis () {
+    const node = this.node;
+
+    if (!node || autoPlayGif) {
+      return;
+    }
+
+    const emojis = node.querySelectorAll('.custom-emoji');
+
+    for (var i = 0; i < emojis.length; i++) {
+      let emoji = emojis[i];
+
+      if (emoji.classList.contains('status-emoji')) {
+        continue;
+      }
+
+      emoji.classList.add('status-emoji');
+
+      emoji.addEventListener('mouseenter', this.handleEmojiMouseEnter, false);
+      emoji.addEventListener('mouseleave', this.handleEmojiMouseLeave, false);
+    }
+  }
+
+  _updateLinks () {
+    const node = this.node;
+
+    if (!node) {
+      return;
+    }
+
+    const links = node.querySelectorAll('a');
+
+    for (var i = 0; i < links.length; ++i) {
+      let link = links[i];
+
+      if (link.classList.contains('status-link')) {
+        continue;
+      }
+
+      link.classList.add('status-link');
+
+      let mention = this.props.announcement.get('mentions').find(item => link.href === item.get('url'));
+
+      if (mention) {
+        link.addEventListener('click', this.onMentionClick.bind(this, mention), false);
+        link.setAttribute('title', mention.get('acct'));
+      } else if (link.textContent[0] === '#' || (link.previousSibling && link.previousSibling.textContent && link.previousSibling.textContent[link.previousSibling.textContent.length - 1] === '#')) {
+        link.addEventListener('click', this.onHashtagClick.bind(this, link.text), false);
+      } else {
+        link.setAttribute('title', link.href);
+        link.classList.add('unhandled-link');
+      }
+
+      link.setAttribute('target', '_blank');
+      link.setAttribute('rel', 'noopener noreferrer');
+    }
+  }
+
+  onMentionClick = (mention, e) => {
+    if (this.context.router && e.button === 0 && !(e.ctrlKey || e.metaKey)) {
+      e.preventDefault();
+      this.context.router.history.push(`/accounts/${mention.get('id')}`);
+    }
+  }
+
+  onHashtagClick = (hashtag, e) => {
+    hashtag = hashtag.replace(/^#/, '');
+
+    if (this.context.router && e.button === 0 && !(e.ctrlKey || e.metaKey)) {
+      e.preventDefault();
+      this.context.router.history.push(`/timelines/tag/${hashtag}`);
+    }
+  }
+
+  handleEmojiMouseEnter = ({ target }) => {
+    target.src = target.getAttribute('data-original');
+  }
+
+  handleEmojiMouseLeave = ({ target }) => {
+    target.src = target.getAttribute('data-static');
+  }
+
+  render () {
+    const { announcement } = this.props;
+
+    return (
+      <div
+        className='announcements__item__content'
+        ref={this.setRef}
+        dangerouslySetInnerHTML={{ __html: announcement.get('contentHtml') }}
+      />
+    );
+  }
+
+}
+
+const assetHost = process.env.CDN_HOST || '';
+
+class Emoji extends React.PureComponent {
+
+  static propTypes = {
+    emoji: PropTypes.string.isRequired,
+    emojiMap: ImmutablePropTypes.map.isRequired,
+    hovered: PropTypes.bool.isRequired,
+  };
+
+  render () {
+    const { emoji, emojiMap, hovered } = this.props;
+
+    if (unicodeMapping[emoji]) {
+      const { filename, shortCode } = unicodeMapping[this.props.emoji];
+      const title = shortCode ? `:${shortCode}:` : '';
+
+      return (
+        <img
+          draggable='false'
+          className='emojione'
+          alt={emoji}
+          title={title}
+          src={`${assetHost}/emoji/${filename}.svg`}
+        />
+      );
+    } else if (emojiMap.get(emoji)) {
+      const filename  = (autoPlayGif || hovered) ? emojiMap.getIn([emoji, 'url']) : emojiMap.getIn([emoji, 'static_url']);
+      const shortCode = `:${emoji}:`;
+
+      return (
+        <img
+          draggable='false'
+          className='emojione custom-emoji'
+          alt={shortCode}
+          title={shortCode}
+          src={filename}
+        />
+      );
+    } else {
+      return null;
+    }
+  }
+
+}
+
+class Reaction extends ImmutablePureComponent {
+
+  static propTypes = {
+    announcementId: PropTypes.string.isRequired,
+    reaction: ImmutablePropTypes.map.isRequired,
+    addReaction: PropTypes.func.isRequired,
+    removeReaction: PropTypes.func.isRequired,
+    emojiMap: ImmutablePropTypes.map.isRequired,
+  };
+
+  state = {
+    hovered: false,
+  };
+
+  handleClick = () => {
+    const { reaction, announcementId, addReaction, removeReaction } = this.props;
+
+    if (reaction.get('me')) {
+      removeReaction(announcementId, reaction.get('name'));
+    } else {
+      addReaction(announcementId, reaction.get('name'));
+    }
+  }
+
+  handleMouseEnter = () => this.setState({ hovered: true })
+
+  handleMouseLeave = () => this.setState({ hovered: false })
+
+  render () {
+    const { reaction } = this.props;
+
+    let shortCode = reaction.get('name');
+
+    if (unicodeMapping[shortCode]) {
+      shortCode = unicodeMapping[shortCode].shortCode;
+    }
+
+    return (
+      <button className={classNames('reactions-bar__item', { active: reaction.get('me') })} onClick={this.handleClick} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave} title={`:${shortCode}:`}>
+        <span className='reactions-bar__item__emoji'><Emoji hovered={this.state.hovered} emoji={reaction.get('name')} emojiMap={this.props.emojiMap} /></span>
+        <span className='reactions-bar__item__count'><FormattedNumber value={reaction.get('count')} /></span>
+      </button>
+    );
+  }
+
+}
+
+class ReactionsBar extends ImmutablePureComponent {
+
+  static propTypes = {
+    announcementId: PropTypes.string.isRequired,
+    reactions: ImmutablePropTypes.list.isRequired,
+    addReaction: PropTypes.func.isRequired,
+    removeReaction: PropTypes.func.isRequired,
+    emojiMap: ImmutablePropTypes.map.isRequired,
+  };
+
+  handleEmojiPick = data => {
+    const { addReaction, announcementId } = this.props;
+    addReaction(announcementId, data.native.replace(/:/g, ''));
+  }
+
+  render () {
+    const { reactions } = this.props;
+    const visibleReactions = reactions.filter(x => x.get('count') > 0);
+
+    return (
+      <div className={classNames('reactions-bar', { 'reactions-bar--empty': visibleReactions.isEmpty() })}>
+        {visibleReactions.map(reaction => (
+          <Reaction
+            key={reaction.get('name')}
+            reaction={reaction}
+            announcementId={this.props.announcementId}
+            addReaction={this.props.addReaction}
+            removeReaction={this.props.removeReaction}
+            emojiMap={this.props.emojiMap}
+          />
+        ))}
+
+        <EmojiPickerDropdown onPickEmoji={this.handleEmojiPick} button={<Icon id='plus' />} />
+      </div>
+    );
+  }
+
+}
+
+class Announcement extends ImmutablePureComponent {
+
+  static propTypes = {
+    announcement: ImmutablePropTypes.map.isRequired,
+    emojiMap: ImmutablePropTypes.map.isRequired,
+    dismissAnnouncement: PropTypes.func.isRequired,
+    addReaction: PropTypes.func.isRequired,
+    removeReaction: PropTypes.func.isRequired,
+    intl: PropTypes.object.isRequired,
+  };
+
+  handleDismissClick = () => {
+    const { dismissAnnouncement, announcement } = this.props;
+    dismissAnnouncement(announcement.get('id'));
+  }
+
+  render () {
+    const { announcement, intl } = this.props;
+    const startsAt = announcement.get('starts_at') && new Date(announcement.get('starts_at'));
+    const endsAt = announcement.get('ends_at') && new Date(announcement.get('ends_at'));
+    const now = new Date();
+    const hasTimeRange = startsAt && endsAt;
+    const skipYear = hasTimeRange && startsAt.getFullYear() === endsAt.getFullYear() && endsAt.getFullYear() === now.getFullYear();
+    const skipEndDate = hasTimeRange && startsAt.getDate() === endsAt.getDate() && startsAt.getMonth() === endsAt.getMonth() && startsAt.getFullYear() === endsAt.getFullYear();
+    const skipTime = announcement.get('all_day');
+
+    return (
+      <div className='announcements__item'>
+        <strong className='announcements__item__range'>
+          <FormattedMessage id='announcement.announcement' defaultMessage='Announcement' />
+          {hasTimeRange && <span> · <FormattedDate value={startsAt} hour12={false} year={(skipYear || startsAt.getFullYear() === now.getFullYear()) ? undefined : 'numeric'} month='short' day='2-digit' hour={skipTime ? undefined : '2-digit'} minute={skipTime ? undefined : '2-digit'} /> - <FormattedDate value={endsAt} hour12={false} year={(skipYear || endsAt.getFullYear() === now.getFullYear()) ? undefined : 'numeric'} month={skipEndDate ? undefined : 'short'} day={skipEndDate ? undefined : '2-digit'} hour={skipTime ? undefined : '2-digit'} minute={skipTime ? undefined : '2-digit'} /></span>}
+        </strong>
+
+        <Content announcement={announcement} />
+
+        <ReactionsBar
+          reactions={announcement.get('reactions')}
+          announcementId={announcement.get('id')}
+          addReaction={this.props.addReaction}
+          removeReaction={this.props.removeReaction}
+          emojiMap={this.props.emojiMap}
+        />
+
+        <IconButton title={intl.formatMessage(messages.close)} icon='times' className='announcements__item__dismiss-icon' onClick={this.handleDismissClick} />
+      </div>
+    );
+  }
+
+}
+
+export default @injectIntl
+class Announcements extends ImmutablePureComponent {
+
+  static propTypes = {
+    announcements: ImmutablePropTypes.list,
+    emojiMap: ImmutablePropTypes.map.isRequired,
+    fetchAnnouncements: PropTypes.func.isRequired,
+    dismissAnnouncement: PropTypes.func.isRequired,
+    addReaction: PropTypes.func.isRequired,
+    removeReaction: PropTypes.func.isRequired,
+    intl: PropTypes.object.isRequired,
+  };
+
+  state = {
+    index: 0,
+  };
+
+  componentDidMount () {
+    const { fetchAnnouncements } = this.props;
+    fetchAnnouncements();
+  }
+
+  handleChangeIndex = index => {
+    this.setState({ index: index % this.props.announcements.size });
+  }
+
+  handleNextClick = () => {
+    this.setState({ index: (this.state.index + 1) % this.props.announcements.size });
+  }
+
+  handlePrevClick = () => {
+    this.setState({ index: (this.props.announcements.size + this.state.index - 1) % this.props.announcements.size });
+  }
+
+  render () {
+    const { announcements, intl } = this.props;
+    const { index } = this.state;
+
+    if (announcements.isEmpty()) {
+      return null;
+    }
+
+    return (
+      <div className='announcements'>
+        <img className='announcements__mastodon' alt='' draggable='false' src={mascot || elephantUIPlane} />
+
+        <div className='announcements__container'>
+          <ReactSwipeableViews index={index} onChangeIndex={this.handleChangeIndex}>
+            {announcements.map(announcement => (
+              <Announcement
+                key={announcement.get('id')}
+                announcement={announcement}
+                emojiMap={this.props.emojiMap}
+                dismissAnnouncement={this.props.dismissAnnouncement}
+                addReaction={this.props.addReaction}
+                removeReaction={this.props.removeReaction}
+                intl={intl}
+              />
+            ))}
+          </ReactSwipeableViews>
+
+          <div className='announcements__pagination'>
+            <IconButton disabled={announcements.size === 1} title={intl.formatMessage(messages.previous)} icon='chevron-left' onClick={this.handlePrevClick} size={13} />
+            <span>{index + 1} / {announcements.size}</span>
+            <IconButton disabled={announcements.size === 1} title={intl.formatMessage(messages.next)} icon='chevron-right' onClick={this.handleNextClick} size={13} />
+          </div>
+        </div>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/getting_started/containers/announcements_container.js b/app/javascript/flavours/glitch/features/getting_started/containers/announcements_container.js
new file mode 100644
index 000000000..b10d1d4ce
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/getting_started/containers/announcements_container.js
@@ -0,0 +1,21 @@
+import { connect } from 'react-redux';
+import { fetchAnnouncements, dismissAnnouncement, addReaction, removeReaction } from 'mastodon/actions/announcements';
+import Announcements from '../components/announcements';
+import { createSelector } from 'reselect';
+import { Map as ImmutableMap } from 'immutable';
+
+const customEmojiMap = createSelector([state => state.get('custom_emojis')], items => items.reduce((map, emoji) => map.set(emoji.get('shortcode'), emoji), ImmutableMap()));
+
+const mapStateToProps = state => ({
+  announcements: state.getIn(['announcements', 'items']),
+  emojiMap: customEmojiMap(state),
+});
+
+const mapDispatchToProps = dispatch => ({
+  fetchAnnouncements: () => dispatch(fetchAnnouncements()),
+  dismissAnnouncement: id => dispatch(dismissAnnouncement(id)),
+  addReaction: (id, name) => dispatch(addReaction(id, name)),
+  removeReaction: (id, name) => dispatch(removeReaction(id, name)),
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(Announcements);
diff --git a/app/javascript/flavours/glitch/features/getting_started/containers/trends_container.js b/app/javascript/flavours/glitch/features/getting_started/containers/trends_container.js
index 1df3fb4fe..7a5268780 100644
--- a/app/javascript/flavours/glitch/features/getting_started/containers/trends_container.js
+++ b/app/javascript/flavours/glitch/features/getting_started/containers/trends_container.js
@@ -1,5 +1,5 @@
 import { connect } from 'react-redux';
-import { fetchTrends } from '../../../actions/trends';
+import { fetchTrends } from 'mastodon/actions/trends';
 import Trends from '../components/trends';
 
 const mapStateToProps = state => ({
diff --git a/app/javascript/flavours/glitch/features/home_timeline/index.js b/app/javascript/flavours/glitch/features/home_timeline/index.js
index 9b71a4404..263371b06 100644
--- a/app/javascript/flavours/glitch/features/home_timeline/index.js
+++ b/app/javascript/flavours/glitch/features/home_timeline/index.js
@@ -9,6 +9,7 @@ import { addColumn, removeColumn, moveColumn } from 'flavours/glitch/actions/col
 import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
 import ColumnSettingsContainer from './containers/column_settings_container';
 import { Link } from 'react-router-dom';
+import AnnouncementsContainer from 'flavours/glitch/features/getting_started/containers/announcements_container';
 
 const messages = defineMessages({
   title: { id: 'column.home', defaultMessage: 'Home' },
@@ -112,6 +113,8 @@ class HomeTimeline extends React.PureComponent {
         </ColumnHeader>
 
         <StatusListContainer
+          prepend={<AnnouncementsContainer />}
+          alwaysPrepend
           trackScroll={!pinned}
           scrollKey={`home_timeline-${columnId}`}
           onLoadMore={this.handleLoadMore}
diff --git a/app/javascript/flavours/glitch/features/ui/components/media_modal.js b/app/javascript/flavours/glitch/features/ui/components/media_modal.js
index c7d6c374c..23e8dac7e 100644
--- a/app/javascript/flavours/glitch/features/ui/components/media_modal.js
+++ b/app/javascript/flavours/glitch/features/ui/components/media_modal.js
@@ -191,7 +191,6 @@ class MediaModal extends ImmutablePureComponent {
             style={swipeableViewsStyle}
             containerStyle={containerStyle}
             onChangeIndex={this.handleSwipe}
-            onSwitching={this.handleSwitching}
             index={index}
           >
             {content}
diff --git a/app/javascript/flavours/glitch/reducers/announcements.js b/app/javascript/flavours/glitch/reducers/announcements.js
new file mode 100644
index 000000000..aa674e516
--- /dev/null
+++ b/app/javascript/flavours/glitch/reducers/announcements.js
@@ -0,0 +1,72 @@
+import {
+  ANNOUNCEMENTS_FETCH_REQUEST,
+  ANNOUNCEMENTS_FETCH_SUCCESS,
+  ANNOUNCEMENTS_FETCH_FAIL,
+  ANNOUNCEMENTS_UPDATE,
+  ANNOUNCEMENTS_DISMISS,
+  ANNOUNCEMENTS_REACTION_UPDATE,
+  ANNOUNCEMENTS_REACTION_ADD_REQUEST,
+  ANNOUNCEMENTS_REACTION_ADD_FAIL,
+  ANNOUNCEMENTS_REACTION_REMOVE_REQUEST,
+  ANNOUNCEMENTS_REACTION_REMOVE_FAIL,
+} from '../actions/announcements';
+import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable';
+
+const initialState = ImmutableMap({
+  items: ImmutableList(),
+  isLoading: false,
+});
+
+const updateReaction = (state, id, name, updater) => state.update('items', list => list.map(announcement => {
+  if (announcement.get('id') === id) {
+    return announcement.update('reactions', reactions => {
+      if (reactions.find(reaction => reaction.get('name') === name)) {
+        return reactions.map(reaction => {
+          if (reaction.get('name') === name) {
+            return updater(reaction);
+          }
+
+          return reaction;
+        });
+      }
+
+      return reactions.push(updater(fromJS({ name, count: 0 })));
+    });
+  }
+
+  return announcement;
+}));
+
+const updateReactionCount = (state, reaction) => updateReaction(state, reaction.announcement_id, reaction.name, x => x.set('count', reaction.count));
+
+const addReaction = (state, id, name) => updateReaction(state, id, name, x => x.set('me', true).update('count', y => y + 1));
+
+const removeReaction = (state, id, name) => updateReaction(state, id, name, x => x.set('me', false).update('count', y => y - 1));
+
+export default function announcementsReducer(state = initialState, action) {
+  switch(action.type) {
+  case ANNOUNCEMENTS_FETCH_REQUEST:
+    return state.set('isLoading', true);
+  case ANNOUNCEMENTS_FETCH_SUCCESS:
+    return state.withMutations(map => {
+      map.set('items', fromJS(action.announcements));
+      map.set('isLoading', false);
+    });
+  case ANNOUNCEMENTS_FETCH_FAIL:
+    return state.set('isLoading', false);
+  case ANNOUNCEMENTS_UPDATE:
+    return state.update('items', list => list.unshift(fromJS(action.announcement)).sortBy(announcement => announcement.get('starts_at')));
+  case ANNOUNCEMENTS_DISMISS:
+    return state.update('items', list => list.filterNot(announcement => announcement.get('id') === action.id));
+  case ANNOUNCEMENTS_REACTION_UPDATE:
+    return updateReactionCount(state, action.reaction);
+  case ANNOUNCEMENTS_REACTION_ADD_REQUEST:
+  case ANNOUNCEMENTS_REACTION_REMOVE_FAIL:
+    return addReaction(state, action.id, action.name);
+  case ANNOUNCEMENTS_REACTION_REMOVE_REQUEST:
+  case ANNOUNCEMENTS_REACTION_ADD_FAIL:
+    return removeReaction(state, action.id, action.name);
+  default:
+    return state;
+  }
+};
diff --git a/app/javascript/flavours/glitch/reducers/index.js b/app/javascript/flavours/glitch/reducers/index.js
index 7dbca3a29..586b84749 100644
--- a/app/javascript/flavours/glitch/reducers/index.js
+++ b/app/javascript/flavours/glitch/reducers/index.js
@@ -35,8 +35,10 @@ import pinnedAccountsEditor from './pinned_accounts_editor';
 import polls from './polls';
 import identity_proofs from './identity_proofs';
 import trends from './trends';
+import announcements from './announcements';
 
 const reducers = {
+  announcements,
   dropdown_menu,
   timelines,
   meta,
diff --git a/app/javascript/flavours/glitch/selectors/index.js b/app/javascript/flavours/glitch/selectors/index.js
index 8ceb71d03..ab7dac66a 100644
--- a/app/javascript/flavours/glitch/selectors/index.js
+++ b/app/javascript/flavours/glitch/selectors/index.js
@@ -27,6 +27,7 @@ export const toServerSideType = columnType => {
   case 'notifications':
   case 'public':
   case 'thread':
+  case 'account':
     return columnType;
   default:
     if (columnType.indexOf('list:') > -1) {
diff --git a/app/javascript/flavours/glitch/styles/components/announcements.scss b/app/javascript/flavours/glitch/styles/components/announcements.scss
new file mode 100644
index 000000000..0d1f1837b
--- /dev/null
+++ b/app/javascript/flavours/glitch/styles/components/announcements.scss
@@ -0,0 +1,212 @@
+.announcements__item__content {
+  word-wrap: break-word;
+
+  .emojione {
+    width: 20px;
+    height: 20px;
+    margin: -3px 0 0;
+  }
+
+  p {
+    margin-bottom: 10px;
+    white-space: pre-wrap;
+
+    &:last-child {
+      margin-bottom: 0;
+    }
+  }
+
+  a {
+    color: $highlight-text-color;
+    text-decoration: none;
+
+    &:hover {
+      text-decoration: underline;
+    }
+
+    &.mention {
+      &:hover {
+        text-decoration: none;
+
+        span {
+          text-decoration: underline;
+        }
+      }
+    }
+  }
+}
+
+.announcements {
+  background: lighten($ui-base-color, 4%);
+  border-top: 1px solid $ui-base-color;
+  font-size: 13px;
+  display: flex;
+  align-items: flex-end;
+
+  &__mastodon {
+    width: 124px;
+    flex: 0 0 auto;
+
+    @media screen and (max-width: 124px + 300px) {
+      display: none;
+    }
+  }
+
+  &__container {
+    width: calc(100% - 124px);
+    flex: 0 0 auto;
+    position: relative;
+
+    @media screen and (max-width: 124px + 300px) {
+      width: 100%;
+    }
+  }
+
+  &__item {
+    box-sizing: border-box;
+    width: 100%;
+    padding: 15px;
+    padding-right: 15px + 18px;
+    position: relative;
+
+    &__range {
+      display: block;
+      font-weight: 500;
+      margin-bottom: 10px;
+    }
+
+    &__dismiss-icon {
+      position: absolute;
+      top: 12px;
+      right: 12px;
+    }
+  }
+
+  &__pagination {
+    padding: 15px;
+    color: $darker-text-color;
+    position: absolute;
+    bottom: 3px;
+    right: 0;
+  }
+}
+
+.layout-multiple-columns .announcements__mastodon {
+  display: none;
+}
+
+.layout-multiple-columns .announcements__container {
+  width: 100%;
+}
+
+.reactions-bar {
+  display: flex;
+  flex-wrap: wrap;
+  align-items: center;
+  margin-top: 15px;
+  margin-left: -2px;
+  width: calc(100% - (90px - 33px));
+
+  &__item {
+    flex-shrink: 0;
+    background: lighten($ui-base-color, 12%);
+    border: 0;
+    border-radius: 3px;
+    margin: 2px;
+    cursor: pointer;
+    user-select: none;
+    padding: 0 6px;
+    display: flex;
+    align-items: center;
+    transition: all 100ms ease-in;
+    transition-property: background-color, color;
+
+    &__emoji {
+      display: block;
+      margin: 3px 0;
+      width: 16px;
+      height: 16px;
+
+      img {
+        display: block;
+        margin: 0;
+        width: 100%;
+        height: 100%;
+        min-width: auto;
+        min-height: auto;
+        vertical-align: bottom;
+        object-fit: contain;
+      }
+    }
+
+    &__count {
+      display: block;
+      min-width: 9px;
+      font-size: 13px;
+      font-weight: 500;
+      text-align: center;
+      margin-left: 6px;
+      color: $darker-text-color;
+    }
+
+    &:hover,
+    &:focus,
+    &:active {
+      background: lighten($ui-base-color, 16%);
+      transition: all 200ms ease-out;
+      transition-property: background-color, color;
+
+      &__count {
+        color: lighten($darker-text-color, 4%);
+      }
+    }
+
+    &.active {
+      transition: all 100ms ease-in;
+      transition-property: background-color, color;
+      background-color: mix(lighten($ui-base-color, 12%), $ui-highlight-color, 90%);
+
+      .reactions-bar__item__count {
+        color: $highlight-text-color;
+      }
+    }
+  }
+
+  .emoji-picker-dropdown {
+    margin: 2px;
+  }
+
+  &:hover .emoji-button {
+    opacity: 0.85;
+  }
+
+  .emoji-button {
+    color: $darker-text-color;
+    margin: 0;
+    font-size: 16px;
+    width: auto;
+    flex-shrink: 0;
+    padding: 0 6px;
+    height: 22px;
+    display: flex;
+    align-items: center;
+    opacity: 0.5;
+    transition: all 100ms ease-in;
+    transition-property: background-color, color;
+
+    &:hover,
+    &:active,
+    &:focus {
+      opacity: 1;
+      color: lighten($darker-text-color, 4%);
+      transition: all 200ms ease-out;
+      transition-property: background-color, color;
+    }
+  }
+
+  &--empty {
+    .emoji-button {
+      padding: 0;
+    }
+  }
+}
diff --git a/app/javascript/flavours/glitch/styles/components/composer.scss b/app/javascript/flavours/glitch/styles/components/composer.scss
index 51287f62e..943776010 100644
--- a/app/javascript/flavours/glitch/styles/components/composer.scss
+++ b/app/javascript/flavours/glitch/styles/components/composer.scss
@@ -1,5 +1,16 @@
 .composer {
   padding: 10px;
+
+  .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);
+    }
+  }
 }
 
 .character-counter {
@@ -235,17 +246,6 @@
   }
 }
 
-.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__autosuggest-wrapper,
 .autosuggest-input {
   position: relative;
diff --git a/app/javascript/flavours/glitch/styles/components/index.scss b/app/javascript/flavours/glitch/styles/components/index.scss
index 8e576fd86..abe933860 100644
--- a/app/javascript/flavours/glitch/styles/components/index.scss
+++ b/app/javascript/flavours/glitch/styles/components/index.scss
@@ -1649,3 +1649,4 @@ noscript {
 @import 'local_settings';
 @import 'error_boundary';
 @import 'single_column';
+@import 'announcements';
diff --git a/app/javascript/flavours/glitch/styles/forms.scss b/app/javascript/flavours/glitch/styles/forms.scss
index 1920c33ea..396e87c6c 100644
--- a/app/javascript/flavours/glitch/styles/forms.scss
+++ b/app/javascript/flavours/glitch/styles/forms.scss
@@ -213,6 +213,12 @@ code {
     }
   }
 
+  .input.datetime .label_input select {
+    display: inline-block;
+    width: auto;
+    flex: 0;
+  }
+
   .required abbr {
     text-decoration: none;
     color: lighten($error-value-color, 12%);
diff --git a/app/javascript/flavours/glitch/util/stream.js b/app/javascript/flavours/glitch/util/stream.js
index 50f90d44c..fe965bcb0 100644
--- a/app/javascript/flavours/glitch/util/stream.js
+++ b/app/javascript/flavours/glitch/util/stream.js
@@ -2,6 +2,14 @@ import WebSocketClient from '@gamestdio/websocket';
 
 const randomIntUpTo = max => Math.floor(Math.random() * Math.floor(max));
 
+const knownEventTypes = [
+  'update',
+  'delete',
+  'notification',
+  'conversation',
+  'filters_changed',
+];
+
 export function connectStream(path, pollingRefresh = null, callbacks = () => ({ onConnect() {}, onDisconnect() {}, onReceive() {} })) {
   return (dispatch, getState) => {
     const streamingAPIBaseURL = getState().getIn(['meta', 'streaming_api_base_url']);
@@ -69,14 +77,42 @@ export function connectStream(path, pollingRefresh = null, callbacks = () => ({
 
 
 export default function getStream(streamingAPIBaseURL, accessToken, stream, { connected, received, disconnected, reconnected }) {
-  const params = [ `stream=${stream}` ];
-
-  const ws = new WebSocketClient(`${streamingAPIBaseURL}/api/v1/streaming/?${params.join('&')}`, accessToken);
-
-  ws.onopen      = connected;
-  ws.onmessage   = e => received(JSON.parse(e.data));
-  ws.onclose     = disconnected;
-  ws.onreconnect = reconnected;
+  const params = stream.split('&');
+  stream = params.shift();
+
+  if (streamingAPIBaseURL.startsWith('ws')) {
+    params.unshift(`stream=${stream}`);
+    const ws = new WebSocketClient(`${streamingAPIBaseURL}/api/v1/streaming/?${params.join('&')}`, accessToken);
+
+    ws.onopen      = connected;
+    ws.onmessage   = e => received(JSON.parse(e.data));
+    ws.onclose     = disconnected;
+    ws.onreconnect = reconnected;
+
+    return ws;
+  }
+
+  params.push(`access_token=${accessToken}`);
+  const es = new EventSource(`${streamingAPIBaseURL}/api/v1/streaming/${stream}?${params.join('&')}`);
+
+  let firstConnect = true;
+  es.onopen = () => {
+    if (firstConnect) {
+      firstConnect = false;
+      connected();
+    } else {
+      reconnected();
+    }
+  };
+  for (let type of knownEventTypes) {
+    es.addEventListener(type, (e) => {
+      received({
+        event: e.type,
+        payload: e.data,
+      });
+    });
+  }
+  es.onerror = disconnected;
 
-  return ws;
+  return es;
 };
diff --git a/app/javascript/images/elephant_ui_plane.svg b/app/javascript/images/elephant_ui_plane.svg
index a2624d170..ca675c9eb 100644
--- a/app/javascript/images/elephant_ui_plane.svg
+++ b/app/javascript/images/elephant_ui_plane.svg
@@ -1 +1 @@
-<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 292.85862 204.49997" width="292.85861" height="204.49997"><g transform="translate(-395.89999 -820.4)"><defs><path id="a" d="M395.89999 745.09998H690.5v279.79999H395.89999z"/></defs><clipPath id="b"><use xlink:href="#a" width="100%" height="100%" overflow="visible"/></clipPath><path class="st53" d="M339.3 1028.6c1.5-3.2 14.4-31.3 27.4-58.8-6-9.3-2-17 1.5-23.7 1.9-3.7 3.8-7.1 3.6-10.4-.8-22 8.1-38.3 22.8-41.6 2.8-.6 5.2-.9 7.5-.9 3 0 5.6.5 8.1 1.6 1.4-1.3 2.8-2.6 4.2-3.8-2.8-2.6-4.3-5.5-4.5-8.8-.3-4.5 2.2-9.5 6.8-13.7 5.3-4.8 16.5-12.9 31.7-12.9.9 0 1.7 0 2.6.1-.4-.9-1-2-2.1-2.9-2.1-1.6-1.9-3.2-1.6-4 .7-2.1 3.6-3.2 8.1-3.2 3.9 0 9.7 1.2 14 4.4.3-.7.7-1.3 1.3-1.7.5-.3 1.3-.5 2.2-.5 3.4 0 10.6 2.7 15.5 9.9 3.6 5.3 3.6 10.8.1 16 18.3 4.7 30.1 15.6 39.5 24.4 2.5 2.4 5 4.6 7.3 6.5 10.7 8.9 21.4 13.2 32.7 13.2.9 0 1.8 0 2.7-.1 2-13.5-4.1-25.5-10-35.7-6.2-10.7-6.4-12.1-4.9-13.9l.1-.1c.6-.7 1.3-1 2.1-1.1h.3c1.7 0 4.5 1 13 8.7 9.9 9 16.9 22.2 19.2 36.5 8.9-4.9 15.2-12.5 17.1-20.3 2-8.6.5-16.8-4.2-22.7l-13.1 6.1-7-16.9-15.6 3.2 7.2-19.5h.1l-.2-.5 4.6-11.1 65.7 11.9c3.1.6 3.9 2.7 3.6 4.5l-.2 1-.4-.1c-.3.4-.6.7-1 .9-.7.3-7.5 3.6-21 9.9 2.1 2.9 2.2 6.1.4 9.4-1.1 1.9-2.5 6.2.4 13.3 3.7 8.9 3.5 29.2-8.3 46.2-8.1 11.7-18.3 23-37.6 26.6-3 4.6-6.5 9-10.2 12.6-7.8 7.6-24 15.3-42.3 15.3-5.3 0-10.7-.6-15.9-1.9-7.6 12.9-10.5 26.2-10.6 32.1-.2 11 .9 16.1.9 16.2l.4 1.8-164.9.8.9-2.3z" clip-path="url(#b)"/><path class="st53" d="M339.8 1028.8c.1-.3 13.9-30.1 27.5-59.1-6.1-9.2-2.1-16.8 1.4-23.5 2-3.7 3.8-7.3 3.7-10.6-.8-21.7 8-37.9 22.4-41 2.7-.6 5.1-.9 7.4-.9 3 0 5.7.5 8.2 1.7 1.6-1.6 3.2-3 4.9-4.4-3-2.6-4.6-5.5-4.8-8.8-.3-4.4 2.1-9.2 6.6-13.3 5.3-4.8 16.4-12.8 31.4-12.8 1.1 0 2.2 0 3.3.1-.3-1.1-1-2.7-2.5-3.8-1.8-1.4-1.7-2.8-1.5-3.4.6-1.8 3.4-2.9 7.6-2.9 4.4 0 10.3 1.5 14.3 4.8.1-.9.6-1.7 1.3-2.1.4-.3 1.1-.4 1.9-.4 3.3 0 10.3 2.6 15.1 9.7 3.6 5.3 3.5 10.9-.3 16.1 18.6 4.6 30.5 15.6 40 24.4 2.6 2.4 5 4.6 7.3 6.5 10.8 8.9 21.6 13.3 33.1 13.3 1 0 2.1 0 3.1-.1 2.2-13.9-4-26.1-10-36.4-6.3-10.9-6.2-11.8-5-13.3l.1-.1c.2-.2.7-.9 1.8-.9h.2c1.5 0 4.2 1 12.7 8.6 10 9.1 17 22.5 19.2 36.9 9.3-5 16-12.8 17.9-20.9 2.1-8.9.4-17.4-4.5-23.4l-13 6.1-6.9-16.8-15.1 3.1 6.8-18.4h.6l-.5-1 4.4-10.6 65.3 11.8c3.5.7 3.3 3.2 3.2 3.9l-.1.5h-.2c-.2.4-.6.8-1 1-.7.3-7.9 3.7-21.6 10.2.1.1.2.2.3.4 2 2.7 2.2 5.8.4 8.9-1.1 2-2.6 6.4.4 13.7 3.6 8.8 3.4 28.8-8.2 45.7-8.1 11.7-18.2 22.9-37.5 26.4-3 4.7-6.5 9.1-10.3 12.7-7.8 7.5-23.7 15.1-42 15.1-5.4 0-10.9-.7-16.1-2-7.7 13.1-10.7 26.7-10.9 32.7-.2 11.1.9 16.2.9 16.3l.3 1.2-163.5.8.5-1.7z" clip-path="url(#b)"/><path d="M577.5 843.7l-1-2.1 3.9-9.4 64.5 11.6c2.4.5 2.5 2 2.4 2.8" clip-path="url(#b)" fill="#fff" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10"/><path d="M584.2 856.7l6.9 16.6s53.7-25.1 55.5-26c.9-.5 1.5-2.2-1-2.3-2.5-.1-69.6-1.4-69.6-1.4l-5.9 16 14.1-2.9z" clip-path="url(#b)" fill="#fff" stroke="#000" stroke-width="1.70000005" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10"/><path d="M585.1 857l58.9-11.2 1.4-.5h.4c.8 0 .7.3.8.6.1.3-.1.6-.4.8L587 861.5l-1.9-4.5z" clip-path="url(#b)" fill="#d1d3d4"/><path class="st57" clip-path="url(#b)" fill="none" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" d="M584.20001 856.70001l61.79999-11.5"/><path class="st58" d="M388.5 927.9c20-40 49.9-56.3 83.4-54.9 33.6 1.4 48.8 21.7 62.4 32.9 13.6 11.2 27.5 15.6 43.4 12.5 15.9-3.1 28.5-14.6 31.2-26.1 2.7-11.5-1-20.7-6.1-25.8-5.1-5.1-1.4-8.1 4.1-6.8 5.4 1.4 8.1 3.4 8.1 3.4s7.8-6.4 9.8-3.7c2 2.7 1.7 5.4.3 7.8-1.4 2.4-2.7 7.1.3 14.6 3.1 7.5 4.1 27.1-8.1 44.8-12.2 17.6-26.5 30.1-62.1 26.5-38.5-3.9-54.6 44.2-54.9 59-.2 11.5.9 16.5.9 16.5l-160.7.8c.2-.1 33.4-72.4 48-101.5z" clip-path="url(#b)" fill="#53606c" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10"/><path class="st59" d="M399.4 989.3c-6.1 0-9.9-4.3-10-4.3l-.2-.2-.3.1c-3.1 1-6.1 1.5-9 1.5-6.1 0-11.4-2.2-15.6-6.5 1.4-3 2.9-6.1 4.3-9.1.1.1.1.2.2.3 6.1 8 12.7 9.7 17 9.7 3.2 0 5.6-.9 6.6-1.3.7 1.3 3 4.7 7.5 4.7 1.2 0 2.5-.3 3.9-.8 6.7-2.5 15.7-13.1 18.3-22.1 3.2 5.7-2.8 18.3-13.8 25.3-3 1.7-6 2.7-8.9 2.7zm24.5-78.1c-5.7-11.1-10.8-14.2-12.4-14.9 15.7-15.3 34.7-23 56.6-23 1.3 0 2.5 0 3.8.1 2.1.1 4.2.3 6.4.5 1.5 2.7-.4 5.6-.8 6.3-3 .8-11.2 6.5-18.5 11.6-1.9 1.3-3.7 2.6-5.3 3.7-5.3 3.7-13.6 4.2-18 4.2-1.8 0-2.9-.1-2.9-.1h-.3l-8.6 11.6zm95.3 49c4.2-7.1 12.6-15.2 28.3-15.2 3.5 0 7.2.4 11 1.2 3.4.7 7 1.1 10.6 1.1 15.9 0 29.8-7.9 34.8-12.5 6.5-6 10.5-9.8 12-12.4 1.4-2.4 3.2-2.8 4.7-2 .1.1-.7 1.1-.6 1.1-.9 1.7-1.9 3.3-3 4.8-11.5 16.6-23.9 26.9-50 26.9-3.6 0-7.5-.2-11.6-.6-1.5-.2-3-.2-4.4-.2-9.4 0-18.1 3.2-25.9 9.4l-5.9-1.6z" clip-path="url(#b)" fill="#38434f"/><path class="st58" d="M427.4 920.5c-9.1-24.2-19-27.9-32.4-25-13.4 3-22.4 18-21.6 40 .4 10.2-15.5 20.5-4.3 35s23.5 8 23.5 8 3 7.1 11 4.1 16.7-13.5 18.5-21.6" clip-path="url(#b)" fill="#53606c" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10"/><path class="st59" d="M417.2 934.2c-7.3.5-8.6-4.8-7.3-7 1.2-2.1 4.6-3.2 4.6-3.2s-5.3-.2-6.3-4c-1-3.7 4-5.3 4-5.3s-2.9-8.6-13.3-6.1-14.4 11.7-14.2 19c.6 15.3-10.2 22.8 0 29.7 10.1 6.9 23.7-2.8 28.5-11.3 4.8-8.5 4-11.8 4-11.8z" clip-path="url(#b)" fill="#38434f"/><path class="st57" d="M392.6 978.6c6.3-3.9 9.1-7.5 9.1-7.5m15.5-36.9c-7.3.5-8.6-4.8-7.3-7 1.2-2.1 4.6-3.2 4.6-3.2s-5.3-.2-6.3-4c-1-3.7 4-5.3 4-5.3s-2.9-8.6-13.3-6.1-14.6 11.7-14.2 19" clip-path="url(#b)" fill="none" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10"/><path d="M417.5 891.4c-9.8-6.9-6.2-16 .3-21.8 6.5-5.8 19-14 35.2-12.2 0 0-.2-3.5-3.1-5.8s-.5-4.5 5.5-4.5 12.9 2.6 15.8 6.9c0 0-1.4-3.2.4-4.4 1.8-1.2 10.2 1 15.6 9s1.1 14.9-5.2 19.9c-6.3 5-36.2 11.6-48.8 10.9" clip-path="url(#b)" fill="#53606c"/><g class="st53" clip-path="url(#b)"><path class="st36" d="M459.5 878.7c-11.1.6-22.1 3.3-32.9 6.1-4.1 1.1-8.2 2.2-12.4 2-.4 0-.8-.1-1.3-.1 1 1.6 2.5 3.2 4.6 4.7l15.6-2.1c11.3.7 37-4.7 46.4-9.4-6.5-1.1-13.3-1.5-20-1.2z" fill="#38434f"/></g><path class="st59" d="M417.5 890.8c-3.5-2.5-5.3-5.5-5.5-8.8-.2-3.2 1.2-6.7 4-9.9-.4 1.6-.2 3.2.5 4.7 1.2 2.5 3.6 4 6.3 4 2.1 0 4.3-.9 6.3-2.6 7.2-6 19.4-10.4 29.1-10.4 1.9 0 3.7.2 5.3.5 7.7 1.6 12.3 3.7 13.8 6.7.8 1.6.8 3.3 0 5.4-10.1 4.3-31.2 8.6-42.1 8.6-.7 0-1.4 0-2.1-.1-1.4-.1-2.8-.1-4-.1-7.5-.1-10.6 1.4-11.6 2z" clip-path="url(#b)" fill="#38434f"/><path class="st57" d="M471.2 854c2.2 3.4 1.1 6.7 1.1 6.7" clip-path="url(#b)" fill="none" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10"/><path d="M489.6 967.9c34.6 23.5 68.7 8.7 80.2-2.4 11.4-11.1 20.8-28.5 20-45.9-.8-17.4-8.5-32.9-19.5-42.8-10.7-9.6-12.4-8.7-13.2-7.7-.8 1-1.4 1.1 5 12.2s13.4 24.6 9.5 40.2c-3.9 15.6-15.6 29.9-29.6 34-14 4-21.6-1.4-26.1-3.2 0 0 1 4-3.4 4.3-4.3.3-11.1-2.9-11.1-2.9s2.7 3 .1 5-5.9-.9-5.9-.9 2.5 2.2 1.5 3.3c-1 1.1-2.3.8-4.2-.6 0 0 3.2 3.6 1 5.5s-4.3 1.9-4.3 1.9z" clip-path="url(#b)" fill="#b3becd" stroke="#000" stroke-width="1.60000002" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10"/><path d="M528.6 979.9c-13.1 0-25.8-3.9-37.6-11.7.9-.2 2.1-.7 3.3-1.8 1.4-1.3 1-3 .3-4.3.4.1.8.2 1.1.2.7 0 1.3-.3 1.8-.8.3-.3.4-.6.4-1 0-.3-.1-.6-.2-.9.6.2 1.2.4 1.9.4.9 0 1.7-.3 2.4-.8.3-.2.5-.5.7-.8 12.6 6.2 22.6 9.1 31.5 9.1 7.8 0 14.7-2.3 21.1-6.9 16.8-12.3 21.3-21.3 24.6-27.9l.3-.6c2.3-4.5 4.2-6.5 6.4-6.5.9 0 1.7.3 2.7.9-1.4 13.6-8.7 28-19.6 38.6-7.9 7.4-23.4 14.8-41.1 14.8z" clip-path="url(#b)" fill="#92a1b5"/><path class="st57" d="M489.6 967.9c-3.9.1-6.7-.9-6.7-.9" clip-path="url(#b)" fill="none" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10"/><path d="M484 908.3c-2.2.9-2.1 3.2.1 6.7 2.1 3.5 4.5 7.9 5.9 10.3 1.4 2.4 2.6 3.5 4 2.8 1.4-.6 1.8-2 .4-4.9-1.4-2.9-5.8-11-7.2-12.7-1.6-1.7-2.4-2.5-3.2-2.2z" clip-path="url(#b)" fill="#38434f" stroke="#000" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10"/><path class="st57" d="M525.9 907.8c2.5-3.7 4.8-5.2 4.8-5.2m3.8 11.9c1.5-3.1 3.7-5.6 3.7-5.6m11.9 13.3c1-3.9 2.7-5.6 2.7-5.6m10.7 9c.6-4.6.9-6.6.9-6.6" clip-path="url(#b)" fill="none" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10"/><g class="st53" clip-path="url(#b)"><path class="st49" d="M504.9 862.2c.8-.4 1.5-.8 2.2-1.1" fill="none" stroke="#e3e5e5" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/><path d="M519.8 856.2c13.6-3.5 22.1.5 28.2 3.3 5.3 2.4 11.6 4.7 17.6 5.3" fill="none" stroke="#e3e5e5" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" stroke-dasharray="4.5286,13.5857"/><path class="st49" d="M572.4 864.5c.8-.2 1.6-.4 2.4-.7" fill="none" stroke="#e3e5e5" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/></g><g class="st53" clip-path="url(#b)"><path d="M491.2 946.7c-4.1 1.1-6.1 1.8-10 3.5-2.1.9-4.6.1-5.6-1.9s0-4.6 2.3-5.6c4.3-1.9 6.6-2.7 11.2-3.9 2.4-.6 4.8.8 5.3 2.9.4 2.2-1 4.4-3.2 5z" fill="#505762"/></g><path class="st57" d="M515.8 952.3c-.2-2.3-1.7-3.3-1.7-3.3" clip-path="url(#b)" fill="none" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10"/><g class="st53" clip-path="url(#b)"><path d="M454.5 887.5c-3.5.7-7.1 1.3-10.6 1.7-3.6.4-7.2.8-10.8.6-.3 0-.5-.2-.5-.5s.2-.5.5-.5c3.5.2 7.1-.1 10.6-.4 3.5-.4 7.1-.9 10.6-1.6.2 0 .4.1.4.3.1.2 0 .4-.2.4z"/></g><g class="st53" clip-path="url(#b)"><path d="M417.2 891.8c-2.2-1.6-4.3-3.6-5.4-6.2l-.4-1-.2-1-.1-.5v-.5c0-.4-.1-.7-.1-1.1 0-1.4.2-2.8.7-4.1.9-2.6 2.6-4.9 4.4-6.9 1.9-2 4-3.6 6.2-5.2s4.6-2.9 7-4.1c2.4-1.2 5-2.2 7.6-2.9 5.2-1.5 10.7-1.9 16.1-1.4l-.6.5c-.2-1.3-.7-2.7-1.4-3.8-.4-.6-.8-1.1-1.4-1.5-.5-.5-1.2-1-1.5-1.9-.1-.2-.1-.5-.1-.7.1-.2.1-.5.2-.7.2-.4.6-.7.9-.9.7-.5 1.4-.7 2.2-.9 1.5-.3 3-.4 4.4-.4 2.9.1 5.9.7 8.6 1.8 2.7 1.1 5.3 2.8 7.1 5.3l-.9.5c-.3-.7-.5-1.3-.5-2.1-.1-.7 0-1.5.4-2.3.3-.3.5-.7 1-.8.2-.1.4-.1.6-.2.2 0 .4-.1.6-.1.7 0 1.4.1 2.1.2 1.4.3 2.6.8 3.9 1.4 1.2.6 2.4 1.3 3.5 2.1 2.2 1.6 4.1 3.6 5.6 5.9.7 1.2 1.4 2.4 1.8 3.7.3.6.4 1.3.5 2 .1.3.1.7.1 1v1c-.1 2.8-1.3 5.5-3 7.6-1.7 2.2-3.6 4-5.9 5.6-2.4 1.4-4.9 2.3-7.4 3.2-2.5.8-5.1 1.5-7.8 1.9 1.2-.5 2.5-.9 3.7-1.4l3.7-1.3c2.5-.9 5-1.8 7.2-3.2 2.1-1.5 4-3.3 5.6-5.4 1.5-2.1 2.6-4.5 2.7-7 .1-2.5-.8-5-2.2-7.1-1.4-2.2-3.2-4.1-5.3-5.6-2.1-1.5-4.4-2.8-6.9-3.4-.6-.1-1.2-.2-1.8-.2-.6 0-1 .1-1.3.5-.5.8-.2 2.3.2 3.4.1.3 0 .5-.3.6-.2.1-.4 0-.6-.2-1.6-2.3-4-3.8-6.6-4.9-2.6-1.1-5.4-1.7-8.2-1.7-1.4 0-2.8.1-4.2.4-.7.2-1.3.4-1.8.7-.5.4-.8.8-.7 1.3.1.5.6.9 1.2 1.4.6.5 1.1 1.1 1.5 1.7.9 1.3 1.4 2.7 1.6 4.3 0 .3-.2.5-.4.5h-.1c-5.3-.5-10.7-.1-15.8 1.4-2.5.7-5 1.7-7.4 2.8-2.4 1.2-4.7 2.5-6.8 4.1-2.2 1.5-4.3 3.2-6.1 5.1-1.8 1.9-3.4 4.1-4.2 6.5-.4 1.2-.6 2.5-.7 3.8 0 .3.1.6.1 1v.5l.1.5.2.9.4.9c1 2.4 2.9 4.3 5 5.8.2.2.3.5.1.7-.2.2-.4.3-.7.1z"/></g><g class="st53" clip-path="url(#b)"><path class="st36" d="M615.1 863.2c2 1.2 2.3 1.8 2.3 1.8" fill="#38434f"/><path d="M615.3 862.9l1.2.9c.2.2.4.3.6.5.2.2.3.4.5.6 0 .1 0 .2-.1.2h-.2l-.5-.5c-.2-.1-.4-.3-.6-.4-.4-.3-.8-.5-1.3-.7-.2-.1-.2-.3-.1-.5s.2-.2.5-.1c-.1 0-.1 0 0 0z"/></g></g></svg>
\ No newline at end of file
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 252.85863 194.49997" width="252.85863" height="194.49997"><g transform="translate(-395.89999 -830.4)"><defs><path id="a" d="M395.89999 745.09998H690.5V1024.9H395.89999z"/></defs><clipPath id="b"><use xlink:href="#a" width="100%" height="100%" overflow="visible"/></clipPath><path class="st53" d="M339.3 1028.6c1.5-3.2 14.4-31.3 27.4-58.8-6-9.3-2-17 1.5-23.7 1.9-3.7 3.8-7.1 3.6-10.4-.8-22 8.1-38.3 22.8-41.6 2.8-.6 5.2-.9 7.5-.9 3 0 5.6.5 8.1 1.6 1.4-1.3 2.8-2.6 4.2-3.8-2.8-2.6-4.3-5.5-4.5-8.8-.3-4.5 2.2-9.5 6.8-13.7 5.3-4.8 16.5-12.9 31.7-12.9.9 0 1.7 0 2.6.1-.4-.9-1-2-2.1-2.9-2.1-1.6-1.9-3.2-1.6-4 .7-2.1 3.6-3.2 8.1-3.2 3.9 0 9.7 1.2 14 4.4.3-.7.7-1.3 1.3-1.7.5-.3 1.3-.5 2.2-.5 3.4 0 10.6 2.7 15.5 9.9 3.6 5.3 3.6 10.8.1 16 18.3 4.7 30.1 15.6 39.5 24.4 2.5 2.4 5 4.6 7.3 6.5 10.7 8.9 21.4 13.2 32.7 13.2.9 0 1.8 0 2.7-.1 2-13.5-4.1-25.5-10-35.7-6.2-10.7-6.4-12.1-4.9-13.9l.1-.1c.6-.7 1.3-1 2.1-1.1h.3c1.7 0 4.5 1 13 8.7 9.9 9 16.9 22.2 19.2 36.5 8.9-4.9 15.2-12.5 17.1-20.3 2-8.6.5-16.8-4.2-22.7l-13.1 6.1-7-16.9-15.6 3.2 7.2-19.5h.1l-.2-.5 4.6-11.1 65.7 11.9c3.1.6 3.9 2.7 3.6 4.5l-.2 1-.4-.1c-.3.4-.6.7-1 .9-.7.3-7.5 3.6-21 9.9 2.1 2.9 2.2 6.1.4 9.4-1.1 1.9-2.5 6.2.4 13.3 3.7 8.9 3.5 29.2-8.3 46.2-8.1 11.7-18.3 23-37.6 26.6-3 4.6-6.5 9-10.2 12.6-7.8 7.6-24 15.3-42.3 15.3-5.3 0-10.7-.6-15.9-1.9-7.6 12.9-10.5 26.2-10.6 32.1-.2 11 .9 16.1.9 16.2l.4 1.8-164.9.8.9-2.3z" clip-path="url(#b)"/><path class="st53" d="M339.8 1028.8c.1-.3 13.9-30.1 27.5-59.1-6.1-9.2-2.1-16.8 1.4-23.5 2-3.7 3.8-7.3 3.7-10.6-.8-21.7 8-37.9 22.4-41 2.7-.6 5.1-.9 7.4-.9 3 0 5.7.5 8.2 1.7 1.6-1.6 3.2-3 4.9-4.4-3-2.6-4.6-5.5-4.8-8.8-.3-4.4 2.1-9.2 6.6-13.3 5.3-4.8 16.4-12.8 31.4-12.8 1.1 0 2.2 0 3.3.1-.3-1.1-1-2.7-2.5-3.8-1.8-1.4-1.7-2.8-1.5-3.4.6-1.8 3.4-2.9 7.6-2.9 4.4 0 10.3 1.5 14.3 4.8.1-.9.6-1.7 1.3-2.1.4-.3 1.1-.4 1.9-.4 3.3 0 10.3 2.6 15.1 9.7 3.6 5.3 3.5 10.9-.3 16.1 18.6 4.6 30.5 15.6 40 24.4 2.6 2.4 5 4.6 7.3 6.5 10.8 8.9 21.6 13.3 33.1 13.3 1 0 2.1 0 3.1-.1 2.2-13.9-4-26.1-10-36.4-6.3-10.9-6.2-11.8-5-13.3l.1-.1c.2-.2.7-.9 1.8-.9h.2c1.5 0 4.2 1 12.7 8.6 10 9.1 17 22.5 19.2 36.9 9.3-5 16-12.8 17.9-20.9 2.1-8.9.4-17.4-4.5-23.4l-13 6.1-6.9-16.8-15.1 3.1 6.8-18.4h.6l-.5-1 4.4-10.6 65.3 11.8c3.5.7 3.3 3.2 3.2 3.9l-.1.5h-.2c-.2.4-.6.8-1 1-.7.3-7.9 3.7-21.6 10.2.1.1.2.2.3.4 2 2.7 2.2 5.8.4 8.9-1.1 2-2.6 6.4.4 13.7 3.6 8.8 3.4 28.8-8.2 45.7-8.1 11.7-18.2 22.9-37.5 26.4-3 4.7-6.5 9.1-10.3 12.7-7.8 7.5-23.7 15.1-42 15.1-5.4 0-10.9-.7-16.1-2-7.7 13.1-10.7 26.7-10.9 32.7-.2 11.1.9 16.2.9 16.3l.3 1.2-163.5.8.5-1.7z" clip-path="url(#b)"/><path d="M577.5 843.7l-1-2.1 3.9-9.4 64.5 11.6c2.4.5 2.5 2 2.4 2.8" clip-path="url(#b)" stroke-miterlimit="10" fill="#fff" stroke="#000" stroke-linecap="round" stroke-linejoin="round"/><path d="M584.2 856.7l6.9 16.6s53.7-25.1 55.5-26c.9-.5 1.5-2.2-1-2.3-2.5-.1-69.6-1.4-69.6-1.4l-5.9 16 14.1-2.9z" clip-path="url(#b)" stroke-miterlimit="10" fill="#fff" stroke="#000" stroke-width="1.70000005" stroke-linecap="round" stroke-linejoin="round"/><path d="M585.1 857l58.9-11.2 1.4-.5h.4c.8 0 .7.3.8.6.1.3-.1.6-.4.8L587 861.5l-1.9-4.5z" clip-path="url(#b)" fill="#d1d3d4"/><path class="st57" clip-path="url(#b)" stroke-miterlimit="10" d="M584.20001 856.70001l61.79999-11.5" fill="none" stroke="#000" stroke-linecap="round" stroke-linejoin="round"/><path class="st58" d="M388.5 927.9c20-40 49.9-56.3 83.4-54.9 33.6 1.4 48.8 21.7 62.4 32.9 13.6 11.2 27.5 15.6 43.4 12.5 15.9-3.1 28.5-14.6 31.2-26.1 2.7-11.5-1-20.7-6.1-25.8-5.1-5.1-1.4-8.1 4.1-6.8 5.4 1.4 8.1 3.4 8.1 3.4s7.8-6.4 9.8-3.7c2 2.7 1.7 5.4.3 7.8-1.4 2.4-2.7 7.1.3 14.6 3.1 7.5 4.1 27.1-8.1 44.8-12.2 17.6-26.5 30.1-62.1 26.5-38.5-3.9-54.6 44.2-54.9 59-.2 11.5.9 16.5.9 16.5l-160.7.8c.2-.1 33.4-72.4 48-101.5z" clip-path="url(#b)" stroke-miterlimit="10" fill="#53606c" stroke="#000" stroke-linecap="round" stroke-linejoin="round"/><path class="st59" d="M399.4 989.3c-6.1 0-9.9-4.3-10-4.3l-.2-.2-.3.1c-3.1 1-6.1 1.5-9 1.5-6.1 0-11.4-2.2-15.6-6.5 1.4-3 2.9-6.1 4.3-9.1.1.1.1.2.2.3 6.1 8 12.7 9.7 17 9.7 3.2 0 5.6-.9 6.6-1.3.7 1.3 3 4.7 7.5 4.7 1.2 0 2.5-.3 3.9-.8 6.7-2.5 15.7-13.1 18.3-22.1 3.2 5.7-2.8 18.3-13.8 25.3-3 1.7-6 2.7-8.9 2.7zm24.5-78.1c-5.7-11.1-10.8-14.2-12.4-14.9 15.7-15.3 34.7-23 56.6-23 1.3 0 2.5 0 3.8.1 2.1.1 4.2.3 6.4.5 1.5 2.7-.4 5.6-.8 6.3-3 .8-11.2 6.5-18.5 11.6-1.9 1.3-3.7 2.6-5.3 3.7-5.3 3.7-13.6 4.2-18 4.2-1.8 0-2.9-.1-2.9-.1h-.3l-8.6 11.6zm95.3 49c4.2-7.1 12.6-15.2 28.3-15.2 3.5 0 7.2.4 11 1.2 3.4.7 7 1.1 10.6 1.1 15.9 0 29.8-7.9 34.8-12.5 6.5-6 10.5-9.8 12-12.4 1.4-2.4 3.2-2.8 4.7-2 .1.1-.7 1.1-.6 1.1-.9 1.7-1.9 3.3-3 4.8-11.5 16.6-23.9 26.9-50 26.9-3.6 0-7.5-.2-11.6-.6-1.5-.2-3-.2-4.4-.2-9.4 0-18.1 3.2-25.9 9.4l-5.9-1.6z" clip-path="url(#b)" fill="#38434f"/><path class="st58" d="M427.4 920.5c-9.1-24.2-19-27.9-32.4-25-13.4 3-22.4 18-21.6 40 .4 10.2-15.5 20.5-4.3 35s23.5 8 23.5 8 3 7.1 11 4.1 16.7-13.5 18.5-21.6" clip-path="url(#b)" stroke-miterlimit="10" fill="#53606c" stroke="#000" stroke-linecap="round" stroke-linejoin="round"/><path class="st59" d="M417.2 934.2c-7.3.5-8.6-4.8-7.3-7 1.2-2.1 4.6-3.2 4.6-3.2s-5.3-.2-6.3-4c-1-3.7 4-5.3 4-5.3s-2.9-8.6-13.3-6.1-14.4 11.7-14.2 19c.6 15.3-10.2 22.8 0 29.7 10.1 6.9 23.7-2.8 28.5-11.3 4.8-8.5 4-11.8 4-11.8z" clip-path="url(#b)" fill="#38434f"/><path class="st57" d="M392.6 978.6c6.3-3.9 9.1-7.5 9.1-7.5m15.5-36.9c-7.3.5-8.6-4.8-7.3-7 1.2-2.1 4.6-3.2 4.6-3.2s-5.3-.2-6.3-4c-1-3.7 4-5.3 4-5.3s-2.9-8.6-13.3-6.1-14.6 11.7-14.2 19" clip-path="url(#b)" stroke-miterlimit="10" fill="none" stroke="#000" stroke-linecap="round" stroke-linejoin="round"/><path d="M417.5 891.4c-9.8-6.9-6.2-16 .3-21.8 6.5-5.8 19-14 35.2-12.2 0 0-.2-3.5-3.1-5.8s-.5-4.5 5.5-4.5 12.9 2.6 15.8 6.9c0 0-1.4-3.2.4-4.4 1.8-1.2 10.2 1 15.6 9s1.1 14.9-5.2 19.9c-6.3 5-36.2 11.6-48.8 10.9" clip-path="url(#b)" fill="#53606c"/><g class="st53" clip-path="url(#b)"><path class="st36" d="M459.5 878.7c-11.1.6-22.1 3.3-32.9 6.1-4.1 1.1-8.2 2.2-12.4 2-.4 0-.8-.1-1.3-.1 1 1.6 2.5 3.2 4.6 4.7l15.6-2.1c11.3.7 37-4.7 46.4-9.4-6.5-1.1-13.3-1.5-20-1.2z" fill="#38434f"/></g><path class="st59" d="M417.5 890.8c-3.5-2.5-5.3-5.5-5.5-8.8-.2-3.2 1.2-6.7 4-9.9-.4 1.6-.2 3.2.5 4.7 1.2 2.5 3.6 4 6.3 4 2.1 0 4.3-.9 6.3-2.6 7.2-6 19.4-10.4 29.1-10.4 1.9 0 3.7.2 5.3.5 7.7 1.6 12.3 3.7 13.8 6.7.8 1.6.8 3.3 0 5.4-10.1 4.3-31.2 8.6-42.1 8.6-.7 0-1.4 0-2.1-.1-1.4-.1-2.8-.1-4-.1-7.5-.1-10.6 1.4-11.6 2z" clip-path="url(#b)" fill="#38434f"/><path class="st57" d="M471.2 854c2.2 3.4 1.1 6.7 1.1 6.7" clip-path="url(#b)" stroke-miterlimit="10" fill="none" stroke="#000" stroke-linecap="round" stroke-linejoin="round"/><path d="M489.6 967.9c34.6 23.5 68.7 8.7 80.2-2.4 11.4-11.1 20.8-28.5 20-45.9-.8-17.4-8.5-32.9-19.5-42.8-10.7-9.6-12.4-8.7-13.2-7.7-.8 1-1.4 1.1 5 12.2s13.4 24.6 9.5 40.2c-3.9 15.6-15.6 29.9-29.6 34-14 4-21.6-1.4-26.1-3.2 0 0 1 4-3.4 4.3-4.3.3-11.1-2.9-11.1-2.9s2.7 3 .1 5-5.9-.9-5.9-.9 2.5 2.2 1.5 3.3c-1 1.1-2.3.8-4.2-.6 0 0 3.2 3.6 1 5.5s-4.3 1.9-4.3 1.9z" clip-path="url(#b)" stroke-miterlimit="10" fill="#b3becd" stroke="#000" stroke-width="1.60000002" stroke-linecap="round" stroke-linejoin="round"/><path d="M528.6 979.9c-13.1 0-25.8-3.9-37.6-11.7.9-.2 2.1-.7 3.3-1.8 1.4-1.3 1-3 .3-4.3.4.1.8.2 1.1.2.7 0 1.3-.3 1.8-.8.3-.3.4-.6.4-1 0-.3-.1-.6-.2-.9.6.2 1.2.4 1.9.4.9 0 1.7-.3 2.4-.8.3-.2.5-.5.7-.8 12.6 6.2 22.6 9.1 31.5 9.1 7.8 0 14.7-2.3 21.1-6.9 16.8-12.3 21.3-21.3 24.6-27.9l.3-.6c2.3-4.5 4.2-6.5 6.4-6.5.9 0 1.7.3 2.7.9-1.4 13.6-8.7 28-19.6 38.6-7.9 7.4-23.4 14.8-41.1 14.8z" clip-path="url(#b)" fill="#92a1b5"/><path class="st57" d="M489.6 967.9c-3.9.1-6.7-.9-6.7-.9" clip-path="url(#b)" stroke-miterlimit="10" fill="none" stroke="#000" stroke-linecap="round" stroke-linejoin="round"/><path d="M484 908.3c-2.2.9-2.1 3.2.1 6.7 2.1 3.5 4.5 7.9 5.9 10.3 1.4 2.4 2.6 3.5 4 2.8 1.4-.6 1.8-2 .4-4.9-1.4-2.9-5.8-11-7.2-12.7-1.6-1.7-2.4-2.5-3.2-2.2z" clip-path="url(#b)" stroke-miterlimit="10" fill="#38434f" stroke="#000" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path class="st57" d="M525.9 907.8c2.5-3.7 4.8-5.2 4.8-5.2m3.8 11.9c1.5-3.1 3.7-5.6 3.7-5.6m11.9 13.3c1-3.9 2.7-5.6 2.7-5.6m10.7 9c.6-4.6.9-6.6.9-6.6" clip-path="url(#b)" stroke-miterlimit="10" fill="none" stroke="#000" stroke-linecap="round" stroke-linejoin="round"/><g class="st53" clip-path="url(#b)"><path class="st49" d="M504.9 862.2c.8-.4 1.5-.8 2.2-1.1" fill="none" stroke="#e3e5e5" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/><path d="M519.8 856.2c13.6-3.5 22.1.5 28.2 3.3 5.3 2.4 11.6 4.7 17.6 5.3" fill="none" stroke="#e3e5e5" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" stroke-dasharray="4.5286,13.5857"/><path class="st49" d="M572.4 864.5c.8-.2 1.6-.4 2.4-.7" fill="none" stroke="#e3e5e5" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/></g><g class="st53" clip-path="url(#b)"><path d="M491.2 946.7c-4.1 1.1-6.1 1.8-10 3.5-2.1.9-4.6.1-5.6-1.9s0-4.6 2.3-5.6c4.3-1.9 6.6-2.7 11.2-3.9 2.4-.6 4.8.8 5.3 2.9.4 2.2-1 4.4-3.2 5z" fill="#505762"/></g><path class="st57" d="M515.8 952.3c-.2-2.3-1.7-3.3-1.7-3.3" clip-path="url(#b)" stroke-miterlimit="10" fill="none" stroke="#000" stroke-linecap="round" stroke-linejoin="round"/><g class="st53" clip-path="url(#b)"><path d="M454.5 887.5c-3.5.7-7.1 1.3-10.6 1.7-3.6.4-7.2.8-10.8.6-.3 0-.5-.2-.5-.5s.2-.5.5-.5c3.5.2 7.1-.1 10.6-.4 3.5-.4 7.1-.9 10.6-1.6.2 0 .4.1.4.3.1.2 0 .4-.2.4z"/></g><g class="st53" clip-path="url(#b)"><path d="M417.2 891.8c-2.2-1.6-4.3-3.6-5.4-6.2l-.4-1-.2-1-.1-.5v-.5c0-.4-.1-.7-.1-1.1 0-1.4.2-2.8.7-4.1.9-2.6 2.6-4.9 4.4-6.9 1.9-2 4-3.6 6.2-5.2s4.6-2.9 7-4.1c2.4-1.2 5-2.2 7.6-2.9 5.2-1.5 10.7-1.9 16.1-1.4l-.6.5c-.2-1.3-.7-2.7-1.4-3.8-.4-.6-.8-1.1-1.4-1.5-.5-.5-1.2-1-1.5-1.9-.1-.2-.1-.5-.1-.7.1-.2.1-.5.2-.7.2-.4.6-.7.9-.9.7-.5 1.4-.7 2.2-.9 1.5-.3 3-.4 4.4-.4 2.9.1 5.9.7 8.6 1.8 2.7 1.1 5.3 2.8 7.1 5.3l-.9.5c-.3-.7-.5-1.3-.5-2.1-.1-.7 0-1.5.4-2.3.3-.3.5-.7 1-.8.2-.1.4-.1.6-.2.2 0 .4-.1.6-.1.7 0 1.4.1 2.1.2 1.4.3 2.6.8 3.9 1.4 1.2.6 2.4 1.3 3.5 2.1 2.2 1.6 4.1 3.6 5.6 5.9.7 1.2 1.4 2.4 1.8 3.7.3.6.4 1.3.5 2 .1.3.1.7.1 1v1c-.1 2.8-1.3 5.5-3 7.6-1.7 2.2-3.6 4-5.9 5.6-2.4 1.4-4.9 2.3-7.4 3.2-2.5.8-5.1 1.5-7.8 1.9 1.2-.5 2.5-.9 3.7-1.4l3.7-1.3c2.5-.9 5-1.8 7.2-3.2 2.1-1.5 4-3.3 5.6-5.4 1.5-2.1 2.6-4.5 2.7-7 .1-2.5-.8-5-2.2-7.1-1.4-2.2-3.2-4.1-5.3-5.6-2.1-1.5-4.4-2.8-6.9-3.4-.6-.1-1.2-.2-1.8-.2-.6 0-1 .1-1.3.5-.5.8-.2 2.3.2 3.4.1.3 0 .5-.3.6-.2.1-.4 0-.6-.2-1.6-2.3-4-3.8-6.6-4.9-2.6-1.1-5.4-1.7-8.2-1.7-1.4 0-2.8.1-4.2.4-.7.2-1.3.4-1.8.7-.5.4-.8.8-.7 1.3.1.5.6.9 1.2 1.4.6.5 1.1 1.1 1.5 1.7.9 1.3 1.4 2.7 1.6 4.3 0 .3-.2.5-.4.5h-.1c-5.3-.5-10.7-.1-15.8 1.4-2.5.7-5 1.7-7.4 2.8-2.4 1.2-4.7 2.5-6.8 4.1-2.2 1.5-4.3 3.2-6.1 5.1-1.8 1.9-3.4 4.1-4.2 6.5-.4 1.2-.6 2.5-.7 3.8 0 .3.1.6.1 1v.5l.1.5.2.9.4.9c1 2.4 2.9 4.3 5 5.8.2.2.3.5.1.7-.2.2-.4.3-.7.1z"/></g><g class="st53" clip-path="url(#b)"><path class="st36" d="M615.1 863.2c2 1.2 2.3 1.8 2.3 1.8" fill="#38434f"/><path d="M615.3 862.9l1.2.9c.2.2.4.3.6.5.2.2.3.4.5.6 0 .1 0 .2-.1.2h-.2l-.5-.5c-.2-.1-.4-.3-.6-.4-.4-.3-.8-.5-1.3-.7-.2-.1-.2-.3-.1-.5s.2-.2.5-.1c-.1 0-.1 0 0 0z"/></g></g></svg>
diff --git a/app/javascript/mastodon/actions/announcements.js b/app/javascript/mastodon/actions/announcements.js
new file mode 100644
index 000000000..c65bc052e
--- /dev/null
+++ b/app/javascript/mastodon/actions/announcements.js
@@ -0,0 +1,133 @@
+import api from '../api';
+import { normalizeAnnouncement } from './importer/normalizer';
+
+export const ANNOUNCEMENTS_FETCH_REQUEST = 'ANNOUNCEMENTS_FETCH_REQUEST';
+export const ANNOUNCEMENTS_FETCH_SUCCESS = 'ANNOUNCEMENTS_FETCH_SUCCESS';
+export const ANNOUNCEMENTS_FETCH_FAIL    = 'ANNOUNCEMENTS_FETCH_FAIL';
+export const ANNOUNCEMENTS_UPDATE        = 'ANNOUNCEMENTS_UPDATE';
+export const ANNOUNCEMENTS_DISMISS       = 'ANNOUNCEMENTS_DISMISS';
+
+export const ANNOUNCEMENTS_REACTION_ADD_REQUEST = 'ANNOUNCEMENTS_REACTION_ADD_REQUEST';
+export const ANNOUNCEMENTS_REACTION_ADD_SUCCESS = 'ANNOUNCEMENTS_REACTION_ADD_SUCCESS';
+export const ANNOUNCEMENTS_REACTION_ADD_FAIL    = 'ANNOUNCEMENTS_REACTION_ADD_FAIL';
+
+export const ANNOUNCEMENTS_REACTION_REMOVE_REQUEST = 'ANNOUNCEMENTS_REACTION_REMOVE_REQUEST';
+export const ANNOUNCEMENTS_REACTION_REMOVE_SUCCESS = 'ANNOUNCEMENTS_REACTION_REMOVE_SUCCESS';
+export const ANNOUNCEMENTS_REACTION_REMOVE_FAIL    = 'ANNOUNCEMENTS_REACTION_REMOVE_FAIL';
+
+export const ANNOUNCEMENTS_REACTION_UPDATE = 'ANNOUNCEMENTS_REACTION_UPDATE';
+
+const noOp = () => {};
+
+export const fetchAnnouncements = (done = noOp) => (dispatch, getState) => {
+  dispatch(fetchAnnouncementsRequest());
+
+  api(getState).get('/api/v1/announcements').then(response => {
+    dispatch(fetchAnnouncementsSuccess(response.data.map(x => normalizeAnnouncement(x))));
+  }).catch(error => {
+    dispatch(fetchAnnouncementsFail(error));
+  }).finally(() => {
+    done();
+  });
+};
+
+export const fetchAnnouncementsRequest = () => ({
+  type: ANNOUNCEMENTS_FETCH_REQUEST,
+  skipLoading: true,
+});
+
+export const fetchAnnouncementsSuccess = announcements => ({
+  type: ANNOUNCEMENTS_FETCH_SUCCESS,
+  announcements,
+  skipLoading: true,
+});
+
+export const fetchAnnouncementsFail= error => ({
+  type: ANNOUNCEMENTS_FETCH_FAIL,
+  error,
+  skipLoading: true,
+  skipAlert: true,
+});
+
+export const updateAnnouncements = announcement => ({
+  type: ANNOUNCEMENTS_UPDATE,
+  announcement: normalizeAnnouncement(announcement),
+});
+
+export const dismissAnnouncement = announcementId => (dispatch, getState) => {
+  dispatch({
+    type: ANNOUNCEMENTS_DISMISS,
+    id: announcementId,
+  });
+
+  api(getState).post(`/api/v1/announcements/${announcementId}/dismiss`);
+};
+
+export const addReaction = (announcementId, name) => (dispatch, getState) => {
+  dispatch(addReactionRequest(announcementId, name));
+
+  api(getState).put(`/api/v1/announcements/${announcementId}/reactions/${name}`).then(() => {
+    dispatch(addReactionSuccess(announcementId, name));
+  }).catch(err => {
+    dispatch(addReactionFail(announcementId, name, err));
+  });
+};
+
+export const addReactionRequest = (announcementId, name) => ({
+  type: ANNOUNCEMENTS_REACTION_ADD_REQUEST,
+  id: announcementId,
+  name,
+  skipLoading: true,
+});
+
+export const addReactionSuccess = (announcementId, name) => ({
+  type: ANNOUNCEMENTS_REACTION_ADD_SUCCESS,
+  id: announcementId,
+  name,
+  skipLoading: true,
+});
+
+export const addReactionFail = (announcementId, name, error) => ({
+  type: ANNOUNCEMENTS_REACTION_ADD_FAIL,
+  id: announcementId,
+  name,
+  error,
+  skipLoading: true,
+});
+
+export const removeReaction = (announcementId, name) => (dispatch, getState) => {
+  dispatch(removeReactionRequest(announcementId, name));
+
+  api(getState).delete(`/api/v1/announcements/${announcementId}/reactions/${name}`).then(() => {
+    dispatch(removeReactionSuccess(announcementId, name));
+  }).catch(err => {
+    dispatch(removeReactionFail(announcementId, name, err));
+  });
+};
+
+export const removeReactionRequest = (announcementId, name) => ({
+  type: ANNOUNCEMENTS_REACTION_REMOVE_REQUEST,
+  id: announcementId,
+  name,
+  skipLoading: true,
+});
+
+export const removeReactionSuccess = (announcementId, name) => ({
+  type: ANNOUNCEMENTS_REACTION_REMOVE_SUCCESS,
+  id: announcementId,
+  name,
+  skipLoading: true,
+});
+
+export const removeReactionFail = (announcementId, name, error) => ({
+  type: ANNOUNCEMENTS_REACTION_REMOVE_FAIL,
+  id: announcementId,
+  name,
+  error,
+  skipLoading: true,
+});
+
+export const updateReaction = reaction => ({
+  type: ANNOUNCEMENTS_REACTION_UPDATE,
+  reaction,
+});
diff --git a/app/javascript/mastodon/actions/importer/normalizer.js b/app/javascript/mastodon/actions/importer/normalizer.js
index 78f321da4..f7cbe4c1c 100644
--- a/app/javascript/mastodon/actions/importer/normalizer.js
+++ b/app/javascript/mastodon/actions/importer/normalizer.js
@@ -76,7 +76,6 @@ export function normalizeStatus(status, normalOldStatus) {
 
 export function normalizePoll(poll) {
   const normalPoll = { ...poll };
-
   const emojiMap = makeEmojiMap(normalPoll);
 
   normalPoll.options = poll.options.map((option, index) => ({
@@ -87,3 +86,12 @@ export function normalizePoll(poll) {
 
   return normalPoll;
 }
+
+export function normalizeAnnouncement(announcement) {
+  const normalAnnouncement = { ...announcement };
+  const emojiMap = makeEmojiMap(normalAnnouncement);
+
+  normalAnnouncement.contentHtml = emojify(normalAnnouncement.content, emojiMap);
+
+  return normalAnnouncement;
+}
diff --git a/app/javascript/mastodon/actions/notifications.js b/app/javascript/mastodon/actions/notifications.js
index 798f9b37e..8a066b896 100644
--- a/app/javascript/mastodon/actions/notifications.js
+++ b/app/javascript/mastodon/actions/notifications.js
@@ -157,9 +157,9 @@ export function expandNotifications({ maxId } = {}, done = noOp) {
 
       dispatch(expandNotificationsSuccess(response.data, next ? next.uri : null, isLoadingMore, isLoadingRecent, isLoadingRecent && preferPendingItems));
       fetchRelatedRelationships(dispatch, response.data);
-      done();
     }).catch(error => {
       dispatch(expandNotificationsFail(error, isLoadingMore));
+    }).finally(() => {
       done();
     });
   };
@@ -188,6 +188,7 @@ export function expandNotificationsFail(error, isLoadingMore) {
     type: NOTIFICATIONS_EXPAND_FAIL,
     error,
     skipLoading: !isLoadingMore,
+    skipAlert: !isLoadingMore,
   };
 };
 
diff --git a/app/javascript/mastodon/actions/streaming.js b/app/javascript/mastodon/actions/streaming.js
index c678e9393..ac325f74c 100644
--- a/app/javascript/mastodon/actions/streaming.js
+++ b/app/javascript/mastodon/actions/streaming.js
@@ -8,6 +8,7 @@ import {
 } from './timelines';
 import { updateNotifications, expandNotifications } from './notifications';
 import { updateConversations } from './conversations';
+import { fetchAnnouncements, updateAnnouncements, updateReaction as updateAnnouncementsReaction } from './announcements';
 import { fetchFilters } from './filters';
 import { getLocale } from '../locales';
 
@@ -44,6 +45,12 @@ export function connectTimelineStream (timelineId, path, pollingRefresh = null,
         case 'filters_changed':
           dispatch(fetchFilters());
           break;
+        case 'announcement':
+          dispatch(updateAnnouncements(JSON.parse(data.payload)));
+          break;
+        case 'announcement.reaction':
+          dispatch(updateAnnouncementsReaction(JSON.parse(data.payload)));
+          break;
         }
       },
     };
@@ -51,7 +58,9 @@ export function connectTimelineStream (timelineId, path, pollingRefresh = null,
 }
 
 const refreshHomeTimelineAndNotification = (dispatch, done) => {
-  dispatch(expandHomeTimeline({}, () => dispatch(expandNotifications({}, done))));
+  dispatch(expandHomeTimeline({}, () =>
+    dispatch(expandNotifications({}, () =>
+      dispatch(fetchAnnouncements(done))))));
 };
 
 export const connectUserStream      = () => connectTimelineStream('home', 'user', refreshHomeTimelineAndNotification);
diff --git a/app/javascript/mastodon/actions/timelines.js b/app/javascript/mastodon/actions/timelines.js
index bc2ac5e82..054668655 100644
--- a/app/javascript/mastodon/actions/timelines.js
+++ b/app/javascript/mastodon/actions/timelines.js
@@ -98,9 +98,9 @@ export function expandTimeline(timelineId, path, params = {}, done = noOp) {
       const next = getLinks(response).refs.find(link => link.rel === 'next');
       dispatch(importFetchedStatuses(response.data));
       dispatch(expandTimelineSuccess(timelineId, response.data, next ? next.uri : null, response.status === 206, isLoadingRecent, isLoadingMore, isLoadingRecent && preferPendingItems));
-      done();
     }).catch(error => {
       dispatch(expandTimelineFail(timelineId, error, isLoadingMore));
+    }).finally(() => {
       done();
     });
   };
diff --git a/app/javascript/mastodon/components/error_boundary.js b/app/javascript/mastodon/components/error_boundary.js
index 800b1c270..4e1c882e2 100644
--- a/app/javascript/mastodon/components/error_boundary.js
+++ b/app/javascript/mastodon/components/error_boundary.js
@@ -58,7 +58,7 @@ export default class ErrorBoundary extends React.PureComponent {
         <div>
           <p className='error-boundary__error'><FormattedMessage id='error.unexpected_crash.explanation' defaultMessage='Due to a bug in our code or a browser compatibility issue, this page could not be displayed correctly.' /></p>
           <p><FormattedMessage id='error.unexpected_crash.next_steps' defaultMessage='Try refreshing the page. If that does not help, you may still be able to use Mastodon through a different browser or native app.' /></p>
-          <p className='error-boundary__footer'>Mastodon v{version} · <a href={source_url} rel='noopener noreferrer' target='_blank'><FormattedMessage id='errors.unexpected_crash.report_issue' defaultMessage='Report issue' /></a> · <button onClick={this.handleCopyStackTrace} className={copied && 'copied'}><FormattedMessage id='errors.unexpected_crash.copy_stacktrace' defaultMessage='Copy stacktrace to clipboard' /></button></p>
+          <p className='error-boundary__footer'>Mastodon v{version} · <a href={source_url} rel='noopener noreferrer' target='_blank'><FormattedMessage id='errors.unexpected_crash.report_issue' defaultMessage='Report issue' /></a> · <button onClick={this.handleCopyStackTrace} className={copied ? 'copied' : ''}><FormattedMessage id='errors.unexpected_crash.copy_stacktrace' defaultMessage='Copy stacktrace to clipboard' /></button></p>
         </div>
       </div>
     );
diff --git a/app/javascript/mastodon/features/account_timeline/index.js b/app/javascript/mastodon/features/account_timeline/index.js
index 8d0cbe5a1..37622d4c0 100644
--- a/app/javascript/mastodon/features/account_timeline/index.js
+++ b/app/javascript/mastodon/features/account_timeline/index.js
@@ -115,6 +115,7 @@ class AccountTimeline extends ImmutablePureComponent {
           shouldUpdateScroll={shouldUpdateScroll}
           emptyMessage={emptyMessage}
           bindToDocument={!multiColumn}
+          timelineId='account'
         />
       </Column>
     );
diff --git a/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js b/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js
index e57c3c20c..582bb0d39 100644
--- a/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js
+++ b/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js
@@ -290,6 +290,7 @@ class EmojiPickerDropdown extends React.PureComponent {
     onPickEmoji: PropTypes.func.isRequired,
     onSkinTone: PropTypes.func.isRequired,
     skinTone: PropTypes.number.isRequired,
+    button: PropTypes.node,
   };
 
   state = {
@@ -350,18 +351,18 @@ class EmojiPickerDropdown extends React.PureComponent {
   }
 
   render () {
-    const { intl, onPickEmoji, onSkinTone, skinTone, frequentlyUsedEmojis } = this.props;
+    const { intl, onPickEmoji, onSkinTone, skinTone, frequentlyUsedEmojis, button } = this.props;
     const title = intl.formatMessage(messages.emoji);
     const { active, loading, placement } = this.state;
 
     return (
       <div className='emoji-picker-dropdown' onKeyDown={this.handleKeyDown}>
         <div ref={this.setTargetRef} className='emoji-button' title={title} aria-label={title} aria-expanded={active} role='button' onClick={this.onToggle} onKeyDown={this.onToggle} tabIndex={0}>
-          <img
+          {button || <img
             className={classNames('emojione', { 'pulse-loading': active && loading })}
             alt='🙂'
             src={`${assetHost}/emoji/1f602.svg`}
-          />
+          />}
         </div>
 
         <Overlay show={active} placement={placement} target={this.findTarget}>
diff --git a/app/javascript/mastodon/features/directory/components/account_card.js b/app/javascript/mastodon/features/directory/components/account_card.js
index 50ad74450..cb47d9db4 100644
--- a/app/javascript/mastodon/features/directory/components/account_card.js
+++ b/app/javascript/mastodon/features/directory/components/account_card.js
@@ -22,6 +22,7 @@ const messages = defineMessages({
   requested: { id: 'account.requested', defaultMessage: 'Awaiting approval' },
   unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' },
   unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' },
+  unfollowConfirm: { id: 'confirmations.unfollow.confirm', defaultMessage: 'Unfollow' },
 });
 
 const makeMapStateToProps = () => {
diff --git a/app/javascript/mastodon/features/getting_started/components/announcements.js b/app/javascript/mastodon/features/getting_started/components/announcements.js
new file mode 100644
index 000000000..ee444e3f0
--- /dev/null
+++ b/app/javascript/mastodon/features/getting_started/components/announcements.js
@@ -0,0 +1,395 @@
+import React from 'react';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import ReactSwipeableViews from 'react-swipeable-views';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import IconButton from 'mastodon/components/icon_button';
+import Icon from 'mastodon/components/icon';
+import { defineMessages, injectIntl, FormattedMessage, FormattedDate, FormattedNumber } from 'react-intl';
+import { autoPlayGif } from 'mastodon/initial_state';
+import elephantUIPlane from 'mastodon/../images/elephant_ui_plane.svg';
+import { mascot } from 'mastodon/initial_state';
+import unicodeMapping from 'mastodon/features/emoji/emoji_unicode_mapping_light';
+import classNames from 'classnames';
+import EmojiPickerDropdown from 'mastodon/features/compose/containers/emoji_picker_dropdown_container';
+
+const messages = defineMessages({
+  close: { id: 'lightbox.close', defaultMessage: 'Close' },
+  previous: { id: 'lightbox.previous', defaultMessage: 'Previous' },
+  next: { id: 'lightbox.next', defaultMessage: 'Next' },
+});
+
+class Content extends ImmutablePureComponent {
+
+  static contextTypes = {
+    router: PropTypes.object,
+  };
+
+  static propTypes = {
+    announcement: ImmutablePropTypes.map.isRequired,
+  };
+
+  setRef = c => {
+    this.node = c;
+  }
+
+  componentDidMount () {
+    this._updateLinks();
+    this._updateEmojis();
+  }
+
+  componentDidUpdate () {
+    this._updateLinks();
+    this._updateEmojis();
+  }
+
+  _updateEmojis () {
+    const node = this.node;
+
+    if (!node || autoPlayGif) {
+      return;
+    }
+
+    const emojis = node.querySelectorAll('.custom-emoji');
+
+    for (var i = 0; i < emojis.length; i++) {
+      let emoji = emojis[i];
+
+      if (emoji.classList.contains('status-emoji')) {
+        continue;
+      }
+
+      emoji.classList.add('status-emoji');
+
+      emoji.addEventListener('mouseenter', this.handleEmojiMouseEnter, false);
+      emoji.addEventListener('mouseleave', this.handleEmojiMouseLeave, false);
+    }
+  }
+
+  _updateLinks () {
+    const node = this.node;
+
+    if (!node) {
+      return;
+    }
+
+    const links = node.querySelectorAll('a');
+
+    for (var i = 0; i < links.length; ++i) {
+      let link = links[i];
+
+      if (link.classList.contains('status-link')) {
+        continue;
+      }
+
+      link.classList.add('status-link');
+
+      let mention = this.props.announcement.get('mentions').find(item => link.href === item.get('url'));
+
+      if (mention) {
+        link.addEventListener('click', this.onMentionClick.bind(this, mention), false);
+        link.setAttribute('title', mention.get('acct'));
+      } else if (link.textContent[0] === '#' || (link.previousSibling && link.previousSibling.textContent && link.previousSibling.textContent[link.previousSibling.textContent.length - 1] === '#')) {
+        link.addEventListener('click', this.onHashtagClick.bind(this, link.text), false);
+      } else {
+        link.setAttribute('title', link.href);
+        link.classList.add('unhandled-link');
+      }
+
+      link.setAttribute('target', '_blank');
+      link.setAttribute('rel', 'noopener noreferrer');
+    }
+  }
+
+  onMentionClick = (mention, e) => {
+    if (this.context.router && e.button === 0 && !(e.ctrlKey || e.metaKey)) {
+      e.preventDefault();
+      this.context.router.history.push(`/accounts/${mention.get('id')}`);
+    }
+  }
+
+  onHashtagClick = (hashtag, e) => {
+    hashtag = hashtag.replace(/^#/, '');
+
+    if (this.context.router && e.button === 0 && !(e.ctrlKey || e.metaKey)) {
+      e.preventDefault();
+      this.context.router.history.push(`/timelines/tag/${hashtag}`);
+    }
+  }
+
+  handleEmojiMouseEnter = ({ target }) => {
+    target.src = target.getAttribute('data-original');
+  }
+
+  handleEmojiMouseLeave = ({ target }) => {
+    target.src = target.getAttribute('data-static');
+  }
+
+  render () {
+    const { announcement } = this.props;
+
+    return (
+      <div
+        className='announcements__item__content'
+        ref={this.setRef}
+        dangerouslySetInnerHTML={{ __html: announcement.get('contentHtml') }}
+      />
+    );
+  }
+
+}
+
+const assetHost = process.env.CDN_HOST || '';
+
+class Emoji extends React.PureComponent {
+
+  static propTypes = {
+    emoji: PropTypes.string.isRequired,
+    emojiMap: ImmutablePropTypes.map.isRequired,
+    hovered: PropTypes.bool.isRequired,
+  };
+
+  render () {
+    const { emoji, emojiMap, hovered } = this.props;
+
+    if (unicodeMapping[emoji]) {
+      const { filename, shortCode } = unicodeMapping[this.props.emoji];
+      const title = shortCode ? `:${shortCode}:` : '';
+
+      return (
+        <img
+          draggable='false'
+          className='emojione'
+          alt={emoji}
+          title={title}
+          src={`${assetHost}/emoji/${filename}.svg`}
+        />
+      );
+    } else if (emojiMap.get(emoji)) {
+      const filename  = (autoPlayGif || hovered) ? emojiMap.getIn([emoji, 'url']) : emojiMap.getIn([emoji, 'static_url']);
+      const shortCode = `:${emoji}:`;
+
+      return (
+        <img
+          draggable='false'
+          className='emojione custom-emoji'
+          alt={shortCode}
+          title={shortCode}
+          src={filename}
+        />
+      );
+    } else {
+      return null;
+    }
+  }
+
+}
+
+class Reaction extends ImmutablePureComponent {
+
+  static propTypes = {
+    announcementId: PropTypes.string.isRequired,
+    reaction: ImmutablePropTypes.map.isRequired,
+    addReaction: PropTypes.func.isRequired,
+    removeReaction: PropTypes.func.isRequired,
+    emojiMap: ImmutablePropTypes.map.isRequired,
+  };
+
+  state = {
+    hovered: false,
+  };
+
+  handleClick = () => {
+    const { reaction, announcementId, addReaction, removeReaction } = this.props;
+
+    if (reaction.get('me')) {
+      removeReaction(announcementId, reaction.get('name'));
+    } else {
+      addReaction(announcementId, reaction.get('name'));
+    }
+  }
+
+  handleMouseEnter = () => this.setState({ hovered: true })
+
+  handleMouseLeave = () => this.setState({ hovered: false })
+
+  render () {
+    const { reaction } = this.props;
+
+    let shortCode = reaction.get('name');
+
+    if (unicodeMapping[shortCode]) {
+      shortCode = unicodeMapping[shortCode].shortCode;
+    }
+
+    return (
+      <button className={classNames('reactions-bar__item', { active: reaction.get('me') })} onClick={this.handleClick} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave} title={`:${shortCode}:`}>
+        <span className='reactions-bar__item__emoji'><Emoji hovered={this.state.hovered} emoji={reaction.get('name')} emojiMap={this.props.emojiMap} /></span>
+        <span className='reactions-bar__item__count'><FormattedNumber value={reaction.get('count')} /></span>
+      </button>
+    );
+  }
+
+}
+
+class ReactionsBar extends ImmutablePureComponent {
+
+  static propTypes = {
+    announcementId: PropTypes.string.isRequired,
+    reactions: ImmutablePropTypes.list.isRequired,
+    addReaction: PropTypes.func.isRequired,
+    removeReaction: PropTypes.func.isRequired,
+    emojiMap: ImmutablePropTypes.map.isRequired,
+  };
+
+  handleEmojiPick = data => {
+    const { addReaction, announcementId } = this.props;
+    addReaction(announcementId, data.native.replace(/:/g, ''));
+  }
+
+  render () {
+    const { reactions } = this.props;
+    const visibleReactions = reactions.filter(x => x.get('count') > 0);
+
+    return (
+      <div className={classNames('reactions-bar', { 'reactions-bar--empty': visibleReactions.isEmpty() })}>
+        {visibleReactions.map(reaction => (
+          <Reaction
+            key={reaction.get('name')}
+            reaction={reaction}
+            announcementId={this.props.announcementId}
+            addReaction={this.props.addReaction}
+            removeReaction={this.props.removeReaction}
+            emojiMap={this.props.emojiMap}
+          />
+        ))}
+
+        <EmojiPickerDropdown onPickEmoji={this.handleEmojiPick} button={<Icon id='plus' />} />
+      </div>
+    );
+  }
+
+}
+
+class Announcement extends ImmutablePureComponent {
+
+  static propTypes = {
+    announcement: ImmutablePropTypes.map.isRequired,
+    emojiMap: ImmutablePropTypes.map.isRequired,
+    dismissAnnouncement: PropTypes.func.isRequired,
+    addReaction: PropTypes.func.isRequired,
+    removeReaction: PropTypes.func.isRequired,
+    intl: PropTypes.object.isRequired,
+  };
+
+  handleDismissClick = () => {
+    const { dismissAnnouncement, announcement } = this.props;
+    dismissAnnouncement(announcement.get('id'));
+  }
+
+  render () {
+    const { announcement, intl } = this.props;
+    const startsAt = announcement.get('starts_at') && new Date(announcement.get('starts_at'));
+    const endsAt = announcement.get('ends_at') && new Date(announcement.get('ends_at'));
+    const now = new Date();
+    const hasTimeRange = startsAt && endsAt;
+    const skipYear = hasTimeRange && startsAt.getFullYear() === endsAt.getFullYear() && endsAt.getFullYear() === now.getFullYear();
+    const skipEndDate = hasTimeRange && startsAt.getDate() === endsAt.getDate() && startsAt.getMonth() === endsAt.getMonth() && startsAt.getFullYear() === endsAt.getFullYear();
+    const skipTime = announcement.get('all_day');
+
+    return (
+      <div className='announcements__item'>
+        <strong className='announcements__item__range'>
+          <FormattedMessage id='announcement.announcement' defaultMessage='Announcement' />
+          {hasTimeRange && <span> · <FormattedDate value={startsAt} hour12={false} year={(skipYear || startsAt.getFullYear() === now.getFullYear()) ? undefined : 'numeric'} month='short' day='2-digit' hour={skipTime ? undefined : '2-digit'} minute={skipTime ? undefined : '2-digit'} /> - <FormattedDate value={endsAt} hour12={false} year={(skipYear || endsAt.getFullYear() === now.getFullYear()) ? undefined : 'numeric'} month={skipEndDate ? undefined : 'short'} day={skipEndDate ? undefined : '2-digit'} hour={skipTime ? undefined : '2-digit'} minute={skipTime ? undefined : '2-digit'} /></span>}
+        </strong>
+
+        <Content announcement={announcement} />
+
+        <ReactionsBar
+          reactions={announcement.get('reactions')}
+          announcementId={announcement.get('id')}
+          addReaction={this.props.addReaction}
+          removeReaction={this.props.removeReaction}
+          emojiMap={this.props.emojiMap}
+        />
+
+        <IconButton title={intl.formatMessage(messages.close)} icon='times' className='announcements__item__dismiss-icon' onClick={this.handleDismissClick} />
+      </div>
+    );
+  }
+
+}
+
+export default @injectIntl
+class Announcements extends ImmutablePureComponent {
+
+  static propTypes = {
+    announcements: ImmutablePropTypes.list,
+    emojiMap: ImmutablePropTypes.map.isRequired,
+    fetchAnnouncements: PropTypes.func.isRequired,
+    dismissAnnouncement: PropTypes.func.isRequired,
+    addReaction: PropTypes.func.isRequired,
+    removeReaction: PropTypes.func.isRequired,
+    intl: PropTypes.object.isRequired,
+  };
+
+  state = {
+    index: 0,
+  };
+
+  componentDidMount () {
+    const { fetchAnnouncements } = this.props;
+    fetchAnnouncements();
+  }
+
+  handleChangeIndex = index => {
+    this.setState({ index: index % this.props.announcements.size });
+  }
+
+  handleNextClick = () => {
+    this.setState({ index: (this.state.index + 1) % this.props.announcements.size });
+  }
+
+  handlePrevClick = () => {
+    this.setState({ index: (this.props.announcements.size + this.state.index - 1) % this.props.announcements.size });
+  }
+
+  render () {
+    const { announcements, intl } = this.props;
+    const { index } = this.state;
+
+    if (announcements.isEmpty()) {
+      return null;
+    }
+
+    return (
+      <div className='announcements'>
+        <img className='announcements__mastodon' alt='' draggable='false' src={mascot || elephantUIPlane} />
+
+        <div className='announcements__container'>
+          <ReactSwipeableViews index={index} onChangeIndex={this.handleChangeIndex}>
+            {announcements.map(announcement => (
+              <Announcement
+                key={announcement.get('id')}
+                announcement={announcement}
+                emojiMap={this.props.emojiMap}
+                dismissAnnouncement={this.props.dismissAnnouncement}
+                addReaction={this.props.addReaction}
+                removeReaction={this.props.removeReaction}
+                intl={intl}
+              />
+            ))}
+          </ReactSwipeableViews>
+
+          <div className='announcements__pagination'>
+            <IconButton disabled={announcements.size === 1} title={intl.formatMessage(messages.previous)} icon='chevron-left' onClick={this.handlePrevClick} size={13} />
+            <span>{index + 1} / {announcements.size}</span>
+            <IconButton disabled={announcements.size === 1} title={intl.formatMessage(messages.next)} icon='chevron-right' onClick={this.handleNextClick} size={13} />
+          </div>
+        </div>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/mastodon/features/getting_started/containers/announcements_container.js b/app/javascript/mastodon/features/getting_started/containers/announcements_container.js
new file mode 100644
index 000000000..b10d1d4ce
--- /dev/null
+++ b/app/javascript/mastodon/features/getting_started/containers/announcements_container.js
@@ -0,0 +1,21 @@
+import { connect } from 'react-redux';
+import { fetchAnnouncements, dismissAnnouncement, addReaction, removeReaction } from 'mastodon/actions/announcements';
+import Announcements from '../components/announcements';
+import { createSelector } from 'reselect';
+import { Map as ImmutableMap } from 'immutable';
+
+const customEmojiMap = createSelector([state => state.get('custom_emojis')], items => items.reduce((map, emoji) => map.set(emoji.get('shortcode'), emoji), ImmutableMap()));
+
+const mapStateToProps = state => ({
+  announcements: state.getIn(['announcements', 'items']),
+  emojiMap: customEmojiMap(state),
+});
+
+const mapDispatchToProps = dispatch => ({
+  fetchAnnouncements: () => dispatch(fetchAnnouncements()),
+  dismissAnnouncement: id => dispatch(dismissAnnouncement(id)),
+  addReaction: (id, name) => dispatch(addReaction(id, name)),
+  removeReaction: (id, name) => dispatch(removeReaction(id, name)),
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(Announcements);
diff --git a/app/javascript/mastodon/features/getting_started/containers/trends_container.js b/app/javascript/mastodon/features/getting_started/containers/trends_container.js
index 1df3fb4fe..7a5268780 100644
--- a/app/javascript/mastodon/features/getting_started/containers/trends_container.js
+++ b/app/javascript/mastodon/features/getting_started/containers/trends_container.js
@@ -1,5 +1,5 @@
 import { connect } from 'react-redux';
-import { fetchTrends } from '../../../actions/trends';
+import { fetchTrends } from 'mastodon/actions/trends';
 import Trends from '../components/trends';
 
 const mapStateToProps = state => ({
diff --git a/app/javascript/mastodon/features/home_timeline/index.js b/app/javascript/mastodon/features/home_timeline/index.js
index 1cafb88ed..b7f9d5095 100644
--- a/app/javascript/mastodon/features/home_timeline/index.js
+++ b/app/javascript/mastodon/features/home_timeline/index.js
@@ -9,6 +9,7 @@ import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
 import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
 import ColumnSettingsContainer from './containers/column_settings_container';
 import { Link } from 'react-router-dom';
+import AnnouncementsContainer from 'mastodon/features/getting_started/containers/announcements_container';
 
 const messages = defineMessages({
   title: { id: 'column.home', defaultMessage: 'Home' },
@@ -113,6 +114,8 @@ class HomeTimeline extends React.PureComponent {
         </ColumnHeader>
 
         <StatusListContainer
+          prepend={<AnnouncementsContainer />}
+          alwaysPrepend
           trackScroll={!pinned}
           scrollKey={`home_timeline-${columnId}`}
           onLoadMore={this.handleLoadMore}
diff --git a/app/javascript/mastodon/features/ui/components/media_modal.js b/app/javascript/mastodon/features/ui/components/media_modal.js
index a785551c0..d7f97f210 100644
--- a/app/javascript/mastodon/features/ui/components/media_modal.js
+++ b/app/javascript/mastodon/features/ui/components/media_modal.js
@@ -211,7 +211,6 @@ class MediaModal extends ImmutablePureComponent {
             style={swipeableViewsStyle}
             containerStyle={containerStyle}
             onChangeIndex={this.handleSwipe}
-            onSwitching={this.handleSwitching}
             index={index}
           >
             {content}
diff --git a/app/javascript/mastodon/locales/ar.json b/app/javascript/mastodon/locales/ar.json
index dc4b8ce76..5ce5eb12e 100644
--- a/app/javascript/mastodon/locales/ar.json
+++ b/app/javascript/mastodon/locales/ar.json
@@ -43,6 +43,7 @@
   "alert.rate_limited.title": "المعدل محدود",
   "alert.unexpected.message": "لقد طرأ هناك خطأ غير متوقّع.",
   "alert.unexpected.title": "المعذرة!",
+  "announcement.announcement": "Announcement",
   "autosuggest_hashtag.per_week": "{count} في الأسبوع",
   "boost_modal.combo": "يمكنك/ي ضغط {combo} لتخطّي هذه في المرّة القادمة",
   "bundle_column_error.body": "لقد وقع هناك خطأ أثناء عملية تحميل هذا العنصر.",
@@ -84,8 +85,8 @@
   "compose_form.poll.duration": "مدة استطلاع الرأي",
   "compose_form.poll.option_placeholder": "الخيار {number}",
   "compose_form.poll.remove_option": "إزالة هذا الخيار",
-  "compose_form.poll.switch_to_multiple": "Change poll to allow multiple choices",
-  "compose_form.poll.switch_to_single": "Change poll to allow for a single choice",
+  "compose_form.poll.switch_to_multiple": "عدّل استطلاع الرأي وغيّره لإتاحة الخيارات المتعددة",
+  "compose_form.poll.switch_to_single": "عدّل استطلاع الرأي وغيّره لإتاحة خيار واحد فقط",
   "compose_form.publish": "بوّق",
   "compose_form.publish_loud": "{publish}!",
   "compose_form.sensitive.hide": "تحديد الوسائط كحساسة",
@@ -142,7 +143,7 @@
   "empty_column.account_timeline": "ليس هناك تبويقات!",
   "empty_column.account_unavailable": "الملف التعريفي غير متوفر",
   "empty_column.blocks": "لم تقم بحظر أي مستخدِم بعد.",
-  "empty_column.bookmarked_statuses": "You don't have any bookmarked toots yet. When you bookmark one, it will show up here.",
+  "empty_column.bookmarked_statuses": "ليس لديك أية تبويقات في الفواصل المرجعية بعد. عندما ستقوم بإضافة البعض منها، ستظهر هنا.",
   "empty_column.community": "الخط العام المحلي فارغ. أكتب شيئا ما للعامة كبداية!",
   "empty_column.direct": "لم تتلق أية رسالة خاصة مباشِرة بعد. سوف يتم عرض الرسائل المباشرة هنا إن قمت بإرسال واحدة أو تلقيت البعض منها.",
   "empty_column.domain_blocks": "ليس هناك نطاقات مخفية بعد.",
diff --git a/app/javascript/mastodon/locales/ast.json b/app/javascript/mastodon/locales/ast.json
index ba344e7ea..e570fc3b7 100644
--- a/app/javascript/mastodon/locales/ast.json
+++ b/app/javascript/mastodon/locales/ast.json
@@ -43,9 +43,10 @@
   "alert.rate_limited.title": "Rate limited",
   "alert.unexpected.message": "Asocedió un fallu inesperáu.",
   "alert.unexpected.title": "¡Meca!",
+  "announcement.announcement": "Announcement",
   "autosuggest_hashtag.per_week": "{count} per selmana",
   "boost_modal.combo": "Pues primir {combo} pa saltar esto la próxima vegada",
-  "bundle_column_error.body": "Something went wrong while loading this component.",
+  "bundle_column_error.body": "Asocedió daqué malo mentanto se cargaba esti componente.",
   "bundle_column_error.retry": "Try again",
   "bundle_column_error.title": "Network error",
   "bundle_modal_error.close": "Close",
diff --git a/app/javascript/mastodon/locales/bg.json b/app/javascript/mastodon/locales/bg.json
index d980df27b..6a6f9a309 100644
--- a/app/javascript/mastodon/locales/bg.json
+++ b/app/javascript/mastodon/locales/bg.json
@@ -43,6 +43,7 @@
   "alert.rate_limited.title": "Rate limited",
   "alert.unexpected.message": "An unexpected error occurred.",
   "alert.unexpected.title": "Oops!",
+  "announcement.announcement": "Announcement",
   "autosuggest_hashtag.per_week": "{count} per week",
   "boost_modal.combo": "You can press {combo} to skip this next time",
   "bundle_column_error.body": "Something went wrong while loading this component.",
diff --git a/app/javascript/mastodon/locales/bn.json b/app/javascript/mastodon/locales/bn.json
index ed1a92dd2..283812fdf 100644
--- a/app/javascript/mastodon/locales/bn.json
+++ b/app/javascript/mastodon/locales/bn.json
@@ -43,6 +43,7 @@
   "alert.rate_limited.title": "হার সীমিত",
   "alert.unexpected.message": "সমস্যা অপ্রত্যাশিত.",
   "alert.unexpected.title": "ওহো!",
+  "announcement.announcement": "Announcement",
   "autosuggest_hashtag.per_week": "প্রতি সপ্তাহে {count}",
   "boost_modal.combo": "পরেরবার আপনি {combo} টিপলে এটি আর আসবে না",
   "bundle_column_error.body": "এই অংশটি দেখতে যেয়ে কোনো সমস্যা হয়েছে।.",
diff --git a/app/javascript/mastodon/locales/br.json b/app/javascript/mastodon/locales/br.json
index 89ea5043e..967577f2e 100644
--- a/app/javascript/mastodon/locales/br.json
+++ b/app/javascript/mastodon/locales/br.json
@@ -43,6 +43,7 @@
   "alert.rate_limited.title": "Rate limited",
   "alert.unexpected.message": "Ur fazi dic'hortozet zo degouezhet.",
   "alert.unexpected.title": "C'hem !",
+  "announcement.announcement": "Announcement",
   "autosuggest_hashtag.per_week": "{count} bep sizhun",
   "boost_modal.combo": "You can press {combo} to skip this next time",
   "bundle_column_error.body": "Something went wrong while loading this component.",
diff --git a/app/javascript/mastodon/locales/ca.json b/app/javascript/mastodon/locales/ca.json
index ceb5c39e4..6516a4a80 100644
--- a/app/javascript/mastodon/locales/ca.json
+++ b/app/javascript/mastodon/locales/ca.json
@@ -43,6 +43,7 @@
   "alert.rate_limited.title": "Límit de freqüència",
   "alert.unexpected.message": "S'ha produït un error inesperat.",
   "alert.unexpected.title": "Vaja!",
+  "announcement.announcement": "Announcement",
   "autosuggest_hashtag.per_week": "{count} per setmana",
   "boost_modal.combo": "Pots premer {combo} per saltar-te això el proper cop",
   "bundle_column_error.body": "S'ha produït un error en carregar aquest component.",
@@ -84,8 +85,8 @@
   "compose_form.poll.duration": "Durada de l'enquesta",
   "compose_form.poll.option_placeholder": "Opció {number}",
   "compose_form.poll.remove_option": "Elimina aquesta opció",
-  "compose_form.poll.switch_to_multiple": "Canvía l’enquesta per a permetre diverses opcions",
-  "compose_form.poll.switch_to_single": "Canvía l’enquesta per a permetre una sola opció",
+  "compose_form.poll.switch_to_multiple": "Canvia l’enquesta per a permetre diverses opcions",
+  "compose_form.poll.switch_to_single": "Canvia l’enquesta per a permetre una única opció",
   "compose_form.publish": "Tut",
   "compose_form.publish_loud": "{publish}!",
   "compose_form.sensitive.hide": "Marcar mèdia com a sensible",
diff --git a/app/javascript/mastodon/locales/co.json b/app/javascript/mastodon/locales/co.json
index 2afd0f1fa..091a73c85 100644
--- a/app/javascript/mastodon/locales/co.json
+++ b/app/javascript/mastodon/locales/co.json
@@ -43,6 +43,7 @@
   "alert.rate_limited.title": "Ghjettu limitatu",
   "alert.unexpected.message": "Un prublemu inaspettatu hè accadutu.",
   "alert.unexpected.title": "Uups!",
+  "announcement.announcement": "Announcement",
   "autosuggest_hashtag.per_week": "{count} per settimana",
   "boost_modal.combo": "Pudete appughjà nant'à {combo} per saltà quessa a prussima volta",
   "bundle_column_error.body": "C'hè statu un prublemu caricandu st'elementu.",
diff --git a/app/javascript/mastodon/locales/cs.json b/app/javascript/mastodon/locales/cs.json
index 8b9b5a86b..f180b7f94 100644
--- a/app/javascript/mastodon/locales/cs.json
+++ b/app/javascript/mastodon/locales/cs.json
@@ -43,6 +43,7 @@
   "alert.rate_limited.title": "Rychlost omezena",
   "alert.unexpected.message": "Objevila se neočekávaná chyba.",
   "alert.unexpected.title": "Jejda!",
+  "announcement.announcement": "Announcement",
   "autosuggest_hashtag.per_week": "{count} za týden",
   "boost_modal.combo": "Příště můžete pro přeskočení stisknout {combo}",
   "bundle_column_error.body": "Při načítání této komponenty se něco pokazilo.",
@@ -84,8 +85,8 @@
   "compose_form.poll.duration": "Doba trvání ankety",
   "compose_form.poll.option_placeholder": "Volba {number}",
   "compose_form.poll.remove_option": "Odstranit tuto volbu",
-  "compose_form.poll.switch_to_multiple": "Povolit u ankety více možností",
-  "compose_form.poll.switch_to_single": "Povolit u ankety jedinou možnost",
+  "compose_form.poll.switch_to_multiple": "Povolit u ankety výběr více možností",
+  "compose_form.poll.switch_to_single": "Povolit u ankety výběr jediné možnosti",
   "compose_form.publish": "Tootnout",
   "compose_form.publish_loud": "{publish}!",
   "compose_form.sensitive.hide": "Označit média jako citlivá",
diff --git a/app/javascript/mastodon/locales/cy.json b/app/javascript/mastodon/locales/cy.json
index 923d1947b..ee58c7168 100644
--- a/app/javascript/mastodon/locales/cy.json
+++ b/app/javascript/mastodon/locales/cy.json
@@ -1,7 +1,7 @@
 {
   "account.add_or_remove_from_list": "Ychwanegu neu Dileu o'r rhestrau",
   "account.badges.bot": "Bot",
-  "account.badges.group": "Group",
+  "account.badges.group": "Grŵp",
   "account.block": "Blocio @{name}",
   "account.block_domain": "Cuddio popeth rhag {domain}",
   "account.blocked": "Blociwyd",
@@ -43,6 +43,7 @@
   "alert.rate_limited.title": "Cyfradd gyfyngedig",
   "alert.unexpected.message": "Digwyddodd gwall annisgwyl.",
   "alert.unexpected.title": "Wps!",
+  "announcement.announcement": "Announcement",
   "autosuggest_hashtag.per_week": "{count} yr wythnos",
   "boost_modal.combo": "Mae modd gwasgu {combo} er mwyn sgipio hyn tro nesa",
   "bundle_column_error.body": "Aeth rhywbeth o'i le tra'n llwytho'r elfen hon.",
@@ -84,8 +85,8 @@
   "compose_form.poll.duration": "Cyfnod pleidlais",
   "compose_form.poll.option_placeholder": "Dewisiad {number}",
   "compose_form.poll.remove_option": "Tynnu'r dewisiad",
-  "compose_form.poll.switch_to_multiple": "Change poll to allow multiple choices",
-  "compose_form.poll.switch_to_single": "Change poll to allow for a single choice",
+  "compose_form.poll.switch_to_multiple": "Newid pleidlais i adael mwy nag un dewis",
+  "compose_form.poll.switch_to_single": "Newid pleidlais i gyfyngu i un dewis",
   "compose_form.publish": "Tŵt",
   "compose_form.publish_loud": "{publish}!",
   "compose_form.sensitive.hide": "Marcio cyfryngau fel eu bod yn sensitif",
diff --git a/app/javascript/mastodon/locales/da.json b/app/javascript/mastodon/locales/da.json
index bd68381d9..a699aaf54 100644
--- a/app/javascript/mastodon/locales/da.json
+++ b/app/javascript/mastodon/locales/da.json
@@ -43,6 +43,7 @@
   "alert.rate_limited.title": "Gradsbegrænset",
   "alert.unexpected.message": "Der opstod en uventet fejl.",
   "alert.unexpected.title": "Ups!",
+  "announcement.announcement": "Announcement",
   "autosuggest_hashtag.per_week": "{count} per uge",
   "boost_modal.combo": "Du kan trykke {combo} for at springe dette over næste gang",
   "bundle_column_error.body": "Noget gik galt under indlæsningen af dette komponent.",
diff --git a/app/javascript/mastodon/locales/de.json b/app/javascript/mastodon/locales/de.json
index 0804d8033..aae5ad1c1 100644
--- a/app/javascript/mastodon/locales/de.json
+++ b/app/javascript/mastodon/locales/de.json
@@ -1,7 +1,7 @@
 {
   "account.add_or_remove_from_list": "Hinzufügen oder Entfernen von Listen",
   "account.badges.bot": "Bot",
-  "account.badges.group": "Group",
+  "account.badges.group": "Gruppe",
   "account.block": "@{name} blockieren",
   "account.block_domain": "Alles von {domain} verstecken",
   "account.blocked": "Blockiert",
@@ -43,6 +43,7 @@
   "alert.rate_limited.title": "Anfragelimit überschritten",
   "alert.unexpected.message": "Ein unerwarteter Fehler ist aufgetreten.",
   "alert.unexpected.title": "Hoppla!",
+  "announcement.announcement": "Ankündigung",
   "autosuggest_hashtag.per_week": "{count} pro Woche",
   "boost_modal.combo": "Drücke {combo}, um dieses Fenster zu überspringen",
   "bundle_column_error.body": "Etwas ist beim Laden schiefgelaufen.",
@@ -84,8 +85,8 @@
   "compose_form.poll.duration": "Umfragedauer",
   "compose_form.poll.option_placeholder": "Wahl {number}",
   "compose_form.poll.remove_option": "Wahl entfernen",
-  "compose_form.poll.switch_to_multiple": "Change poll to allow multiple choices",
-  "compose_form.poll.switch_to_single": "Change poll to allow for a single choice",
+  "compose_form.poll.switch_to_multiple": "Umfrage ändern, um mehrere Optionen zu erlauben",
+  "compose_form.poll.switch_to_single": "Umfrage ändern, um eine einzige Wahl zu ermöglichen",
   "compose_form.publish": "Tröt",
   "compose_form.publish_loud": "{publish}!",
   "compose_form.sensitive.hide": "Medien als heikel markieren",
diff --git a/app/javascript/mastodon/locales/defaultMessages.json b/app/javascript/mastodon/locales/defaultMessages.json
index fea31633a..8cd2bb8a3 100644
--- a/app/javascript/mastodon/locales/defaultMessages.json
+++ b/app/javascript/mastodon/locales/defaultMessages.json
@@ -1408,6 +1408,10 @@
         "id": "account.unmute"
       },
       {
+        "defaultMessage": "Unfollow",
+        "id": "confirmations.unfollow.confirm"
+      },
+      {
         "defaultMessage": "Are you sure you want to unfollow {name}?",
         "id": "confirmations.unfollow.message"
       },
@@ -1563,6 +1567,10 @@
       {
         "defaultMessage": "Next",
         "id": "lightbox.next"
+      },
+      {
+        "defaultMessage": "Announcement",
+        "id": "announcement.announcement"
       }
     ],
     "path": "app/javascript/mastodon/features/getting_started/components/announcements.json"
diff --git a/app/javascript/mastodon/locales/el.json b/app/javascript/mastodon/locales/el.json
index 9ae9e9a84..d9d2ef590 100644
--- a/app/javascript/mastodon/locales/el.json
+++ b/app/javascript/mastodon/locales/el.json
@@ -1,9 +1,9 @@
 {
   "account.add_or_remove_from_list": "Προσθήκη ή Αφαίρεση από λίστες",
   "account.badges.bot": "Μποτ",
-  "account.badges.group": "Group",
+  "account.badges.group": "Ομάδα",
   "account.block": "Αποκλεισμός @{name}",
-  "account.block_domain": "Απόκρυψε τα πάντα από το {domain}",
+  "account.block_domain": "Απόκρυψη όλων από {domain}",
   "account.blocked": "Αποκλεισμένος/η",
   "account.cancel_follow_request": "Ακύρωση αιτήματος παρακολούθησης",
   "account.direct": "Προσωπικό μήνυμα προς @{name}",
@@ -43,6 +43,7 @@
   "alert.rate_limited.title": "Περιορισμός συχνότητας",
   "alert.unexpected.message": "Προέκυψε απροσδόκητο σφάλμα.",
   "alert.unexpected.title": "Εεπ!",
+  "announcement.announcement": "Announcement",
   "autosuggest_hashtag.per_week": "{count} ανα εβδομάδα",
   "boost_modal.combo": "Μπορείς να πατήσεις {combo} για να το προσπεράσεις αυτό την επόμενη φορά",
   "bundle_column_error.body": "Κάτι πήγε στραβά ενώ φορτωνόταν αυτό το στοιχείο.",
@@ -84,8 +85,8 @@
   "compose_form.poll.duration": "Διάρκεια δημοσκόπησης",
   "compose_form.poll.option_placeholder": "Επιλογή {number}",
   "compose_form.poll.remove_option": "Αφαίρεση επιλογής",
-  "compose_form.poll.switch_to_multiple": "Change poll to allow multiple choices",
-  "compose_form.poll.switch_to_single": "Change poll to allow for a single choice",
+  "compose_form.poll.switch_to_multiple": "Ενημέρωση δημοσκόπησης με πολλαπλές επιλογές",
+  "compose_form.poll.switch_to_single": "Ενημέρωση δημοσκόπησης με μοναδική επιλογή",
   "compose_form.publish": "Τουτ",
   "compose_form.publish_loud": "{publish}!",
   "compose_form.sensitive.hide": "Σημείωσε τα πολυμέσα ως ευαίσθητα",
diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json
index 2910eedbd..5871819a9 100644
--- a/app/javascript/mastodon/locales/en.json
+++ b/app/javascript/mastodon/locales/en.json
@@ -43,6 +43,7 @@
   "alert.rate_limited.title": "Rate limited",
   "alert.unexpected.message": "An unexpected error occurred.",
   "alert.unexpected.title": "Oops!",
+  "announcement.announcement": "Announcement",
   "autosuggest_hashtag.per_week": "{count} per week",
   "boost_modal.combo": "You can press {combo} to skip this next time",
   "bundle_column_error.body": "Something went wrong while loading this component.",
diff --git a/app/javascript/mastodon/locales/eo.json b/app/javascript/mastodon/locales/eo.json
index f921b263c..e84e59b2e 100644
--- a/app/javascript/mastodon/locales/eo.json
+++ b/app/javascript/mastodon/locales/eo.json
@@ -43,6 +43,7 @@
   "alert.rate_limited.title": "Mesaĝkvante limigita",
   "alert.unexpected.message": "Neatendita eraro okazis.",
   "alert.unexpected.title": "Ups!",
+  "announcement.announcement": "Announcement",
   "autosuggest_hashtag.per_week": "{count} semajne",
   "boost_modal.combo": "Vi povas premi {combo} por preterpasi sekvafoje",
   "bundle_column_error.body": "Io misfunkciis en la ŝargado de ĉi tiu elemento.",
diff --git a/app/javascript/mastodon/locales/es-AR.json b/app/javascript/mastodon/locales/es-AR.json
index ee46e24dd..70bcef133 100644
--- a/app/javascript/mastodon/locales/es-AR.json
+++ b/app/javascript/mastodon/locales/es-AR.json
@@ -43,6 +43,7 @@
   "alert.rate_limited.title": "Tarifa limitada",
   "alert.unexpected.message": "Ocurrió un error.",
   "alert.unexpected.title": "¡Epa!",
+  "announcement.announcement": "Announcement",
   "autosuggest_hashtag.per_week": "{count} por semana",
   "boost_modal.combo": "Podés hacer clic en {combo} para saltar esto la próxima vez",
   "bundle_column_error.body": "Algo salió mal al cargar este componente.",
diff --git a/app/javascript/mastodon/locales/es.json b/app/javascript/mastodon/locales/es.json
index d056a843c..31f190616 100644
--- a/app/javascript/mastodon/locales/es.json
+++ b/app/javascript/mastodon/locales/es.json
@@ -43,6 +43,7 @@
   "alert.rate_limited.title": "Tasa limitada",
   "alert.unexpected.message": "Hubo un error inesperado.",
   "alert.unexpected.title": "¡Ups!",
+  "announcement.announcement": "Announcement",
   "autosuggest_hashtag.per_week": "{count} por semana",
   "boost_modal.combo": "Puedes hacer clic en {combo} para saltar este aviso la próxima vez",
   "bundle_column_error.body": "Algo salió mal al cargar este componente.",
@@ -57,7 +58,7 @@
   "column.direct": "Mensajes directos",
   "column.directory": "Buscar perfiles",
   "column.domain_blocks": "Dominios ocultados",
-  "column.favourites": "Levantar la trompa",
+  "column.favourites": "Favoritos",
   "column.follow_requests": "Solicitudes de seguimiento",
   "column.home": "Inicio",
   "column.lists": "Listas",
@@ -86,7 +87,7 @@
   "compose_form.poll.remove_option": "Eliminar esta opción",
   "compose_form.poll.switch_to_multiple": "Modificar encuesta para permitir múltiples opciones",
   "compose_form.poll.switch_to_single": "Modificar encuesta para permitir una única opción",
-  "compose_form.publish": "Ipoxta",
+  "compose_form.publish": "Ipotxa",
   "compose_form.publish_loud": "{publish}!",
   "compose_form.sensitive.hide": "Marcar multimedia como sensible",
   "compose_form.sensitive.marked": "Material marcado como sensible",
@@ -131,7 +132,7 @@
   "emoji_button.food": "Comida y bebida",
   "emoji_button.label": "Insertar emoji",
   "emoji_button.nature": "Naturaleza",
-  "emoji_button.not_found": "No hay emojos!! (╯°□°)╯︵ ┻━┻",
+  "emoji_button.not_found": "No hay emojis!! ¯\\_(ツ)_/¯",
   "emoji_button.objects": "Objetos",
   "emoji_button.people": "Gente",
   "emoji_button.recent": "Usados frecuentemente",
@@ -205,7 +206,7 @@
   "introduction.welcome.text": "¡Bienvenido al fediverso! En unos momentos, podrás transmitir mensajes y hablar con tus amigos a través de una amplia variedad de servidores. Pero este servidor, {domain}, es especial, alberga tu perfil, así que recuerda su nombre.",
   "keyboard_shortcuts.back": "volver atrás",
   "keyboard_shortcuts.blocked": "abrir una lista de usuarios bloqueados",
-  "keyboard_shortcuts.boost": "reipoxta",
+  "keyboard_shortcuts.boost": "retootear",
   "keyboard_shortcuts.column": "enfocar un estado en una de las columnas",
   "keyboard_shortcuts.compose": "enfocar el área de texto de redacción",
   "keyboard_shortcuts.description": "Descripción",
@@ -356,7 +357,7 @@
   "status.block": "Bloquear a @{name}",
   "status.bookmark": "Marcador",
   "status.cancel_reblog_private": "Des-impulsar",
-  "status.cannot_reblog": "Este bramido no puede rebarritarse",
+  "status.cannot_reblog": "Este toot no puede retootearse",
   "status.copy": "Copiar enlace al estado",
   "status.delete": "Borrar",
   "status.detailed_status": "Vista de conversación detallada",
@@ -374,7 +375,7 @@
   "status.pin": "Fijar",
   "status.pinned": "Toot fijado",
   "status.read_more": "Leer más",
-  "status.reblog": "ReIpoxta",
+  "status.reblog": "Retootear",
   "status.reblog_private": "Implusar a la audiencia original",
   "status.reblogged_by": "Retooteado por {name}",
   "status.reblogs.empty": "Nadie impulsó este toot todavía. Cuando alguien lo haga, aparecerá aqui.",
diff --git a/app/javascript/mastodon/locales/et.json b/app/javascript/mastodon/locales/et.json
index 94e73ae3d..9a13b4ee0 100644
--- a/app/javascript/mastodon/locales/et.json
+++ b/app/javascript/mastodon/locales/et.json
@@ -43,6 +43,7 @@
   "alert.rate_limited.title": "Piiratud",
   "alert.unexpected.message": "Tekkis ootamatu viga.",
   "alert.unexpected.title": "Oih!",
+  "announcement.announcement": "Announcement",
   "autosuggest_hashtag.per_week": "{count} nädalas",
   "boost_modal.combo": "Võite vajutada {combo}, et see järgmine kord vahele jätta",
   "bundle_column_error.body": "Midagi läks valesti selle komponendi laadimisel.",
diff --git a/app/javascript/mastodon/locales/eu.json b/app/javascript/mastodon/locales/eu.json
index 3ae39ffba..e9fbcedac 100644
--- a/app/javascript/mastodon/locales/eu.json
+++ b/app/javascript/mastodon/locales/eu.json
@@ -1,7 +1,7 @@
 {
   "account.add_or_remove_from_list": "Gehitu edo kendu zerrendetatik",
   "account.badges.bot": "Bot-a",
-  "account.badges.group": "Group",
+  "account.badges.group": "Taldea",
   "account.block": "Blokeatu @{name}",
   "account.block_domain": "Ezkutatu {domain} domeinuko guztia",
   "account.blocked": "Blokeatuta",
@@ -43,6 +43,7 @@
   "alert.rate_limited.title": "Abiadura mugatua",
   "alert.unexpected.message": "Ustekabeko errore bat gertatu da.",
   "alert.unexpected.title": "Ene!",
+  "announcement.announcement": "Announcement",
   "autosuggest_hashtag.per_week": "{count} asteko",
   "boost_modal.combo": "{combo} sakatu dezakezu hurrengoan hau saltatzeko",
   "bundle_column_error.body": "Zerbait okerra gertatu da osagai hau kargatzean.",
@@ -84,8 +85,8 @@
   "compose_form.poll.duration": "Inkestaren iraupena",
   "compose_form.poll.option_placeholder": "{number}. aukera",
   "compose_form.poll.remove_option": "Kendu aukera hau",
-  "compose_form.poll.switch_to_multiple": "Change poll to allow multiple choices",
-  "compose_form.poll.switch_to_single": "Change poll to allow for a single choice",
+  "compose_form.poll.switch_to_multiple": "Aldatu inkesta hainbat aukera onartzeko",
+  "compose_form.poll.switch_to_single": "Aldatu inkesta aukera bakarra onartzeko",
   "compose_form.publish": "Toot",
   "compose_form.publish_loud": "{publish}!",
   "compose_form.sensitive.hide": "Markatu multimedia hunkigarri gisa",
diff --git a/app/javascript/mastodon/locales/fa.json b/app/javascript/mastodon/locales/fa.json
index 38a53e9b1..c2a8eb0de 100644
--- a/app/javascript/mastodon/locales/fa.json
+++ b/app/javascript/mastodon/locales/fa.json
@@ -1,7 +1,7 @@
 {
   "account.add_or_remove_from_list": "افزودن یا برداشتن از فهرست",
   "account.badges.bot": "ربات",
-  "account.badges.group": "Group",
+  "account.badges.group": "گروه",
   "account.block": "مسدودسازی @{name}",
   "account.block_domain": "پنهان‌سازی همه چیز از سرور {domain}",
   "account.blocked": "مسدود",
@@ -43,6 +43,7 @@
   "alert.rate_limited.title": "محدود شده",
   "alert.unexpected.message": "خطایی غیرمنتظره رخ داد.",
   "alert.unexpected.title": "وای!",
+  "announcement.announcement": "Announcement",
   "autosuggest_hashtag.per_week": "{count} در هفته",
   "boost_modal.combo": "دکمهٔ {combo} را بزنید تا دیگر این را نبینید",
   "bundle_column_error.body": "هنگام بازکردن این بخش خطایی رخ داد.",
@@ -84,8 +85,8 @@
   "compose_form.poll.duration": "مدت نظرسنجی",
   "compose_form.poll.option_placeholder": "گزینهٔ {number}",
   "compose_form.poll.remove_option": "برداشتن این گزینه",
-  "compose_form.poll.switch_to_multiple": "Change poll to allow multiple choices",
-  "compose_form.poll.switch_to_single": "Change poll to allow for a single choice",
+  "compose_form.poll.switch_to_multiple": "تبدیل به نظرسنجی چندگزینه‌ای",
+  "compose_form.poll.switch_to_single": "تبدیل به نظرسنجی تک‌گزینه‌ای",
   "compose_form.publish": "بوق",
   "compose_form.publish_loud": "{publish}!",
   "compose_form.sensitive.hide": "علامت‌گذاری رسانه به عنوان حساس",
diff --git a/app/javascript/mastodon/locales/fi.json b/app/javascript/mastodon/locales/fi.json
index b43f18d23..8a069f2a3 100644
--- a/app/javascript/mastodon/locales/fi.json
+++ b/app/javascript/mastodon/locales/fi.json
@@ -43,6 +43,7 @@
   "alert.rate_limited.title": "Määrää rajoitettu",
   "alert.unexpected.message": "Tapahtui odottamaton virhe.",
   "alert.unexpected.title": "Hups!",
+  "announcement.announcement": "Announcement",
   "autosuggest_hashtag.per_week": "{count} viikossa",
   "boost_modal.combo": "Ensi kerralla voit ohittaa tämän painamalla {combo}",
   "bundle_column_error.body": "Jokin meni vikaan komponenttia ladattaessa.",
diff --git a/app/javascript/mastodon/locales/fr.json b/app/javascript/mastodon/locales/fr.json
index ffb77d9f6..7cd5ec0e3 100644
--- a/app/javascript/mastodon/locales/fr.json
+++ b/app/javascript/mastodon/locales/fr.json
@@ -43,6 +43,7 @@
   "alert.rate_limited.title": "Débit limité",
   "alert.unexpected.message": "Une erreur inattendue s’est produite.",
   "alert.unexpected.title": "Oups !",
+  "announcement.announcement": "Annonce",
   "autosuggest_hashtag.per_week": "{count} par semaine",
   "boost_modal.combo": "Vous pouvez appuyer sur {combo} pour passer ceci, la prochaine fois",
   "bundle_column_error.body": "Une erreur s’est produite lors du chargement de ce composant.",
diff --git a/app/javascript/mastodon/locales/ga.json b/app/javascript/mastodon/locales/ga.json
index 4966946cc..508456736 100644
--- a/app/javascript/mastodon/locales/ga.json
+++ b/app/javascript/mastodon/locales/ga.json
@@ -43,6 +43,7 @@
   "alert.rate_limited.title": "Rate limited",
   "alert.unexpected.message": "An unexpected error occurred.",
   "alert.unexpected.title": "Oops!",
+  "announcement.announcement": "Announcement",
   "autosuggest_hashtag.per_week": "{count} per week",
   "boost_modal.combo": "You can press {combo} to skip this next time",
   "bundle_column_error.body": "Something went wrong while loading this component.",
diff --git a/app/javascript/mastodon/locales/gl.json b/app/javascript/mastodon/locales/gl.json
index 695017b0e..b2715cc4b 100644
--- a/app/javascript/mastodon/locales/gl.json
+++ b/app/javascript/mastodon/locales/gl.json
@@ -4,32 +4,32 @@
   "account.badges.group": "Grupo",
   "account.block": "Bloquear @{name}",
   "account.block_domain": "Agochar todo de {domain}",
-  "account.blocked": "Bloqueado",
+  "account.blocked": "Bloqueada",
   "account.cancel_follow_request": "Desbotar solicitude de seguimento",
   "account.direct": "Mensaxe directa @{name}",
   "account.domain_blocked": "Dominio agochado",
   "account.edit_profile": "Editar perfil",
   "account.endorse": "Amosar no perfil",
   "account.follow": "Seguir",
-  "account.followers": "Seguidores",
-  "account.followers.empty": "Aínda ninguén segue este usuario.",
+  "account.followers": "Seguidoras",
+  "account.followers.empty": "Aínda ninguén segue esta usuaria.",
   "account.follows": "Seguindo",
-  "account.follows.empty": "Este usuario aínda non segue a ninguén.",
+  "account.follows.empty": "Esta usuaria aínda non segue a ninguén.",
   "account.follows_you": "Séguete",
-  "account.hide_reblogs": "Agochar compartidos de @{name}",
+  "account.hide_reblogs": "Agochar repeticións de @{name}",
   "account.last_status": "Última actividade",
   "account.link_verified_on": "A propiedade desta ligazón foi verificada o {date}",
-  "account.locked_info": "Esta é unha conta privada. O dono revisa de xeito manual quen pode seguilo.",
+  "account.locked_info": "Esta é unha conta privada. A propietaria revisa de xeito manual quen pode seguila.",
   "account.media": "Multimedia",
   "account.mention": "Mencionar @{name}",
   "account.moved_to": "{name} mudouse a:",
-  "account.mute": "Silenciar @{name}",
-  "account.mute_notifications": "Silenciar notificacións de @{name}",
-  "account.muted": "Silenciado",
+  "account.mute": "Acalar @{name}",
+  "account.mute_notifications": "Acalar as notificacións de @{name}",
+  "account.muted": "Acalada",
   "account.never_active": "Nunca",
   "account.posts": "Toots",
   "account.posts_with_replies": "Toots e respostas",
-  "account.report": "Denunciar @{name}",
+  "account.report": "Informar sobre @{name}",
   "account.requested": "Agardando aprovación. Preme para desbotar a solicitude de seguimento",
   "account.share": "Compartir o perfil de @{name}",
   "account.show_reblogs": "Amosar compartidos de @{name}",
@@ -43,15 +43,16 @@
   "alert.rate_limited.title": "Límite de intentos",
   "alert.unexpected.message": "Ocorreu un erro non agardado.",
   "alert.unexpected.title": "Vaites!",
+  "announcement.announcement": "Anuncio",
   "autosuggest_hashtag.per_week": "{count} por semana",
   "boost_modal.combo": "Preme {combo} para ignorar isto na seguinte vez",
   "bundle_column_error.body": "Ocorreu un erro ó cargar este compoñente.",
   "bundle_column_error.retry": "Téntao de novo",
-  "bundle_column_error.title": "Erro na rede",
+  "bundle_column_error.title": "Fallo na rede",
   "bundle_modal_error.close": "Pechar",
   "bundle_modal_error.message": "Ocorreu un erro ó cargar este compoñente.",
   "bundle_modal_error.retry": "Téntao de novo",
-  "column.blocks": "Usuarios bloqueados",
+  "column.blocks": "Usuarias bloqueadas",
   "column.bookmarks": "Marcadores",
   "column.community": "Cronoloxía local",
   "column.direct": "Mensaxes directas",
@@ -59,9 +60,9 @@
   "column.domain_blocks": "Dominios agochados",
   "column.favourites": "Favoritos",
   "column.follow_requests": "Peticións de seguimento",
-  "column.home": "Páxina inicial",
+  "column.home": "Inicio",
   "column.lists": "Listaxes",
-  "column.mutes": "Usuarios silenciados",
+  "column.mutes": "Usuarias acaladas",
   "column.notifications": "Notificacións",
   "column.pins": "Toots fixados",
   "column.public": "Cronoloxía federada",
@@ -74,7 +75,7 @@
   "column_header.unpin": "Desafixar",
   "column_subheading.settings": "Axustes",
   "community.column_settings.media_only": "Só multimedia",
-  "compose_form.direct_message_warning": "Este toot só será enviado ós usuarios mencionados.",
+  "compose_form.direct_message_warning": "Este toot só será enviado ás usuarias mencionadas.",
   "compose_form.direct_message_warning_learn_more": "Coñecer máis",
   "compose_form.hashtag_warning": "Este toot non se amosará baixo cancelos (hashtags) porque non é público. Só os toots públicos poden ser procurados por cancelos.",
   "compose_form.lock_disclaimer": "A túa conta non está {locked}. Todos poden seguirche para ollar os teus toots só para seguidores.",
@@ -86,7 +87,7 @@
   "compose_form.poll.remove_option": "Eliminar esta opción",
   "compose_form.poll.switch_to_multiple": "Mudar a enquisa para permitir múltiples escollas",
   "compose_form.poll.switch_to_single": "Mudar a enquisa para permitir unha soa escolla",
-  "compose_form.publish": "Toot",
+  "compose_form.publish": "Tootear",
   "compose_form.publish_loud": "{publish}!",
   "compose_form.sensitive.hide": "Marcar coma contido multimedia sensíbel",
   "compose_form.sensitive.marked": "Contido multimedia marcado coma sensíbel",
@@ -111,7 +112,7 @@
   "confirmations.mute.message": "Tes a certeza de querer silenciar a {name}?",
   "confirmations.redraft.confirm": "Eliminar e reescribir",
   "confirmations.redraft.message": "Tes a certeza de querer eliminar este estado e reescribilo? Perderás os compartidos e favoritos, e as respostas á publicación orixinal ficarán orfas.",
-  "confirmations.reply.confirm": "Respostar",
+  "confirmations.reply.confirm": "Responder",
   "confirmations.reply.message": "Respostar agora sobrescribirá a mensaxe que estás a compor. Tes a certeza de que queres continuar?",
   "confirmations.unfollow.confirm": "Deixar de seguir",
   "confirmations.unfollow.message": "Desexas deixar de seguir a {name}?",
@@ -141,7 +142,7 @@
   "emoji_button.travel": "Viaxes e lugares",
   "empty_column.account_timeline": "Non hai toots aquí!",
   "empty_column.account_unavailable": "Perfil non dispoñíbel",
-  "empty_column.blocks": "Aínda non bloqueaches a ningún usuario.",
+  "empty_column.blocks": "Aínda non bloqueaches a ningún usuaria.",
   "empty_column.bookmarked_statuses": "Aínda non marcaches ningún toot. Cando o fagas, amosaranse aquí.",
   "empty_column.community": "A cronoloxía local está baleira. Escribe algo de xeito público para espallalo!",
   "empty_column.direct": "Aínda non tes mensaxes directas. Cando envíes ou recibas unha, amosarase aquí.",
@@ -150,13 +151,13 @@
   "empty_column.favourites": "A ninguén lle gostou este toot polo momento. Cando a alguén lle goste, aparecerá aquí.",
   "empty_column.follow_requests": "Non tes peticións de seguimento. Cando recibas unha, amosarase aquí.",
   "empty_column.hashtag": "Aínda non hai nada con este cancelo.",
-  "empty_column.home": "A túa cronoloxía inicial está baleira! Visita {public} ou emprega a procura para atopar outros usuarios.",
+  "empty_column.home": "A túa cronoloxía inicial está baleira! Visita {public} ou emprega a procura para atopar outras usuarias.",
   "empty_column.home.public_timeline": "a cronoloxía pública",
-  "empty_column.list": "Aínda non hai nada nesta listaxe. Cando os usuarios incluídas na listaxe publiquen mensaxes, amosaranse aquí.",
+  "empty_column.list": "Aínda non hai nada en esta lista. Cando as usuarias incluídas na lista publiquen mensaxes, aparecerán aquí.",
   "empty_column.lists": "Aínda non tes listaxes. Cando crees unha, amosarase aquí.",
-  "empty_column.mutes": "Aínda non silenciaches a ningún usuario.",
+  "empty_column.mutes": "Aínda non silenciaches a ningúnha usuaria.",
   "empty_column.notifications": "Aínda non tes notificacións. Interactúa con outros para comezar unha conversa.",
-  "empty_column.public": "Nada por aquí! Escribe algo de xeito público, ou sigue de xeito manual usuarios doutros servidores para ir enchéndoo",
+  "empty_column.public": "Nada por aquí! Escribe algo de xeito público, ou segue de xeito manual usuarias doutros servidores para ir enchéndoo",
   "error.unexpected_crash.explanation": "Debido a un erro no noso código ou a unha compatilidade co teu navegador, esta páxina non pode ser amosada correctamente.",
   "error.unexpected_crash.next_steps": "Tenta actualizar a páxina. Se esto non axuda podes tamén empregar o Mastodon noutro navegador ou aplicación nativa.",
   "errors.unexpected_crash.copy_stacktrace": "Copiar trazas (stacktrace) ó portapapeis",
@@ -196,16 +197,16 @@
   "introduction.interactions.action": "Rematar titorial!",
   "introduction.interactions.favourite.headline": "Favorito",
   "introduction.interactions.favourite.text": "Podes gardar un toot para depois e facer saber ó autor que che gostou marcandoo coma favorito.",
-  "introduction.interactions.reblog.headline": "Compartir na cronoloxía",
-  "introduction.interactions.reblog.text": "Podes compartir os toots doutras persoas cos teus seguidores.",
+  "introduction.interactions.reblog.headline": "Promover",
+  "introduction.interactions.reblog.text": "Podes compartir os toots doutras persoas coas túas seguidoras.",
   "introduction.interactions.reply.headline": "Respostar",
   "introduction.interactions.reply.text": "Podes respostar ós toots doutras persoas e ós teus propios, así ficarán encadeados nunha conversa.",
   "introduction.welcome.action": "Imos!",
   "introduction.welcome.headline": "Primeiros pasos",
   "introduction.welcome.text": "Benvido ó fediverso! Nun intre poderás difundir mensaxes e falar coas túas amizades nun grande número de servidores. Mais este servidor, {domain}, é especial—hospeda o teu perfil, por iso lémbrate do seu nome.",
   "keyboard_shortcuts.back": "para voltar atrás",
-  "keyboard_shortcuts.blocked": "para abrir a listaxe de usuarios bloqueados",
-  "keyboard_shortcuts.boost": "para compartir na cronoloxía",
+  "keyboard_shortcuts.blocked": "abrir lista de usuarias bloqueadas",
+  "keyboard_shortcuts.boost": "promover",
   "keyboard_shortcuts.column": "para destacar un estado nunha das columnas",
   "keyboard_shortcuts.compose": "para destacar a área de escritura",
   "keyboard_shortcuts.description": "Descrición",
@@ -221,7 +222,7 @@
   "keyboard_shortcuts.legend": "para amosar esta lenda",
   "keyboard_shortcuts.local": "para abrir a cronoloxía local",
   "keyboard_shortcuts.mention": "para mencionar ó autor",
-  "keyboard_shortcuts.muted": "para abrir a listaxe dos usuarios silenciados",
+  "keyboard_shortcuts.muted": "abrir lista de usuarias acaladas",
   "keyboard_shortcuts.my_profile": "para abrir o teu perfil",
   "keyboard_shortcuts.notifications": "para abrir a columna das notificacións",
   "keyboard_shortcuts.open_media": "para abrir o contido multimedia",
@@ -254,9 +255,9 @@
   "media_gallery.toggle_visible": "Trocar visibilidade",
   "missing_indicator.label": "Non atopado",
   "missing_indicator.sublabel": "Este recurso non foi atopado",
-  "mute_modal.hide_notifications": "Agochar notificacións deste usuario?",
+  "mute_modal.hide_notifications": "Agochar notificacións desta usuaria?",
   "navigation_bar.apps": "Aplicacións móbiles",
-  "navigation_bar.blocks": "Usuarios bloqueados",
+  "navigation_bar.blocks": "Usuarias bloqueadas",
   "navigation_bar.bookmarks": "Marcadores",
   "navigation_bar.community_timeline": "Cronoloxía local",
   "navigation_bar.compose": "Escribir un novo toot",
@@ -272,7 +273,7 @@
   "navigation_bar.keyboard_shortcuts": "Atallos do teclado",
   "navigation_bar.lists": "Listaxes",
   "navigation_bar.logout": "Pechar sesión",
-  "navigation_bar.mutes": "Usuarios silenciados",
+  "navigation_bar.mutes": "Usuarias silenciadas",
   "navigation_bar.personal": "Persoal",
   "navigation_bar.pins": "Toots fixados",
   "navigation_bar.preferences": "Preferencias",
@@ -284,7 +285,7 @@
   "notification.mention": "{name} mencionoute",
   "notification.own_poll": "A túa enquisa rematou",
   "notification.poll": "Unha enquisa na que votaches rematou",
-  "notification.reblog": "{name} compartiu o teu estado",
+  "notification.reblog": "{name} promoveu o teu estado",
   "notifications.clear": "Limpar notificacións",
   "notifications.clear_confirmation": "Tes a certeza de querer limpar de xeito permanente todas as túas notificacións?",
   "notifications.column_settings.alert": "Notificacións de escritorio",
@@ -297,7 +298,7 @@
   "notifications.column_settings.mention": "Mencións:",
   "notifications.column_settings.poll": "Resultados da enquisa:",
   "notifications.column_settings.push": "Notificacións emerxentes",
-  "notifications.column_settings.reblog": "Compartidos:",
+  "notifications.column_settings.reblog": "Promocións:",
   "notifications.column_settings.show": "Amosar en columna",
   "notifications.column_settings.sound": "Reproducir son",
   "notifications.filter.all": "Todo",
@@ -316,7 +317,7 @@
   "poll_button.add_poll": "Engadir unha enquisa",
   "poll_button.remove_poll": "Eliminar enquisa",
   "privacy.change": "Axustar privacidade",
-  "privacy.direct.long": "Só para os usuarios mencionados",
+  "privacy.direct.long": "Só para as usuarias mencionadas",
   "privacy.direct.short": "Directo",
   "privacy.private.long": "Só para os seguidores",
   "privacy.private.short": "Só seguidores",
@@ -341,11 +342,11 @@
   "report.target": "Denunciar a {target}",
   "search.placeholder": "Procurar",
   "search_popout.search_format": "Formato de procura avanzada",
-  "search_popout.tips.full_text": "Texto sinxelo que devolve estados que ti escribiches, compartiches, marcaches favorito, ou foches mencionado, así como nomes de usuario coincidentes, nomes públicos e cancelos.",
+  "search_popout.tips.full_text": "Texto simple devolve estados que ti escribiches, promoviches, marcaches  favoritos, ou foches mencionada, así como nomes de usuaria coincidentes, nomes públicos e etiquetas.",
   "search_popout.tips.hashtag": "cancelo",
   "search_popout.tips.status": "estado",
-  "search_popout.tips.text": "Texto sinxelo que devolve coincidencias con nomes públicos, nomes de usuario e cancelos",
-  "search_popout.tips.user": "usuario",
+  "search_popout.tips.text": "Texto simple devolve coincidencias con nomes públicos, nomes de usuaria e etiquetas",
+  "search_popout.tips.user": "usuaria",
   "search_results.accounts": "Persoas",
   "search_results.hashtags": "Cancelos",
   "search_results.statuses": "Toots",
@@ -356,7 +357,7 @@
   "status.block": "Bloquear @{name}",
   "status.bookmark": "Marcar",
   "status.cancel_reblog_private": "Desfacer compartido",
-  "status.cannot_reblog": "Esta publicación non pode ser compartida",
+  "status.cannot_reblog": "Esta publicación non pode ser promovida",
   "status.copy": "Copiar ligazón ó estado",
   "status.delete": "Eliminar",
   "status.detailed_status": "Vista detallada da conversa",
@@ -374,10 +375,10 @@
   "status.pin": "Fixar no perfil",
   "status.pinned": "Toot fixado",
   "status.read_more": "Ler máis",
-  "status.reblog": "Compartir",
+  "status.reblog": "Promover",
   "status.reblog_private": "Compartir á audiencia orixinal",
-  "status.reblogged_by": "{name} compartiu",
-  "status.reblogs.empty": "Aínda ninguén compartiu este toot. Cando alguén o faga, amosarase aquí.",
+  "status.reblogged_by": "{name} promoveu",
+  "status.reblogs.empty": "Aínda ninguén promoveu este toot. Cando alguén o faga, amosarase aquí.",
   "status.redraft": "Eliminar e reescribir",
   "status.remove_bookmark": "Eliminar marcador",
   "status.reply": "Respostar",
diff --git a/app/javascript/mastodon/locales/he.json b/app/javascript/mastodon/locales/he.json
index e69fa5d8f..07217364a 100644
--- a/app/javascript/mastodon/locales/he.json
+++ b/app/javascript/mastodon/locales/he.json
@@ -43,6 +43,7 @@
   "alert.rate_limited.title": "Rate limited",
   "alert.unexpected.message": "אירעה שגיאה בלתי צפויה.",
   "alert.unexpected.title": "אופס!",
+  "announcement.announcement": "Announcement",
   "autosuggest_hashtag.per_week": "{count} per week",
   "boost_modal.combo": "ניתן להקיש {combo} כדי לדלג בפעם הבאה",
   "bundle_column_error.body": "משהו השתבש בעת הצגת הרכיב הזה.",
diff --git a/app/javascript/mastodon/locales/hi.json b/app/javascript/mastodon/locales/hi.json
index 0e8830174..ff7ea9e6a 100644
--- a/app/javascript/mastodon/locales/hi.json
+++ b/app/javascript/mastodon/locales/hi.json
@@ -43,6 +43,7 @@
   "alert.rate_limited.title": "सीमित दर",
   "alert.unexpected.message": "एक अप्रत्याशित त्रुटि हुई है!",
   "alert.unexpected.title": "उफ़!",
+  "announcement.announcement": "Announcement",
   "autosuggest_hashtag.per_week": "{count} हर सप्ताह",
   "boost_modal.combo": "अगली बार स्किप करने के लिए आप {combo} दबा सकते है",
   "bundle_column_error.body": "इस कॉम्पोनेन्ट को लोड करते वक्त कुछ गलत हो गया",
diff --git a/app/javascript/mastodon/locales/hr.json b/app/javascript/mastodon/locales/hr.json
index da01b92ae..31327e3d3 100644
--- a/app/javascript/mastodon/locales/hr.json
+++ b/app/javascript/mastodon/locales/hr.json
@@ -43,6 +43,7 @@
   "alert.rate_limited.title": "Rate limited",
   "alert.unexpected.message": "An unexpected error occurred.",
   "alert.unexpected.title": "Oops!",
+  "announcement.announcement": "Announcement",
   "autosuggest_hashtag.per_week": "{count} per week",
   "boost_modal.combo": "Možeš pritisnuti {combo} kako bi ovo preskočio sljedeći put",
   "bundle_column_error.body": "Something went wrong while loading this component.",
diff --git a/app/javascript/mastodon/locales/hu.json b/app/javascript/mastodon/locales/hu.json
index 57511307a..f4280e740 100644
--- a/app/javascript/mastodon/locales/hu.json
+++ b/app/javascript/mastodon/locales/hu.json
@@ -1,7 +1,7 @@
 {
   "account.add_or_remove_from_list": "Hozzáadás vagy eltávolítás a listáról",
   "account.badges.bot": "Bot",
-  "account.badges.group": "Group",
+  "account.badges.group": "Csoport",
   "account.block": "@{name} letiltása",
   "account.block_domain": "Minden elrejtése innen: {domain}",
   "account.blocked": "Letiltva",
@@ -43,6 +43,7 @@
   "alert.rate_limited.title": "Forgalomkorlátozás",
   "alert.unexpected.message": "Váratlan hiba történt.",
   "alert.unexpected.title": "Hoppá!",
+  "announcement.announcement": "Announcement",
   "autosuggest_hashtag.per_week": "{count}/hét",
   "boost_modal.combo": "Hogy átugord ezt következő alkalommal, használd {combo}",
   "bundle_column_error.body": "Hiba történt a komponens betöltése közben.",
@@ -84,8 +85,8 @@
   "compose_form.poll.duration": "Szavazás időtartama",
   "compose_form.poll.option_placeholder": "{number}. lehetőség",
   "compose_form.poll.remove_option": "Lehetőség törlése",
-  "compose_form.poll.switch_to_multiple": "Change poll to allow multiple choices",
-  "compose_form.poll.switch_to_single": "Change poll to allow for a single choice",
+  "compose_form.poll.switch_to_multiple": "Szavazás megváltoztatása több választásosra",
+  "compose_form.poll.switch_to_single": "Szavazás megváltoztatása egyetlen választásosra",
   "compose_form.publish": "Tülk",
   "compose_form.publish_loud": "{publish}!",
   "compose_form.sensitive.hide": "Média megjelölése szenzitívként",
diff --git a/app/javascript/mastodon/locales/hy.json b/app/javascript/mastodon/locales/hy.json
index b20b744d5..1ad3cb0fd 100644
--- a/app/javascript/mastodon/locales/hy.json
+++ b/app/javascript/mastodon/locales/hy.json
@@ -1,7 +1,7 @@
 {
   "account.add_or_remove_from_list": "Աւելացնել կամ հեռացնել ցանկերից",
   "account.badges.bot": "Բոտ",
-  "account.badges.group": "Group",
+  "account.badges.group": "Խումբ",
   "account.block": "Արգելափակել @{name}֊ին",
   "account.block_domain": "Թաքցնել ամենը հետեւյալ տիրույթից՝ {domain}",
   "account.blocked": "Արգելափակուած է",
@@ -27,7 +27,7 @@
   "account.mute_notifications": "Անջատել ծանուցումները @{name}֊ից",
   "account.muted": "Լռեցուած",
   "account.never_active": "Երբեք",
-  "account.posts": "Գրառումներ",
+  "account.posts": "Թութ",
   "account.posts_with_replies": "Toots with replies",
   "account.report": "Բողոքել @{name}֊ից",
   "account.requested": "Հաստատման կարիք ունի։ Սեղմիր՝ հետեւելու հայցը չեղարկելու համար։",
@@ -43,6 +43,7 @@
   "alert.rate_limited.title": "Rate limited",
   "alert.unexpected.message": "Անսպասելի սխալ տեղի ունեցաւ։",
   "alert.unexpected.title": "Վա՜յ",
+  "announcement.announcement": "Announcement",
   "autosuggest_hashtag.per_week": "շաբաթը՝ {count}",
   "boost_modal.combo": "Կարող ես սեղմել {combo}՝ սա հաջորդ անգամ բաց թողնելու համար",
   "bundle_column_error.body": "Այս բաղադրիչը բեռնելու ընթացքում ինչ֊որ բան խափանվեց։",
@@ -401,25 +402,25 @@
   "tabs_bar.notifications": "Ծանուցումներ",
   "tabs_bar.search": "Փնտրել",
   "time_remaining.days": "{number, plural, one {# day} other {# days}} left",
-  "time_remaining.hours": "{number, plural, one {# hour} other {# hours}} left",
-  "time_remaining.minutes": "{number, plural, one {# minute} other {# minutes}} left",
+  "time_remaining.hours": "{number, plural, one {# ժամ} other {# ժամ}} անց",
+  "time_remaining.minutes": "{number, plural, one {# րոպե} other {# րոպե}} անց",
   "time_remaining.moments": "Moments remaining",
-  "time_remaining.seconds": "{number, plural, one {# second} other {# seconds}} left",
+  "time_remaining.seconds": "{number, plural, one {# վայրկյան} other {# վայրկյան}} անց",
   "trends.count_by_accounts": "{count} {rawCount, plural, one {person} other {people}} talking",
-  "trends.trending_now": "Trending now",
+  "trends.trending_now": "Այժմ արդիական",
   "ui.beforeunload": "Քո սեւագիրը կկորի, եթե լքես Մաստոդոնը։",
   "upload_area.title": "Քաշիր ու նետիր՝ վերբեռնելու համար",
   "upload_button.label": "Ավելացնել մեդիա",
-  "upload_error.limit": "File upload limit exceeded.",
+  "upload_error.limit": "Ֆայլի վերբեռնման սահմանաչափը գերազանցված է։",
   "upload_error.poll": "File upload not allowed with polls.",
   "upload_form.audio_description": "Describe for people with hearing loss",
   "upload_form.description": "Նկարագրություն ավելացրու տեսողական խնդիրներ ունեցողների համար",
   "upload_form.edit": "Խմբագրել",
   "upload_form.undo": "Հետարկել",
   "upload_form.video_description": "Describe for people with hearing loss or visual impairment",
-  "upload_modal.analyzing_picture": "Analyzing picture…",
+  "upload_modal.analyzing_picture": "Լուսանկարի վերլուծում…",
   "upload_modal.apply": "Կիրառել",
-  "upload_modal.description_placeholder": "A quick brown fox jumps over the lazy dog",
+  "upload_modal.description_placeholder": "Ճկուն շագանակագույն աղվեսը ցատկում է ծույլ շան վրայով",
   "upload_modal.detect_text": "Հայտնբերել տեքստը նկարից",
   "upload_modal.edit_media": "Խմբագրել մեդիան",
   "upload_modal.hint": "Click or drag the circle on the preview to choose the focal point which will always be in view on all thumbnails.",
diff --git a/app/javascript/mastodon/locales/id.json b/app/javascript/mastodon/locales/id.json
index 8e7b492fe..b1605ac60 100644
--- a/app/javascript/mastodon/locales/id.json
+++ b/app/javascript/mastodon/locales/id.json
@@ -43,6 +43,7 @@
   "alert.rate_limited.title": "Batasan tingkat",
   "alert.unexpected.message": "Terjadi kesalahan yang tidak terduga.",
   "alert.unexpected.title": "Oops!",
+  "announcement.announcement": "Announcement",
   "autosuggest_hashtag.per_week": "{count} per minggu",
   "boost_modal.combo": "Anda dapat menekan {combo} untuk melewati ini",
   "bundle_column_error.body": "Kesalahan terjadi saat memuat komponen ini.",
diff --git a/app/javascript/mastodon/locales/io.json b/app/javascript/mastodon/locales/io.json
index 673a0deb2..36bf1a588 100644
--- a/app/javascript/mastodon/locales/io.json
+++ b/app/javascript/mastodon/locales/io.json
@@ -43,6 +43,7 @@
   "alert.rate_limited.title": "Rate limited",
   "alert.unexpected.message": "An unexpected error occurred.",
   "alert.unexpected.title": "Oops!",
+  "announcement.announcement": "Announcement",
   "autosuggest_hashtag.per_week": "{count} per week",
   "boost_modal.combo": "Tu povas presar sur {combo} por omisar co en la venonta foyo",
   "bundle_column_error.body": "Something went wrong while loading this component.",
diff --git a/app/javascript/mastodon/locales/is.json b/app/javascript/mastodon/locales/is.json
index c1f1b3720..53b2a150a 100644
--- a/app/javascript/mastodon/locales/is.json
+++ b/app/javascript/mastodon/locales/is.json
@@ -10,10 +10,10 @@
   "account.domain_blocked": "Lén falið",
   "account.edit_profile": "Breyta notandasniði",
   "account.endorse": "Birta á notandasniði",
-  "account.follow": "Fylgja",
+  "account.follow": "Fylgjast með",
   "account.followers": "Fylgjendur",
   "account.followers.empty": "Ennþá fylgist enginn með þessum notanda.",
-  "account.follows": "Fylgir",
+  "account.follows": "Fylgist með",
   "account.follows.empty": "Þessi notandi fylgist ennþá ekki með neinum.",
   "account.follows_you": "Fylgir þér",
   "account.hide_reblogs": "Fela endurbirtingar fyrir @{name}",
@@ -43,6 +43,7 @@
   "alert.rate_limited.title": "Með takmörkum",
   "alert.unexpected.message": "Upp kom óvænt villa.",
   "alert.unexpected.title": "Úbbs!",
+  "announcement.announcement": "Announcement",
   "autosuggest_hashtag.per_week": "{count} á viku",
   "boost_modal.combo": "Þú getur ýtt á {combo} til að sleppa þessu næst",
   "bundle_column_error.body": "Eitthvað fór úrskeiðis við að hlaða inn þessari einingu.",
@@ -266,7 +267,7 @@
   "navigation_bar.edit_profile": "Breyta notandasniði",
   "navigation_bar.favourites": "Eftirlæti",
   "navigation_bar.filters": "Þögguð orð",
-  "navigation_bar.follow_requests": "Fylgja beiðnum",
+  "navigation_bar.follow_requests": "Beiðnir um að fylgjast með",
   "navigation_bar.follows_and_followers": "Fylgist með og fylgjendur",
   "navigation_bar.info": "Um þennan vefþjón",
   "navigation_bar.keyboard_shortcuts": "Flýtilyklar",
@@ -279,7 +280,7 @@
   "navigation_bar.public_timeline": "Sameiginleg tímalína",
   "navigation_bar.security": "Öryggi",
   "notification.favourite": "{name} setti stöðufærslu þína í eftirlæti",
-  "notification.follow": "{name} fylgdist með þér",
+  "notification.follow": "{name} fylgist með þér",
   "notification.follow_request": "{name} hefur beðið um að fylgjast með þér",
   "notification.mention": "{name} minntist á þig",
   "notification.own_poll": "Könnuninni þinni er lokið",
diff --git a/app/javascript/mastodon/locales/it.json b/app/javascript/mastodon/locales/it.json
index 3d5353f2a..1b31f90f6 100644
--- a/app/javascript/mastodon/locales/it.json
+++ b/app/javascript/mastodon/locales/it.json
@@ -1,7 +1,7 @@
 {
   "account.add_or_remove_from_list": "Aggiungi o togli dalle liste",
   "account.badges.bot": "Bot",
-  "account.badges.group": "Group",
+  "account.badges.group": "Gruppo",
   "account.block": "Blocca @{name}",
   "account.block_domain": "Nascondi tutto da {domain}",
   "account.blocked": "Bloccato",
@@ -43,6 +43,7 @@
   "alert.rate_limited.title": "Numero massimo di richieste superato",
   "alert.unexpected.message": "Si è verificato un errore inatteso.",
   "alert.unexpected.title": "Oops!",
+  "announcement.announcement": "Announcement",
   "autosuggest_hashtag.per_week": "{count} per settimana",
   "boost_modal.combo": "Puoi premere {combo} per saltare questo passaggio la prossima volta",
   "bundle_column_error.body": "E' avvenuto un errore durante il caricamento di questo componente.",
diff --git a/app/javascript/mastodon/locales/ja.json b/app/javascript/mastodon/locales/ja.json
index 76ad78012..51eeb1eb6 100644
--- a/app/javascript/mastodon/locales/ja.json
+++ b/app/javascript/mastodon/locales/ja.json
@@ -43,6 +43,7 @@
   "alert.rate_limited.title": "制限に達しました",
   "alert.unexpected.message": "不明なエラーが発生しました。",
   "alert.unexpected.title": "エラー!",
+  "announcement.announcement": "告知",
   "autosuggest_hashtag.per_week": "{count} 回 / 週",
   "boost_modal.combo": "次からは{combo}を押せばスキップできます",
   "bundle_column_error.body": "コンポーネントの読み込み中に問題が発生しました。",
diff --git a/app/javascript/mastodon/locales/ka.json b/app/javascript/mastodon/locales/ka.json
index 1c6203c46..1222702a4 100644
--- a/app/javascript/mastodon/locales/ka.json
+++ b/app/javascript/mastodon/locales/ka.json
@@ -43,6 +43,7 @@
   "alert.rate_limited.title": "Rate limited",
   "alert.unexpected.message": "წარმოიშვა მოულოდნელი შეცდომა.",
   "alert.unexpected.title": "უპს!",
+  "announcement.announcement": "Announcement",
   "autosuggest_hashtag.per_week": "კვირაში {count}",
   "boost_modal.combo": "შეგიძლიათ დააჭიროთ {combo}-ს რათა შემდეგ ჯერზე გამოტოვოთ ეს",
   "bundle_column_error.body": "ამ კომპონენტის ჩატვირთვისას რაღაც აირია.",
diff --git a/app/javascript/mastodon/locales/kab.json b/app/javascript/mastodon/locales/kab.json
index edb539f4f..ec77fa9e4 100644
--- a/app/javascript/mastodon/locales/kab.json
+++ b/app/javascript/mastodon/locales/kab.json
@@ -1,15 +1,15 @@
 {
-  "account.add_or_remove_from_list": "Rnu neɣ kkes seg tebdarin",
+  "account.add_or_remove_from_list": "Rnu neγ kkes seg tebdarin",
   "account.badges.bot": "Aṛubut",
-  "account.badges.group": "Group",
+  "account.badges.group": "Agraw",
   "account.block": "Seḥbes @{name}",
   "account.block_domain": "Ffer kra i d-yekkan seg {domain}",
   "account.blocked": "Yettuseḥbes",
   "account.cancel_follow_request": "Sefsex asuter n weḍfaṛ",
   "account.direct": "Izen usrid i @{name}",
-  "account.domain_blocked": "Taɣult yeffren",
-  "account.edit_profile": "Ẓreg amaɣnu",
-  "account.endorse": "Welleh fell-as deg umaɣnu-inek",
+  "account.domain_blocked": "Taγult yeffren",
+  "account.edit_profile": "Ẓreg amaγnu",
+  "account.endorse": "Welleh fell-as deg umaγnu-inek",
   "account.follow": "Ḍfeṛ",
   "account.followers": "Imeḍfaṛen",
   "account.followers.empty": "Ar tura, ulac yiwen i yeṭṭafaṛen amseqdac-agi.",
@@ -18,33 +18,34 @@
   "account.follows_you": "Yeṭṭafaṛ-ik",
   "account.hide_reblogs": "Ffer ayen i ibeṭṭu @{name}",
   "account.last_status": "Armud aneggaru",
-  "account.link_verified_on": "Taɣara n useɣwen-a tettwasenqed de {date}",
+  "account.link_verified_on": "Taγara n useγwen-a tettwasenqed ass n {date}",
   "account.locked_info": "Amiḍan-agi uslig isekweṛ. D bab-is kan i izemren ad yeǧǧ, s ufus-is, win ara t-iḍefṛen.",
   "account.media": "Allal n teywalt",
   "account.mention": "Bder-d @{name}",
-  "account.moved_to": "{name} ibeddel ɣer:",
+  "account.moved_to": "{name} ibeddel γer:",
   "account.mute": "Sgugem @{name}",
-  "account.mute_notifications": "Ḥbes ilɣa sɣur @{name}",
+  "account.mute_notifications": "Susem ilγa sγur @{name}",
   "account.muted": "Yettwasgugem",
   "account.never_active": "Werǧin",
   "account.posts": "Tiberraḥin",
   "account.posts_with_replies": "Tibarraḥin d tririyin",
   "account.report": "Sewɛed @{name}",
   "account.requested": "Di laɛḍil ad yettwaqbel. Ssit iwakken ad yefsex usuter n weḍfar",
-  "account.share": "Bḍu amaɣnu n @{name}",
+  "account.share": "Bḍu amaγnu n @{name}",
   "account.show_reblogs": "Sken-d inebḍa n @{name}",
   "account.unblock": "Serreḥ i @{name}",
   "account.unblock_domain": "Kkes tuffra i {domain}",
-  "account.unendorse": "Ur ttwellih ara fell-as deg umaɣnu-inek",
+  "account.unendorse": "Ur ttwellih ara fell-as deg umaγnu-inek",
   "account.unfollow": "Ur ṭṭafaṛ ara",
-  "account.unmute": "Kkes asgugem ɣef @{name}",
-  "account.unmute_notifications": "Serreḥ ilɣa sɣur @{name}",
-  "alert.rate_limited.message": "Ma ulac aɣilif ɛreḍ tikelt-nniḍen mbeɛd {retry_time, time, medium}.",
+  "account.unmute": "Kkes asgugem γef @{name}",
+  "account.unmute_notifications": "Serreḥ ilγa sγur @{name}",
+  "alert.rate_limited.message": "Ma ulac aγilif ɛreḍ tikelt-nniḍen mbeɛd {retry_time, time, medium}.",
   "alert.rate_limited.title": "Aktum s talast",
   "alert.unexpected.message": "Tella-d tuccḍa i ɣef ur nedmi ara.",
   "alert.unexpected.title": "Ayhuh!",
+  "announcement.announcement": "Announcement",
   "autosuggest_hashtag.per_week": "{count} i yimalas",
-  "boost_modal.combo": "Tzemreḍ ad tetekkiḍ ɣef {combo} akken ad tessurfeḍ aya tikelt-nniḍen",
+  "boost_modal.combo": "Tzemreḍ ad tetekkiḍ γef {combo} akken ad tessurfeḍ aya tikelt-nniḍen",
   "bundle_column_error.body": "Tella-d kra n tuccḍa mi d-yettali ugbur-agi.",
   "bundle_column_error.retry": "Ɛreḍ tikelt-nniḍen",
   "bundle_column_error.title": "Tuccḍa deg uẓeṭṭa",
@@ -55,24 +56,24 @@
   "column.bookmarks": "Ticraḍ",
   "column.community": "Tasuddemt tadigant",
   "column.direct": "Iznan usriden",
-  "column.directory": "Qelleb deg yimaɣnuten",
-  "column.domain_blocks": "Tiɣula yettwaffren",
+  "column.directory": "Qelleb deg imaγnuten",
+  "column.domain_blocks": "Tiγula yettwaffren",
   "column.favourites": "Ismenyifen",
   "column.follow_requests": "Isuturen n teḍfeṛt",
   "column.home": "Agejdan",
   "column.lists": "Tibdarin",
   "column.mutes": "Imiḍanen yettwasgugmen",
-  "column.notifications": "Tilɣa",
+  "column.notifications": "Tilγa",
   "column.pins": "Tiberraḥin yettwasenṭḍen",
   "column.public": "Tasuddemt tamatut",
-  "column_back_button.label": "Tuɣalin",
-  "column_header.hide_settings": "Ffer iɣewwaṛen",
-  "column_header.moveLeft_settings": "Err ajgu ɣer tama tazelmaḍt",
-  "column_header.moveRight_settings": "Err ajgu ɣer tama tayfust",
+  "column_back_button.label": "Tuγalin",
+  "column_header.hide_settings": "Ffer iγewwaṛen",
+  "column_header.moveLeft_settings": "Err ajgu γer tama tazelmaḍt",
+  "column_header.moveRight_settings": "Err ajgu γer tama tayfust",
   "column_header.pin": "Senteḍ",
-  "column_header.show_settings": "Sken iɣewwaṛen",
+  "column_header.show_settings": "Sken iγewwaṛen",
   "column_header.unpin": "Kkes asenteḍ",
-  "column_subheading.settings": "Iɣewwaṛen",
+  "column_subheading.settings": "Iγewwaṛen",
   "community.column_settings.media_only": "Allal n teywalt kan",
   "compose_form.direct_message_warning": "Taberraḥt-a ad d-tettwasken kan i yimseqdacen i d-yettwabedren.",
   "compose_form.direct_message_warning_learn_more": "Issin ugar",
@@ -91,39 +92,39 @@
   "compose_form.sensitive.hide": "Creḍ allal n teywalt d anafri",
   "compose_form.sensitive.marked": "Allal n teywalt yettwacreḍ d anafri",
   "compose_form.sensitive.unmarked": "Allal n teywalt ur yettwacreḍ ara d anafri",
-  "compose_form.spoiler.marked": "Aḍris yeffer deffir n walɣu",
+  "compose_form.spoiler.marked": "Aḍris yeffer deffir n walγu",
   "compose_form.spoiler.unmarked": "Aḍris ur yettwaffer ara",
-  "compose_form.spoiler_placeholder": "Aru alɣu-inek da",
+  "compose_form.spoiler_placeholder": "Aru alγu-inek da",
   "confirmation_modal.cancel": "Sefsex",
   "confirmations.block.block_and_report": "Sewḥel & sewɛed",
   "confirmations.block.confirm": "Sewḥel",
-  "confirmations.block.message": "Tebɣiḍ s tidet ad tesḥebseḍ {name}?",
+  "confirmations.block.message": "Tebγiḍ s tidet ad tesḥebseḍ {name}?",
   "confirmations.delete.confirm": "Kkes",
-  "confirmations.delete.message": "Tebɣiḍ s tidet ad tekkseḍ tasuffeɣt-agi?",
+  "confirmations.delete.message": "Tebγiḍ s tidet ad tekkseḍ tasuffeγt-agi?",
   "confirmations.delete_list.confirm": "Kkes",
-  "confirmations.delete_list.message": "Tebɣiḍ s tidet ad tekkseḍ tabdert-agi i lebda?",
-  "confirmations.domain_block.confirm": "Ffer taɣult meṛṛa",
+  "confirmations.delete_list.message": "Tebγiḍ s tidet ad tekkseḍ tabdert-agi i lebda?",
+  "confirmations.domain_block.confirm": "Ffer taγult meṛṛa",
   "confirmations.domain_block.message": "Are you really, really sure you want to block the entire {domain}? In most cases a few targeted blocks or mutes are sufficient and preferable. You will not see content from that domain in any public timelines or your notifications. Your followers from that domain will be removed.",
-  "confirmations.logout.confirm": "Ffeɣ",
-  "confirmations.logout.message": "D tidet tebɣiḍ ad teffɣeḍ?",
+  "confirmations.logout.confirm": "Ffeγ",
+  "confirmations.logout.message": "D tidet tebγiḍ ad teffγeḍ?",
   "confirmations.mute.confirm": "Sgugem",
-  "confirmations.mute.explanation": "Aya ad yeffer iznan-is d wid i deg d-yettwabder neɣ d-tettwabder, maca xas akka yezmer neɣ tezmer awali n yiznan-inek d uḍfaṛ-ik.",
-  "confirmations.mute.message": "Tetḥeqqeḍ belli tebɣiḍ asɛuggen n {name}?",
+  "confirmations.mute.explanation": "Aya ad yeffer iznan-is d wid i deg d-yettwabder neγ d-tettwabder, maca xas akka yezmer neγ tezmer awali n yiznan-inek d uḍfaṛ-ik.",
+  "confirmations.mute.message": "Tetḥeqqeḍ belli tebγiḍ asɛuggen n {name}?",
   "confirmations.redraft.confirm": "Sfeḍ & Ɛiwed tira",
-  "confirmations.redraft.message": "Tetḥeqqeḍ belli tebɣiḍ asfaḍ n waddad-agi iwakken ad s-tɛiwdeḍ tira? Ismenyifen d beḍḍuwat ad ṛuḥen, ma d tiririyin-is ad uɣalent d tigujilin.",
+  "confirmations.redraft.message": "Tetḥeqqeḍ belli tebγiḍ asfaḍ n waddad-agi iwakken ad s-tɛiwdeḍ tira? Ismenyifen d beḍḍuwat ad ṛuḥen, ma d tiririyin-is ad uγalent d tigujilin.",
   "confirmations.reply.confirm": "Err",
-  "confirmations.reply.message": "Tiririt akka tura ad k-degger izen-agi i tettaruḍ. Tebɣiḍ ad tkemmleḍ?",
+  "confirmations.reply.message": "Tiririt akka tura ad k-degger izen-agi i tettaruḍ. Tebγiḍ ad tkemmleḍ?",
   "confirmations.unfollow.confirm": "Ur ḍḍafaṛ ara",
-  "confirmations.unfollow.message": "Tetḥeqqeḍ belli tebɣiḍ ur teḍḍafaṛeḍ ara {name}?",
+  "confirmations.unfollow.message": "Tetḥeqqeḍ belli tebγiḍ ur teḍḍafaṛeḍ ara {name}?",
   "conversation.delete": "Sfeḍ adiwenni",
-  "conversation.mark_as_read": "Creḍ yettwaɣṛa",
+  "conversation.mark_as_read": "Creḍ yettwaγṛa",
   "conversation.open": "Sken adiwenni",
   "conversation.with": "Akked {names}",
   "directory.federated": "Seg fedivers yettwasnen",
   "directory.local": "Seg {domain} kan",
   "directory.new_arrivals": "Inebgawen imaynuten",
   "directory.recently_active": "Yermed xas melmi kan",
-  "embed.instructions": "Ẓẓu addad-agi deg usmel-inek s wenɣal n tangalt yellan sdaw-agi.",
+  "embed.instructions": "Ẓẓu addad-agi deg usmel-inek s wenγal n tangalt yellan sdaw-agi.",
   "embed.preview": "Akka ara d-iban:",
   "emoji_button.activity": "Aqeddic",
   "emoji_button.custom": "Udmawan",
@@ -132,7 +133,7 @@
   "emoji_button.label": "Sekcem imuji",
   "emoji_button.nature": "Agama",
   "emoji_button.not_found": "Ulac izamulen n yiḥulfan  !! (╯°□°)╯︵ ┻━┻",
-  "emoji_button.objects": "Tiɣawsiwin",
+  "emoji_button.objects": "Tiγawsiwin",
   "emoji_button.people": "Medden",
   "emoji_button.recent": "Wid yettuseqdacen s waṭas",
   "emoji_button.search": "Nadi…",
@@ -140,35 +141,35 @@
   "emoji_button.symbols": "Izamulen",
   "emoji_button.travel": "Imeḍqan d Yinigen",
   "empty_column.account_timeline": "Ulac tiberraḥin dagi!",
-  "empty_column.account_unavailable": "Ur nufi ara amaɣnu-a",
+  "empty_column.account_unavailable": "Ur nufi ara amaγnu-a",
   "empty_column.blocks": "Ur tesḥebseḍ ula yiwen n umseqdac ar tura.",
-  "empty_column.bookmarked_statuses": "Ulac tiberraḥin i terniḍ ɣer yismenyifen-ik ar tura. Ticki terniḍ yiwet, ad d-tettwasken da.",
+  "empty_column.bookmarked_statuses": "Ulac tiberraḥin i terniḍ γer yismenyifen-ik ar tura. Ticki terniḍ yiwet, ad d-tettwasken da.",
   "empty_column.community": "Tasuddemt tazayezt tadigant n yisallen d tilemt. Aru ihi kra akken ad tt-teččareḍ!",
-  "empty_column.direct": "Ulac ɣur-k ula yiwen n yizen usrid. Ad d-yettwasken da, ticki tuzneḍ neɣ teṭṭfeḍ-d yiwen.",
-  "empty_column.domain_blocks": "Ulac kra n taɣult yettwaffren ar tura.",
+  "empty_column.direct": "Ulac γur-k ula yiwen n yizen usrid. Ad d-yettwasken da, ticki tuzneḍ neγ teṭṭfeḍ-d yiwen.",
+  "empty_column.domain_blocks": "Ulac kra n taγult yettwaffren ar tura.",
   "empty_column.favourited_statuses": "Ulac ula yiwet n tberraḥt deg yismenyifen-ik ar tura. Ticki Tella-d yiwet, ad d-ban da.",
   "empty_column.favourites": "Ula yiwen ur yerri taberraḥt-agi deg yismenyifen-is. Melmi i d-yella waya, ad d-yettwasken da.",
-  "empty_column.follow_requests": "Ulac ɣur-k ula yiwen n usuter n teḍfeṛt. Ticki teṭṭfeḍ-d yiwen ad d-yettwasken da.",
-  "empty_column.hashtag": "Ar tura ulac kra n ugbur yesɛan assaɣ ɣer uhacṭag-agi.",
-  "empty_column.home": "Tasuddemt tagejdant n yisallen d tilemt! Ẓer {public} neɣ nadi ad tafeḍ imseqdacen-nniḍen ad ten-ḍefṛeḍ.",
+  "empty_column.follow_requests": "Ulac γur-k ula yiwen n usuter n teḍfeṛt. Ticki teṭṭfeḍ-d yiwen ad d-yettwasken da.",
+  "empty_column.hashtag": "Ar tura ulac kra n ugbur yesɛan assaγ γer uhacṭag-agi.",
+  "empty_column.home": "Tasuddemt tagejdant n yisallen d tilemt! Ẓer {public} neγ nadi ad tafeḍ imseqdacen-nniḍen ad ten-ḍefṛeḍ.",
   "empty_column.home.public_timeline": "tasuddemt tazayezt n yisallen",
-  "empty_column.list": "Ar tura ur yelli kra deg tebdert-a. Ad d-yettwasken da ticki iɛeggalen n tebdert-a suffɣen-d kra.",
-  "empty_column.lists": "Ulac ɣur-k kra n tebdert yakan. Ad d-tettwasken da ticki tesluleḍ-d yiwet.",
-  "empty_column.mutes": "Ulac ɣur-k imseqdacen i yettwasgugmen.",
-  "empty_column.notifications": "Ulac ɣur-k tilɣa. Sedmer akked yemdanen-nniḍen akken ad tebduḍ adiwenni.",
-  "empty_column.public": "Ulac kra da! Aru kra, neɣ ḍfeṛ imdanen i yellan deg yiqeddacen-nniḍen akken ad d-teččar tsuddemt tazayezt",
+  "empty_column.list": "Ar tura ur yelli kra deg tebdert-a. Ad d-yettwasken da ticki iɛeggalen n tebdert-a suffγen-d kra.",
+  "empty_column.lists": "Ulac γur-k kra n tebdert yakan. Ad d-tettwasken da ticki tesluleḍ-d yiwet.",
+  "empty_column.mutes": "Ulac γur-k imseqdacen i yettwasgugmen.",
+  "empty_column.notifications": "Ulac γur-k tilγa. Sedmer akked yemdanen-nniḍen akken ad tebduḍ adiwenni.",
+  "empty_column.public": "Ulac kra da! Aru kra, neγ ḍfeṛ imdanen i yellan deg yiqeddacen-nniḍen akken ad d-teččar tsuddemt tazayezt",
   "error.unexpected_crash.explanation": "Due to a bug in our code or a browser compatibility issue, this page could not be displayed correctly.",
-  "error.unexpected_crash.next_steps": "Smiren asebter-a, ma ur yekkis ara wugur, ẓer d akken tzemreḍ ad tesqedceḍ Mastudun deg yiminig-nniḍen neɣ deg usnas anaṣli.",
+  "error.unexpected_crash.next_steps": "Smiren asebter-a, ma ur yekkis ara wugur, ẓer d akken tzemreḍ ad tesqedceḍ Masṭudun deg yiminig-nniḍen neγ deg usnas anaṣli.",
   "errors.unexpected_crash.copy_stacktrace": "Copy stacktrace to clipboard",
   "errors.unexpected_crash.report_issue": "Mmel ugur",
   "follow_request.authorize": "Ssireg",
   "follow_request.reject": "Agi",
   "getting_started.developers": "Ineflayen",
-  "getting_started.directory": "Imaɣnuten",
+  "getting_started.directory": "Imaγnuten",
   "getting_started.documentation": "Amnir",
   "getting_started.heading": "Bdu",
   "getting_started.invite": "Snebgi-d imdanen",
-  "getting_started.open_source_notice": "Mastudun d aseɣzan s uɣbalu yeldin. Tzemreḍ ad tɛiwneḍ neɣ ad temmleḍ uguren seg GitHub {github}.",
+  "getting_started.open_source_notice": "Mastudun d aseγzan s uγbalu yeldin. Tzemreḍ ad tɛiwneḍ neγ ad temmleḍ uguren deg GitHub {github}.",
   "getting_started.security": "Iγewwaṛen n umiḍan",
   "getting_started.terms": "Tiwetlin n useqdec",
   "hashtag.column_header.tag_mode.all": "d {additional}",
@@ -190,29 +191,29 @@
   "introduction.federation.federated.headline": "Amatu",
   "introduction.federation.federated.text": "Iznan izuyaz i d-yekkan seg yiqeddacen-nniḍen n fediverse ad banen deg tsuddemt tazayezt tamatut n yisallen.",
   "introduction.federation.home.headline": "Agejdan",
-  "introduction.federation.home.text": "Iznan n yemdanen i teṭṭafaṛeḍ ad banen deg tsuddemt n umagger. Tzemreḍ ad tḍefṛeḍ win tebɣiḍ deg uqeddac i tebɣiḍ!",
+  "introduction.federation.home.text": "Iznan n yemdanen i teṭṭafaṛeḍ ad banen deg tsuddemt n umagger. Tzemreḍ ad tḍefṛeḍ win tebγiḍ deg uqeddac i tebγiḍ!",
   "introduction.federation.local.headline": "Adigan",
   "introduction.federation.local.text": "Iznan izuyaz n yemdanen i yellan deg yiwen uqeddac akked kečč ad d-banen deg tsuddemt tazayezt tadigant.",
   "introduction.interactions.action": "Fakk tameskant!",
   "introduction.interactions.favourite.headline": "Ismenyifen",
-  "introduction.interactions.favourite.text": "Tzemreḍ ad teǧǧeḍ kra n tberraḥt i ticki, daɣen ad tiniḍ i bab-is d akken taɛǧeb-ik, s tmerna-ines ɣer yismenyifen-ik.",
+  "introduction.interactions.favourite.text": "Tzemreḍ ad teǧǧeḍ kra n tberraḥt i ticki, daγen ad tiniḍ i bab-is d akken taɛǧeb-ik, s tmerna-ines γer yismenyifen-ik.",
   "introduction.interactions.reblog.headline": "Bḍu tikelt-nniḍen",
   "introduction.interactions.reblog.text": "Tzemreḍ ad tebḍuḍ akked yimeḍfaṛen-ik tiberraḥin n yemdanen-nniḍen s beṭṭu-nsent tikelt-nniḍen.",
   "introduction.interactions.reply.headline": "Err",
-  "introduction.interactions.reply.text": "Tzemreḍ ad terreḍ ɣef tberraḥin-ik d tid n medden-nniḍen, d acu ara tent-id-iɛeqden ta deffir ta deg udiwenni.",
+  "introduction.interactions.reply.text": "Tzemreḍ ad terreḍ γef tberraḥin-ik d tid n medden-nniḍen, d acu ara tent-id-iɛeqden ta deffir ta deg udiwenni.",
   "introduction.welcome.action": "Bdu!",
   "introduction.welcome.headline": "Isurifen imenza",
-  "introduction.welcome.text": "Anṣuf ɣer fediverse! Deg kra n yimiren, ad tizmireḍ ad tzzuzreḍ iznan neɣ ad tmeslayeḍ i yemddukkal deg waṭas n yiqeddacen. Maca aqeddac-agi, {domain}, mačči am wiyaḍ - deg-s i yella umaɣnu-ik, ihi cfu ɣef yisem-is.",
-  "keyboard_shortcuts.back": "uɣal ar deffir",
+  "introduction.welcome.text": "Anṣuf γer fediverse! Deg kra n yimiren, ad tizmireḍ ad tzzuzreḍ iznan neɣ ad tmeslayeḍ i yemddukkal deg waṭas n yiqeddacen. Maca aqeddac-agi, {domain}, mačči am wiyaḍ - deg-s i yella umaγnu-ik, ihi cfu γef yisem-is.",
+  "keyboard_shortcuts.back": "uγal ar deffir",
   "keyboard_shortcuts.blocked": "akken ad teldiḍ tabdert n yimseqdacen yettwasḥebsen",
   "keyboard_shortcuts.boost": "i beṭṭu tikelt-nniḍen",
   "keyboard_shortcuts.column": "to focus a status in one of the columns",
   "keyboard_shortcuts.compose": "to focus the compose textarea",
   "keyboard_shortcuts.description": "Aglam",
   "keyboard_shortcuts.direct": "akken ad teldiḍ ajgu n yiznan usriden",
-  "keyboard_shortcuts.down": "i kennu ɣer wadda n tebdert",
-  "keyboard_shortcuts.enter": "i tildin n tsuffeɣt",
-  "keyboard_shortcuts.favourite": "akken ad ternuḍ ɣer yismenyifen",
+  "keyboard_shortcuts.down": "i kennu γer wadda n tebdert",
+  "keyboard_shortcuts.enter": "i tildin n tsuffeγt",
+  "keyboard_shortcuts.favourite": "akken ad ternuḍ γer yismenyifen",
   "keyboard_shortcuts.favourites": "i tildin n tebdert n yismenyifen",
   "keyboard_shortcuts.federated": "i tildin n tsuddemt tamatut n yisallen",
   "keyboard_shortcuts.heading": "Inegzumen n unasiw",
@@ -222,11 +223,11 @@
   "keyboard_shortcuts.local": "i tildin n tsuddemt tadigant n yisallen",
   "keyboard_shortcuts.mention": "akken ad d-bedreḍ ameskar",
   "keyboard_shortcuts.muted": "akken ad teldiḍ tabdert n yimseqdacen yettwasgugmen",
-  "keyboard_shortcuts.my_profile": "akken ad d-teldiḍ amaɣnu-ik",
-  "keyboard_shortcuts.notifications": "akken ad d-teldiḍ ajgu n tilɣa",
+  "keyboard_shortcuts.my_profile": "akken ad d-teldiḍ amaγnu-ik",
+  "keyboard_shortcuts.notifications": "akken ad d-teldiḍ ajgu n tilγa",
   "keyboard_shortcuts.open_media": "to open media",
   "keyboard_shortcuts.pinned": "i tildin n tebdert n tberraḥin yettwasentḍen",
-  "keyboard_shortcuts.profile": "akken ad d-teldiḍ amaɣnu n umeskar",
+  "keyboard_shortcuts.profile": "akken ad d-teldiḍ amaγnu n umeskar",
   "keyboard_shortcuts.reply": "i tririt",
   "keyboard_shortcuts.requests": "akken ad d-teldiḍ tabdert n yisuturen n teḍfeṛt",
   "keyboard_shortcuts.search": "to focus search",
@@ -235,7 +236,7 @@
   "keyboard_shortcuts.toggle_sensitivity": "i teskent/tuffra n yimidyaten",
   "keyboard_shortcuts.toot": "i beddu n tberraḥt tamaynut",
   "keyboard_shortcuts.unfocus": "to un-focus compose textarea/search",
-  "keyboard_shortcuts.up": "i tulin ɣer ufella n tebdert",
+  "keyboard_shortcuts.up": "i tulin γer ufella n tebdert",
   "lightbox.close": "Mdel",
   "lightbox.next": "Γer zdat",
   "lightbox.previous": "Γer deffir",
@@ -253,8 +254,8 @@
   "loading_indicator.label": "Asali...",
   "media_gallery.toggle_visible": "Sken / Ffer",
   "missing_indicator.label": "Ulac-it",
-  "missing_indicator.sublabel": "Ur nufi ara aɣbalu-a",
-  "mute_modal.hide_notifications": "Tebɣiḍ ad teffreḍ talɣutin n umseqdac-a?",
+  "missing_indicator.sublabel": "Ur nufi ara aγbalu-a",
+  "mute_modal.hide_notifications": "Tebγiḍ ad teffreḍ talγutin n umseqdac-a?",
   "navigation_bar.apps": "Isnasen izirazen",
   "navigation_bar.blocks": "Imseqdacen yettusḥebsen",
   "navigation_bar.bookmarks": "Ticraḍ",
@@ -262,13 +263,13 @@
   "navigation_bar.compose": "Aru taberraḥt tamaynut",
   "navigation_bar.direct": "Iznan usridden",
   "navigation_bar.discover": "Ẓer",
-  "navigation_bar.domain_blocks": "Tiɣula yeffren",
-  "navigation_bar.edit_profile": "Ẓreg amaɣnu",
+  "navigation_bar.domain_blocks": "Tiγula yeffren",
+  "navigation_bar.edit_profile": "Ẓreg amaγnu",
   "navigation_bar.favourites": "Ismenyifen",
   "navigation_bar.filters": "Awalen i yettwasgugmen",
   "navigation_bar.follow_requests": "Isuturen n teḍfeṛt",
   "navigation_bar.follows_and_followers": "Imeḍfaṛen akked wid i teṭṭafaṛeḍ",
-  "navigation_bar.info": "Ɣef uqeddac-agi",
+  "navigation_bar.info": "Γef uqeddac-a",
   "navigation_bar.keyboard_shortcuts": "Inegzumen n unasiw",
   "navigation_bar.lists": "Tibdarin",
   "navigation_bar.logout": "Ffeγ",
@@ -278,16 +279,16 @@
   "navigation_bar.preferences": "Imenyafen",
   "navigation_bar.public_timeline": "Tasuddemt tazayezt tamatut",
   "navigation_bar.security": "Taγellist",
-  "notification.favourite": "{name} yesmenyef tasuffeɣt-ik",
+  "notification.favourite": "{name} yesmenyef tasuffeγt-ik",
   "notification.follow": "{name} yeṭṭafaṛ-ik",
   "notification.follow_request": "{name} yessuter-d ad k-yeḍfeṛ",
   "notification.mention": "{name} yebder-ik-id",
   "notification.own_poll": "Your poll has ended",
   "notification.poll": "A poll you have voted in has ended",
   "notification.reblog": "{name} yebḍa taberraḥ-ik i tikelt-nniḍen",
-  "notifications.clear": "Sfeḍ tilɣa",
-  "notifications.clear_confirmation": "Tebɣiḍ s tidet ad tekkseḍ akk tilɣa-ik i lebda?",
-  "notifications.column_settings.alert": "Tilɣa n tnarit",
+  "notifications.clear": "Sfeḍ tilγa",
+  "notifications.clear_confirmation": "Tebγiḍ s tidet ad tekkseḍ akk tilγa-ik i lebda?",
+  "notifications.column_settings.alert": "Tilγa n tnarit",
   "notifications.column_settings.favourite": "Ismenyifen:",
   "notifications.column_settings.filter_bar.advanced": "Sken-d meṛṛa tiggayin",
   "notifications.column_settings.filter_bar.category": "Iri n usizdeg uzrib",
@@ -295,26 +296,26 @@
   "notifications.column_settings.follow": "Imeḍfaṛen imaynuten:",
   "notifications.column_settings.follow_request": "Isuturen imaynuten n teḍfeṛt:",
   "notifications.column_settings.mention": "Abdar:",
-  "notifications.column_settings.poll": "Poll results:",
-  "notifications.column_settings.push": "Tilɣa yettudemmren",
+  "notifications.column_settings.poll": "Igemmaḍ n usenqed:",
+  "notifications.column_settings.push": "Tilγa yettudemmren",
   "notifications.column_settings.reblog": "Boosts:",
-  "notifications.column_settings.show": "Sken-d tilɣa deg ujgu",
+  "notifications.column_settings.show": "Sken-d tilγa deg ujgu",
   "notifications.column_settings.sound": "Rmed imesli",
   "notifications.filter.all": "Akk",
   "notifications.filter.boosts": "Boosts",
   "notifications.filter.favourites": "Ismenyifen",
   "notifications.filter.follows": "Yeṭafaṛ",
   "notifications.filter.mentions": "Abdar",
-  "notifications.filter.polls": "Poll results",
-  "notifications.group": "{count} n tilɣa",
+  "notifications.filter.polls": "Igemmaḍ n usenqed",
+  "notifications.group": "{count} n tilγa",
   "poll.closed": "Ifukk",
   "poll.refresh": "Smiren",
   "poll.total_people": "{count, plural, one {# n wemdan} other {# n yemdanen}}",
-  "poll.total_votes": "{count, plural, one {# n udɣaṛ} other {# n yedɣaṛen}}",
-  "poll.vote": "Dɣeṛ",
-  "poll.voted": "Tdeɣṛeḍ ɣef tririt-agi",
-  "poll_button.add_poll": "Add a poll",
-  "poll_button.remove_poll": "Remove poll",
+  "poll.total_votes": "{count, plural, one {# n udγaṛ} other {# n yedγaṛen}}",
+  "poll.vote": "Dγeṛ",
+  "poll.voted": "Tdeγṛeḍ γef tririt-agi",
+  "poll_button.add_poll": "Rnu asenqed",
+  "poll_button.remove_poll": "Kkes asenqed",
   "privacy.change": "Adjust status privacy",
   "privacy.direct.long": "Bḍu gar yimseqdacen i tbedreḍ kan",
   "privacy.direct.short": "Usrid",
@@ -356,13 +357,13 @@
   "status.block": "Seḥbes @{name}",
   "status.bookmark": "Creḍ",
   "status.cancel_reblog_private": "Sefsex beṭṭu",
-  "status.cannot_reblog": "Tasuffeɣt-a ur tezmir ara ad tettwabḍu tikelt-nniḍen",
-  "status.copy": "Nɣel assaɣ ɣer tasuffeɣt",
+  "status.cannot_reblog": "Tasuffeγt-a ur tezmir ara ad tettwabḍu tikelt-nniḍen",
+  "status.copy": "Nγel assaγ γer tasuffeγt",
   "status.delete": "Kkes",
   "status.detailed_status": "Detailed conversation view",
   "status.direct": "Izen usrid i @{name}",
   "status.embed": "Embed",
-  "status.favourite": "Rnu ɣer yismenyifen",
+  "status.favourite": "Rnu γer yismenyifen",
   "status.filtered": "Yettwasizdeg",
   "status.load_more": "Sali ugar",
   "status.media_hidden": "Media hidden",
@@ -370,8 +371,8 @@
   "status.more": "Ugar",
   "status.mute": "Sussem @{name}",
   "status.mute_conversation": "Mute conversation",
-  "status.open": "Semɣeṛ tasuffeɣt-agi",
-  "status.pin": "Senteḍ-itt deg umaɣnu",
+  "status.open": "Semγeṛ tasuffeγt-agi",
+  "status.pin": "Senteḍ-itt deg umaγnu",
   "status.pinned": "Tiberraḥin yettwasentḍen",
   "status.read_more": "Issin ugar",
   "status.reblog": "Bḍu",
@@ -386,13 +387,13 @@
   "status.sensitive_warning": "Agbur amḥulfu",
   "status.share": "Bḍu",
   "status.show_less": "Sken-d drus",
-  "status.show_less_all": "Semẓi akk tisuffɣin",
+  "status.show_less_all": "Semẓi akk tisuffγin",
   "status.show_more": "Sken-ed ugar",
   "status.show_more_all": "Ẓerr ugar lebda",
   "status.show_thread": "Show thread",
   "status.uncached_media_warning": "Ulac-it",
   "status.unmute_conversation": "Kkes asgugem n udiwenni",
-  "status.unpin": "Kkes asenteḍ seg umaɣnu",
+  "status.unpin": "Kkes asenteḍ seg umaγnu",
   "suggestions.dismiss": "Dismiss suggestion",
   "suggestions.header": "Ahat ad tcelgeḍ deg…",
   "tabs_bar.federated_timeline": "Amatu",
@@ -416,10 +417,10 @@
   "upload_form.description": "Glem-d i yemdaneni yesɛan ugur deg yiẓri",
   "upload_form.edit": "Ẓreg",
   "upload_form.undo": "Kkes",
-  "upload_form.video_description": "Glem-d i yemdanen i yesɛan ugur deg tmesliwt neɣ deg yiẓri",
+  "upload_form.video_description": "Glem-d i yemdanen i yesɛan ugur deg tmesliwt neγ deg yiẓri",
   "upload_modal.analyzing_picture": "Tasleḍt n tugna tetteddu…",
   "upload_modal.apply": "Snes",
-  "upload_modal.description_placeholder": "Aberraɣ arurad ineggez nnig n uqjun amuṭṭis",
+  "upload_modal.description_placeholder": "Aberraγ arurad ineggez nnig n uqjun amuṭṭis",
   "upload_modal.detect_text": "Detect text from picture",
   "upload_modal.edit_media": "Edit media",
   "upload_modal.hint": "Click or drag the circle on the preview to choose the focal point which will always be in view on all thumbnails.",
@@ -427,8 +428,8 @@
   "upload_progress.label": "Asali iteddu...",
   "video.close": "Mdel tabidyutt",
   "video.download": "Sidered afaylu",
-  "video.exit_fullscreen": "Ffeɣ seg ugdil aččuran",
-  "video.expand": "Semɣeṛ tavidyut",
+  "video.exit_fullscreen": "Ffeγ seg ugdil aččuran",
+  "video.expand": "Semγeṛ tavidyut",
   "video.fullscreen": "Agdil aččuran",
   "video.hide": "Ffer tabidyutt",
   "video.mute": "Gzem imesli",
diff --git a/app/javascript/mastodon/locales/kk.json b/app/javascript/mastodon/locales/kk.json
index 86f3626f9..b307a3161 100644
--- a/app/javascript/mastodon/locales/kk.json
+++ b/app/javascript/mastodon/locales/kk.json
@@ -43,6 +43,7 @@
   "alert.rate_limited.title": "Бағалау шектеулі",
   "alert.unexpected.message": "Бір нәрсе дұрыс болмады.",
   "alert.unexpected.title": "Өй!",
+  "announcement.announcement": "Announcement",
   "autosuggest_hashtag.per_week": "{count} аптасына",
   "boost_modal.combo": "Келесіде өткізіп жіберу үшін басыңыз {combo}",
   "bundle_column_error.body": "Бұл компонентті жүктеген кезде бір қате пайда болды.",
diff --git a/app/javascript/mastodon/locales/kn.json b/app/javascript/mastodon/locales/kn.json
index 96872bbbd..278f6b14c 100644
--- a/app/javascript/mastodon/locales/kn.json
+++ b/app/javascript/mastodon/locales/kn.json
@@ -43,6 +43,7 @@
   "alert.rate_limited.title": "Rate limited",
   "alert.unexpected.message": "An unexpected error occurred.",
   "alert.unexpected.title": "Oops!",
+  "announcement.announcement": "Announcement",
   "autosuggest_hashtag.per_week": "{count} per week",
   "boost_modal.combo": "You can press {combo} to skip this next time",
   "bundle_column_error.body": "Something went wrong while loading this component.",
diff --git a/app/javascript/mastodon/locales/ko.json b/app/javascript/mastodon/locales/ko.json
index 635a6c8bd..f7be26c09 100644
--- a/app/javascript/mastodon/locales/ko.json
+++ b/app/javascript/mastodon/locales/ko.json
@@ -43,6 +43,7 @@
   "alert.rate_limited.title": "빈도 제한",
   "alert.unexpected.message": "예측하지 못한 에러가 발생했습니다.",
   "alert.unexpected.title": "앗!",
+  "announcement.announcement": "Announcement",
   "autosuggest_hashtag.per_week": "주간 {count}회",
   "boost_modal.combo": "{combo}를 누르면 다음부터 이 과정을 건너뛸 수 있습니다",
   "bundle_column_error.body": "컴포넌트를 불러오는 과정에서 문제가 발생했습니다.",
diff --git a/app/javascript/mastodon/locales/lt.json b/app/javascript/mastodon/locales/lt.json
index 96872bbbd..278f6b14c 100644
--- a/app/javascript/mastodon/locales/lt.json
+++ b/app/javascript/mastodon/locales/lt.json
@@ -43,6 +43,7 @@
   "alert.rate_limited.title": "Rate limited",
   "alert.unexpected.message": "An unexpected error occurred.",
   "alert.unexpected.title": "Oops!",
+  "announcement.announcement": "Announcement",
   "autosuggest_hashtag.per_week": "{count} per week",
   "boost_modal.combo": "You can press {combo} to skip this next time",
   "bundle_column_error.body": "Something went wrong while loading this component.",
diff --git a/app/javascript/mastodon/locales/lv.json b/app/javascript/mastodon/locales/lv.json
index efdb23436..b2f8fedbf 100644
--- a/app/javascript/mastodon/locales/lv.json
+++ b/app/javascript/mastodon/locales/lv.json
@@ -43,6 +43,7 @@
   "alert.rate_limited.title": "Rate limited",
   "alert.unexpected.message": "Negaidīta kļūda.",
   "alert.unexpected.title": "Ups!",
+  "announcement.announcement": "Announcement",
   "autosuggest_hashtag.per_week": "{count} per week",
   "boost_modal.combo": "Nospied {combo} lai izlaistu šo nākamreiz",
   "bundle_column_error.body": "Kaut kas nogāja greizi ielādējot šo komponenti.",
diff --git a/app/javascript/mastodon/locales/mk.json b/app/javascript/mastodon/locales/mk.json
index 2a31a5c22..90c8d2418 100644
--- a/app/javascript/mastodon/locales/mk.json
+++ b/app/javascript/mastodon/locales/mk.json
@@ -43,6 +43,7 @@
   "alert.rate_limited.title": "Rate limited",
   "alert.unexpected.message": "Неочекувана грешка.",
   "alert.unexpected.title": "Упс!",
+  "announcement.announcement": "Announcement",
   "autosuggest_hashtag.per_week": "{count} неделно",
   "boost_modal.combo": "Кликни {combo} за да го прескокниш ова нареден пат",
   "bundle_column_error.body": "Се случи проблем при вчитувањето.",
diff --git a/app/javascript/mastodon/locales/ml.json b/app/javascript/mastodon/locales/ml.json
index 0705f20e3..6a042e8c4 100644
--- a/app/javascript/mastodon/locales/ml.json
+++ b/app/javascript/mastodon/locales/ml.json
@@ -43,6 +43,7 @@
   "alert.rate_limited.title": "തോത് പരിമിതപ്പെടുത്തിയിരിക്കുന്നു",
   "alert.unexpected.message": "അപ്രതീക്ഷിതമായി എന്തോ സംഭവിച്ചു.",
   "alert.unexpected.title": "ശ്ശോ!",
+  "announcement.announcement": "Announcement",
   "autosuggest_hashtag.per_week": "ആഴ്ച തോറും {count}",
   "boost_modal.combo": "അടുത്ത തവണ ഇത് ഒഴിവാക്കുവാൻ {combo} ഞെക്കാവുന്നതാണ്",
   "bundle_column_error.body": "ഈ ഘടകം പ്രദശിപ്പിക്കുമ്പോൾ എന്തോ കുഴപ്പം സംഭവിച്ചു.",
diff --git a/app/javascript/mastodon/locales/mr.json b/app/javascript/mastodon/locales/mr.json
index a4dcbb144..f265042f2 100644
--- a/app/javascript/mastodon/locales/mr.json
+++ b/app/javascript/mastodon/locales/mr.json
@@ -43,6 +43,7 @@
   "alert.rate_limited.title": "Rate limited",
   "alert.unexpected.message": "An unexpected error occurred.",
   "alert.unexpected.title": "अरेरे!",
+  "announcement.announcement": "Announcement",
   "autosuggest_hashtag.per_week": "{count} प्रतिसप्ताह",
   "boost_modal.combo": "You can press {combo} to skip this next time",
   "bundle_column_error.body": "हा घटक लोड करतांना काहीतरी चुकले आहे.",
diff --git a/app/javascript/mastodon/locales/ms.json b/app/javascript/mastodon/locales/ms.json
index 4e858d59e..3bd6e145e 100644
--- a/app/javascript/mastodon/locales/ms.json
+++ b/app/javascript/mastodon/locales/ms.json
@@ -43,6 +43,7 @@
   "alert.rate_limited.title": "Rate limited",
   "alert.unexpected.message": "An unexpected error occurred.",
   "alert.unexpected.title": "Oops!",
+  "announcement.announcement": "Announcement",
   "autosuggest_hashtag.per_week": "{count} per week",
   "boost_modal.combo": "You can press {combo} to skip this next time",
   "bundle_column_error.body": "Something went wrong while loading this component.",
diff --git a/app/javascript/mastodon/locales/nl.json b/app/javascript/mastodon/locales/nl.json
index 7fbaf4204..d570f3612 100644
--- a/app/javascript/mastodon/locales/nl.json
+++ b/app/javascript/mastodon/locales/nl.json
@@ -43,6 +43,7 @@
   "alert.rate_limited.title": "Beperkt te gebruiken",
   "alert.unexpected.message": "Er deed zich een onverwachte fout voor",
   "alert.unexpected.title": "Oeps!",
+  "announcement.announcement": "Announcement",
   "autosuggest_hashtag.per_week": "{count} per week",
   "boost_modal.combo": "Je kunt {combo} klikken om dit de volgende keer over te slaan",
   "bundle_column_error.body": "Tijdens het laden van dit onderdeel is er iets fout gegaan.",
diff --git a/app/javascript/mastodon/locales/nn.json b/app/javascript/mastodon/locales/nn.json
index 00e323cc1..d69717292 100644
--- a/app/javascript/mastodon/locales/nn.json
+++ b/app/javascript/mastodon/locales/nn.json
@@ -43,6 +43,7 @@
   "alert.rate_limited.title": "Begrensa rate",
   "alert.unexpected.message": "Eit uventa problem oppstod.",
   "alert.unexpected.title": "Oi sann!",
+  "announcement.announcement": "Announcement",
   "autosuggest_hashtag.per_week": "{count} per veke",
   "boost_modal.combo": "Du kan trykkja {combo} for å hoppa over dette neste gong",
   "bundle_column_error.body": "Noko gjekk gale mens denne komponenten vart lasta ned.",
@@ -170,7 +171,7 @@
   "getting_started.invite": "Byd folk inn",
   "getting_started.open_source_notice": "Mastodon er fri programvare. Du kan bidraga eller rapportera problem med GitHub på {github}.",
   "getting_started.security": "Kontoinnstillingar",
-  "getting_started.terms": "Brukarvillkår",
+  "getting_started.terms": "Brukarvilkår",
   "hashtag.column_header.tag_mode.all": "og {additional}",
   "hashtag.column_header.tag_mode.any": "eller {additional}",
   "hashtag.column_header.tag_mode.none": "utan {additional}",
@@ -354,7 +355,7 @@
   "status.admin_account": "Opne moderasjonsgrensesnitt for @{name}",
   "status.admin_status": "Opne denne statusen i moderasjonsgrensesnittet",
   "status.block": "Blokker @{name}",
-  "status.bookmark": "Bokmerke",
+  "status.bookmark": "Bokmerk",
   "status.cancel_reblog_private": "Opphev framheving",
   "status.cannot_reblog": "Denne posten kan ikkje framhevast",
   "status.copy": "Kopier lenke til status",
diff --git a/app/javascript/mastodon/locales/no.json b/app/javascript/mastodon/locales/no.json
index a6aa5db7c..c6dc4ca0e 100644
--- a/app/javascript/mastodon/locales/no.json
+++ b/app/javascript/mastodon/locales/no.json
@@ -43,6 +43,7 @@
   "alert.rate_limited.title": "Hastighetsbegrenset",
   "alert.unexpected.message": "En uventet feil oppstod.",
   "alert.unexpected.title": "Oops!",
+  "announcement.announcement": "Announcement",
   "autosuggest_hashtag.per_week": "{count} per uke",
   "boost_modal.combo": "You kan trykke {combo} for å hoppe over dette neste gang",
   "bundle_column_error.body": "Noe gikk galt mens denne komponenten lastet.",
diff --git a/app/javascript/mastodon/locales/oc.json b/app/javascript/mastodon/locales/oc.json
index 601ed93c6..b7701c17e 100644
--- a/app/javascript/mastodon/locales/oc.json
+++ b/app/javascript/mastodon/locales/oc.json
@@ -43,6 +43,7 @@
   "alert.rate_limited.title": "Taus limitat",
   "alert.unexpected.message": "Una error s’es producha.",
   "alert.unexpected.title": "Ops !",
+  "announcement.announcement": "Announcement",
   "autosuggest_hashtag.per_week": "{count} per setmana",
   "boost_modal.combo": "Podètz botar {combo} per passar aquò lo còp que ven",
   "bundle_column_error.body": "Quicòm a fach mèuca pendent lo cargament d’aqueste compausant.",
diff --git a/app/javascript/mastodon/locales/pl.json b/app/javascript/mastodon/locales/pl.json
index 9972597d6..3874f1596 100644
--- a/app/javascript/mastodon/locales/pl.json
+++ b/app/javascript/mastodon/locales/pl.json
@@ -43,6 +43,7 @@
   "alert.rate_limited.title": "Ograniczony czasowo",
   "alert.unexpected.message": "Wystąpił nieoczekiwany błąd.",
   "alert.unexpected.title": "O nie!",
+  "announcement.announcement": "Announcement",
   "autosuggest_hashtag.per_week": "{count} co tydzień",
   "boost_modal.combo": "Naciśnij {combo}, aby pominąć to następnym razem",
   "bundle_column_error.body": "Coś poszło nie tak podczas ładowania tego składnika.",
diff --git a/app/javascript/mastodon/locales/pt-BR.json b/app/javascript/mastodon/locales/pt-BR.json
index 7dedbe2cc..f78c327fb 100644
--- a/app/javascript/mastodon/locales/pt-BR.json
+++ b/app/javascript/mastodon/locales/pt-BR.json
@@ -43,6 +43,7 @@
   "alert.rate_limited.title": "Tentativas limitadas",
   "alert.unexpected.message": "Ocorreu um erro inesperado.",
   "alert.unexpected.title": "Eita!",
+  "announcement.announcement": "Announcement",
   "autosuggest_hashtag.per_week": "{count} por semana",
   "boost_modal.combo": "Pressione {combo} para ignorar este diálogo na próxima vez",
   "bundle_column_error.body": "Ocorreu um problema ao carregar este componente.",
diff --git a/app/javascript/mastodon/locales/pt-PT.json b/app/javascript/mastodon/locales/pt-PT.json
index 72eebaa1f..b2fb2a012 100644
--- a/app/javascript/mastodon/locales/pt-PT.json
+++ b/app/javascript/mastodon/locales/pt-PT.json
@@ -43,6 +43,7 @@
   "alert.rate_limited.title": "Limite de tentativas",
   "alert.unexpected.message": "Ocorreu um erro inesperado.",
   "alert.unexpected.title": "Bolas!",
+  "announcement.announcement": "Announcement",
   "autosuggest_hashtag.per_week": "{count} por semana",
   "boost_modal.combo": "Pode clicar {combo} para não voltar a ver",
   "bundle_column_error.body": "Algo de errado aconteceu enquanto este componente era carregado.",
diff --git a/app/javascript/mastodon/locales/ro.json b/app/javascript/mastodon/locales/ro.json
index 7703d46fb..4d01ad3a5 100644
--- a/app/javascript/mastodon/locales/ro.json
+++ b/app/javascript/mastodon/locales/ro.json
@@ -43,6 +43,7 @@
   "alert.rate_limited.title": "Rate limited",
   "alert.unexpected.message": "A apărut o eroare neașteptată.",
   "alert.unexpected.title": "Hopa!",
+  "announcement.announcement": "Announcement",
   "autosuggest_hashtag.per_week": "{count} per week",
   "boost_modal.combo": "Poți apăsa {combo} pentru a omite asta data viitoare",
   "bundle_column_error.body": "Ceva nu a funcționat la încărcarea acestui component.",
diff --git a/app/javascript/mastodon/locales/ru.json b/app/javascript/mastodon/locales/ru.json
index 89f364947..1681da968 100644
--- a/app/javascript/mastodon/locales/ru.json
+++ b/app/javascript/mastodon/locales/ru.json
@@ -43,6 +43,7 @@
   "alert.rate_limited.title": "Вы выполняете действие слишком часто",
   "alert.unexpected.message": "Что-то пошло не так.",
   "alert.unexpected.title": "Ой!",
+  "announcement.announcement": "Announcement",
   "autosuggest_hashtag.per_week": "{count} / неделю",
   "boost_modal.combo": "{combo}, чтобы пропустить это в следующий раз",
   "bundle_column_error.body": "Что-то пошло не так при загрузке этого компонента.",
@@ -365,7 +366,7 @@
   "status.favourite": "Нравится",
   "status.filtered": "Отфильтровано",
   "status.load_more": "Загрузить остальное",
-  "status.media_hidden": "Медиа скрыто",
+  "status.media_hidden": "Файл скрыт",
   "status.mention": "Упомянуть @{name}",
   "status.more": "Больше",
   "status.mute": "Игнорировать @{name}",
@@ -390,7 +391,7 @@
   "status.show_more": "Развернуть",
   "status.show_more_all": "Развернуть все спойлеры в ветке",
   "status.show_thread": "Показать обсуждение",
-  "status.uncached_media_warning": "Недоступно",
+  "status.uncached_media_warning": "Файл недоступен",
   "status.unmute_conversation": "Не игнорировать обсуждение",
   "status.unpin": "Открепить от профиля",
   "suggestions.dismiss": "Удалить предложение",
@@ -409,7 +410,7 @@
   "trends.trending_now": "Самое актуальное",
   "ui.beforeunload": "Ваш черновик будет утерян, если вы покинете Mastodon.",
   "upload_area.title": "Перетащите сюда, чтобы загрузить",
-  "upload_button.label": "Добавить файл медиа ({formats})",
+  "upload_button.label": "Прикрепить фото, видео или аудио",
   "upload_error.limit": "Достигнут лимит загруженных файлов.",
   "upload_error.poll": "К опросам нельзя прикреплять файлы.",
   "upload_form.audio_description": "Опишите аудиофайл для людей с нарушением слуха",
@@ -421,7 +422,7 @@
   "upload_modal.apply": "Применить",
   "upload_modal.description_placeholder": "На дворе трава, на траве дрова",
   "upload_modal.detect_text": "Найти текст на картинке",
-  "upload_modal.edit_media": "Изменение медиа",
+  "upload_modal.edit_media": "Изменить файл",
   "upload_modal.hint": "Нажмите и перетащите круг в предпросмотре в точку фокуса, которая всегда будет видна на эскизах.",
   "upload_modal.preview_label": "Предпросмотр ({ratio})",
   "upload_progress.label": "Загрузка...",
diff --git a/app/javascript/mastodon/locales/sk.json b/app/javascript/mastodon/locales/sk.json
index 689d6245c..2410daf06 100644
--- a/app/javascript/mastodon/locales/sk.json
+++ b/app/javascript/mastodon/locales/sk.json
@@ -1,7 +1,7 @@
 {
   "account.add_or_remove_from_list": "Pridaj do, alebo odober zo zoznamov",
   "account.badges.bot": "Bot",
-  "account.badges.group": "Group",
+  "account.badges.group": "Skupina",
   "account.block": "Blokuj @{name}",
   "account.block_domain": "Ukry všetko z {domain}",
   "account.blocked": "Blokovaný/á",
@@ -43,6 +43,7 @@
   "alert.rate_limited.title": "Tempo obmedzené",
   "alert.unexpected.message": "Vyskytla sa nečakaná chyba.",
   "alert.unexpected.title": "Ups!",
+  "announcement.announcement": "Announcement",
   "autosuggest_hashtag.per_week": "{count} týždenne",
   "boost_modal.combo": "Nabudúce môžeš kliknúť {combo} pre preskočenie",
   "bundle_column_error.body": "Pri načítaní tohto prvku nastala nejaká chyba.",
@@ -84,8 +85,8 @@
   "compose_form.poll.duration": "Trvanie ankety",
   "compose_form.poll.option_placeholder": "Voľba {number}",
   "compose_form.poll.remove_option": "Odstráň túto voľbu",
-  "compose_form.poll.switch_to_multiple": "Change poll to allow multiple choices",
-  "compose_form.poll.switch_to_single": "Change poll to allow for a single choice",
+  "compose_form.poll.switch_to_multiple": "Zmeň anketu pre povolenie viacerých možností",
+  "compose_form.poll.switch_to_single": "Zmeň anketu na takú s jedinou voľbou",
   "compose_form.publish": "Pošli",
   "compose_form.publish_loud": "{publish}!",
   "compose_form.sensitive.hide": "Označ médiá ako chúlostivé",
diff --git a/app/javascript/mastodon/locales/sl.json b/app/javascript/mastodon/locales/sl.json
index da816837f..bcf3b8d1a 100644
--- a/app/javascript/mastodon/locales/sl.json
+++ b/app/javascript/mastodon/locales/sl.json
@@ -43,6 +43,7 @@
   "alert.rate_limited.title": "Rate limited",
   "alert.unexpected.message": "Zgodila se je nepričakovana napaka.",
   "alert.unexpected.title": "Uups!",
+  "announcement.announcement": "Announcement",
   "autosuggest_hashtag.per_week": "{count} per week",
   "boost_modal.combo": "Če želite preskočiti to, lahko pritisnete {combo}",
   "bundle_column_error.body": "Med nalaganjem te komponente je prišlo do napake.",
diff --git a/app/javascript/mastodon/locales/sq.json b/app/javascript/mastodon/locales/sq.json
index 0b5a613c2..617c3aee3 100644
--- a/app/javascript/mastodon/locales/sq.json
+++ b/app/javascript/mastodon/locales/sq.json
@@ -43,6 +43,7 @@
   "alert.rate_limited.title": "Rate limited",
   "alert.unexpected.message": "Ndodhi një gabim të papritur.",
   "alert.unexpected.title": "Hëm!",
+  "announcement.announcement": "Announcement",
   "autosuggest_hashtag.per_week": "{count} per week",
   "boost_modal.combo": "Mund të shtypni {combo}, që të anashkalohet kjo herës tjetër",
   "bundle_column_error.body": "Diç shkoi ters teksa ngarkohej ky përbërës.",
diff --git a/app/javascript/mastodon/locales/sr-Latn.json b/app/javascript/mastodon/locales/sr-Latn.json
index aab2dfa15..09b30ff5f 100644
--- a/app/javascript/mastodon/locales/sr-Latn.json
+++ b/app/javascript/mastodon/locales/sr-Latn.json
@@ -43,6 +43,7 @@
   "alert.rate_limited.title": "Rate limited",
   "alert.unexpected.message": "An unexpected error occurred.",
   "alert.unexpected.title": "Oops!",
+  "announcement.announcement": "Announcement",
   "autosuggest_hashtag.per_week": "{count} per week",
   "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.",
diff --git a/app/javascript/mastodon/locales/sr.json b/app/javascript/mastodon/locales/sr.json
index 35436a93d..5b04f9826 100644
--- a/app/javascript/mastodon/locales/sr.json
+++ b/app/javascript/mastodon/locales/sr.json
@@ -43,6 +43,7 @@
   "alert.rate_limited.title": "Rate limited",
   "alert.unexpected.message": "Појавила се неочекивана грешка.",
   "alert.unexpected.title": "Упс!",
+  "announcement.announcement": "Announcement",
   "autosuggest_hashtag.per_week": "{count} per week",
   "boost_modal.combo": "Можете притиснути {combo} да прескочите ово следећи пут",
   "bundle_column_error.body": "Нешто је пошло по злу приликом учитавања ове компоненте.",
diff --git a/app/javascript/mastodon/locales/sv.json b/app/javascript/mastodon/locales/sv.json
index 843ab9fc0..4e778a481 100644
--- a/app/javascript/mastodon/locales/sv.json
+++ b/app/javascript/mastodon/locales/sv.json
@@ -1,7 +1,7 @@
 {
   "account.add_or_remove_from_list": "Lägg till i eller ta bort från listor",
   "account.badges.bot": "Robot",
-  "account.badges.group": "Group",
+  "account.badges.group": "Grupp",
   "account.block": "Blockera @{name}",
   "account.block_domain": "Dölj allt från {domain}",
   "account.blocked": "Blockerad",
@@ -43,6 +43,7 @@
   "alert.rate_limited.title": "Mängd begränsad",
   "alert.unexpected.message": "Ett oväntat fel uppstod.",
   "alert.unexpected.title": "Hoppsan!",
+  "announcement.announcement": "Announcement",
   "autosuggest_hashtag.per_week": "{count} per vecka",
   "boost_modal.combo": "Du kan trycka {combo} för att slippa detta nästa gång",
   "bundle_column_error.body": "Något gick fel medan denna komponent laddades.",
@@ -84,8 +85,8 @@
   "compose_form.poll.duration": "Varaktighet för omröstning",
   "compose_form.poll.option_placeholder": "Val {number}",
   "compose_form.poll.remove_option": "Ta bort detta val",
-  "compose_form.poll.switch_to_multiple": "Change poll to allow multiple choices",
-  "compose_form.poll.switch_to_single": "Change poll to allow for a single choice",
+  "compose_form.poll.switch_to_multiple": "Ändra enkät för att tillåta flera val",
+  "compose_form.poll.switch_to_single": "Ändra enkät för att tillåta ett enda val",
   "compose_form.publish": "Tut",
   "compose_form.publish_loud": "{publish}!",
   "compose_form.sensitive.hide": "Markera media som känsligt",
diff --git a/app/javascript/mastodon/locales/ta.json b/app/javascript/mastodon/locales/ta.json
index ddd1d9fd7..8d1a8f14d 100644
--- a/app/javascript/mastodon/locales/ta.json
+++ b/app/javascript/mastodon/locales/ta.json
@@ -1,7 +1,7 @@
 {
   "account.add_or_remove_from_list": "பட்டியல்களில் சேர்/நீக்கு",
   "account.badges.bot": "பாட்",
-  "account.badges.group": "Group",
+  "account.badges.group": "குழு",
   "account.block": "@{name} -ஐத் தடு",
   "account.block_domain": "{domain} யில் இருந்து வரும் எல்லாவற்றையும் மறை",
   "account.blocked": "முடக்கப்பட்டது",
@@ -43,6 +43,7 @@
   "alert.rate_limited.title": "விகிதம் வரையறுக்கப்பட்டுள்ளது",
   "alert.unexpected.message": "எதிர்பாராத பிழை ஏற்பட்டுவிட்டது.",
   "alert.unexpected.title": "அச்சச்சோ!",
+  "announcement.announcement": "Announcement",
   "autosuggest_hashtag.per_week": "ஒவ்வொரு வாரம் {count}",
   "boost_modal.combo": "நீங்கள் இதை அடுத்தமுறை தவிர்க்க {combo} வை அழுத்தவும்",
   "bundle_column_error.body": "இக்கூற்றை ஏற்றம் செய்யும்பொழுது ஏதோ தவறு ஏற்பட்டுள்ளது.",
diff --git a/app/javascript/mastodon/locales/te.json b/app/javascript/mastodon/locales/te.json
index 12af667c7..8b47fec52 100644
--- a/app/javascript/mastodon/locales/te.json
+++ b/app/javascript/mastodon/locales/te.json
@@ -43,6 +43,7 @@
   "alert.rate_limited.title": "Rate limited",
   "alert.unexpected.message": "అనుకోని తప్పు జరిగినది.",
   "alert.unexpected.title": "అయ్యో!",
+  "announcement.announcement": "Announcement",
   "autosuggest_hashtag.per_week": "{count} per week",
   "boost_modal.combo": "మీరు తదుపరిసారి దీనిని దాటవేయడానికి {combo} నొక్కవచ్చు",
   "bundle_column_error.body": "ఈ భాగం లోడ్ అవుతున్నప్పుడు ఏదో తప్పు జరిగింది.",
diff --git a/app/javascript/mastodon/locales/th.json b/app/javascript/mastodon/locales/th.json
index 56e383da7..8a72783a2 100644
--- a/app/javascript/mastodon/locales/th.json
+++ b/app/javascript/mastodon/locales/th.json
@@ -43,6 +43,7 @@
   "alert.rate_limited.title": "มีการจำกัดอัตรา",
   "alert.unexpected.message": "เกิดข้อผิดพลาดที่ไม่คาดคิด",
   "alert.unexpected.title": "อุปส์!",
+  "announcement.announcement": "Announcement",
   "autosuggest_hashtag.per_week": "{count} ต่อสัปดาห์",
   "boost_modal.combo": "คุณสามารถกด {combo} เพื่อข้ามสิ่งนี้ในครั้งถัดไป",
   "bundle_column_error.body": "มีบางอย่างผิดพลาดขณะโหลดส่วนประกอบนี้",
@@ -84,8 +85,8 @@
   "compose_form.poll.duration": "ระยะเวลาการสำรวจความคิดเห็น",
   "compose_form.poll.option_placeholder": "ทางเลือก {number}",
   "compose_form.poll.remove_option": "เอาทางเลือกนี้ออก",
-  "compose_form.poll.switch_to_multiple": "Change poll to allow multiple choices",
-  "compose_form.poll.switch_to_single": "Change poll to allow for a single choice",
+  "compose_form.poll.switch_to_multiple": "เปลี่ยนการสำรวจความคิดเห็นเป็นอนุญาตหลายทางเลือก",
+  "compose_form.poll.switch_to_single": "เปลี่ยนการสำรวจความคิดเห็นเป็นอนุญาตทางเลือกเดี่ยว",
   "compose_form.publish": "โพสต์",
   "compose_form.publish_loud": "{publish}!",
   "compose_form.sensitive.hide": "ทำเครื่องหมายสื่อว่าละเอียดอ่อน",
diff --git a/app/javascript/mastodon/locales/tr.json b/app/javascript/mastodon/locales/tr.json
index 514e7b956..3ade92977 100644
--- a/app/javascript/mastodon/locales/tr.json
+++ b/app/javascript/mastodon/locales/tr.json
@@ -43,6 +43,7 @@
   "alert.rate_limited.title": "Oran sınırlıdır",
   "alert.unexpected.message": "Beklenmedik bir hata oluştu.",
   "alert.unexpected.title": "Hay aksi!",
+  "announcement.announcement": "Announcement",
   "autosuggest_hashtag.per_week": "Haftada {count}",
   "boost_modal.combo": "Bir daha ki sefere {combo} tuşuna basabilirsiniz",
   "bundle_column_error.body": "Bu bileşen yüklenirken bir şeyler ters gitti.",
diff --git a/app/javascript/mastodon/locales/uk.json b/app/javascript/mastodon/locales/uk.json
index 1c37f8e44..3dc69d6d6 100644
--- a/app/javascript/mastodon/locales/uk.json
+++ b/app/javascript/mastodon/locales/uk.json
@@ -43,6 +43,7 @@
   "alert.rate_limited.title": "Швидкість обмежена",
   "alert.unexpected.message": "Трапилась неочікувана помилка.",
   "alert.unexpected.title": "Ой!",
+  "announcement.announcement": "Announcement",
   "autosuggest_hashtag.per_week": "{count} в тиждень",
   "boost_modal.combo": "Ви можете натиснути {combo}, щоб пропустити це наступного разу",
   "bundle_column_error.body": "Щось пішло не так під час завантаження компоненту.",
diff --git a/app/javascript/mastodon/locales/ur.json b/app/javascript/mastodon/locales/ur.json
index 6f14ad3c6..01477906c 100644
--- a/app/javascript/mastodon/locales/ur.json
+++ b/app/javascript/mastodon/locales/ur.json
@@ -43,6 +43,7 @@
   "alert.rate_limited.title": "Rate limited",
   "alert.unexpected.message": "ایک غیر متوقع سہو ہوا ہے.",
   "alert.unexpected.title": "ا رے!",
+  "announcement.announcement": "Announcement",
   "autosuggest_hashtag.per_week": "{count} فی ہفتہ",
   "boost_modal.combo": "آئیندہ یہ نہ دیکھنے کیلئے آپ {combo} دبا سکتے ہیں",
   "bundle_column_error.body": "اس عنصر کو برآمد کرتے وقت کچھ خرابی پیش آئی ہے.",
diff --git a/app/javascript/mastodon/locales/vi.json b/app/javascript/mastodon/locales/vi.json
index d94be9f5b..07dff79fa 100644
--- a/app/javascript/mastodon/locales/vi.json
+++ b/app/javascript/mastodon/locales/vi.json
@@ -43,6 +43,7 @@
   "alert.rate_limited.title": "Tỷ lệ giới hạn",
   "alert.unexpected.message": "Đã xảy ra lỗi không mong muốn.",
   "alert.unexpected.title": "Ốiii!",
+  "announcement.announcement": "Announcement",
   "autosuggest_hashtag.per_week": "{tính} mỗi tuần",
   "boost_modal.combo": "Bạn có thể nhấn {combo} để bỏ qua lần sau",
   "bundle_column_error.body": "Có gì đó sai sai trong khi tải nội dung này",
diff --git a/app/javascript/mastodon/locales/zh-CN.json b/app/javascript/mastodon/locales/zh-CN.json
index 84670d4b5..62a86e75b 100644
--- a/app/javascript/mastodon/locales/zh-CN.json
+++ b/app/javascript/mastodon/locales/zh-CN.json
@@ -43,6 +43,7 @@
   "alert.rate_limited.title": "频率受限",
   "alert.unexpected.message": "发生了意外错误。",
   "alert.unexpected.title": "哎呀!",
+  "announcement.announcement": "Announcement",
   "autosuggest_hashtag.per_week": "每星期 {count} 条",
   "boost_modal.combo": "下次按住 {combo} 即可跳过此提示",
   "bundle_column_error.body": "载入这个组件时发生了错误。",
diff --git a/app/javascript/mastodon/locales/zh-HK.json b/app/javascript/mastodon/locales/zh-HK.json
index a685409ee..4dcf70e6b 100644
--- a/app/javascript/mastodon/locales/zh-HK.json
+++ b/app/javascript/mastodon/locales/zh-HK.json
@@ -43,6 +43,7 @@
   "alert.rate_limited.title": "已限速",
   "alert.unexpected.message": "發生不可預期的錯誤。",
   "alert.unexpected.title": "噢!",
+  "announcement.announcement": "Announcement",
   "autosuggest_hashtag.per_week": "{count} / 週",
   "boost_modal.combo": "如你想在下次路過這顯示,請按{combo},",
   "bundle_column_error.body": "加載本組件出錯。",
diff --git a/app/javascript/mastodon/locales/zh-TW.json b/app/javascript/mastodon/locales/zh-TW.json
index e5f740b46..ab680223e 100644
--- a/app/javascript/mastodon/locales/zh-TW.json
+++ b/app/javascript/mastodon/locales/zh-TW.json
@@ -43,6 +43,7 @@
   "alert.rate_limited.title": "已限速",
   "alert.unexpected.message": "發生了非預期的錯誤。",
   "alert.unexpected.title": "哎呀!",
+  "announcement.announcement": "Announcement",
   "autosuggest_hashtag.per_week": "{count} / 週",
   "boost_modal.combo": "下次您可以按 {combo} 跳過",
   "bundle_column_error.body": "載入此元件時發生錯誤。",
diff --git a/app/javascript/mastodon/reducers/announcements.js b/app/javascript/mastodon/reducers/announcements.js
new file mode 100644
index 000000000..aa674e516
--- /dev/null
+++ b/app/javascript/mastodon/reducers/announcements.js
@@ -0,0 +1,72 @@
+import {
+  ANNOUNCEMENTS_FETCH_REQUEST,
+  ANNOUNCEMENTS_FETCH_SUCCESS,
+  ANNOUNCEMENTS_FETCH_FAIL,
+  ANNOUNCEMENTS_UPDATE,
+  ANNOUNCEMENTS_DISMISS,
+  ANNOUNCEMENTS_REACTION_UPDATE,
+  ANNOUNCEMENTS_REACTION_ADD_REQUEST,
+  ANNOUNCEMENTS_REACTION_ADD_FAIL,
+  ANNOUNCEMENTS_REACTION_REMOVE_REQUEST,
+  ANNOUNCEMENTS_REACTION_REMOVE_FAIL,
+} from '../actions/announcements';
+import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable';
+
+const initialState = ImmutableMap({
+  items: ImmutableList(),
+  isLoading: false,
+});
+
+const updateReaction = (state, id, name, updater) => state.update('items', list => list.map(announcement => {
+  if (announcement.get('id') === id) {
+    return announcement.update('reactions', reactions => {
+      if (reactions.find(reaction => reaction.get('name') === name)) {
+        return reactions.map(reaction => {
+          if (reaction.get('name') === name) {
+            return updater(reaction);
+          }
+
+          return reaction;
+        });
+      }
+
+      return reactions.push(updater(fromJS({ name, count: 0 })));
+    });
+  }
+
+  return announcement;
+}));
+
+const updateReactionCount = (state, reaction) => updateReaction(state, reaction.announcement_id, reaction.name, x => x.set('count', reaction.count));
+
+const addReaction = (state, id, name) => updateReaction(state, id, name, x => x.set('me', true).update('count', y => y + 1));
+
+const removeReaction = (state, id, name) => updateReaction(state, id, name, x => x.set('me', false).update('count', y => y - 1));
+
+export default function announcementsReducer(state = initialState, action) {
+  switch(action.type) {
+  case ANNOUNCEMENTS_FETCH_REQUEST:
+    return state.set('isLoading', true);
+  case ANNOUNCEMENTS_FETCH_SUCCESS:
+    return state.withMutations(map => {
+      map.set('items', fromJS(action.announcements));
+      map.set('isLoading', false);
+    });
+  case ANNOUNCEMENTS_FETCH_FAIL:
+    return state.set('isLoading', false);
+  case ANNOUNCEMENTS_UPDATE:
+    return state.update('items', list => list.unshift(fromJS(action.announcement)).sortBy(announcement => announcement.get('starts_at')));
+  case ANNOUNCEMENTS_DISMISS:
+    return state.update('items', list => list.filterNot(announcement => announcement.get('id') === action.id));
+  case ANNOUNCEMENTS_REACTION_UPDATE:
+    return updateReactionCount(state, action.reaction);
+  case ANNOUNCEMENTS_REACTION_ADD_REQUEST:
+  case ANNOUNCEMENTS_REACTION_REMOVE_FAIL:
+    return addReaction(state, action.id, action.name);
+  case ANNOUNCEMENTS_REACTION_REMOVE_REQUEST:
+  case ANNOUNCEMENTS_REACTION_ADD_FAIL:
+    return removeReaction(state, action.id, action.name);
+  default:
+    return state;
+  }
+};
diff --git a/app/javascript/mastodon/reducers/index.js b/app/javascript/mastodon/reducers/index.js
index b8d608888..b9817cd38 100644
--- a/app/javascript/mastodon/reducers/index.js
+++ b/app/javascript/mastodon/reducers/index.js
@@ -34,8 +34,10 @@ import polls from './polls';
 import identity_proofs from './identity_proofs';
 import trends from './trends';
 import missed_updates from './missed_updates';
+import announcements from './announcements';
 
 const reducers = {
+  announcements,
   dropdown_menu,
   timelines,
   meta,
diff --git a/app/javascript/mastodon/selectors/index.js b/app/javascript/mastodon/selectors/index.js
index 6f1ce9602..6a48f3b3f 100644
--- a/app/javascript/mastodon/selectors/index.js
+++ b/app/javascript/mastodon/selectors/index.js
@@ -26,6 +26,7 @@ const toServerSideType = columnType => {
   case 'notifications':
   case 'public':
   case 'thread':
+  case 'account':
     return columnType;
   default:
     if (columnType.indexOf('list:') > -1) {
diff --git a/app/javascript/mastodon/stream.js b/app/javascript/mastodon/stream.js
index 50f90d44c..fe965bcb0 100644
--- a/app/javascript/mastodon/stream.js
+++ b/app/javascript/mastodon/stream.js
@@ -2,6 +2,14 @@ import WebSocketClient from '@gamestdio/websocket';
 
 const randomIntUpTo = max => Math.floor(Math.random() * Math.floor(max));
 
+const knownEventTypes = [
+  'update',
+  'delete',
+  'notification',
+  'conversation',
+  'filters_changed',
+];
+
 export function connectStream(path, pollingRefresh = null, callbacks = () => ({ onConnect() {}, onDisconnect() {}, onReceive() {} })) {
   return (dispatch, getState) => {
     const streamingAPIBaseURL = getState().getIn(['meta', 'streaming_api_base_url']);
@@ -69,14 +77,42 @@ export function connectStream(path, pollingRefresh = null, callbacks = () => ({
 
 
 export default function getStream(streamingAPIBaseURL, accessToken, stream, { connected, received, disconnected, reconnected }) {
-  const params = [ `stream=${stream}` ];
-
-  const ws = new WebSocketClient(`${streamingAPIBaseURL}/api/v1/streaming/?${params.join('&')}`, accessToken);
-
-  ws.onopen      = connected;
-  ws.onmessage   = e => received(JSON.parse(e.data));
-  ws.onclose     = disconnected;
-  ws.onreconnect = reconnected;
+  const params = stream.split('&');
+  stream = params.shift();
+
+  if (streamingAPIBaseURL.startsWith('ws')) {
+    params.unshift(`stream=${stream}`);
+    const ws = new WebSocketClient(`${streamingAPIBaseURL}/api/v1/streaming/?${params.join('&')}`, accessToken);
+
+    ws.onopen      = connected;
+    ws.onmessage   = e => received(JSON.parse(e.data));
+    ws.onclose     = disconnected;
+    ws.onreconnect = reconnected;
+
+    return ws;
+  }
+
+  params.push(`access_token=${accessToken}`);
+  const es = new EventSource(`${streamingAPIBaseURL}/api/v1/streaming/${stream}?${params.join('&')}`);
+
+  let firstConnect = true;
+  es.onopen = () => {
+    if (firstConnect) {
+      firstConnect = false;
+      connected();
+    } else {
+      reconnected();
+    }
+  };
+  for (let type of knownEventTypes) {
+    es.addEventListener(type, (e) => {
+      received({
+        event: e.type,
+        payload: e.data,
+      });
+    });
+  }
+  es.onerror = disconnected;
 
-  return ws;
+  return es;
 };
diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss
index ccfce1475..8d0a070d5 100644
--- a/app/javascript/styles/mastodon/components.scss
+++ b/app/javascript/styles/mastodon/components.scss
@@ -859,6 +859,44 @@
   }
 }
 
+.announcements__item__content {
+  word-wrap: break-word;
+
+  .emojione {
+    width: 20px;
+    height: 20px;
+    margin: -3px 0 0;
+  }
+
+  p {
+    margin-bottom: 10px;
+    white-space: pre-wrap;
+
+    &:last-child {
+      margin-bottom: 0;
+    }
+  }
+
+  a {
+    color: $highlight-text-color;
+    text-decoration: none;
+
+    &:hover {
+      text-decoration: underline;
+    }
+
+    &.mention {
+      &:hover {
+        text-decoration: none;
+
+        span {
+          text-decoration: underline;
+        }
+      }
+    }
+  }
+}
+
 .status__content.status__content--collapsed {
   max-height: 20px * 15; // 15 lines is roughly above 500 characters
 }
@@ -6581,3 +6619,178 @@ noscript {
     }
   }
 }
+
+.announcements {
+  background: lighten($ui-base-color, 4%);
+  border-top: 1px solid $ui-base-color;
+  font-size: 13px;
+  display: flex;
+  align-items: flex-end;
+
+  &__mastodon {
+    width: 124px;
+    flex: 0 0 auto;
+
+    @media screen and (max-width: 124px + 300px) {
+      display: none;
+    }
+  }
+
+  &__container {
+    width: calc(100% - 124px);
+    flex: 0 0 auto;
+    position: relative;
+
+    @media screen and (max-width: 124px + 300px) {
+      width: 100%;
+    }
+  }
+
+  &__item {
+    box-sizing: border-box;
+    width: 100%;
+    padding: 15px;
+    padding-right: 15px + 18px;
+    position: relative;
+
+    &__range {
+      display: block;
+      font-weight: 500;
+      margin-bottom: 10px;
+    }
+
+    &__dismiss-icon {
+      position: absolute;
+      top: 12px;
+      right: 12px;
+    }
+  }
+
+  &__pagination {
+    padding: 15px;
+    color: $darker-text-color;
+    position: absolute;
+    bottom: 3px;
+    right: 0;
+  }
+}
+
+.layout-multiple-columns .announcements__mastodon {
+  display: none;
+}
+
+.layout-multiple-columns .announcements__container {
+  width: 100%;
+}
+
+.reactions-bar {
+  display: flex;
+  flex-wrap: wrap;
+  align-items: center;
+  margin-top: 15px;
+  margin-left: -2px;
+  width: calc(100% - (90px - 33px));
+
+  &__item {
+    flex-shrink: 0;
+    background: lighten($ui-base-color, 12%);
+    border: 0;
+    border-radius: 3px;
+    margin: 2px;
+    cursor: pointer;
+    user-select: none;
+    padding: 0 6px;
+    display: flex;
+    align-items: center;
+    transition: all 100ms ease-in;
+    transition-property: background-color, color;
+
+    &__emoji {
+      display: block;
+      margin: 3px 0;
+      width: 16px;
+      height: 16px;
+
+      img {
+        display: block;
+        margin: 0;
+        width: 100%;
+        height: 100%;
+        min-width: auto;
+        min-height: auto;
+        vertical-align: bottom;
+        object-fit: contain;
+      }
+    }
+
+    &__count {
+      display: block;
+      min-width: 9px;
+      font-size: 13px;
+      font-weight: 500;
+      text-align: center;
+      margin-left: 6px;
+      color: $darker-text-color;
+    }
+
+    &:hover,
+    &:focus,
+    &:active {
+      background: lighten($ui-base-color, 16%);
+      transition: all 200ms ease-out;
+      transition-property: background-color, color;
+
+      &__count {
+        color: lighten($darker-text-color, 4%);
+      }
+    }
+
+    &.active {
+      transition: all 100ms ease-in;
+      transition-property: background-color, color;
+      background-color: mix(lighten($ui-base-color, 12%), $ui-highlight-color, 90%);
+
+      .reactions-bar__item__count {
+        color: $highlight-text-color;
+      }
+    }
+  }
+
+  .emoji-picker-dropdown {
+    margin: 2px;
+  }
+
+  &:hover .emoji-button {
+    opacity: 0.85;
+  }
+
+  .emoji-button {
+    color: $darker-text-color;
+    margin: 0;
+    font-size: 16px;
+    width: auto;
+    flex-shrink: 0;
+    padding: 0 6px;
+    height: 22px;
+    display: flex;
+    align-items: center;
+    opacity: 0.5;
+    transition: all 100ms ease-in;
+    transition-property: background-color, color;
+
+    &:hover,
+    &:active,
+    &:focus {
+      opacity: 1;
+      color: lighten($darker-text-color, 4%);
+      transition: all 200ms ease-out;
+      transition-property: background-color, color;
+    }
+  }
+
+  &--empty {
+    .emoji-button {
+      padding: 0;
+    }
+  }
+}
diff --git a/app/javascript/styles/mastodon/forms.scss b/app/javascript/styles/mastodon/forms.scss
index 8965ce675..65cefbd7c 100644
--- a/app/javascript/styles/mastodon/forms.scss
+++ b/app/javascript/styles/mastodon/forms.scss
@@ -222,6 +222,12 @@ code {
     }
   }
 
+  .input.datetime .label_input select {
+    display: inline-block;
+    width: auto;
+    flex: 0;
+  }
+
   .required abbr {
     text-decoration: none;
     color: lighten($error-value-color, 12%);
diff --git a/app/lib/entity_cache.rb b/app/lib/entity_cache.rb
index 8fff544a0..35a3773d2 100644
--- a/app/lib/entity_cache.rb
+++ b/app/lib/entity_cache.rb
@@ -8,7 +8,7 @@ class EntityCache
   MAX_EXPIRATION = 7.days.freeze
 
   def mention(username, domain)
-    Rails.cache.fetch(to_key(:mention, username, domain), expires_in: MAX_EXPIRATION) { Account.select(:username, :domain, :url).find_remote(username, domain) }
+    Rails.cache.fetch(to_key(:mention, username, domain), expires_in: MAX_EXPIRATION) { Account.select(:id, :username, :domain, :url).find_remote(username, domain) }
   end
 
   def emoji(shortcodes, domain)
diff --git a/app/lib/formatter.rb b/app/lib/formatter.rb
index 36cdae9f7..f1a751f84 100644
--- a/app/lib/formatter.rb
+++ b/app/lib/formatter.rb
@@ -308,9 +308,9 @@ class Formatter
     end
 
     standard = Extractor.extract_entities_with_indices(text, options)
-    xmpp = Extractor.extract_xmpp_uris_with_indices(text, options)
+    extra = Extractor.extract_extra_uris_with_indices(text, options)
 
-    Extractor.remove_overlapping_entities(special + standard + xmpp)
+    Extractor.remove_overlapping_entities(special + standard + extra)
   end
 
   def html_friendly_extractor(html, options = {})
diff --git a/app/lib/inline_renderer.rb b/app/lib/inline_renderer.rb
index 761a8822d..27e334a4d 100644
--- a/app/lib/inline_renderer.rb
+++ b/app/lib/inline_renderer.rb
@@ -15,6 +15,10 @@ class InlineRenderer
       serializer = REST::NotificationSerializer
     when :conversation
       serializer = REST::ConversationSerializer
+    when :announcement
+      serializer = REST::AnnouncementSerializer
+    when :reaction
+      serializer = REST::ReactionSerializer
     else
       return
     end
diff --git a/app/lib/sanitize_config.rb b/app/lib/sanitize_config.rb
index 3554d34b9..2b5d554b5 100644
--- a/app/lib/sanitize_config.rb
+++ b/app/lib/sanitize_config.rb
@@ -2,7 +2,7 @@
 
 class Sanitize
   module Config
-    HTTP_PROTOCOLS ||= ['http', 'https', 'dat', 'dweb', 'ipfs', 'ipns', 'ssb', 'gopher', 'xmpp', :relative].freeze
+    HTTP_PROTOCOLS ||= ['http', 'https', 'dat', 'dweb', 'ipfs', 'ipns', 'ssb', 'gopher', 'xmpp', 'magnet', :relative].freeze
 
     CLASS_WHITELIST_TRANSFORMER = lambda do |env|
       node = env[:node]
diff --git a/app/middleware/handle_bad_encoding_middleware.rb b/app/middleware/handle_bad_encoding_middleware.rb
deleted file mode 100644
index 6fce84b15..000000000
--- a/app/middleware/handle_bad_encoding_middleware.rb
+++ /dev/null
@@ -1,18 +0,0 @@
-# frozen_string_literal: true
-# See: https://jamescrisp.org/2018/05/28/fixing-invalid-query-parameters-invalid-encoding-in-a-rails-app/
-
-class HandleBadEncodingMiddleware
-  def initialize(app)
-    @app = app
-  end
-
-  def call(env)
-    begin
-      Rack::Utils.parse_nested_query(env['QUERY_STRING'].to_s)
-    rescue Rack::Utils::InvalidParameterError
-      env['QUERY_STRING'] = ''
-    end
-
-    @app.call(env)
-  end
-end
diff --git a/app/models/account.rb b/app/models/account.rb
index 9b9361670..b856d1c76 100644
--- a/app/models/account.rb
+++ b/app/models/account.rb
@@ -478,6 +478,12 @@ class Account < ApplicationRecord
       records
     end
 
+    def from_text(text)
+      return [] if text.blank?
+
+      text.scan(MENTION_RE).map { |match| match.first.split('@', 2) }.uniq.map { |(username, domain)| EntityCache.instance.mention(username, domain) }
+    end
+
     private
 
     def generate_query_for_search(terms)
diff --git a/app/models/announcement.rb b/app/models/announcement.rb
new file mode 100644
index 000000000..4da9f94d6
--- /dev/null
+++ b/app/models/announcement.rb
@@ -0,0 +1,85 @@
+# frozen_string_literal: true
+
+# == Schema Information
+#
+# Table name: announcements
+#
+#  id           :bigint(8)        not null, primary key
+#  text         :text             default(""), not null
+#  published    :boolean          default(FALSE), not null
+#  all_day      :boolean          default(FALSE), not null
+#  scheduled_at :datetime
+#  starts_at    :datetime
+#  ends_at      :datetime
+#  created_at   :datetime         not null
+#  updated_at   :datetime         not null
+#
+
+class Announcement < ApplicationRecord
+  after_commit :queue_publish, on: :create
+
+  scope :unpublished, -> { where(published: false) }
+  scope :published, -> { where(published: true) }
+  scope :without_muted, ->(account) { joins("LEFT OUTER JOIN announcement_mutes ON announcement_mutes.announcement_id = announcements.id AND announcement_mutes.account_id = #{account.id}").where('announcement_mutes.id IS NULL') }
+  scope :chronological, -> { order(Arel.sql('COALESCE(announcements.starts_at, announcements.scheduled_at, announcements.created_at) ASC')) }
+
+  has_many :announcement_mutes, dependent: :destroy
+  has_many :announcement_reactions, dependent: :destroy
+
+  validates :text, presence: true
+  validates :starts_at, presence: true, if: -> { ends_at.present? }
+  validates :ends_at, presence: true, if: -> { starts_at.present? }
+
+  before_validation :set_all_day
+  before_validation :set_starts_at, on: :create
+  before_validation :set_ends_at, on: :create
+
+  def time_range?
+    starts_at.present? && ends_at.present?
+  end
+
+  def mentions
+    @mentions ||= Account.from_text(text)
+  end
+
+  def tags
+    @tags ||= Tag.find_or_create_by_names(Extractor.extract_hashtags(text))
+  end
+
+  def emojis
+    @emojis ||= CustomEmoji.from_text(text)
+  end
+
+  def reactions(account = nil)
+    records = begin
+      scope = announcement_reactions.group(:announcement_id, :name, :custom_emoji_id).order(Arel.sql('MIN(created_at) ASC'))
+
+      if account.nil?
+        scope.select('name, custom_emoji_id, count(*) as count, false as me')
+      else
+        scope.select("name, custom_emoji_id, count(*) as count, exists(select 1 from announcement_reactions r where r.account_id = #{account.id} and r.announcement_id = announcement_reactions.announcement_id and r.name = announcement_reactions.name) as me")
+      end
+    end
+
+    ActiveRecord::Associations::Preloader.new.preload(records, :custom_emoji)
+    records
+  end
+
+  private
+
+  def set_all_day
+    self.all_day = false if starts_at.blank? || ends_at.blank?
+  end
+
+  def set_starts_at
+    self.starts_at = starts_at.change(hour: 0, min: 0, sec: 0) if all_day? && starts_at.present?
+  end
+
+  def set_ends_at
+    self.ends_at = ends_at.change(hour: 23, min: 59, sec: 59) if all_day? && ends_at.present?
+  end
+
+  def queue_publish
+    PublishScheduledAnnouncementWorker.perform_async(id) if scheduled_at.blank?
+  end
+end
diff --git a/app/models/announcement_filter.rb b/app/models/announcement_filter.rb
new file mode 100644
index 000000000..950852460
--- /dev/null
+++ b/app/models/announcement_filter.rb
@@ -0,0 +1,39 @@
+# frozen_string_literal: true
+
+class AnnouncementFilter
+  KEYS = %i(
+    published
+    unpublished
+  ).freeze
+
+  attr_reader :params
+
+  def initialize(params)
+    @params = params
+  end
+
+  def results
+    scope = Announcement.unscoped
+
+    params.each do |key, value|
+      next if key.to_s == 'page'
+
+      scope.merge!(scope_for(key, value.to_s.strip)) if value.present?
+    end
+
+    scope.chronological
+  end
+
+  private
+
+  def scope_for(key, _value)
+    case key.to_s
+    when 'published'
+      Announcement.published
+    when 'unpublished'
+      Announcement.unpublished
+    else
+      raise "Unknown filter: #{key}"
+    end
+  end
+end
diff --git a/app/models/announcement_mute.rb b/app/models/announcement_mute.rb
new file mode 100644
index 000000000..46fda2f5d
--- /dev/null
+++ b/app/models/announcement_mute.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+# == Schema Information
+#
+# Table name: announcement_mutes
+#
+#  id              :bigint(8)        not null, primary key
+#  account_id      :bigint(8)
+#  announcement_id :bigint(8)
+#  created_at      :datetime         not null
+#  updated_at      :datetime         not null
+#
+
+class AnnouncementMute < ApplicationRecord
+  belongs_to :account
+  belongs_to :announcement, inverse_of: :announcement_mutes
+
+  validates :account_id, uniqueness: { scope: :announcement_id }
+end
diff --git a/app/models/announcement_reaction.rb b/app/models/announcement_reaction.rb
new file mode 100644
index 000000000..d22771034
--- /dev/null
+++ b/app/models/announcement_reaction.rb
@@ -0,0 +1,37 @@
+# frozen_string_literal: true
+
+# == Schema Information
+#
+# Table name: announcement_reactions
+#
+#  id              :bigint(8)        not null, primary key
+#  account_id      :bigint(8)
+#  announcement_id :bigint(8)
+#  name            :string           default(""), not null
+#  custom_emoji_id :bigint(8)
+#  created_at      :datetime         not null
+#  updated_at      :datetime         not null
+#
+
+class AnnouncementReaction < ApplicationRecord
+  after_commit :queue_publish
+
+  belongs_to :account
+  belongs_to :announcement, inverse_of: :announcement_reactions
+  belongs_to :custom_emoji, optional: true
+
+  validates :name, presence: true
+  validates_with ReactionValidator
+
+  before_validation :set_custom_emoji
+
+  private
+
+  def set_custom_emoji
+    self.custom_emoji = CustomEmoji.local.find_by(disabled: false, shortcode: name) if name.present?
+  end
+
+  def queue_publish
+    PublishAnnouncementReactionWorker.perform_async(announcement_id, name) unless announcement.destroyed?
+  end
+end
diff --git a/app/models/backup.rb b/app/models/backup.rb
index 8eeb1748a..d242fd62c 100644
--- a/app/models/backup.rb
+++ b/app/models/backup.rb
@@ -7,11 +7,11 @@
 #  user_id           :bigint(8)
 #  dump_file_name    :string
 #  dump_content_type :string
-#  dump_file_size    :bigint
 #  dump_updated_at   :datetime
 #  processed         :boolean          default(FALSE), not null
 #  created_at        :datetime         not null
 #  updated_at        :datetime         not null
+#  dump_file_size    :bigint(8)
 #
 
 class Backup < ApplicationRecord
diff --git a/app/models/bookmark.rb b/app/models/bookmark.rb
index 01dc48ee7..916261a17 100644
--- a/app/models/bookmark.rb
+++ b/app/models/bookmark.rb
@@ -3,11 +3,11 @@
 #
 # Table name: bookmarks
 #
-#  id         :integer          not null, primary key
+#  id         :bigint(8)        not null, primary key
+#  account_id :bigint(8)        not null
+#  status_id  :bigint(8)        not null
 #  created_at :datetime         not null
 #  updated_at :datetime         not null
-#  account_id :integer          not null
-#  status_id  :integer          not null
 #
 
 class Bookmark < ApplicationRecord
diff --git a/app/models/concerns/account_interactions.rb b/app/models/concerns/account_interactions.rb
index f27d39483..14bcf7bb1 100644
--- a/app/models/concerns/account_interactions.rb
+++ b/app/models/concerns/account_interactions.rb
@@ -84,6 +84,7 @@ module AccountInteractions
     has_many :muted_by, -> { order('mutes.id desc') }, through: :muted_by_relationships, source: :account
     has_many :conversation_mutes, dependent: :destroy
     has_many :domain_blocks, class_name: 'AccountDomainBlock', dependent: :destroy
+    has_many :announcement_mutes, dependent: :destroy
   end
 
   def follow!(other_account, reblogs: nil, uri: nil)
diff --git a/app/models/custom_emoji.rb b/app/models/custom_emoji.rb
index 0dacaf654..d177cf281 100644
--- a/app/models/custom_emoji.rb
+++ b/app/models/custom_emoji.rb
@@ -67,7 +67,7 @@ class CustomEmoji < ApplicationRecord
   end
 
   class << self
-    def from_text(text, domain)
+    def from_text(text, domain = nil)
       return [] if text.blank?
 
       shortcodes = text.scan(SCAN_RE).map(&:first).uniq
diff --git a/app/models/custom_filter.rb b/app/models/custom_filter.rb
index 382562fb8..8df8a4fbf 100644
--- a/app/models/custom_filter.rb
+++ b/app/models/custom_filter.rb
@@ -20,6 +20,7 @@ class CustomFilter < ApplicationRecord
     notifications
     public
     thread
+    account
   ).freeze
 
   include Expireable
diff --git a/app/models/media_attachment.rb b/app/models/media_attachment.rb
index b87b1b9d3..6a0b892f6 100644
--- a/app/models/media_attachment.rb
+++ b/app/models/media_attachment.rb
@@ -142,6 +142,7 @@ class MediaAttachment < ApplicationRecord
 
   validates :account, presence: true
   validates :description, length: { maximum: MAX_DESCRIPTION_LENGTH }, if: :local?
+  validates :file, presence: true, if: :local?
 
   scope :attached,   -> { where.not(status_id: nil).or(where.not(scheduled_status_id: nil)) }
   scope :unattached, -> { where(status_id: nil, scheduled_status_id: nil) }
diff --git a/app/models/relationship_filter.rb b/app/models/relationship_filter.rb
index 51640f494..e6859bf3d 100644
--- a/app/models/relationship_filter.rb
+++ b/app/models/relationship_filter.rb
@@ -7,5 +7,114 @@ class RelationshipFilter
     by_domain
     activity
     order
+    location
   ).freeze
+
+  attr_reader :params, :account
+
+  def initialize(account, params)
+    @account = account
+    @params  = params
+
+    set_defaults!
+  end
+
+  def results
+    scope = scope_for('relationship', params['relationship'].to_s.strip)
+
+    params.each do |key, value|
+      next if key.to_s == 'page'
+
+      scope.merge!(scope_for(key.to_s, value.to_s.strip)) if value.present?
+    end
+
+    scope
+  end
+
+  private
+
+  def set_defaults!
+    params['relationship'] = 'following' if params['relationship'].blank?
+    params['order']        = 'recent' if params['order'].blank?
+  end
+
+  def scope_for(key, value)
+    case key
+    when 'relationship'
+      relationship_scope(value)
+    when 'by_domain'
+      by_domain_scope(value)
+    when 'location'
+      location_scope(value)
+    when 'status'
+      status_scope(value)
+    when 'order'
+      order_scope(value)
+    when 'activity'
+      activity_scope(value)
+    else
+      raise "Unknown filter: #{key}"
+    end
+  end
+
+  def relationship_scope(value)
+    case value
+    when 'following'
+      account.following.eager_load(:account_stat).reorder(nil)
+    when 'followed_by'
+      account.followers.eager_load(:account_stat).reorder(nil)
+    when 'mutual'
+      account.followers.eager_load(:account_stat).reorder(nil).merge(Account.where(id: account.following))
+    when 'invited'
+      Account.joins(user: :invite).merge(Invite.where(user: account.user)).eager_load(:account_stat).reorder(nil)
+    else
+      raise "Unknown relationship: #{value}"
+    end
+  end
+
+  def by_domain_scope(value)
+    Account.where(domain: value)
+  end
+
+  def location_scope(value)
+    case value
+    when 'local'
+      Account.local
+    when 'remote'
+      Account.remote
+    else
+      raise "Unknown location: #{value}"
+    end
+  end
+
+  def status_scope(value)
+    case value
+    when 'moved'
+      Account.where.not(moved_to_account_id: nil)
+    when 'primary'
+      Account.where(moved_to_account_id: nil)
+    else
+      raise "Unknown status: #{value}"
+    end
+  end
+
+  def order_scope(value)
+    case value
+    when 'active'
+      Account.by_recent_status
+    when 'recent'
+      params[:relationship] == 'invited' ? Account.recent : Follow.recent
+    else
+      raise "Unknown order: #{value}"
+    end
+  end
+
+  def activity_scope(value)
+    case value
+    when 'dormant'
+      AccountStat.where(last_status_at: nil).or(AccountStat.where(AccountStat.arel_table[:last_status_at].lt(1.month.ago)))
+    else
+      raise "Unknown activity: #{value}"
+    end
+  end
 end
diff --git a/app/models/user.rb b/app/models/user.rb
index 794c2091c..9d5114e74 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -248,7 +248,7 @@ class User < ApplicationRecord
                                  ip: request.remote_ip).session_id
   end
 
-  def exclusive_session(id)
+  def clear_other_sessions(id)
     session_activations.exclusive(id)
   end
 
diff --git a/app/policies/announcement_policy.rb b/app/policies/announcement_policy.rb
new file mode 100644
index 000000000..0a4e4575c
--- /dev/null
+++ b/app/policies/announcement_policy.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+class AnnouncementPolicy < ApplicationPolicy
+  def index?
+    staff?
+  end
+
+  def create?
+    admin?
+  end
+
+  def update?
+    admin?
+  end
+
+  def destroy?
+    admin?
+  end
+end
diff --git a/app/serializers/rest/announcement_serializer.rb b/app/serializers/rest/announcement_serializer.rb
new file mode 100644
index 000000000..924d87b34
--- /dev/null
+++ b/app/serializers/rest/announcement_serializer.rb
@@ -0,0 +1,34 @@
+# frozen_string_literal: true
+
+class REST::AnnouncementSerializer < ActiveModel::Serializer
+  attributes :id, :content, :starts_at, :ends_at, :all_day
+
+  has_many :mentions
+  has_many :tags, serializer: REST::StatusSerializer::TagSerializer
+  has_many :emojis, serializer: REST::CustomEmojiSerializer
+  has_many :reactions, serializer: REST::ReactionSerializer
+
+  def id
+    object.id.to_s
+  end
+
+  def content
+    Formatter.instance.linkify(object.text)
+  end
+
+  def reactions
+    object.reactions(current_user&.account)
+  end
+
+  class AccountSerializer < ActiveModel::Serializer
+    attributes :id, :username, :url, :acct
+
+    def id
+      object.id.to_s
+    end
+
+    def url
+      ActivityPub::TagManager.instance.url_for(object)
+    end
+  end
+end
diff --git a/app/serializers/rest/reaction_serializer.rb b/app/serializers/rest/reaction_serializer.rb
new file mode 100644
index 000000000..1a5dca018
--- /dev/null
+++ b/app/serializers/rest/reaction_serializer.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+class REST::ReactionSerializer < ActiveModel::Serializer
+  include RoutingHelper
+
+  attributes :name, :count
+
+  attribute :me, if: :current_user?
+  attribute :url, if: :custom_emoji?
+  attribute :static_url, if: :custom_emoji?
+
+  def count
+    object.respond_to?(:count) ? object.count : 0
+  end
+
+  def current_user?
+    !current_user.nil?
+  end
+
+  def custom_emoji?
+    object.custom_emoji.present?
+  end
+
+  def url
+    full_asset_url(object.custom_emoji.image.url)
+  end
+
+  def static_url
+    full_asset_url(object.custom_emoji.image.url(:static))
+  end
+end
diff --git a/app/validators/reaction_validator.rb b/app/validators/reaction_validator.rb
new file mode 100644
index 000000000..de0f2c94b
--- /dev/null
+++ b/app/validators/reaction_validator.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+class ReactionValidator < ActiveModel::Validator
+  SUPPORTED_EMOJIS = Oj.load(File.read(Rails.root.join('app', 'javascript', 'mastodon', 'features', 'emoji', 'emoji_map.json'))).keys.freeze
+
+  def validate(reaction)
+    return if reaction.name.blank? || reaction.custom_emoji_id.present?
+
+    reaction.errors.add(:name, I18n.t('reactions.errors.unrecognized_emoji')) unless unicode_emoji?(reaction.name)
+  end
+
+  private
+
+  def unicode_emoji?(name)
+    SUPPORTED_EMOJIS.include?(name)
+  end
+end
diff --git a/app/views/accounts/show.html.haml b/app/views/accounts/show.html.haml
index 9c26dbabc..c312fe2bd 100644
--- a/app/views/accounts/show.html.haml
+++ b/app/views/accounts/show.html.haml
@@ -2,8 +2,6 @@
   = "#{display_name(@account)} (@#{@account.local_username_and_domain})"
 
 - content_for :header_tags do
-  %meta{ name: 'description', content: account_description(@account) }/
-
   - if @account.user&.setting_noindex
     %meta{ name: 'robots', content: 'noindex, noarchive' }/
 
diff --git a/app/views/admin/accounts/show.html.haml b/app/views/admin/accounts/show.html.haml
index 1429f56d5..a83f77134 100644
--- a/app/views/admin/accounts/show.html.haml
+++ b/app/views/admin/accounts/show.html.haml
@@ -41,7 +41,7 @@
       .dashboard__counters__num= number_to_human_size @account.media_attachments.sum('file_file_size')
       .dashboard__counters__label= t 'admin.accounts.media_attachments'
   %div
-    = link_to admin_account_followers_path(@account.id) do
+    = link_to admin_account_relationships_path(@account.id, location: 'local', relationship: 'followed_by') do
       .dashboard__counters__num= number_with_delimiter @account.local_followers_count
       .dashboard__counters__label= t 'admin.accounts.followers'
   %div
diff --git a/app/views/admin/announcements/_announcement.html.haml b/app/views/admin/announcements/_announcement.html.haml
new file mode 100644
index 000000000..75768c7ba
--- /dev/null
+++ b/app/views/admin/announcements/_announcement.html.haml
@@ -0,0 +1,14 @@
+%tr
+  %td
+    = truncate(announcement.text)
+  %td
+    = time_range(announcement) if announcement.time_range?
+  %td
+    - if announcement.scheduled_at.present?
+      = fa_icon('clock-o') if announcement.scheduled_at > Time.now.utc
+      = l(announcement.scheduled_at)
+    - else
+      = l(announcement.created_at)
+  %td
+    = table_link_to 'pencil', t('generic.edit'), edit_admin_announcement_path(announcement) if can?(:update, announcement)
+    = table_link_to 'trash', t('generic.delete'), admin_announcement_path(announcement), method: :delete, data: { confirm: t('admin.accounts.are_you_sure') } if can?(:destroy, announcement)
diff --git a/app/views/admin/announcements/edit.html.haml b/app/views/admin/announcements/edit.html.haml
new file mode 100644
index 000000000..c5c605e93
--- /dev/null
+++ b/app/views/admin/announcements/edit.html.haml
@@ -0,0 +1,22 @@
+- content_for :page_title do
+  = t('.title')
+
+= simple_form_for @announcement, url: admin_announcement_path(@announcement) do |f|
+  = render 'shared/error_messages', object: @announcement
+
+  .fields-group
+    = f.input :starts_at, include_blank: true, wrapper: :with_block_label
+    = f.input :ends_at, include_blank: true, wrapper: :with_block_label
+
+  .fields-group
+    = f.input :all_day, as: :boolean, wrapper: :with_label
+
+  .fields-group
+    = f.input :text, wrapper: :with_block_label
+
+  - if @announcement.scheduled_at.present? && !@announcement.published?
+    .fields-group
+      = f.input :scheduled_at, include_blank: true, wrapper: :with_block_label
+
+  .actions
+    = f.button :button, t('generic.save_changes'), type: :submit
diff --git a/app/views/admin/announcements/index.html.haml b/app/views/admin/announcements/index.html.haml
new file mode 100644
index 000000000..634f586fb
--- /dev/null
+++ b/app/views/admin/announcements/index.html.haml
@@ -0,0 +1,30 @@
+- content_for :page_title do
+  = t('admin.announcements.title')
+
+- content_for :heading_actions do
+  = link_to t('admin.announcements.new.title'), new_admin_announcement_path, class: 'button'
+
+.filters
+  .filter-subset
+    %strong= t('admin.relays.status')
+    %ul
+      %li= filter_link_to t('generic.all'), published: nil, unpublished: nil
+      %li= filter_link_to safe_join([t('admin.announcements.live'), "(#{number_with_delimiter(Announcement.published.count)})"], ' '), published: '1', unpublished: nil
+
+- if @announcements.empty?
+  %div.muted-hint.center-text
+    = t 'admin.announcements.empty'
+- else
+  .table-wrapper
+    %table.table
+      %thead
+        %tr
+          %th= t('simple_form.labels.announcement.text')
+          %th= t('admin.announcements.time_range')
+          %th= t('admin.announcements.published')
+          %th
+      %tbody
+        = render partial: 'announcement', collection: @announcements
+
+= paginate @announcements
+
diff --git a/app/views/admin/announcements/new.html.haml b/app/views/admin/announcements/new.html.haml
new file mode 100644
index 000000000..a5298c5f6
--- /dev/null
+++ b/app/views/admin/announcements/new.html.haml
@@ -0,0 +1,21 @@
+- content_for :page_title do
+  = t('.title')
+
+= simple_form_for @announcement, url: admin_announcements_path do |f|
+  = render 'shared/error_messages', object: @announcement
+
+  .fields-group
+    = f.input :starts_at, include_blank: true, wrapper: :with_block_label
+    = f.input :ends_at, include_blank: true, wrapper: :with_block_label
+
+  .fields-group
+    = f.input :all_day, as: :boolean, wrapper: :with_label
+
+  .fields-group
+    = f.input :text, wrapper: :with_block_label
+
+  .fields-group
+    = f.input :scheduled_at, include_blank: true, wrapper: :with_block_label
+
+  .actions
+    = f.button :button, t('.create'), type: :submit
diff --git a/app/views/admin/followers/index.html.haml b/app/views/admin/followers/index.html.haml
deleted file mode 100644
index 25f1f290f..000000000
--- a/app/views/admin/followers/index.html.haml
+++ /dev/null
@@ -1,28 +0,0 @@
-- content_for :page_title do
-  = t('admin.followers.title', acct: @account.acct)
-
-.filters
-  .filter-subset
-    %strong= t('admin.accounts.location.title')
-    %ul
-      %li= link_to t('admin.accounts.location.local'), admin_account_followers_path(@account.id), class: 'selected'
-  .back-link{ style: 'flex: 1 1 auto; text-align: right' }
-    = link_to admin_account_path(@account.id) do
-      = fa_icon 'chevron-left fw'
-      = t('admin.followers.back_to_account')
-
-%hr.spacer/
-
-.table-wrapper
-  %table.table
-    %thead
-      %tr
-        %th= t('admin.accounts.username')
-        %th= t('admin.accounts.role')
-        %th= t('admin.accounts.most_recent_ip')
-        %th= t('admin.accounts.most_recent_activity')
-        %th
-    %tbody
-      = render partial: 'admin/accounts/account', collection: @followers
-
-= paginate @followers
diff --git a/app/views/admin/relationships/index.html.haml b/app/views/admin/relationships/index.html.haml
new file mode 100644
index 000000000..3afaff615
--- /dev/null
+++ b/app/views/admin/relationships/index.html.haml
@@ -0,0 +1,39 @@
+- content_for :page_title do
+  = t('admin.relationships.title', acct: @account.acct)
+
+.filters
+  .filter-subset
+    %strong= t 'relationships.relationship'
+    %ul
+      %li= filter_link_to t('relationships.following'), relationship: nil
+      %li= filter_link_to t('relationships.followers'), relationship: 'followed_by'
+      %li= filter_link_to t('relationships.mutual'), relationship: 'mutual'
+      %li= filter_link_to t('relationships.invited'), relationship: 'invited'
+
+  .filter-subset
+    %strong= t('admin.accounts.location.title')
+    %ul
+      %li= filter_link_to t('admin.accounts.moderation.all'), location: nil
+      %li= filter_link_to t('admin.accounts.location.local'), location: 'local'
+      %li= filter_link_to t('admin.accounts.location.remote'), location: 'remote'
+
+  .back-link{ style: 'flex: 1 1 auto; text-align: right' }
+    = link_to admin_account_path(@account.id) do
+      = fa_icon 'chevron-left fw'
+      = t('admin.statuses.back_to_account')
+
+%hr.spacer/
+
+.table-wrapper
+  %table.table
+    %thead
+      %tr
+        %th= t('admin.accounts.username')
+        %th= t('admin.accounts.role')
+        %th= t('admin.accounts.most_recent_ip')
+        %th= t('admin.accounts.most_recent_activity')
+        %th
+    %tbody
+      = render partial: 'admin/accounts/account', collection: @accounts
+
+= paginate @accounts
diff --git a/app/workers/publish_announcement_reaction_worker.rb b/app/workers/publish_announcement_reaction_worker.rb
new file mode 100644
index 000000000..6f3b6dc5b
--- /dev/null
+++ b/app/workers/publish_announcement_reaction_worker.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+class PublishAnnouncementReactionWorker
+  include Sidekiq::Worker
+  include Redisable
+
+  def perform(announcement_id, name)
+    announcement = Announcement.find(announcement_id)
+
+    reaction,  = announcement.announcement_reactions.where(name: name).group(:announcement_id, :name, :custom_emoji_id).select('name, custom_emoji_id, count(*) as count, false as me')
+    reaction ||= announcement.announcement_reactions.new(name: name)
+
+    payload = InlineRenderer.render(reaction, nil, :reaction).tap { |h| h[:announcement_id] = announcement_id }
+    payload = Oj.dump(event: :'announcement.reaction', payload: payload)
+
+    Account.joins(:user).where('users.current_sign_in_at > ?', User::ACTIVE_DURATION.ago).find_each do |account|
+      redis.publish("timeline:#{account.id}", payload) if redis.exists("subscribed:timeline:#{account.id}")
+    end
+  rescue ActiveRecord::RecordNotFound
+    true
+  end
+end
diff --git a/app/workers/publish_scheduled_announcement_worker.rb b/app/workers/publish_scheduled_announcement_worker.rb
new file mode 100644
index 000000000..4b2014e34
--- /dev/null
+++ b/app/workers/publish_scheduled_announcement_worker.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+class PublishScheduledAnnouncementWorker
+  include Sidekiq::Worker
+  include Redisable
+
+  def perform(announcement_id)
+    announcement = Announcement.find(announcement_id)
+    announcement.update(published: true)
+
+    payload = InlineRenderer.render(announcement, nil, :announcement)
+    payload = Oj.dump(event: :announcement, payload: payload)
+
+    Account.joins(:user).where('users.current_sign_in_at > ?', User::ACTIVE_DURATION.ago).find_each do |account|
+      redis.publish("timeline:#{account.id}", payload) if redis.exists("subscribed:timeline:#{account.id}")
+    end
+  end
+end
diff --git a/app/workers/scheduler/scheduled_statuses_scheduler.rb b/app/workers/scheduler/scheduled_statuses_scheduler.rb
index 1772a246b..4262f1d01 100644
--- a/app/workers/scheduler/scheduled_statuses_scheduler.rb
+++ b/app/workers/scheduler/scheduled_statuses_scheduler.rb
@@ -6,14 +6,38 @@ class Scheduler::ScheduledStatusesScheduler
   sidekiq_options unique: :until_executed, retry: 0
 
   def perform
+    publish_scheduled_statuses!
+    publish_scheduled_announcements!
+    unpublish_expired_announcements!
+  end
+
+  private
+
+  def publish_scheduled_statuses!
     due_statuses.find_each do |scheduled_status|
       PublishScheduledStatusWorker.perform_at(scheduled_status.scheduled_at, scheduled_status.id)
     end
   end
 
-  private
-
   def due_statuses
     ScheduledStatus.where('scheduled_at <= ?', Time.now.utc + PostStatusService::MIN_SCHEDULE_OFFSET)
   end
+
+  def publish_scheduled_announcements!
+    due_announcements.find_each do |announcement|
+      PublishScheduledAnnouncementWorker.perform_at(announcement.scheduled_at, announcement.id)
+    end
+  end
+
+  def due_announcements
+    Announcement.unpublished.where('scheduled_at IS NOT NULL AND scheduled_at <= ?', Time.now.utc + PostStatusService::MIN_SCHEDULE_OFFSET)
+  end
+
+  def unpublish_expired_announcements!
+    expired_announcements.in_batches.update_all(published: false)
+  end
+
+  def expired_announcements
+    Announcement.published.where('ends_at IS NOT NULL AND ends_at <= ?', Time.now.utc)
+  end
 end