about summary refs log tree commit diff
diff options
context:
space:
mode:
authorClaire <claire.github-309c@sitedethib.com>2022-03-29 16:39:43 +0200
committerGitHub <noreply@github.com>2022-03-29 16:39:43 +0200
commiteaea849035ea407afb2d5db411dbddc1ccca6f44 (patch)
tree19a0b6e761fc453fac2f5a8aba24595ad93f0382
parent2287eebae0c1d699436a8cf3218d7cfe990a3605 (diff)
parent8d6f3f8a379c8d552d2101cf35ae8f6fe956da53 (diff)
Merge pull request #1724 from ClearlyClaire/glitch-soc/merge-upstream
Merge upstream changes
-rw-r--r--.circleci/config.yml2
-rw-r--r--CHANGELOG.md6
-rw-r--r--app/chewy/statuses_index.rb4
-rw-r--r--app/controllers/api/v1/trends/links_controller.rb26
-rw-r--r--app/controllers/api/v1/trends/statuses_controller.rb24
-rw-r--r--app/controllers/api/v1/trends/tags_controller.rb26
-rw-r--r--app/controllers/api/web/embeds_controller.rb2
-rw-r--r--app/helpers/accounts_helper.rb6
-rw-r--r--app/helpers/admin/trends/statuses_helper.rb5
-rw-r--r--app/helpers/application_helper.rb4
-rw-r--r--app/helpers/formatting_helper.rb27
-rw-r--r--app/helpers/routing_helper.rb3
-rw-r--r--app/helpers/statuses_helper.rb14
-rw-r--r--app/javascript/flavours/glitch/components/admin/Counter.js5
-rw-r--r--app/javascript/mastodon/components/admin/Counter.js5
-rw-r--r--app/javascript/mastodon/locales/fa.json142
-rw-r--r--app/javascript/mastodon/locales/ja.json2
-rw-r--r--app/javascript/mastodon/locales/ku.json4
-rw-r--r--app/javascript/mastodon/locales/nl.json212
-rw-r--r--app/lib/activitypub/activity/create.rb4
-rw-r--r--app/lib/activitypub/parser/media_attachment_parser.rb4
-rw-r--r--app/lib/admin/system_check.rb1
-rw-r--r--app/lib/admin/system_check/elasticsearch_check.rb39
-rw-r--r--app/lib/advanced_text_formatter.rb131
-rw-r--r--app/lib/emoji_formatter.rb98
-rw-r--r--app/lib/extractor.rb82
-rw-r--r--app/lib/feed_manager.rb3
-rw-r--r--app/lib/formatter.rb382
-rw-r--r--app/lib/html_aware_formatter.rb42
-rw-r--r--app/lib/link_details_extractor.rb2
-rw-r--r--app/lib/plain_text_formatter.rb30
-rw-r--r--app/lib/rss/serializer.rb23
-rw-r--r--app/lib/text_formatter.rb158
-rw-r--r--app/mailers/application_mailer.rb1
-rw-r--r--app/models/account.rb2
-rw-r--r--app/models/concerns/status_snapshot_concern.rb36
-rw-r--r--app/models/media_attachment.rb7
-rw-r--r--app/models/status.rb22
-rw-r--r--app/models/trends/query.rb4
-rw-r--r--app/models/user.rb6
-rw-r--r--app/serializers/activitypub/actor_serializer.rb7
-rw-r--r--app/serializers/activitypub/note_serializer.rb6
-rw-r--r--app/serializers/rest/account_serializer.rb7
-rw-r--r--app/serializers/rest/announcement_serializer.rb4
-rw-r--r--app/serializers/rest/status_edit_serializer.rb4
-rw-r--r--app/serializers/rest/status_serializer.rb4
-rw-r--r--app/services/activitypub/process_status_update_service.rb22
-rw-r--r--app/services/fetch_link_card_service.rb2
-rw-r--r--app/services/update_status_service.rb28
-rw-r--r--app/views/accounts/_bio.html.haml6
-rw-r--r--app/views/admin/accounts/_account.html.haml2
-rw-r--r--app/views/admin/accounts/show.html.haml6
-rw-r--r--app/views/admin/instances/show.html.haml3
-rw-r--r--app/views/admin/reports/_status.html.haml6
-rw-r--r--app/views/admin/reports/show.html.haml2
-rw-r--r--app/views/admin/tags/show.html.haml2
-rw-r--r--app/views/directories/index.html.haml2
-rw-r--r--app/views/disputes/strikes/show.html.haml2
-rw-r--r--app/views/notification_mailer/_status.html.haml4
-rw-r--r--app/views/notification_mailer/_status.text.erb2
-rw-r--r--app/views/notification_mailer/digest.text.erb2
-rw-r--r--app/views/statuses/_detailed_status.html.haml5
-rw-r--r--app/views/statuses/_poll.html.haml4
-rw-r--r--app/views/statuses/_simple_status.html.haml5
-rw-r--r--app/views/user_mailer/warning.html.haml2
-rw-r--r--config/environments/production.rb6
-rw-r--r--config/initializers/twitter_regex.rb26
-rw-r--r--config/locales/activerecord.nl.yml2
-rw-r--r--config/locales/ca.yml1
-rw-r--r--config/locales/da.yml1
-rw-r--r--config/locales/de.yml3
-rw-r--r--config/locales/doorkeeper.id.yml33
-rw-r--r--config/locales/doorkeeper.ku.yml4
-rw-r--r--config/locales/doorkeeper.nl.yml46
-rw-r--r--config/locales/en.yml9
-rw-r--r--config/locales/es-AR.yml1
-rw-r--r--config/locales/es.yml1
-rw-r--r--config/locales/gl.yml1
-rw-r--r--config/locales/hu.yml1
-rw-r--r--config/locales/id.yml1
-rw-r--r--config/locales/is.yml1
-rw-r--r--config/locales/it.yml1
-rw-r--r--config/locales/ko.yml1
-rw-r--r--config/locales/ku.yml11
-rw-r--r--config/locales/lv.yml1
-rw-r--r--config/locales/nl.yml96
-rw-r--r--config/locales/pt-PT.yml1
-rw-r--r--config/locales/ru.yml4
-rw-r--r--config/locales/simple_form.fa.yml9
-rw-r--r--config/locales/simple_form.ku.yml2
-rw-r--r--config/locales/simple_form.nl.yml48
-rw-r--r--config/locales/simple_form.th.yml2
-rw-r--r--config/locales/sv.yml2
-rw-r--r--config/locales/th.yml13
-rw-r--r--config/locales/tr.yml1
-rw-r--r--config/locales/zh-CN.yml1
-rw-r--r--config/locales/zh-TW.yml1
-rw-r--r--lib/mastodon/version.rb2
-rw-r--r--lib/sanitize_ext/sanitize_config.rb57
-rw-r--r--spec/controllers/admin/accounts_controller_spec.rb10
-rw-r--r--spec/controllers/settings/exports/bookmarks_controller_spec.rb (renamed from spec/controllers/settings/exports/bookmarks_controller_specs.rb)11
-rw-r--r--spec/lib/advanced_text_formatter_spec.rb282
-rw-r--r--spec/lib/emoji_formatter_spec.rb55
-rw-r--r--spec/lib/formatter_spec.rb638
-rw-r--r--spec/lib/html_aware_formatter_spec.rb44
-rw-r--r--spec/lib/link_details_extractor_spec.rb8
-rw-r--r--spec/lib/plain_text_formatter_spec.rb24
-rw-r--r--spec/lib/rss/serializer_spec.rb7
-rw-r--r--spec/lib/sanitize_config_spec.rb18
-rw-r--r--spec/lib/text_formatter_spec.rb313
-rw-r--r--spec/models/media_attachment_spec.rb8
-rw-r--r--spec/services/activitypub/process_status_update_service_spec.rb31
-rw-r--r--spec/services/after_block_service_spec.rb8
-rw-r--r--spec/services/delete_account_service_spec.rb14
-rw-r--r--spec/services/mute_service_spec.rb22
-rw-r--r--spec/services/notify_service_spec.rb46
-rw-r--r--spec/services/suspend_account_service_spec.rb12
-rw-r--r--spec/services/unsuspend_account_service_spec.rb26
-rw-r--r--spec/services/update_status_service_spec.rb17
119 files changed, 2137 insertions, 1582 deletions
diff --git a/.circleci/config.yml b/.circleci/config.yml
index 4fcc8c618..b9228f996 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -1,7 +1,7 @@
 version: 2.1
 
 orbs:
-  ruby: circleci/ruby@1.4.0
+  ruby: circleci/ruby@1.4.1
   node: circleci/node@5.0.1
 
 executors:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 52a62a213..f0305d148 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -81,6 +81,7 @@ All notable changes to this project will be documented in this file.
 - Add lazy loading for emoji picker in web UI ([mashirozx](https://github.com/mastodon/mastodon/pull/16907), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/17011))
 - Add single option votes tooltip in polls in web UI ([Brawaru](https://github.com/mastodon/mastodon/pull/16849))
 - Add confirmation modal when closing media edit modal with unsaved changes in web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/16518))
+- Add hint about missing media attachment description in web UI ([Gargron](https://github.com/mastodon/mastodon/pull/17845))
 - Add support for fetching Create and Announce activities by URI in ActivityPub ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/16383))
 - Add `S3_FORCE_SINGLE_REQUEST` environment variable ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/16866))
 - Add `OMNIAUTH_ONLY` environment variable ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/17288), [ClearlyClaire](https://github.com/mastodon/mastodon/pull/17345))
@@ -130,6 +131,11 @@ All notable changes to this project will be documented in this file.
 
 ### Fixed
 
+- Fix IDN domains not being rendered correctly in a few left-over places ([Gargron](https://github.com/mastodon/mastodon/pull/17848))
+- Fix Sanskrit translation not being used in web UI ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/17820))
+- Fix Kurdish languages having the wrong language codes ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/17812))
+- Fix pghero making database schema suggestions ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/17807))
+- Fix encoding glitch in the OpenGraph description of a profile page ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/17821))
 - Fix web manifest not permitting PWA usage from alternate domains ([HolgerHuo](https://github.com/mastodon/mastodon/pull/16714))
 - Fix not being able to edit media attachments for scheduled posts ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/17690))
 - Fix subscribed relay activities being recorded as boosts ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/17571))
diff --git a/app/chewy/statuses_index.rb b/app/chewy/statuses_index.rb
index 65cbb6fcd..bfd61a048 100644
--- a/app/chewy/statuses_index.rb
+++ b/app/chewy/statuses_index.rb
@@ -1,6 +1,8 @@
 # frozen_string_literal: true
 
 class StatusesIndex < Chewy::Index
