about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--app/controllers/admin/account_actions_controller.rb2
-rw-r--r--app/controllers/admin/export_domain_allows_controller.rb4
-rw-r--r--app/controllers/admin/export_domain_blocks_controller.rb24
-rw-r--r--app/controllers/admin/reports/actions_controller.rb17
-rw-r--r--app/controllers/api/v1/admin/trends/tags_controller.rb8
-rw-r--r--app/controllers/api/v1/statuses_controller.rb7
-rw-r--r--app/controllers/concerns/admin_export_controller_concern.rb10
-rw-r--r--app/controllers/concerns/signature_verification.rb16
-rw-r--r--app/javascript/flavours/glitch/actions/compose.js46
-rw-r--r--app/javascript/flavours/glitch/actions/tags.js82
-rw-r--r--app/javascript/flavours/glitch/components/admin/Trends.js2
-rw-r--r--app/javascript/flavours/glitch/features/account/components/header.js4
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/action_bar.js2
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/upload.js8
-rw-r--r--app/javascript/flavours/glitch/features/followed_tags/index.js89
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/focal_point_modal.js2
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/sign_in_banner.js2
-rw-r--r--app/javascript/flavours/glitch/features/ui/index.js6
-rw-r--r--app/javascript/flavours/glitch/features/ui/util/async-components.js4
-rw-r--r--app/javascript/flavours/glitch/initial_state.js2
-rw-r--r--app/javascript/flavours/glitch/reducers/compose.js2
-rw-r--r--app/javascript/flavours/glitch/reducers/followed_tags.js42
-rw-r--r--app/javascript/flavours/glitch/reducers/index.js2
-rw-r--r--app/javascript/flavours/glitch/styles/admin.scss9
-rw-r--r--app/javascript/flavours/glitch/styles/components/compose_form.scss2
-rw-r--r--app/javascript/mastodon/actions/compose.js46
-rw-r--r--app/javascript/mastodon/actions/tags.js82
-rw-r--r--app/javascript/mastodon/components/admin/Trends.js2
-rw-r--r--app/javascript/mastodon/features/account/components/header.js4
-rw-r--r--app/javascript/mastodon/features/compose/components/action_bar.js2
-rw-r--r--app/javascript/mastodon/features/compose/components/upload.js4
-rw-r--r--app/javascript/mastodon/features/followed_tags/index.js89
-rw-r--r--app/javascript/mastodon/features/ui/components/focal_point_modal.js2
-rw-r--r--app/javascript/mastodon/features/ui/components/sign_in_banner.js2
-rw-r--r--app/javascript/mastodon/features/ui/index.js6
-rw-r--r--app/javascript/mastodon/features/ui/util/async-components.js4
-rw-r--r--app/javascript/mastodon/initial_state.js2
-rw-r--r--app/javascript/mastodon/locales/defaultMessages.json8
-rw-r--r--app/javascript/mastodon/locales/en.json3
-rw-r--r--app/javascript/mastodon/reducers/compose.js2
-rw-r--r--app/javascript/mastodon/reducers/followed_tags.js42
-rw-r--r--app/javascript/mastodon/reducers/index.js2
-rw-r--r--app/javascript/styles/mastodon/admin.scss9
-rw-r--r--app/javascript/styles/mastodon/components.scss2
-rw-r--r--app/lib/admin/system_check/elasticsearch_check.rb11
-rw-r--r--app/models/account.rb4
-rw-r--r--app/models/admin/import.rb43
-rw-r--r--app/models/admin/status_batch_action.rb9
-rw-r--r--app/models/concerns/account_finder_concern.rb6
-rw-r--r--app/models/form/admin_settings.rb2
-rw-r--r--app/serializers/initial_state_serializer.rb1
-rw-r--r--app/serializers/rest/account_serializer.rb14
-rw-r--r--app/services/activitypub/fetch_remote_actor_service.rb1
-rw-r--r--app/services/update_status_service.rb11
-rw-r--r--app/views/admin/announcements/edit.html.haml2
-rw-r--r--app/views/admin/report_notes/_report_note.html.haml2
-rw-r--r--app/views/admin/reports/_actions.html.haml2
-rw-r--r--app/views/admin/reports/actions/preview.html.haml78
-rw-r--r--app/views/admin/settings/discovery/show.html.haml3
-rw-r--r--config/i18n-tasks.yml4
-rw-r--r--config/locales/en.yml22
-rw-r--r--config/locales/simple_form.en.yml2
-rw-r--r--config/routes.rb7
-rw-r--r--config/settings.yml1
-rw-r--r--db/seeds/02_instance_actor.rb2
-rw-r--r--lib/mastodon/domains_cli.rb77
-rw-r--r--spec/controllers/admin/export_domain_blocks_controller_spec.rb34
-rw-r--r--spec/controllers/admin/reports/actions_controller_spec.rb128
-rw-r--r--spec/controllers/concerns/signature_verification_spec.rb107
-rw-r--r--spec/fixtures/files/domain_blocks.csv6
-rw-r--r--spec/fixtures/files/domain_blocks_list.txt3
-rw-r--r--spec/services/update_status_service_spec.rb22
72 files changed, 1159 insertions, 152 deletions
diff --git a/app/controllers/admin/account_actions_controller.rb b/app/controllers/admin/account_actions_controller.rb
index 3f2e28b6a..e89404b60 100644
--- a/app/controllers/admin/account_actions_controller.rb
+++ b/app/controllers/admin/account_actions_controller.rb
@@ -21,7 +21,7 @@ module Admin
       account_action.save!
 
       if account_action.with_report?
-        redirect_to admin_reports_path
+        redirect_to admin_reports_path, notice: I18n.t('admin.reports.processed_msg', id: params[:report_id])
       else
         redirect_to admin_account_path(@account.id)
       end
diff --git a/app/controllers/admin/export_domain_allows_controller.rb b/app/controllers/admin/export_domain_allows_controller.rb
index 57fb12c62..adfc39da2 100644
--- a/app/controllers/admin/export_domain_allows_controller.rb
+++ b/app/controllers/admin/export_domain_allows_controller.rb
@@ -23,9 +23,7 @@ module Admin
         @import = Admin::Import.new(import_params)
         return render :new unless @import.validate
 
-        parse_import_data!(export_headers)
-
-        @data.take(Admin::Import::ROWS_PROCESSING_LIMIT).each do |row|
+        @import.csv_rows.each do |row|
           domain = row['#domain'].strip
           next if DomainAllow.allowed?(domain)
 
diff --git a/app/controllers/admin/export_domain_blocks_controller.rb b/app/controllers/admin/export_domain_blocks_controller.rb
index fb0cd05d2..816422d4f 100644
--- a/app/controllers/admin/export_domain_blocks_controller.rb
+++ b/app/controllers/admin/export_domain_blocks_controller.rb
@@ -23,24 +23,30 @@ module Admin
       @import = Admin::Import.new(import_params)
       return render :new unless @import.validate
 
-      parse_import_data!(export_headers)
-
       @global_private_comment = I18n.t('admin.export_domain_blocks.import.private_comment_template', source: @import.data_file_name, date: I18n.l(Time.now.utc))
 
       @form = Form::DomainBlockBatch.new
-      @domain_blocks = @data.take(Admin::Import::ROWS_PROCESSING_LIMIT).filter_map do |row|
+      @domain_blocks = @import.csv_rows.filter_map do |row|
         domain = row['#domain'].strip
         next if DomainBlock.rule_for(domain).present?
 
         domain_block = DomainBlock.new(domain: domain,
-                                       severity: row['#severity'].strip,
-                                       reject_media: row['#reject_media'].strip,
-                                       reject_reports: row['#reject_reports'].strip,
+                                       severity: row.fetch('#severity', :suspend),
+                                       reject_media: row.fetch('#reject_media', false),
+                                       reject_reports: row.fetch('#reject_reports', false),
                                        private_comment: @global_private_comment,
-                                       public_comment: row['#public_comment']&.strip,
-                                       obfuscate: row['#obfuscate'].strip)
+                                       public_comment: row['#public_comment'],
+                                       obfuscate: row.fetch('#obfuscate', false))
+
+        if domain_block.invalid?
+          flash.now[:alert] = I18n.t('admin.export_domain_blocks.invalid_domain_block', error: domain_block.errors.full_messages.join(', '))
+          next
+        end
 
-        domain_block if domain_block.valid?
+        domain_block
+      rescue ArgumentError => e
+        flash.now[:alert] = I18n.t('admin.export_domain_blocks.invalid_domain_block', error: e.message)
+        next
       end
 
       @warning_domains = Instance.where(domain: @domain_blocks.map(&:domain)).where('EXISTS (SELECT 1 FROM follows JOIN accounts ON follows.account_id = accounts.id OR follows.target_account_id = accounts.id WHERE accounts.domain = instances.domain)').pluck(:domain)
diff --git a/app/controllers/admin/reports/actions_controller.rb b/app/controllers/admin/reports/actions_controller.rb
index 5cb5c744f..554f7906f 100644
--- a/app/controllers/admin/reports/actions_controller.rb
+++ b/app/controllers/admin/reports/actions_controller.rb
@@ -3,6 +3,11 @@
 class Admin::Reports::ActionsController < Admin::BaseController
   before_action :set_report
 
+  def preview
+    authorize @report, :show?
+    @moderation_action = action_from_button
+  end
+
   def create
     authorize @report, :show?
 
@@ -13,7 +18,8 @@ class Admin::Reports::ActionsController < Admin::BaseController
         status_ids: @report.status_ids,
         current_account: current_account,
         report_id: @report.id,
-        send_email_notification: !@report.spam?
+        send_email_notification: !@report.spam?,
+        text: params[:text]
       )
 
       status_batch_action.save!
@@ -23,13 +29,16 @@ class Admin::Reports::ActionsController < Admin::BaseController
         report_id: @report.id,
         target_account: @report.target_account,
         current_account: current_account,
-        send_email_notification: !@report.spam?
+        send_email_notification: !@report.spam?,
+        text: params[:text]
       )
 
       account_action.save!
+    else
+      return redirect_to admin_report_path(@report), alert: I18n.t('admin.reports.unknown_action_msg', action: action_from_button)
     end
 
-    redirect_to admin_reports_path
+    redirect_to admin_reports_path, notice: I18n.t('admin.reports.processed_msg', id: @report.id)
   end
 
   private
@@ -47,6 +56,8 @@ class Admin::Reports::ActionsController < Admin::BaseController
       'silence'
     elsif params[:suspend]
       'suspend'
+    elsif params[:moderation_action]
+      params[:moderation_action]
     end
   end
 end
diff --git a/app/controllers/api/v1/admin/trends/tags_controller.rb b/app/controllers/api/v1/admin/trends/tags_controller.rb
index f3c0c4b6b..e77df3021 100644
--- a/app/controllers/api/v1/admin/trends/tags_controller.rb
+++ b/app/controllers/api/v1/admin/trends/tags_controller.rb
@@ -3,6 +3,14 @@
 class Api::V1::Admin::Trends::TagsController < Api::V1::Trends::TagsController
   before_action -> { authorize_if_got_token! :'admin:read' }
 
+  def index
+    if current_user&.can?(:manage_taxonomies)
+      render json: @tags, each_serializer: REST::Admin::TagSerializer
+    else
+      super
+    end
+  end
+
   private
 
   def enabled?
diff --git a/app/controllers/api/v1/statuses_controller.rb b/app/controllers/api/v1/statuses_controller.rb
index e2e48f633..3a9cf056b 100644
--- a/app/controllers/api/v1/statuses_controller.rb
+++ b/app/controllers/api/v1/statuses_controller.rb
@@ -80,6 +80,7 @@ class Api::V1::StatusesController < Api::BaseController
       current_account.id,
       text: status_params[:status],
       media_ids: status_params[:media_ids],
+      media_attributes: status_params[:media_attributes],
       sensitive: status_params[:sensitive],
       language: status_params[:language],
       spoiler_text: status_params[:spoiler_text],
@@ -131,6 +132,12 @@ class Api::V1::StatusesController < Api::BaseController
       :scheduled_at,
       :content_type,
       media_ids: [],