+  include FormattingHelper
+
   settings index: { refresh_interval: '15m' }, analysis: {
     filter: {
       english_stop: {
@@ -57,7 +59,7 @@ class StatusesIndex < Chewy::Index
     field :id, type: 'long'
     field :account_id, type: 'long'
 
-    field :text, type: 'text', value: ->(status) { [status.spoiler_text, Formatter.instance.plaintext(status)].concat(status.ordered_media_attachments.map(&:description)).concat(status.preloadable_poll ? status.preloadable_poll.options : []).join("\n\n") } do
+    field :text, type: 'text', value: ->(status) { [status.spoiler_text, extract_status_plain_text(status)].concat(status.ordered_media_attachments.map(&:description)).concat(status.preloadable_poll ? status.preloadable_poll.options : []).join("\n\n") } do
       field :stemmed, type: 'text', analyzer: 'content'
     end
 
diff --git a/app/controllers/api/v1/trends/links_controller.rb b/app/controllers/api/v1/trends/links_controller.rb
index ad20e7f8b..b1cde5a4b 100644
--- a/app/controllers/api/v1/trends/links_controller.rb
+++ b/app/controllers/api/v1/trends/links_controller.rb
@@ -3,6 +3,10 @@
 class Api::V1::Trends::LinksController < Api::BaseController
   before_action :set_links
 
+  after_action :insert_pagination_headers
+
+  DEFAULT_LINKS_LIMIT = 10
+
   def index
     render json: @links, each_serializer: REST::Trends::LinkSerializer
   end
@@ -20,6 +24,26 @@ class Api::V1::Trends::LinksController < Api::BaseController
   end
 
   def links_from_trends
-    Trends.links.query.allowed.in_locale(content_locale).limit(limit_param(10))
+    Trends.links.query.allowed.in_locale(content_locale).offset(offset_param).limit(limit_param(DEFAULT_LINKS_LIMIT))
+  end
+
+  def insert_pagination_headers
+    set_pagination_headers(next_path, prev_path)
+  end
+
+  def pagination_params(core_params)
+    params.slice(:limit).permit(:limit).merge(core_params)
+  end
+
+  def next_path
+    api_v1_trends_links_url pagination_params(offset: offset_param + limit_param(DEFAULT_LINKS_LIMIT))
+  end
+
+  def prev_path
+    api_v1_trends_links_url pagination_params(offset: offset_param - limit_param(DEFAULT_LINKS_LIMIT)) if offset_param > limit_param(DEFAULT_LINKS_LIMIT)
+  end
+
+  def offset_param
+    params[:offset].to_i
   end
 end
diff --git a/app/controllers/api/v1/trends/statuses_controller.rb b/app/controllers/api/v1/trends/statuses_controller.rb
index d4ec97ae5..4977803fb 100644
--- a/app/controllers/api/v1/trends/statuses_controller.rb
+++ b/app/controllers/api/v1/trends/statuses_controller.rb
@@ -3,6 +3,8 @@
 class Api::V1::Trends::StatusesController < Api::BaseController
   before_action :set_statuses
 
+  after_action :insert_pagination_headers
+
   def index
     render json: @statuses, each_serializer: REST::StatusSerializer
   end
@@ -22,6 +24,26 @@ class Api::V1::Trends::StatusesController < Api::BaseController
   def statuses_from_trends
     scope = Trends.statuses.query.allowed.in_locale(content_locale)
     scope = scope.filtered_for(current_account) if user_signed_in?
-    scope.limit(limit_param(DEFAULT_STATUSES_LIMIT))
+    scope.offset(offset_param).limit(limit_param(DEFAULT_STATUSES_LIMIT))
+  end
+
+  def insert_pagination_headers
+    set_pagination_headers(next_path, prev_path)
+  end
+
+  def pagination_params(core_params)
+    params.slice(:limit).permit(:limit).merge(core_params)
+  end
+
+  def next_path
+    api_v1_trends_statuses_url pagination_params(offset: offset_param + limit_param(DEFAULT_STATUSES_LIMIT))
+  end
+
+  def prev_path
+    api_v1_trends_statuses_url pagination_params(offset: offset_param - limit_param(DEFAULT_STATUSES_LIMIT)) if offset_param > limit_param(DEFAULT_STATUSES_LIMIT)
+  end
+
+  def offset_param
+    params[:offset].to_i
   end
 end
diff --git a/app/controllers/api/v1/trends/tags_controller.rb b/app/controllers/api/v1/trends/tags_controller.rb
index 1334b72d2..d77857871 100644
--- a/app/controllers/api/v1/trends/tags_controller.rb
+++ b/app/controllers/api/v1/trends/tags_controller.rb
@@ -3,6 +3,10 @@
 class Api::V1::Trends::TagsController < Api::BaseController
   before_action :set_tags
 
+  after_action :insert_pagination_headers
+
+  DEFAULT_TAGS_LIMIT = 10
+
   def index
     render json: @tags, each_serializer: REST::TagSerializer
   end
@@ -12,10 +16,30 @@ class Api::V1::Trends::TagsController < Api::BaseController
   def set_tags
     @tags = begin
       if Setting.trends
-        Trends.tags.query.allowed.limit(limit_param(10))
+        Trends.tags.query.allowed.limit(limit_param(DEFAULT_TAGS_LIMIT))
       else
         []
       end
     end
   end
+
+  def insert_pagination_headers
+    set_pagination_headers(next_path, prev_path)
+  end
+
+  def pagination_params(core_params)
+    params.slice(:limit).permit(:limit).merge(core_params)
+  end
+
+  def next_path
+    api_v1_trends_tags_url pagination_params(offset: offset_param + limit_param(DEFAULT_TAGS_LIMIT))
+  end
+
+  def prev_path
+    api_v1_trends_tags_url pagination_params(offset: offset_param - limit_param(DEFAULT_TAGS_LIMIT)) if offset_param > limit_param(DEFAULT_TAGS_LIMIT)
+  end
+
+  def offset_param
+    params[:offset].to_i
+  end
 end
diff --git a/app/controllers/api/web/embeds_controller.rb b/app/controllers/api/web/embeds_controller.rb
index 741ba910f..58f6345e6 100644
--- a/app/controllers/api/web/embeds_controller.rb
+++ b/app/controllers/api/web/embeds_controller.rb
@@ -15,7 +15,7 @@ class Api::Web::EmbedsController < Api::Web::BaseController
     return not_found if oembed.nil?
 
     begin
-      oembed[:html] = Formatter.instance.sanitize(oembed[:html], Sanitize::Config::MASTODON_OEMBED)
+      oembed[:html] = Sanitize.fragment(oembed[:html], Sanitize::Config::MASTODON_OEMBED)
     rescue ArgumentError
       return not_found
     end
diff --git a/app/helpers/accounts_helper.rb b/app/helpers/accounts_helper.rb
index bb2374c0e..f0becf8bd 100644
--- a/app/helpers/accounts_helper.rb
+++ b/app/helpers/accounts_helper.rb
@@ -2,10 +2,12 @@
 
 module AccountsHelper
   def display_name(account, **options)
+    str = account.display_name.presence || account.username
+
     if options[:custom_emojify]
-      Formatter.instance.format_display_name(account, **options)
+      prerender_custom_emojis(h(str), account.emojis)
     else
-      account.display_name.presence || account.username
+      str
     end
   end
 
diff --git a/app/helpers/admin/trends/statuses_helper.rb b/app/helpers/admin/trends/statuses_helper.rb
index d16e3dd12..214c1e2a6 100644
--- a/app/helpers/admin/trends/statuses_helper.rb
+++ b/app/helpers/admin/trends/statuses_helper.rb
@@ -12,9 +12,6 @@ module Admin::Trends::StatusesHelper
 
     return '' if text.blank?
 
-    html = Formatter.instance.send(:encode, text)
-    html = Formatter.instance.send(:encode_custom_emojis, html, status.emojis, prefers_autoplay?)
-
-    html.html_safe # rubocop:disable Rails/OutputSafety
+    prerender_custom_emojis(h(text), status.emojis)
   end
 end
diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb
index eace78af6..d482ad1a2 100644
--- a/app/helpers/application_helper.rb
+++ b/app/helpers/application_helper.rb
@@ -240,4 +240,8 @@ module ApplicationHelper
       end
     end.values
   end
+
+  def prerender_custom_emojis(html, custom_emojis)
+    EmojiFormatter.new(html, custom_emojis, animate: prefers_autoplay?).to_s
+  end
 end
diff --git a/app/helpers/formatting_helper.rb b/app/helpers/formatting_helper.rb
new file mode 100644
index 000000000..2a622ae0b
--- /dev/null
+++ b/app/helpers/formatting_helper.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+module FormattingHelper
+  def html_aware_format(text, local, options = {})
+    HtmlAwareFormatter.new(text, local, options).to_s
+  end
+
+  def linkify(text, options = {})
+    TextFormatter.new(text, options).to_s
+  end
+
+  def extract_status_plain_text(status)
+    PlainTextFormatter.new(status.text, status.local?).to_s
+  end
+
+  def status_content_format(status)
+    html_aware_format(status.text, status.local?, preloaded_accounts: [status.account] + (status.respond_to?(:active_mentions) ? status.active_mentions.map(&:account) : []), content_type: status.content_type)
+  end
+
+  def account_bio_format(account)
+    html_aware_format(account.note, account.local?)
+  end
+
+  def account_field_value_format(field, with_rel_me: true)
+    html_aware_format(field.value, field.account.local?, with_rel_me: with_rel_me, with_domains: true, multiline: false)
+  end
+end
diff --git a/app/helpers/routing_helper.rb b/app/helpers/routing_helper.rb
index fb24a1b28..f95f46a56 100644
--- a/app/helpers/routing_helper.rb
+++ b/app/helpers/routing_helper.rb
@@ -2,6 +2,7 @@
 
 module RoutingHelper
   extend ActiveSupport::Concern
+
   include Rails.application.routes.url_helpers
   include ActionView::Helpers::AssetTagHelper
   include Webpacker::Helper
@@ -22,8 +23,6 @@ module RoutingHelper
     full_asset_url(asset_pack_path(source, **options))
   end
 
-  private
-
   def use_storage?
     Rails.configuration.x.use_s3 || Rails.configuration.x.use_swift
   end
diff --git a/app/helpers/statuses_helper.rb b/app/helpers/statuses_helper.rb
index d328f89b7..e92b4c839 100644
--- a/app/helpers/statuses_helper.rb
+++ b/app/helpers/statuses_helper.rb
@@ -113,20 +113,6 @@ module StatusesHelper
     end
   end
 
-  private
-
-  def simplified_text(text)
-    text.dup.tap do |new_text|
-      URI.extract(new_text).each do |url|
-        new_text.gsub!(url, '')
-      end
-
-      new_text.gsub!(Account::MENTION_RE, '')
-      new_text.gsub!(Tag::HASHTAG_RE, '')
-      new_text.gsub!(/\s+/, '')
-    end
-  end
-
   def embedded_view?
     params[:controller] == EMBEDDED_CONTROLLER && params[:action] == EMBEDDED_ACTION
   end
diff --git a/app/javascript/flavours/glitch/components/admin/Counter.js b/app/javascript/flavours/glitch/components/admin/Counter.js
index ecb242950..a4d6cef41 100644
--- a/app/javascript/flavours/glitch/components/admin/Counter.js
+++ b/app/javascript/flavours/glitch/components/admin/Counter.js
@@ -33,6 +33,7 @@ export default class Counter extends React.PureComponent {
     label: PropTypes.string.isRequired,
     href: PropTypes.string,
     params: PropTypes.object,
+    target: PropTypes.string,
   };
 
   state = {
@@ -54,7 +55,7 @@ export default class Counter extends React.PureComponent {
   }
 
   render () {
-    const { label, href } = this.props;
+    const { label, href, target } = this.props;
     const { loading, data } = this.state;
 
     let content;
@@ -100,7 +101,7 @@ export default class Counter extends React.PureComponent {
 
     if (href) {
       return (
-        <a href={href} className='sparkline'>
+        <a href={href} className='sparkline' target={target}>
           {inner}
         </a>
       );
diff --git a/app/javascript/mastodon/components/admin/Counter.js b/app/javascript/mastodon/components/admin/Counter.js
index 6edb7bcfc..5a5b2b869 100644
--- a/app/javascript/mastodon/components/admin/Counter.js
+++ b/app/javascript/mastodon/components/admin/Counter.js
@@ -33,6 +33,7 @@ export default class Counter extends React.PureComponent {
     label: PropTypes.string.isRequired,
     href: PropTypes.string,
     params: PropTypes.object,
+    target: PropTypes.string,
   };
 
   state = {
@@ -54,7 +55,7 @@ export default class Counter extends React.PureComponent {
   }
 
   render () {
-    const { label, href } = this.props;
+    const { label, href, target } = this.props;
     const { loading, data } = this.state;
 
     let content;
@@ -100,7 +101,7 @@ export default class Counter extends React.PureComponent {
 
     if (href) {
       return (
-        <a href={href} className='sparkline'>
+        <a href={href} className='sparkline' target={target}>
           {inner}
         </a>
       );
diff --git a/app/javascript/mastodon/locales/fa.json b/app/javascript/mastodon/locales/fa.json
index 820b90640..d5276039d 100644
--- a/app/javascript/mastodon/locales/fa.json
+++ b/app/javascript/mastodon/locales/fa.json
@@ -3,16 +3,16 @@
   "account.add_or_remove_from_list": "افزودن یا برداشتن از سیاهه‌ها",
   "account.badges.bot": "روبات",
   "account.badges.group": "گروه",
-  "account.block": "مسدود کردن @{name}",
+  "account.block": "مسدود کردن ‎@{name}",
   "account.block_domain": "مسدود کردن دامنهٔ {domain}",
   "account.blocked": "مسدود",
   "account.browse_more_on_origin_server": "مرور بیش‌تر روی نمایهٔ اصلی",
   "account.cancel_follow_request": "لغو درخواست پی‌گیری",
-  "account.direct": "پیام مستقیم به @{name}",
-  "account.disable_notifications": "آگاهی به من هنگام فرستادن‌های @{name} پایان یابد",
+  "account.direct": "پیام مستقیم به ‎@{name}",
+  "account.disable_notifications": "آگاهی به من هنگام فرستادن‌های ‎@{name} پایان یابد",
   "account.domain_blocked": "دامنه مسدود شد",
   "account.edit_profile": "ویرایش نمایه",
-  "account.enable_notifications": "هنگام فرسته‌های @{name} مرا آگاه کن",
+  "account.enable_notifications": "هنگام فرسته‌های ‎@{name} مرا آگاه کن",
   "account.endorse": "معرّفی در نمایه",
   "account.follow": "پی‌گیری",
   "account.followers": "پی‌گیرندگان",
@@ -22,34 +22,34 @@
   "account.following_counter": "{count, plural, one {{counter} پی‌گرفته} other {{counter} پی‌گرفته}}",
   "account.follows.empty": "این کاربر هنوز پی‌گیر کسی نیست.",
   "account.follows_you": "پی می‌گیردتان",
-  "account.hide_reblogs": "نهفتن تقویت‌های @{name}",
+  "account.hide_reblogs": "نهفتن تقویت‌های ‎@{name}",
   "account.joined": "پیوسته از {date}",
   "account.link_verified_on": "مالکیت این پیوند در {date} بررسی شد",
   "account.locked_info": "این حساب خصوصی است. صاحبش تصمیم می‌گیرد که چه کسی پی‌گیرش باشد.",
   "account.media": "رسانه",
-  "account.mention": "نام‌بردن از @{name}",
+  "account.mention": "نام‌بردن از ‎@{name}",
   "account.moved_to": "{name} منتقل شده به:",
-  "account.mute": "خموشاندن @{name}",
-  "account.mute_notifications": "خموشاندن آگاهی‌ها از @{name}",
+  "account.mute": "خموشاندن ‎@{name}",
+  "account.mute_notifications": "خموشاندن آگاهی‌های ‎@{name}",
   "account.muted": "خموش",
   "account.posts": "فرسته",
   "account.posts_with_replies": "فرسته‌ها و پاسخ‌ها",
-  "account.report": "گزارش @{name}",
+  "account.report": "گزارش ‎@{name}",
   "account.requested": "منتظر پذیرش است. برای لغو درخواست پی‌گیری کلیک کنید",
-  "account.share": "هم‌رسانی نمایهٔ @{name}",
-  "account.show_reblogs": "نمایش تقویت‌های @{name}",
+  "account.share": "هم‌رسانی نمایهٔ ‎@{name}",
+  "account.show_reblogs": "نمایش تقویت‌های ‎@{name}",
   "account.statuses_counter": "{count, plural, one {{counter} فرسته} other {{counter} فرسته}}",
-  "account.unblock": "رفع مسدودیت @{name}",
+  "account.unblock": "رفع مسدودیت ‎@{name}",
   "account.unblock_domain": "رفع مسدودیت دامنهٔ {domain}",
   "account.unblock_short": "رفع مسدودیت",
   "account.unendorse": "معرّفی نکردن در نمایه",
   "account.unfollow": "ناپی‌گیری",
-  "account.unmute": "ناخموشی @{name}",
-  "account.unmute_notifications": "ناخموشی آگاهی‌ها از @{name}",
+  "account.unmute": "ناخموشی ‎@{name}",
+  "account.unmute_notifications": "ناخموشی آگاهی‌های ‎@{name}",
   "account.unmute_short": "ناخموشی",
   "account_note.placeholder": "برای افزودن یادداشت کلیک کنید",
-  "admin.dashboard.daily_retention": "User retention rate by day after sign-up",
-  "admin.dashboard.monthly_retention": "User retention rate by month after sign-up",
+  "admin.dashboard.daily_retention": "نرخ حفظ کاربر در روز پس از ثبت نام",
+  "admin.dashboard.monthly_retention": "نرخ حفظ کاربر در ماه پس از ثبت نام",
   "admin.dashboard.retention.average": "میانگین",
   "admin.dashboard.retention.cohort": "ماه ثبت‌نام",
   "admin.dashboard.retention.cohort_size": "کاربران جدید",
@@ -79,13 +79,13 @@
   "column.lists": "سیاهه‌ها",
   "column.mutes": "کاربران خموش",
   "column.notifications": "آگاهی‌ها",
-  "column.pins": "فرسته‌های سنجاق‌شده",
+  "column.pins": "فرسته‌های سنجاق شده",
   "column.public": "خط زمانی همگانی",
   "column_back_button.label": "بازگشت",
   "column_header.hide_settings": "نهفتن تنظیمات",
   "column_header.moveLeft_settings": "جابه‌جایی ستون به چپ",
   "column_header.moveRight_settings": "جابه‌جایی ستون به راست",
-  "column_header.pin": "سنجاق‌کردن",
+  "column_header.pin": "سنجاق کردن",
   "column_header.show_settings": "نمایش تنظیمات",
   "column_header.unpin": "برداشتن سنجاق",
   "column_subheading.settings": "تنظیمات",
@@ -94,7 +94,7 @@
   "community.column_settings.remote_only": "تنها دوردست",
   "compose_form.direct_message_warning": "این فرسته تنها به کاربرانی که از آن‌ها نام برده شده فرستاده خواهد شد.",
   "compose_form.direct_message_warning_learn_more": "بیشتر بدانید",
-  "compose_form.hashtag_warning": "از آن‌جا که این فرسته فهرست‌نشده است، در نتایج جست‌وجوی برچسب‌ها پیدا نخواهد شد. تنها فرسته‌های عمومی را می‌توان با جست‌وجوی برچسب یافت.",
+  "compose_form.hashtag_warning": "از آن‌جا که این فرسته فهرست نشده است، در نتایج جست‌وجوی هشتگ‌ها پیدا نخواهد شد. تنها فرسته‌های عمومی را می‌توان با جست‌وجوی هشتگ یافت.",
   "compose_form.lock_disclaimer": "حسابتان {locked} نیست. هر کسی می‌تواند پی‌گیرتان شده و فرسته‌های ویژهٔ پی‌گیرانتان را ببیند.",
   "compose_form.lock_disclaimer.lock": "قفل‌شده",
   "compose_form.placeholder": "تازه چه خبر؟",
@@ -144,7 +144,7 @@
   "directory.local": "تنها از {domain}",
   "directory.new_arrivals": "تازه‌واردان",
   "directory.recently_active": "کاربران فعال اخیر",
-  "embed.instructions": "برای جاگذاری این فرسته در سایت خودتان، کد زیر را کپی کنید.",
+  "embed.instructions": "برای جاسازی این فرسته در سایت خودتان، کد زیر را رونوشت کنید.",
   "embed.preview": "این گونه دیده خواهد شد:",
   "emoji_button.activity": "فعالیت",
   "emoji_button.custom": "سفارشی",
@@ -164,11 +164,11 @@
   "empty_column.account_timeline": "هیچ فرسته‌ای این‌جا نیست!",
   "empty_column.account_unavailable": "نمایهٔ موجود نیست",
   "empty_column.blocks": "هنوز کسی را مسدود نکرده‌اید.",
-  "empty_column.bookmarked_statuses": "هنوز هیچ فرستهٔ نشان‌شده‌ای ندارید. هنگامی که فرسته‌ای را نشان‌کنید، این‌جا نشان داده خواهد شد.",
+  "empty_column.bookmarked_statuses": "هنوز هیچ فرستهٔ نشانه‌گذاری شده‌ای ندارید. هنگامی که فرسته‌ای را نشانه‌گذاری کنید، این‌جا نشان داده خواهد شد.",
   "empty_column.community": "خط زمانی محلّی خالی است. چیزی بنویسید تا چرخش بچرخد!",
   "empty_column.direct": "هنوز هیچ پیام مستقیمی ندارید. هنگامی که چنین پیامی بگیرید یا بفرستید این‌جا نشان داده خواهد شد.",
   "empty_column.domain_blocks": "هنوز هیچ دامنه‌ای مسدود نشده است.",
-  "empty_column.explore_statuses": "Nothing is trending right now. Check back later!",
+  "empty_column.explore_statuses": "الآن چیزی پرطرفدار نیست. بعداً دوباره بررسی کنید!",
   "empty_column.favourited_statuses": "شما هنوز هیچ فرسته‌ای را نپسندیده‌اید. هنگامی که فرسته‌ای را بپسندید، این‌جا نشان داده خواهد شد.",
   "empty_column.favourites": "هنوز هیچ کسی این فرسته را نپسندیده است. هنگامی که کسی آن را بپسندد، این‌جا نشان داده خواهد شد.",
   "empty_column.follow_recommendations": "ظاهرا هیچ پیشنهادی برای شما نمی‌توانیم تولید کنیم. می‌توانید از امکان جست‌وجو برای یافتن افرادی که ممکن است بشناسید و یا کاوش میان برچسب‌های داغ استفاده کنید.",
@@ -247,7 +247,7 @@
   "keyboard_shortcuts.my_profile": "گشودن نمایه‌تان",
   "keyboard_shortcuts.notifications": "گشودن ستون آگاهی‌ها",
   "keyboard_shortcuts.open_media": "گشودن رسانه",
-  "keyboard_shortcuts.pinned": "گشودن سیاههٔ فرسته‌های سنجاق شده",
+  "keyboard_shortcuts.pinned": "گشودن فهرست فرسته‌های سنجاق شده",
   "keyboard_shortcuts.profile": "گشودن نمایهٔ نویسنده",
   "keyboard_shortcuts.reply": "پاسخ به فرسته",
   "keyboard_shortcuts.requests": "گشودن سیاههٔ درخواست‌های پی‌گیری",
@@ -305,7 +305,7 @@
   "navigation_bar.logout": "خروج",
   "navigation_bar.mutes": "کاربران خموشانده",
   "navigation_bar.personal": "شخصی",
-  "navigation_bar.pins": "فرسته‌های سنجاق‌شده",
+  "navigation_bar.pins": "فرسته‌های سنجاق شده",
   "navigation_bar.preferences": "ترجیحات",
   "navigation_bar.public_timeline": "خط زمانی همگانی",
   "navigation_bar.security": "امنیت",
@@ -392,40 +392,40 @@
   "report.block": "مسدود کردن",
   "report.block_explanation": "شما فرسته‌هایشان را نخواهید دید. آن‌ها نمی‌توانند فرسته‌هایتان را ببینند یا شما را پی‌بگیرند. آنها می‌توانند بگویند که مسدود شده‌اند.",
   "report.categories.other": "غیره",
-  "report.categories.spam": "Spam",
-  "report.categories.violation": "Content violates one or more server rules",
-  "report.category.subtitle": "Choose the best match",
-  "report.category.title": "Tell us what's going on with this {type}",
-  "report.category.title_account": "profile",
-  "report.category.title_status": "post",
-  "report.close": "Done",
-  "report.comment.title": "Is there anything else you think we should know?",
+  "report.categories.spam": "هرزنامه",
+  "report.categories.violation": "محتوا یک یا چند قانون کارساز را نقض می‌کند",
+  "report.category.subtitle": "منطبق‌ترین را انتخاب کنید",
+  "report.category.title": "به ما بگویید با این {type} چه مشکلی دارید",
+  "report.category.title_account": "نمایه",
+  "report.category.title_status": "فرسته",
+  "report.close": "انجام شد",
+  "report.comment.title": "آیا چیز دیگری هست که فکر می‌کنید باید بدانیم؟",
   "report.forward": "فرستادن به {target}",
   "report.forward_hint": "این حساب در کارساز دیگری ثبت شده. آیا می‌خواهید رونوشتی ناشناس از این گزارش به آن‌جا هم فرستاده شود؟",
-  "report.mute": "Mute",
-  "report.mute_explanation": "You will not see their posts. They can still follow you and see your posts and will not know that they are muted.",
-  "report.next": "Next",
+  "report.mute": "خموش",
+  "report.mute_explanation": "شما فرسته‌های آن‌ها را نخواهید دید. آن‌ها همچنان می‌توانند شما را پی‌بگیرند و فرسته‌هایتان را ببینند و نمی‌دانند که خموش شده‌اند.",
+  "report.next": "بعدی",
   "report.placeholder": "توضیحات اضافه",
-  "report.reasons.dislike": "I don't like it",
-  "report.reasons.dislike_description": "It is not something you want to see",
-  "report.reasons.other": "It's something else",
-  "report.reasons.other_description": "The issue does not fit into other categories",
-  "report.reasons.spam": "It's spam",
-  "report.reasons.spam_description": "Malicious links, fake engagement, or repetitive replies",
-  "report.reasons.violation": "It violates server rules",
-  "report.reasons.violation_description": "You are aware that it breaks specific rules",
-  "report.rules.subtitle": "Select all that apply",
-  "report.rules.title": "Which rules are being violated?",
-  "report.statuses.subtitle": "Select all that apply",
-  "report.statuses.title": "Are there any posts that back up this report?",
+  "report.reasons.dislike": "من آن را دوست ندارم",
+  "report.reasons.dislike_description": "این چیزی نیست که بخواهید ببینید",
+  "report.reasons.other": "بخواطر چیز دیگری است",
+  "report.reasons.other_description": "این موضوع در دسته‌بندی‌های دیگر نمی‌گنجد",
+  "report.reasons.spam": "این هرزنامه است",
+  "report.reasons.spam_description": "پیوندهای مخرب، تعامل جعلی یا پاسخ‌های تکراری",
+  "report.reasons.violation": "قوانین کارساز را نقض می‌کند",
+  "report.reasons.violation_description": "شما آگاه هستید که قوانین خاصی را زیر پا می‌گذارد",
+  "report.rules.subtitle": "همهٔ موارد انجام شده را برگزینید",
+  "report.rules.title": "کدام قوانین نقض شده‌اند؟",
+  "report.statuses.subtitle": "همهٔ موارد انجام شده را برگزینید",
+  "report.statuses.title": "آیا فرسته‌ای وجود دارد که از این گزارش پشتیبانی کند؟",
   "report.submit": "فرستادن",
   "report.target": "در حال گزارش {target}",
-  "report.thanks.take_action": "Here are your options for controlling what you see on Mastodon:",
-  "report.thanks.take_action_actionable": "While we review this, you can take action against @{name}:",
-  "report.thanks.title": "Don't want to see this?",
-  "report.thanks.title_actionable": "Thanks for reporting, we'll look into this.",
-  "report.unfollow": "Unfollow @{name}",
-  "report.unfollow_explanation": "You are following this account. To not see their posts in your home feed anymore, unfollow them.",
+  "report.thanks.take_action": "در اینجا گزینه‌هایی برای کنترل آنچه در ماستودون میبینید، وجود دارد:",
+  "report.thanks.take_action_actionable": "در حالی که ما این مورد را بررسی می‌کنیم، می‌توانید علیه ‎@{name} اقدام کنید:",
+  "report.thanks.title": "نمی‌خواهید این را ببینید؟",
+  "report.thanks.title_actionable": "ممنون بابت گزارش، ما آن را بررسی خواهیم کرد.",
+  "report.unfollow": "ناپی‌گیری ‎@{name}",
+  "report.unfollow_explanation": "شما این حساب را پی‌گرفته‌اید، برای اینکه دیگر فرسته‌هایش را در خوراک خانه‌تان نبینید؛ آن را پی‌نگیرید.",
   "search.placeholder": "جست‌وجو",
   "search_popout.search_format": "راهنمای جست‌وجوی پیشرفته",
   "search_popout.tips.full_text": "جست‌وجوی متنی ساده فرسته‌هایی که نوشته، پسندیده، تقویت‌کرده یا در آن‌ها نام‌برده شده‌اید را به علاوهٔ نام‌های کاربری، نام‌های نمایشی و برچسب‌ها برمی‌گرداند.",
@@ -434,39 +434,39 @@
   "search_popout.tips.text": "جست‌وجوی متنی ساده برای نام‌ها، نام‌های کاربری، و برچسب‌ها",
   "search_popout.tips.user": "کاربر",
   "search_results.accounts": "افراد",
-  "search_results.all": "All",
+  "search_results.all": "همه",
   "search_results.hashtags": "برچسب‌ها",
-  "search_results.nothing_found": "Could not find anything for these search terms",
+  "search_results.nothing_found": "چیزی برای این عبارت جست‌وجو یافت نشد",
   "search_results.statuses": "فرسته‌ها",
-  "search_results.statuses_fts_disabled": "جست‌وجوی محتوای فرسته‌ها در این کارساز ماستودون فعال نشده است.",
+  "search_results.statuses_fts_disabled": "جست‌وجوی محتوای فرسته‌ها در این کارساز ماستودون به کار انداخته نشده است.",
   "search_results.total": "{count, number} {count, plural, one {نتیجه} other {نتیجه}}",
-  "status.admin_account": "گشودن واسط مدیریت برای @{name}",
+  "status.admin_account": "گشودن واسط مدیریت برای ‎@{name}",
   "status.admin_status": "گشودن این فرسته در واسط مدیریت",
-  "status.block": "مسدود کردن @{name}",
+  "status.block": "مسدود کردن ‎@{name}",
   "status.bookmark": "نشانک",
   "status.cancel_reblog_private": "لغو تقویت",
   "status.cannot_reblog": "این فرسته قابل تقویت نیست",
-  "status.copy": "رونویسی از نشانی فرسته",
+  "status.copy": "رونوشت پیوند فرسته",
   "status.delete": "حذف",
   "status.detailed_status": "نمایش کامل گفتگو",
-  "status.direct": "پیام مستقیم به @{name}",
-  "status.edit": "Edit",
-  "status.edited": "Edited {date}",
-  "status.edited_x_times": "Edited {count, plural, one {{count} time} other {{count} times}}",
-  "status.embed": "جاگذاری",
+  "status.direct": "پیام مستقیم به ‎@{name}",
+  "status.edit": "ویرایش",
+  "status.edited": "ویرایش شده در {date}",
+  "status.edited_x_times": "{count, plural, one {{count} مرتبه} other {{count} مرتبه}} ویرایش شد",
+  "status.embed": "جاسازی",
   "status.favourite": "پسندیدن",
   "status.filtered": "پالوده",
-  "status.history.created": "{name} created {date}",
-  "status.history.edited": "{name} edited {date}",
+  "status.history.created": "توسط {name} در {date} ایجاد شد",
+  "status.history.edited": "توسط {name} در {date} ویرایش شد",
   "status.load_more": "بار کردن بیش‌تر",
   "status.media_hidden": "رسانهٔ نهفته",
-  "status.mention": "نام‌بردن از @{name}",
+  "status.mention": "نام‌بردن از ‎@{name}",
   "status.more": "بیشتر",
-  "status.mute": "خموشاندن @{name}",
+  "status.mute": "خموشاندن ‎@{name}",
   "status.mute_conversation": "خموشاندن گفت‌وگو",
   "status.open": "گسترش این فرسته",
-  "status.pin": "سنجاق‌کردن در نمایه",
-  "status.pinned": "فرستهٔ سنجاق‌شده",
+  "status.pin": "سنجاق کردن در نمایه",
+  "status.pinned": "فرستهٔ سنجاق شده",
   "status.read_more": "بیشتر بخوانید",
   "status.reblog": "تقویت",
   "status.reblog_private": "تقویت برای مخاطبان نخستین",
@@ -476,7 +476,7 @@
   "status.remove_bookmark": "برداشتن نشانک",
   "status.reply": "پاسخ",
   "status.replyAll": "پاسخ به رشته",
-  "status.report": "گزارش @{name}",
+  "status.report": "گزارش ‎@{name}",
   "status.sensitive_warning": "محتوای حساس",
   "status.share": "هم‌رسانی",
   "status.show_less": "نمایش کمتر",
diff --git a/app/javascript/mastodon/locales/ja.json b/app/javascript/mastodon/locales/ja.json
index 4a388f92b..e08c8bb33 100644
--- a/app/javascript/mastodon/locales/ja.json
+++ b/app/javascript/mastodon/locales/ja.json
@@ -192,7 +192,7 @@
   "errors.unexpected_crash.copy_stacktrace": "スタックトレースをクリップボードにコピー",
   "errors.unexpected_crash.report_issue": "問題を報告",
   "explore.search_results": "検索結果",
-  "explore.suggested_follows": "あなたに",
+  "explore.suggested_follows": "おすすめ",
   "explore.title": "エクスプローラー",
   "explore.trending_links": "ニュース",
   "explore.trending_statuses": "投稿",
diff --git a/app/javascript/mastodon/locales/ku.json b/app/javascript/mastodon/locales/ku.json
index 0298c51ec..2bf8fc520 100644
--- a/app/javascript/mastodon/locales/ku.json
+++ b/app/javascript/mastodon/locales/ku.json
@@ -342,7 +342,7 @@
   "notifications.filter.all": "Hemû",
   "notifications.filter.boosts": "Bilindkirî",
   "notifications.filter.favourites": "Bijarte",
-  "notifications.filter.follows": "Şopîner",
+  "notifications.filter.follows": "Dişopîne",
   "notifications.filter.mentions": "Qalkirin",
   "notifications.filter.polls": "Encamên rapirsiyê",
   "notifications.filter.statuses": "Ji kesên tu dişopînî re rojanekirin",
@@ -501,7 +501,7 @@
   "time_remaining.seconds": "{number, plural, one {# çirke} other {# çirke}} maye",
   "timeline_hint.remote_resource_not_displayed": "{resource} Ji rajekerên din nayê dîtin.",
   "timeline_hint.resources.followers": "Şopîner",
-  "timeline_hint.resources.follows": "Şopîner",
+  "timeline_hint.resources.follows": "Dişopîne",
   "timeline_hint.resources.statuses": "Şandiyên kevn",
   "trends.counter_by_accounts": "{count, plural, one {{counter} kes} other {{counter} kes}} diaxivin",
   "trends.trending_now": "Rojev",
diff --git a/app/javascript/mastodon/locales/nl.json b/app/javascript/mastodon/locales/nl.json
index f30755286..d1cfdd5f4 100644
--- a/app/javascript/mastodon/locales/nl.json
+++ b/app/javascript/mastodon/locales/nl.json
@@ -9,44 +9,44 @@
   "account.browse_more_on_origin_server": "Meer op het originele profiel bekijken",
   "account.cancel_follow_request": "Volgverzoek annuleren",
   "account.direct": "@{name} een direct bericht sturen",
-  "account.disable_notifications": "Geef geen melding meer wanneer @{name} toot",
+  "account.disable_notifications": "Geef geen melding meer wanneer @{name} een bericht plaatst",
   "account.domain_blocked": "Domein geblokkeerd",
   "account.edit_profile": "Profiel bewerken",
-  "account.enable_notifications": "Geef een melding wanneer @{name} toot",
+  "account.enable_notifications": "Geef een melding wanneer @{name} een bericht plaatst",
   "account.endorse": "Op profiel weergeven",
   "account.follow": "Volgen",
   "account.followers": "Volgers",
   "account.followers.empty": "Niemand volgt nog deze gebruiker.",
   "account.followers_counter": "{count, plural, one {{counter} volger} other {{counter} volgers}}",
-  "account.following": "Following",
+  "account.following": "Volgend",
   "account.following_counter": "{count, plural, one {{counter} volgend} other {{counter} volgend}}",
   "account.follows.empty": "Deze gebruiker volgt nog niemand.",
   "account.follows_you": "Volgt jou",
   "account.hide_reblogs": "Boosts van @{name} verbergen",
   "account.joined": "Geregistreerd in {date}",
   "account.link_verified_on": "Eigendom van deze link is gecontroleerd op {date}",
-  "account.locked_info": "De privacystatus van dit account is op besloten gezet. De eigenaar bepaalt handmatig wie hen kan volgen.",
+  "account.locked_info": "De privacystatus van dit account is op besloten gezet. De eigenaar bepaalt handmatig wie diegene kan volgen.",
   "account.media": "Media",
   "account.mention": "@{name} vermelden",
   "account.moved_to": "{name} is verhuisd naar:",
   "account.mute": "@{name} negeren",
   "account.mute_notifications": "Meldingen van @{name} negeren",
   "account.muted": "Genegeerd",
-  "account.posts": "Toots",
-  "account.posts_with_replies": "Toots en reacties",
+  "account.posts": "Berichten",
+  "account.posts_with_replies": "Berichten en reacties",
   "account.report": "@{name} rapporteren",
   "account.requested": "Wacht op goedkeuring. Klik om het volgverzoek te annuleren",
   "account.share": "Profiel van @{name} delen",
   "account.show_reblogs": "Boosts van @{name} tonen",
-  "account.statuses_counter": "{count, plural, one {{counter} toot} other {{counter} toots}}",
+  "account.statuses_counter": "{count, plural, one {{counter} bericht} other {{counter} berichten}}",
   "account.unblock": "@{name} deblokkeren",
   "account.unblock_domain": "{domain} niet langer verbergen",
-  "account.unblock_short": "Unblock",
+  "account.unblock_short": "Deblokkeren",
   "account.unendorse": "Niet op profiel weergeven",
   "account.unfollow": "Ontvolgen",
   "account.unmute": "@{name} niet langer negeren",
   "account.unmute_notifications": "Meldingen van @{name} niet langer negeren",
-  "account.unmute_short": "Unmute",
+  "account.unmute_short": "Niet langer negeren",
   "account_note.placeholder": "Klik om een opmerking toe te voegen",
   "admin.dashboard.daily_retention": "User retention rate by day after sign-up",
   "admin.dashboard.monthly_retention": "User retention rate by month after sign-up",
@@ -79,7 +79,7 @@
   "column.lists": "Lijsten",
   "column.mutes": "Genegeerde gebruikers",
   "column.notifications": "Meldingen",
-  "column.pins": "Vastgezette toots",
+  "column.pins": "Vastgezette berichten",
   "column.public": "Globale tijdlijn",
   "column_back_button.label": "Terug",
   "column_header.hide_settings": "Instellingen verbergen",
@@ -92,10 +92,10 @@
   "community.column_settings.local_only": "Alleen lokaal",
   "community.column_settings.media_only": "Alleen media",
   "community.column_settings.remote_only": "Alleen andere servers",
-  "compose_form.direct_message_warning": "Deze toot wordt alleen naar vermelde gebruikers verstuurd.",
+  "compose_form.direct_message_warning": "Dit bericht wordt alleen naar vermelde gebruikers verstuurd.",
   "compose_form.direct_message_warning_learn_more": "Meer leren",
-  "compose_form.hashtag_warning": "Deze toot valt niet onder een hashtag te bekijken, omdat deze niet op openbare tijdlijnen wordt getoond. Alleen openbare toots kunnen via hashtags gevonden worden.",
-  "compose_form.lock_disclaimer": "Jouw account is niet {locked}. Iedereen kan jou volgen en kan de toots zien die je alleen aan jouw volgers hebt gericht.",
+  "compose_form.hashtag_warning": "Dit bericht valt niet onder een hashtag te bekijken, omdat deze niet op openbare tijdlijnen wordt getoond. Alleen openbare berichten kunnen via hashtags gevonden worden.",
+  "compose_form.lock_disclaimer": "Jouw account is niet {locked}. Iedereen kan jou volgen en kan de berichten zien die je alleen aan jouw volgers hebt gericht.",
   "compose_form.lock_disclaimer.lock": "besloten",
   "compose_form.placeholder": "Wat wil je kwijt?",
   "compose_form.poll.add_option": "Keuze toevoegen",
@@ -106,7 +106,7 @@
   "compose_form.poll.switch_to_single": "Poll wijzigen om een enkele keuze toe te staan",
   "compose_form.publish": "Toot",
   "compose_form.publish_loud": "{publish}!",
-  "compose_form.save_changes": "Save changes",
+  "compose_form.save_changes": "Wijzigingen opslaan",
   "compose_form.sensitive.hide": "{count, plural, one {Media als gevoelig markeren} other {Media als gevoelig markeren}}",
   "compose_form.sensitive.marked": "{count, plural, one {Media is als gevoelig gemarkeerd} other {Media is als gevoelig gemarkeerd}}",
   "compose_form.sensitive.unmarked": "{count, plural, one {Media is niet als gevoelig gemarkeerd} other {Media is niet als gevoelig gemarkeerd}}",
@@ -118,22 +118,22 @@
   "confirmations.block.confirm": "Blokkeren",
   "confirmations.block.message": "Weet je het zeker dat je {name} wilt blokkeren?",
   "confirmations.delete.confirm": "Verwijderen",
-  "confirmations.delete.message": "Weet je het zeker dat je deze toot wilt verwijderen?",
+  "confirmations.delete.message": "Weet je het zeker dat je dit bericht wilt verwijderen?",
   "confirmations.delete_list.confirm": "Verwijderen",
   "confirmations.delete_list.message": "Weet je zeker dat je deze lijst definitief wilt verwijderen?",
   "confirmations.discard_edit_media.confirm": "Weggooien",
   "confirmations.discard_edit_media.message": "Je hebt niet-opgeslagen wijzigingen in de mediabeschrijving of voorvertonning, wil je deze toch weggooien?",
   "confirmations.domain_block.confirm": "Verberg alles van deze server",
-  "confirmations.domain_block.message": "Weet je het echt heel erg zeker dat je alles van {domain} wilt negeren? In de meeste gevallen is het blokkeren of negeren van een paar specifieke personen voldoende en beter. Je zult geen toots van deze server op openbare tijdlijnen zien of in jouw meldingen. Jouw volgers van deze server worden verwijderd.",
+  "confirmations.domain_block.message": "Weet je het echt heel erg zeker dat je alles van {domain} wilt negeren? In de meeste gevallen is het blokkeren of negeren van een paar specifieke personen voldoende en beter. Je zult geen berichten van deze server op openbare tijdlijnen zien of in jouw meldingen. Jouw volgers van deze server worden verwijderd.",
   "confirmations.logout.confirm": "Uitloggen",
   "confirmations.logout.message": "Weet je zeker dat je wilt uitloggen?",
   "confirmations.mute.confirm": "Negeren",
-  "confirmations.mute.explanation": "Dit verbergt toots van hen en toots waar hen in wordt vermeld, maar hen kan nog steeds jouw toots bekijken en jou volgen.",
+  "confirmations.mute.explanation": "Dit verbergt diens berichten en berichten waar diegene in wordt vermeld, maar diegene kan nog steeds jouw berichten bekijken en jou volgen.",
   "confirmations.mute.message": "Weet je het zeker dat je {name} wilt negeren?",
   "confirmations.redraft.confirm": "Verwijderen en herschrijven",
-  "confirmations.redraft.message": "Weet je zeker dat je deze toot wilt verwijderen en herschrijven? Je verliest wel de boosts en favorieten, en de reacties op de originele toot zitten niet meer aan de nieuwe toot vast.",
+  "confirmations.redraft.message": "Weet je zeker dat je dit bericht wilt verwijderen en herschrijven? Je verliest wel de boosts en favorieten, en de reacties op het originele bericht raak je kwijt.",
   "confirmations.reply.confirm": "Reageren",
-  "confirmations.reply.message": "Door nu te reageren overschrijf je de toot die je op dit moment aan het schrijven bent. Weet je zeker dat je verder wil gaan?",
+  "confirmations.reply.message": "Door nu te reageren overschrijf je het bericht dat je op dit moment aan het schrijven bent. Weet je zeker dat je verder wil gaan?",
   "confirmations.unfollow.confirm": "Ontvolgen",
   "confirmations.unfollow.message": "Weet je het zeker dat je {name} wilt ontvolgen?",
   "conversation.delete": "Gesprek verwijderen",
@@ -144,7 +144,7 @@
   "directory.local": "Alleen {domain}",
   "directory.new_arrivals": "Nieuwe accounts",
   "directory.recently_active": "Onlangs actief",
-  "embed.instructions": "Embed deze toot op jouw website, door de onderstaande code te kopiëren.",
+  "embed.instructions": "Embed dit bericht op jouw website door de onderstaande code te kopiëren.",
   "embed.preview": "Zo komt het eruit te zien:",
   "emoji_button.activity": "Activiteiten",
   "emoji_button.custom": "Lokale emoji’s",
@@ -161,41 +161,41 @@
   "emoji_button.symbols": "Symbolen",
   "emoji_button.travel": "Reizen en locaties",
   "empty_column.account_suspended": "Account opgeschort",
-  "empty_column.account_timeline": "Hier zijn geen toots!",
+  "empty_column.account_timeline": "Hier zijn geen berichten!",
   "empty_column.account_unavailable": "Profiel is niet beschikbaar",
   "empty_column.blocks": "Jij hebt nog geen enkele gebruiker geblokkeerd.",
-  "empty_column.bookmarked_statuses": "Jij hebt nog geen toots aan je bladwijzers toegevoegd. Wanneer je er een aan jouw bladwijzers toevoegt, valt deze hier te zien.",
-  "empty_column.community": "De lokale tijdlijn is nog leeg. Toot iets in het openbaar om de spits af te bijten!",
+  "empty_column.bookmarked_statuses": "Jij hebt nog geen berichten aan je bladwijzers toegevoegd. Wanneer je er een aan jouw bladwijzers toevoegt, valt deze hier te zien.",
+  "empty_column.community": "De lokale tijdlijn is nog leeg. Plaats een openbaar bericht om de spits af te bijten!",
   "empty_column.direct": "Je hebt nog geen directe berichten. Wanneer je er een verzend of ontvangt, zijn deze hier te zien.",
   "empty_column.domain_blocks": "Er zijn nog geen geblokkeerde domeinen.",
-  "empty_column.explore_statuses": "Nothing is trending right now. Check back later!",
-  "empty_column.favourited_statuses": "Jij hebt nog geen favoriete toots. Wanneer je er een aan jouw favorieten toevoegt, valt deze hier te zien.",
-  "empty_column.favourites": "Niemand heeft deze toot nog aan hun favorieten toegevoegd. Wanneer iemand dit doet, valt dat hier te zien.",
+  "empty_column.explore_statuses": "Momenteel zijn er geen trends. Kom later terug!",
+  "empty_column.favourited_statuses": "Jij hebt nog geen favoriete berichten. Wanneer je er een aan jouw favorieten toevoegt, valt deze hier te zien.",
+  "empty_column.favourites": "Niemand heeft dit bericht nog aan diens favorieten toegevoegd. Wanneer iemand dit doet, valt dat hier te zien.",
   "empty_column.follow_recommendations": "Het lijkt er op dat er geen aanbevelingen voor jou aangemaakt kunnen worden. Je kunt proberen te zoeken naar mensen die je wellicht kent, zoeken op hashtags, de lokale en globale tijdlijnen bekijken of de gebruikersgids doorbladeren.",
   "empty_column.follow_requests": "Jij hebt nog enkel volgverzoek ontvangen. Wanneer je er eentje ontvangt, valt dat hier te zien.",
   "empty_column.hashtag": "Er is nog niks te vinden onder deze hashtag.",
   "empty_column.home": "Deze tijdlijn is leeg! Volg meer mensen om het te vullen. {suggestions}",
   "empty_column.home.suggestions": "Enkele aanbevelingen bekijken",
-  "empty_column.list": "Er is nog niks te zien in deze lijst. Wanneer lijstleden nieuwe toots publiceren, zijn deze hier te zien.",
+  "empty_column.list": "Er is nog niks te zien in deze lijst. Wanneer lijstleden nieuwe berichten plaatsen, zijn deze hier te zien.",
   "empty_column.lists": "Jij hebt nog geen enkele lijst. Wanneer je er eentje hebt aangemaakt, valt deze hier te zien.",
   "empty_column.mutes": "Jij hebt nog geen gebruikers genegeerd.",
   "empty_column.notifications": "Je hebt nog geen meldingen. Begin met iemand een gesprek.",
-  "empty_column.public": "Er is hier helemaal niks! Toot iets in het openbaar of volg mensen van andere servers om het te vullen",
+  "empty_column.public": "Er is hier helemaal niks! Plaatst een openbaar bericht of volg mensen van andere servers om het te vullen",
   "error.unexpected_crash.explanation": "Als gevolg van een bug in onze broncode of als gevolg van een compatibiliteitsprobleem met jouw webbrowser, kan deze pagina niet goed worden weergegeven.",
   "error.unexpected_crash.explanation_addons": "Deze pagina kon niet correct geladen worden. Deze fout wordt waarschijnlijk door een browser-add-on of een automatische vertalingshulpmiddel veroorzaakt.",
   "error.unexpected_crash.next_steps": "Probeer deze pagina te vernieuwen. Wanneer dit niet helpt is het nog steeds mogelijk om Mastodon in een andere webbrowser of mobiele app te gebruiken.",
   "error.unexpected_crash.next_steps_addons": "Probeer deze uit te schakelen en de pagina te verversen. Wanneer dat niet helpt, kun je Mastodon nog altijd met een andere webbrowser of mobiele app gebruiken.",
   "errors.unexpected_crash.copy_stacktrace": "Stacktrace naar klembord kopiëren",
   "errors.unexpected_crash.report_issue": "Technisch probleem melden",
-  "explore.search_results": "Search results",
-  "explore.suggested_follows": "For you",
-  "explore.title": "Explore",
-  "explore.trending_links": "News",
-  "explore.trending_statuses": "Posts",
+  "explore.search_results": "Zoekresultaten",
+  "explore.suggested_follows": "Voor jou",
+  "explore.title": "Verkennen",
+  "explore.trending_links": "Nieuws",
+  "explore.trending_statuses": "Berichten",
   "explore.trending_tags": "Hashtags",
   "follow_recommendations.done": "Klaar",
-  "follow_recommendations.heading": "Volg mensen waarvan je graag toots wil zien! Hier zijn enkele aanbevelingen.",
-  "follow_recommendations.lead": "Toots van mensen die je volgt zullen in chronologische volgorde onder start verschijnen. Wees niet bang om hierin fouten te maken, want je kunt mensen op elk moment net zo eenvoudig ontvolgen!",
+  "follow_recommendations.heading": "Volg mensen waarvan je graag berichten wil zien! Hier zijn enkele aanbevelingen.",
+  "follow_recommendations.lead": "Berichten van mensen die je volgt zullen in chronologische volgorde onder start verschijnen. Wees niet bang om hierin fouten te maken, want je kunt mensen op elk moment net zo eenvoudig ontvolgen!",
   "follow_request.authorize": "Goedkeuren",
   "follow_request.reject": "Afkeuren",
   "follow_requests.unlocked_explanation": "Ook al is jouw account niet besloten, de medewerkers van {domain} denken dat jij misschien de volgende volgverzoeken handmatig wil controleren.",
@@ -227,13 +227,13 @@
   "intervals.full.minutes": "{number, plural, one {# minuut} other {# minuten}}",
   "keyboard_shortcuts.back": "Ga terug",
   "keyboard_shortcuts.blocked": "Geblokkeerde gebruikers tonen",
-  "keyboard_shortcuts.boost": "Toot boosten",
+  "keyboard_shortcuts.boost": "Bericht boosten",
   "keyboard_shortcuts.column": "Op één van de kolommen focussen",
-  "keyboard_shortcuts.compose": "Tekstveld voor toots focussen",
+  "keyboard_shortcuts.compose": "Tekstveld om een bericht te schrijven focussen",
   "keyboard_shortcuts.description": "Omschrijving",
   "keyboard_shortcuts.direct": "Jouw directe berichten tonen",
   "keyboard_shortcuts.down": "Naar beneden in de lijst bewegen",
-  "keyboard_shortcuts.enter": "Toot volledig tonen",
+  "keyboard_shortcuts.enter": "Volledig bericht tonen",
   "keyboard_shortcuts.favourite": "Aan jouw favorieten toevoegen",
   "keyboard_shortcuts.favourites": "Favorieten tonen",
   "keyboard_shortcuts.federated": "Globale tijdlijn tonen",
@@ -247,7 +247,7 @@
   "keyboard_shortcuts.my_profile": "Jouw profiel tonen",
   "keyboard_shortcuts.notifications": "Meldingen tonen",
   "keyboard_shortcuts.open_media": "Media openen",
-  "keyboard_shortcuts.pinned": "Jouw vastgezette toots tonen",
+  "keyboard_shortcuts.pinned": "Jouw vastgemaakte berichten tonen",
   "keyboard_shortcuts.profile": "Gebruikersprofiel auteur openen",
   "keyboard_shortcuts.reply": "Reageren",
   "keyboard_shortcuts.requests": "Jouw volgverzoeken tonen",
@@ -256,7 +256,7 @@
   "keyboard_shortcuts.start": "\"Aan de slag\" tonen",
   "keyboard_shortcuts.toggle_hidden": "Inhoudswaarschuwing tonen/verbergen",
   "keyboard_shortcuts.toggle_sensitivity": "Media tonen/verbergen",
-  "keyboard_shortcuts.toot": "Nieuwe toot schrijven",
+  "keyboard_shortcuts.toot": "Nieuw bericht schrijven",
   "keyboard_shortcuts.unfocus": "Tekst- en zoekveld ontfocussen",
   "keyboard_shortcuts.up": "Naar boven in de lijst bewegen",
   "lightbox.close": "Sluiten",
@@ -289,12 +289,12 @@
   "navigation_bar.blocks": "Geblokkeerde gebruikers",
   "navigation_bar.bookmarks": "Bladwijzers",
   "navigation_bar.community_timeline": "Lokale tijdlijn",
-  "navigation_bar.compose": "Nieuw toot schrijven",
+  "navigation_bar.compose": "Nieuw bericht schrijven",
   "navigation_bar.direct": "Directe berichten",
   "navigation_bar.discover": "Ontdekken",
   "navigation_bar.domain_blocks": "Geblokkeerde domeinen",
   "navigation_bar.edit_profile": "Profiel bewerken",
-  "navigation_bar.explore": "Explore",
+  "navigation_bar.explore": "Verkennen",
   "navigation_bar.favourites": "Favorieten",
   "navigation_bar.filters": "Filters",
   "navigation_bar.follow_requests": "Volgverzoeken",
@@ -305,23 +305,23 @@
   "navigation_bar.logout": "Uitloggen",
   "navigation_bar.mutes": "Genegeerde gebruikers",
   "navigation_bar.personal": "Persoonlijk",
-  "navigation_bar.pins": "Vastgezette toots",
+  "navigation_bar.pins": "Vastgemaakte berichten",
   "navigation_bar.preferences": "Instellingen",
   "navigation_bar.public_timeline": "Globale tijdlijn",
   "navigation_bar.security": "Beveiliging",
-  "notification.admin.sign_up": "{name} signed up",
-  "notification.favourite": "{name} voegde jouw toot als favoriet toe",
+  "notification.admin.sign_up": "{name} heeft zich aangemeld",
+  "notification.favourite": "{name} voegde jouw bericht als favoriet toe",
   "notification.follow": "{name} volgt jou nu",
   "notification.follow_request": "{name} wil jou graag volgen",
   "notification.mention": "{name} vermeldde jou",
   "notification.own_poll": "Jouw poll is beëindigd",
   "notification.poll": "Een poll waaraan jij hebt meegedaan is beëindigd",
-  "notification.reblog": "{name} boostte jouw toot",
-  "notification.status": "{name} heeft zojuist een toot geplaatst",
-  "notification.update": "{name} edited a post",
+  "notification.reblog": "{name} boostte jouw bericht",
+  "notification.status": "{name} heeft zojuist een bericht geplaatst",
+  "notification.update": "{name} heeft een bericht bewerkt",
   "notifications.clear": "Meldingen verwijderen",
   "notifications.clear_confirmation": "Weet je het zeker dat je al jouw meldingen wilt verwijderen?",
-  "notifications.column_settings.admin.sign_up": "New sign-ups:",
+  "notifications.column_settings.admin.sign_up": "Nieuwe aanmeldingen:",
   "notifications.column_settings.alert": "Desktopmeldingen",
   "notifications.column_settings.favourite": "Favorieten:",
   "notifications.column_settings.filter_bar.advanced": "Alle categorieën tonen",
@@ -335,10 +335,10 @@
   "notifications.column_settings.reblog": "Boosts:",
   "notifications.column_settings.show": "In kolom tonen",
   "notifications.column_settings.sound": "Geluid afspelen",
-  "notifications.column_settings.status": "Nieuwe toots:",
+  "notifications.column_settings.status": "Nieuwe berichten:",
   "notifications.column_settings.unread_notifications.category": "Ongelezen meldingen",
   "notifications.column_settings.unread_notifications.highlight": "Ongelezen meldingen markeren",
-  "notifications.column_settings.update": "Edits:",
+  "notifications.column_settings.update": "Bewerkingen:",
   "notifications.filter.all": "Alles",
   "notifications.filter.boosts": "Boosts",
   "notifications.filter.favourites": "Favorieten",
@@ -365,7 +365,7 @@
   "poll.votes": "{votes, plural, one {# stem} other {# stemmen}}",
   "poll_button.add_poll": "Poll toevoegen",
   "poll_button.remove_poll": "Poll verwijderen",
-  "privacy.change": "Zichtbaarheid van toot aanpassen",
+  "privacy.change": "Zichtbaarheid van bericht aanpassen",
   "privacy.direct.long": "Alleen aan vermelde gebruikers tonen",
   "privacy.direct.short": "Direct",
   "privacy.private.long": "Alleen aan volgers tonen",
@@ -378,100 +378,100 @@
   "regeneration_indicator.label": "Aan het laden…",
   "regeneration_indicator.sublabel": "Jouw tijdlijn wordt aangemaakt!",
   "relative_time.days": "{number}d",
-  "relative_time.full.days": "{number, plural, one {# day} other {# days}} ago",
-  "relative_time.full.hours": "{number, plural, one {# hour} other {# hours}} ago",
-  "relative_time.full.just_now": "just now",
-  "relative_time.full.minutes": "{number, plural, one {# minute} other {# minutes}} ago",
-  "relative_time.full.seconds": "{number, plural, one {# second} other {# seconds}} ago",
+  "relative_time.full.days": "{number, plural, one {# dag} other {# dagen}} geleden",
+  "relative_time.full.hours": "{number, plural, one {# uur} other {# uur}} geleden",
+  "relative_time.full.just_now": "zojuist",
+  "relative_time.full.minutes": "{number, plural, one {# minuut} other {# minuten}} geleden",
+  "relative_time.full.seconds": "{number, plural, one {# seconde} other {# seconden}} geleden",
   "relative_time.hours": "{number}u",
   "relative_time.just_now": "nu",
   "relative_time.minutes": "{number}m",
   "relative_time.seconds": "{number}s",
   "relative_time.today": "vandaag",
   "reply_indicator.cancel": "Annuleren",
-  "report.block": "Block",
-  "report.block_explanation": "You will not see their posts. They will not be able to see your posts or follow you. They will be able to tell that they are blocked.",
-  "report.categories.other": "Other",
+  "report.block": "Blokkeren",
+  "report.block_explanation": "Je kunt diens berichten niet zien. Je kunt door diegene niet gevolgd worden en jouw berichten zijn onzichtbaar. Diegene kan zien dat die door jou is geblokkeerd.",
+  "report.categories.other": "Overig",
   "report.categories.spam": "Spam",
-  "report.categories.violation": "Content violates one or more server rules",
-  "report.category.subtitle": "Choose the best match",
-  "report.category.title": "Tell us what's going on with this {type}",
-  "report.category.title_account": "profile",
-  "report.category.title_status": "post",
-  "report.close": "Done",
-  "report.comment.title": "Is there anything else you think we should know?",
+  "report.categories.violation": "De inhoud overtreedt een of meerdere serverregels",
+  "report.category.subtitle": "Kies wat het meeste overeenkomt",
+  "report.category.title": "Vertel ons wat er met dit {type} aan de hand is",
+  "report.category.title_account": "profiel",
+  "report.category.title_status": "bericht",
+  "report.close": "Klaar",
+  "report.comment.title": "Zijn er nog andere dingen waarvan je denkt dat wij dat moeten weten?",
   "report.forward": "Naar {target} doorsturen",
   "report.forward_hint": "Het account bevindt zich op een andere server. Wil je daar eveneens een geanonimiseerde kopie van deze rapportage naar toe sturen?",
-  "report.mute": "Mute",
-  "report.mute_explanation": "You will not see their posts. They can still follow you and see your posts and will not know that they are muted.",
-  "report.next": "Next",
+  "report.mute": "Negeren",
+  "report.mute_explanation": "Je kunt diens berichten niet zien. Je kunt nog wel gevolgd worden en jouw berichten zijn nog zichtbaar, maar diegene kan niet zien dat die wordt genegeerd.",
+  "report.next": "Volgende",
   "report.placeholder": "Extra opmerkingen",
-  "report.reasons.dislike": "I don't like it",
-  "report.reasons.dislike_description": "It is not something you want to see",
-  "report.reasons.other": "It's something else",
-  "report.reasons.other_description": "The issue does not fit into other categories",
-  "report.reasons.spam": "It's spam",
-  "report.reasons.spam_description": "Malicious links, fake engagement, or repetitive replies",
-  "report.reasons.violation": "It violates server rules",
-  "report.reasons.violation_description": "You are aware that it breaks specific rules",
-  "report.rules.subtitle": "Select all that apply",
-  "report.rules.title": "Which rules are being violated?",
-  "report.statuses.subtitle": "Select all that apply",
-  "report.statuses.title": "Are there any posts that back up this report?",
+  "report.reasons.dislike": "Ik vind het niet leuk",
+  "report.reasons.dislike_description": "Het is iets wat je niet wilt zien",
+  "report.reasons.other": "Het is iets anders",
+  "report.reasons.other_description": "Het probleem past niet in een andere categorie",
+  "report.reasons.spam": "Het is spam",
+  "report.reasons.spam_description": "Schadelijke links, reclame, misleiding of herhalende antwoorden",
+  "report.reasons.violation": "Het schendt de serverregels",
+  "report.reasons.violation_description": "Je weet dat het specifieke regels schendt",
+  "report.rules.subtitle": "Selecteer wat van toepassing is",
+  "report.rules.title": "Welke regels worden geschonden?",
+  "report.statuses.subtitle": "Selecteer wat van toepassing is",
+  "report.statuses.title": "Zijn er berichten die deze rapportage ondersteunen?",
   "report.submit": "Verzenden",
   "report.target": "{target} rapporteren",
-  "report.thanks.take_action": "Here are your options for controlling what you see on Mastodon:",
-  "report.thanks.take_action_actionable": "While we review this, you can take action against @{name}:",
-  "report.thanks.title": "Don't want to see this?",
-  "report.thanks.title_actionable": "Thanks for reporting, we'll look into this.",
-  "report.unfollow": "Unfollow @{name}",
-  "report.unfollow_explanation": "You are following this account. To not see their posts in your home feed anymore, unfollow them.",
+  "report.thanks.take_action": "Hier zijn jouw opties waarmee je kunt bepalen wat je in Mastodon wilt zien:",
+  "report.thanks.take_action_actionable": "Terwijl wij jouw rapportage beroordelen, kun je deze acties ondernemen tegen @{name}:",
+  "report.thanks.title": "Wil je dit niet zien?",
+  "report.thanks.title_actionable": "Dank je voor het rapporteren. Wij gaan er naar kijken.",
+  "report.unfollow": "@{name} ontvolgen",
+  "report.unfollow_explanation": "Je volgt dit account. Om diens berichten niet meer op jouw starttijdlijn te zien, kun je diegene ontvolgen.",
   "search.placeholder": "Zoeken",
   "search_popout.search_format": "Geavanceerd zoeken",
-  "search_popout.tips.full_text": "Gebruik gewone tekst om te zoeken in jouw toots, gebooste toots, favorieten en in toots waarin je bent vermeldt, en tevens naar gebruikersnamen, weergavenamen en hashtags.",
+  "search_popout.tips.full_text": "Gebruik gewone tekst om te zoeken in jouw berichten, gebooste berichten, favorieten en in berichten waarin je bent vermeldt, en tevens naar gebruikersnamen, weergavenamen en hashtags.",
   "search_popout.tips.hashtag": "hashtag",
-  "search_popout.tips.status": "toot",
+  "search_popout.tips.status": "bericht",
   "search_popout.tips.text": "Gebruik gewone tekst om te zoeken op weergavenamen, gebruikersnamen en hashtags",
   "search_popout.tips.user": "gebruiker",
   "search_results.accounts": "Gebruikers",
-  "search_results.all": "All",
+  "search_results.all": "Alles",
   "search_results.hashtags": "Hashtags",
-  "search_results.nothing_found": "Could not find anything for these search terms",
-  "search_results.statuses": "Toots",
-  "search_results.statuses_fts_disabled": "Het zoeken in toots is op deze Mastodon-server niet ingeschakeld.",
+  "search_results.nothing_found": "Deze zoektermen leveren geen resultaat op",
+  "search_results.statuses": "Berichten",
+  "search_results.statuses_fts_disabled": "Het zoeken in berichten is op deze Mastodon-server niet ingeschakeld.",
   "search_results.total": "{count, number} {count, plural, one {resultaat} other {resultaten}}",
   "status.admin_account": "Moderatie-omgeving van @{name} openen",
-  "status.admin_status": "Deze toot in de moderatie-omgeving openen",
+  "status.admin_status": "Dit bericht in de moderatie-omgeving openen",
   "status.block": "@{name} blokkeren",
   "status.bookmark": "Bladwijzer toevoegen",
   "status.cancel_reblog_private": "Niet langer boosten",
-  "status.cannot_reblog": "Deze toot kan niet geboost worden",
-  "status.copy": "Link naar toot kopiëren",
+  "status.cannot_reblog": "Dit bericht kan niet geboost worden",
+  "status.copy": "Link naar bericht kopiëren",
   "status.delete": "Verwijderen",
   "status.detailed_status": "Uitgebreide gespreksweergave",
   "status.direct": "@{name} een direct bericht sturen",
-  "status.edit": "Edit",
-  "status.edited": "Edited {date}",
-  "status.edited_x_times": "Edited {count, plural, one {{count} time} other {{count} times}}",
+  "status.edit": "Bewerken",
+  "status.edited": "Bewerkt op {date}",
+  "status.edited_x_times": "{count, plural, one {{count} keer} other {{count} keer}} bewerkt",
   "status.embed": "Insluiten",
   "status.favourite": "Favoriet",
   "status.filtered": "Gefilterd",
-  "status.history.created": "{name} created {date}",
-  "status.history.edited": "{name} edited {date}",
+  "status.history.created": "{name} plaatste dit {date}",
+  "status.history.edited": "{name} bewerkte dit {date}",
   "status.load_more": "Meer laden",
   "status.media_hidden": "Media verborgen",
   "status.mention": "@{name} vermelden",
   "status.more": "Meer",
   "status.mute": "@{name} negeren",
   "status.mute_conversation": "Negeer gesprek",
-  "status.open": "Volledige toot tonen",
+  "status.open": "Volledig bericht tonen",
   "status.pin": "Aan profielpagina vastmaken",
-  "status.pinned": "Vastgemaakte toot",
+  "status.pinned": "Vastgemaakt bericht",
   "status.read_more": "Meer lezen",
   "status.reblog": "Boosten",
   "status.reblog_private": "Boost naar oorspronkelijke ontvangers",
   "status.reblogged_by": "{name} boostte",
-  "status.reblogs.empty": "Niemand heeft deze toot nog geboost. Wanneer iemand dit doet, valt dat hier te zien.",
+  "status.reblogs.empty": "Niemand heeft dit bericht nog geboost. Wanneer iemand dit doet, valt dat hier te zien.",
   "status.redraft": "Verwijderen en herschrijven",
   "status.remove_bookmark": "Bladwijzer verwijderen",
   "status.reply": "Reageren",
@@ -502,7 +502,7 @@
   "timeline_hint.remote_resource_not_displayed": "{resource} van andere servers worden niet getoond.",
   "timeline_hint.resources.followers": "Volgers",
   "timeline_hint.resources.follows": "Volgend",
-  "timeline_hint.resources.statuses": "Oudere toots",
+  "timeline_hint.resources.statuses": "Oudere berichten",
   "trends.counter_by_accounts": "{count, plural, one {{counter} persoon} other {{counter} personen}} zijn aan het praten",
   "trends.trending_now": "Huidige trends",
   "ui.beforeunload": "Je concept gaat verloren wanneer je Mastodon verlaat.",
diff --git a/app/lib/activitypub/activity/create.rb b/app/lib/activitypub/activity/create.rb
index cf31b6ff6..1ac509f18 100644
--- a/app/lib/activitypub/activity/create.rb
+++ b/app/lib/activitypub/activity/create.rb
@@ -1,6 +1,8 @@
 # frozen_string_literal: true
 
 class ActivityPub::Activity::Create < ActivityPub::Activity
+  include FormattingHelper
+
   def perform
     dereference_object!
 
@@ -367,7 +369,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
   end
 
   def converted_text
-    Formatter.instance.linkify([@status_parser.title.presence, @status_parser.spoiler_text.presence, @status_parser.url || @status_parser.uri].compact.join("\n\n"))
+    linkify([@status_parser.title.presence, @status_parser.spoiler_text.presence, @status_parser.url || @status_parser.uri].compact.join("\n\n"))
   end
 
   def unsupported_media_type?(mime_type)
diff --git a/app/lib/activitypub/parser/media_attachment_parser.rb b/app/lib/activitypub/parser/media_attachment_parser.rb
index 1798e58a4..30bea1f0e 100644
--- a/app/lib/activitypub/parser/media_attachment_parser.rb
+++ b/app/lib/activitypub/parser/media_attachment_parser.rb
@@ -27,7 +27,9 @@ class ActivityPub::Parser::MediaAttachmentParser
   end
 
   def description
-    @json['summary'].presence || @json['name'].presence
+    str = @json['summary'].presence || @json['name'].presence
+    str = str.strip[0...MediaAttachment::MAX_DESCRIPTION_LENGTH] if str.present?
+    str
   end
 
   def focus
diff --git a/app/lib/admin/system_check.rb b/app/lib/admin/system_check.rb
index afb20cb47..877a42ef6 100644
--- a/app/lib/admin/system_check.rb
+++ b/app/lib/admin/system_check.rb
@@ -5,6 +5,7 @@ class Admin::SystemCheck
     Admin::SystemCheck::DatabaseSchemaCheck,
     Admin::SystemCheck::SidekiqProcessCheck,
     Admin::SystemCheck::RulesCheck,
+    Admin::SystemCheck::ElasticsearchCheck,
   ].freeze
 
   def self.perform
diff --git a/app/lib/admin/system_check/elasticsearch_check.rb b/app/lib/admin/system_check/elasticsearch_check.rb
new file mode 100644
index 000000000..1b48a5415
--- /dev/null
+++ b/app/lib/admin/system_check/elasticsearch_check.rb
@@ -0,0 +1,39 @@
+# frozen_string_literal: true
+
+class Admin::SystemCheck::ElasticsearchCheck < Admin::SystemCheck::BaseCheck
+  def pass?
+    return true unless Chewy.enabled?
+
+    running_version.present? && compatible_version?
+  end
+
+  def message
+    if running_version.present?
+      Admin::SystemCheck::Message.new(:elasticsearch_version_check, I18n.t('admin.system_checks.elasticsearch_version_check.version_comparison', running_version: running_version, required_version: required_version))
+    else
+      Admin::SystemCheck::Message.new(:elasticsearch_running_check)
+    end
+  end
+
+  private
+
+  def running_version
+    @running_version ||= begin
+      Chewy.client.info['version']['number']
+    rescue Faraday::ConnectionFailed
+      nil
+    end
+  end
+
+  def required_version
+    '7.x'
+  end
+
+  def compatible_version?
+    Gem::Version.new(running_version) >= Gem::Version.new(required_version)
+  end
+
+  def missing_queues
+    @missing_queues ||= Sidekiq::ProcessSet.new.reduce(SIDEKIQ_QUEUES) { |queues, process| queues - process['queues'] }
+  end
+end
diff --git a/app/lib/advanced_text_formatter.rb b/app/lib/advanced_text_formatter.rb
new file mode 100644
index 000000000..728400819
--- /dev/null
+++ b/app/lib/advanced_text_formatter.rb
@@ -0,0 +1,131 @@
+# frozen_string_literal: true
+
+class AdvancedTextFormatter < TextFormatter
+  class HTMLRenderer < Redcarpet::Render::HTML
+    def initialize(options, &block)
+      super(options)
+      @format_link = block
+    end
+
+    def block_code(code, _language)
+      <<~HTML.squish
+        <pre><code>#{ERB::Util.h(code).gsub("\n", '<br/>')}</code></pre>
+      HTML
+    end
+
+    def autolink(link, link_type)
+      return link if link_type == :email
+      @format_link.call(link)
+    end
+  end
+
+  # @param [String] text
+  # @param [Hash] options
+  # @option options [Boolean] :multiline
+  # @option options [Boolean] :with_domains
+  # @option options [Boolean] :with_rel_me
+  # @option options [Array<Account>] :preloaded_accounts
+  # @option options [String] :content_type
+  def initialize(text, options = {})
+    content_type = options.delete(:content_type)
+    super(text, options)
+
+    @text = format_markdown(text) if content_type == 'text/markdown'
+  end
+
+  # Differs from TextFormatter by not messing with newline after parsing
+  def to_s
+    return ''.html_safe if text.blank?
+
+    html = rewrite do |entity|
+      if entity[:url]
+        link_to_url(entity)
+      elsif entity[:hashtag]
+        link_to_hashtag(entity)
+      elsif entity[:screen_name]
+        link_to_mention(entity)
+      end
+    end
+
+    html.html_safe # rubocop:disable Rails/OutputSafety
+  end
+
+  # Differs from `TextFormatter` by skipping HTML tags and entities
+  def entities
+    @entities ||= begin
+      gaps = []
+      total_offset = 0
+
+      escaped = text.gsub(/<[^>]*>|&#[0-9]+;/) do |match|
+        total_offset += match.length - 1
+        end_offset = Regexp.last_match.end(0)
+        gaps << [end_offset - total_offset, total_offset]
+        ' '
+      end
+
+      Extractor.extract_entities_with_indices(escaped, extract_url_without_protocol: false).map do |entity|
+        start_pos, end_pos = entity[:indices]
+        offset_idx = gaps.rindex { |gap| gap.first <= start_pos }
+        offset = offset_idx.nil? ? 0 : gaps[offset_idx].last
+        entity.merge(indices: [start_pos + offset, end_pos + offset])
+      end
+    end
+  end
+
+  private
+
+  # Differs from `TextFormatter` in that it keeps HTML; but it sanitizes at the end to remain safe
+  def rewrite
+    entities.sort_by! do |entity|
+      entity[:indices].first
+    end
+
+    result = ''.dup
+
+    last_index = entities.reduce(0) do |index, entity|
+      indices = entity[:indices]
+      result << text[index...indices.first]
+      result << yield(entity)
+      indices.last
+    end
+
+    result << text[last_index..-1]
+
+    Sanitize.fragment(result, Sanitize::Config::MASTODON_OUTGOING)
+  end
+
+  def format_markdown(html)
+    html = markdown_formatter.render(html)
+    html.delete("\r").delete("\n")
+  end
+
+  def markdown_formatter
+    extensions = {
+      autolink: true,
+      no_intra_emphasis: true,
+      fenced_code_blocks: true,
+      disable_indented_code_blocks: true,
+      strikethrough: true,
+      lax_spacing: true,
+      space_after_headers: true,
+      superscript: true,
+      underline: true,
+      highlight: true,
+      footnotes: false,
+    }
+
+    renderer = HTMLRenderer.new({
+      filter_html: false,
+      escape_html: false,
+      no_images: true,
+      no_styles: true,
+      safe_links_only: true,
+      hard_wrap: true,
+      link_attributes: { target: '_blank', rel: 'nofollow noopener' },
+    }) do |url|
+      link_to_url({ url: url })
+    end
+
+    Redcarpet::Markdown.new(renderer, extensions)
+  end
+end
diff --git a/app/lib/emoji_formatter.rb b/app/lib/emoji_formatter.rb
new file mode 100644
index 000000000..f808f3a22
--- /dev/null
+++ b/app/lib/emoji_formatter.rb
@@ -0,0 +1,98 @@
+# frozen_string_literal: true
+
+class EmojiFormatter
+  include RoutingHelper
+
+  DISALLOWED_BOUNDING_REGEX = /[[:alnum:]:]/.freeze
+
+  attr_reader :html, :custom_emojis, :options
+
+  # @param [ActiveSupport::SafeBuffer] html
+  # @param [Array<CustomEmoji>] custom_emojis
+  # @param [Hash] options
+  # @option options [Boolean] :animate
+  def initialize(html, custom_emojis, options = {})
+    raise ArgumentError unless html.html_safe?
+
+    @html = html
+    @custom_emojis = custom_emojis
+    @options = options
+  end
+
+  def to_s
+    return html if custom_emojis.empty? || html.blank?
+
+    i                     = -1
+    tag_open_index        = nil
+    inside_shortname      = false
+    shortname_start_index = -1
+    invisible_depth       = 0
+    last_index            = 0
+    result                = ''.dup
+
+    while i + 1 < html.size
+      i += 1
+
+      if invisible_depth.zero? && inside_shortname && html[i] == ':'
+        inside_shortname = false
+        shortcode = html[shortname_start_index + 1..i - 1]
+        char_after = html[i + 1]
+
+        next unless (char_after.nil? || !DISALLOWED_BOUNDING_REGEX.match?(char_after)) && (emoji = emoji_map[shortcode])
+
+        result << html[last_index..shortname_start_index - 1] if shortname_start_index.positive?
+        result << image_for_emoji(shortcode, emoji)
+        last_index = i + 1
+      elsif tag_open_index && html[i] == '>'
+        tag = html[tag_open_index..i]
+        tag_open_index = nil
+
+        if invisible_depth.positive?
+          invisible_depth += count_tag_nesting(tag)
+        elsif tag == '<span class="invisible">'
+          invisible_depth = 1
+        end
+      elsif html[i] == '<'
+        tag_open_index = i
+        inside_shortname = false
+      elsif !tag_open_index && html[i] == ':' && (i.zero? || !DISALLOWED_BOUNDING_REGEX.match?(html[i - 1]))
+        inside_shortname = true
+        shortname_start_index = i
+      end
+    end
+
+    result << html[last_index..-1]
+
+    result.html_safe # rubocop:disable Rails/OutputSafety
+  end
+
+  private
+
+  def emoji_map
+    @emoji_map ||= custom_emojis.each_with_object({}) { |e, h| h[e.shortcode] = [full_asset_url(e.image.url), full_asset_url(e.image.url(:static))] }
+  end
+
+  def count_tag_nesting(tag)
+    if tag[1] == '/'
+      -1
+    elsif tag[-2] == '/'
+      0
+    else
+      1
+    end
+  end
+
+  def image_for_emoji(shortcode, emoji)
+    original_url, static_url = emoji
+
+    if animate?
+      image_tag(original_url, draggable: false, class: 'emojione', alt: ":#{shortcode}:", title: ":#{shortcode}:")
+    else
+      image_tag(original_url, draggable: false, class: 'emojione custom-emoji', alt: ":#{shortcode}:", title: ":#{shortcode}:", data: { original: original_url, static: static_url })
+    end
+  end
+
+  def animate?
+    @options[:animate]
+  end
+end
diff --git a/app/lib/extractor.rb b/app/lib/extractor.rb
index 8020aa916..ef9407864 100644
--- a/app/lib/extractor.rb
+++ b/app/lib/extractor.rb
@@ -5,18 +5,34 @@ module Extractor
 
   module_function
 
-  # :yields: username, list_slug, start, end
+  def extract_entities_with_indices(text, options = {}, &block)
+    entities = begin
+      extract_urls_with_indices(text, options) +
+        extract_hashtags_with_indices(text, check_url_overlap: false) +
+        extract_mentions_or_lists_with_indices(text) +
+        extract_extra_uris_with_indices(text)
+    end
+
+    return [] if entities.empty?
+
+    entities = remove_overlapping_entities(entities)
+    entities.each(&block) if block_given?
+    entities
+  end
+
   def extract_mentions_or_lists_with_indices(text)
-    return [] unless Twitter::TwitterText::Regex[:at_signs].match?(text)
+    return [] unless text && Twitter::TwitterText::Regex[:at_signs].match?(text)
 
     possible_entries = []
 
-    text.to_s.scan(Account::MENTION_RE) do |screen_name, _|
+    text.scan(Account::MENTION_RE) do |screen_name, _|
       match_data = $LAST_MATCH_INFO
-      after = $'
+      after      = $'
+
       unless Twitter::TwitterText::Regex[:end_mention_match].match?(after)
         start_position = match_data.char_begin(1) - 1
-        end_position = match_data.char_end(1)
+        end_position   = match_data.char_end(1)
+
         possible_entries << {
           screen_name: screen_name,
           indices: [start_position, end_position],
@@ -29,36 +45,70 @@ module Extractor
         yield mention[:screen_name], mention[:indices].first, mention[:indices].last
       end
     end
+
     possible_entries
   end
 
-  def extract_hashtags_with_indices(text, **)
-    return [] unless /#/.match?(text)
+  def extract_hashtags_with_indices(text, _options = {})
+    return [] unless text&.index('#')
+
+    possible_entries = []
 
-    tags = []
     text.scan(Tag::HASHTAG_RE) do |hash_text, _|
-      match_data = $LAST_MATCH_INFO
+      match_data     = $LAST_MATCH_INFO
       start_position = match_data.char_begin(1) - 1
-      end_position = match_data.char_end(1)
-      after = $'
+      end_position   = match_data.char_end(1)
+      after          = $'
+
       if %r{\A://}.match?(after)
         hash_text.match(/(.+)(https?\Z)/) do |matched|
-          hash_text = matched[1]
+          hash_text     = matched[1]
           end_position -= matched[2].codepoint_length
         end
       end
 
-      tags << {
+      possible_entries << {
         hashtag: hash_text,
         indices: [start_position, end_position],
       }
     end
 
-    tags.each { |tag| yield tag[:hashtag], tag[:indices].first, tag[:indices].last } if block_given?
-    tags
+    if block_given?
+      possible_entries.each do |tag|
+        yield tag[:hashtag], tag[:indices].first, tag[:indices].last
+      end
+    end
+
+    possible_entries
   end
 
   def extract_cashtags_with_indices(_text)
-    [] # always returns empty array
+    []
+  end
+
+  def extract_extra_uris_with_indices(text)
+    return [] unless text&.index(':')
+
+    possible_entries = []
+
+    text.scan(Twitter::TwitterText::Regex[:valid_extended_uri]) do
+      valid_uri_match_data = $LAST_MATCH_INFO
+
+      start_position = valid_uri_match_data.char_begin(3)
+      end_position   = valid_uri_match_data.char_end(3)
+
+      possible_entries << {
+        url: valid_uri_match_data[3],
+        indices: [start_position, end_position],
+      }
+    end
+
+    if block_given?
+      possible_entries.each do |url|
+        yield url[:url], url[:indices].first, url[:indices].last
+      end
+    end
+
+    possible_entries
   end
 end
diff --git a/app/lib/feed_manager.rb b/app/lib/feed_manager.rb
index efc9da34b..6994f00ae 100644
--- a/app/lib/feed_manager.rb
+++ b/app/lib/feed_manager.rb
@@ -5,6 +5,7 @@ require 'singleton'
 class FeedManager
   include Singleton
   include Redisable
+  include FormattingHelper
 
   # Maximum number of items stored in a single feed
   MAX_ITEMS = 400
@@ -503,7 +504,7 @@ class FeedManager
     status         = status.reblog if status.reblog?
 
     combined_text = [
-      Formatter.instance.plaintext(status),
+      extract_status_plain_text(status),
       status.spoiler_text,
       status.preloadable_poll ? status.preloadable_poll.options.join("\n\n") : nil,
       status.ordered_media_attachments.map(&:description).join("\n\n"),
diff --git a/app/lib/formatter.rb b/app/lib/formatter.rb
deleted file mode 100644
index dfa493ed5..000000000
--- a/app/lib/formatter.rb
+++ /dev/null
@@ -1,382 +0,0 @@
-# frozen_string_literal: true
-
-require 'singleton'
-
-class HTMLRenderer < Redcarpet::Render::HTML
-  def block_code(code, language)
-    "<pre><code>#{encode(code).gsub("\n", "<br/>")}</code></pre>"
-  end
-
-  def autolink(link, link_type)
-    return link if link_type == :email
-    Formatter.instance.link_url(link)
-  rescue Addressable::URI::InvalidURIError, IDN::Idna::IdnaError
-    encode(link)
-  end
-
-  private
-
-  def html_entities
-    @html_entities ||= HTMLEntities.new
-  end
-
-  def encode(html)
-    html_entities.encode(html)
-  end
-end
-
-class Formatter
-  include Singleton
-  include RoutingHelper
-
-  include ActionView::Helpers::TextHelper
-
-  def format(status, **options)
-    if status.respond_to?(:reblog?) && status.reblog?
-      prepend_reblog = status.reblog.account.acct
-      status         = status.proper
-    else
-      prepend_reblog = false
-    end
-
-    raw_content = status.text
-
-    if options[:inline_poll_options] && status.preloadable_poll
-      raw_content = raw_content + "\n\n" + status.preloadable_poll.options.map { |title| "[ ] #{title}" }.join("\n")
-    end
-
-    return '' if raw_content.blank?
-
-    unless status.local?
-      html = reformat(raw_content)
-      html = encode_custom_emojis(html, status.emojis, options[:autoplay]) if options[:custom_emojify]
-      return html.html_safe # rubocop:disable Rails/OutputSafety
-    end
-
-    linkable_accounts = status.respond_to?(:active_mentions) ? status.active_mentions.map(&:account) : []
-    linkable_accounts << status.account
-
-    html = raw_content
-    html = "RT @#{prepend_reblog} #{html}" if prepend_reblog
-    html = format_markdown(html) if status.content_type == 'text/markdown'
-    html = encode_and_link_urls(html, linkable_accounts, keep_html: %w(text/markdown text/html).include?(status.content_type))
-    html = reformat(html, true) if %w(text/markdown text/html).include?(status.content_type)
-    html = encode_custom_emojis(html, status.emojis, options[:autoplay]) if options[:custom_emojify]
-
-    unless %w(text/markdown text/html).include?(status.content_type)
-      html = simple_format(html, {}, sanitize: false)
-      html = html.delete("\n")
-    end
-
-    html.html_safe # rubocop:disable Rails/OutputSafety
-  end
-
-  def format_markdown(html)
-    html = markdown_formatter.render(html)
-    html.delete("\r").delete("\n")
-  end
-
-  def reformat(html, outgoing = false)
-    sanitize(html, Sanitize::Config::MASTODON_STRICT.merge(outgoing: outgoing))
-  rescue ArgumentError
-    ''
-  end
-
-  def plaintext(status)
-    return status.text if status.local?
-
-    text = status.text.gsub(/(<br \/>|<br>|<\/p>)+/) { |match| "#{match}\n" }
-    strip_tags(text)
-  end
-
-  def simplified_format(account, **options)
-    return '' if account.note.blank?
-
-    html = account.local? ? linkify(account.note) : reformat(account.note)
-    html = encode_custom_emojis(html, account.emojis, options[:autoplay]) if options[:custom_emojify]
-    html.html_safe # rubocop:disable Rails/OutputSafety
-  end
-
-  def sanitize(html, config)
-    Sanitize.fragment(html, config)
-  end
-
-  def format_spoiler(status, **options)
-    html = encode(status.spoiler_text)
-    html = encode_custom_emojis(html, status.emojis, options[:autoplay])
-    html.html_safe # rubocop:disable Rails/OutputSafety
-  end
-
-  def format_poll_option(status, option, **options)
-    html = encode(option.title)
-    html = encode_custom_emojis(html, status.emojis, options[:autoplay])
-    html.html_safe # rubocop:disable Rails/OutputSafety
-  end
-
-  def format_display_name(account, **options)
-    html = encode(account.display_name.presence || account.username)
-    html = encode_custom_emojis(html, account.emojis, options[:autoplay]) if options[:custom_emojify]
-    html.html_safe # rubocop:disable Rails/OutputSafety
-  end
-
-  def format_field(account, str, **options)
-    html = account.local? ? encode_and_link_urls(str, me: true, with_domain: true) : reformat(str)
-    html = encode_custom_emojis(html, account.emojis, options[:autoplay]) if options[:custom_emojify]
-    html.html_safe # rubocop:disable Rails/OutputSafety
-  end
-
-  def linkify(text)
-    html = encode_and_link_urls(text)
-    html = simple_format(html, {}, sanitize: false)
-    html = html.delete("\n")
-
-    html.html_safe # rubocop:disable Rails/OutputSafety
-  end
-
-  def link_url(url)
-    "<a href=\"#{encode(url)}\" target=\"blank\" rel=\"nofollow noopener noreferrer\">#{link_html(url)}</a>"
-  end
-
-  private
-
-  def markdown_formatter
-    extensions = {
-      autolink: true,
-      no_intra_emphasis: true,
-      fenced_code_blocks: true,
-      disable_indented_code_blocks: true,
-      strikethrough: true,
-      lax_spacing: true,
-      space_after_headers: true,
-      superscript: true,
-      underline: true,
-      highlight: true,
-      footnotes: false,
-    }
-
-    renderer = HTMLRenderer.new({
-      filter_html: false,
-      escape_html: false,
-      no_images: true,
-      no_styles: true,
-      safe_links_only: true,
-      hard_wrap: true,
-      link_attributes: { target: '_blank', rel: 'nofollow noopener' },
-    })
-
-    Redcarpet::Markdown.new(renderer, extensions)
-  end
-
-  def html_entities
-    @html_entities ||= HTMLEntities.new
-  end
-
-  def encode(html)
-    html_entities.encode(html)
-  end
-
-  def encode_and_link_urls(html, accounts = nil, options = {})
-    if accounts.is_a?(Hash)
-      options  = accounts
-      accounts = nil
-    end
-
-    entities = options[:keep_html] ? html_friendly_extractor(html) : utf8_friendly_extractor(html, extract_url_without_protocol: false)
-
-    rewrite(html.dup, entities, options[:keep_html]) do |entity|
-      if entity[:url]
-        link_to_url(entity, options)
-      elsif entity[:hashtag]
-        link_to_hashtag(entity)
-      elsif entity[:screen_name]
-        link_to_mention(entity, accounts, options)
-      end
-    end
-  end
-
-  def count_tag_nesting(tag)
-    if tag[1] == '/' then -1
-    elsif tag[-2] == '/' then 0
-    else 1
-    end
-  end
-
-  # rubocop:disable Metrics/BlockNesting
-  def encode_custom_emojis(html, emojis, animate = false)
-    return html if emojis.empty?
-
-    emoji_map = emojis.each_with_object({}) { |e, h| h[e.shortcode] = [full_asset_url(e.image.url), full_asset_url(e.image.url(:static))] }
-
-    i                     = -1
-    tag_open_index        = nil
-    inside_shortname      = false
-    shortname_start_index = -1
-    invisible_depth       = 0
-
-    while i + 1 < html.size
-      i += 1
-
-      if invisible_depth.zero? && inside_shortname && html[i] == ':'
-        shortcode = html[shortname_start_index + 1..i - 1]
-        emoji     = emoji_map[shortcode]
-
-        if emoji
-          original_url, static_url = emoji
-          replacement = begin
-            if animate
-              image_tag(original_url, draggable: false, class: 'emojione', alt: ":#{shortcode}:", title: ":#{shortcode}:")
-            else
-              image_tag(original_url, draggable: false, class: 'emojione custom-emoji', alt: ":#{shortcode}:", title: ":#{shortcode}:", data: { original: original_url, static: static_url })
-            end
-          end
-          before_html = shortname_start_index.positive? ? html[0..shortname_start_index - 1] : ''
-          html        = before_html + replacement + html[i + 1..-1]
-          i          += replacement.size - (shortcode.size + 2) - 1
-        else
-          i -= 1
-        end
-
-        inside_shortname = false
-      elsif tag_open_index && html[i] == '>'
-        tag = html[tag_open_index..i]
-        tag_open_index = nil
-        if invisible_depth.positive?
-          invisible_depth += count_tag_nesting(tag)
-        elsif tag == '<span class="invisible">'
-          invisible_depth = 1
-        end
-      elsif html[i] == '<'
-        tag_open_index   = i
-        inside_shortname = false
-      elsif !tag_open_index && html[i] == ':'
-        inside_shortname      = true
-        shortname_start_index = i
-      end
-    end
-
-    html
-  end
-  # rubocop:enable Metrics/BlockNesting
-
-  def rewrite(text, entities, keep_html = false)
-    text = text.to_s
-
-    # Sort by start index
-    entities = entities.sort_by do |entity|
-      indices = entity.respond_to?(:indices) ? entity.indices : entity[:indices]
-      indices.first
-    end
-
-    result = []
-
-    last_index = entities.reduce(0) do |index, entity|
-      indices = entity.respond_to?(:indices) ? entity.indices : entity[:indices]
-      result << (keep_html ? text[index...indices.first] : encode(text[index...indices.first]))
-      result << yield(entity)
-      indices.last
-    end
-
-    result << (keep_html ? text[last_index..-1] : encode(text[last_index..-1]))
-
-    result.flatten.join
-  end
-
-  def utf8_friendly_extractor(text, options = {})
-    # Note: I couldn't obtain list_slug with @user/list-name format
-    # for mention so this requires additional check
-    special = Extractor.extract_urls_with_indices(text, options)
-    standard = Extractor.extract_entities_with_indices(text, options)
-    extra = Extractor.extract_extra_uris_with_indices(text, options)
-
-    Extractor.remove_overlapping_entities(special + standard + extra)
-  end
-
-  def html_friendly_extractor(html, options = {})
-    gaps = []
-    total_offset = 0
-
-    escaped = html.gsub(/<[^>]*>|&#[0-9]+;/) do |match|
-      total_offset += match.length - 1
-      end_offset = Regexp.last_match.end(0)
-      gaps << [end_offset - total_offset, total_offset]
-      "\u200b"
-    end
-
-    entities = Extractor.extract_hashtags_with_indices(escaped, :check_url_overlap => false) +
-               Extractor.extract_mentions_or_lists_with_indices(escaped)
-    Extractor.remove_overlapping_entities(entities).map do |extract|
-      pos = extract[:indices].first
-      offset_idx = gaps.rindex { |gap| gap.first <= pos }
-      offset = offset_idx.nil? ? 0 : gaps[offset_idx].last
-      next extract.merge(
-        :indices => [extract[:indices].first + offset, extract[:indices].last + offset]
-      )
-    end
-  end
-
-  def link_to_url(entity, options = {})
-    url        = Addressable::URI.parse(entity[:url])
-    html_attrs = { target: '_blank', rel: 'nofollow noopener noreferrer' }
-
-    html_attrs[:rel] = "me #{html_attrs[:rel]}" if options[:me]
-
-    Twitter::TwitterText::Autolink.send(:link_to_text, entity, link_html(entity[:url]), url, html_attrs)
-  rescue Addressable::URI::InvalidURIError, IDN::Idna::IdnaError
-    encode(entity[:url])
-  end
-
-  def link_to_mention(entity, linkable_accounts, options = {})
-    acct = entity[:screen_name]
-
-    return link_to_account(acct, options) unless linkable_accounts
-
-    same_username_hits = 0
-    account = nil
-    username, domain = acct.split('@')
-    domain = nil if TagManager.instance.local_domain?(domain)
-
-    linkable_accounts.each do |item|
-      same_username = item.username.casecmp(username).zero?
-      same_domain   = item.domain.nil? ? domain.nil? : item.domain.casecmp(domain)&.zero?
-
-      if same_username && !same_domain
-        same_username_hits += 1
-      elsif same_username && same_domain
-        account = item
-      end
-    end
-
-    account ? mention_html(account, with_domain: same_username_hits.positive? || options[:with_domain]) : "@#{encode(acct)}"
-  end
-
-  def link_to_account(acct, options = {})
-    username, domain = acct.split('@')
-
-    domain  = nil if TagManager.instance.local_domain?(domain)
-    account = EntityCache.instance.mention(username, domain)
-
-    account ? mention_html(account, with_domain: options[:with_domain]) : "@#{encode(acct)}"
-  end
-
-  def link_to_hashtag(entity)
-    hashtag_html(entity[:hashtag])
-  end
-
-  def link_html(url)
-    url    = Addressable::URI.parse(url).to_s
-    prefix = url.match(/\A(https?:\/\/(www\.)?|xmpp:)/).to_s
-    text   = url[prefix.length, 30]
-    suffix = url[prefix.length + 30..-1]
-    cutoff = url[prefix.length..-1].length > 30
-
-    "<span class=\"invisible\">#{encode(prefix)}</span><span class=\"#{cutoff ? 'ellipsis' : ''}\">#{encode(text)}</span><span class=\"invisible\">#{encode(suffix)}</span>"
-  end
-
-  def hashtag_html(tag)
-    "<a href=\"#{encode(tag_url(tag))}\" class=\"mention hashtag\" rel=\"tag\">#<span>#{encode(tag)}</span></a>"
-  end
-
-  def mention_html(account, with_domain: false)
-    "<span class=\"h-card\"><a href=\"#{encode(ActivityPub::TagManager.instance.url_for(account))}\" class=\"u-url mention\">@<span>#{encode(with_domain ? account.pretty_acct : account.username)}</span></a></span>"
-  end
-end
diff --git a/app/lib/html_aware_formatter.rb b/app/lib/html_aware_formatter.rb
new file mode 100644
index 000000000..7a1cd0340
--- /dev/null
+++ b/app/lib/html_aware_formatter.rb
@@ -0,0 +1,42 @@
+# frozen_string_literal: true
+
+class HtmlAwareFormatter
+  attr_reader :text, :local, :options
+
+  alias local? local
+
+  # @param [String] text
+  # @param [Boolean] local
+  # @param [Hash] options
+  def initialize(text, local, options = {})
+    @text    = text
+    @local   = local
+    @options = options
+  end
+
+  def to_s
+    return ''.html_safe if text.blank?
+
+    if local?
+      linkify
+    else
+      reformat.html_safe # rubocop:disable Rails/OutputSafety
+    end
+  rescue ArgumentError
+    ''.html_safe
+  end
+
+  private
+
+  def reformat
+    Sanitize.fragment(text, Sanitize::Config::MASTODON_STRICT)
+  end
+
+  def linkify
+    if %w(text/markdown text/html).include?(@options[:content_type])
+      AdvancedTextFormatter.new(text, options).to_s
+    else
+      TextFormatter.new(text, options).to_s
+    end
+  end
+end
diff --git a/app/lib/link_details_extractor.rb b/app/lib/link_details_extractor.rb
index fabbd244d..b0c4e4f42 100644
--- a/app/lib/link_details_extractor.rb
+++ b/app/lib/link_details_extractor.rb
@@ -208,7 +208,7 @@ class LinkDetailsExtractor
   end
 
   def valid_url_or_nil(str, same_origin_only: false)
-    return if str.blank?
+    return if str.blank? || str == 'null'
 
     url = @original_url + Addressable::URI.parse(str)
 
diff --git a/app/lib/plain_text_formatter.rb b/app/lib/plain_text_formatter.rb
new file mode 100644
index 000000000..08aa29696
--- /dev/null
+++ b/app/lib/plain_text_formatter.rb
@@ -0,0 +1,30 @@
+# frozen_string_literal: true
+
+class PlainTextFormatter
+  include ActionView::Helpers::TextHelper
+
+  NEWLINE_TAGS_RE = /(<br \/>|<br>|<\/p>)+/.freeze
+
+  attr_reader :text, :local
+
+  alias local? local
+
+  def initialize(text, local)
+    @text  = text
+    @local = local
+  end
+
+  def to_s
+    if local?
+      text
+    else
+      strip_tags(insert_newlines).chomp
+    end
+  end
+
+  private
+
+  def insert_newlines
+    text.gsub(NEWLINE_TAGS_RE) { |match| "#{match}\n" }
+  end
+end
diff --git a/app/lib/rss/serializer.rb b/app/lib/rss/serializer.rb
index 7e3ed1f17..d44e94221 100644
--- a/app/lib/rss/serializer.rb
+++ b/app/lib/rss/serializer.rb
@@ -1,6 +1,8 @@
 # frozen_string_literal: true
 
 class RSS::Serializer
+  include FormattingHelper
+
   private
 
   def render_statuses(builder, statuses)
@@ -9,7 +11,7 @@ class RSS::Serializer
         item.title(status_title(status))
             .link(ActivityPub::TagManager.instance.url_for(status))
             .pub_date(status.created_at)
-            .description(status.spoiler_text.presence || Formatter.instance.format(status, inline_poll_options: true).to_str)
+            .description(status_description(status))
 
         status.ordered_media_attachments.each do |media|
           item.enclosure(full_asset_url(media.file.url(:original, false)), media.file.content_type, media.file.size)
@@ -19,9 +21,8 @@ class RSS::Serializer
   end
 
   def status_title(status)
-    return "#{status.account.acct} deleted status" if status.destroyed?
-
     preview = status.proper.spoiler_text.presence || status.proper.text
+
     if preview.length > 30 || preview[0, 30].include?("\n")
       preview = preview[0, 30]
       preview = preview[0, preview.index("\n").presence || 30] + '…'
@@ -35,4 +36,20 @@ class RSS::Serializer
       "#{status.account.acct}: #{preview}"
     end
   end
+
+  def status_description(status)
+    if status.proper.spoiler_text?
+      status.proper.spoiler_text
+    else
+      html = status_content_format(status.proper).to_str
+      after_html = ''
+
+      if status.proper.preloadable_poll
+        poll_options_html = status.proper.preloadable_poll.options.map { |o| "[ ] #{o}" }.join('<br />')
+        after_html = "<p>#{poll_options_html}</p>"
+      end
+
+      "#{html}#{after_html}"
+    end
+  end
 end
diff --git a/app/lib/text_formatter.rb b/app/lib/text_formatter.rb
new file mode 100644
index 000000000..48e2fc233
--- /dev/null
+++ b/app/lib/text_formatter.rb
@@ -0,0 +1,158 @@
+# frozen_string_literal: true
+
+class TextFormatter
+  include ActionView::Helpers::TextHelper
+  include ERB::Util
+  include RoutingHelper
+
+  URL_PREFIX_REGEX = /\A(https?:\/\/(www\.)?|xmpp:)/.freeze
+
+  DEFAULT_REL = %w(nofollow noopener noreferrer).freeze
+
+  DEFAULT_OPTIONS = {
+    multiline: true,
+  }.freeze
+
+  attr_reader :text, :options
+
+  # @param [String] text
+  # @param [Hash] options
+  # @option options [Boolean] :multiline
+  # @option options [Boolean] :with_domains
+  # @option options [Boolean] :with_rel_me
+  # @option options [Array<Account>] :preloaded_accounts
+  def initialize(text, options = {})
+    @text    = text
+    @options = DEFAULT_OPTIONS.merge(options)
+  end
+
+  def entities
+    @entities ||= Extractor.extract_entities_with_indices(text, extract_url_without_protocol: false)
+  end
+
+  def to_s
+    return ''.html_safe if text.blank?
+
+    html = rewrite do |entity|
+      if entity[:url]
+        link_to_url(entity)
+      elsif entity[:hashtag]
+        link_to_hashtag(entity)
+      elsif entity[:screen_name]
+        link_to_mention(entity)
+      end
+    end
+
+    html = simple_format(html, {}, sanitize: false).delete("\n") if multiline?
+
+    html.html_safe # rubocop:disable Rails/OutputSafety
+  end
+
+  private
+
+  def rewrite
+    entities.sort_by! do |entity|
+      entity[:indices].first
+    end
+
+    result = ''.dup
+
+    last_index = entities.reduce(0) do |index, entity|
+      indices = entity[:indices]
+      result << h(text[index...indices.first])
+      result << yield(entity)
+      indices.last
+    end
+
+    result << h(text[last_index..-1])
+
+    result
+  end
+
+  def link_to_url(entity)
+    url = Addressable::URI.parse(entity[:url]).to_s
+    rel = with_rel_me? ? (DEFAULT_REL + %w(me)) : DEFAULT_REL
+
+    prefix      = url.match(URL_PREFIX_REGEX).to_s
+    display_url = url[prefix.length, 30]
+    suffix      = url[prefix.length + 30..-1]
+    cutoff      = url[prefix.length..-1].length > 30
+
+    <<~HTML.squish
+      <a href="#{h(url)}" target="_blank" rel="#{rel.join(' ')}"><span class="invisible">#{h(prefix)}</span><span class="#{cutoff ? 'ellipsis' : ''}">#{h(display_url)}</span><span class="invisible">#{h(suffix)}</span></a>
+    HTML
+  rescue Addressable::URI::InvalidURIError, IDN::Idna::IdnaError
+    h(entity[:url])
+  end
+
+  def link_to_hashtag(entity)
+    hashtag = entity[:hashtag]
+    url     = tag_url(hashtag)
+
+    <<~HTML.squish
+      <a href="#{h(url)}" class="mention hashtag" rel="tag">#<span>#{h(hashtag)}</span></a>
+    HTML
+  end
+
+  def link_to_mention(entity)
+    username, domain = entity[:screen_name].split('@')
+    domain           = nil if local_domain?(domain)
+    account          = nil
+
+    if preloaded_accounts?
+      same_username_hits = 0
+
+      preloaded_accounts.each do |other_account|
+        same_username = other_account.username.casecmp(username).zero?
+        same_domain   = other_account.domain.nil? ? domain.nil? : other_account.domain.casecmp(domain)&.zero?
+
+        if same_username && !same_domain
+          same_username_hits += 1
+        elsif same_username && same_domain
+          account = other_account
+        end
+      end
+    else
+      account = entity_cache.mention(username, domain)
+    end
+
+    return "@#{h(entity[:screen_name])}" if account.nil?
+
+    url = ActivityPub::TagManager.instance.url_for(account)
+    display_username = same_username_hits&.positive? || with_domains? ? account.pretty_acct : account.username
+
+    <<~HTML.squish
+      <span class="h-card"><a href="#{h(url)}" class="u-url mention">@<span>#{h(display_username)}</span></a></span>
+    HTML
+  end
+
+  def entity_cache
+    @entity_cache ||= EntityCache.instance
+  end
+
+  def tag_manager
+    @tag_manager ||= TagManager.instance
+  end
+
+  delegate :local_domain?, to: :tag_manager
+
+  def multiline?
+    options[:multiline]
+  end
+
+  def with_domains?
+    options[:with_domains]
+  end
+
+  def with_rel_me?
+    options[:with_rel_me]
+  end
+
+  def preloaded_accounts
+    options[:preloaded_accounts]
+  end
+
+  def preloaded_accounts?
+    preloaded_accounts.present?
+  end
+end
diff --git a/app/mailers/application_mailer.rb b/app/mailers/application_mailer.rb
index cc585c3b7..a37682eca 100644
--- a/app/mailers/application_mailer.rb
+++ b/app/mailers/application_mailer.rb
@@ -5,6 +5,7 @@ class ApplicationMailer < ActionMailer::Base
 
   helper :application
   helper :instance
+  helper :formatting
 
   protected
 
diff --git a/app/models/account.rb b/app/models/account.rb
index dfbe0b8bc..1966c5a48 100644
--- a/app/models/account.rb
+++ b/app/models/account.rb
@@ -132,13 +132,13 @@ class Account < ApplicationRecord
            :approved?,
            :pending?,
            :disabled?,
+           :unconfirmed?,
            :unconfirmed_or_pending?,
            :role,
            :admin?,
            :moderator?,
            :staff?,
            :locale,
-           :hides_network?,
            :shows_application?,
            to: :user,
            prefix: true,
diff --git a/app/models/concerns/status_snapshot_concern.rb b/app/models/concerns/status_snapshot_concern.rb
new file mode 100644
index 000000000..c728db7c3
--- /dev/null
+++ b/app/models/concerns/status_snapshot_concern.rb
@@ -0,0 +1,36 @@
+# frozen_string_literal: true
+
+module StatusSnapshotConcern
+  extend ActiveSupport::Concern
+
+  included do
+    has_many :edits, class_name: 'StatusEdit', inverse_of: :status, dependent: :destroy
+  end
+
+  def edited?
+    edited_at.present?
+  end
+
+  def build_snapshot(account_id: nil, at_time: nil, rate_limit: true)
+    # We don't use `edits#new` here to avoid it having saved when the
+    # status is saved, since we want to control that manually
+
+    StatusEdit.new(
+      status_id: id,
+      text: text,
+      spoiler_text: spoiler_text,
+      sensitive: sensitive,
+      ordered_media_attachment_ids: ordered_media_attachment_ids&.dup || media_attachments.pluck(:id),
+      media_descriptions: ordered_media_attachments.map(&:description),
+      poll_options: preloadable_poll&.options&.dup,
+      account_id: account_id || self.account_id,
+      content_type: content_type,
+      created_at: at_time || edited_at,
+      rate_limit: rate_limit
+    )
+  end
+
+  def snapshot!(**options)
+    build_snapshot(**options).save!
+  end
+end
diff --git a/app/models/media_attachment.rb b/app/models/media_attachment.rb
index 4b38d729e..a5ce1e837 100644
--- a/app/models/media_attachment.rb
+++ b/app/models/media_attachment.rb
@@ -185,7 +185,7 @@ class MediaAttachment < ApplicationRecord
   remotable_attachment :thumbnail, IMAGE_LIMIT, suppress_errors: true, download_on_assign: false
 
   validates :account, presence: true
-  validates :description, length: { maximum: MAX_DESCRIPTION_LENGTH }, if: :local?
+  validates :description, length: { maximum: MAX_DESCRIPTION_LENGTH }
   validates :file, presence: true, if: :local?
   validates :thumbnail, absence: true, if: -> { local? && !audio_or_video? }
 
@@ -258,7 +258,6 @@ class MediaAttachment < ApplicationRecord
   after_commit :enqueue_processing, on: :create
   after_commit :reset_parent_cache, on: :update
 
-  before_create :prepare_description, unless: :local?
   before_create :set_unknown_type
   before_create :set_processing
 
@@ -306,10 +305,6 @@ class MediaAttachment < ApplicationRecord
     self.type = :unknown if file.blank? && !type_changed?
   end
 
-  def prepare_description
-    self.description = description.strip[0...MAX_DESCRIPTION_LENGTH] unless description.nil?
-  end
-
   def set_type_and_extension
     self.type = begin
       if VIDEO_MIME_TYPES.include?(file_content_type)
diff --git a/app/models/status.rb b/app/models/status.rb
index b979252b3..62f9e5831 100644
--- a/app/models/status.rb
+++ b/app/models/status.rb
@@ -37,6 +37,7 @@ class Status < ApplicationRecord
   include Paginable
   include Cacheable
   include StatusThreadingConcern
+  include StatusSnapshotConcern
   include RateLimitable
 
   rate_limit by: :account, family: :statuses
@@ -61,8 +62,6 @@ class Status < ApplicationRecord
   belongs_to :thread, foreign_key: 'in_reply_to_id', class_name: 'Status', inverse_of: :replies, optional: true
   belongs_to :reblog, foreign_key: 'reblog_of_id', class_name: 'Status', inverse_of: :reblogs, optional: true
 
-  has_many :edits, class_name: 'StatusEdit', inverse_of: :status, dependent: :destroy
-
   has_many :favourites, inverse_of: :status, dependent: :destroy
   has_many :bookmarks, inverse_of: :status, dependent: :destroy
   has_many :reblogs, foreign_key: 'reblog_of_id', class_name: 'Status', inverse_of: :reblog, dependent: :destroy
@@ -217,25 +216,6 @@ class Status < ApplicationRecord
     public_visibility? || unlisted_visibility?
   end
 
-  def snapshot!(account_id: nil, at_time: nil, rate_limit: true)
-    edits.create!(
-      text: text,
-      spoiler_text: spoiler_text,
-      sensitive: sensitive,
-      ordered_media_attachment_ids: ordered_media_attachment_ids || media_attachments.pluck(:id),
-      media_descriptions: ordered_media_attachments.map(&:description),
-      poll_options: preloadable_poll&.options,
-      account_id: account_id || self.account_id,
-      content_type: content_type,
-      created_at: at_time || edited_at,
-      rate_limit: rate_limit
-    )
-  end
-
-  def edited?
-    edited_at.present?
-  end
-
   alias sign? distributable?
 
   def with_media?
diff --git a/app/models/trends/query.rb b/app/models/trends/query.rb
index 64a4c0c1f..231b65228 100644
--- a/app/models/trends/query.rb
+++ b/app/models/trends/query.rb
@@ -37,7 +37,7 @@ class Trends::Query
   end
 
   def offset!(value)
-    @offset = value
+    @offset = value.to_i
     self
   end
 
@@ -46,7 +46,7 @@ class Trends::Query
   end
 
   def limit!(value)
-    @limit = value
+    @limit = value.to_i
     self
   end
 
diff --git a/app/models/user.rb b/app/models/user.rb
index f657f1b27..7c9ced6ae 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -208,8 +208,12 @@ class User < ApplicationRecord
     confirmed? && approved? && !disabled? && !account.suspended? && !account.memorial?
   end
 
+  def unconfirmed?
+    !confirmed?
+  end
+
   def unconfirmed_or_pending?
-    !(confirmed? && approved?)
+    unconfirmed? || pending?
   end
 
   def inactive_message
diff --git a/app/serializers/activitypub/actor_serializer.rb b/app/serializers/activitypub/actor_serializer.rb
index 48707aa16..e6dd8040e 100644
--- a/app/serializers/activitypub/actor_serializer.rb
+++ b/app/serializers/activitypub/actor_serializer.rb
@@ -2,6 +2,7 @@
 
 class ActivityPub::ActorSerializer < ActivityPub::Serializer
   include RoutingHelper
+  include FormattingHelper
 
   context :security
 
@@ -102,7 +103,7 @@ class ActivityPub::ActorSerializer < ActivityPub::Serializer
   end
 
   def summary
-    object.suspended? ? '' : Formatter.instance.simplified_format(object)
+    object.suspended? ? '' : account_bio_format(object)
   end
 
   def icon
@@ -185,6 +186,8 @@ class ActivityPub::ActorSerializer < ActivityPub::Serializer
   end
 
   class Account::FieldSerializer < ActivityPub::Serializer
+    include FormattingHelper
+
     attributes :type, :name, :value
 
     def type
@@ -192,7 +195,7 @@ class ActivityPub::ActorSerializer < ActivityPub::Serializer
     end
 
     def value
-      Formatter.instance.format_field(object.account, object.value)
+      account_field_value_format(object)
     end
   end
 
diff --git a/app/serializers/activitypub/note_serializer.rb b/app/serializers/activitypub/note_serializer.rb
index 05f2ee14f..ca067ed9b 100644
--- a/app/serializers/activitypub/note_serializer.rb
+++ b/app/serializers/activitypub/note_serializer.rb
@@ -1,6 +1,8 @@
 # frozen_string_literal: true
 
 class ActivityPub::NoteSerializer < ActivityPub::Serializer
+  include FormattingHelper
+
   context_extensions :atom_uri, :conversation, :sensitive, :voters_count, :direct_message
 
   attributes :id, :type, :summary,
@@ -50,11 +52,11 @@ class ActivityPub::NoteSerializer < ActivityPub::Serializer
   end
 
   def content
-    Formatter.instance.format(object)
+    status_content_format(object)
   end
 
   def content_map
-    { object.language => Formatter.instance.format(object) }
+    { object.language => content }
   end
 
   def replies
diff --git a/app/serializers/rest/account_serializer.rb b/app/serializers/rest/account_serializer.rb
index 36886181f..113e0cca7 100644
--- a/app/serializers/rest/account_serializer.rb
+++ b/app/serializers/rest/account_serializer.rb
@@ -2,6 +2,7 @@
 
 class REST::AccountSerializer < ActiveModel::Serializer
   include RoutingHelper
+  include FormattingHelper
 
   attributes :id, :username, :acct, :display_name, :locked, :bot, :discoverable, :group, :created_at,
              :note, :url, :avatar, :avatar_static, :header, :header_static,
@@ -14,10 +15,12 @@ class REST::AccountSerializer < ActiveModel::Serializer
   attribute :suspended, if: :suspended?
 
   class FieldSerializer < ActiveModel::Serializer
+    include FormattingHelper
+
     attributes :name, :value, :verified_at
 
     def value
-      Formatter.instance.format_field(object.account, object.value)
+      account_field_value_format(object)
     end
   end
 
@@ -32,7 +35,7 @@ class REST::AccountSerializer < ActiveModel::Serializer
   end
 
   def note
-    object.suspended? ? '' : Formatter.instance.simplified_format(object)
+    object.suspended? ? '' : account_bio_format(object)
   end
 
   def url
diff --git a/app/serializers/rest/announcement_serializer.rb b/app/serializers/rest/announcement_serializer.rb
index 9343b97d2..23b2fa514 100644
--- a/app/serializers/rest/announcement_serializer.rb
+++ b/app/serializers/rest/announcement_serializer.rb
@@ -1,6 +1,8 @@
 # frozen_string_literal: true
 
 class REST::AnnouncementSerializer < ActiveModel::Serializer
+  include FormattingHelper
+
   attributes :id, :content, :starts_at, :ends_at, :all_day,
              :published_at, :updated_at
 
@@ -25,7 +27,7 @@ class REST::AnnouncementSerializer < ActiveModel::Serializer
   end
 
   def content
-    Formatter.instance.linkify(object.text)
+    linkify(object.text)
   end
 
   def reactions
diff --git a/app/serializers/rest/status_edit_serializer.rb b/app/serializers/rest/status_edit_serializer.rb
index 05ccd5e94..f7a48797d 100644
--- a/app/serializers/rest/status_edit_serializer.rb
+++ b/app/serializers/rest/status_edit_serializer.rb
@@ -1,6 +1,8 @@
 # frozen_string_literal: true
 
 class REST::StatusEditSerializer < ActiveModel::Serializer
+  include FormattingHelper
+
   has_one :account, serializer: REST::AccountSerializer
 
   attributes :content, :spoiler_text, :sensitive, :created_at
@@ -11,7 +13,7 @@ class REST::StatusEditSerializer < ActiveModel::Serializer
   attribute :poll, if: -> { object.poll_options.present? }
 
   def content
-    Formatter.instance.format(object)
+    status_content_format(object)
   end
 
   def poll
diff --git a/app/serializers/rest/status_serializer.rb b/app/serializers/rest/status_serializer.rb
index 0a00ed77e..daa7de7ea 100644
--- a/app/serializers/rest/status_serializer.rb
+++ b/app/serializers/rest/status_serializer.rb
@@ -1,6 +1,8 @@
 # frozen_string_literal: true
 
 class REST::StatusSerializer < ActiveModel::Serializer
+  include FormattingHelper
+
   attributes :id, :created_at, :in_reply_to_id, :in_reply_to_account_id,
              :sensitive, :spoiler_text, :visibility, :language,
              :uri, :url, :replies_count, :reblogs_count,
@@ -73,7 +75,7 @@ class REST::StatusSerializer < ActiveModel::Serializer
   end
 
   def content
-    Formatter.instance.format(object)
+    status_content_format(object)
   end
 
   def url
diff --git a/app/services/activitypub/process_status_update_service.rb b/app/services/activitypub/process_status_update_service.rb
index 47a788c30..6dc14d8c2 100644
--- a/app/services/activitypub/process_status_update_service.rb
+++ b/app/services/activitypub/process_status_update_service.rb
@@ -4,6 +4,8 @@ class ActivityPub::ProcessStatusUpdateService < BaseService
   include JsonLdHelper
 
   def call(status, json)
+    raise ArgumentError, 'Status has unsaved changes' if status.changed?
+
     @json                      = json
     @status_parser             = ActivityPub::Parser::StatusParser.new(@json)
     @uri                       = @status_parser.uri
@@ -17,16 +19,19 @@ class ActivityPub::ProcessStatusUpdateService < BaseService
 
     last_edit_date = status.edited_at.presence || status.created_at
 
+    # Since we rely on tracking of previous changes, ensure clean slate
+    status.clear_changes_information
+
     # Only allow processing one create/update per status at a time
     RedisLock.acquire(lock_options) do |lock|
       if lock.acquired?
         Status.transaction do
-          create_previous_edit!
+          record_previous_edit!
           update_media_attachments!
           update_poll!
           update_immediate_attributes!
           update_metadata!
-          create_edit!
+          create_edits!
         end
 
         queue_poll_notifications!
@@ -216,19 +221,14 @@ class ActivityPub::ProcessStatusUpdateService < BaseService
     { redis: Redis.current, key: "create:#{@uri}", autorelease: 15.minutes.seconds }
   end
 
-  def create_previous_edit!
-    # We only need to create a previous edit when no previous edits exist, e.g.
-    # when the status has never been edited. For other cases, we always create
-    # an edit, so the step can be skipped
-
-    return if @status.edits.any?
-
-    @status.snapshot!(at_time: @status.created_at, rate_limit: false)
+  def record_previous_edit!
+    @previous_edit = @status.build_snapshot(at_time: @status.created_at, rate_limit: false) if @status.edits.empty?
   end
 
-  def create_edit!
+  def create_edits!
     return unless significant_changes?
 
+    @previous_edit&.save!
     @status.snapshot!(account_id: @account.id, rate_limit: false)
   end
 
diff --git a/app/services/fetch_link_card_service.rb b/app/services/fetch_link_card_service.rb
index 239ab9b93..9c8b5ea20 100644
--- a/app/services/fetch_link_card_service.rb
+++ b/app/services/fetch_link_card_service.rb
@@ -134,7 +134,7 @@ class FetchLinkCardService < BaseService
     when 'video'
       @card.width            = embed[:width].presence  || 0
       @card.height           = embed[:height].presence || 0
-      @card.html             = Formatter.instance.sanitize(embed[:html], Sanitize::Config::MASTODON_OEMBED)
+      @card.html             = Sanitize.fragment(embed[:html], Sanitize::Config::MASTODON_OEMBED)
       @card.image_remote_url = (url + embed[:thumbnail_url]).to_s if embed[:thumbnail_url].present?
     when 'rich'
       # Most providers rely on <script> tags, which is a no-no
diff --git a/app/services/update_status_service.rb b/app/services/update_status_service.rb
index f5155a2f5..cc4ec670d 100644
--- a/app/services/update_status_service.rb
+++ b/app/services/update_status_service.rb
@@ -4,6 +4,8 @@ class UpdateStatusService < BaseService
   include Redisable
   include LanguagesHelper
 
+  class NoChangesSubmittedError < StandardError; end
+
   # @param [Status] status
   # @param [Integer] account_id
   # @param [Hash] options
@@ -18,6 +20,8 @@ class UpdateStatusService < BaseService
     @status                    = status
     @options                   = options
     @account_id                = account_id
+    @media_attachments_changed = false
+    @poll_changed              = false
 
     Status.transaction do
       create_previous_edit!
@@ -33,18 +37,24 @@ class UpdateStatusService < BaseService
     broadcast_updates!
 
     @status
+  rescue NoChangesSubmittedError
+    # For calls that result in no changes, swallow the error
+    # but get back to the original state
+
+    @status.reload
   end
 
   private
 
   def update_media_attachments!
-    previous_media_attachments = @status.media_attachments.to_a
+    previous_media_attachments = @status.ordered_media_attachments.to_a
     next_media_attachments     = validate_media!
     added_media_attachments    = next_media_attachments - previous_media_attachments
 
     MediaAttachment.where(id: added_media_attachments.map(&:id)).update_all(status_id: @status.id)
 
     @status.ordered_media_attachment_ids = (@options[:media_ids] || []).map(&:to_i) & next_media_attachments.map(&:id)
+    @media_attachments_changed = previous_media_attachments.map(&:id) != @status.ordered_media_attachment_ids
     @status.media_attachments.reload
   end
 
@@ -70,20 +80,23 @@ class UpdateStatusService < BaseService
 
       # If for some reasons the options were changed, it invalidates all previous
       # votes, so we need to remove them
-      poll_changed = true if @options[:poll][:options] != poll.options || ActiveModel::Type::Boolean.new.cast(@options[:poll][:multiple]) != poll.multiple
+      @poll_changed = true if @options[:poll][:options] != poll.options || ActiveModel::Type::Boolean.new.cast(@options[:poll][:multiple]) != poll.multiple
 
       poll.options     = @options[:poll][:options]
       poll.hide_totals = @options[:poll][:hide_totals] || false
       poll.multiple    = @options[:poll][:multiple] || false
       poll.expires_in  = @options[:poll][:expires_in]
-      poll.reset_votes! if poll_changed
+      poll.reset_votes! if @poll_changed
       poll.save!
 
       @status.poll_id = poll.id
     elsif previous_poll.present?
       previous_poll.destroy
+      @poll_changed = true
       @status.poll_id = nil
     end
+
+    @poll_changed = true if @previous_expires_at != @status.preloadable_poll&.expires_at
   end
 
   def update_immediate_attributes!
@@ -92,8 +105,11 @@ class UpdateStatusService < BaseService
     @status.sensitive    = @options[:sensitive] || @options[:spoiler_text].present? if @options.key?(:sensitive) || @options.key?(:spoiler_text)
     @status.language     = valid_locale_cascade(@options[:language], @status.language, @status.account.user&.preferred_posting_language, I18n.default_locale)
     @status.content_type = @options[:content_type] || @status.content_type
-    @status.edited_at    = Time.now.utc
 
+    # We raise here to rollback the entire transaction
+    raise NoChangesSubmittedError unless significant_changes?
+
+    @status.edited_at = Time.now.utc
     @status.save!
   end
 
@@ -139,4 +155,8 @@ class UpdateStatusService < BaseService
   def create_edit!
     @status.snapshot!(account_id: @account_id)
   end
+
+  def significant_changes?
+    @status.changed? || @poll_changed || @media_attachments_changed
+  end
 end
diff --git a/app/views/accounts/_bio.html.haml b/app/views/accounts/_bio.html.haml
index e8a49a1aa..e2539b1d4 100644
--- a/app/views/accounts/_bio.html.haml
+++ b/app/views/accounts/_bio.html.haml
@@ -5,17 +5,17 @@
     .account__header__fields
       - fields.each do |field|
         %dl
-          %dt.emojify{ title: field.name }= Formatter.instance.format_field(account, field.name, custom_emojify: true)
+          %dt.emojify{ title: field.name }= prerender_custom_emojis(h(field.name), account.emojis)
           %dd{ title: field.value, class: custom_field_classes(field) }
             - if field.verified?
               %span.verified__mark{ title: t('accounts.link_verified_on', date: l(field.verified_at)) }
                 = fa_icon 'check'
-            = Formatter.instance.format_field(account, field.value, custom_emojify: true)
+            = prerender_custom_emojis(account_field_value_format(field), account.emojis)
 
   = account_badge(account)
 
   - if account.note.present?
-    .account__header__content.emojify= Formatter.instance.simplified_format(account, custom_emojify: true)
+    .account__header__content.emojify= prerender_custom_emojis(account_bio_format(account), account.emojis)
 
   .public-account-bio__extra
     = t 'accounts.joined', date: l(account.created_at, format: :month)
diff --git a/app/views/admin/accounts/_account.html.haml b/app/views/admin/accounts/_account.html.haml
index 2df91301e..82dd8dfb2 100644
--- a/app/views/admin/accounts/_account.html.haml
+++ b/app/views/admin/accounts/_account.html.haml
@@ -1,4 +1,4 @@
-.batch-table__row{ class: [!account.suspended? && account.user_pending? && 'batch-table__row--attention', account.suspended? && 'batch-table__row--muted'] }
+.batch-table__row{ class: [!account.suspended? && account.user_pending? && 'batch-table__row--attention', (account.suspended? || account.user_unconfirmed?) && 'batch-table__row--muted'] }
   %label.batch-table__row__select.batch-table__row__select--aligned.batch-checkbox
     = f.check_box :account_ids, { multiple: true, include_hidden: false }, account.id
   .batch-table__row__content.batch-table__row__content--unpadded
diff --git a/app/views/admin/accounts/show.html.haml b/app/views/admin/accounts/show.html.haml
index 805cf8a4f..1230294fe 100644
--- a/app/views/admin/accounts/show.html.haml
+++ b/app/views/admin/accounts/show.html.haml
@@ -16,16 +16,16 @@
         .account__header__fields
           - fields.each do |field|
             %dl
-              %dt.emojify{ title: field.name }= Formatter.instance.format_field(account, field.name, custom_emojify: true)
+              %dt.emojify{ title: field.name }= prerender_custom_emojis(h(field.name), account.emojis)
               %dd{ title: field.value, class: custom_field_classes(field) }
                 - if field.verified?
                   %span.verified__mark{ title: t('accounts.link_verified_on', date: l(field.verified_at)) }
                     = fa_icon 'check'
-                = Formatter.instance.format_field(account, field.value, custom_emojify: true)
+                = prerender_custom_emojis(account_field_value_format(field, with_rel_me: false), account.emojis)
 
     - if account.note.present?
       %div
-        .account__header__content.emojify= Formatter.instance.simplified_format(account, custom_emojify: true)
+        .account__header__content.emojify= prerender_custom_emojis(account_bio_format(account), account.emojis)
 
 .dashboard__counters.admin-account-counters
   %div
diff --git a/app/views/admin/instances/show.html.haml b/app/views/admin/instances/show.html.haml
index 9b2a46e7f..e8cc1c400 100644
--- a/app/views/admin/instances/show.html.haml
+++ b/app/views/admin/instances/show.html.haml
@@ -84,7 +84,8 @@
     - else
       %span.negative-hint
         = t('admin.instances.availability.failures_recorded', count: @instance.delivery_failure_tracker.days)
-        = link_to t('admin.instances.delivery.clear'), clear_delivery_errors_admin_instance_path(@instance), data: { confirm: t('admin.accounts.are_you_sure'), method: :post } unless @instance.exhausted_deliveries_days.empty?
+        %span= link_to t('admin.instances.delivery.clear'), clear_delivery_errors_admin_instance_path(@instance), data: { confirm: t('admin.accounts.are_you_sure'), method: :post } unless @instance.exhausted_deliveries_days.empty?
+        %span= link_to t('admin.instances.delivery.stop'), stop_delivery_admin_instance_path(@instance), data: { confirm: t('admin.accounts.are_you_sure'), method: :post }
 
 - if @instance.purgeable?
   %p= t('admin.instances.purge_description_html')
diff --git a/app/views/admin/reports/_status.html.haml b/app/views/admin/reports/_status.html.haml
index 7538cfd54..392fc8f81 100644
--- a/app/views/admin/reports/_status.html.haml
+++ b/app/views/admin/reports/_status.html.haml
@@ -4,12 +4,12 @@
   .batch-table__row__content
     .status__content><
       - if status.proper.spoiler_text.blank?
-        = Formatter.instance.format(status.proper, custom_emojify: true)
+        = prerender_custom_emojis(status_content_format(status.proper), status.proper.emojis)
       - else
         %details<
           %summary><
-            %strong> Content warning: #{Formatter.instance.format_spoiler(status.proper)}
-          = Formatter.instance.format(status.proper, custom_emojify: true)
+            %strong> Content warning: #{prerender_custom_emojis(h(status.proper.spoiler_text), status.proper.emojis)}
+          = prerender_custom_emojis(status_content_format(status.proper), status.proper.emojis)
 
     - unless status.proper.ordered_media_attachments.empty?
       - if status.proper.ordered_media_attachments.first.video?
diff --git a/app/views/admin/reports/show.html.haml b/app/views/admin/reports/show.html.haml
index 209fbb698..e5ea56779 100644
--- a/app/views/admin/reports/show.html.haml
+++ b/app/views/admin/reports/show.html.haml
@@ -23,7 +23,7 @@
             = fa_icon('lock') if @report.target_account.locked?
       - if @report.target_account.note.present?
         .account-card__bio.emojify
-          = Formatter.instance.simplified_format(@report.target_account, custom_emojify: true)
+          = prerender_custom_emojis(account_bio_format(@report.target_account), @report.target_account.emojis)
       .account-card__actions
         .account-card__counters
           .account-card__counters__item
diff --git a/app/views/admin/tags/show.html.haml b/app/views/admin/tags/show.html.haml
index c41ce2fc2..5ac57e1f2 100644
--- a/app/views/admin/tags/show.html.haml
+++ b/app/views/admin/tags/show.html.haml
@@ -8,7 +8,7 @@
 
 .dashboard
   .dashboard__item
-    = react_admin_component :counter, measure: 'tag_accounts', start_at: @time_period.first, end_at: @time_period.last, params: { id: @tag.id }, label: t('admin.trends.tags.dashboard.tag_accounts_measure')
+    = react_admin_component :counter, measure: 'tag_accounts', start_at: @time_period.first, end_at: @time_period.last, params: { id: @tag.id }, label: t('admin.trends.tags.dashboard.tag_accounts_measure'), href: tag_url(@tag), target: '_blank'
   .dashboard__item
     = react_admin_component :counter, measure: 'tag_uses', start_at: @time_period.first, end_at: @time_period.last, params: { id: @tag.id }, label: t('admin.trends.tags.dashboard.tag_uses_measure')
   .dashboard__item
diff --git a/app/views/directories/index.html.haml b/app/views/directories/index.html.haml
index 2ac700fe6..4872432d4 100644
--- a/app/views/directories/index.html.haml
+++ b/app/views/directories/index.html.haml
@@ -34,7 +34,7 @@
                 = fa_icon('lock') if account.locked?
         - if account.note.present?
           .account-card__bio.emojify
-            = Formatter.instance.simplified_format(account, custom_emojify: true)
+            = prerender_custom_emojis(account_bio_format(account), account.emojis)
         - else
           .flex-spacer
         .account-card__actions
diff --git a/app/views/disputes/strikes/show.html.haml b/app/views/disputes/strikes/show.html.haml
index 0fc32b918..0b71e14a3 100644
--- a/app/views/disputes/strikes/show.html.haml
+++ b/app/views/disputes/strikes/show.html.haml
@@ -26,7 +26,7 @@
         %p= t "user_mailer.warning.explanation.#{@strike.action}", instance: Rails.configuration.x.local_domain
 
       - unless @strike.text.blank?
-        = Formatter.instance.linkify(@strike.text)
+        = linkify(@strike.text)
 
       - if @strike.report && !@strike.report.other?
         %p
diff --git a/app/views/notification_mailer/_status.html.haml b/app/views/notification_mailer/_status.html.haml
index 219e7e667..444b06fe6 100644
--- a/app/views/notification_mailer/_status.html.haml
+++ b/app/views/notification_mailer/_status.html.haml
@@ -28,10 +28,10 @@
                               - if status.spoiler_text?
                                 %div.auto-dir
                                   %p
-                                    = Formatter.instance.format_spoiler(status)
+                                    = status.spoiler_text
 
                               %div.auto-dir
-                                = Formatter.instance.format(status)
+                                = status_content_format(status)
 
                                 - if status.ordered_media_attachments.size > 0
                                   %p
diff --git a/app/views/notification_mailer/_status.text.erb b/app/views/notification_mailer/_status.text.erb
index c43f32d9f..1dc8de739 100644
--- a/app/views/notification_mailer/_status.text.erb
+++ b/app/views/notification_mailer/_status.text.erb
@@ -3,6 +3,6 @@
 > ----
 >
 <% end %>
-> <%= raw word_wrap(Formatter.instance.plaintext(status), break_sequence: "\n> ") %>
+> <%= raw word_wrap(extract_status_plain_text(status), break_sequence: "\n> ") %>
 
 <%= raw t('application_mailer.view')%> <%= web_url("statuses/#{status.id}") %>
diff --git a/app/views/notification_mailer/digest.text.erb b/app/views/notification_mailer/digest.text.erb
index 4cd4190c1..0f84a4ef0 100644
--- a/app/views/notification_mailer/digest.text.erb
+++ b/app/views/notification_mailer/digest.text.erb
@@ -5,7 +5,7 @@
 
 * <%= raw t('notification_mailer.digest.mention', name: notification.from_account.pretty_acct) %>
 
-  <%= raw Formatter.instance.plaintext(notification.target_status) %>
+  <%= raw extract_status_plain_text(notification.target_status) %>
 
   <%= raw t('application_mailer.view')%> <%= web_url("statuses/#{notification.target_status.id}") %>
 <% end %>
diff --git a/app/views/statuses/_detailed_status.html.haml b/app/views/statuses/_detailed_status.html.haml
index fd7e034b1..1d0e5a38c 100644
--- a/app/views/statuses/_detailed_status.html.haml
+++ b/app/views/statuses/_detailed_status.html.haml
@@ -18,10 +18,11 @@
   .status__content.emojify{ :data => ({ spoiler: current_account&.user&.setting_expand_spoilers ? 'expanded' : 'folded' } if status.spoiler_text?) }<
     - if status.spoiler_text?
       %p<
-        %span.p-summary> #{Formatter.instance.format_spoiler(status, autoplay: prefers_autoplay?)}&nbsp;
+        %span.p-summary> #{prerender_custom_emojis(h(status.spoiler_text), status.emojis)}&nbsp;
         %button.status__content__spoiler-link= t('statuses.show_more')
     .e-content
-      = Formatter.instance.format(status, custom_emojify: true, autoplay: prefers_autoplay?)
+      = prerender_custom_emojis(status_content_format(status), status.emojis)
+
       - if status.preloadable_poll
         = render_poll_component(status)
 
diff --git a/app/views/statuses/_poll.html.haml b/app/views/statuses/_poll.html.haml
index 3546a923e..d0f264095 100644
--- a/app/views/statuses/_poll.html.haml
+++ b/app/views/statuses/_poll.html.haml
@@ -12,7 +12,7 @@
             %span.poll__number><
               = "#{percent.round}%"
             %span.poll__option__text
-              = Formatter.instance.format_poll_option(status, option, autoplay: prefers_autoplay?)
+              = prerender_custom_emojis(h(option.title), status.emojis)
             - if own_votes.include?(index)
               %span.poll__voted
                 %i.poll__voted__mark.fa.fa-check
@@ -23,7 +23,7 @@
           %label.poll__option><
             %span.poll__input{ class: poll.multiple? ? 'checkbox' : nil}><
             %span.poll__option__text
-              = Formatter.instance.format_poll_option(status, option, autoplay: prefers_autoplay?)
+              = prerender_custom_emojis(h(option.title), status.emojis)
   .poll__footer
     - unless show_results
       %button.button.button-secondary{ disabled: true }
diff --git a/app/views/statuses/_simple_status.html.haml b/app/views/statuses/_simple_status.html.haml
index a41656323..7b672bda7 100644
--- a/app/views/statuses/_simple_status.html.haml
+++ b/app/views/statuses/_simple_status.html.haml
@@ -30,10 +30,11 @@
   .status__content.emojify{ :data => ({ spoiler: current_account&.user&.setting_expand_spoilers ? 'expanded' : 'folded' } if status.spoiler_text?) }<
     - if status.spoiler_text?
       %p<
-        %span.p-summary> #{Formatter.instance.format_spoiler(status, autoplay: prefers_autoplay?)}&nbsp;
+        %span.p-summary> #{prerender_custom_emojis(h(status.spoiler_text), status.emojis)}&nbsp;
         %button.status__content__spoiler-link= t('statuses.show_more')
     .e-content<
-      = Formatter.instance.format(status, custom_emojify: true, autoplay: prefers_autoplay?)
+      = prerender_custom_emojis(status_content_format(status), status.emojis)
+
       - if status.preloadable_poll
         = render_poll_component(status)
 
diff --git a/app/views/user_mailer/warning.html.haml b/app/views/user_mailer/warning.html.haml
index b308e18f7..fff61fa90 100644
--- a/app/views/user_mailer/warning.html.haml
+++ b/app/views/user_mailer/warning.html.haml
@@ -40,7 +40,7 @@
                                 %p= t "user_mailer.warning.explanation.#{@warning.action}", instance: @instance
 
                               - unless @warning.text.blank?
-                                = Formatter.instance.linkify(@warning.text)
+                                = linkify(@warning.text)
 
                               - if @warning.report && !@warning.report.other?
                                 %p
diff --git a/config/environments/production.rb b/config/environments/production.rb
index ce3c41799..40fcd3bdd 100644
--- a/config/environments/production.rb
+++ b/config/environments/production.rb
@@ -91,11 +91,13 @@ Rails.application.configure do
 
   # E-mails
   outgoing_email_address = ENV.fetch('SMTP_FROM_ADDRESS', 'notifications@localhost')
-  outgoing_mail_domain   = Mail::Address.new(outgoing_email_address).domain
+  outgoing_email_domain  = Mail::Address.new(outgoing_email_address).domain
+
   config.action_mailer.default_options = {
     from: outgoing_email_address,
     reply_to: ENV['SMTP_REPLY_TO'],
-    'Message-ID': -> { "<#{Mail.random_tag}@#{outgoing_mail_domain}>" },
+    return_path: ENV['SMTP_RETURN_PATH'],
+    message_id: -> { "<#{Mail.random_tag}@#{outgoing_email_domain}>" },
   }
 
   config.action_mailer.smtp_settings = {
diff --git a/config/initializers/twitter_regex.rb b/config/initializers/twitter_regex.rb
index d2ea5f974..6a7723fd2 100644
--- a/config/initializers/twitter_regex.rb
+++ b/config/initializers/twitter_regex.rb
@@ -75,30 +75,4 @@ module Twitter::TwitterText
       )
     }iox
   end
-
-  module Extractor
-    # Extracts a list of all XMPP and magnet URIs included in the Toot <tt>text</tt> along
-    # with the indices. If the <tt>text</tt> is <tt>nil</tt> or contains no
-    # XMPP or magnet URIs an empty array will be returned.
-    #
-    # If a block is given then it will be called for each XMPP URI.
-    def extract_extra_uris_with_indices(text, _options = {}) # :yields: uri, start, end
-      return [] unless text && text.index(":")
-      urls = []
-
-      text.to_s.scan(Twitter::TwitterText::Regex[:valid_extended_uri]) do
-        valid_uri_match_data = $~
-
-        start_position = valid_uri_match_data.char_begin(3)
-        end_position = valid_uri_match_data.char_end(3)
-
-        urls << {
-          :url => valid_uri_match_data[3],
-          :indices => [start_position, end_position]
-        }
-      end
-      urls.each{|url| yield url[:url], url[:indices].first, url[:indices].last} if block_given?
-      urls
-    end
-  end
 end
diff --git a/config/locales/activerecord.nl.yml b/config/locales/activerecord.nl.yml
index 6bbdc5b40..b5a122001 100644
--- a/config/locales/activerecord.nl.yml
+++ b/config/locales/activerecord.nl.yml
@@ -24,7 +24,7 @@ nl:
         status:
           attributes:
             reblog:
-              taken: van toot bestaat al
+              taken: van bericht bestaat al
         user:
           attributes:
             email:
diff --git a/config/locales/ca.yml b/config/locales/ca.yml
index 054db2e5b..6c5fc95fe 100644
--- a/config/locales/ca.yml
+++ b/config/locales/ca.yml
@@ -490,6 +490,7 @@ ca:
           other: Intents fallits en %{count} diferents dies.
         no_failures_recorded: Sense errors registrats.
         title: Disponibilitat
+        warning: El darrer intent de connectar a aquest servidor no ha tingut èxit
       back_to_all: Totes
       back_to_limited: Limitades
       back_to_warning: Avís
diff --git a/config/locales/da.yml b/config/locales/da.yml
index 34e56ded4..b264dffae 100644
--- a/config/locales/da.yml
+++ b/config/locales/da.yml
@@ -490,6 +490,7 @@ da:
           other: Mislykkede forsøg på %{count} forskellige dage.
         no_failures_recorded: Ingen fejl noteret.
         title: Tilgængelighed
+        warning: Seneste forsøg på at oprette forbindelse til denne server mislykkedes
       back_to_all: Alle
       back_to_limited: Begrænset
       back_to_warning: Advarsel
diff --git a/config/locales/de.yml b/config/locales/de.yml
index 4768f4e80..d595fdd42 100644
--- a/config/locales/de.yml
+++ b/config/locales/de.yml
@@ -373,6 +373,7 @@ de:
       enable: Aktivieren
       enabled: Aktiviert
       enabled_msg: Das Emoji wurde aktiviert
+      image_hint: PNG oder GIF bis %{size}
       list: Liste
       listed: Gelistet
       new:
@@ -489,6 +490,7 @@ de:
           other: Fehlgeschlagener Versuch am %{count}. Tag.
         no_failures_recorded: Keine Fehler bei der Aufzeichnung.
         title: Verfügbarkeit
+        warning: Der letzte Versuch, sich mit diesem Server zu verbinden, war nicht erfolgreich
       back_to_all: Alle
       back_to_limited: Beschränkt
       back_to_warning: Warnung
@@ -1436,6 +1438,7 @@ de:
     disallowed_hashtags:
       one: 'enthält einen verbotenen Hashtag: %{tags}'
       other: 'enthält verbotene Hashtags: %{tags}'
+    edited_at_html: Bearbeitet %{date}
     errors:
       in_reply_not_found: Der Beitrag, auf den du antworten möchtest, scheint nicht zu existieren.
     open_in_web: Im Web öffnen
diff --git a/config/locales/doorkeeper.id.yml b/config/locales/doorkeeper.id.yml
index 050d97dc5..9a3fed94d 100644
--- a/config/locales/doorkeeper.id.yml
+++ b/config/locales/doorkeeper.id.yml
@@ -73,6 +73,10 @@ id:
       index:
         authorized_at: Diberi hak otorisasi pada %{date}
         description_html: Ini adalah aplikasi yang dapat mengakses akun Anda menggunakan API. Jika ada aplikasi yang tidak Anda kenal di sini, atau aplikasi yang berperilaku aneh, Anda dapat mencabut hak aksesnya.
+        last_used_at: Terakhir dipakai pada %{date}
+        never_used: Tidak pernah dipakai
+        scopes: Hak akses
+        superapp: Internal
         title: Aplikasi yang anda izinkan
     errors:
       messages:
@@ -108,6 +112,33 @@ id:
       authorized_applications:
         destroy:
           notice: Aplikasi dicabut.
+    grouped_scopes:
+      access:
+        read: Akses baca-saja
+        read/write: Akses baca dan tulis
+        write: Akses tulis-saja
+      title:
+        accounts: Akun
+        admin/accounts: Administrasi akun
+        admin/all: Semua fungsi administratif
+        admin/reports: Administrasi laporan
+        all: Segalanya
+        blocks: Blokir
+        bookmarks: Markah
+        conversations: Percakapan
+        crypto: Enkripsi end-to-end
+        favourites: Favorit
+        filters: Saringan
+        follow: Hubungan
+        follows: Mengikuti
+        lists: Daftar
+        media: Lampiran media
+        mutes: Bisukan
+        notifications: Notifikasi
+        push: Notifikasi dorong
+        reports: Laporan
+        search: Pencarian
+        statuses: Kiriman
     layouts:
       admin:
         nav:
@@ -122,6 +153,7 @@ id:
       admin:write: ubah semua data di server
       admin:write:accounts: lakukan aksi moderasi akun
       admin:write:reports: lakukan aksi moderasi laporan
+      crypto: menggunakan enkripsi end-to-end
       follow: mengikuti, blokir, menghapus blokir, dan berhenti mengikuti akun
       push: terima notifikasi dorong
       read: membaca data pada akun anda
@@ -141,6 +173,7 @@ id:
       write:accounts: ubah profil Anda
       write:blocks: blokir akun dan domain
       write:bookmarks: status markah
+      write:conversations: bisukan dan hapus percakapan
       write:favourites: status favorit
       write:filters: buat saringan
       write:follows: ikuti orang
diff --git a/config/locales/doorkeeper.ku.yml b/config/locales/doorkeeper.ku.yml
index 3a98486e3..6db8bb73d 100644
--- a/config/locales/doorkeeper.ku.yml
+++ b/config/locales/doorkeeper.ku.yml
@@ -130,7 +130,7 @@ ku:
         favourites: Bijarte
         filters: Parzûn
         follow: Pêwendî
-        follows: Şopîner
+        follows: Dişopîne
         lists: Rêzok
         media: Pêvekên medya
         mutes: Bêdengkirin
@@ -162,7 +162,7 @@ ku:
       read:bookmarks: şûnpelên xwe bibîne
       read:favourites: bijarteyên xwe bibîne
       read:filters: parzûnûn xwe bibîne
-      read:follows: şopînerên xwe bibîne
+      read:follows: ên tu dişopînî bibîne
       read:lists: rêzoka xwe bibîne
       read:mutes: ajimêrên bêdeng kirî bibîne
       read:notifications: agahdariyên xwe bibîne
diff --git a/config/locales/doorkeeper.nl.yml b/config/locales/doorkeeper.nl.yml
index cb0c70aab..76f3b88c3 100644
--- a/config/locales/doorkeeper.nl.yml
+++ b/config/locales/doorkeeper.nl.yml
@@ -60,6 +60,8 @@ nl:
       error:
         title: Er is een fout opgetreden
       new:
+        prompt_html: "%{client_name} heeft toestemming nodig om toegang te krijgen tot jouw account. Het betreft een third-party-toepassing.<strong>Als je dit niet vertrouwt, moet je geen toestemming verlenen.</strong>"
+        review_permissions: Toestemmingen beoordelen
         title: Autorisatie vereist
       show:
         title: Kopieer deze autorisatiecode en plak het in de toepassing.
@@ -69,6 +71,11 @@ nl:
       confirmations:
         revoke: Weet je het zeker?
       index:
+        authorized_at: Toestemming verleent op %{date}
+        last_used_at: Voor het laatst gebruikt op %{date}
+        never_used: Nooit gebruikt
+        scopes: Toestemmingen
+        superapp: Intern
         title: Jouw geautoriseerde toepassingen
     errors:
       messages:
@@ -104,6 +111,33 @@ nl:
       authorized_applications:
         destroy:
           notice: Toepassing ingetrokken.
+    grouped_scopes:
+      access:
+        read: Alleen leestoegang
+        read/write: Lees- en schrijftoegang
+        write: Alleen schrijftoegang
+      title:
+        accounts: Accounts
+        admin/accounts: Accountbeheer
+        admin/all: Alle beheerfuncties
+        admin/reports: Rapportagebeheer
+        all: Alles
+        blocks: Blokkeren
+        bookmarks: Bladwijzers
+        conversations: Gesprekken
+        crypto: End-to-end-encryptie
+        favourites: Favorieten
+        filters: Filters
+        follow: Relaties
+        follows: Volgend
+        lists: Lijsten
+        media: Mediabijlagen
+        mutes: Negeren
+        notifications: Meldingen
+        push: Pushmeldingen
+        reports: Rapportages
+        search: Zoeken
+        statuses: Berichten
     layouts:
       admin:
         nav:
@@ -118,6 +152,7 @@ nl:
       admin:write: wijzig alle gegevens op de server
       admin:write:accounts: moderatieacties op accounts uitvoeren
       admin:write:reports: moderatieacties op rapportages uitvoeren
+      crypto: end-to-end-encryptie gebruiken
       follow: relaties tussen accounts bewerken
       push: jouw pushmeldingen ontvangen
       read: alle gegevens van jouw account lezen
@@ -130,14 +165,15 @@ nl:
       read:lists: jouw lijsten bekijken
       read:mutes: jouw genegeerde gebruikers bekijken
       read:notifications: jouw meldingen bekijken
-      read:reports: jouw gerapporteerde toots bekijken
+      read:reports: jouw gerapporteerde berichten bekijken
       read:search: namens jou zoeken
-      read:statuses: alle toots bekijken
+      read:statuses: alle berichten bekijken
       write: alle gegevens van jouw account bewerken
       write:accounts: jouw profiel bewerken
       write:blocks: accounts en domeinen blokkeren
-      write:bookmarks: toots aan bladwijzers toevoegen
-      write:favourites: toots als favoriet markeren
+      write:bookmarks: berichten aan bladwijzers toevoegen
+      write:conversations: gespreken negeren en verwijderen
+      write:favourites: berichten als favoriet markeren
       write:filters: filters aanmaken
       write:follows: mensen volgen
       write:lists: lijsten aanmaken
@@ -145,4 +181,4 @@ nl:
       write:mutes: mensen en gesprekken negeren
       write:notifications: meldingen verwijderen
       write:reports: andere mensen rapporteren
-      write:statuses: toots publiceren
+      write:statuses: berichten plaatsen
diff --git a/config/locales/en.yml b/config/locales/en.yml
index a6ded38f7..829cd61d0 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -168,7 +168,6 @@ en:
       previous_strikes_description_html:
         one: This account has <strong>one</strong> strike.
         other: This account has <strong>%{count}</strong> strikes.
-        zero: This account is <strong>in good standing</strong>.
       promote: Promote
       protocol: Protocol
       public: Public
@@ -530,7 +529,6 @@ en:
       known_accounts:
         one: "%{count} known account"
         other: "%{count} known accounts"
-        zero: No known account
       moderation:
         all: All
         limited: Limited
@@ -775,6 +773,11 @@ en:
     system_checks:
       database_schema_check:
         message_html: There are pending database migrations. Please run them to ensure the application behaves as expected
+      elasticsearch_running_check:
+        message_html: Could not connect to Elasticsearch. Please check that it is running, or disable full-text search
+      elasticsearch_version_check:
+        message_html: 'Incompatible Elasticsearch version: %{value}'
+        version_comparison: Elasticsearch %{running_version} is running while %{required_version} is required
       rules_check:
         action: Manage server rules
         message_html: You haven't defined any server rules.
@@ -797,7 +800,6 @@ en:
         shared_by_over_week:
           one: Shared by one person over the last week
           other: Shared by %{count} people over the last week
-          zero: Shared by noone over the last week
         title: Trending links
         usage_comparison: Shared %{today} times today, compared to %{yesterday} yesterday
       pending_review: Pending review
@@ -840,7 +842,6 @@ en:
         used_by_over_week:
           one: Used by one person over the last week
           other: Used by %{count} people over the last week
-          zero: Used by noone over the last week
       title: Trends
     warning_presets:
       add_new: Add new
diff --git a/config/locales/es-AR.yml b/config/locales/es-AR.yml
index 7dee30a27..2acc958a7 100644
--- a/config/locales/es-AR.yml
+++ b/config/locales/es-AR.yml
@@ -490,6 +490,7 @@ es-AR:
           other: Intentos fallidos en %{count} días.
         no_failures_recorded: No hay fallos en el registro.
         title: Disponibilidad
+        warning: El último intento de conexión a este servidor no fue exitoso
       back_to_all: Todos
       back_to_limited: Limitados
       back_to_warning: Advertencia
diff --git a/config/locales/es.yml b/config/locales/es.yml
index bcce44e20..31970da48 100644
--- a/config/locales/es.yml
+++ b/config/locales/es.yml
@@ -490,6 +490,7 @@ es:
           other: Intentos fallidos en %{count} días diferentes.
         no_failures_recorded: No hay fallos en el registro.
         title: Disponibilidad
+        warning: El último intento de conexión a este servidor no ha tenido éxito
       back_to_all: Todos
       back_to_limited: Limitados
       back_to_warning: Advertencia
diff --git a/config/locales/gl.yml b/config/locales/gl.yml
index f3a4beb70..27642844c 100644
--- a/config/locales/gl.yml
+++ b/config/locales/gl.yml
@@ -490,6 +490,7 @@ gl:
           other: Intentos fallidos durante %{count} días distintos.
         no_failures_recorded: Non hai fallos rexistrados.
         title: Dispoñibilidade
+        warning: Fallou o último intento de conectar con este servidor
       back_to_all: Todo
       back_to_limited: Limitado
       back_to_warning: Aviso
diff --git a/config/locales/hu.yml b/config/locales/hu.yml
index 000184317..ec2d4fa94 100644
--- a/config/locales/hu.yml
+++ b/config/locales/hu.yml
@@ -492,6 +492,7 @@ hu:
           other: Sikertelen próbálkozás %{count} különböző napon.
         no_failures_recorded: Nem rögzítettünk hibát.
         title: Elérhetőség
+        warning: Sikertelen volt az utolsó csatlakozási próbálkozás ehhez a szerverhez
       back_to_all: Mind
       back_to_limited: Korlátozott
       back_to_warning: Figyelmeztetés
diff --git a/config/locales/id.yml b/config/locales/id.yml
index 97443b4a5..63b9066ce 100644
--- a/config/locales/id.yml
+++ b/config/locales/id.yml
@@ -477,6 +477,7 @@ id:
           other: Upaya gagal dalam %{count} hari berbeda.
         no_failures_recorded: Tidak ada kegagalan tercatat.
         title: Ketersediaan
+        warning: Upaya terakhir untuk menyambung ke server ini tidak berhasil
       back_to_all: Semua
       back_to_limited: Terbatas
       back_to_warning: Peringatan
diff --git a/config/locales/is.yml b/config/locales/is.yml
index 04d193975..92eb0e31e 100644
--- a/config/locales/is.yml
+++ b/config/locales/is.yml
@@ -490,6 +490,7 @@ is:
           other: Misheppnaðar tilraunir á %{count} mismunandi dögum.
         no_failures_recorded: Engar misheppnaðar tilraunir á skrá.
         title: Tiltækileiki
+        warning: Síðasta tilraun til að tengjast þessum netþjóni mistókst
       back_to_all: Allt
       back_to_limited: Takmarkað
       back_to_warning: Aðvörun
diff --git a/config/locales/it.yml b/config/locales/it.yml
index d96e58540..5af135fbc 100644
--- a/config/locales/it.yml
+++ b/config/locales/it.yml
@@ -490,6 +490,7 @@ it:
           other: Tentativo fallito %{count} giorni differenti.
         no_failures_recorded: Nessun fallimento registrato.
         title: Disponibilità
+        warning: L'ultimo tentativo di connessione a questo server non è riuscito
       back_to_all: Tutto
       back_to_limited: Limitato
       back_to_warning: Avviso
diff --git a/config/locales/ko.yml b/config/locales/ko.yml
index 7dacd90d3..19cabb1ce 100644
--- a/config/locales/ko.yml
+++ b/config/locales/ko.yml
@@ -481,6 +481,7 @@ ko:
           other: 실패한 전달 시도 총 %{count}일.
         no_failures_recorded: 실패 기록이 없습니다.
         title: 가용성
+        warning: 이 서버에 대한 마지막 연결 시도가 성공적이지 않았습니다
       back_to_all: 전체
       back_to_limited: 제한됨
       back_to_warning: 경고
diff --git a/config/locales/ku.yml b/config/locales/ku.yml
index 5245f85ec..7dac9ae44 100644
--- a/config/locales/ku.yml
+++ b/config/locales/ku.yml
@@ -133,7 +133,7 @@ ku:
       enabled: Çalakkirî
       enabled_msg: Ajimêra %{username} bi serkeftî hat çalak kirin
       followers: Şopîner
-      follows: Dişopînê
+      follows: Dişopîne
       header: Jormalper
       inbox_url: Peyamên hatî URl
       invite_request_text: Sedemên tevlêbûnê
@@ -492,6 +492,7 @@ ku:
           other: Hewldanên têkçûyî di %{count} rojên cuda de.
         no_failures_recorded: Di tomarê de têkçûn tune.
         title: Berdestbûnî
+        warning: Hewldana dawî ji bo girêdana bi vê rajekarê re bi ser neket
       back_to_all: Hemû
       back_to_limited: Sînorkirî
       back_to_warning: Hişyarî
@@ -543,7 +544,7 @@ ku:
       title: Giştî
       total_blocked_by_us: Ji aliyê me ve hatiye astengkirin
       total_followed_by_them: Ji aliyê wan ve hatiye şopandin
-      total_followed_by_us: Ji aliyê ve me hate şopandin
+      total_followed_by_us: Ji aliyê me ve hatiye şopandin
       total_reported: Giliyên derheqê wan de
       total_storage: Pêvekên medyayê
       totals_time_period_hint_html: Tevahiyên ku li jêr têne xuyakirin daneyên hemû deman dihewîne.
@@ -975,7 +976,7 @@ ku:
       close: An jî, tu dikarî tenê ev çarçoveyê bigirî.
       return: Profîla vê bikarhênerê nîşan bike
       web: Biçe tevneyê
-    title: Bişopîne %{acct}
+    title: "%{acct} bişopîne"
   challenge:
     confirm: Bidomîne
     hint_html: "<strong>Nîşe:</strong>Ji bo demjimêreke din em ê pêborîna te careke din ji te nexwazin."
@@ -1247,7 +1248,7 @@ ku:
     follow:
       body: "%{name} niha te dişopîne!"
       subject: "%{name} niha te dişopîne"
-      title: Şopînereke nû
+      title: Şopînera nû
     follow_request:
       action: Daxwazên şopandinê bi rê ve bibe
       body: "%{name} daxwaza şopandina te kir"
@@ -1416,7 +1417,7 @@ ku:
     notifications: Agahdarî
     preferences: Hilbijarte
     profile: Profîl
-    relationships: Yên tê şopandin û şopîner
+    relationships: Şopandin û şopîner
     statuses_cleanup: Bi xweberî ve jêbirina şandiya
     strikes: Binpêkirinên çavdêriyê
     two_factor_authentication: Piştrastkirinê du-faktorî
diff --git a/config/locales/lv.yml b/config/locales/lv.yml
index 8f6df9961..3440da7ef 100644
--- a/config/locales/lv.yml
+++ b/config/locales/lv.yml
@@ -501,6 +501,7 @@ lv:
           zero: Neizdevušies mēģinājumi %{count} dienās.
         no_failures_recorded: Nav reģistrētu kļūdu.
         title: Pieejamība
+        warning: Pēdējais mēģinājums izveidot savienojumu ar šo serveri ir bijis neveiksmīgs
       back_to_all: Visas
       back_to_limited: Ierobežotās
       back_to_warning: Brīdinājums
diff --git a/config/locales/nl.yml b/config/locales/nl.yml
index a51ef07af..276fdb9b2 100644
--- a/config/locales/nl.yml
+++ b/config/locales/nl.yml
@@ -1,7 +1,7 @@
 ---
 nl:
   about:
-    about_hashtag_html: Dit zijn openbare toots die getagged zijn met <strong>#%{hashtag}</strong>. Je kunt er op reageren of iets anders mee doen als je op Mastodon (of ergens anders in de fediverse) een account hebt.
+    about_hashtag_html: Dit zijn openbare berichten die getagged zijn met <strong>#%{hashtag}</strong>. Je kunt er op reageren of iets anders mee doen als je op Mastodon (of ergens anders in de fediverse) een account hebt.
     about_mastodon_html: Mastodon is een sociaal netwerk dat gebruikt maakt van open webprotocollen en vrije software. Het is net zoals e-mail gedecentraliseerd.
     about_this: Over deze server
     active_count_after: actief
@@ -31,7 +31,7 @@ nl:
     source_code: Broncode
     status_count_after:
       one: toot
-      other: toots
+      other: berichten
     status_count_before: Zij schreven
     tagline: Vrienden volgen en nieuwe ontdekken
     terms: Gebruiksvoorwaarden
@@ -41,7 +41,7 @@ nl:
       reason: 'Reden:'
       rejecting_media: 'Mediabestanden van deze server worden niet verwerkt en er worden geen thumbnails getoond. Je moet handmatig naar deze server doorklikken om de mediabestanden te kunnen bekijken:'
       rejecting_media_title: Mediabestanden geweigerd
-      silenced: Toots van deze server worden nergens weergegeven, behalve op jouw eigen starttijdlijn wanneer je het account volgt.
+      silenced: Berichten van deze server worden nergens weergegeven, behalve op jouw eigen starttijdlijn wanneer je het account volgt.
       silenced_title: Beperkte servers
       suspended: Je bent niet in staat om iemand van deze server te volgen, en er worden geen gegevens van deze server verwerkt of opgeslagen, en met deze server uitgewisseld.
       suspended_title: Opgeschorte servers
@@ -74,9 +74,9 @@ nl:
       following: Je moet dit account wel al volgen, alvorens je het kan aanbevelen
     posts:
       one: Toot
-      other: Toots
-    posts_tab_heading: Toots
-    posts_with_replies: Toots en reacties
+      other: Berichten
+    posts_tab_heading: Berichten
+    posts_with_replies: Berichten en reacties
     roles:
       admin: Beheerder
       bot: Bot
@@ -193,7 +193,7 @@ nl:
         targeted_reports: Door anderen gerapporteerd
       silence: Beperken
       silenced: Beperkt
-      statuses: Toots
+      statuses: Berichten
       subscribe: Abonneren
       suspended: Opgeschort
       suspension_irreversible: De gegevens van dit account zijn onomkeerbaar verwijderd. Je kunt het opschorten van dit account ongedaan maken zodat het weer valt te gebruiken, maar de verwijderde gegevens worden hiermee niet hersteld.
@@ -229,7 +229,7 @@ nl:
         destroy_custom_emoji: Lokale emoji verwijderen
         destroy_domain_allow: Domeingoedkeuring verwijderen
         destroy_domain_block: Domeinblokkade verwijderen
-        destroy_email_domain_block: E-maildomeinblokkade verwijderen
+        destroy_email_domain_block: Blokkade van e-maildomein verwijderen
         destroy_ip_block: IP-regel verwijderen
         destroy_status: Toot verwijderen
         destroy_unavailable_domain: Niet beschikbaar domein verwijderen
@@ -245,16 +245,16 @@ nl:
         reset_password_user: Wachtwoord opnieuw instellen
         resolve_report: Rapportage oplossen
         sensitive_account: De media in jouw account als gevoelig markeren
-        silence_account: Account negeren
+        silence_account: Account beperken
         suspend_account: Account opschorten
         unassigned_report: Rapportage niet langer toewijzen
         unsensitive_account: De media in jouw account niet langer als gevoelig markeren
-        unsilence_account: Account niet langer negeren
+        unsilence_account: Account niet langer beperken
         unsuspend_account: Account niet langer opschorten
         update_announcement: Mededeling bijwerken
         update_custom_emoji: Lokale emoji bijwerken
         update_domain_block: Domeinblokkade bijwerken
-        update_status: Toot bijwerken
+        update_status: Bericht bijwerken
       actions:
         assigned_to_self_report_html: "%{name} heeft rapportage %{target} aan zichzelf toegewezen"
         change_email_user_html: "%{name} veranderde het e-mailadres van gebruiker %{target}"
@@ -274,7 +274,7 @@ nl:
         destroy_domain_block_html: Domein %{target} is door %{name} gedeblokkeerd
         destroy_email_domain_block_html: "%{name} heeft het e-maildomein %{target} gedeblokkeerd"
         destroy_ip_block_html: "%{name} verwijderde regel voor IP %{target}"
-        destroy_status_html: Toot van %{target} is door %{name} verwijderd
+        destroy_status_html: Bericht van %{target} is door %{name} verwijderd
         destroy_unavailable_domain_html: "%{name} heeft de bezorging voor domein %{target} hervat"
         disable_2fa_user_html: De vereiste tweestapsverificatie voor %{target} is door %{name} uitgeschakeld
         disable_custom_emoji_html: Emoji %{target} is door %{name} uitgeschakeld
@@ -297,8 +297,8 @@ nl:
         update_announcement_html: "%{name} heeft de mededeling %{target} bijgewerkt"
         update_custom_emoji_html: Emoji %{target} is door %{name} bijgewerkt
         update_domain_block_html: "%{name} heeft de domeinblokkade bijgewerkt voor %{target}"
-        update_status_html: "%{name} heeft de toots van %{target} bijgewerkt"
-      deleted_status: "(verwijderde toot}"
+        update_status_html: "%{name} heeft de berichten van %{target} bijgewerkt"
+      deleted_status: "(verwijderd bericht}"
       empty: Geen logs gevonden.
       filter_by_action: Op actie filteren
       filter_by_user: Op gebruiker filteren
@@ -461,11 +461,11 @@ nl:
     relays:
       add_new: Nieuwe relayserver toevoegen
       delete: Verwijderen
-      description_html: Een <strong>federatierelay</strong> is een tussenliggende server die grote hoeveelheden openbare toots uitwisselt tussen servers die zich hierop hebben geabonneerd. <strong>Het kan kleine en middelgrote servers helpen om content uit de fediverse te ontdekken</strong>, waarvoor anders lokale gebruikers handmatig mensen van externe servers moeten volgen.
+      description_html: Een <strong>federatierelay</strong> is een tussenliggende server die grote hoeveelheden openbare berichten uitwisselt tussen servers die zich hierop hebben geabonneerd. <strong>Het kan kleine en middelgrote servers helpen om content van de fediverse te ontdekken</strong>, waarvoor anders lokale gebruikers handmatig mensen van externe servers moeten volgen.
       disable: Uitschakelen
       disabled: Uitgeschakeld
       enable: Inschakelen
-      enable_hint: Eenmaal ingeschakeld gaat jouw server zich op alle openbare toots van deze relayserver abonneren en stuurt het de openbare toots van jouw server naar de relayserver.
+      enable_hint: Eenmaal ingeschakeld gaat jouw server zich op alle openbare berichten van deze relayserver abonneren en stuurt het de openbare berichten van jouw server naar de relayserver.
       enabled: Ingeschakeld
       inbox_url: Relay-URL
       pending: Aan het wachten op toestemming van de relayserver
@@ -506,7 +506,7 @@ nl:
       reported_by: Gerapporteerd door
       resolved: Opgelost
       resolved_msg: Rapportage succesvol opgelost!
-      status: Toot
+      status: Bericht
       title: Rapportages
       unassign: Niet langer toewijzen
       unresolved: Onopgelost
@@ -520,7 +520,7 @@ nl:
       title: Serverregels
     settings:
       activity_api_enabled:
-        desc_html: Wekelijks overzicht van de hoeveelheid lokale toots, actieve gebruikers en nieuwe registraties
+        desc_html: Wekelijks overzicht van de hoeveelheid lokale berichten, actieve gebruikers en nieuwe registraties
         title: Statistieken over gebruikersactiviteit via de API publiceren
       bootstrap_timeline_accounts:
         desc_html: Meerdere gebruikersnamen met komma's scheiden. Deze accounts worden in ieder geval aan nieuwe gebruikers aanbevolen
@@ -533,7 +533,7 @@ nl:
         title: Aangepaste CSS
       default_noindex:
         desc_html: Heeft invloed op alle gebruikers die deze instelling niet zelf hebben veranderd
-        title: Toots van gebruikers standaard niet door zoekmachines laten indexeren
+        title: Berichten van gebruikers standaard niet door zoekmachines laten indexeren
       domain_blocks:
         all: Aan iedereen
         disabled: Aan niemand
@@ -561,7 +561,7 @@ nl:
           desc_html: Wordt op de voorpagina weergegeven wanneer registratie van nieuwe accounts is uitgeschakeld<br>En ook hier kan je HTML gebruiken
           title: Bericht wanneer registratie is uitgeschakeld
         deletion:
-          desc_html: Toestaan dat iedereen hun eigen account kan verwijderen
+          desc_html: Toestaan dat iedereen diens eigen account kan verwijderen
           title: Verwijderen account toestaan
         min_invite_role:
           disabled: Niemand
@@ -606,7 +606,7 @@ nl:
         title: Hashtags toestaan om trending te worden zonder voorafgaande beoordeling
       trends:
         desc_html: Eerder beoordeelde hashtags die op dit moment trending zijn openbaar tonen
-        title: Trending hashtags
+        title: Trends
     site_uploads:
       delete: Geüpload bestand verwijderen
       destroyed_msg: Verwijderen website-upload geslaagd!
@@ -615,8 +615,8 @@ nl:
       deleted: Verwijderd
       media:
         title: Media
-      no_status_selected: Er werden geen toots gewijzigd, omdat er geen enkele werd geselecteerd
-      title: Toots van account
+      no_status_selected: Er werden geen berichten gewijzigd, omdat er geen enkele werd geselecteerd
+      title: Berichten van account
       with_media: Met media
     system_checks:
       database_schema_check:
@@ -662,14 +662,14 @@ nl:
       guide_link: https://crowdin.com/project/mastodon/nl
       guide_link_text: Iedereen kan bijdragen.
     sensitive_content: Gevoelige inhoud
-    toot_layout: Lay-out van toots
+    toot_layout: Lay-out van berichten
   application_mailer:
     notification_preferences: E-mailvoorkeuren wijzigen
     salutation: "%{name},"
     settings: 'E-mailvoorkeuren wijzigen: %{link}'
     view: 'Bekijk:'
     view_profile: Profiel bekijken
-    view_status: Toot bekijken
+    view_status: Bericht bekijken
   applications:
     created: Aanmaken toepassing geslaagd
     destroyed: Verwijderen toepassing geslaagd
@@ -768,8 +768,8 @@ nl:
     success_msg: Jouw account is succesvol verwijderd
     warning:
       before: 'Lees deze tekst zorgvuldig voordat je verder gaat:'
-      caches: Toots en media die op andere servers zijn opgeslagen kunnen daar achterblijven
-      data_removal: Jouw toots en andere gegevens worden permanent verwijderd
+      caches: Berichten en media die op andere servers zijn opgeslagen kunnen daar achterblijven
+      data_removal: Jouw berichten en andere gegevens worden permanent verwijderd
       email_change_html: Je kunt <a href="%{path}">je e-mailadres wijzigen</a> zonder dat je jouw account hoeft te verwijderen
       email_contact_html: Wanneer het nog steeds niet aankomt, kun je voor hulp e-mailen naar <a href="mailto:%{email}">%{email}</a>
       email_reconfirmation_html: Wanneer je de bevestigingsmail niet hebt ontvangen, kun je deze <a href="%{path}">opnieuw aanvragen</a>
@@ -805,7 +805,7 @@ nl:
     archive_takeout:
       date: Datum
       download: Jouw archief downloaden
-      hint_html: Je kunt een archief opvragen van jouw <strong>toots en geüploade media</strong>. De geëxporteerde gegevens zijn in het ActivityPub-formaat, dat door hiervoor geschikte software valt uit te lezen. Je kunt elke 7 dagen een kopie van je archief aanvragen.
+      hint_html: Je kunt een archief opvragen van jouw <strong>berichten en geüploade media</strong>. De geëxporteerde gegevens zijn in het ActivityPub-formaat, dat door hiervoor geschikte software valt uit te lezen. Je kunt elke 7 dagen een kopie van je archief aanvragen.
       in_progress: Jouw archief wordt samengesteld...
       request: Jouw archief opvragen
       size: Omvang
@@ -820,7 +820,7 @@ nl:
     add_new: Nieuwe toevoegen
     errors:
       limit: Je hebt al het maximaal aantal hashtags uitgelicht
-    hint_html: "<strong>Wat zijn uitgelichte hashtags?</strong> Deze worden prominent op jouw openbare profiel getoond en stelt mensen in staat om jouw openbare toots per hashtag te bekijken. Het zijn een goed hulpmiddel om creatieve werkzaamheden of langetermijnprojecten bij te houden."
+    hint_html: "<strong>Wat zijn uitgelichte hashtags?</strong> Deze worden prominent op jouw openbare profiel getoond en stelt mensen in staat om jouw openbare berichten per hashtag te bekijken. Het zijn een goed hulpmiddel om creatieve werkzaamheden of langetermijnprojecten bij te houden."
   filters:
     contexts:
       account: Profielen
@@ -901,7 +901,7 @@ nl:
       limit: Je hebt het maximaal aantal lijsten bereikt
   media_attachments:
     validations:
-      images_and_video: Een video kan niet aan een toot met afbeeldingen worden gekoppeld
+      images_and_video: Een video kan niet aan een bericht met afbeeldingen worden gekoppeld
       not_ready: Kan geen bestanden toevoegen die nog niet zijn verwerkt. Probeer het later opnieuw!
       too_many: Er kunnen niet meer dan 4 afbeeldingen toegevoegd worden
   migrations:
@@ -954,8 +954,8 @@ nl:
         other: "%{count} nieuwe meldingen sinds jouw laatste bezoek \U0001F418"
       title: Tijdens jouw afwezigheid...
     favourite:
-      body: 'Jouw toot werd door %{name} aan hun favorieten toegevoegd:'
-      subject: "%{name} voegde jouw toot als favoriet toe"
+      body: 'Jouw bericht werd door %{name} aan diens favorieten toegevoegd:'
+      subject: "%{name} voegde jouw bericht als favoriet toe"
       title: Nieuwe favoriet
     follow:
       body: "%{name} volgt jou nu!"
@@ -974,11 +974,11 @@ nl:
     poll:
       subject: Een poll van %{name} is beëindigd
     reblog:
-      body: 'Jouw toot werd door %{name} geboost:'
-      subject: "%{name} boostte jouw toot"
+      body: 'Jouw bericht werd door %{name} geboost:'
+      subject: "%{name} boostte jouw bericht"
       title: Nieuwe boost
     status:
-      subject: "%{name} heeft zojuist een toot geplaatst"
+      subject: "%{name} heeft zojuist een bericht geplaatst"
   notifications:
     email_events: E-mailmeldingen voor gebeurtenissen
     email_events_hint: 'Selecteer gebeurtenissen waarvoor je meldingen wilt ontvangen:'
@@ -997,7 +997,7 @@ nl:
     code_hint: Voer de code in die door de authenticatie-app werd gegenereerd
     description_html: Na het instellen van <strong>tweestapsverificatie</strong> met een authenticatie-app, kun je alleen inloggen als je jouw mobiele telefoon bij je hebt. Hiermee genereer je namelijk de in te voeren toegangscode.
     enable: Inschakelen
-    instructions_html: "<strong>Scan deze QR-code in Google Authenticator of een soortgelijke app op jouw mobiele telefoon</strong>. Van nu af aan genereert deze app toegangscodes die je bij het inloggen moet invoeren."
+    instructions_html: "<strong>Scan deze QR-code in Google Authenticator of een soortgelijke app op jouw mobiele telefoon</strong>. Vanaf nu genereert deze app toegangscodes die je bij het inloggen moet invoeren."
     manual_instructions: 'Voor het geval je de QR-code niet kunt scannen en het handmatig moet invoeren, vind je hieronder de geheime code in platte tekst:'
     setup: Instellen
     wrong_code: De ingevoerde code is ongeldig! Klopt de systeemtijd van de server en die van jouw apparaat?
@@ -1053,17 +1053,17 @@ nl:
   remote_interaction:
     favourite:
       proceed: Doorgaan met toevoegen aan jouw favorieten
-      prompt: 'Je wilt de volgende toot aan jouw favorieten toevoegen:'
+      prompt: 'Je wilt het volgende bericht aan jouw favorieten toevoegen:'
     reblog:
       proceed: Doorgaan met boosten
-      prompt: 'Je wilt de volgende toot boosten:'
+      prompt: 'Je wilt het volgende bericht boosten:'
     reply:
       proceed: Doorgaan met reageren
-      prompt: 'Je wilt op de volgende toot reageren:'
+      prompt: 'Je wilt op het volgende bericht reageren:'
   scheduled_statuses:
-    over_daily_limit: Je hebt de limiet van %{limit} in te plannen toots voor die dag overschreden
-    over_total_limit: Je hebt de limiet van %{limit} in te plannen toots overschreden
-    too_soon: De datum voor de ingeplande toot moet in de toekomst liggen
+    over_daily_limit: Je hebt de limiet van %{limit} in te plannen berichten voor vandaag overschreden
+    over_total_limit: Je hebt de limiet van %{limit} in te plannen berichten overschreden
+    too_soon: De datum voor het ingeplande bericht moet in de toekomst liggen
   sessions:
     activity: Laatst actief
     browser: Webbrowser
@@ -1093,7 +1093,7 @@ nl:
       adobe_air: Adobe Air
       android: Android
       blackberry: Blackberry
-      chrome_os: ChromeOS
+      chrome_os: Chrome OS
       firefox_os: Firefox OS
       ios: iOS
       linux: Linux
@@ -1144,12 +1144,12 @@ nl:
       one: 'bevatte een niet toegestane hashtag: %{tags}'
       other: 'bevatte niet toegestane hashtags: %{tags}'
     errors:
-      in_reply_not_found: De toot waarop je probeert te reageren lijkt niet te bestaan.
+      in_reply_not_found: Het bericht waarop je probeert te reageren lijkt niet te bestaan.
     open_in_web: In de webapp openen
     over_character_limit: Limiet van %{max} tekens overschreden
     pin_errors:
-      limit: Je hebt het maximaal aantal toots al vastgezet
-      ownership: Een toot van iemand anders kan niet worden vastgezet
+      limit: Je hebt het maximaal aantal bericht al vastgemaakt
+      ownership: Een bericht van iemand anders kan niet worden vastgemaakt
       reblog: Een boost kan niet worden vastgezet
     poll:
       total_people:
@@ -1174,7 +1174,7 @@ nl:
       unlisted: Minder openbaar
       unlisted_long: Aan iedereen tonen, maar niet op openbare tijdlijnen
   stream_entries:
-    pinned: Vastgemaakte toot
+    pinned: Vastgemaakt bericht
     reblogged: boostte
     sensitive_content: Gevoelige inhoud
   tags:
@@ -1332,7 +1332,7 @@ nl:
     otp_lost_help_html: Als je toegang tot beiden kwijt bent geraakt, neem dan contact op via %{email}
     seamless_external_login: Je bent ingelogd via een externe dienst, daarom zijn wachtwoorden en e-mailinstellingen niet beschikbaar.
     signed_in_as: 'Ingelogd als:'
-    suspicious_sign_in_confirmation: Het lijkt er op dat je nog niet eerder op dit apparaat bent ingelogd, en je bent een tijdje niet ingelogd, dus sturen we een beveiligingscode naar je e-mailadres om te bevestigen dat jij het bent.
+    suspicious_sign_in_confirmation: Het lijkt er op dat je nog niet eerder op dit apparaat bent ingelogd, dus sturen we een beveiligingscode naar jouw e-mailadres om te bevestigen dat jij het bent.
   verification:
     explanation_html: 'Je kunt <strong>jezelf verifiëren als de eigenaar van de links in de metadata van jouw profiel</strong>. Hiervoor moet op de gelinkte website een link terug naar jouw Mastodonprofiel staan. Deze link <strong>moet</strong> het <code>rel="me"</code>-attribuut bevatten. De omschrijving van de link maakt niet uit. Hier is een voorbeeld:'
     verification: Verificatie
diff --git a/config/locales/pt-PT.yml b/config/locales/pt-PT.yml
index 0fdfff517..348545cbf 100644
--- a/config/locales/pt-PT.yml
+++ b/config/locales/pt-PT.yml
@@ -490,6 +490,7 @@ pt-PT:
           other: Tentativas em %{count} dias diferentes.
         no_failures_recorded: Sem falhas registadas.
         title: Disponibilidade
+        warning: A última tentativa de conectar a este servidor não foi bem sucedida
       back_to_all: Todas
       back_to_limited: Limitadas
       back_to_warning: Aviso
diff --git a/config/locales/ru.yml b/config/locales/ru.yml
index 579ea6462..4440f3336 100644
--- a/config/locales/ru.yml
+++ b/config/locales/ru.yml
@@ -480,6 +480,7 @@ ru:
       availability:
         no_failures_recorded: Сбоев в записи нет.
         title: Доступность
+        warning: Последняя попытка подключения к этому серверу не удалась
       back_to_all: Все узлы
       back_to_limited: Все ограниченные узлы
       back_to_warning: Все узлы требующие внимания
@@ -742,6 +743,7 @@ ru:
         none: "%{name} отправил(а) предупреждение %{target}"
         sensitive: "%{name} отметил(а) учетную запись %{target} как деликатную"
         silence: "%{name} ограничил(а) учетную запись %{target}"
+      appeal_approved: Обжаловано
       appeal_pending: Обжалование в обработке
     system_checks:
       database_schema_check:
@@ -806,6 +808,8 @@ ru:
       empty: Вы еще не определили пресеты предупреждений.
       title: Управление шаблонами предупреждений
   admin_mailer:
+    new_appeal:
+      subject: "%{username} обжалует решение модерации на %{instance}"
     new_pending_account:
       body: Ниже указана информация учётной записи. Вы можете одобрить или отклонить заявку.
       subject: Новая учётная запись для рассмотрения на %{instance} (%{username})
diff --git a/config/locales/simple_form.fa.yml b/config/locales/simple_form.fa.yml
index 679d7d8fd..7fdd3b7a8 100644
--- a/config/locales/simple_form.fa.yml
+++ b/config/locales/simple_form.fa.yml
@@ -27,6 +27,8 @@ fa:
         scheduled_at: برای انتشار فوری اعلامیه، خالی بگذارید
         starts_at: اختیاری. در صورتی که اعلامیه‌تان محدود به بازهٔ زمانی خاصی است
         text: می‌توانید مانند یک بوق‌ معمولی بنویسید. یادتان باشد که اعلامیهٔ شما فضای صفحهٔ کاربران را اشغال خواهد کرد
+      appeal:
+        text: فقط یک بار می‌توانید برای اخطار اعتراض کنید
       defaults:
         autofollow: کسانی که از راه دعوت‌نامه عضو می‌شوند به طور خودکار پیگیر شما خواهند شد
         avatar: یکی از قالب‌های PNG یا  GIF یا JPG. بیشترین اندازه %{size}. تصویر به اندازهٔ %{dimensions} پیکسل تبدیل خواهد شد
@@ -35,6 +37,7 @@ fa:
         current_password: به دلایل امنیتی لطفاً رمز این حساب را وارد کنید
         current_username: برای تأیید، لطفاً نام کاربری حساب فعلی را وارد کنید
         digest: تنها وقتی فرستاده می‌شود که مدتی طولانی فعالیتی نداشته باشید و در این مدت برای شما پیغام خصوصی‌ای نوشته شده باشد
+        discoverable: اجازه دهید حساب‌تان از طریق پیشنهادها، پرطرفدارها و سایر قابلیت‌ها، توسط افراد غریبه قابل کشف باشد
         email: به شما ایمیل تأییدی فرستاده خواهد شد
         fields: شما می‌توانید تا چهار مورد را در یک جدول در نمایهٔ خود نمایش دهید
         header: یکی از قالب‌های PNG یا  GIF یا JPG. بیشترین اندازه %{size}. تصویر به اندازهٔ %{dimensions} پیکسل تبدیل خواهد شد
@@ -60,6 +63,7 @@ fa:
       domain_allow:
         domain: این دامین خواهد توانست داده‌ها از این سرور را دریافت کند و داده‌های از این دامین در این‌جا پردازش و ذخیره خواهند شد
       email_domain_block:
+        domain: این می‌تواند نام دامنه‌ای باشد که در نشانی رایانامه یا رکورد MX استفاده می‌شود. پس از ثبت نام بررسی خواهند شد.
         with_dns_records: تلاشی برای resolve کردن رکوردهای ساناد دامنهٔ داده‌شده انجام شده و نتیجه نیز مسدود خواهد شد
       featured_tag:
         name: 'شاید بخواهید چنین چیزهایی را به کار ببرید:'
@@ -116,6 +120,8 @@ fa:
         scheduled_at: زمان‌بندی انتشار
         starts_at: آغاز رویداد
         text: اعلامیه
+      appeal:
+        text: توضیح دهید که چرا این تصمیم باید معکوس شود
       defaults:
         autofollow: دعوت از دیگران برای عضو شدن و پیگیری حساب شما
         avatar: تصویر نمایه
@@ -194,6 +200,7 @@ fa:
           sign_up_requires_approval: محدود کردن ثبت نام‌ها
         severity: قانون
       notification_emails:
+        appeal: شخصی به تصمیم ناظر اعتراض کرد
         digest: فرستادن رایانامه‌های خلاصه
         favourite: وقتی کسی نوشتهٔ شما را پسندید ایمیل بفرست
         follow: وقتی کسی پیگیر شما شد ایمیل بفرست
@@ -201,6 +208,8 @@ fa:
         mention: وقتی کسی از شما نام برد ایمیل بفرست
         pending_account: وقتی حساب تازه‌ای نیاز به بازبینی داشت ایمیل بفرست
         reblog: وقتی کسی نوشتهٔ شما را بازبوقید ایمیل بفرست
+        report: گزارش جدیدی فرستاده شد
+        trending_tag: روند جدیدی نیازمند بازبینی است
       rule:
         text: قانون
       tag:
diff --git a/config/locales/simple_form.ku.yml b/config/locales/simple_form.ku.yml
index 4f38ae030..dcf723591 100644
--- a/config/locales/simple_form.ku.yml
+++ b/config/locales/simple_form.ku.yml
@@ -144,7 +144,7 @@ ku:
         inbox_url: URLya guhêzkera wergirtî
         irreversible: Li şûna veşartinê jê bibe
         locale: Zimanê navrûyê
-        locked: Ajimêr qefl bike
+        locked: Ajimêr kilît bike
         max_uses: Hejmara bikaranîna herî zêde
         new_password: Pêborîna nû
         note: Jiyanname
diff --git a/config/locales/simple_form.nl.yml b/config/locales/simple_form.nl.yml
index 13d86443c..33968b508 100644
--- a/config/locales/simple_form.nl.yml
+++ b/config/locales/simple_form.nl.yml
@@ -7,18 +7,18 @@ nl:
       account_migration:
         acct: Vul de gebruikersnaam@domein van het account in, waarnaartoe je wilt verhuizen
       account_warning_preset:
-        text: Je kunt voor toots specifieke tekst gebruiken, zoals URL's, hashtags en vermeldingen
+        text: Je kunt specifieke tekst voor berichten gebruiken, zoals URL's, hashtags en vermeldingen
         title: Optioneel. Niet zichtbaar voor de ontvanger
       admin_account_action:
-        include_statuses: De gebruiker ziet welke toots verantwoordelijk zijn voor de moderatieactie of waarschuwing
-        send_email_notification: De gebruiker ontvangt een uitleg over wat er met hun account is gebeurd
-        text_html: Optioneel. Je kunt voor toots specifieke tekst gebruiken. Om tijd te besparen kun je <a href="%{path}">presets voor waarschuwingen toevoegen</a>
+        include_statuses: De gebruiker ziet welke berichten verantwoordelijk zijn voor de moderatieactie of waarschuwing
+        send_email_notification: De gebruiker ontvangt een uitleg over wat er met diens account is gebeurd
+        text_html: Optioneel. Je kunt specifieke tekst voor berichten gebruiken. Om tijd te besparen kun je <a href="%{path}">presets voor waarschuwingen toevoegen</a>
         type_html: Kies wat er met <strong>%{acct}</strong> moet gebeuren
         types:
-          disable: Voorkom dat de gebruiker hun account gebruikt, maar verwijder of verberg de inhoud niet.
+          disable: Voorkom dat de gebruiker diens account gebruikt, maar verwijder of verberg de inhoud niet.
           none: Gebruik dit om een waarschuwing naar de gebruiker te sturen, zonder dat nog een andere actie wordt uitgevoerd.
           sensitive: Forceer dat alle mediabijlagen van deze gebruiker als gevoelig worden gemarkeerd.
-          silence: Voorkom dat de gebruiker openbare toots kan versturen, verberg hun toots en meldingen voor mensen die hen niet volgen.
+          silence: Voorkom dat de gebruiker openbare berichten kan versturen, verberg diens berichten en meldingen voor mensen die diegene niet volgen.
           suspend: Alle interacties van en met dit account blokkeren en de inhoud verwijderen. Dit kan binnen dertig dagen worden teruggedraaid.
         warning_preset_id: Optioneel. Je kunt nog steeds handmatig tekst toevoegen aan het eind van de voorinstelling
       announcement:
@@ -26,7 +26,7 @@ nl:
         ends_at: Optioneel. De publicatie van de mededeling wordt op dit tijdstip automatisch beëindigd
         scheduled_at: Laat leeg om de mededeling meteen te publiceren
         starts_at: Optioneel. In het geval dat jouw mededeling aan een bepaald tijdvak is gebonden
-        text: Je kunt voor toots specifieke tekst gebruiken. Let op de ruimte die de mededeling op het scherm van de gebruiker inneemt
+        text: Je kunt specifieke tekst voor berichten gebruiken. Let op de ruimte die de mededeling op het scherm van de gebruiker inneemt
       defaults:
         autofollow: Mensen die zich via de uitnodiging hebben geregistreerd, volgen jou automatisch
         avatar: PNG, GIF of JPG. Maximaal %{size}. Wordt teruggeschaald naar %{dimensions}px
@@ -39,20 +39,20 @@ nl:
         fields: Je kan maximaal 4 items als een tabel op je profiel weergeven
         header: PNG, GIF of JPG. Maximaal %{size}. Wordt teruggeschaald naar %{dimensions}px
         inbox_url: Kopieer de URL van de voorpagina van de relayserver die je wil gebruiken
-        irreversible: Gefilterde toots verdwijnen onomkeerbaar, zelfs als de filter later wordt verwijderd
+        irreversible: Gefilterde berichten verdwijnen onomkeerbaar, zelfs als de filter later wordt verwijderd
         locale: De taal van de gebruikersomgeving, e-mails en pushmeldingen
         locked: Door het goedkeuren van volgers handmatig bepalen wie jou mag volgen
         password: Gebruik tenminste 8 tekens
         phrase: Komt overeen ongeacht hoofd-/kleine letters of een inhoudswaarschuwing
         scopes: Tot welke API's heeft de toepassing toegang. Wanneer je een toestemming van het bovenste niveau kiest, hoef je geen individuele toestemmingen meer te kiezen.
-        setting_aggregate_reblogs: Geen nieuwe boosts tonen voor toots die recentelijk nog zijn geboost (heeft alleen effect op nieuw ontvangen boosts)
+        setting_aggregate_reblogs: Geen nieuwe boosts tonen voor berichten die recentelijk nog zijn geboost (heeft alleen effect op nieuw ontvangen boosts)
         setting_default_sensitive: Gevoelige media wordt standaard verborgen en kan met één klik worden getoond
         setting_display_media_default: Als gevoelig gemarkeerde media verbergen
         setting_display_media_hide_all: Media altijd verbergen
         setting_display_media_show_all: Media altijd tonen
         setting_hide_network: Wie jij volgt en wie jou volgen wordt niet op jouw profiel getoond
-        setting_noindex: Heeft invloed op jouw openbare profiel en toots
-        setting_show_application: De toepassing de je gebruikt om te tooten wordt in de gedetailleerde weergave van de toot getoond
+        setting_noindex: Heeft invloed op jouw openbare profiel en pagina's met berichten
+        setting_show_application: De toepassing de je gebruikt om berichten te plaatsen wordt in de gedetailleerde weergave van het bericht getoond
         setting_use_blurhash: Wazige kleurovergangen zijn gebaseerd op de kleuren van de verborgen media, waarmee elk detail verdwijnt
         setting_use_pending_items: De tijdlijn wordt bijgewerkt door op het aantal nieuwe items te klikken, in plaats van dat deze automatisch wordt bijgewerkt
         username: Jouw gebruikersnaam is uniek op %{domain}
@@ -85,7 +85,7 @@ nl:
       tag:
         name: Je kunt elk woord met een hoofdletter beginnen, om zo bijvoorbeeld de tekst leesbaarder te maken
       user:
-        chosen_languages: Alleen toots in de aangevinkte talen worden op de openbare tijdlijnen getoond
+        chosen_languages: Alleen berichten in de aangevinkte talen worden op de openbare tijdlijnen getoond
     labels:
       account:
         fields:
@@ -99,7 +99,7 @@ nl:
         text: Tekst van preset
         title: Titel
       admin_account_action:
-        include_statuses: Gerapporteerde toots aan de e-mail toevoegen
+        include_statuses: Gerapporteerde berichten aan de e-mail toevoegen
         send_email_notification: Meld dit per e-mail aan de gebruiker
         text: Aangepaste waarschuwing
         type: Actie
@@ -146,22 +146,22 @@ nl:
         setting_advanced_layout: Geavanceerde webomgeving inschakelen
         setting_aggregate_reblogs: Boosts in tijdlijnen groeperen
         setting_auto_play_gif: Speel geanimeerde GIF's automatisch af
-        setting_boost_modal: Vraag voor het boosten van een toot een bevestiging
-        setting_crop_images: Afbeeldingen bijsnijden tot 16x9 in toots op tijdlijnen
-        setting_default_language: Taal van jouw toots
-        setting_default_privacy: Standaardzichtbaarheid van jouw toots
+        setting_boost_modal: Vraag voor het boosten van een bericht een bevestiging
+        setting_crop_images: Afbeeldingen bijsnijden tot 16x9 in berichten op tijdlijnen
+        setting_default_language: Taal van jouw berichten
+        setting_default_privacy: Zichtbaarheid van nieuwe berichten
         setting_default_sensitive: Media altijd als gevoelig markeren
-        setting_delete_modal: Vraag voor het verwijderen van een toot een bevestiging
+        setting_delete_modal: Vraag voor het verwijderen van een bericht een bevestiging
         setting_disable_swiping: Swipebewegingen uitschakelen
         setting_display_media: Mediaweergave
         setting_display_media_default: Standaard
         setting_display_media_hide_all: Alles verbergen
         setting_display_media_show_all: Alles tonen
-        setting_expand_spoilers: Altijd toots met inhoudswaarschuwingen uitklappen
+        setting_expand_spoilers: Altijd berichten met inhoudswaarschuwingen uitklappen
         setting_hide_network: Jouw volgers en wie je volgt verbergen
-        setting_noindex: Jouw toots niet door zoekmachines laten indexeren
+        setting_noindex: Jouw berichten niet door zoekmachines laten indexeren
         setting_reduce_motion: Langzamere animaties
-        setting_show_application: Toepassing onthullen die je voor het verzenden van toots gebruikt
+        setting_show_application: Toepassing onthullen die je voor het verzenden van berichten gebruikt
         setting_system_font_ui: Standaardlettertype van jouw systeem gebruiken
         setting_theme: Thema website
         setting_trends: Trends van vandaag tonen
@@ -195,19 +195,19 @@ nl:
         severity: Regel
       notification_emails:
         digest: Periodiek e-mails met een samenvatting versturen
-        favourite: Wanneer iemand jouw toot aan hun favorieten heeft toegevoegd
+        favourite: Wanneer iemand jouw bericht aan diens favorieten heeft toegevoegd
         follow: Wanneer iemand jou is gaan volgen
         follow_request: Wanneer iemand jou wil volgen
         mention: Wanneer iemand jou heeft vermeld
         pending_account: Wanneer een nieuw account moet worden beoordeeld
-        reblog: Wanneer iemand jouw toot heeft geboost
+        reblog: Wanneer iemand jouw bericht heeft geboost
       rule:
         text: Regel
       tag:
         listable: Toestaan dat deze hashtag in zoekopdrachten en aanbevelingen te zien valt
         name: Hashtag
         trendable: Toestaan dat deze hashtag onder trends te zien valt
-        usable: Toestaan dat deze hashtag in toots gebruikt mag worden
+        usable: Toestaan dat deze hashtag in berichten gebruikt mag worden
     'no': Nee
     recommended: Aanbevolen
     required:
diff --git a/config/locales/simple_form.th.yml b/config/locales/simple_form.th.yml
index 6127cb9d5..b954b50fe 100644
--- a/config/locales/simple_form.th.yml
+++ b/config/locales/simple_form.th.yml
@@ -74,7 +74,7 @@ th:
         text: นี่จะช่วยให้เราตรวจทานใบสมัครของคุณ
       ip_block:
         comment: ไม่จำเป็น จดจำเหตุผลที่คุณเพิ่มกฎนี้
-        ip: ป้อนที่อยู่ IPv4 หรือ IPv6 คุณสามารถปิดกั้นทั้งช่วงได้โดยใช้ไวยากรณ์ CIDR ระวังอย่าล็อคตัวเองออก!
+        ip: ป้อนที่อยู่ IPv4 หรือ IPv6 คุณสามารถปิดกั้นทั้งช่วงได้โดยใช้ไวยากรณ์ CIDR ระวังอย่าล็อคตัวคุณเองออก!
         severities:
           no_access: ปิดกั้นการเข้าถึงทรัพยากรทั้งหมด
           sign_up_requires_approval: การลงทะเบียนใหม่จะต้องมีการอนุมัติของคุณ
diff --git a/config/locales/sv.yml b/config/locales/sv.yml
index 40337ce69..441044516 100644
--- a/config/locales/sv.yml
+++ b/config/locales/sv.yml
@@ -404,6 +404,8 @@ sv:
       status: Status
       title: Följ rekommendationer
     instances:
+      availability:
+        warning: Det senaste försöket att ansluta till denna värddator har misslyckats
       back_to_all: Alla
       back_to_limited: Begränsat
       back_to_warning: Varning
diff --git a/config/locales/th.yml b/config/locales/th.yml
index beafc4da4..d6248b7a8 100644
--- a/config/locales/th.yml
+++ b/config/locales/th.yml
@@ -556,6 +556,9 @@ th:
           other: "%{count} หมายเหตุ"
       action_log: รายการบันทึกการตรวจสอบ
       action_taken_by: ใช้การกระทำโดย
+      actions:
+        resolve_description_html: จะไม่ใช้การกระทำกับบัญชีที่รายงาน ไม่มีการบันทึกการดำเนินการ และจะปิดรายงาน
+      actions_description_html: ตัดสินใจว่าการกระทำใดที่จะใช้เพื่อแก้ปัญหารายงานนี้ หากคุณใช้การกระทำที่เป็นการลงโทษกับบัญชีที่รายงาน จะส่งการแจ้งเตือนอีเมลถึงเขา ยกเว้นเมื่อมีการเลือกหมวดหมู่ <strong>สแปม</strong>
       are_you_sure: คุณแน่ใจหรือไม่?
       assign_to_self: มอบหมายให้ฉัน
       assigned: ผู้ควบคุมที่ได้รับมอบหมาย
@@ -883,6 +886,7 @@ th:
       confirming: กำลังรอการยืนยันอีเมลให้เสร็จสมบูรณ์
       functional: บัญชีของคุณทำงานได้อย่างเต็มที่
       pending: ใบสมัครของคุณกำลังรอดำเนินการตรวจทานโดยพนักงานของเรา นี่อาจใช้เวลาสักครู่ คุณจะได้รับอีเมลหากใบสมัครของคุณได้รับการอนุมัติ
+      view_strikes: ดูการดำเนินการที่ผ่านมากับบัญชีของคุณ
     too_fast: ส่งแบบฟอร์มเร็วเกินไป ลองอีกครั้ง
     trouble_logging_in: มีปัญหาในการเข้าสู่ระบบ?
     use_security_key: ใช้กุญแจความปลอดภัย
@@ -954,6 +958,7 @@ th:
         submit: ส่งการอุทธรณ์
       associated_report: รายงานที่เกี่ยวข้อง
       created_at: ลงวันที่
+      description_html: นี่คือการกระทำที่ใช้กับบัญชีของคุณและคำเตือนที่ส่งถึงคุณโดยพนักงานของ %{instance}
       recipient: ส่งถึง
       status: 'โพสต์ #%{id}'
       title: "%{action} จาก %{date}"
@@ -1410,9 +1415,11 @@ th:
   user_mailer:
     appeal_approved:
       action: ไปยังบัญชีของคุณ
+      explanation: อนุมัติการอุทธรณ์การดำเนินการกับบัญชีของคุณเมื่อ %{strike_date} ที่คุณได้ส่งเมื่อ %{appeal_date} แล้ว บัญชีของคุณอยู่ในสถานะที่ดีอีกครั้งหนึ่ง
       subject: อนุมัติการอุทธรณ์ของคุณจาก %{date} แล้ว
       title: อนุมัติการอุทธรณ์แล้ว
     appeal_rejected:
+      explanation: ปฏิเสธการอุทธรณ์การดำเนินการกับบัญชีของคุณเมื่อ %{strike_date} ที่คุณได้ส่งเมื่อ %{appeal_date} แล้ว
       subject: ปฏิเสธการอุทธรณ์ของคุณจาก %{date} แล้ว
       title: ปฏิเสธการอุทธรณ์แล้ว
     backup_ready:
@@ -1431,6 +1438,12 @@ th:
       categories:
         spam: สแปม
         violation: เนื้อหาละเมิดหลักเกณฑ์ชุมชนดังต่อไปนี้
+      explanation:
+        delete_statuses: มีการพบว่าบางโพสต์ของคุณละเมิดหนึ่งหลักเกณฑ์ชุมชนหรือมากกว่าและได้รับการเอาออกโดยผู้ควบคุมของ %{instance} ในเวลาต่อมา
+        disable: คุณไม่สามารถใช้บัญชีของคุณได้อีกต่อไป แต่โปรไฟล์และข้อมูลอื่น ๆ ของคุณยังคงอยู่ในสภาพเดิม คุณสามารถขอข้อมูลสำรองของข้อมูลของคุณ เปลี่ยนการตั้งค่าบัญชี หรือลบบัญชีของคุณ
+        mark_statuses_as_sensitive: ทำเครื่องหมายบางโพสต์ของคุณว่าละเอียดอ่อนโดยผู้ควบคุมของ %{instance} แล้ว นี่หมายความว่าผู้คนจะต้องแตะสื่อในโพสต์ก่อนที่จะแสดงตัวอย่าง คุณสามารถทำเครื่องหมายสื่อว่าละเอียดอ่อนด้วยตัวคุณเองเมื่อโพสต์ในอนาคต
+        sensitive: จากนี้ไป จะทำเครื่องหมายไฟล์สื่อที่อัปโหลดทั้งหมดของคุณว่าละเอียดอ่อนและซ่อนอยู่หลังการคลิกไปยังคำเตือน
+        silence: คุณยังคงสามารถใช้บัญชีของคุณแต่เฉพาะผู้คนที่กำลังติดตามคุณอยู่แล้วเท่านั้นที่จะเห็นโพสต์ของคุณในเซิร์ฟเวอร์นี้ และอาจแยกคุณออกจากคุณลักษณะการค้นพบต่าง ๆ อย่างไรก็ตาม ผู้อื่นอาจยังติดตามคุณด้วยตนเอง
       reason: 'เหตุผล:'
       statuses: 'โพสต์ที่อ้างถึง:'
       subject:
diff --git a/config/locales/tr.yml b/config/locales/tr.yml
index 6e6477b92..47a55326b 100644
--- a/config/locales/tr.yml
+++ b/config/locales/tr.yml
@@ -490,6 +490,7 @@ tr:
           other: "%{count} farklı gün başarısız girişim."
         no_failures_recorded: Kayıtlı başarısızlık yok.
         title: Ulaşılabilirlik
+        warning: Bu sunucuya önceki bağlanma denemesi başarısız olmuştu
       back_to_all: Tümü
       back_to_limited: Sınırlı
       back_to_warning: Uyarı
diff --git a/config/locales/zh-CN.yml b/config/locales/zh-CN.yml
index 6561a5716..6341b4eed 100644
--- a/config/locales/zh-CN.yml
+++ b/config/locales/zh-CN.yml
@@ -479,6 +479,7 @@ zh-CN:
           other: 在 %{count} 天中尝试失败。
         no_failures_recorded: 没有失败记录。
         title: 可用性
+        warning: 上一次连接到此服务器的尝试失败了
       back_to_all: 全部
       back_to_limited: 受限
       back_to_warning: 警告
diff --git a/config/locales/zh-TW.yml b/config/locales/zh-TW.yml
index 43a71a5dd..1b64f9893 100644
--- a/config/locales/zh-TW.yml
+++ b/config/locales/zh-TW.yml
@@ -479,6 +479,7 @@ zh-TW:
           other: 錯誤嘗試於 %{count} 天。
         no_failures_recorded: 報告中沒有錯誤。
         title: 可用狀態
+        warning: 上一次嘗試連線至本伺服器失敗
       back_to_all: 所有
       back_to_limited: 受限制的
       back_to_warning: 警告
diff --git a/lib/mastodon/version.rb b/lib/mastodon/version.rb
index 4ba47b4ee..f44b32756 100644
--- a/lib/mastodon/version.rb
+++ b/lib/mastodon/version.rb
@@ -17,7 +17,7 @@ module Mastodon
     end
 
     def flags
-      'rc1'
+      'rc3'
     end
 
     def suffix
diff --git a/lib/sanitize_ext/sanitize_config.rb b/lib/sanitize_ext/sanitize_config.rb
index ecaec2f84..935e1f4f6 100644
--- a/lib/sanitize_ext/sanitize_config.rb
+++ b/lib/sanitize_ext/sanitize_config.rb
@@ -55,18 +55,6 @@ class Sanitize
       end
     end
 
-    LINK_REL_TRANSFORMER = lambda do |env|
-      return unless env[:node_name] == 'a' and env[:node]['href']
-
-      node = env[:node]
-
-      rel = (node['rel'] || '').split(' ') & ['tag']
-      unless env[:config][:outgoing] && TagManager.instance.local_url?(node['href'])
-        rel += ['nofollow', 'noopener', 'noreferrer']
-      end
-      node['rel'] = rel.join(' ')
-    end
-
     UNSUPPORTED_HREF_TRANSFORMER = lambda do |env|
       return unless env[:node_name] == 'a'
 
@@ -97,6 +85,7 @@ class Sanitize
 
       add_attributes: {
         'a' => {
+          'rel' => 'nofollow noopener noreferrer',
           'target' => '_blank',
         },
       },
@@ -110,7 +99,6 @@ class Sanitize
         CLASS_WHITELIST_TRANSFORMER,
         IMG_TAG_TRANSFORMER,
         UNSUPPORTED_HREF_TRANSFORMER,
-        LINK_REL_TRANSFORMER,
       ]
     )
 
@@ -135,5 +123,48 @@ class Sanitize
         'source' => { 'src' => HTTP_PROTOCOLS }
       )
     )
+
+    LINK_REL_TRANSFORMER = lambda do |env|
+      return unless env[:node_name] == 'a' && env[:node]['href']
+
+      node = env[:node]
+
+      rel = (node['rel'] || '').split(' ') & ['tag']
+      rel += ['nofollow', 'noopener', 'noreferrer'] unless TagManager.instance.local_url?(node['href'])
+
+      if rel.empty?
+        node['rel']&.delete
+      else
+        node['rel'] = rel.join(' ')
+      end
+    end
+
+    LINK_TARGET_TRANSFORMER = lambda do |env|
+      return unless env[:node_name] == 'a' && env[:node]['href']
+
+      node = env[:node]
+      if node['target'] != '_blank' && TagManager.instance.local_url?(node['href'])
+        node['target']&.delete
+      else
+        node['target'] = '_blank'
+      end
+    end
+
+    MASTODON_OUTGOING ||= freeze_config MASTODON_STRICT.merge(
+      attributes: merge(
+        MASTODON_STRICT[:attributes],
+        'a' => %w(href rel class title target)
+      ),
+
+      add_attributes: {},
+
+      transformers: [
+        CLASS_WHITELIST_TRANSFORMER,
+        IMG_TAG_TRANSFORMER,
+        UNSUPPORTED_HREF_TRANSFORMER,
+        LINK_REL_TRANSFORMER,
+        LINK_TARGET_TRANSFORMER,
+      ]
+    )
   end
 end
diff --git a/spec/controllers/admin/accounts_controller_spec.rb b/spec/controllers/admin/accounts_controller_spec.rb
index 0f71d697c..1779fb7c0 100644
--- a/spec/controllers/admin/accounts_controller_spec.rb
+++ b/spec/controllers/admin/accounts_controller_spec.rb
@@ -194,9 +194,7 @@ RSpec.describe Admin::AccountsController, type: :controller do
   end
 
   describe 'POST #unblock_email' do
-    subject do
-      -> { post :unblock_email, params: { id: account.id } }
-    end
+    subject { post :unblock_email, params: { id: account.id } }
 
     let(:current_user) { Fabricate(:user, admin: admin) }
     let(:account) { Fabricate(:account, suspended: true) }
@@ -206,11 +204,11 @@ RSpec.describe Admin::AccountsController, type: :controller do
       let(:admin) { true }
 
       it 'succeeds in removing email blocks' do
-        is_expected.to change { CanonicalEmailBlock.where(reference_account: account).count }.from(1).to(0)
+        expect { subject }.to change { CanonicalEmailBlock.where(reference_account: account).count }.from(1).to(0)
       end
 
       it 'redirects to admin account path' do
-        subject.call
+        subject
         expect(response).to redirect_to admin_account_path(account.id)
       end
     end
@@ -219,7 +217,7 @@ RSpec.describe Admin::AccountsController, type: :controller do
       let(:admin) { false }
 
       it 'fails to remove avatar' do
-        subject.call
+        subject
         expect(response).to have_http_status :forbidden
       end
     end
diff --git a/spec/controllers/settings/exports/bookmarks_controller_specs.rb b/spec/controllers/settings/exports/bookmarks_controller_spec.rb
index 85761577b..a06c02e0c 100644
--- a/spec/controllers/settings/exports/bookmarks_controller_specs.rb
+++ b/spec/controllers/settings/exports/bookmarks_controller_spec.rb
@@ -3,11 +3,16 @@ require 'rails_helper'
 describe Settings::Exports::BookmarksController do
   render_views
 
+  let(:user)    { Fabricate(:user) }
+  let(:account) { Fabricate(:account, domain: 'foo.bar') }
+  let(:status)  { Fabricate(:status, account: account, uri: 'https://foo.bar/statuses/1312') }
+
   describe 'GET #index' do
-    it 'returns a csv of the bookmarked toots' do
-      user = Fabricate(:user)
-      user.account.bookmarks.create!(status: Fabricate(:status, uri: 'https://foo.bar/statuses/1312'))
+    before do
+      user.account.bookmarks.create!(status: status)
+    end
 
+    it 'returns a csv of the bookmarked toots' do
       sign_in user, scope: :user
       get :index, format: :csv
 
diff --git a/spec/lib/advanced_text_formatter_spec.rb b/spec/lib/advanced_text_formatter_spec.rb
new file mode 100644
index 000000000..4e859c93c
--- /dev/null
+++ b/spec/lib/advanced_text_formatter_spec.rb
@@ -0,0 +1,282 @@
+require 'rails_helper'
+
+RSpec.describe AdvancedTextFormatter do
+  describe '#to_s' do
+    let(:preloaded_accounts) { nil }
+    let(:content_type) { 'text/markdown' }
+
+    subject { described_class.new(text, preloaded_accounts: preloaded_accounts, content_type: content_type).to_s }
+
+    context 'given a markdown source' do
+      let(:content_type) { 'text/markdown' }
+
+      context 'given text containing plain text' do
+        let(:text) { 'text' }
+
+        it 'paragraphizes the text' do
+          is_expected.to eq '<p>text</p>'
+        end
+      end
+
+      context 'given text containing line feeds' do
+        let(:text) { "line\nfeed" }
+
+        it 'removes line feeds' do
+          is_expected.not_to include "\n"
+        end
+      end
+
+      context 'given some inline code using backticks' do
+        let(:text) { 'test `foo` bar' }
+
+        it 'formats code using <code>' do
+          is_expected.to include 'test <code>foo</code> bar'
+        end
+      end
+
+      context 'given a block code' do
+        let(:text) { "test\n\n```\nint main(void) {\n  return 0;\n}\n```\n" }
+
+        it 'formats code using <pre> and <code>' do
+          is_expected.to include '<pre><code>int main'
+        end
+      end
+
+      context 'given some quote' do
+        let(:text) { "> foo\n\nbar" }
+
+        it 'formats code using <code>' do
+          is_expected.to include '<blockquote><p>foo</p></blockquote>'
+        end
+      end
+
+      context 'given text containing linkable mentions' do
+        let(:preloaded_accounts) { [Fabricate(:account, username: 'alice')] }
+        let(:text) { '@alice' }
+
+        it 'creates a mention link' do
+          is_expected.to include '<a href="https://cb6e6126.ngrok.io/@alice" class="u-url mention">@<span>alice</span></a></span>'
+        end
+      end
+
+      context 'given text containing unlinkable mentions' do
+        let(:preloaded_accounts) { [] }
+        let(:text) { '@alice' }
+
+        it 'does not create a mention link' do
+          is_expected.to include '@alice'
+        end
+      end
+
+      context 'given a stand-alone medium URL' do
+        let(:text) { 'https://hackernoon.com/the-power-to-build-communities-a-response-to-mark-zuckerberg-3f2cac9148a4' }
+
+        it 'matches the full URL' do
+          is_expected.to include 'href="https://hackernoon.com/the-power-to-build-communities-a-response-to-mark-zuckerberg-3f2cac9148a4"'
+        end
+      end
+
+      context 'given a stand-alone google URL' do
+        let(:text) { 'http://google.com' }
+
+        it 'matches the full URL' do
+          is_expected.to include 'href="http://google.com"'
+        end
+      end
+
+      context 'given a stand-alone URL with a newer TLD' do
+        let(:text) { 'http://example.gay' }
+
+        it 'matches the full URL' do
+          is_expected.to include 'href="http://example.gay"'
+        end
+      end
+
+      context 'given a stand-alone IDN URL' do
+        let(:text) { 'https://nic.みんな/' }
+
+        it 'matches the full URL' do
+          is_expected.to include 'href="https://nic.みんな/"'
+        end
+
+        it 'has display URL' do
+          is_expected.to include '<span class="">nic.みんな/</span>'
+        end
+      end
+
+      context 'given a URL with a trailing period' do
+        let(:text) { 'http://www.mcmansionhell.com/post/156408871451/50-states-of-mcmansion-hell-scottsdale-arizona. ' }
+
+        it 'matches the full URL but not the period' do
+          is_expected.to include 'href="http://www.mcmansionhell.com/post/156408871451/50-states-of-mcmansion-hell-scottsdale-arizona"'
+        end
+      end
+
+      context 'given a URL enclosed with parentheses' do
+        let(:text) { '(http://google.com/)' }
+
+        it 'matches the full URL but not the parentheses' do
+          is_expected.to include 'href="http://google.com/"'
+        end
+      end
+
+      context 'given a URL with a trailing exclamation point' do
+        let(:text) { 'http://www.google.com!' }
+
+        it 'matches the full URL but not the exclamation point' do
+          is_expected.to include 'href="http://www.google.com"'
+        end
+      end
+
+      context 'given a URL with a trailing single quote' do
+        let(:text) { "http://www.google.com'" }
+
+        it 'matches the full URL but not the single quote' do
+          is_expected.to include 'href="http://www.google.com"'
+        end
+      end
+    end
+
+    context 'given a URL with a trailing angle bracket' do
+      let(:text) { 'http://www.google.com>' }
+
+      it 'matches the full URL but not the angle bracket' do
+        is_expected.to include 'href="http://www.google.com"'
+      end
+    end
+
+    context 'given a URL with a query string' do
+      context 'with escaped unicode character' do
+        let(:text) { 'https://www.ruby-toolbox.com/search?utf8=%E2%9C%93&q=autolink' }
+
+        it 'matches the full URL' do
+          is_expected.to include 'href="https://www.ruby-toolbox.com/search?utf8=%E2%9C%93&amp;q=autolink"'
+        end
+      end
+
+      context 'with unicode character' do
+        let(:text) { 'https://www.ruby-toolbox.com/search?utf8=✓&q=autolink' }
+
+        it 'matches the full URL' do
+          is_expected.to include 'href="https://www.ruby-toolbox.com/search?utf8=✓&amp;q=autolink"'
+        end
+      end
+
+      context 'with unicode character at the end' do
+        let(:text) { 'https://www.ruby-toolbox.com/search?utf8=✓' }
+
+        it 'matches the full URL' do
+          is_expected.to include 'href="https://www.ruby-toolbox.com/search?utf8=✓"'
+        end
+      end
+
+      context 'with escaped and not escaped unicode characters' do
+        let(:text) { 'https://www.ruby-toolbox.com/search?utf8=%E2%9C%93&utf81=✓&q=autolink' }
+
+        it 'preserves escaped unicode characters' do
+          is_expected.to include 'href="https://www.ruby-toolbox.com/search?utf8=%E2%9C%93&amp;utf81=✓&amp;q=autolink"'
+        end
+      end
+
+      context 'given a URL with parentheses in it' do
+        let(:text) { 'https://en.wikipedia.org/wiki/Diaspora_(software)' }
+
+        it 'matches the full URL' do
+          is_expected.to include 'href="https://en.wikipedia.org/wiki/Diaspora_(software)"'
+        end
+      end
+
+      context 'given a URL in quotation marks' do
+        let(:text) { '"https://example.com/"' }
+
+        it 'does not match the quotation marks' do
+          is_expected.to include 'href="https://example.com/"'
+        end
+      end
+
+      context 'given a URL in angle brackets' do
+        let(:text) { '<https://example.com/>' }
+
+        it 'does not match the angle brackets' do
+          is_expected.to include 'href="https://example.com/"'
+        end
+      end
+
+      context 'given a URL containing unsafe code (XSS attack, invisible part)' do
+        let(:text) { %q{http://example.com/blahblahblahblah/a<script>alert("Hello")</script>} }
+
+        it 'does not include the HTML in the URL' do
+          is_expected.to include '"http://example.com/blahblahblahblah/a"'
+        end
+
+        it 'does not include a script tag' do
+          is_expected.to_not include '<script>'
+        end
+      end
+
+      context 'given text containing HTML code (script tag)' do
+        let(:text) { '<script>alert("Hello")</script>' }
+
+        it 'does not include a script tag' do
+          is_expected.to_not include '<script>'
+        end
+      end
+
+      context 'given text containing HTML (XSS attack)' do
+        let(:text) { %q{<img src="javascript:alert('XSS');">} }
+
+        it 'does not include the javascript' do
+          is_expected.to_not include 'href="javascript:'
+        end
+      end
+
+      context 'given an invalid URL' do
+        let(:text) { 'http://www\.google\.com' }
+
+        it 'outputs the raw URL' do
+          is_expected.to eq '<p>http://www\.google\.com</p>'
+        end
+      end
+
+      context 'given text containing a hashtag' do
+        let(:text)  { '#hashtag' }
+
+        it 'creates a hashtag link' do
+          is_expected.to include '/tags/hashtag" class="mention hashtag" rel="tag">#<span>hashtag</span></a>'
+        end
+      end
+
+      context 'given text containing a hashtag with Unicode chars' do
+        let(:text)  { '#hashtagタグ' }
+
+        it 'creates a hashtag link' do
+          is_expected.to include '/tags/hashtag%E3%82%BF%E3%82%B0" class="mention hashtag" rel="tag">#<span>hashtagタグ</span></a>'
+        end
+      end
+
+      context 'given text with a stand-alone xmpp: URI' do
+        let(:text) { 'xmpp:user@instance.com' }
+
+        it 'matches the full URI' do
+          is_expected.to include 'href="xmpp:user@instance.com"'
+        end
+      end
+
+      context 'given text with an xmpp: URI with a query-string' do
+        let(:text) { 'please join xmpp:muc@instance.com?join right now' }
+
+        it 'matches the full URI' do
+          is_expected.to include 'href="xmpp:muc@instance.com?join"'
+        end
+      end
+
+      context 'given text containing a magnet: URI' do
+        let(:text) { 'wikipedia gives this example of a magnet uri: magnet:?xt=urn:btih:c12fe1c06bba254a9dc9f519b335aa7c1367a88a' }
+
+        it 'matches the full URI' do
+          is_expected.to include 'href="magnet:?xt=urn:btih:c12fe1c06bba254a9dc9f519b335aa7c1367a88a"'
+        end
+      end
+    end
+  end
+end
diff --git a/spec/lib/emoji_formatter_spec.rb b/spec/lib/emoji_formatter_spec.rb
new file mode 100644
index 000000000..129445aa5
--- /dev/null
+++ b/spec/lib/emoji_formatter_spec.rb
@@ -0,0 +1,55 @@
+require 'rails_helper'
+
+RSpec.describe EmojiFormatter do
+  let!(:emoji) { Fabricate(:custom_emoji, shortcode: 'coolcat') }
+
+  def preformat_text(str)
+    TextFormatter.new(str).to_s
+  end
+
+  describe '#to_s' do
+    subject { described_class.new(text, emojis).to_s }
+
+    let(:emojis) { [emoji] }
+
+    context 'given text that is not marked as html-safe' do
+      let(:text) { 'Foo' }
+
+      it 'raises an argument error' do
+        expect { subject }.to raise_error ArgumentError
+      end
+    end
+
+    context 'given text with an emoji shortcode at the start' do
+      let(:text) { preformat_text(':coolcat: Beep boop') }
+
+      it 'converts the shortcode to an image tag' do
+        is_expected.to match(/<img draggable="false" class="emojione custom-emoji" alt=":coolcat:"/)
+      end
+    end
+
+    context 'given text with an emoji shortcode in the middle' do
+      let(:text) { preformat_text('Beep :coolcat: boop') }
+
+      it 'converts the shortcode to an image tag' do
+        is_expected.to match(/Beep <img draggable="false" class="emojione custom-emoji" alt=":coolcat:"/)
+      end
+    end
+
+    context 'given text with concatenated emoji shortcodes' do
+      let(:text) { preformat_text(':coolcat::coolcat:') }
+
+      it 'does not touch the shortcodes' do
+        is_expected.to match(/:coolcat::coolcat:/)
+      end
+    end
+
+    context 'given text with an emoji shortcode at the end' do
+      let(:text) { preformat_text('Beep boop :coolcat:') }
+
+      it 'converts the shortcode to an image tag' do
+        is_expected.to match(/boop <img draggable="false" class="emojione custom-emoji" alt=":coolcat:"/)
+      end
+    end
+  end
+end
diff --git a/spec/lib/formatter_spec.rb b/spec/lib/formatter_spec.rb
deleted file mode 100644
index 73cb39550..000000000
--- a/spec/lib/formatter_spec.rb
+++ /dev/null
@@ -1,638 +0,0 @@
-require 'rails_helper'
-
-RSpec.describe Formatter do
-  let(:local_account)  { Fabricate(:account, domain: nil, username: 'alice') }
-  let(:remote_account) { Fabricate(:account, domain: 'remote.test', username: 'bob', url: 'https://remote.test/') }
-
-  shared_examples 'encode and link URLs' do
-    context 'given a stand-alone medium URL' do
-      let(:text) { 'https://hackernoon.com/the-power-to-build-communities-a-response-to-mark-zuckerberg-3f2cac9148a4' }
-
-      it 'matches the full URL' do
-        is_expected.to include 'href="https://hackernoon.com/the-power-to-build-communities-a-response-to-mark-zuckerberg-3f2cac9148a4"'
-      end
-    end
-
-    context 'given a stand-alone google URL' do
-      let(:text) { 'http://google.com' }
-
-      it 'matches the full URL' do
-        is_expected.to include 'href="http://google.com"'
-      end
-    end
-
-    context 'given a stand-alone URL with a newer TLD' do
-      let(:text) { 'http://example.gay' }
-
-      it 'matches the full URL' do
-        is_expected.to include 'href="http://example.gay"'
-      end
-    end
-
-    context 'given a stand-alone IDN URL' do
-      let(:text) { 'https://nic.みんな/' }
-
-      it 'matches the full URL' do
-        is_expected.to include 'href="https://nic.みんな/"'
-      end
-
-      it 'has display URL' do
-        is_expected.to include '<span class="">nic.みんな/</span>'
-      end
-    end
-
-    context 'given a URL with a trailing period' do
-      let(:text) { 'http://www.mcmansionhell.com/post/156408871451/50-states-of-mcmansion-hell-scottsdale-arizona. ' }
-
-      it 'matches the full URL but not the period' do
-        is_expected.to include 'href="http://www.mcmansionhell.com/post/156408871451/50-states-of-mcmansion-hell-scottsdale-arizona"'
-      end
-    end
-
-    context 'given a URL enclosed with parentheses' do
-      let(:text) { '(http://google.com/)' }
-
-      it 'matches the full URL but not the parentheses' do
-        is_expected.to include 'href="http://google.com/"'
-      end
-    end
-
-    context 'given a URL with a trailing exclamation point' do
-      let(:text) { 'http://www.google.com!' }
-
-      it 'matches the full URL but not the exclamation point' do
-        is_expected.to include 'href="http://www.google.com"'
-      end
-    end
-
-    context 'given a URL with a trailing single quote' do
-      let(:text) { "http://www.google.com'" }
-
-      it 'matches the full URL but not the single quote' do
-        is_expected.to include 'href="http://www.google.com"'
-      end
-    end
-
-    context 'given a URL with a trailing angle bracket' do
-      let(:text) { 'http://www.google.com>' }
-
-      it 'matches the full URL but not the angle bracket' do
-        is_expected.to include 'href="http://www.google.com"'
-      end
-    end
-
-    context 'given a URL with a query string' do
-      context 'with escaped unicode character' do
-        let(:text) { 'https://www.ruby-toolbox.com/search?utf8=%E2%9C%93&q=autolink' }
-
-        it 'matches the full URL' do
-          is_expected.to include 'href="https://www.ruby-toolbox.com/search?utf8=%E2%9C%93&amp;q=autolink"'
-        end
-      end
-
-      context 'with unicode character' do
-        let(:text) { 'https://www.ruby-toolbox.com/search?utf8=✓&q=autolink' }
-
-        it 'matches the full URL' do
-          is_expected.to include 'href="https://www.ruby-toolbox.com/search?utf8=✓&amp;q=autolink"'
-        end
-      end
-
-      context 'with unicode character at the end' do
-        let(:text) { 'https://www.ruby-toolbox.com/search?utf8=✓' }
-
-        it 'matches the full URL' do
-          is_expected.to include 'href="https://www.ruby-toolbox.com/search?utf8=✓"'
-        end
-      end
-
-      context 'with escaped and not escaped unicode characters' do
-        let(:text) { 'https://www.ruby-toolbox.com/search?utf8=%E2%9C%93&utf81=✓&q=autolink' }
-
-        it 'preserves escaped unicode characters' do
-          is_expected.to include 'href="https://www.ruby-toolbox.com/search?utf8=%E2%9C%93&amp;utf81=✓&amp;q=autolink"'
-        end
-      end
-    end
-
-    context 'given a URL with parentheses in it' do
-      let(:text) { 'https://en.wikipedia.org/wiki/Diaspora_(software)' }
-
-      it 'matches the full URL' do
-        is_expected.to include 'href="https://en.wikipedia.org/wiki/Diaspora_(software)"'
-      end
-    end
-
-    context 'given a URL in quotation marks' do
-      let(:text) { '"https://example.com/"' }
-
-      it 'does not match the quotation marks' do
-        is_expected.to include 'href="https://example.com/"'
-      end
-    end
-
-    context 'given a URL in angle brackets' do
-      let(:text) { '<https://example.com/>' }
-
-      it 'does not match the angle brackets' do
-        is_expected.to include 'href="https://example.com/"'
-      end
-    end
-
-    context 'given a URL with Japanese path string' do
-      let(:text) { 'https://ja.wikipedia.org/wiki/日本' }
-
-      it 'matches the full URL' do
-        is_expected.to include 'href="https://ja.wikipedia.org/wiki/日本"'
-      end
-    end
-
-    context 'given a URL with Korean path string' do
-      let(:text) { 'https://ko.wikipedia.org/wiki/대한민국' }
-
-      it 'matches the full URL' do
-        is_expected.to include 'href="https://ko.wikipedia.org/wiki/대한민국"'
-      end
-    end
-
-    context 'given a URL with a full-width space' do
-      let(:text) { 'https://example.com/ abc123' }
-
-      it 'does not match the full-width space' do
-        is_expected.to include 'href="https://example.com/"'
-      end
-    end
-
-    context 'given a URL in Japanese quotation marks' do
-      let(:text) { '「[https://example.org/」' }
-
-      it 'does not match the quotation marks' do
-        is_expected.to include 'href="https://example.org/"'
-      end
-    end
-
-    context 'given a URL with Simplified Chinese path string' do
-      let(:text) { 'https://baike.baidu.com/item/中华人民共和国' }
-
-      it 'matches the full URL' do
-        is_expected.to include 'href="https://baike.baidu.com/item/中华人民共和国"'
-      end
-    end
-
-    context 'given a URL with Traditional Chinese path string' do
-      let(:text) { 'https://zh.wikipedia.org/wiki/臺灣' }
-
-      it 'matches the full URL' do
-        is_expected.to include 'href="https://zh.wikipedia.org/wiki/臺灣"'
-      end
-    end
-
-    context 'given a URL containing unsafe code (XSS attack, visible part)' do
-      let(:text) { %q{http://example.com/b<del>b</del>} }
-
-      it 'does not include the HTML in the URL' do
-        is_expected.to include '"http://example.com/b"'
-      end
-
-      it 'escapes the HTML' do
-        is_expected.to include '&lt;del&gt;b&lt;/del&gt;'
-      end
-    end
-
-    context 'given a URL containing unsafe code (XSS attack, invisible part)' do
-      let(:text) { %q{http://example.com/blahblahblahblah/a<script>alert("Hello")</script>} }
-
-      it 'does not include the HTML in the URL' do
-        is_expected.to include '"http://example.com/blahblahblahblah/a"'
-      end
-
-      it 'escapes the HTML' do
-        is_expected.to include '&lt;script&gt;alert(&quot;Hello&quot;)&lt;/script&gt;'
-      end
-    end
-
-    context 'given text containing HTML code (script tag)' do
-      let(:text) { '<script>alert("Hello")</script>' }
-
-      it 'escapes the HTML' do
-        is_expected.to include '<p>&lt;script&gt;alert(&quot;Hello&quot;)&lt;/script&gt;</p>'
-      end
-    end
-
-    context 'given text containing HTML (XSS attack)' do
-      let(:text) { %q{<img src="javascript:alert('XSS');">} }
-
-      it 'escapes the HTML' do
-        is_expected.to include '<p>&lt;img src=&quot;javascript:alert(&apos;XSS&apos;);&quot;&gt;</p>'
-      end
-    end
-
-    context 'given an invalid URL' do
-      let(:text) { 'http://www\.google\.com' }
-
-      it 'outputs the raw URL' do
-        is_expected.to eq '<p>http://www\.google\.com</p>'
-      end
-    end
-
-    context 'given text containing a hashtag' do
-      let(:text)  { '#hashtag' }
-
-      it 'creates a hashtag link' do
-        is_expected.to include '/tags/hashtag" class="mention hashtag" rel="tag">#<span>hashtag</span></a>'
-      end
-    end
-
-    context 'given text containing a hashtag with Unicode chars' do
-      let(:text)  { '#hashtagタグ' }
-
-      it 'creates a hashtag link' do
-        is_expected.to include '/tags/hashtag%E3%82%BF%E3%82%B0" class="mention hashtag" rel="tag">#<span>hashtagタグ</span></a>'
-      end
-    end
-
-    context 'given a stand-alone xmpp: URI' do
-      let(:text) { 'xmpp:user@instance.com' }
-
-      it 'matches the full URI' do
-        is_expected.to include 'href="xmpp:user@instance.com"'
-      end
-    end
-
-    context 'given a an xmpp: URI with a query-string' do
-      let(:text) { 'please join xmpp:muc@instance.com?join right now' }
-
-      it 'matches the full URI' do
-        is_expected.to include 'href="xmpp:muc@instance.com?join"'
-      end
-    end
-
-    context 'given text containing a magnet: URI' do
-      let(:text) { 'wikipedia gives this example of a magnet uri: magnet:?xt=urn:btih:c12fe1c06bba254a9dc9f519b335aa7c1367a88a' }
-
-      it 'matches the full URI' do
-        is_expected.to include 'href="magnet:?xt=urn:btih:c12fe1c06bba254a9dc9f519b335aa7c1367a88a"'
-      end
-    end
-  end
-
-  describe '#format_spoiler' do
-    subject { Formatter.instance.format_spoiler(status) }
-
-    context 'given a post containing plain text' do
-      let(:status) { Fabricate(:status, text: 'text', spoiler_text: 'Secret!', uri: nil) }
-
-      it 'Returns the spoiler text' do
-        is_expected.to eq 'Secret!'
-      end
-    end
-
-    context 'given a post with an emoji shortcode at the start' do
-      let!(:emoji) { Fabricate(:custom_emoji) }
-      let(:status) { Fabricate(:status, text: 'text', spoiler_text: ':coolcat: Secret!', uri: nil) }
-      let(:text) { ':coolcat: Beep boop' }
-
-      it 'converts the shortcode to an image tag' do
-        is_expected.to match(/<img draggable="false" class="emojione custom-emoji" alt=":coolcat:"/)
-      end
-    end
-  end
-
-  describe '#format' do
-    subject { Formatter.instance.format(status) }
-
-    context 'given a post with local status' do
-      context 'given a reblogged post' do
-        let(:reblog) { Fabricate(:status, account: local_account, text: 'Hello world', uri: nil) }
-        let(:status) { Fabricate(:status, reblog: reblog) }
-
-        it 'returns original status with credit to its author' do
-          is_expected.to include 'RT <span class="h-card"><a href="https://cb6e6126.ngrok.io/@alice" class="u-url mention">@<span>alice</span></a></span> Hello world'
-        end
-      end
-
-      context 'given a post containing plain text' do
-        let(:status) { Fabricate(:status, text: 'text', uri: nil) }
-
-        it 'paragraphizes the text' do
-          is_expected.to eq '<p>text</p>'
-        end
-      end
-
-      context 'given a post containing line feeds' do
-        let(:status) { Fabricate(:status, text: "line\nfeed", uri: nil) }
-
-        it 'removes line feeds' do
-          is_expected.not_to include "\n"
-        end
-      end
-
-      context 'given a post containing linkable mentions' do
-        let(:status) { Fabricate(:status, mentions: [ Fabricate(:mention, account: local_account) ], text: '@alice') }
-
-        it 'creates a mention link' do
-          is_expected.to include '<a href="https://cb6e6126.ngrok.io/@alice" class="u-url mention">@<span>alice</span></a></span>'
-        end
-      end
-
-      context 'given a post containing unlinkable mentions' do
-        let(:status) { Fabricate(:status, text: '@alice', uri: nil) }
-
-        it 'does not create a mention link' do
-          is_expected.to include '@alice'
-        end
-      end
-
-      context do
-        let(:content_type) { 'text/plain' }
-
-        subject do
-          status = Fabricate(:status, text: text, content_type: content_type, uri: nil)
-          Formatter.instance.format(status)
-        end
-
-        context 'given an invalid URL (invalid port)' do
-          let(:text) { 'https://foo.bar:X/' }
-          let(:content_type) { 'text/markdown' }
-
-          it 'outputs the raw URL' do
-            is_expected.to eq '<p>https://foo.bar:X/</p>'
-          end
-        end
-
-        include_examples 'encode and link URLs'
-      end
-
-      context 'given a post with custom_emojify option' do
-        let!(:emoji) { Fabricate(:custom_emoji) }
-        let(:status) { Fabricate(:status, account: local_account, text: text) }
-
-        subject { Formatter.instance.format(status, custom_emojify: true) }
-
-        context 'given a post with an emoji shortcode at the start' do
-          let(:text) { ':coolcat: Beep boop' }
-
-          it 'converts the shortcode to an image tag' do
-            is_expected.to match(/<p><img draggable="false" class="emojione custom-emoji" alt=":coolcat:"/)
-          end
-        end
-
-        context 'given a post with an emoji shortcode in the middle' do
-          let(:text) { 'Beep :coolcat: boop' }
-
-          it 'converts the shortcode to an image tag' do
-            is_expected.to match(/Beep <img draggable="false" class="emojione custom-emoji" alt=":coolcat:"/)
-          end
-        end
-
-        context 'given a post with concatenated emoji shortcodes' do
-          let(:text) { ':coolcat::coolcat:' }
-
-          it 'does not touch the shortcodes' do
-            is_expected.to match(/:coolcat::coolcat:/)
-          end
-        end
-
-        context 'given a post with an emoji shortcode at the end' do
-          let(:text) { 'Beep boop :coolcat:' }
-
-          it 'converts the shortcode to an image tag' do
-            is_expected.to match(/boop <img draggable="false" class="emojione custom-emoji" alt=":coolcat:"/)
-          end
-        end
-      end
-    end
-
-    context 'given a post with remote status' do
-      let(:status) { Fabricate(:status, account: remote_account, text: 'Beep boop') }
-
-      it 'reformats the post' do
-        is_expected.to eq 'Beep boop'
-      end
-
-      context 'given a post with custom_emojify option' do
-        let!(:emoji) { Fabricate(:custom_emoji, domain: remote_account.domain) }
-        let(:status) { Fabricate(:status, account: remote_account, text: text) }
-
-        subject { Formatter.instance.format(status, custom_emojify: true) }
-
-        context 'given a post with an emoji shortcode at the start' do
-          let(:text) { '<p>:coolcat: Beep boop<br />' }
-
-          it 'converts the shortcode to an image tag' do
-            is_expected.to match(/<p><img draggable="false" class="emojione custom-emoji" alt=":coolcat:"/)
-          end
-        end
-
-        context 'given a post with an emoji shortcode in the middle' do
-          let(:text) { '<p>Beep :coolcat: boop</p>' }
-
-          it 'converts the shortcode to an image tag' do
-            is_expected.to match(/Beep <img draggable="false" class="emojione custom-emoji" alt=":coolcat:"/)
-          end
-        end
-
-        context 'given a post with concatenated emoji' do
-          let(:text) { '<p>:coolcat::coolcat:</p>' }
-
-          it 'does not touch the shortcodes' do
-            is_expected.to match(/<p>:coolcat::coolcat:<\/p>/)
-          end
-        end
-
-        context 'given a post with an emoji shortcode at the end' do
-          let(:text) { '<p>Beep boop<br />:coolcat:</p>' }
-
-          it 'converts the shortcode to an image tag' do
-            is_expected.to match(/<br><img draggable="false" class="emojione custom-emoji" alt=":coolcat:"/)
-          end
-        end
-      end
-    end
-  end
-
-  describe '#reformat' do
-    subject { Formatter.instance.reformat(text) }
-
-    context 'given a post containing plain text' do
-      let(:text) { 'Beep boop' }
-
-      it 'keeps the plain text' do
-        is_expected.to include 'Beep boop'
-      end
-    end
-
-    context 'given a post containing script tags' do
-      let(:text) { '<script>alert("Hello")</script>' }
-
-      it 'strips the scripts' do
-        is_expected.to_not include '<script>alert("Hello")</script>'
-      end
-    end
-
-    context 'given a post containing malicious classes' do
-      let(:text) { '<span class="mention	status__content__spoiler-link">Show more</span>' }
-
-      it 'strips the malicious classes' do
-        is_expected.to_not include 'status__content__spoiler-link'
-      end
-    end
-  end
-
-  describe '#plaintext' do
-    subject { Formatter.instance.plaintext(status) }
-
-    context 'given a post with local status' do
-      let(:status) { Fabricate(:status, text: '<p>a text by a nerd who uses an HTML tag in text</p>', content_type: content_type, uri: nil) }
-      let(:content_type) { 'text/plain' }
-
-      it 'returns the raw text' do
-        is_expected.to eq '<p>a text by a nerd who uses an HTML tag in text</p>'
-      end
-    end
-
-    context 'given a post with remote status' do
-      let(:status) { Fabricate(:status, account: remote_account, text: '<script>alert("Hello")</script>') }
-
-      it 'returns tag-stripped text' do
-        is_expected.to eq ''
-      end
-    end
-  end
-
-  describe '#simplified_format' do
-    subject { Formatter.instance.simplified_format(account) }
-
-    context 'given a post with local status' do
-      let(:account) { Fabricate(:account, domain: nil, note: text) }
-
-      context 'given a post containing linkable mentions for local accounts' do
-        let(:text) { '@alice' }
-
-        before { local_account }
-
-        it 'creates a mention link' do
-          is_expected.to eq '<p><span class="h-card"><a href="https://cb6e6126.ngrok.io/@alice" class="u-url mention">@<span>alice</span></a></span></p>'
-        end
-      end
-
-      context 'given a post containing linkable mentions for remote accounts' do
-        let(:text) { '@bob@remote.test' }
-
-        before { remote_account }
-
-        it 'creates a mention link' do
-          is_expected.to eq '<p><span class="h-card"><a href="https://remote.test/" class="u-url mention">@<span>bob</span></a></span></p>'
-        end
-      end
-
-      context 'given a post containing unlinkable mentions' do
-        let(:text) { '@alice' }
-
-        it 'does not create a mention link' do
-          is_expected.to eq '<p>@alice</p>'
-        end
-      end
-
-      context 'given a post with custom_emojify option' do
-        let!(:emoji) { Fabricate(:custom_emoji) }
-
-        before { account.note = text }
-        subject { Formatter.instance.simplified_format(account, custom_emojify: true) }
-
-        context 'given a post with an emoji shortcode at the start' do
-          let(:text) { ':coolcat: Beep boop' }
-
-          it 'converts the shortcode to an image tag' do
-            is_expected.to match(/<p><img draggable="false" class="emojione custom-emoji" alt=":coolcat:"/)
-          end
-        end
-
-        context 'given a post with an emoji shortcode in the middle' do
-          let(:text) { 'Beep :coolcat: boop' }
-
-          it 'converts the shortcode to an image tag' do
-            is_expected.to match(/Beep <img draggable="false" class="emojione custom-emoji" alt=":coolcat:"/)
-          end
-        end
-
-        context 'given a post with concatenated emoji shortcodes' do
-          let(:text) { ':coolcat::coolcat:' }
-
-          it 'does not touch the shortcodes' do
-            is_expected.to match(/:coolcat::coolcat:/)
-          end
-        end
-
-        context 'given a post with an emoji shortcode at the end' do
-          let(:text) { 'Beep boop :coolcat:' }
-
-          it 'converts the shortcode to an image tag' do
-            is_expected.to match(/boop <img draggable="false" class="emojione custom-emoji" alt=":coolcat:"/)
-          end
-        end
-      end
-
-      include_examples 'encode and link URLs'
-    end
-
-    context 'given a post with remote status' do
-      let(:text) { '<script>alert("Hello")</script>' }
-      let(:account) { Fabricate(:account, domain: 'remote', note: text) }
-
-      it 'reformats' do
-        is_expected.to_not include '<script>alert("Hello")</script>'
-      end
-
-      context 'with custom_emojify option' do
-        let!(:emoji) { Fabricate(:custom_emoji, domain: remote_account.domain) }
-
-        before { remote_account.note = text }
-
-        subject { Formatter.instance.simplified_format(remote_account, custom_emojify: true) }
-
-        context 'given a post with an emoji shortcode at the start' do
-          let(:text) { '<p>:coolcat: Beep boop<br />' }
-
-          it 'converts shortcode to image tag' do
-            is_expected.to match(/<p><img draggable="false" class="emojione custom-emoji" alt=":coolcat:"/)
-          end
-        end
-
-        context 'given a post with an emoji shortcode in the middle' do
-          let(:text) { '<p>Beep :coolcat: boop</p>' }
-
-          it 'converts shortcode to image tag' do
-            is_expected.to match(/Beep <img draggable="false" class="emojione custom-emoji" alt=":coolcat:"/)
-          end
-        end
-
-        context 'given a post with concatenated emoji shortcodes' do
-          let(:text) { '<p>:coolcat::coolcat:</p>' }
-
-          it 'does not touch the shortcodes' do
-            is_expected.to match(/<p>:coolcat::coolcat:<\/p>/)
-          end
-        end
-
-        context 'given a post with an emoji shortcode at the end' do
-          let(:text) { '<p>Beep boop<br />:coolcat:</p>' }
-
-          it 'converts shortcode to image tag' do
-            is_expected.to match(/<br><img draggable="false" class="emojione custom-emoji" alt=":coolcat:"/)
-          end
-        end
-      end
-    end
-  end
-
-  describe '#sanitize' do
-    let(:html) { '<script>alert("Hello")</script>' }
-
-    subject { Formatter.instance.sanitize(html, Sanitize::Config::MASTODON_STRICT) }
-
-    it 'sanitizes' do
-      is_expected.to eq ''
-    end
-  end
-end
diff --git a/spec/lib/html_aware_formatter_spec.rb b/spec/lib/html_aware_formatter_spec.rb
new file mode 100644
index 000000000..18d23abf5
--- /dev/null
+++ b/spec/lib/html_aware_formatter_spec.rb
@@ -0,0 +1,44 @@
+require 'rails_helper'
+
+RSpec.describe HtmlAwareFormatter do
+  describe '#to_s' do
+    subject { described_class.new(text, local).to_s }
+
+    context 'when local' do
+      let(:local) { true }
+      let(:text) { 'Foo bar' }
+
+      it 'returns formatted text' do
+        is_expected.to eq '<p>Foo bar</p>'
+      end
+    end
+
+    context 'when remote' do
+      let(:local) { false }
+
+      context 'given plain text' do
+        let(:text) { 'Beep boop' }
+
+        it 'keeps the plain text' do
+          is_expected.to include 'Beep boop'
+        end
+      end
+
+      context 'given text containing script tags' do
+        let(:text) { '<script>alert("Hello")</script>' }
+
+        it 'strips the scripts' do
+          is_expected.to_not include '<script>alert("Hello")</script>'
+        end
+      end
+
+      context 'given text containing malicious classes' do
+        let(:text) { '<span class="mention  status__content__spoiler-link">Show more</span>' }
+
+        it 'strips the malicious classes' do
+          is_expected.to_not include 'status__content__spoiler-link'
+        end
+      end
+    end
+  end
+end
diff --git a/spec/lib/link_details_extractor_spec.rb b/spec/lib/link_details_extractor_spec.rb
index 84bb4579c..7ea867c61 100644
--- a/spec/lib/link_details_extractor_spec.rb
+++ b/spec/lib/link_details_extractor_spec.rb
@@ -25,6 +25,14 @@ RSpec.describe LinkDetailsExtractor do
         expect(subject.canonical_url).to eq 'https://foo.com/article'
       end
     end
+
+    context 'when canonical URL is set to "null"' do
+      let(:html) { '<!doctype html><link rel="canonical" href="null" />' }
+
+      it 'ignores the canonical URLs' do
+        expect(subject.canonical_url).to eq original_url
+      end
+    end
   end
 
   context 'when structured data is present' do
diff --git a/spec/lib/plain_text_formatter_spec.rb b/spec/lib/plain_text_formatter_spec.rb
new file mode 100644
index 000000000..c3d0ee630
--- /dev/null
+++ b/spec/lib/plain_text_formatter_spec.rb
@@ -0,0 +1,24 @@
+require 'rails_helper'
+
+RSpec.describe PlainTextFormatter do
+  describe '#to_s' do
+    subject { described_class.new(status.text, status.local?).to_s }
+
+    context 'given a post with local status' do
+      let(:status) { Fabricate(:status, text: '<p>a text by a nerd who uses an HTML tag in text</p>', uri: nil) }
+
+      it 'returns the raw text' do
+        is_expected.to eq '<p>a text by a nerd who uses an HTML tag in text</p>'
+      end
+    end
+
+    context 'given a post with remote status' do
+      let(:remote_account) { Fabricate(:account, domain: 'remote.test', username: 'bob', url: 'https://remote.test/') }
+      let(:status) { Fabricate(:status, account: remote_account, text: '<p>Hello</p><script>alert("Hello")</script>') }
+
+      it 'returns tag-stripped text' do
+        is_expected.to eq 'Hello'
+      end
+    end
+  end
+end
diff --git a/spec/lib/rss/serializer_spec.rb b/spec/lib/rss/serializer_spec.rb
index 0364d13de..1da45d302 100644
--- a/spec/lib/rss/serializer_spec.rb
+++ b/spec/lib/rss/serializer_spec.rb
@@ -13,13 +13,6 @@ describe RSS::Serializer do
 
     subject { RSS::Serializer.new.send(:status_title, status) }
 
-    context 'if destroyed?' do
-      it 'returns "#{account.acct} deleted status"' do
-        status.destroy!
-        expect(subject).to eq "#{account.acct} deleted status"
-      end
-    end
-
     context 'on a toot with long text' do
       let(:text) { "This toot's text is longer than the allowed number of characters" }
 
diff --git a/spec/lib/sanitize_config_spec.rb b/spec/lib/sanitize_config_spec.rb
index 8bcffb2e5..dc6418e5b 100644
--- a/spec/lib/sanitize_config_spec.rb
+++ b/spec/lib/sanitize_config_spec.rb
@@ -41,18 +41,8 @@ describe Sanitize::Config do
     end
   end
 
-  describe '::MASTODON_STRICT' do
-    subject { Sanitize::Config::MASTODON_STRICT }
-
-    it_behaves_like 'common HTML sanitization'
-
-    it 'keeps a with href and rel tag' do
-      expect(Sanitize.fragment('<a href="http://example.com" rel="tag">Test</a>', subject)).to eq '<a href="http://example.com" rel="tag nofollow noopener noreferrer" target="_blank">Test</a>'
-    end
-  end
-
-  describe '::MASTODON_STRICT with outgoing toots' do
-    subject { Sanitize::Config::MASTODON_STRICT.merge(outgoing: true) }
+  describe '::MASTODON_OUTGOING' do
+    subject { Sanitize::Config::MASTODON_OUTGOING }
 
     around do |example|
       original_web_domain = Rails.configuration.x.web_domain
@@ -62,9 +52,9 @@ describe Sanitize::Config do
 
     it_behaves_like 'common HTML sanitization'
 
-    it 'keeps a with href and rel tag, not adding to rel if url is local' do
+    it 'keeps a with href and rel tag, not adding to rel or target if url is local' do
       Rails.configuration.x.web_domain = 'domain.test'
-      expect(Sanitize.fragment('<a href="http://domain.test/tags/foo" rel="tag">Test</a>', subject)).to eq '<a href="http://domain.test/tags/foo" rel="tag" target="_blank">Test</a>'
+      expect(Sanitize.fragment('<a href="http://domain.test/tags/foo" rel="tag">Test</a>', subject)).to eq '<a href="http://domain.test/tags/foo" rel="tag">Test</a>'
     end
   end
 end
diff --git a/spec/lib/text_formatter_spec.rb b/spec/lib/text_formatter_spec.rb
new file mode 100644
index 000000000..52a9d2498
--- /dev/null
+++ b/spec/lib/text_formatter_spec.rb
@@ -0,0 +1,313 @@
+require 'rails_helper'
+
+RSpec.describe TextFormatter do
+  describe '#to_s' do
+    let(:preloaded_accounts) { nil }
+
+    subject { described_class.new(text, preloaded_accounts: preloaded_accounts).to_s }
+
+    context 'given text containing plain text' do
+      let(:text) { 'text' }
+
+      it 'paragraphizes the text' do
+        is_expected.to eq '<p>text</p>'
+      end
+    end
+
+    context 'given text containing line feeds' do
+      let(:text) { "line\nfeed" }
+
+      it 'removes line feeds' do
+        is_expected.not_to include "\n"
+      end
+    end
+
+    context 'given text containing linkable mentions' do
+      let(:preloaded_accounts) { [Fabricate(:account, username: 'alice')] }
+      let(:text) { '@alice' }
+
+      it 'creates a mention link' do
+        is_expected.to include '<a href="https://cb6e6126.ngrok.io/@alice" class="u-url mention">@<span>alice</span></a></span>'
+      end
+    end
+
+    context 'given text containing unlinkable mentions' do
+      let(:preloaded_accounts) { [] }
+      let(:text) { '@alice' }
+
+      it 'does not create a mention link' do
+        is_expected.to include '@alice'
+      end
+    end
+
+    context 'given a stand-alone medium URL' do
+      let(:text) { 'https://hackernoon.com/the-power-to-build-communities-a-response-to-mark-zuckerberg-3f2cac9148a4' }
+
+      it 'matches the full URL' do
+        is_expected.to include 'href="https://hackernoon.com/the-power-to-build-communities-a-response-to-mark-zuckerberg-3f2cac9148a4"'
+      end
+    end
+
+    context 'given a stand-alone google URL' do
+      let(:text) { 'http://google.com' }
+
+      it 'matches the full URL' do
+        is_expected.to include 'href="http://google.com"'
+      end
+    end
+
+    context 'given a stand-alone URL with a newer TLD' do
+      let(:text) { 'http://example.gay' }
+
+      it 'matches the full URL' do
+        is_expected.to include 'href="http://example.gay"'
+      end
+    end
+
+    context 'given a stand-alone IDN URL' do
+      let(:text) { 'https://nic.みんな/' }
+
+      it 'matches the full URL' do
+        is_expected.to include 'href="https://nic.みんな/"'
+      end
+
+      it 'has display URL' do
+        is_expected.to include '<span class="">nic.みんな/</span>'
+      end
+    end
+
+    context 'given a URL with a trailing period' do
+      let(:text) { 'http://www.mcmansionhell.com/post/156408871451/50-states-of-mcmansion-hell-scottsdale-arizona. ' }
+
+      it 'matches the full URL but not the period' do
+        is_expected.to include 'href="http://www.mcmansionhell.com/post/156408871451/50-states-of-mcmansion-hell-scottsdale-arizona"'
+      end
+    end
+
+    context 'given a URL enclosed with parentheses' do
+      let(:text) { '(http://google.com/)' }
+
+      it 'matches the full URL but not the parentheses' do
+        is_expected.to include 'href="http://google.com/"'
+      end
+    end
+
+    context 'given a URL with a trailing exclamation point' do
+      let(:text) { 'http://www.google.com!' }
+
+      it 'matches the full URL but not the exclamation point' do
+        is_expected.to include 'href="http://www.google.com"'
+      end
+    end
+
+    context 'given a URL with a trailing single quote' do
+      let(:text) { "http://www.google.com'" }
+
+      it 'matches the full URL but not the single quote' do
+        is_expected.to include 'href="http://www.google.com"'
+      end
+    end
+
+    context 'given a URL with a trailing angle bracket' do
+      let(:text) { 'http://www.google.com>' }
+
+      it 'matches the full URL but not the angle bracket' do
+        is_expected.to include 'href="http://www.google.com"'
+      end
+    end
+
+    context 'given a URL with a query string' do
+      context 'with escaped unicode character' do
+        let(:text) { 'https://www.ruby-toolbox.com/search?utf8=%E2%9C%93&q=autolink' }
+
+        it 'matches the full URL' do
+          is_expected.to include 'href="https://www.ruby-toolbox.com/search?utf8=%E2%9C%93&amp;q=autolink"'
+        end
+      end
+
+      context 'with unicode character' do
+        let(:text) { 'https://www.ruby-toolbox.com/search?utf8=✓&q=autolink' }
+
+        it 'matches the full URL' do
+          is_expected.to include 'href="https://www.ruby-toolbox.com/search?utf8=✓&amp;q=autolink"'
+        end
+      end
+
+      context 'with unicode character at the end' do
+        let(:text) { 'https://www.ruby-toolbox.com/search?utf8=✓' }
+
+        it 'matches the full URL' do
+          is_expected.to include 'href="https://www.ruby-toolbox.com/search?utf8=✓"'
+        end
+      end
+
+      context 'with escaped and not escaped unicode characters' do
+        let(:text) { 'https://www.ruby-toolbox.com/search?utf8=%E2%9C%93&utf81=✓&q=autolink' }
+
+        it 'preserves escaped unicode characters' do
+          is_expected.to include 'href="https://www.ruby-toolbox.com/search?utf8=%E2%9C%93&amp;utf81=✓&amp;q=autolink"'
+        end
+      end
+    end
+
+    context 'given a URL with parentheses in it' do
+      let(:text) { 'https://en.wikipedia.org/wiki/Diaspora_(software)' }
+
+      it 'matches the full URL' do
+        is_expected.to include 'href="https://en.wikipedia.org/wiki/Diaspora_(software)"'
+      end
+    end
+
+    context 'given a URL in quotation marks' do
+      let(:text) { '"https://example.com/"' }
+
+      it 'does not match the quotation marks' do
+        is_expected.to include 'href="https://example.com/"'
+      end
+    end
+
+    context 'given a URL in angle brackets' do
+      let(:text) { '<https://example.com/>' }
+
+      it 'does not match the angle brackets' do
+        is_expected.to include 'href="https://example.com/"'
+      end
+    end
+
+    context 'given a URL with Japanese path string' do
+      let(:text) { 'https://ja.wikipedia.org/wiki/日本' }
+
+      it 'matches the full URL' do
+        is_expected.to include 'href="https://ja.wikipedia.org/wiki/日本"'
+      end
+    end
+
+    context 'given a URL with Korean path string' do
+      let(:text) { 'https://ko.wikipedia.org/wiki/대한민국' }
+
+      it 'matches the full URL' do
+        is_expected.to include 'href="https://ko.wikipedia.org/wiki/대한민국"'
+      end
+    end
+
+    context 'given a URL with a full-width space' do
+      let(:text) { 'https://example.com/ abc123' }
+
+      it 'does not match the full-width space' do
+        is_expected.to include 'href="https://example.com/"'
+      end
+    end
+
+    context 'given a URL in Japanese quotation marks' do
+      let(:text) { '「[https://example.org/」' }
+
+      it 'does not match the quotation marks' do
+        is_expected.to include 'href="https://example.org/"'
+      end
+    end
+
+    context 'given a URL with Simplified Chinese path string' do
+      let(:text) { 'https://baike.baidu.com/item/中华人民共和国' }
+
+      it 'matches the full URL' do
+        is_expected.to include 'href="https://baike.baidu.com/item/中华人民共和国"'
+      end
+    end
+
+    context 'given a URL with Traditional Chinese path string' do
+      let(:text) { 'https://zh.wikipedia.org/wiki/臺灣' }
+
+      it 'matches the full URL' do
+        is_expected.to include 'href="https://zh.wikipedia.org/wiki/臺灣"'
+      end
+    end
+
+    context 'given a URL containing unsafe code (XSS attack, visible part)' do
+      let(:text) { %q{http://example.com/b<del>b</del>} }
+
+      it 'does not include the HTML in the URL' do
+        is_expected.to include '"http://example.com/b"'
+      end
+
+      it 'escapes the HTML' do
+        is_expected.to include '&lt;del&gt;b&lt;/del&gt;'
+      end
+    end
+
+    context 'given a URL containing unsafe code (XSS attack, invisible part)' do
+      let(:text) { %q{http://example.com/blahblahblahblah/a<script>alert("Hello")</script>} }
+
+      it 'does not include the HTML in the URL' do
+        is_expected.to include '"http://example.com/blahblahblahblah/a"'
+      end
+
+      it 'escapes the HTML' do
+        is_expected.to include '&lt;script&gt;alert(&quot;Hello&quot;)&lt;/script&gt;'
+      end
+    end
+
+    context 'given text containing HTML code (script tag)' do
+      let(:text) { '<script>alert("Hello")</script>' }
+
+      it 'escapes the HTML' do
+        is_expected.to include '<p>&lt;script&gt;alert(&quot;Hello&quot;)&lt;/script&gt;</p>'
+      end
+    end
+
+    context 'given text containing HTML (XSS attack)' do
+      let(:text) { %q{<img src="javascript:alert('XSS');">} }
+
+      it 'escapes the HTML' do
+        is_expected.to include '<p>&lt;img src=&quot;javascript:alert(&#39;XSS&#39;);&quot;&gt;</p>'
+      end
+    end
+
+    context 'given an invalid URL' do
+      let(:text) { 'http://www\.google\.com' }
+
+      it 'outputs the raw URL' do
+        is_expected.to eq '<p>http://www\.google\.com</p>'
+      end
+    end
+
+    context 'given text containing a hashtag' do
+      let(:text)  { '#hashtag' }
+
+      it 'creates a hashtag link' do
+        is_expected.to include '/tags/hashtag" class="mention hashtag" rel="tag">#<span>hashtag</span></a>'
+      end
+    end
+
+    context 'given text containing a hashtag with Unicode chars' do
+      let(:text)  { '#hashtagタグ' }
+
+      it 'creates a hashtag link' do
+        is_expected.to include '/tags/hashtag%E3%82%BF%E3%82%B0" class="mention hashtag" rel="tag">#<span>hashtagタグ</span></a>'
+      end
+    end
+
+    context 'given text with a stand-alone xmpp: URI' do
+      let(:text) { 'xmpp:user@instance.com' }
+
+      it 'matches the full URI' do
+        is_expected.to include 'href="xmpp:user@instance.com"'
+      end
+    end
+
+    context 'given text with an xmpp: URI with a query-string' do
+      let(:text) { 'please join xmpp:muc@instance.com?join right now' }
+
+      it 'matches the full URI' do
+        is_expected.to include 'href="xmpp:muc@instance.com?join"'
+      end
+    end
+
+    context 'given text containing a magnet: URI' do
+      let(:text) { 'wikipedia gives this example of a magnet uri: magnet:?xt=urn:btih:c12fe1c06bba254a9dc9f519b335aa7c1367a88a' }
+
+      it 'matches the full URI' do
+        is_expected.to include 'href="magnet:?xt=urn:btih:c12fe1c06bba254a9dc9f519b335aa7c1367a88a"'
+      end
+    end
+  end
+end
diff --git a/spec/models/media_attachment_spec.rb b/spec/models/media_attachment_spec.rb
index 7360b23cf..cbd9a09c5 100644
--- a/spec/models/media_attachment_spec.rb
+++ b/spec/models/media_attachment_spec.rb
@@ -186,14 +186,6 @@ RSpec.describe MediaAttachment, type: :model do
     expect(media.valid?).to be false
   end
 
-  describe 'descriptions for remote attachments' do
-    it 'are cut off at 1500 characters' do
-      media = Fabricate(:media_attachment, description: 'foo' * 1000, remote_url: 'http://example.com/blah.jpg')
-
-      expect(media.description.size).to be <= 1_500
-    end
-  end
-
   describe 'size limit validation' do
     it 'rejects video files that are too large' do
       stub_const 'MediaAttachment::IMAGE_LIMIT', 100.megabytes
diff --git a/spec/services/activitypub/process_status_update_service_spec.rb b/spec/services/activitypub/process_status_update_service_spec.rb
index 788c7c9d9..f87adcae1 100644
--- a/spec/services/activitypub/process_status_update_service_spec.rb
+++ b/spec/services/activitypub/process_status_update_service_spec.rb
@@ -46,6 +46,29 @@ RSpec.describe ActivityPub::ProcessStatusUpdateService, type: :service do
       expect(status.reload.spoiler_text).to eq 'Show more'
     end
 
+    context 'with no changes at all' do
+      let(:payload) do
+        {
+          '@context': 'https://www.w3.org/ns/activitystreams',
+          id: 'foo',
+          type: 'Note',
+          content: 'Hello world',
+        }
+      end
+
+      before do
+        subject.call(status, json)
+      end
+
+      it 'does not create any edits' do
+        expect(status.reload.edits).to be_empty
+      end
+
+      it 'does not mark status as edited' do
+        expect(status.edited?).to be false
+      end
+    end
+
     context 'with no changes and originally with no ordered_media_attachment_ids' do
       let(:payload) do
         {
@@ -61,8 +84,12 @@ RSpec.describe ActivityPub::ProcessStatusUpdateService, type: :service do
         subject.call(status, json)
       end
 
-      it 'does not record an update' do
-        expect(status.reload.edited?).to be false
+      it 'does not create any edits' do
+        expect(status.reload.edits).to be_empty
+      end
+
+      it 'does not mark status as edited' do
+        expect(status.edited?).to be false
       end
     end
 
diff --git a/spec/services/after_block_service_spec.rb b/spec/services/after_block_service_spec.rb
index fe5b26b2b..c09425d7c 100644
--- a/spec/services/after_block_service_spec.rb
+++ b/spec/services/after_block_service_spec.rb
@@ -1,9 +1,7 @@
 require 'rails_helper'
 
 RSpec.describe AfterBlockService, type: :service do
-  subject do
-    -> { described_class.new.call(account, target_account) }
-  end
+  subject { described_class.new.call(account, target_account) }
 
   let(:account)              { Fabricate(:account) }
   let(:target_account)       { Fabricate(:account) }
@@ -24,7 +22,7 @@ RSpec.describe AfterBlockService, type: :service do
       FeedManager.instance.push_to_home(account, other_account_status)
       FeedManager.instance.push_to_home(account, other_account_reblog)
 
-      is_expected.to change {
+      expect { subject }.to change {
         Redis.current.zrange(home_timeline_key, 0, -1)
       }.from([status.id.to_s, other_account_status.id.to_s, other_account_reblog.id.to_s]).to([other_account_status.id.to_s])
     end
@@ -43,7 +41,7 @@ RSpec.describe AfterBlockService, type: :service do
       FeedManager.instance.push_to_list(list, other_account_status)
       FeedManager.instance.push_to_list(list, other_account_reblog)
 
-      is_expected.to change {
+      expect { subject }.to change {
         Redis.current.zrange(list_timeline_key, 0, -1)
       }.from([status.id.to_s, other_account_status.id.to_s, other_account_reblog.id.to_s]).to([other_account_status.id.to_s])
     end
diff --git a/spec/services/delete_account_service_spec.rb b/spec/services/delete_account_service_spec.rb
index 9c785fc17..1fbe4d07c 100644
--- a/spec/services/delete_account_service_spec.rb
+++ b/spec/services/delete_account_service_spec.rb
@@ -23,12 +23,10 @@ RSpec.describe DeleteAccountService, type: :service do
 
     let!(:account_note) { Fabricate(:account_note, account: account) }
 
-    subject do
-      -> { described_class.new.call(account) }
-    end
+    subject { described_class.new.call(account) }
 
     it 'deletes associated owned records' do
-      is_expected.to change {
+      expect { subject }.to change {
         [
           account.statuses,
           account.media_attachments,
@@ -43,7 +41,7 @@ RSpec.describe DeleteAccountService, type: :service do
     end
 
     it 'deletes associated target records' do
-      is_expected.to change {
+      expect { subject }.to change {
         [
           AccountPin.where(target_account: account),
         ].map(&:count)
@@ -51,7 +49,7 @@ RSpec.describe DeleteAccountService, type: :service do
     end
 
     it 'deletes associated target notifications' do
-      is_expected.to change {
+      expect { subject }.to change {
         [
           'poll', 'favourite', 'status', 'mention', 'follow'
         ].map { |type| Notification.where(type: type).count }
@@ -73,7 +71,7 @@ RSpec.describe DeleteAccountService, type: :service do
       let!(:local_follower) { Fabricate(:account) }
 
       it 'sends a delete actor activity to all known inboxes' do
-        subject.call
+        subject
         expect(a_request(:post, "https://alice.com/inbox")).to have_been_made.once
         expect(a_request(:post, "https://bob.com/inbox")).to have_been_made.once
       end
@@ -91,7 +89,7 @@ RSpec.describe DeleteAccountService, type: :service do
       let!(:local_follower) { Fabricate(:account) }
 
       it 'sends a reject follow to follower inboxes' do
-        subject.call
+        subject
         expect(a_request(:post, account.inbox_url)).to have_been_made.once
       end
     end
diff --git a/spec/services/mute_service_spec.rb b/spec/services/mute_service_spec.rb
index 4bb839b8d..bdec1c67b 100644
--- a/spec/services/mute_service_spec.rb
+++ b/spec/services/mute_service_spec.rb
@@ -1,9 +1,7 @@
 require 'rails_helper'
 
 RSpec.describe MuteService, type: :service do
-  subject do
-    -> { described_class.new.call(account, target_account) }
-  end
+  subject { described_class.new.call(account, target_account) }
 
   let(:account) { Fabricate(:account) }
   let(:target_account) { Fabricate(:account) }
@@ -21,45 +19,41 @@ RSpec.describe MuteService, type: :service do
       FeedManager.instance.push_to_home(account, status)
       FeedManager.instance.push_to_home(account, other_account_status)
 
-      is_expected.to change {
+      expect { subject }.to change {
         Redis.current.zrange(home_timeline_key, 0, -1)
       }.from([status.id.to_s, other_account_status.id.to_s]).to([other_account_status.id.to_s])
     end
   end
 
   it 'mutes account' do
-    is_expected.to change {
+    expect { subject }.to change {
       account.muting?(target_account)
     }.from(false).to(true)
   end
 
   context 'without specifying a notifications parameter' do
     it 'mutes notifications from the account' do
-      is_expected.to change {
+      expect { subject }.to change {
         account.muting_notifications?(target_account)
       }.from(false).to(true)
     end
   end
 
   context 'with a true notifications parameter' do
-    subject do
-      -> { described_class.new.call(account, target_account, notifications: true) }
-    end
+    subject { described_class.new.call(account, target_account, notifications: true) }
 
     it 'mutes notifications from the account' do
-      is_expected.to change {
+      expect { subject }.to change {
         account.muting_notifications?(target_account)
       }.from(false).to(true)
     end
   end
 
   context 'with a false notifications parameter' do
-    subject do
-      -> { described_class.new.call(account, target_account, notifications: false) }
-    end
+    subject { described_class.new.call(account, target_account, notifications: false) }
 
     it 'does not mute notifications from the account' do
-      is_expected.to_not change {
+      expect { subject }.to_not change {
         account.muting_notifications?(target_account)
       }.from(false)
     end
diff --git a/spec/services/notify_service_spec.rb b/spec/services/notify_service_spec.rb
index 83e62ff36..67dd0483b 100644
--- a/spec/services/notify_service_spec.rb
+++ b/spec/services/notify_service_spec.rb
@@ -1,9 +1,7 @@
 require 'rails_helper'
 
 RSpec.describe NotifyService, type: :service do
-  subject do
-    -> { described_class.new.call(recipient, type, activity) }
-  end
+  subject { described_class.new.call(recipient, type, activity) }
 
   let(:user) { Fabricate(:user) }
   let(:recipient) { user.account }
@@ -11,42 +9,42 @@ RSpec.describe NotifyService, type: :service do
   let(:activity) { Fabricate(:follow, account: sender, target_account: recipient) }
   let(:type) { :follow }
 
-  it { is_expected.to change(Notification, :count).by(1) }
+  it { expect { subject }.to change(Notification, :count).by(1) }
 
   it 'does not notify when sender is blocked' do
     recipient.block!(sender)
-    is_expected.to_not change(Notification, :count)
+    expect { subject }.to_not change(Notification, :count)
   end
 
   it 'does not notify when sender is muted with hide_notifications' do
     recipient.mute!(sender, notifications: true)
-    is_expected.to_not change(Notification, :count)
+    expect { subject }.to_not change(Notification, :count)
   end
 
   it 'does notify when sender is muted without hide_notifications' do
     recipient.mute!(sender, notifications: false)
-    is_expected.to change(Notification, :count)
+    expect { subject }.to change(Notification, :count)
   end
 
   it 'does not notify when sender\'s domain is blocked' do
     recipient.block_domain!(sender.domain)
-    is_expected.to_not change(Notification, :count)
+    expect { subject }.to_not change(Notification, :count)
   end
 
   it 'does still notify when sender\'s domain is blocked but sender is followed' do
     recipient.block_domain!(sender.domain)
     recipient.follow!(sender)
-    is_expected.to change(Notification, :count)
+    expect { subject }.to change(Notification, :count)
   end
 
   it 'does not notify when sender is silenced and not followed' do
     sender.silence!
-    is_expected.to_not change(Notification, :count)
+    expect { subject }.to_not change(Notification, :count)
   end
 
   it 'does not notify when recipient is suspended' do
     recipient.suspend!
-    is_expected.to_not change(Notification, :count)
+    expect { subject }.to_not change(Notification, :count)
   end
   
   context 'for direct messages' do
@@ -61,7 +59,7 @@ RSpec.describe NotifyService, type: :service do
       let(:enabled) { true }
 
       it 'does not notify' do
-        is_expected.to_not change(Notification, :count)
+        expect { subject }.to_not change(Notification, :count)
       end
 
       context 'if the message chain is initiated by recipient, but is not direct message' do
@@ -70,7 +68,7 @@ RSpec.describe NotifyService, type: :service do
         let(:activity) { Fabricate(:mention, account: recipient, status: Fabricate(:status, account: sender, visibility: :direct, thread: reply_to)) }
 
         it 'does not notify' do
-          is_expected.to_not change(Notification, :count)
+          expect { subject }.to_not change(Notification, :count)
         end
       end
 
@@ -81,7 +79,7 @@ RSpec.describe NotifyService, type: :service do
         let(:activity) { Fabricate(:mention, account: recipient, status: Fabricate(:status, account: sender, visibility: :direct, thread: dummy_reply)) }
 
         it 'does not notify' do
-          is_expected.to_not change(Notification, :count)
+          expect { subject }.to_not change(Notification, :count)
         end
       end
 
@@ -91,7 +89,7 @@ RSpec.describe NotifyService, type: :service do
         let(:activity) { Fabricate(:mention, account: recipient, status: Fabricate(:status, account: sender, visibility: :direct, thread: reply_to)) }
 
         it 'does notify' do
-          is_expected.to change(Notification, :count)
+          expect { subject }.to change(Notification, :count)
         end
       end
     end
@@ -100,7 +98,7 @@ RSpec.describe NotifyService, type: :service do
       let(:enabled) { false }
 
       it 'does notify' do
-        is_expected.to change(Notification, :count)
+        expect { subject }.to change(Notification, :count)
       end
     end
   end
@@ -112,17 +110,17 @@ RSpec.describe NotifyService, type: :service do
 
     it 'shows reblogs by default' do
       recipient.follow!(sender)
-      is_expected.to change(Notification, :count)
+      expect { subject }.to change(Notification, :count)
     end
 
     it 'shows reblogs when explicitly enabled' do
       recipient.follow!(sender, reblogs: true)
-      is_expected.to change(Notification, :count)
+      expect { subject }.to change(Notification, :count)
     end
 
     it 'shows reblogs when disabled' do
       recipient.follow!(sender, reblogs: false)
-      is_expected.to change(Notification, :count)
+      expect { subject }.to change(Notification, :count)
     end
   end
 
@@ -134,12 +132,12 @@ RSpec.describe NotifyService, type: :service do
 
     it 'does not notify when conversation is muted' do
       recipient.mute_conversation!(activity.status.conversation)
-      is_expected.to_not change(Notification, :count)
+      expect { subject }.to_not change(Notification, :count)
     end
 
     it 'does not notify when it is a reply to a blocked user' do
       recipient.block!(asshole)
-      is_expected.to_not change(Notification, :count)
+      expect { subject }.to_not change(Notification, :count)
     end
   end
 
@@ -147,7 +145,7 @@ RSpec.describe NotifyService, type: :service do
     let(:sender) { recipient }
 
     it 'does not notify when recipient is the sender' do
-      is_expected.to_not change(Notification, :count)
+      expect { subject }.to_not change(Notification, :count)
     end
   end
 
@@ -163,7 +161,7 @@ RSpec.describe NotifyService, type: :service do
       let(:enabled) { true }
 
       it 'sends email' do
-        is_expected.to change(ActionMailer::Base.deliveries, :count).by(1)
+        expect { subject }.to change(ActionMailer::Base.deliveries, :count).by(1)
       end
     end
 
@@ -171,7 +169,7 @@ RSpec.describe NotifyService, type: :service do
       let(:enabled) { false }
 
       it "doesn't send email" do
-        is_expected.to_not change(ActionMailer::Base.deliveries, :count).from(0)
+        expect { subject }.to_not change(ActionMailer::Base.deliveries, :count).from(0)
       end
     end
   end
diff --git a/spec/services/suspend_account_service_spec.rb b/spec/services/suspend_account_service_spec.rb
index cf7eb257a..5d45e4ffd 100644
--- a/spec/services/suspend_account_service_spec.rb
+++ b/spec/services/suspend_account_service_spec.rb
@@ -5,9 +5,7 @@ RSpec.describe SuspendAccountService, type: :service do
     let!(:local_follower) { Fabricate(:user, current_sign_in_at: 1.hour.ago).account }
     let!(:list)           { Fabricate(:list, account: local_follower) }
 
-    subject do
-      -> { described_class.new.call(account) }
-    end
+    subject { described_class.new.call(account) }
 
     before do
       allow(FeedManager.instance).to receive(:unmerge_from_home).and_return(nil)
@@ -18,13 +16,13 @@ RSpec.describe SuspendAccountService, type: :service do
     end
 
     it "unmerges from local followers' feeds" do
-      subject.call
+      subject
       expect(FeedManager.instance).to have_received(:unmerge_from_home).with(account, local_follower)
       expect(FeedManager.instance).to have_received(:unmerge_from_list).with(account, list)
     end
 
     it 'marks account as suspended' do
-      is_expected.to change { account.suspended? }.from(false).to(true)
+      expect { subject }.to change { account.suspended? }.from(false).to(true)
     end
   end
 
@@ -51,7 +49,7 @@ RSpec.describe SuspendAccountService, type: :service do
       end
 
       it 'sends an update actor to followers and reporters' do
-        subject.call
+        subject
         expect(a_request(:post, remote_follower.inbox_url).with { |req| match_update_actor_request(req, account) }).to have_been_made.once
         expect(a_request(:post, remote_reporter.inbox_url).with { |req| match_update_actor_request(req, account) }).to have_been_made.once
       end
@@ -77,7 +75,7 @@ RSpec.describe SuspendAccountService, type: :service do
       end
 
       it 'sends a reject follow' do
-        subject.call
+        subject
         expect(a_request(:post, account.inbox_url).with { |req| match_reject_follow_request(req, account, local_followee) }).to have_been_made.once
       end
     end
diff --git a/spec/services/unsuspend_account_service_spec.rb b/spec/services/unsuspend_account_service_spec.rb
index 0593beb6f..3ac4cc085 100644
--- a/spec/services/unsuspend_account_service_spec.rb
+++ b/spec/services/unsuspend_account_service_spec.rb
@@ -5,9 +5,7 @@ RSpec.describe UnsuspendAccountService, type: :service do
     let!(:local_follower) { Fabricate(:user, current_sign_in_at: 1.hour.ago).account }
     let!(:list)           { Fabricate(:list, account: local_follower) }
 
-    subject do
-      -> { described_class.new.call(account) }
-    end
+    subject { described_class.new.call(account) }
 
     before do
       allow(FeedManager.instance).to receive(:merge_into_home).and_return(nil)
@@ -33,7 +31,7 @@ RSpec.describe UnsuspendAccountService, type: :service do
     end
 
     it 'marks account as unsuspended' do
-      is_expected.to change { account.suspended? }.from(true).to(false)
+      expect { subject }.to change { account.suspended? }.from(true).to(false)
     end
 
     include_examples 'common behavior' do
@@ -47,13 +45,13 @@ RSpec.describe UnsuspendAccountService, type: :service do
       end
 
       it "merges back into local followers' feeds" do
-        subject.call
+        subject
         expect(FeedManager.instance).to have_received(:merge_into_home).with(account, local_follower)
         expect(FeedManager.instance).to have_received(:merge_into_list).with(account, list)
       end
 
       it 'sends an update actor to followers and reporters' do
-        subject.call
+        subject
         expect(a_request(:post, remote_follower.inbox_url).with { |req| match_update_actor_request(req, account) }).to have_been_made.once
         expect(a_request(:post, remote_reporter.inbox_url).with { |req| match_update_actor_request(req, account) }).to have_been_made.once
       end
@@ -75,18 +73,18 @@ RSpec.describe UnsuspendAccountService, type: :service do
         end
 
         it 're-fetches the account' do
-          subject.call
+          subject
           expect(resolve_account_service).to have_received(:call).with(account)
         end
 
         it "merges back into local followers' feeds" do
-          subject.call
+          subject
           expect(FeedManager.instance).to have_received(:merge_into_home).with(account, local_follower)
           expect(FeedManager.instance).to have_received(:merge_into_list).with(account, list)
         end
 
         it 'marks account as unsuspended' do
-          is_expected.to change { account.suspended? }.from(true).to(false)
+          expect { subject }.to change { account.suspended? }.from(true).to(false)
         end
       end
 
@@ -99,18 +97,18 @@ RSpec.describe UnsuspendAccountService, type: :service do
         end
 
         it 're-fetches the account' do
-          subject.call
+          subject
           expect(resolve_account_service).to have_received(:call).with(account)
         end
 
         it "does not merge back into local followers' feeds" do
-          subject.call
+          subject
           expect(FeedManager.instance).to_not have_received(:merge_into_home).with(account, local_follower)
           expect(FeedManager.instance).to_not have_received(:merge_into_list).with(account, list)
         end
 
         it 'does not mark the account as unsuspended' do
-          is_expected.not_to change { account.suspended? }
+          expect { subject }.not_to change { account.suspended? }
         end
       end
 
@@ -120,12 +118,12 @@ RSpec.describe UnsuspendAccountService, type: :service do
         end
 
         it 're-fetches the account' do
-          subject.call
+          subject
           expect(resolve_account_service).to have_received(:call).with(account)
         end
 
         it "does not merge back into local followers' feeds" do
-          subject.call
+          subject
           expect(FeedManager.instance).to_not have_received(:merge_into_home).with(account, local_follower)
           expect(FeedManager.instance).to_not have_received(:merge_into_list).with(account, list)
         end
diff --git a/spec/services/update_status_service_spec.rb b/spec/services/update_status_service_spec.rb
index 78cc89cd4..71a73be5b 100644
--- a/spec/services/update_status_service_spec.rb
+++ b/spec/services/update_status_service_spec.rb
@@ -3,6 +3,23 @@ require 'rails_helper'
 RSpec.describe UpdateStatusService, type: :service do
   subject { described_class.new }
 
+  context 'when nothing changes' do
+    let!(:status) { Fabricate(:status, text: 'Foo', language: 'en') }
+
+    before do
+      allow(ActivityPub::DistributionWorker).to receive(:perform_async)
+      subject.call(status, status.account_id, text: 'Foo')
+    end
+
+    it 'does not create an edit' do
+      expect(status.reload.edits).to be_empty
+    end
+
+    it 'does not notify anyone' do
+      expect(ActivityPub::DistributionWorker).to_not have_received(:perform_async)
+    end
+  end
+
   context 'when text changes' do
     let!(:status) { Fabricate(:status, text: 'Foo') }
     let(:preview_card) { Fabricate(:preview_card) }