+      media_attributes: [
+        :id,
+        :thumbnail,
+        :description,
+        :focus,
+      ],
       poll: [
         :multiple,
         :hide_totals,
diff --git a/app/controllers/concerns/admin_export_controller_concern.rb b/app/controllers/concerns/admin_export_controller_concern.rb
index b40c76557..4ac48a04b 100644
--- a/app/controllers/concerns/admin_export_controller_concern.rb
+++ b/app/controllers/concerns/admin_export_controller_concern.rb
@@ -26,14 +26,4 @@ module AdminExportControllerConcern
   def import_params
     params.require(:admin_import).permit(:data)
   end
-
-  def import_data_path
-    params[:admin_import][:data].path
-  end
-
-  def parse_import_data!(default_headers)
-    data = CSV.read(import_data_path, headers: true, encoding: 'UTF-8')
-    data = CSV.read(import_data_path, headers: default_headers, encoding: 'UTF-8') unless data.headers&.first&.strip&.include?(default_headers[0])
-    @data = data.reject(&:blank?)
-  end
 end
diff --git a/app/controllers/concerns/signature_verification.rb b/app/controllers/concerns/signature_verification.rb
index 4502da698..a9950d21f 100644
--- a/app/controllers/concerns/signature_verification.rb
+++ b/app/controllers/concerns/signature_verification.rb
@@ -46,11 +46,11 @@ module SignatureVerification
   end
 
   def require_account_signature!
-    render plain: signature_verification_failure_reason, status: signature_verification_failure_code unless signed_request_account
+    render json: signature_verification_failure_reason, status: signature_verification_failure_code unless signed_request_account
   end
 
   def require_actor_signature!
-    render plain: signature_verification_failure_reason, status: signature_verification_failure_code unless signed_request_actor
+    render json: signature_verification_failure_reason, status: signature_verification_failure_code unless signed_request_actor
   end
 
   def signed_request?
@@ -97,11 +97,11 @@ module SignatureVerification
 
     actor = stoplight_wrap_request { actor_refresh_key!(actor) }
 
-    raise SignatureVerificationError, "Public key not found for key #{signature_params['keyId']}" if actor.nil?
+    raise SignatureVerificationError, "Could not refresh public key #{signature_params['keyId']}" if actor.nil?
 
     return actor unless verify_signature(actor, signature, compare_signed_string).nil?
 
-    fail_with! "Verification failed for #{actor.to_log_human_identifier} #{actor.uri} using rsa-sha256 (RSASSA-PKCS1-v1_5 with SHA-256)"
+    fail_with! "Verification failed for #{actor.to_log_human_identifier} #{actor.uri} using rsa-sha256 (RSASSA-PKCS1-v1_5 with SHA-256)", signed_string: compare_signed_string, signature: signature_params['signature']
   rescue SignatureVerificationError => e
     fail_with! e.message
   rescue HTTP::Error, OpenSSL::SSL::SSLError => e
@@ -118,8 +118,8 @@ module SignatureVerification
 
   private
 
-  def fail_with!(message)
-    @signature_verification_failure_reason = message
+  def fail_with!(message, **options)
+    @signature_verification_failure_reason = { error: message }.merge(options)
     @signed_request_actor = nil
   end
 
@@ -209,8 +209,8 @@ module SignatureVerification
       end
 
       expires_time = Time.at(signature_params['expires'].to_i).utc if signature_params['expires'].present?
-    rescue ArgumentError
-      return false
+    rescue ArgumentError => e
+      raise SignatureVerificationError, "Invalid Date header: #{e.message}"
     end
 
     expires_time ||= created_time + 5.minutes unless created_time.nil?
diff --git a/app/javascript/flavours/glitch/actions/compose.js b/app/javascript/flavours/glitch/actions/compose.js
index 7a4af4cda..267cff563 100644
--- a/app/javascript/flavours/glitch/actions/compose.js
+++ b/app/javascript/flavours/glitch/actions/compose.js
@@ -181,6 +181,18 @@ export function submitCompose(routerHistory) {
 
     dispatch(submitComposeRequest());
 
+    // If we're editing a post with media attachments, those have not
+    // necessarily been changed on the server. Do it now in the same
+    // API call.
+    let media_attributes;
+    if (statusId !== null) {
+      media_attributes = media.map(item => ({
+        id: item.get('id'),
+        description: item.get('description'),
+        focus: item.get('focus'),
+      }));
+    }
+
     api(getState).request({
       url: statusId === null ? '/api/v1/statuses' : `/api/v1/statuses/${statusId}`,
       method: statusId === null ? 'post' : 'put',
@@ -189,6 +201,7 @@ export function submitCompose(routerHistory) {
         content_type: getState().getIn(['compose', 'content_type']),
         in_reply_to_id: getState().getIn(['compose', 'in_reply_to'], null),
         media_ids: media.map(item => item.get('id')),
+        media_attributes,
         sensitive: getState().getIn(['compose', 'sensitive']) || (spoilerText.length > 0 && media.size !== 0),
         spoiler_text: spoilerText,
         visibility: getState().getIn(['compose', 'privacy']),
@@ -415,11 +428,31 @@ export function changeUploadCompose(id, params) {
   return (dispatch, getState) => {
     dispatch(changeUploadComposeRequest());
 
-    api(getState).put(`/api/v1/media/${id}`, params).then(response => {
-      dispatch(changeUploadComposeSuccess(response.data));
-    }).catch(error => {
-      dispatch(changeUploadComposeFail(id, error));
-    });
+    let media = getState().getIn(['compose', 'media_attachments']).find((item) => item.get('id') === id);
+
+    // Editing already-attached media is deferred to editing the post itself.
+    // For simplicity's sake, fake an API reply.
+    if (media && !media.get('unattached')) {
+      let { description, focus } = params;
+      const data = media.toJS();
+
+      if (description) {
+        data.description = description;
+      }
+
+      if (focus) {
+        focus = focus.split(',');
+        data.meta = { focus: { x: parseFloat(focus[0]), y: parseFloat(focus[1]) } };
+      }
+
+      dispatch(changeUploadComposeSuccess(data, true));
+    } else {
+      api(getState).put(`/api/v1/media/${id}`, params).then(response => {
+        dispatch(changeUploadComposeSuccess(response.data, false));
+      }).catch(error => {
+        dispatch(changeUploadComposeFail(id, error));
+      });
+    }
   };
 };
 
@@ -430,10 +463,11 @@ export function changeUploadComposeRequest() {
   };
 };
 
-export function changeUploadComposeSuccess(media) {
+export function changeUploadComposeSuccess(media, attached) {
   return {
     type: COMPOSE_UPLOAD_CHANGE_SUCCESS,
     media: media,
+    attached: attached,
     skipLoading: true,
   };
 };
diff --git a/app/javascript/flavours/glitch/actions/tags.js b/app/javascript/flavours/glitch/actions/tags.js
index 37e79d4cb..08a08cda3 100644
--- a/app/javascript/flavours/glitch/actions/tags.js
+++ b/app/javascript/flavours/glitch/actions/tags.js
@@ -1,9 +1,17 @@
-import api from '../api';
+import api, { getLinks } from '../api';
 
 export const HASHTAG_FETCH_REQUEST = 'HASHTAG_FETCH_REQUEST';
 export const HASHTAG_FETCH_SUCCESS = 'HASHTAG_FETCH_SUCCESS';
 export const HASHTAG_FETCH_FAIL    = 'HASHTAG_FETCH_FAIL';
 
+export const FOLLOWED_HASHTAGS_FETCH_REQUEST = 'FOLLOWED_HASHTAGS_FETCH_REQUEST';
+export const FOLLOWED_HASHTAGS_FETCH_SUCCESS = 'FOLLOWED_HASHTAGS_FETCH_SUCCESS';
+export const FOLLOWED_HASHTAGS_FETCH_FAIL    = 'FOLLOWED_HASHTAGS_FETCH_FAIL';
+
+export const FOLLOWED_HASHTAGS_EXPAND_REQUEST = 'FOLLOWED_HASHTAGS_EXPAND_REQUEST';
+export const FOLLOWED_HASHTAGS_EXPAND_SUCCESS = 'FOLLOWED_HASHTAGS_EXPAND_SUCCESS';
+export const FOLLOWED_HASHTAGS_EXPAND_FAIL    = 'FOLLOWED_HASHTAGS_EXPAND_FAIL';
+
 export const HASHTAG_FOLLOW_REQUEST = 'HASHTAG_FOLLOW_REQUEST';
 export const HASHTAG_FOLLOW_SUCCESS = 'HASHTAG_FOLLOW_SUCCESS';
 export const HASHTAG_FOLLOW_FAIL    = 'HASHTAG_FOLLOW_FAIL';
@@ -37,6 +45,78 @@ export const fetchHashtagFail = error => ({
   error,
 });
 
+export const fetchFollowedHashtags = () => (dispatch, getState) => {
+  dispatch(fetchFollowedHashtagsRequest());
+
+  api(getState).get('/api/v1/followed_tags').then(response => {
+    const next = getLinks(response).refs.find(link => link.rel === 'next');
+    dispatch(fetchFollowedHashtagsSuccess(response.data, next ? next.uri : null));
+  }).catch(err => {
+    dispatch(fetchFollowedHashtagsFail(err));
+  });
+};
+
+export function fetchFollowedHashtagsRequest() {
+  return {
+    type: FOLLOWED_HASHTAGS_FETCH_REQUEST,
+  };
+};
+
+export function fetchFollowedHashtagsSuccess(followed_tags, next) {
+  return {
+    type: FOLLOWED_HASHTAGS_FETCH_SUCCESS,
+    followed_tags,
+    next,
+  };
+};
+
+export function fetchFollowedHashtagsFail(error) {
+  return {
+    type: FOLLOWED_HASHTAGS_FETCH_FAIL,
+    error,
+  };
+};
+
+export function expandFollowedHashtags() {
+  return (dispatch, getState) => {
+    const url = getState().getIn(['followed_tags', 'next']);
+
+    if (url === null) {
+      return;
+    }
+
+    dispatch(expandFollowedHashtagsRequest());
+
+    api(getState).get(url).then(response => {
+      const next = getLinks(response).refs.find(link => link.rel === 'next');
+      dispatch(expandFollowedHashtagsSuccess(response.data, next ? next.uri : null));
+    }).catch(error => {
+      dispatch(expandFollowedHashtagsFail(error));
+    });
+  };
+};
+
+export function expandFollowedHashtagsRequest() {
+  return {
+    type: FOLLOWED_HASHTAGS_EXPAND_REQUEST,
+  };
+};
+
+export function expandFollowedHashtagsSuccess(followed_tags, next) {
+  return {
+    type: FOLLOWED_HASHTAGS_EXPAND_SUCCESS,
+    followed_tags,
+    next,
+  };
+};
+
+export function expandFollowedHashtagsFail(error) {
+  return {
+    type: FOLLOWED_HASHTAGS_EXPAND_FAIL,
+    error,
+  };
+};
+
 export const followHashtag = name => (dispatch, getState) => {
   dispatch(followHashtagRequest(name));
 
diff --git a/app/javascript/flavours/glitch/components/admin/Trends.js b/app/javascript/flavours/glitch/components/admin/Trends.js
index 4c17b69a0..774bf36e6 100644
--- a/app/javascript/flavours/glitch/components/admin/Trends.js
+++ b/app/javascript/flavours/glitch/components/admin/Trends.js
@@ -50,7 +50,7 @@ export default class Trends extends React.PureComponent {
             <Hashtag
               key={hashtag.name}
               name={hashtag.name}
-              href={`/admin/tags/${hashtag.id}`}
+              href={hashtag.id === undefined ? undefined : `/admin/tags/${hashtag.id}`}
               people={hashtag.history[0].accounts * 1 + hashtag.history[1].accounts * 1}
               uses={hashtag.history[0].uses * 1 + hashtag.history[1].uses * 1}
               history={hashtag.history.reverse().map(day => day.uses)}
diff --git a/app/javascript/flavours/glitch/features/account/components/header.js b/app/javascript/flavours/glitch/features/account/components/header.js
index ec4a192bc..071d00bb4 100644
--- a/app/javascript/flavours/glitch/features/account/components/header.js
+++ b/app/javascript/flavours/glitch/features/account/components/header.js
@@ -45,6 +45,7 @@ const messages = defineMessages({
   follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' },
   favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favourites' },
   lists: { id: 'navigation_bar.lists', defaultMessage: 'Lists' },
+  followed_tags: { id: 'navigation_bar.followed_tags', defaultMessage: 'Followed hashtags' },
   blocks: { id: 'navigation_bar.blocks', defaultMessage: 'Blocked users' },
   domain_blocks: { id: 'navigation_bar.domain_blocks', defaultMessage: 'Blocked domains' },
   mutes: { id: 'navigation_bar.mutes', defaultMessage: 'Muted users' },
@@ -188,7 +189,7 @@ class Header extends ImmutablePureComponent {
     }
 
     if (account.getIn(['relationship', 'requested']) || account.getIn(['relationship', 'following'])) {
-      bellBtn = <IconButton icon='bell-o' size={24} active={account.getIn(['relationship', 'notifying'])} title={intl.formatMessage(account.getIn(['relationship', 'notifying']) ? messages.disableNotifications : messages.enableNotifications, { name: account.get('username') })} onClick={this.props.onNotifyToggle} />;
+      bellBtn = <IconButton icon={account.getIn(['relationship', 'notifying']) ? 'bell' : 'bell-o'} size={24} active={account.getIn(['relationship', 'notifying'])} title={intl.formatMessage(account.getIn(['relationship', 'notifying']) ? messages.disableNotifications : messages.enableNotifications, { name: account.get('username') })} onClick={this.props.onNotifyToggle} />;
     }
 
     if (me !== account.get('id')) {
@@ -245,6 +246,7 @@ class Header extends ImmutablePureComponent {
       menu.push({ text: intl.formatMessage(messages.follow_requests), to: '/follow_requests' });
       menu.push({ text: intl.formatMessage(messages.favourites), to: '/favourites' });
       menu.push({ text: intl.formatMessage(messages.lists), to: '/lists' });
+      menu.push({ text: intl.formatMessage(messages.followed_tags), to: '/followed_tags' });
       menu.push(null);
       menu.push({ text: intl.formatMessage(messages.mutes), to: '/mutes' });
       menu.push({ text: intl.formatMessage(messages.blocks), to: '/blocks' });
diff --git a/app/javascript/flavours/glitch/features/compose/components/action_bar.js b/app/javascript/flavours/glitch/features/compose/components/action_bar.js
index 267c0ba69..838ef09ea 100644
--- a/app/javascript/flavours/glitch/features/compose/components/action_bar.js
+++ b/app/javascript/flavours/glitch/features/compose/components/action_bar.js
@@ -12,6 +12,7 @@ const messages = defineMessages({
   follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' },
   favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favourites' },
   lists: { id: 'navigation_bar.lists', defaultMessage: 'Lists' },
+  followed_tags: { id: 'navigation_bar.followed_tags', defaultMessage: 'Followed hashtags' },
   blocks: { id: 'navigation_bar.blocks', defaultMessage: 'Blocked users' },
   domain_blocks: { id: 'navigation_bar.domain_blocks', defaultMessage: 'Hidden domains' },
   mutes: { id: 'navigation_bar.mutes', defaultMessage: 'Muted users' },
@@ -46,6 +47,7 @@ class ActionBar extends React.PureComponent {
     menu.push({ text: intl.formatMessage(messages.favourites), to: '/favourites' });
     menu.push({ text: intl.formatMessage(messages.bookmarks), to: '/bookmarks' });
     menu.push({ text: intl.formatMessage(messages.lists), to: '/lists' });
+    menu.push({ text: intl.formatMessage(messages.followed_tags), to: '/followed_tags' });
     menu.push(null);
     menu.push({ text: intl.formatMessage(messages.mutes), to: '/mutes' });
     menu.push({ text: intl.formatMessage(messages.blocks), to: '/blocks' });
diff --git a/app/javascript/flavours/glitch/features/compose/components/upload.js b/app/javascript/flavours/glitch/features/compose/components/upload.js
index 6528bbc84..cd4524540 100644
--- a/app/javascript/flavours/glitch/features/compose/components/upload.js
+++ b/app/javascript/flavours/glitch/features/compose/components/upload.js
@@ -43,13 +43,13 @@ export default class Upload extends ImmutablePureComponent {
           {({ scale }) => (
             <div className='compose-form__upload-thumbnail' style={{ transform: `scale(${scale})`, backgroundImage: `url(${media.get('preview_url')})`, backgroundPosition: `${x}% ${y}%` }}>
               <div className='compose-form__upload__actions'>
-                <button className='icon-button' onClick={this.handleUndoClick}><Icon id='times' /> <FormattedMessage id='upload_form.undo' defaultMessage='Delete' /></button>
-                {!!media.get('unattached') && (<button className='icon-button' onClick={this.handleFocalPointClick}><Icon id='pencil' /> <FormattedMessage id='upload_form.edit' defaultMessage='Edit' /></button>)}
+                <button type='button' className='icon-button' onClick={this.handleUndoClick}><Icon id='times' /> <FormattedMessage id='upload_form.undo' defaultMessage='Delete' /></button>
+                <button type='button' className='icon-button' onClick={this.handleFocalPointClick}><Icon id='pencil' /> <FormattedMessage id='upload_form.edit' defaultMessage='Edit' /></button>
               </div>
 
-              {(media.get('description') || '').length === 0 && !!media.get('unattached') && (
+              {(media.get('description') || '').length === 0 && (
                 <div className='compose-form__upload__warning'>
-                  <button className='icon-button' onClick={this.handleFocalPointClick}><Icon id='info-circle' /> <FormattedMessage id='upload_form.description_missing' defaultMessage='No description added' /></button>
+                  <button type='button' className='icon-button' onClick={this.handleFocalPointClick}><Icon id='info-circle' /> <FormattedMessage id='upload_form.description_missing' defaultMessage='No description added' /></button>
                 </div>
               )}
             </div>
diff --git a/app/javascript/flavours/glitch/features/followed_tags/index.js b/app/javascript/flavours/glitch/features/followed_tags/index.js
new file mode 100644
index 000000000..4a23afc2d
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/followed_tags/index.js
@@ -0,0 +1,89 @@
+import { debounce } from 'lodash';
+import PropTypes from 'prop-types';
+import React from 'react';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import { connect } from 'react-redux';
+import ColumnHeader from 'flavours/glitch/components/column_header';
+import ScrollableList from 'flavours/glitch/components/scrollable_list';
+import Column from 'flavours/glitch/features/ui/components/column';
+import { Helmet } from 'react-helmet';
+import Hashtag from 'flavours/glitch/components/hashtag';
+import { expandFollowedHashtags, fetchFollowedHashtags } from 'flavours/glitch/actions/tags';
+
+const messages = defineMessages({
+  heading: { id: 'followed_tags', defaultMessage: 'Followed hashtags' },
+});
+
+const mapStateToProps = state => ({
+  hashtags: state.getIn(['followed_tags', 'items']),
+  isLoading: state.getIn(['followed_tags', 'isLoading'], true),
+  hasMore: !!state.getIn(['followed_tags', 'next']),
+});
+
+export default @connect(mapStateToProps)
+@injectIntl
+class FollowedTags extends ImmutablePureComponent {
+
+  static propTypes = {
+    params: PropTypes.object.isRequired,
+    dispatch: PropTypes.func.isRequired,
+    intl: PropTypes.object.isRequired,
+    hashtags: ImmutablePropTypes.list,
+    isLoading: PropTypes.bool,
+    hasMore: PropTypes.bool,
+    multiColumn: PropTypes.bool,
+  };
+
+  componentDidMount() {
+    this.props.dispatch(fetchFollowedHashtags());
+  };
+
+  handleLoadMore = debounce(() => {
+    this.props.dispatch(expandFollowedHashtags());
+  }, 300, { leading: true });
+
+  render () {
+    const { intl, hashtags, isLoading, hasMore, multiColumn } = this.props;
+
+    const emptyMessage = <FormattedMessage id='empty_column.followed_tags' defaultMessage='You have not followed any hashtags yet. When you do, they will show up here.' />;
+
+    return (
+      <Column bindToDocument={!multiColumn}>
+        <ColumnHeader
+          icon='hashtag'
+          title={intl.formatMessage(messages.heading)}
+          showBackButton
+          multiColumn={multiColumn}
+        />
+
+        <ScrollableList
+          scrollKey='followed_tags'
+          emptyMessage={emptyMessage}
+          hasMore={hasMore}
+          isLoading={isLoading}
+          onLoadMore={this.handleLoadMore}
+          bindToDocument={!multiColumn}
+        >
+          {hashtags.map((hashtag) => (
+            <Hashtag
+              key={hashtag.get('name')}
+              name={hashtag.get('name')}
+              to={`/tags/${hashtag.get('name')}`}
+              withGraph={false}
+              // Taken from ImmutableHashtag. Should maybe refactor ImmutableHashtag to accept more options?
+              people={hashtag.getIn(['history', 0, 'accounts']) * 1 + hashtag.getIn(['history', 1, 'accounts']) * 1}
+              history={hashtag.get('history').reverse().map((day) => day.get('uses')).toArray()}
+            />
+          ))}
+        </ScrollableList>
+
+        <Helmet>
+          <meta name='robots' content='noindex' />
+        </Helmet>
+      </Column>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/ui/components/focal_point_modal.js b/app/javascript/flavours/glitch/features/ui/components/focal_point_modal.js
index 0dd07fb76..fb432cf9c 100644
--- a/app/javascript/flavours/glitch/features/ui/components/focal_point_modal.js
+++ b/app/javascript/flavours/glitch/features/ui/components/focal_point_modal.js
@@ -320,7 +320,7 @@ class FocalPointModal extends ImmutablePureComponent {
               <React.Fragment>
                 <label className='setting-text-label' htmlFor='upload-modal__thumbnail'><FormattedMessage id='upload_form.thumbnail' defaultMessage='Change thumbnail' /></label>
 
-                <Button disabled={isUploadingThumbnail} text={intl.formatMessage(messages.chooseImage)} onClick={this.handleFileInputClick} />
+                <Button disabled={isUploadingThumbnail || !media.get('unattached')} text={intl.formatMessage(messages.chooseImage)} onClick={this.handleFileInputClick} />
 
                 <label>
                   <span style={{ display: 'none' }}>{intl.formatMessage(messages.chooseImage)}</span>
diff --git a/app/javascript/flavours/glitch/features/ui/components/sign_in_banner.js b/app/javascript/flavours/glitch/features/ui/components/sign_in_banner.js
index e8023803f..c0d62aca0 100644
--- a/app/javascript/flavours/glitch/features/ui/components/sign_in_banner.js
+++ b/app/javascript/flavours/glitch/features/ui/components/sign_in_banner.js
@@ -30,7 +30,7 @@ const SignInBanner = () => {
 
   return (
     <div className='sign-in-banner'>
-      <p><FormattedMessage id='sign_in_banner.text' defaultMessage='Sign in to follow profiles or hashtags, favourite, share and reply to posts, or interact from your account on a different server.' /></p>
+      <p><FormattedMessage id='sign_in_banner.text' defaultMessage='Sign in to follow profiles or hashtags, favourite, share and reply to posts. You can also interact from your account on a different server.' /></p>
       <a href='/auth/sign_in' className='button button--block'><FormattedMessage id='sign_in_banner.sign_in' defaultMessage='Sign in' /></a>
       {signupButton}
     </div>
diff --git a/app/javascript/flavours/glitch/features/ui/index.js b/app/javascript/flavours/glitch/features/ui/index.js
index 72e13d9d6..d8889f9f9 100644
--- a/app/javascript/flavours/glitch/features/ui/index.js
+++ b/app/javascript/flavours/glitch/features/ui/index.js
@@ -42,6 +42,7 @@ import {
   FollowRequests,
   FavouritedStatuses,
   BookmarkedStatuses,
+  FollowedTags,
   ListTimeline,
   Blocks,
   DomainBlocks,
@@ -56,7 +57,7 @@ import {
   PrivacyPolicy,
 } from './util/async-components';
 import { HotKeys } from 'react-hotkeys';
-import initialState, { me, owner, singleUserMode, showTrends } from '../../initial_state';
+import initialState, { me, owner, singleUserMode, showTrends, trendsAsLanding } from '../../initial_state';
 import { closeOnboarding, INTRODUCTION_VERSION } from 'flavours/glitch/actions/onboarding';
 import { defineMessages, FormattedMessage, injectIntl } from 'react-intl';
 import { Helmet } from 'react-helmet';
@@ -177,7 +178,7 @@ class SwitchingColumnsArea extends React.PureComponent {
       }
     } else if (singleUserMode && owner && initialState?.accounts[owner]) {
       redirect = <Redirect from='/' to={`/@${initialState.accounts[owner].username}`} exact />;
-    } else if (showTrends) {
+    } else if (showTrends && trendsAsLanding) {
       redirect = <Redirect from='/' to='/explore' exact />;
     } else {
       redirect = <Redirect from='/' to='/about' exact />;
@@ -230,6 +231,7 @@ class SwitchingColumnsArea extends React.PureComponent {
           <WrappedRoute path='/follow_requests' component={FollowRequests} content={children} />
           <WrappedRoute path='/blocks' component={Blocks} content={children} />
           <WrappedRoute path='/domain_blocks' component={DomainBlocks} content={children} />
+          <WrappedRoute path='/followed_tags' component={FollowedTags} content={children} />
           <WrappedRoute path='/mutes' component={Mutes} content={children} />
           <WrappedRoute path='/lists' component={Lists} content={children} />
           <WrappedRoute path='/getting-started-misc' component={GettingStartedMisc} content={children} />
diff --git a/app/javascript/flavours/glitch/features/ui/util/async-components.js b/app/javascript/flavours/glitch/features/ui/util/async-components.js
index 025b22e61..03e501628 100644
--- a/app/javascript/flavours/glitch/features/ui/util/async-components.js
+++ b/app/javascript/flavours/glitch/features/ui/util/async-components.js
@@ -98,6 +98,10 @@ export function FavouritedStatuses () {
   return import(/* webpackChunkName: "flavours/glitch/async/favourited_statuses" */'flavours/glitch/features/favourited_statuses');
 }
 
+export function FollowedTags () {
+  return import(/* webpackChunkName: "flavours/glitch/async/followed_tags" */'flavours/glitch/features/followed_tags');
+}
+
 export function BookmarkedStatuses () {
   return import(/* webpackChunkName: "flavours/glitch/async/bookmarked_statuses" */'flavours/glitch/features/bookmarked_statuses');
 }
diff --git a/app/javascript/flavours/glitch/initial_state.js b/app/javascript/flavours/glitch/initial_state.js
index bbf25c8a8..eefbdca80 100644
--- a/app/javascript/flavours/glitch/initial_state.js
+++ b/app/javascript/flavours/glitch/initial_state.js
@@ -75,6 +75,7 @@
  * @property {boolean} timeline_preview
  * @property {string} title
  * @property {boolean} trends
+ * @property {boolean} trends_as_landing_page
  * @property {boolean} unfollow_modal
  * @property {boolean} use_blurhash
  * @property {boolean=} use_pending_items
@@ -134,6 +135,7 @@ export const singleUserMode = getMeta('single_user_mode');
 export const source_url = getMeta('source_url');
 export const timelinePreview = getMeta('timeline_preview');
 export const title = getMeta('title');
+export const trendsAsLanding = getMeta('trends_as_landing_page');
 export const unfollowModal = getMeta('unfollow_modal');
 export const useBlurhash = getMeta('use_blurhash');
 export const usePendingItems = getMeta('use_pending_items');
diff --git a/app/javascript/flavours/glitch/reducers/compose.js b/app/javascript/flavours/glitch/reducers/compose.js
index 814b6a1a7..a69c0f7f2 100644
--- a/app/javascript/flavours/glitch/reducers/compose.js
+++ b/app/javascript/flavours/glitch/reducers/compose.js
@@ -551,7 +551,7 @@ export default function compose(state = initialState, action) {
       .setIn(['media_modal', 'dirty'], false)
       .update('media_attachments', list => list.map(item => {
         if (item.get('id') === action.media.id) {
-          return fromJS(action.media).set('unattached', true);
+          return fromJS(action.media).set('unattached', !action.attached);
         }
 
         return item;
diff --git a/app/javascript/flavours/glitch/reducers/followed_tags.js b/app/javascript/flavours/glitch/reducers/followed_tags.js
new file mode 100644
index 000000000..4109b0b10
--- /dev/null
+++ b/app/javascript/flavours/glitch/reducers/followed_tags.js
@@ -0,0 +1,42 @@
+import {
+  FOLLOWED_HASHTAGS_FETCH_REQUEST,
+  FOLLOWED_HASHTAGS_FETCH_SUCCESS,
+  FOLLOWED_HASHTAGS_FETCH_FAIL,
+  FOLLOWED_HASHTAGS_EXPAND_REQUEST,
+  FOLLOWED_HASHTAGS_EXPAND_SUCCESS,
+  FOLLOWED_HASHTAGS_EXPAND_FAIL,
+} from 'flavours/glitch/actions/tags';
+import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable';
+
+const initialState = ImmutableMap({
+  items: ImmutableList(),
+  isLoading: false,
+  next: null,
+});
+
+export default function followed_tags(state = initialState, action) {
+  switch(action.type) {
+  case FOLLOWED_HASHTAGS_FETCH_REQUEST:
+    return state.set('isLoading', true);
+  case FOLLOWED_HASHTAGS_FETCH_SUCCESS:
+    return state.withMutations(map => {
+      map.set('items', fromJS(action.followed_tags));
+      map.set('isLoading', false);
+      map.set('next', action.next);
+    });
+  case FOLLOWED_HASHTAGS_FETCH_FAIL:
+    return state.set('isLoading', false);
+  case FOLLOWED_HASHTAGS_EXPAND_REQUEST:
+    return state.set('isLoading', true);
+  case FOLLOWED_HASHTAGS_EXPAND_SUCCESS:
+    return state.withMutations(map => {
+      map.update('items', set => set.concat(fromJS(action.followed_tags)));
+      map.set('isLoading', false);
+      map.set('next', action.next);
+    });
+  case FOLLOWED_HASHTAGS_EXPAND_FAIL:
+    return state.set('isLoading', false);
+  default:
+    return state;
+  }
+};
diff --git a/app/javascript/flavours/glitch/reducers/index.js b/app/javascript/flavours/glitch/reducers/index.js
index 09c08a362..5b7bdbf69 100644
--- a/app/javascript/flavours/glitch/reducers/index.js
+++ b/app/javascript/flavours/glitch/reducers/index.js
@@ -42,6 +42,7 @@ import picture_in_picture from './picture_in_picture';
 import accounts_map from './accounts_map';
 import history from './history';
 import tags from './tags';
+import followed_tags from './followed_tags';
 
 const reducers = {
   announcements,
@@ -87,6 +88,7 @@ const reducers = {
   picture_in_picture,
   history,
   tags,
+  followed_tags,
 };
 
 export default combineReducers(reducers);
diff --git a/app/javascript/flavours/glitch/styles/admin.scss b/app/javascript/flavours/glitch/styles/admin.scss
index 8ddf815c3..9aa2318ce 100644
--- a/app/javascript/flavours/glitch/styles/admin.scss
+++ b/app/javascript/flavours/glitch/styles/admin.scss
@@ -1588,6 +1588,15 @@ a.sparkline {
           margin-bottom: 0;
         }
       }
+
+      a {
+        color: $highlight-text-color;
+        text-decoration: none;
+
+        &:hover {
+          text-decoration: underline;
+        }
+      }
     }
 
     &__actions {
diff --git a/app/javascript/flavours/glitch/styles/components/compose_form.scss b/app/javascript/flavours/glitch/styles/components/compose_form.scss
index aa2d52ed0..40adf28c9 100644
--- a/app/javascript/flavours/glitch/styles/components/compose_form.scss
+++ b/app/javascript/flavours/glitch/styles/components/compose_form.scss
@@ -118,7 +118,7 @@
 
     &.active {
       border-color: $highlight-text-color;
-      background: $highlight-text-color;
+      background: $highlight-text-color url("data:image/svg+xml;utf8,<svg width='18' height='18' fill='none' xmlns='http://www.w3.org/2000/svg'><path d='M4.5 8.5L8 12l6-6' stroke='white' stroke-width='1.5'/></svg>") center center no-repeat;
     }
   }
 }
diff --git a/app/javascript/mastodon/actions/compose.js b/app/javascript/mastodon/actions/compose.js
index 12d25490b..cc27840d0 100644
--- a/app/javascript/mastodon/actions/compose.js
+++ b/app/javascript/mastodon/actions/compose.js
@@ -160,6 +160,18 @@ export function submitCompose(routerHistory) {
 
     dispatch(submitComposeRequest());
 
+    // If we're editing a post with media attachments, those have not
+    // necessarily been changed on the server. Do it now in the same
+    // API call.
+    let media_attributes;
+    if (statusId !== null) {
+      media_attributes = media.map(item => ({
+        id: item.get('id'),
+        description: item.get('description'),
+        focus: item.get('focus'),
+      }));
+    }
+
     api(getState).request({
       url: statusId === null ? '/api/v1/statuses' : `/api/v1/statuses/${statusId}`,
       method: statusId === null ? 'post' : 'put',
@@ -167,6 +179,7 @@ export function submitCompose(routerHistory) {
         status,
         in_reply_to_id: getState().getIn(['compose', 'in_reply_to'], null),
         media_ids: media.map(item => item.get('id')),
+        media_attributes,
         sensitive: getState().getIn(['compose', 'sensitive']),
         spoiler_text: getState().getIn(['compose', 'spoiler']) ? getState().getIn(['compose', 'spoiler_text'], '') : '',
         visibility: getState().getIn(['compose', 'privacy']),
@@ -377,11 +390,31 @@ export function changeUploadCompose(id, params) {
   return (dispatch, getState) => {
     dispatch(changeUploadComposeRequest());
 
-    api(getState).put(`/api/v1/media/${id}`, params).then(response => {
-      dispatch(changeUploadComposeSuccess(response.data));
-    }).catch(error => {
-      dispatch(changeUploadComposeFail(id, error));
-    });
+    let media = getState().getIn(['compose', 'media_attachments']).find((item) => item.get('id') === id);
+
+    // Editing already-attached media is deferred to editing the post itself.
+    // For simplicity's sake, fake an API reply.
+    if (media && !media.get('unattached')) {
+      let { description, focus } = params;
+      const data = media.toJS();
+
+      if (description) {
+        data.description = description;
+      }
+
+      if (focus) {
+        focus = focus.split(',');
+        data.meta = { focus: { x: parseFloat(focus[0]), y: parseFloat(focus[1]) } };
+      }
+
+      dispatch(changeUploadComposeSuccess(data, true));
+    } else {
+      api(getState).put(`/api/v1/media/${id}`, params).then(response => {
+        dispatch(changeUploadComposeSuccess(response.data, false));
+      }).catch(error => {
+        dispatch(changeUploadComposeFail(id, error));
+      });
+    }
   };
 }
 
@@ -392,10 +425,11 @@ export function changeUploadComposeRequest() {
   };
 }
 
-export function changeUploadComposeSuccess(media) {
+export function changeUploadComposeSuccess(media, attached) {
   return {
     type: COMPOSE_UPLOAD_CHANGE_SUCCESS,
     media: media,
+    attached: attached,
     skipLoading: true,
   };
 }
diff --git a/app/javascript/mastodon/actions/tags.js b/app/javascript/mastodon/actions/tags.js
index 37e79d4cb..08a08cda3 100644
--- a/app/javascript/mastodon/actions/tags.js
+++ b/app/javascript/mastodon/actions/tags.js
@@ -1,9 +1,17 @@
-import api from '../api';
+import api, { getLinks } from '../api';
 
 export const HASHTAG_FETCH_REQUEST = 'HASHTAG_FETCH_REQUEST';
 export const HASHTAG_FETCH_SUCCESS = 'HASHTAG_FETCH_SUCCESS';
 export const HASHTAG_FETCH_FAIL    = 'HASHTAG_FETCH_FAIL';
 
+export const FOLLOWED_HASHTAGS_FETCH_REQUEST = 'FOLLOWED_HASHTAGS_FETCH_REQUEST';
+export const FOLLOWED_HASHTAGS_FETCH_SUCCESS = 'FOLLOWED_HASHTAGS_FETCH_SUCCESS';
+export const FOLLOWED_HASHTAGS_FETCH_FAIL    = 'FOLLOWED_HASHTAGS_FETCH_FAIL';
+
+export const FOLLOWED_HASHTAGS_EXPAND_REQUEST = 'FOLLOWED_HASHTAGS_EXPAND_REQUEST';
+export const FOLLOWED_HASHTAGS_EXPAND_SUCCESS = 'FOLLOWED_HASHTAGS_EXPAND_SUCCESS';
+export const FOLLOWED_HASHTAGS_EXPAND_FAIL    = 'FOLLOWED_HASHTAGS_EXPAND_FAIL';
+
 export const HASHTAG_FOLLOW_REQUEST = 'HASHTAG_FOLLOW_REQUEST';
 export const HASHTAG_FOLLOW_SUCCESS = 'HASHTAG_FOLLOW_SUCCESS';
 export const HASHTAG_FOLLOW_FAIL    = 'HASHTAG_FOLLOW_FAIL';
@@ -37,6 +45,78 @@ export const fetchHashtagFail = error => ({
   error,
 });
 
+export const fetchFollowedHashtags = () => (dispatch, getState) => {
+  dispatch(fetchFollowedHashtagsRequest());
+
+  api(getState).get('/api/v1/followed_tags').then(response => {
+    const next = getLinks(response).refs.find(link => link.rel === 'next');
+    dispatch(fetchFollowedHashtagsSuccess(response.data, next ? next.uri : null));
+  }).catch(err => {
+    dispatch(fetchFollowedHashtagsFail(err));
+  });
+};
+
+export function fetchFollowedHashtagsRequest() {
+  return {
+    type: FOLLOWED_HASHTAGS_FETCH_REQUEST,
+  };
+};
+
+export function fetchFollowedHashtagsSuccess(followed_tags, next) {
+  return {
+    type: FOLLOWED_HASHTAGS_FETCH_SUCCESS,
+    followed_tags,
+    next,
+  };
+};
+
+export function fetchFollowedHashtagsFail(error) {
+  return {
+    type: FOLLOWED_HASHTAGS_FETCH_FAIL,
+    error,
+  };
+};
+
+export function expandFollowedHashtags() {
+  return (dispatch, getState) => {
+    const url = getState().getIn(['followed_tags', 'next']);
+
+    if (url === null) {
+      return;
+    }
+
+    dispatch(expandFollowedHashtagsRequest());
+
+    api(getState).get(url).then(response => {
+      const next = getLinks(response).refs.find(link => link.rel === 'next');
+      dispatch(expandFollowedHashtagsSuccess(response.data, next ? next.uri : null));
+    }).catch(error => {
+      dispatch(expandFollowedHashtagsFail(error));
+    });
+  };
+};
+
+export function expandFollowedHashtagsRequest() {
+  return {
+    type: FOLLOWED_HASHTAGS_EXPAND_REQUEST,
+  };
+};
+
+export function expandFollowedHashtagsSuccess(followed_tags, next) {
+  return {
+    type: FOLLOWED_HASHTAGS_EXPAND_SUCCESS,
+    followed_tags,
+    next,
+  };
+};
+
+export function expandFollowedHashtagsFail(error) {
+  return {
+    type: FOLLOWED_HASHTAGS_EXPAND_FAIL,
+    error,
+  };
+};
+
 export const followHashtag = name => (dispatch, getState) => {
   dispatch(followHashtagRequest(name));
 
diff --git a/app/javascript/mastodon/components/admin/Trends.js b/app/javascript/mastodon/components/admin/Trends.js
index 9530c2a5b..d01b8437e 100644
--- a/app/javascript/mastodon/components/admin/Trends.js
+++ b/app/javascript/mastodon/components/admin/Trends.js
@@ -50,7 +50,7 @@ export default class Trends extends React.PureComponent {
             <Hashtag
               key={hashtag.name}
               name={hashtag.name}
-              to={`/admin/tags/${hashtag.id}`}
+              to={hashtag.id === undefined ? undefined : `/admin/tags/${hashtag.id}`}
               people={hashtag.history[0].accounts * 1 + hashtag.history[1].accounts * 1}
               uses={hashtag.history[0].uses * 1 + hashtag.history[1].uses * 1}
               history={hashtag.history.reverse().map(day => day.uses)}
diff --git a/app/javascript/mastodon/features/account/components/header.js b/app/javascript/mastodon/features/account/components/header.js
index 2481e4783..46fb89f2f 100644
--- a/app/javascript/mastodon/features/account/components/header.js
+++ b/app/javascript/mastodon/features/account/components/header.js
@@ -46,6 +46,7 @@ const messages = defineMessages({
   follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' },
   favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favourites' },
   lists: { id: 'navigation_bar.lists', defaultMessage: 'Lists' },
+  followed_tags: { id: 'navigation_bar.followed_tags', defaultMessage: 'Followed hashtags' },
   blocks: { id: 'navigation_bar.blocks', defaultMessage: 'Blocked users' },
   domain_blocks: { id: 'navigation_bar.domain_blocks', defaultMessage: 'Blocked domains' },
   mutes: { id: 'navigation_bar.mutes', defaultMessage: 'Muted users' },
@@ -193,7 +194,7 @@ class Header extends ImmutablePureComponent {
     }
 
     if (account.getIn(['relationship', 'requested']) || account.getIn(['relationship', 'following'])) {
-      bellBtn = <IconButton icon='bell-o' size={24} active={account.getIn(['relationship', 'notifying'])} title={intl.formatMessage(account.getIn(['relationship', 'notifying']) ? messages.disableNotifications : messages.enableNotifications, { name: account.get('username') })} onClick={this.props.onNotifyToggle} />;
+      bellBtn = <IconButton icon={account.getIn(['relationship', 'notifying']) ? 'bell' : 'bell-o'} size={24} active={account.getIn(['relationship', 'notifying'])} title={intl.formatMessage(account.getIn(['relationship', 'notifying']) ? messages.disableNotifications : messages.enableNotifications, { name: account.get('username') })} onClick={this.props.onNotifyToggle} />;
     }
 
     if (me !== account.get('id')) {
@@ -242,6 +243,7 @@ class Header extends ImmutablePureComponent {
       menu.push({ text: intl.formatMessage(messages.follow_requests), to: '/follow_requests' });
       menu.push({ text: intl.formatMessage(messages.favourites), to: '/favourites' });
       menu.push({ text: intl.formatMessage(messages.lists), to: '/lists' });
+      menu.push({ text: intl.formatMessage(messages.followed_tags), to: '/followed_tags' });
       menu.push(null);
       menu.push({ text: intl.formatMessage(messages.mutes), to: '/mutes' });
       menu.push({ text: intl.formatMessage(messages.blocks), to: '/blocks' });
diff --git a/app/javascript/mastodon/features/compose/components/action_bar.js b/app/javascript/mastodon/features/compose/components/action_bar.js
index ceed928bf..90c85321e 100644
--- a/app/javascript/mastodon/features/compose/components/action_bar.js
+++ b/app/javascript/mastodon/features/compose/components/action_bar.js
@@ -11,6 +11,7 @@ const messages = defineMessages({
   follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' },
   favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favourites' },
   lists: { id: 'navigation_bar.lists', defaultMessage: 'Lists' },
+  followed_tags: { id: 'navigation_bar.followed_tags', defaultMessage: 'Followed hashtags' },
   blocks: { id: 'navigation_bar.blocks', defaultMessage: 'Blocked users' },
   domain_blocks: { id: 'navigation_bar.domain_blocks', defaultMessage: 'Hidden domains' },
   mutes: { id: 'navigation_bar.mutes', defaultMessage: 'Muted users' },
@@ -45,6 +46,7 @@ class ActionBar extends React.PureComponent {
     menu.push({ text: intl.formatMessage(messages.favourites), to: '/favourites' });
     menu.push({ text: intl.formatMessage(messages.bookmarks), to: '/bookmarks' });
     menu.push({ text: intl.formatMessage(messages.lists), to: '/lists' });
+    menu.push({ text: intl.formatMessage(messages.followed_tags), to: '/followed_tags' });
     menu.push(null);
     menu.push({ text: intl.formatMessage(messages.mutes), to: '/mutes' });
     menu.push({ text: intl.formatMessage(messages.blocks), to: '/blocks' });
diff --git a/app/javascript/mastodon/features/compose/components/upload.js b/app/javascript/mastodon/features/compose/components/upload.js
index b08307ade..af06ce1bf 100644
--- a/app/javascript/mastodon/features/compose/components/upload.js
+++ b/app/javascript/mastodon/features/compose/components/upload.js
@@ -43,10 +43,10 @@ export default class Upload extends ImmutablePureComponent {
             <div className='compose-form__upload-thumbnail' style={{ transform: `scale(${scale})`, backgroundImage: `url(${media.get('preview_url')})`, backgroundPosition: `${x}% ${y}%` }}>
               <div className='compose-form__upload__actions'>
                 <button type='button' className='icon-button' onClick={this.handleUndoClick}><Icon id='times' /> <FormattedMessage id='upload_form.undo' defaultMessage='Delete' /></button>
-                {!!media.get('unattached') && (<button type='button' className='icon-button' onClick={this.handleFocalPointClick}><Icon id='pencil' /> <FormattedMessage id='upload_form.edit' defaultMessage='Edit' /></button>)}
+                <button type='button' className='icon-button' onClick={this.handleFocalPointClick}><Icon id='pencil' /> <FormattedMessage id='upload_form.edit' defaultMessage='Edit' /></button>
               </div>
 
-              {(media.get('description') || '').length === 0 && !!media.get('unattached') && (
+              {(media.get('description') || '').length === 0 && (
                 <div className='compose-form__upload__warning'>
                   <button type='button' className='icon-button' onClick={this.handleFocalPointClick}><Icon id='info-circle' /> <FormattedMessage id='upload_form.description_missing' defaultMessage='No description added' /></button>
                 </div>
diff --git a/app/javascript/mastodon/features/followed_tags/index.js b/app/javascript/mastodon/features/followed_tags/index.js
new file mode 100644
index 000000000..0a62ca76d
--- /dev/null
+++ b/app/javascript/mastodon/features/followed_tags/index.js
@@ -0,0 +1,89 @@
+import { debounce } from 'lodash';
+import PropTypes from 'prop-types';
+import React from 'react';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import { connect } from 'react-redux';
+import ColumnHeader from 'mastodon/components/column_header';
+import ScrollableList from 'mastodon/components/scrollable_list';
+import Column from 'mastodon/features/ui/components/column';
+import { Helmet } from 'react-helmet';
+import Hashtag from 'mastodon/components/hashtag';
+import { expandFollowedHashtags, fetchFollowedHashtags } from 'mastodon/actions/tags';
+
+const messages = defineMessages({
+  heading: { id: 'followed_tags', defaultMessage: 'Followed hashtags' },
+});
+
+const mapStateToProps = state => ({
+  hashtags: state.getIn(['followed_tags', 'items']),
+  isLoading: state.getIn(['followed_tags', 'isLoading'], true),
+  hasMore: !!state.getIn(['followed_tags', 'next']),
+});
+
+export default @connect(mapStateToProps)
+@injectIntl
+class FollowedTags extends ImmutablePureComponent {
+
+  static propTypes = {
+    params: PropTypes.object.isRequired,
+    dispatch: PropTypes.func.isRequired,
+    intl: PropTypes.object.isRequired,
+    hashtags: ImmutablePropTypes.list,
+    isLoading: PropTypes.bool,
+    hasMore: PropTypes.bool,
+    multiColumn: PropTypes.bool,
+  };
+
+  componentDidMount() {
+    this.props.dispatch(fetchFollowedHashtags());
+  };
+
+  handleLoadMore = debounce(() => {
+    this.props.dispatch(expandFollowedHashtags());
+  }, 300, { leading: true });
+
+  render () {
+    const { intl, hashtags, isLoading, hasMore, multiColumn } = this.props;
+
+    const emptyMessage = <FormattedMessage id='empty_column.followed_tags' defaultMessage='You have not followed any hashtags yet. When you do, they will show up here.' />;
+
+    return (
+      <Column bindToDocument={!multiColumn}>
+        <ColumnHeader
+          icon='hashtag'
+          title={intl.formatMessage(messages.heading)}
+          showBackButton
+          multiColumn={multiColumn}
+        />
+
+        <ScrollableList
+          scrollKey='followed_tags'
+          emptyMessage={emptyMessage}
+          hasMore={hasMore}
+          isLoading={isLoading}
+          onLoadMore={this.handleLoadMore}
+          bindToDocument={!multiColumn}
+        >
+          {hashtags.map((hashtag) => (
+            <Hashtag
+              key={hashtag.get('name')}
+              name={hashtag.get('name')}
+              to={`/tags/${hashtag.get('name')}`}
+              withGraph={false}
+              // Taken from ImmutableHashtag. Should maybe refactor ImmutableHashtag to accept more options?
+              people={hashtag.getIn(['history', 0, 'accounts']) * 1 + hashtag.getIn(['history', 1, 'accounts']) * 1}
+              history={hashtag.get('history').reverse().map((day) => day.get('uses')).toArray()}
+            />
+          ))}
+        </ScrollableList>
+
+        <Helmet>
+          <meta name='robots' content='noindex' />
+        </Helmet>
+      </Column>
+    );
+  }
+
+}
diff --git a/app/javascript/mastodon/features/ui/components/focal_point_modal.js b/app/javascript/mastodon/features/ui/components/focal_point_modal.js
index 479f4abd2..b9dbd9390 100644
--- a/app/javascript/mastodon/features/ui/components/focal_point_modal.js
+++ b/app/javascript/mastodon/features/ui/components/focal_point_modal.js
@@ -320,7 +320,7 @@ class FocalPointModal extends ImmutablePureComponent {
               <React.Fragment>
                 <label className='setting-text-label' htmlFor='upload-modal__thumbnail'><FormattedMessage id='upload_form.thumbnail' defaultMessage='Change thumbnail' /></label>
 
-                <Button disabled={isUploadingThumbnail} text={intl.formatMessage(messages.chooseImage)} onClick={this.handleFileInputClick} />
+                <Button disabled={isUploadingThumbnail || !media.get('unattached')} text={intl.formatMessage(messages.chooseImage)} onClick={this.handleFileInputClick} />
 
                 <label>
                   <span style={{ display: 'none' }}>{intl.formatMessage(messages.chooseImage)}</span>
diff --git a/app/javascript/mastodon/features/ui/components/sign_in_banner.js b/app/javascript/mastodon/features/ui/components/sign_in_banner.js
index 8bd32edf9..86fcc11b5 100644
--- a/app/javascript/mastodon/features/ui/components/sign_in_banner.js
+++ b/app/javascript/mastodon/features/ui/components/sign_in_banner.js
@@ -30,7 +30,7 @@ const SignInBanner = () => {
 
   return (
     <div className='sign-in-banner'>
-      <p><FormattedMessage id='sign_in_banner.text' defaultMessage='Sign in to follow profiles or hashtags, favourite, share and reply to posts, or interact from your account on a different server.' /></p>
+      <p><FormattedMessage id='sign_in_banner.text' defaultMessage='Sign in to follow profiles or hashtags, favourite, share and reply to posts. You can also interact from your account on a different server.' /></p>
       <a href='/auth/sign_in' className='button button--block'><FormattedMessage id='sign_in_banner.sign_in' defaultMessage='Sign in' /></a>
       {signupButton}
     </div>
diff --git a/app/javascript/mastodon/features/ui/index.js b/app/javascript/mastodon/features/ui/index.js
index b05956606..78dc9ea40 100644
--- a/app/javascript/mastodon/features/ui/index.js
+++ b/app/javascript/mastodon/features/ui/index.js
@@ -42,6 +42,7 @@ import {
   FollowRequests,
   FavouritedStatuses,
   BookmarkedStatuses,
+  FollowedTags,
   ListTimeline,
   Blocks,
   DomainBlocks,
@@ -54,7 +55,7 @@ import {
   About,
   PrivacyPolicy,
 } from './util/async-components';
-import initialState, { me, owner, singleUserMode, showTrends } from '../../initial_state';
+import initialState, { me, owner, singleUserMode, showTrends, trendsAsLanding } from '../../initial_state';
 import { closeOnboarding, INTRODUCTION_VERSION } from 'mastodon/actions/onboarding';
 import Header from './components/header';
 
@@ -163,7 +164,7 @@ class SwitchingColumnsArea extends React.PureComponent {
       }
     } else if (singleUserMode && owner && initialState?.accounts[owner]) {
       redirect = <Redirect from='/' to={`/@${initialState.accounts[owner].username}`} exact />;
-    } else if (showTrends) {
+    } else if (showTrends && trendsAsLanding) {
       redirect = <Redirect from='/' to='/explore' exact />;
     } else {
       redirect = <Redirect from='/' to='/about' exact />;
@@ -216,6 +217,7 @@ class SwitchingColumnsArea extends React.PureComponent {
           <WrappedRoute path='/follow_requests' component={FollowRequests} content={children} />
           <WrappedRoute path='/blocks' component={Blocks} content={children} />
           <WrappedRoute path='/domain_blocks' component={DomainBlocks} content={children} />
+          <WrappedRoute path='/followed_tags' component={FollowedTags} content={children} />
           <WrappedRoute path='/mutes' component={Mutes} content={children} />
           <WrappedRoute path='/lists' component={Lists} content={children} />
 
diff --git a/app/javascript/mastodon/features/ui/util/async-components.js b/app/javascript/mastodon/features/ui/util/async-components.js
index 6046578de..1cf07f645 100644
--- a/app/javascript/mastodon/features/ui/util/async-components.js
+++ b/app/javascript/mastodon/features/ui/util/async-components.js
@@ -90,6 +90,10 @@ export function FavouritedStatuses () {
   return import(/* webpackChunkName: "features/favourited_statuses" */'../../favourited_statuses');
 }
 
+export function FollowedTags () {
+  return import(/* webpackChunkName: "features/followed_tags" */'../../followed_tags');
+}
+
 export function BookmarkedStatuses () {
   return import(/* webpackChunkName: "features/bookmarked_statuses" */'../../bookmarked_statuses');
 }
diff --git a/app/javascript/mastodon/initial_state.js b/app/javascript/mastodon/initial_state.js
index 8eec7f7c7..f58b4eb0e 100644
--- a/app/javascript/mastodon/initial_state.js
+++ b/app/javascript/mastodon/initial_state.js
@@ -75,6 +75,7 @@
  * @property {boolean} timeline_preview
  * @property {string} title
  * @property {boolean} trends
+ * @property {boolean} trends_as_landing_page
  * @property {boolean} unfollow_modal
  * @property {boolean} use_blurhash
  * @property {boolean=} use_pending_items
@@ -126,6 +127,7 @@ export const singleUserMode = getMeta('single_user_mode');
 export const source_url = getMeta('source_url');
 export const timelinePreview = getMeta('timeline_preview');
 export const title = getMeta('title');
+export const trendsAsLanding = getMeta('trends_as_landing_page');
 export const unfollowModal = getMeta('unfollow_modal');
 export const useBlurhash = getMeta('use_blurhash');
 export const usePendingItems = getMeta('use_pending_items');
diff --git a/app/javascript/mastodon/locales/defaultMessages.json b/app/javascript/mastodon/locales/defaultMessages.json
index 3ed438fb8..249ad0770 100644
--- a/app/javascript/mastodon/locales/defaultMessages.json
+++ b/app/javascript/mastodon/locales/defaultMessages.json
@@ -1392,6 +1392,10 @@
         "id": "navigation_bar.lists"
       },
       {
+        "defaultMessage": "Followed hashtags",
+        "id": "navigation_bar.followed_tags"
+      },
+      {
         "defaultMessage": "Blocked users",
         "id": "navigation_bar.blocks"
       },
@@ -4220,7 +4224,7 @@
         "id": "sign_in_banner.create_account"
       },
       {
-        "defaultMessage": "Sign in to follow profiles or hashtags, favourite, share and reply to posts, or interact from your account on a different server.",
+        "defaultMessage": "Sign in to follow profiles or hashtags, favourite, share and reply to posts. You can also interact from your account on a different server.",
         "id": "sign_in_banner.text"
       },
       {
@@ -4310,4 +4314,4 @@
     ],
     "path": "app/javascript/mastodon/features/video/index.json"
   }
-]
\ No newline at end of file
+]
diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json
index 9db0f3eb7..a2f7b23ad 100644
--- a/app/javascript/mastodon/locales/en.json
+++ b/app/javascript/mastodon/locales/en.json
@@ -383,6 +383,7 @@
   "navigation_bar.favourites": "Favourites",
   "navigation_bar.filters": "Muted words",
   "navigation_bar.follow_requests": "Follow requests",
+  "navigation_bar.followed_tags": "Followed hashtags",
   "navigation_bar.follows_and_followers": "Follows and followers",
   "navigation_bar.lists": "Lists",
   "navigation_bar.misc": "Misc",
@@ -545,7 +546,7 @@
   "server_banner.server_stats": "Server stats:",
   "sign_in_banner.create_account": "Create account",
   "sign_in_banner.sign_in": "Sign in",
-  "sign_in_banner.text": "Sign in to follow profiles or hashtags, favourite, share and reply to posts, or interact from your account on a different server.",
+  "sign_in_banner.text": "Sign in to follow profiles or hashtags, favourite, share and reply to posts. You can also interact from your account on a different server.",
   "status.admin_account": "Open moderation interface for @{name}",
   "status.admin_domain": "Open moderation interface for {domain}",
   "status.admin_status": "Open this post in the moderation interface",
diff --git a/app/javascript/mastodon/reducers/compose.js b/app/javascript/mastodon/reducers/compose.js
index 9ce7e97ed..1760c7c89 100644
--- a/app/javascript/mastodon/reducers/compose.js
+++ b/app/javascript/mastodon/reducers/compose.js
@@ -444,7 +444,7 @@ export default function compose(state = initialState, action) {
       .setIn(['media_modal', 'dirty'], false)
       .update('media_attachments', list => list.map(item => {
         if (item.get('id') === action.media.id) {
-          return fromJS(action.media).set('unattached', true);
+          return fromJS(action.media).set('unattached', !action.attached);
         }
 
         return item;
diff --git a/app/javascript/mastodon/reducers/followed_tags.js b/app/javascript/mastodon/reducers/followed_tags.js
new file mode 100644
index 000000000..f50ee6aa3
--- /dev/null
+++ b/app/javascript/mastodon/reducers/followed_tags.js
@@ -0,0 +1,42 @@
+import {
+  FOLLOWED_HASHTAGS_FETCH_REQUEST,
+  FOLLOWED_HASHTAGS_FETCH_SUCCESS,
+  FOLLOWED_HASHTAGS_FETCH_FAIL,
+  FOLLOWED_HASHTAGS_EXPAND_REQUEST,
+  FOLLOWED_HASHTAGS_EXPAND_SUCCESS,
+  FOLLOWED_HASHTAGS_EXPAND_FAIL,
+} from 'mastodon/actions/tags';
+import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable';
+
+const initialState = ImmutableMap({
+  items: ImmutableList(),
+  isLoading: false,
+  next: null,
+});
+
+export default function followed_tags(state = initialState, action) {
+  switch(action.type) {
+  case FOLLOWED_HASHTAGS_FETCH_REQUEST:
+    return state.set('isLoading', true);
+  case FOLLOWED_HASHTAGS_FETCH_SUCCESS:
+    return state.withMutations(map => {
+      map.set('items', fromJS(action.followed_tags));
+      map.set('isLoading', false);
+      map.set('next', action.next);
+    });
+  case FOLLOWED_HASHTAGS_FETCH_FAIL:
+    return state.set('isLoading', false);
+  case FOLLOWED_HASHTAGS_EXPAND_REQUEST:
+    return state.set('isLoading', true);
+  case FOLLOWED_HASHTAGS_EXPAND_SUCCESS:
+    return state.withMutations(map => {
+      map.update('items', set => set.concat(fromJS(action.followed_tags)));
+      map.set('isLoading', false);
+      map.set('next', action.next);
+    });
+  case FOLLOWED_HASHTAGS_EXPAND_FAIL:
+    return state.set('isLoading', false);
+  default:
+    return state;
+  }
+};
diff --git a/app/javascript/mastodon/reducers/index.js b/app/javascript/mastodon/reducers/index.js
index bccdc1865..69771ad1b 100644
--- a/app/javascript/mastodon/reducers/index.js
+++ b/app/javascript/mastodon/reducers/index.js
@@ -40,6 +40,7 @@ import picture_in_picture from './picture_in_picture';
 import accounts_map from './accounts_map';
 import history from './history';
 import tags from './tags';
+import followed_tags from './followed_tags';
 
 const reducers = {
   announcements,
@@ -83,6 +84,7 @@ const reducers = {
   picture_in_picture,
   history,
   tags,
+  followed_tags,
 };
 
 export default combineReducers(reducers);
diff --git a/app/javascript/styles/mastodon/admin.scss b/app/javascript/styles/mastodon/admin.scss
index 4250cf2b6..798d520cd 100644
--- a/app/javascript/styles/mastodon/admin.scss
+++ b/app/javascript/styles/mastodon/admin.scss
@@ -1588,6 +1588,15 @@ a.sparkline {
           margin-bottom: 0;
         }
       }
+
+      a {
+        color: $highlight-text-color;
+        text-decoration: none;
+
+        &:hover {
+          text-decoration: underline;
+        }
+      }
     }
 
     &__actions {
diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss
index d5937643f..22f404c3b 100644
--- a/app/javascript/styles/mastodon/components.scss
+++ b/app/javascript/styles/mastodon/components.scss
@@ -423,7 +423,7 @@ body > [data-popper-placement] {
 
       &.active {
         border-color: $highlight-text-color;
-        background: $highlight-text-color;
+        background: $highlight-text-color url("data:image/svg+xml;utf8,<svg width='18' height='18' fill='none' xmlns='http://www.w3.org/2000/svg'><path d='M4.5 8.5L8 12l6-6' stroke='white' stroke-width='1.5'/></svg>") center center no-repeat;
       }
     }
   }
diff --git a/app/lib/admin/system_check/elasticsearch_check.rb b/app/lib/admin/system_check/elasticsearch_check.rb
index 7f922978f..5b4c12399 100644
--- a/app/lib/admin/system_check/elasticsearch_check.rb
+++ b/app/lib/admin/system_check/elasticsearch_check.rb
@@ -30,19 +30,24 @@ class Admin::SystemCheck::ElasticsearchCheck < Admin::SystemCheck::BaseCheck
 
   def running_version
     @running_version ||= begin
-      Chewy.client.info['version']['minimum_wire_compatibility_version'] ||
-        Chewy.client.info['version']['number']
+      Chewy.client.info['version']['number']
     rescue Faraday::ConnectionFailed
       nil
     end
   end
 
+  def compatible_wire_version
+    Chewy.client.info['version']['minimum_wire_compatibility_version']
+  end
+
   def required_version
     '7.x'
   end
 
   def compatible_version?
     return false if running_version.nil?
-    Gem::Version.new(running_version) >= Gem::Version.new(required_version)
+
+    Gem::Version.new(running_version) >= Gem::Version.new(required_version) ||
+      Gem::Version.new(compatible_wire_version) >= Gem::Version.new(required_version)
   end
 end
diff --git a/app/models/account.rb b/app/models/account.rb
index 851ce1fd6..f4631e938 100644
--- a/app/models/account.rb
+++ b/app/models/account.rb
@@ -88,8 +88,8 @@ class Account < ApplicationRecord
   validates :username, presence: true
   validates_with UniqueUsernameValidator, if: -> { will_save_change_to_username? }
 
-  # Remote user validations
-  validates :username, format: { with: USERNAME_ONLY_RE }, if: -> { !local? && will_save_change_to_username? }
+  # Remote user validations, also applies to internal actors
+  validates :username, format: { with: USERNAME_ONLY_RE }, if: -> { (!local? || actor_type == 'Application') && will_save_change_to_username? }
 
   # Local user validations
   validates :username, format: { with: /\A[a-z0-9_]+\z/i }, length: { maximum: 30 }, if: -> { local? && will_save_change_to_username? && actor_type != 'Application' }
diff --git a/app/models/admin/import.rb b/app/models/admin/import.rb
index 79c0722d5..fecde4878 100644
--- a/app/models/admin/import.rb
+++ b/app/models/admin/import.rb
@@ -1,5 +1,7 @@
 # frozen_string_literal: true
 
+require 'csv'
+
 # A non-activerecord helper class for csv upload
 class Admin::Import
   include ActiveModel::Model
@@ -15,17 +17,46 @@ class Admin::Import
     data.original_filename
   end
 
+  def csv_rows
+    csv_data.rewind
+
+    csv_data.take(ROWS_PROCESSING_LIMIT + 1)
+  end
+
   private
 
-  def validate_data
-    return if data.blank?
+  def csv_data
+    return @csv_data if defined?(@csv_data)
+
+    csv_converter = lambda do |field, field_info|
+      case field_info.header
+      when '#domain', '#public_comment'
+        field&.strip
+      when '#severity'
+        field&.strip&.to_sym
+      when '#reject_media', '#reject_reports', '#obfuscate'
+        ActiveModel::Type::Boolean.new.cast(field)
+      else
+        field
+      end
+    end
+
+    @csv_data = CSV.open(data.path, encoding: 'UTF-8', skip_blanks: true, headers: true, converters: csv_converter)
+    @csv_data.take(1) # Ensure the headers are read
+    @csv_data = CSV.open(data.path, encoding: 'UTF-8', skip_blanks: true, headers: ['#domain'], converters: csv_converter) unless @csv_data.headers&.first == '#domain'
+    @csv_data
+  end
 
-    csv_data = CSV.read(data.path, encoding: 'UTF-8')
+  def csv_row_count
+    return @csv_row_count if defined?(@csv_row_count)
 
-    row_count  = csv_data.size
-    row_count -= 1 if csv_data.first&.first == '#domain'
+    csv_data.rewind
+    @csv_row_count = csv_data.take(ROWS_PROCESSING_LIMIT + 2).count
+  end
 
-    errors.add(:data, I18n.t('imports.errors.over_rows_processing_limit', count: ROWS_PROCESSING_LIMIT)) if row_count > ROWS_PROCESSING_LIMIT
+  def validate_data
+    return if data.nil?
+    errors.add(:data, I18n.t('imports.errors.over_rows_processing_limit', count: ROWS_PROCESSING_LIMIT)) if csv_row_count > ROWS_PROCESSING_LIMIT
   rescue CSV::MalformedCSVError => e
     errors.add(:data, I18n.t('imports.errors.invalid_csv_file', error: e.message))
   end
diff --git a/app/models/admin/status_batch_action.rb b/app/models/admin/status_batch_action.rb
index 39cd7d0eb..b8bdec722 100644
--- a/app/models/admin/status_batch_action.rb
+++ b/app/models/admin/status_batch_action.rb
@@ -6,7 +6,8 @@ class Admin::StatusBatchAction
   include Authorization
 
   attr_accessor :current_account, :type,
-                :status_ids, :report_id
+                :status_ids, :report_id,
+                :text
 
   attr_reader :send_email_notification
 
@@ -57,7 +58,8 @@ class Admin::StatusBatchAction
         action: :delete_statuses,
         account: current_account,
         report: report,
-        status_ids: status_ids
+        status_ids: status_ids,
+        text: text
       )
 
       statuses.each { |status| Tombstone.find_or_create_by(uri: status.uri, account: status.account, by_moderator: true) } unless target_account.local?
@@ -95,7 +97,8 @@ class Admin::StatusBatchAction
       action: :mark_statuses_as_sensitive,
       account: current_account,
       report: report,
-      status_ids: status_ids
+      status_ids: status_ids,
+      text: text
     )
 
     UserMailer.warning(target_account.user, @warning).deliver_later! if warnable?
diff --git a/app/models/concerns/account_finder_concern.rb b/app/models/concerns/account_finder_concern.rb
index e8b804934..37c3b8895 100644
--- a/app/models/concerns/account_finder_concern.rb
+++ b/app/models/concerns/account_finder_concern.rb
@@ -13,9 +13,11 @@ module AccountFinderConcern
     end
 
     def representative
-      Account.find(-99).tap(&:ensure_keys!)
+      actor = Account.find(-99).tap(&:ensure_keys!)
+      actor.update!(username: 'mastodon.internal') if actor.username.include?(':')
+      actor
     rescue ActiveRecord::RecordNotFound
-      Account.create!(id: -99, actor_type: 'Application', locked: true, username: Rails.configuration.x.local_domain)
+      Account.create!(id: -99, actor_type: 'Application', locked: true, username: 'mastodon.internal')
     end
 
     def find_local(username)
diff --git a/app/models/form/admin_settings.rb b/app/models/form/admin_settings.rb
index b595529f8..f76ee42e7 100644
--- a/app/models/form/admin_settings.rb
+++ b/app/models/form/admin_settings.rb
@@ -28,6 +28,7 @@ class Form::AdminSettings
     show_reblogs_in_public_timelines
     show_replies_in_public_timelines
     trends
+    trends_as_landing_page
     trendable_by_default
     trending_status_cw
     show_domain_blocks
@@ -57,6 +58,7 @@ class Form::AdminSettings
     show_reblogs_in_public_timelines
     show_replies_in_public_timelines
     trends
+    trends_as_landing_page
     trendable_by_default
     trending_status_cw
     noindex
diff --git a/app/serializers/initial_state_serializer.rb b/app/serializers/initial_state_serializer.rb
index d753fa51a..c45de4e06 100644
--- a/app/serializers/initial_state_serializer.rb
+++ b/app/serializers/initial_state_serializer.rb
@@ -46,6 +46,7 @@ class InitialStateSerializer < ActiveModel::Serializer
       activity_api_enabled: Setting.activity_api_enabled,
       single_user_mode: Rails.configuration.x.single_user_mode,
       translation_enabled: TranslationService.configured?,
+      trends_as_landing_page: Setting.trends_as_landing_page,
     }
 
     if object.current_account
diff --git a/app/serializers/rest/account_serializer.rb b/app/serializers/rest/account_serializer.rb
index 9a3ca75dc..6152d54f7 100644
--- a/app/serializers/rest/account_serializer.rb
+++ b/app/serializers/rest/account_serializer.rb
@@ -16,6 +16,16 @@ class REST::AccountSerializer < ActiveModel::Serializer
   attribute :silenced, key: :limited, if: :silenced?
   attribute :noindex, if: :local?
 
+  class AccountDecorator < SimpleDelegator
+    def self.model_name
+      Account.model_name
+    end
+
+    def moved?
+      false
+    end
+  end
+
   class FieldSerializer < ActiveModel::Serializer
     include FormattingHelper
 
@@ -89,7 +99,7 @@ class REST::AccountSerializer < ActiveModel::Serializer
   end
 
   def moved_to_account
-    object.suspended? ? nil : object.moved_to_account
+    object.suspended? ? nil : AccountDecorator.new(object.moved_to_account)
   end
 
   def emojis
@@ -115,6 +125,6 @@ class REST::AccountSerializer < ActiveModel::Serializer
   delegate :suspended?, :silenced?, :local?, to: :object
 
   def moved_and_not_nested?
-    object.moved? && object.moved_to_account.moved_to_account_id.nil?
+    object.moved?
   end
 end
diff --git a/app/services/activitypub/fetch_remote_actor_service.rb b/app/services/activitypub/fetch_remote_actor_service.rb
index a25fa54c4..4f60ea5e8 100644
--- a/app/services/activitypub/fetch_remote_actor_service.rb
+++ b/app/services/activitypub/fetch_remote_actor_service.rb
@@ -28,6 +28,7 @@ class ActivityPub::FetchRemoteActorService < BaseService
     raise Error, "Unsupported JSON-LD context for document #{uri}" unless supported_context?
     raise Error, "Unexpected object type for actor #{uri} (expected any of: #{SUPPORTED_TYPES})" unless expected_type?
     raise Error, "Actor #{uri} has moved to #{@json['movedTo']}" if break_on_redirect && @json['movedTo'].present?
+    raise Error, "Actor #{uri} has no 'preferredUsername', which is a requirement for Mastodon compatibility" unless @json['preferredUsername'].present?
 
     @uri      = @json['id']
     @username = @json['preferredUsername']
diff --git a/app/services/update_status_service.rb b/app/services/update_status_service.rb
index cc4ec670d..7a8245839 100644
--- a/app/services/update_status_service.rb
+++ b/app/services/update_status_service.rb
@@ -10,6 +10,7 @@ class UpdateStatusService < BaseService
   # @param [Integer] account_id
   # @param [Hash] options
   # @option options [Array<Integer>] :media_ids
+  # @option options [Array<Hash>] :media_attributes
   # @option options [Hash] :poll
   # @option options [String] :text
   # @option options [String] :spoiler_text
@@ -51,10 +52,18 @@ class UpdateStatusService < BaseService
     next_media_attachments     = validate_media!
     added_media_attachments    = next_media_attachments - previous_media_attachments
 
+    (@options[:media_attributes] || []).each do |attributes|
+      media = next_media_attachments.find { |attachment| attachment.id == attributes[:id].to_i }
+      next if media.nil?
+
+      media.update!(attributes.slice(:thumbnail, :description, :focus))
+      @media_attachments_changed ||= media.significantly_changed?
+    end
+
     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
+    @media_attachments_changed ||= previous_media_attachments.map(&:id) != @status.ordered_media_attachment_ids
     @status.media_attachments.reload
   end
 
diff --git a/app/views/admin/announcements/edit.html.haml b/app/views/admin/announcements/edit.html.haml
index fa69c6fab..df1ac455f 100644
--- a/app/views/admin/announcements/edit.html.haml
+++ b/app/views/admin/announcements/edit.html.haml
@@ -16,7 +16,7 @@
 
   - unless @announcement.published?
     .fields-group
-      = f.input :scheduled_at, include_blank: true, wrapper: :with_block_label
+      = f.input :scheduled_at, include_blank: true, wrapper: :with_block_label, html5: true, input_html: { pattern: '[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}(:[0-9]{2}){1,2}', placeholder: Time.now.strftime('%FT%R') }
 
   .actions
     = f.button :button, t('generic.save_changes'), type: :submit
diff --git a/app/views/admin/report_notes/_report_note.html.haml b/app/views/admin/report_notes/_report_note.html.haml
index 54c252ee8..64628989a 100644
--- a/app/views/admin/report_notes/_report_note.html.haml
+++ b/app/views/admin/report_notes/_report_note.html.haml
@@ -8,7 +8,7 @@
       = l report_note.created_at.to_date
 
   .report-notes__item__content
-    = simple_format(h(report_note.content))
+    = linkify(report_note.content)
 
   - if can?(:destroy, report_note)
     .report-notes__item__actions
diff --git a/app/views/admin/reports/_actions.html.haml b/app/views/admin/reports/_actions.html.haml
index 486eb486c..aad441625 100644
--- a/app/views/admin/reports/_actions.html.haml
+++ b/app/views/admin/reports/_actions.html.haml
@@ -1,4 +1,4 @@
-= form_tag admin_report_actions_path(@report), method: :post do
+= form_tag preview_admin_report_actions_path(@report), method: :post do
   .report-actions
     .report-actions__item
       .report-actions__item__button
diff --git a/app/views/admin/reports/actions/preview.html.haml b/app/views/admin/reports/actions/preview.html.haml
new file mode 100644
index 000000000..58745319c
--- /dev/null
+++ b/app/views/admin/reports/actions/preview.html.haml
@@ -0,0 +1,78 @@
+- target_acct = @report.target_account.acct
+- warning_action = { 'delete' => 'delete_statuses', 'mark_as_sensitive' => 'mark_statuses_as_sensitive' }.fetch(@moderation_action, @moderation_action)
+
+- content_for :page_title do
+  = t('admin.reports.confirm_action', acct: target_acct)
+
+= form_tag admin_report_actions_path(@report), class: 'simple_form', method: :post do
+  = hidden_field_tag :moderation_action, @moderation_action
+
+  %p.hint= t("admin.reports.summary.action_preambles.#{@moderation_action}_html", acct: target_acct)
+  %ul.hint
+    %li.warning-hint= t("admin.reports.summary.actions.#{@moderation_action}_html", acct: target_acct)
+    - if @moderation_action == 'suspend'
+      %li.warning-hint= t('admin.reports.summary.delete_data_html', acct: target_acct)
+    - if %w(silence suspend).include?(@moderation_action)
+      %li.warning-hint= t('admin.reports.summary.close_reports_html', acct: target_acct)
+    - else
+      %li= t('admin.reports.summary.close_report', id: @report.id)
+    %li= t('admin.reports.summary.record_strike_html', acct: target_acct)
+    - if @report.target_account.local? && !@report.spam?
+      %li= t('admin.reports.summary.send_email_html', acct: target_acct)
+
+  %hr.spacer/
+
+  - if @report.target_account.local?
+    %p.hint= t('admin.reports.summary.preview_preamble_html', acct: target_acct)
+
+    .strike-card
+      - unless warning_action == 'none'
+        %p= t "user_mailer.warning.explanation.#{warning_action}", instance: Rails.configuration.x.local_domain
+
+      .fields-group
+        = text_area_tag :text, nil, placeholder: t('admin.reports.summary.warning_placeholder')
+
+      - if !@report.other?
+        %p
+          %strong= t('user_mailer.warning.reason')
+          = t("user_mailer.warning.categories.#{@report.category}")
+
+        - if @report.violation? && @report.rule_ids.present?
+          %ul.strike-card__rules
+            - @report.rules.each do |rule|
+              %li
+                %span.strike-card__rules__text= rule.text
+
+      - if @report.status_ids.present? && !@report.status_ids.empty?
+        %p
+          %strong= t('user_mailer.warning.statuses')
+
+        .strike-card__statuses-list
+          - status_map = @report.statuses.includes(:application, :media_attachments).index_by(&:id)
+
+          - @report.status_ids.each do |status_id|
+            .strike-card__statuses-list__item
+              - if (status = status_map[status_id.to_i])
+                .one-liner
+                  = link_to short_account_status_url(@report.target_account, status_id), class: 'emojify' do
+                    = one_line_preview(status)
+
+                    - status.ordered_media_attachments.each do |media_attachment|
+                      %abbr{ title: media_attachment.description }
+                        = fa_icon 'link'
+                        = media_attachment.file_file_name
+                .strike-card__statuses-list__item__meta
+                  %time.formatted{ datetime: status.created_at.iso8601, title: l(status.created_at) }= l(status.created_at)
+                  - unless status.application.nil?
+                    ·
+                    = status.application.name
+              - else
+                .one-liner= t('disputes.strikes.status', id: status_id)
+                .strike-card__statuses-list__item__meta
+                  = t('disputes.strikes.status_removed')
+
+    %hr.spacer/
+
+  .actions
+    = link_to t('admin.reports.cancel'), admin_report_path(@report), class: 'button button-tertiary'
+    = button_tag t('admin.reports.confirm'), name: :confirm, class: 'button', type: :submit
diff --git a/app/views/admin/settings/discovery/show.html.haml b/app/views/admin/settings/discovery/show.html.haml
index 01e3124cf..460bb5709 100644
--- a/app/views/admin/settings/discovery/show.html.haml
+++ b/app/views/admin/settings/discovery/show.html.haml
@@ -16,6 +16,9 @@
     = f.input :trends, as: :boolean, wrapper: :with_label
 
   .fields-group
+    = f.input :trends_as_landing_page, as: :boolean, wrapper: :with_label
+
+  .fields-group
     = f.input :trendable_by_default, as: :boolean, wrapper: :with_label, recommended: :not_recommended
 
   .fields-group
diff --git a/config/i18n-tasks.yml b/config/i18n-tasks.yml
index 334511efe..a3b5e5c72 100644
--- a/config/i18n-tasks.yml
+++ b/config/i18n-tasks.yml
@@ -65,9 +65,11 @@ ignore_unused:
   - 'errors.429'
   - 'admin.accounts.roles.*'
   - 'admin.action_logs.actions.*'
-  - 'themes.*'
+  - 'admin.reports.summary.action_preambles.*'
+  - 'admin.reports.summary.actions.*'
   - 'admin_mailer.new_appeal.actions.*'
   - 'statuses.attached.*'
+  - 'themes.*'
   - 'move_handler.carry_{mutes,blocks}_over_text'
   - 'notification_mailer.*'
 
diff --git a/config/locales/en.yml b/config/locales/en.yml
index 763110c77..3de2f2772 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -441,6 +441,7 @@ en:
         private_comment_description_html: 'To help you track where imported blocks come from, imported blocks will be created with the following private comment: <q>%{comment}</q>'
         private_comment_template: Imported from %{source} on %{date}
         title: Import domain blocks
+      invalid_domain_block: 'One or more domain blocks were skipped because of the following error(s): %{error}'
       new:
         title: Import domain blocks
       no_file: No file selected
@@ -589,6 +590,7 @@ en:
       comment:
         none: None
       comment_description_html: 'To provide more information, %{name} wrote:'
+      confirm_action: Confirm moderation action against @%{acct}
       created_at: Reported
       delete_and_resolve: Delete posts
       forwarded: Forwarded
@@ -605,6 +607,7 @@ en:
         placeholder: Describe what actions have been taken, or any other related updates...
         title: Notes
       notes_description_html: View and leave notes to other moderators and your future self
+      processed_msg: 'Report #%{id} successfully processed'
       quick_actions_description_html: 'Take a quick action or scroll down to see reported content:'
       remote_user_placeholder: the remote user from %{instance}
       reopen: Reopen report
@@ -617,9 +620,28 @@ en:
       status: Status
       statuses: Reported content
       statuses_description_html: Offending content will be cited in communication with the reported account
+      summary:
+        action_preambles:
+          delete_html: 'You are about to <strong>remove</strong> some of <strong>@%{acct}</strong>''s posts. This will:'
+          mark_as_sensitive_html: 'You are about to <strong>mark</strong> some of <strong>@%{acct}</strong>''s posts as <strong>sensitive</strong>. This will:'
+          silence_html: 'You are about to <strong>limit</strong> <strong>@%{acct}</strong>''s account. This will:'
+          suspend_html: 'You are about to <strong>suspend</strong> <strong>@%{acct}</strong>''s account. This will:'
+        actions:
+          delete_html: Remove the offending posts
+          mark_as_sensitive_html: Mark the offending posts' media as sensitive
+          silence_html: Severely limit <strong>@%{acct}</strong>'s reach by making their profile and contents only visible to people already following them or manually looking it profile up
+          suspend_html: Suspend <strong>@%{acct}</strong>, making their profile and contents inaccessible and impossible to interact with
+        close_report: 'Mark report #%{id} as resolved'
+        close_reports_html: Mark <strong>all</strong> reports against <strong>@%{acct}</strong> as resolved
+        delete_data_html: Delete <strong>@%{acct}</strong>'s profile and contents 30 days from now unless they get unsuspended in the meantime
+        preview_preamble_html: "<strong>@%{acct}</strong> will receive a warning with the following contents:"
+        record_strike_html: Record a strike against <strong>@%{acct}</strong> to help you escalate on future violations from this account
+        send_email_html: Send <strong>@%{acct}</strong> a warning e-mail
+        warning_placeholder: Optional additional reasoning for the moderation action.
       target_origin: Origin of reported account
       title: Reports
       unassign: Unassign
+      unknown_action_msg: 'Unknown action: %{action}'
       unresolved: Unresolved
       updated_at: Updated
       view_profile: View profile
diff --git a/config/locales/simple_form.en.yml b/config/locales/simple_form.en.yml
index f66e12c4c..d01f0ae75 100644
--- a/config/locales/simple_form.en.yml
+++ b/config/locales/simple_form.en.yml
@@ -96,6 +96,7 @@ en:
         timeline_preview: Logged out visitors will be able to browse the most recent public posts available on the server.
         trendable_by_default: Skip manual review of trending content. Individual items can still be removed from trends after the fact.
         trends: Trends show which posts, hashtags and news stories are gaining traction on your server.
+        trends_as_landing_page: Show trending content to logged-out users and visitors instead of a description of this server. Requires trends to be enabled.
       form_challenge:
         current_password: You are entering a secure area
       imports:
@@ -256,6 +257,7 @@ en:
         timeline_preview: Allow unauthenticated access to public timelines
         trendable_by_default: Allow trends without prior review
         trends: Enable trends
+        trends_as_landing_page: Use trends as the landing page
       interactions:
         must_be_follower: Block notifications from non-followers
         must_be_following: Block notifications from people you don't follow
diff --git a/config/routes.rb b/config/routes.rb
index 8639f0ef5..f78d692b6 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -27,6 +27,7 @@ Rails.application.routes.draw do
     /blocks
     /domain_blocks
     /mutes
+    /followed_tags
     /statuses/(*any)
   ).freeze
 
@@ -314,7 +315,11 @@ Rails.application.routes.draw do
     end
 
     resources :reports, only: [:index, :show] do
-      resources :actions, only: [:create], controller: 'reports/actions'
+      resources :actions, only: [:create], controller: 'reports/actions' do
+        collection do
+          post :preview
+        end
+      end
 
       member do
         post :assign_to_self
diff --git a/config/settings.yml b/config/settings.yml
index 1f44fd308..c8d37a678 100644
--- a/config/settings.yml
+++ b/config/settings.yml
@@ -39,6 +39,7 @@ defaults: &defaults
   use_blurhash: true
   use_pending_items: false
   trends: true
+  trends_as_landing_page: true
   trendable_by_default: false
   trending_status_cw: true
   crop_images: true
diff --git a/db/seeds/02_instance_actor.rb b/db/seeds/02_instance_actor.rb
index 39186b273..f9aa372f1 100644
--- a/db/seeds/02_instance_actor.rb
+++ b/db/seeds/02_instance_actor.rb
@@ -1 +1 @@
-Account.create_with(actor_type: 'Application', locked: true, username: ENV['LOCAL_DOMAIN'] || Rails.configuration.x.local_domain).find_or_create_by(id: -99)
+Account.create_with(actor_type: 'Application', locked: true, username: 'mastodon.internal').find_or_create_by(id: -99)
diff --git a/lib/mastodon/domains_cli.rb b/lib/mastodon/domains_cli.rb
index 77364ffbb..81ee53c18 100644
--- a/lib/mastodon/domains_cli.rb
+++ b/lib/mastodon/domains_cli.rb
@@ -18,6 +18,8 @@ module Mastodon
     option :dry_run, type: :boolean
     option :limited_federation_mode, type: :boolean
     option :by_uri, type: :boolean
+    option :include_subdomains, type: :boolean
+    option :purge_domain_blocks, type: :boolean
     desc 'purge [DOMAIN...]', 'Remove accounts from a DOMAIN without a trace'
     long_desc <<-LONG_DESC
       Remove all accounts from a given DOMAIN without leaving behind any
@@ -33,40 +35,75 @@ module Mastodon
       that has the handle `foo@bar.com` but whose profile is at the URL
       `https://mastodon-bar.com/users/foo`, would be purged by either
       `tootctl domains purge bar.com` or `tootctl domains purge --by-uri mastodon-bar.com`.
+
+      When the --include-subdomains option is given, not only DOMAIN is deleted, but all
+      subdomains as well. Note that this may be considerably slower.
+
+      When the --purge-domain-blocks option is given, also purge matching domain blocks.
     LONG_DESC
     def purge(*domains)
-      dry_run = options[:dry_run] ? ' (DRY RUN)' : ''
-
-      scope = begin
-        if options[:limited_federation_mode]
-          Account.remote.where.not(domain: DomainAllow.pluck(:domain))
-        elsif !domains.empty?
-          if options[:by_uri]
-            domains.map { |domain| Account.remote.where(Account.arel_table[:uri].matches("https://#{domain}/%", false, true)) }.reduce(:or)
-          else
-            Account.remote.where(domain: domains)
-          end
+      dry_run            = options[:dry_run] ? ' (DRY RUN)' : ''
+      domains            = domains.map { |domain| TagManager.instance.normalize_domain(domain) }
+      account_scope      = Account.none
+      domain_block_scope = DomainBlock.none
+      emoji_scope        = CustomEmoji.none
+
+      # Sanity check on command arguments
+      if options[:limited_federation_mode] && !domains.empty?
+        say('DOMAIN parameter not supported with --limited-federation-mode', :red)
+        exit(1)
+      elsif domains.empty? && !options[:limited_federation_mode]
+        say('No domain(s) given', :red)
+        exit(1)
+      end
+
+      # Build scopes from command arguments
+      if options[:limited_federation_mode]
+        account_scope = Account.remote.where.not(domain: DomainAllow.select(:domain))
+        emoji_scope   = CustomEmoji.remote.where.not(domain: DomainAllow.select(:domain))
+      else
+        # Handle wildcard subdomains
+        subdomain_patterns = domains.filter_map { |domain| "%.#{Account.sanitize_sql_like(domain[2..])}" if domain.start_with?('*.') }
+        domains = domains.filter { |domain| !domain.start_with?('*.') }
+        # Handle --include-subdomains
+        subdomain_patterns += domains.map { |domain| "%.#{Account.sanitize_sql_like(domain)}" } if options[:include_subdomains]
+        uri_patterns = (domains.map { |domain| Account.sanitize_sql_like(domain) } + subdomain_patterns).map { |pattern| "https://#{pattern}/%" }
+
+        if options[:purge_domain_blocks]
+          domain_block_scope = DomainBlock.where(domain: domains)
+          domain_block_scope = domain_block_scope.or(DomainBlock.where(DomainBlock.arel_table[:domain].matches_any(subdomain_patterns))) unless subdomain_patterns.empty?
+        end
+
+        if options[:by_uri]
+          account_scope = Account.remote.where(Account.arel_table[:uri].matches_any(uri_patterns, false, true))
+          emoji_scope   = CustomEmoji.remote.where(CustomEmoji.arel_table[:uri].matches_any(uri_patterns, false, true))
         else
-          say('No domain(s) given', :red)
-          exit(1)
+          account_scope = Account.remote.where(domain: domains)
+          account_scope = account_scope.or(Account.remote.where(Account.arel_table[:domain].matches_any(subdomain_patterns))) unless subdomain_patterns.empty?
+          emoji_scope   = CustomEmoji.where(domain: domains)
+          emoji_scope   = emoji_scope.or(CustomEmoji.remote.where(CustomEmoji.arel_table[:uri].matches_any(subdomain_patterns))) unless subdomain_patterns.empty?
         end
       end
 
-      processed, = parallelize_with_progress(scope) do |account|
+      # Actually perform the deletions
+      processed, = parallelize_with_progress(account_scope) do |account|
         DeleteAccountService.new.call(account, reserve_username: false, skip_side_effects: true) unless options[:dry_run]
       end
 
-      DomainBlock.where(domain: domains).destroy_all unless options[:dry_run]
-
       say("Removed #{processed} accounts#{dry_run}", :green)
 
-      custom_emojis = CustomEmoji.where(domain: domains)
-      custom_emojis_count = custom_emojis.count
-      custom_emojis.destroy_all unless options[:dry_run]
+      if options[:purge_domain_blocks]
+        domain_block_count = domain_block_scope.count
+        domain_block_scope.in_batches.destroy_all unless options[:dry_run]
+        say("Removed #{domain_block_count} domain blocks#{dry_run}", :green)
+      end
+
+      custom_emojis_count = emoji_scope.count
+      emoji_scope.in_batches.destroy_all unless options[:dry_run]
 
       Instance.refresh unless options[:dry_run]
 
-      say("Removed #{custom_emojis_count} custom emojis", :green)
+      say("Removed #{custom_emojis_count} custom emojis#{dry_run}", :green)
     end
 
     option :concurrency, type: :numeric, default: 50, aliases: [:c]
diff --git a/spec/controllers/admin/export_domain_blocks_controller_spec.rb b/spec/controllers/admin/export_domain_blocks_controller_spec.rb
index 8697e0c21..2766102c8 100644
--- a/spec/controllers/admin/export_domain_blocks_controller_spec.rb
+++ b/spec/controllers/admin/export_domain_blocks_controller_spec.rb
@@ -9,9 +9,9 @@ RSpec.describe Admin::ExportDomainBlocksController, type: :controller do
 
   describe 'GET #export' do
     it 'renders instances' do
-      Fabricate(:domain_block, domain: 'bad.domain', severity: 'silence', public_comment: 'bad')
-      Fabricate(:domain_block, domain: 'worse.domain', severity: 'suspend', reject_media: true, reject_reports: true, public_comment: 'worse', obfuscate: true)
-      Fabricate(:domain_block, domain: 'reject.media', severity: 'noop', reject_media: true, public_comment: 'reject media')
+      Fabricate(:domain_block, domain: 'bad.domain', severity: 'silence', public_comment: 'bad server')
+      Fabricate(:domain_block, domain: 'worse.domain', severity: 'suspend', reject_media: true, reject_reports: true, public_comment: 'worse server', obfuscate: true)
+      Fabricate(:domain_block, domain: 'reject.media', severity: 'noop', reject_media: true, public_comment: 'reject media and test unicode characters ♥')
       Fabricate(:domain_block, domain: 'no.op', severity: 'noop', public_comment: 'noop')
 
       get :export, params: { format: :csv }
@@ -21,10 +21,32 @@ RSpec.describe Admin::ExportDomainBlocksController, type: :controller do
   end
 
   describe 'POST #import' do
-    it 'blocks imported domains' do
-      post :import, params: { admin_import: { data: fixture_file_upload('domain_blocks.csv') } }
+    context 'with complete domain blocks CSV' do
+      before do
+        post :import, params: { admin_import: { data: fixture_file_upload('domain_blocks.csv') } }
+      end
 
-      expect(assigns(:domain_blocks).map(&:domain)).to match_array ['bad.domain', 'worse.domain', 'reject.media']
+      it 'renders page with expected domain blocks' do
+        expect(assigns(:domain_blocks).map { |block| [block.domain, block.severity.to_sym] }).to match_array [['bad.domain', :silence], ['worse.domain', :suspend], ['reject.media', :noop]]
+      end
+
+      it 'returns http success' do
+        expect(response).to have_http_status(200)
+      end
+    end
+
+    context 'with a list of only domains' do
+      before do
+        post :import, params: { admin_import: { data: fixture_file_upload('domain_blocks_list.txt') } }
+      end
+
+      it 'renders page with expected domain blocks' do
+        expect(assigns(:domain_blocks).map { |block| [block.domain, block.severity.to_sym] }).to match_array [['bad.domain', :suspend], ['worse.domain', :suspend], ['reject.media', :suspend]]
+      end
+
+      it 'returns http success' do
+        expect(response).to have_http_status(200)
+      end
     end
   end
 
diff --git a/spec/controllers/admin/reports/actions_controller_spec.rb b/spec/controllers/admin/reports/actions_controller_spec.rb
index 6609798dc..9890ac9ce 100644
--- a/spec/controllers/admin/reports/actions_controller_spec.rb
+++ b/spec/controllers/admin/reports/actions_controller_spec.rb
@@ -4,39 +4,131 @@ describe Admin::Reports::ActionsController do
   render_views
 
   let(:user) { Fabricate(:user, role: UserRole.find_by(name: 'Admin')) }
-  let(:account) { Fabricate(:account) }
-  let!(:status) { Fabricate(:status, account: account) }
-  let(:media_attached_status) { Fabricate(:status, account: account) }
-  let!(:media_attachment) { Fabricate(:media_attachment, account: account, status: media_attached_status) }
-  let(:media_attached_deleted_status) { Fabricate(:status, account: account, deleted_at: 1.day.ago) }
-  let!(:media_attachment2) { Fabricate(:media_attachment, account: account, status: media_attached_deleted_status) }
-  let(:last_media_attached_status) { Fabricate(:status, account: account) }
-  let!(:last_media_attachment) { Fabricate(:media_attachment, account: account, status: last_media_attached_status) }
-  let!(:last_status) { Fabricate(:status, account: account) }
 
   before do
     sign_in user, scope: :user
   end
 
-  describe 'POST #create' do
-    let(:report) { Fabricate(:report, status_ids: status_ids, account: user.account, target_account: account) }
-    let(:status_ids) { [media_attached_status.id, media_attached_deleted_status.id] }
+  describe 'POST #preview' do
+    let(:report) { Fabricate(:report) }
 
     before do
-      post :create, params: { report_id: report.id, action => '' }
+      post :preview, params: { report_id: report.id, action => '' }
+    end
+
+    context 'when the action is "suspend"' do
+      let(:action) { 'suspend' }
+
+      it 'returns http success' do
+        expect(response).to have_http_status(200)
+      end
     end
 
-    context 'when action is mark_as_sensitive' do
+    context 'when the action is "silence"' do
+      let(:action) { 'silence' }
 
+      it 'returns http success' do
+        expect(response).to have_http_status(200)
+      end
+    end
+
+    context 'when the action is "delete"' do
+      let(:action) { 'delete' }
+
+      it 'returns http success' do
+        expect(response).to have_http_status(200)
+      end
+    end
+
+    context 'when the action is "mark_as_sensitive"' do
       let(:action) { 'mark_as_sensitive' }
 
-      it 'resolves the report' do
-        expect(report.reload.action_taken_at).to_not be_nil
+      it 'returns http success' do
+        expect(response).to have_http_status(200)
+      end
+    end
+  end
+
+  describe 'POST #create' do
+    let(:target_account) { Fabricate(:account) }
+    let(:statuses)       { [Fabricate(:status, account: target_account), Fabricate(:status, account: target_account)] }
+    let!(:media)         { Fabricate(:media_attachment, account: target_account, status: statuses[0]) }
+    let(:report)         { Fabricate(:report, target_account: target_account, status_ids: statuses.map(&:id)) }
+    let(:text)           { 'hello' }
+
+    shared_examples 'common behavior' do
+      it 'closes the report' do
+        expect { subject }.to change { report.reload.action_taken? }.from(false).to(true)
       end
 
-      it 'marks the non-deleted as sensitive' do
-        expect(media_attached_status.reload.sensitive).to eq true
+      it 'creates a strike with the expected text' do
+        expect { subject }.to change { report.target_account.strikes.count }.by(1)
+        expect(report.target_account.strikes.last.text).to eq text
       end
+
+      it 'redirects' do
+        subject
+        expect(response).to redirect_to(admin_reports_path)
+      end
+    end
+
+    shared_examples 'all action types' do
+      context 'when the action is "suspend"' do
+        let(:action) { 'suspend' }
+
+        it_behaves_like 'common behavior'
+
+        it 'suspends the target account' do
+          expect { subject }.to change { report.target_account.reload.suspended? }.from(false).to(true)
+        end
+      end
+
+      context 'when the action is "silence"' do
+        let(:action) { 'silence' }
+
+        it_behaves_like 'common behavior'
+
+        it 'suspends the target account' do
+          expect { subject }.to change { report.target_account.reload.silenced? }.from(false).to(true)
+        end
+      end
+
+      context 'when the action is "delete"' do
+        let(:action) { 'delete' }
+
+        it_behaves_like 'common behavior'
+      end
+
+      context 'when the action is "mark_as_sensitive"' do
+        let(:action) { 'mark_as_sensitive' }
+        let(:statuses) { [media_attached_status, media_attached_deleted_status] }
+
+        let!(:status) { Fabricate(:status, account: target_account) }
+        let(:media_attached_status) { Fabricate(:status, account: target_account) }
+        let!(:media_attachment) { Fabricate(:media_attachment, account: target_account, status: media_attached_status) }
+        let(:media_attached_deleted_status) { Fabricate(:status, account: target_account, deleted_at: 1.day.ago) }
+        let!(:media_attachment2) { Fabricate(:media_attachment, account: target_account, status: media_attached_deleted_status) }
+        let(:last_media_attached_status) { Fabricate(:status, account: target_account) }
+        let!(:last_media_attachment) { Fabricate(:media_attachment, account: target_account, status: last_media_attached_status) }
+        let!(:last_status) { Fabricate(:status, account: target_account) }
+
+        it_behaves_like 'common behavior'
+
+        it 'marks the non-deleted as sensitive' do
+          subject
+          expect(media_attached_status.reload.sensitive).to eq true
+        end
+      end
+    end
+
+    context 'action as submit button' do
+      subject { post :create, params: { report_id: report.id, text: text, action => '' } }
+      it_behaves_like 'all action types'
+    end
+
+    context 'action as submit button' do
+      subject { post :create, params: { report_id: report.id, text: text, moderation_action: action } }
+      it_behaves_like 'all action types'
     end
   end
 end
diff --git a/spec/controllers/concerns/signature_verification_spec.rb b/spec/controllers/concerns/signature_verification_spec.rb
index 6e73643b4..13655f313 100644
--- a/spec/controllers/concerns/signature_verification_spec.rb
+++ b/spec/controllers/concerns/signature_verification_spec.rb
@@ -16,6 +16,8 @@ describe ApplicationController, type: :controller do
   controller do
     include SignatureVerification
 
+    before_action :require_actor_signature!, only: [:signature_required]
+
     def success
       head 200
     end
@@ -23,10 +25,17 @@ describe ApplicationController, type: :controller do
     def alternative_success
       head 200
     end
+
+    def signature_required
+      head 200
+    end
   end
 
   before do
-    routes.draw { match via: [:get, :post], 'success' => 'anonymous#success' }
+    routes.draw do
+      match via: [:get, :post], 'success' => 'anonymous#success'
+      match via: [:get, :post], 'signature_required' => 'anonymous#signature_required'
+    end
   end
 
   context 'without signature header' do
@@ -118,6 +127,37 @@ describe ApplicationController, type: :controller do
       end
     end
 
+    context 'with request with unparseable Date header' do
+      before do
+        get :success
+
+        fake_request = Request.new(:get, request.url)
+        fake_request.add_headers({ 'Date' => 'wrong date' })
+        fake_request.on_behalf_of(author)
+
+        request.headers.merge!(fake_request.headers)
+      end
+
+      describe '#signed_request?' do
+        it 'returns true' do
+          expect(controller.signed_request?).to be true
+        end
+      end
+
+      describe '#signed_request_account' do
+        it 'returns nil' do
+          expect(controller.signed_request_account).to be_nil
+        end
+      end
+
+      describe '#signature_verification_failure_reason' do
+        it 'contains an error description' do
+          controller.signed_request_account
+          expect(controller.signature_verification_failure_reason[:error]).to eq 'Invalid Date header: not RFC 2616 compliant date: "wrong date"'
+        end
+      end
+    end
+
     context 'with request older than a day' do
       before do
         get :success
@@ -140,6 +180,13 @@ describe ApplicationController, type: :controller do
           expect(controller.signed_request_account).to be_nil
         end
       end
+
+      describe '#signature_verification_failure_reason' do
+        it 'contains an error description' do
+          controller.signed_request_account
+          expect(controller.signature_verification_failure_reason[:error]).to eq 'Signed request date outside acceptable time window'
+        end
+      end
     end
 
     context 'with inaccessible key' do
@@ -171,6 +218,7 @@ describe ApplicationController, type: :controller do
 
     context 'with body' do
       before do
+        allow(controller).to receive(:actor_refresh_key!).and_return(author)
         post :success, body: 'Hello world'
 
         fake_request = Request.new(:post, request.url, body: 'Hello world')
@@ -189,21 +237,66 @@ describe ApplicationController, type: :controller do
         it 'returns an account' do
           expect(controller.signed_request_account).to eq author
         end
+      end
 
-        it 'returns nil when path does not match' do
+      context 'when path does not match' do
+        before do
           request.path = '/alternative-path'
-          expect(controller.signed_request_account).to be_nil
         end
 
-        it 'returns nil when method does not match' do
+        describe '#signed_request_account' do
+          it 'returns nil' do
+            expect(controller.signed_request_account).to be_nil
+          end
+        end
+
+        describe '#signature_verification_failure_reason' do
+          it 'contains an error description' do
+            controller.signed_request_account
+            expect(controller.signature_verification_failure_reason[:error]).to include('using rsa-sha256 (RSASSA-PKCS1-v1_5 with SHA-256)')
+            expect(controller.signature_verification_failure_reason[:signed_string]).to include("(request-target): post /alternative-path\n")
+          end
+        end
+      end
+
+      context 'when method does not match' do
+        before do
           get :success
-          expect(controller.signed_request_account).to be_nil
         end
 
-        it 'returns nil when body has been tampered' do
+        describe '#signed_request_account' do
+          it 'returns nil' do
+            expect(controller.signed_request_account).to be_nil
+          end
+        end
+      end
+
+      context 'when body has been tampered' do
+        before do
           post :success, body: 'doo doo doo'
-          expect(controller.signed_request_account).to be_nil
         end
+
+        describe '#signed_request_account' do
+          it 'returns nil when body has been tampered' do
+            expect(controller.signed_request_account).to be_nil
+          end
+        end
+      end
+    end
+  end
+
+  context 'when a signature is required' do
+    before do
+      get :signature_required
+    end
+
+    context 'without signature header' do
+      it 'returns HTTP 401' do
+        expect(response).to have_http_status(401)
+      end
+
+      it 'returns an error' do
+        expect(Oj.load(response.body)['error']).to eq 'Request not signed'
       end
     end
   end
diff --git a/spec/fixtures/files/domain_blocks.csv b/spec/fixtures/files/domain_blocks.csv
index 28ffb9175..9dbfb4eaf 100644
--- a/spec/fixtures/files/domain_blocks.csv
+++ b/spec/fixtures/files/domain_blocks.csv
@@ -1,4 +1,4 @@
 #domain,#severity,#reject_media,#reject_reports,#public_comment,#obfuscate
-bad.domain,silence,false,false,bad,false
-worse.domain,suspend,true,true,worse,true
-reject.media,noop,true,false,reject media,false
+bad.domain,silence,false,false,bad server,false
+worse.domain,suspend,true,true,worse server,true
+reject.media,noop,true,false,reject media and test unicode characters ♥,false
diff --git a/spec/fixtures/files/domain_blocks_list.txt b/spec/fixtures/files/domain_blocks_list.txt
new file mode 100644
index 000000000..7b6b24253
--- /dev/null
+++ b/spec/fixtures/files/domain_blocks_list.txt
@@ -0,0 +1,3 @@
+bad.domain
+worse.domain
+reject.media
diff --git a/spec/services/update_status_service_spec.rb b/spec/services/update_status_service_spec.rb
index 71a73be5b..16e981d2b 100644
--- a/spec/services/update_status_service_spec.rb
+++ b/spec/services/update_status_service_spec.rb
@@ -87,6 +87,28 @@ RSpec.describe UpdateStatusService, type: :service do
     end
   end
 
+  context 'when already-attached media changes' do
+    let!(:status) { Fabricate(:status, text: 'Foo') }
+    let!(:media_attachment) { Fabricate(:media_attachment, account: status.account, description: 'Old description') }
+
+    before do
+      status.media_attachments << media_attachment
+      subject.call(status, status.account_id, text: 'Foo', media_ids: [media_attachment.id], media_attributes: [{ id: media_attachment.id, description: 'New description' }])
+    end
+
+    it 'does not detach media attachment' do
+      expect(media_attachment.reload.status_id).to eq status.id
+    end
+
+    it 'updates the media attachment description' do
+      expect(media_attachment.reload.description).to eq 'New description'
+    end
+
+    it 'saves edit history' do
+      expect(status.edits.map { |edit| edit.ordered_media_attachments.map(&:description) }).to eq [['Old description'], ['New description']]
+    end
+  end
+
   context 'when poll changes' do
     let(:account) { Fabricate(:account) }
     let!(:status) { Fabricate(:status, text: 'Foo', account: account, poll_attributes: {options: %w(Foo Bar), account: account, multiple: false, hide_totals: false, expires_at: 7.days.from_now }) }