diff --git a/app/controllers/admin/account_moderation_notes_controller.rb b/app/controllers/admin/account_moderation_notes_controller.rb
index 44f6e34f80b0ad4f30d6dba37969320d4d842851..4f36f33f47e0fee4d4d889f1f9291bc7dfe8563f 100644
--- a/app/controllers/admin/account_moderation_notes_controller.rb
+++ b/app/controllers/admin/account_moderation_notes_controller.rb
@@ -14,7 +14,7 @@ module Admin
       else
         @account          = @account_moderation_note.target_account
         @moderation_notes = @account.targeted_moderation_notes.latest
-        @warnings         = @account.targeted_account_warnings.latest.custom
+        @warnings         = @account.strikes.custom.latest
 
         render template: 'admin/accounts/show'
       end
diff --git a/app/controllers/admin/accounts_controller.rb b/app/controllers/admin/accounts_controller.rb
index 0786985fac334ab0192b06911a517482e27db101..e7f56e243bbe0c8408f309008222f895ce2b1479 100644
--- a/app/controllers/admin/accounts_controller.rb
+++ b/app/controllers/admin/accounts_controller.rb
@@ -28,7 +28,7 @@ module Admin
       @deletion_request        = @account.deletion_request
       @account_moderation_note = current_account.account_moderation_notes.new(target_account: @account)
       @moderation_notes        = @account.targeted_moderation_notes.latest
-      @warnings                = @account.targeted_account_warnings.latest.custom
+      @warnings                = @account.strikes.custom.latest
       @domain_block            = DomainBlock.rule_for(@account.domain)
     end
 
diff --git a/app/controllers/admin/report_notes_controller.rb b/app/controllers/admin/report_notes_controller.rb
index b816c5b5d484ec88c4129cad3911aa2cf9d603e2..3fd815b60a0b58e403d8df62fe019170406d2165 100644
--- a/app/controllers/admin/report_notes_controller.rb
+++ b/app/controllers/admin/report_notes_controller.rb
@@ -14,20 +14,17 @@ module Admin
         if params[:create_and_resolve]
           @report.resolve!(current_account)
           log_action :resolve, @report
-
-          redirect_to admin_reports_path, notice: I18n.t('admin.reports.resolved_msg')
-          return
-        end
-
-        if params[:create_and_unresolve]
+        elsif params[:create_and_unresolve]
           @report.unresolve!
           log_action :reopen, @report
         end
 
-        redirect_to admin_report_path(@report), notice: I18n.t('admin.report_notes.created_msg')
+        redirect_to after_create_redirect_path, notice: I18n.t('admin.report_notes.created_msg')
       else
-        @report_notes = (@report.notes.latest + @report.history + @report.target_account.targeted_account_warnings.latest.custom).sort_by(&:created_at)
-        @form         = Form::StatusBatch.new
+        @report_notes = @report.notes.includes(:account).order(id: :desc)
+        @action_logs  = @report.history.includes(:target)
+        @form         = Admin::StatusBatchAction.new
+        @statuses     = @report.statuses.with_includes
 
         render template: 'admin/reports/show'
       end
@@ -41,6 +38,14 @@ module Admin
 
     private
 
+    def after_create_redirect_path
+      if params[:create_and_resolve]
+        admin_reports_path
+      else
+        admin_report_path(@report)
+      end
+    end
+
     def resource_params
       params.require(:report_note).permit(
         :content,
diff --git a/app/controllers/admin/reported_statuses_controller.rb b/app/controllers/admin/reported_statuses_controller.rb
deleted file mode 100644
index 3ba9f5df21e8d47db5c016d9ec47d58ae444d7f0..0000000000000000000000000000000000000000
--- a/app/controllers/admin/reported_statuses_controller.rb
+++ /dev/null
@@ -1,44 +0,0 @@
-# frozen_string_literal: true
-
-module Admin
-  class ReportedStatusesController < BaseController
-    before_action :set_report
-
-    def create
-      authorize :status, :update?
-
-      @form         = Form::StatusBatch.new(form_status_batch_params.merge(current_account: current_account, action: action_from_button))
-      flash[:alert] = I18n.t('admin.statuses.failed_to_execute') unless @form.save
-
-      redirect_to admin_report_path(@report)
-    rescue ActionController::ParameterMissing
-      flash[:alert] = I18n.t('admin.statuses.no_status_selected')
-
-      redirect_to admin_report_path(@report)
-    end
-
-    private
-
-    def status_params
-      params.require(:status).permit(:sensitive)
-    end
-
-    def form_status_batch_params
-      params.require(:form_status_batch).permit(status_ids: [])
-    end
-
-    def action_from_button
-      if params[:nsfw_on]
-        'nsfw_on'
-      elsif params[:nsfw_off]
-        'nsfw_off'
-      elsif params[:delete]
-        'delete'
-      end
-    end
-
-    def set_report
-      @report = Report.find(params[:report_id])
-    end
-  end
-end
diff --git a/app/controllers/admin/reports_controller.rb b/app/controllers/admin/reports_controller.rb
index 7c831b3d4c38304f98faf58377b2c772463f619c..00d200d7c88ccc8283fb2eb4c943930bbd446017 100644
--- a/app/controllers/admin/reports_controller.rb
+++ b/app/controllers/admin/reports_controller.rb
@@ -13,8 +13,10 @@ module Admin
       authorize @report, :show?
 
       @report_note  = @report.notes.new
-      @report_notes = (@report.notes.latest + @report.history + @report.target_account.targeted_account_warnings.latest.custom).sort_by(&:created_at)
-      @form         = Form::StatusBatch.new
+      @report_notes = @report.notes.includes(:account).order(id: :desc)
+      @action_logs  = @report.history.includes(:target)
+      @form         = Admin::StatusBatchAction.new
+      @statuses     = @report.statuses.with_includes
     end
 
     def assign_to_self
diff --git a/app/controllers/admin/statuses_controller.rb b/app/controllers/admin/statuses_controller.rb
index b3fd4c42465c19fb75a4cc6006ab6b84c9e32ccb..8d039b2810bcf32fabf32fbfa8557cb00842a9f3 100644
--- a/app/controllers/admin/statuses_controller.rb
+++ b/app/controllers/admin/statuses_controller.rb
@@ -2,71 +2,57 @@
 
 module Admin
   class StatusesController < BaseController
-    helper_method :current_params
-
     before_action :set_account
+    before_action :set_statuses
 
     PER_PAGE = 20
 
     def index
       authorize :status, :index?
 
-      @statuses = @account.statuses.where(visibility: [:public, :unlisted])
-
-      if params[:media]
-        @statuses = @statuses.merge(Status.joins(:media_attachments).merge(@account.media_attachments.reorder(nil)).group(:id)).reorder('statuses.id desc')
-      end
-
-      @statuses = @statuses.preload(:media_attachments, :mentions).page(params[:page]).per(PER_PAGE)
-      @form     = Form::StatusBatch.new
-    end
-
-    def show
-      authorize :status, :index?
-
-      @statuses = @account.statuses.where(id: params[:id])
-      authorize @statuses.first, :show?
-
-      @form = Form::StatusBatch.new
+      @status_batch_action = Admin::StatusBatchAction.new
     end
 
-    def create
-      authorize :status, :update?
-
-      @form         = Form::StatusBatch.new(form_status_batch_params.merge(current_account: current_account, action: action_from_button))
-      flash[:alert] = I18n.t('admin.statuses.failed_to_execute') unless @form.save
-
-      redirect_to admin_account_statuses_path(@account.id, current_params)
+    def batch
+      @status_batch_action = Admin::StatusBatchAction.new(admin_status_batch_action_params.merge(current_account: current_account, report_id: params[:report_id], type: action_from_button))
+      @status_batch_action.save!
     rescue ActionController::ParameterMissing
       flash[:alert] = I18n.t('admin.statuses.no_status_selected')
-
-      redirect_to admin_account_statuses_path(@account.id, current_params)
+    ensure
+      redirect_to after_create_redirect_path
     end
 
     private
 
-    def form_status_batch_params
-      params.require(:form_status_batch).permit(:action, status_ids: [])
+    def admin_status_batch_action_params
+      params.require(:admin_status_batch_action).permit(status_ids: [])
+    end
+
+    def after_create_redirect_path
+      if @status_batch_action.report_id.present?
+        admin_report_path(@status_batch_action.report_id)
+      else
+        admin_account_statuses_path(params[:account_id], current_params)
+      end
     end
 
     def set_account
       @account = Account.find(params[:account_id])
     end
 
-    def current_params
-      page = (params[:page] || 1).to_i
+    def set_statuses
+      @statuses = Admin::StatusFilter.new(@account, filter_params).results.preload(:application, :preloadable_poll, :media_attachments, active_mentions: :account, reblog: [:account, :application, :preloadable_poll, :media_attachments, active_mentions: :account]).page(params[:page]).per(PER_PAGE)
+    end
 
-      {
-        media: params[:media],
-        page: page > 1 && page,
-      }.select { |_, value| value.present? }
+    def filter_params
+      params.slice(*Admin::StatusFilter::KEYS).permit(*Admin::StatusFilter::KEYS)
     end
 
     def action_from_button
-      if params[:nsfw_on]
-        'nsfw_on'
-      elsif params[:nsfw_off]
-        'nsfw_off'
+      if params[:report]
+        'report'
+      elsif params[:remove_from_report]
+        'remove_from_report'
       elsif params[:delete]
         'delete'
       end
diff --git a/app/controllers/api/v1/admin/account_actions_controller.rb b/app/controllers/api/v1/admin/account_actions_controller.rb
index 29c9b7107bf1a1cec4c065036d991abb8a177f08..15af50822e36c2c082931ad71a6011002f2fbc7c 100644
--- a/app/controllers/api/v1/admin/account_actions_controller.rb
+++ b/app/controllers/api/v1/admin/account_actions_controller.rb
@@ -1,7 +1,9 @@
 # frozen_string_literal: true
 
 class Api::V1::Admin::AccountActionsController < Api::BaseController
-  before_action -> { doorkeeper_authorize! :'admin:write', :'admin:write:accounts' }
+  protect_from_forgery with: :exception
+
+  before_action -> { authorize_if_got_token! :'admin:write', :'admin:write:accounts' }
   before_action :require_staff!
   before_action :set_account
 
diff --git a/app/controllers/api/v1/admin/accounts_controller.rb b/app/controllers/api/v1/admin/accounts_controller.rb
index 9b8f2fb059fe34f11063fe91aa6bb465be94ea50..65330b8c814a6038289ff3c5d9e25d4a20a4473e 100644
--- a/app/controllers/api/v1/admin/accounts_controller.rb
+++ b/app/controllers/api/v1/admin/accounts_controller.rb
@@ -1,13 +1,15 @@
 # frozen_string_literal: true
 
 class Api::V1::Admin::AccountsController < Api::BaseController
+  protect_from_forgery with: :exception
+
   include Authorization
   include AccountableConcern
 
   LIMIT = 100
 
-  before_action -> { doorkeeper_authorize! :'admin:read', :'admin:read:accounts' }, only: [:index, :show]
-  before_action -> { doorkeeper_authorize! :'admin:write', :'admin:write:accounts' }, except: [:index, :show]
+  before_action -> { authorize_if_got_token! :'admin:read', :'admin:read:accounts' }, only: [:index, :show]
+  before_action -> { authorize_if_got_token! :'admin:write', :'admin:write:accounts' }, except: [:index, :show]
   before_action :require_staff!
   before_action :set_accounts, only: :index
   before_action :set_account, except: :index
diff --git a/app/controllers/api/v1/admin/dimensions_controller.rb b/app/controllers/api/v1/admin/dimensions_controller.rb
index 5e8f0f89f015484539f779420003d70c5a29b7aa..b1f738990190e918feeba6a2fb1ee11e0d91b102 100644
--- a/app/controllers/api/v1/admin/dimensions_controller.rb
+++ b/app/controllers/api/v1/admin/dimensions_controller.rb
@@ -3,6 +3,7 @@
 class Api::V1::Admin::DimensionsController < Api::BaseController
   protect_from_forgery with: :exception
 
+  before_action -> { authorize_if_got_token! :'admin:read' }
   before_action :require_staff!
   before_action :set_dimensions
 
diff --git a/app/controllers/api/v1/admin/measures_controller.rb b/app/controllers/api/v1/admin/measures_controller.rb
index f2819175345be6e86cd99c2dd8fd220bd1e89580..d64c3cdf704a8e1525a6d6e1467dcda610015b88 100644
--- a/app/controllers/api/v1/admin/measures_controller.rb
+++ b/app/controllers/api/v1/admin/measures_controller.rb
@@ -3,6 +3,7 @@
 class Api::V1::Admin::MeasuresController < Api::BaseController
   protect_from_forgery with: :exception
 
+  before_action -> { authorize_if_got_token! :'admin:read' }
   before_action :require_staff!
   before_action :set_measures
 
diff --git a/app/controllers/api/v1/admin/reports_controller.rb b/app/controllers/api/v1/admin/reports_controller.rb
index c8f4cd8d80c828cf94e32df107720a81733ab19a..fbfd0ee128c1bdba8fbff1f7cea29e86c7db78e6 100644
--- a/app/controllers/api/v1/admin/reports_controller.rb
+++ b/app/controllers/api/v1/admin/reports_controller.rb
@@ -1,13 +1,15 @@
 # frozen_string_literal: true
 
 class Api::V1::Admin::ReportsController < Api::BaseController
+  protect_from_forgery with: :exception
+
   include Authorization
   include AccountableConcern
 
   LIMIT = 100
 
-  before_action -> { doorkeeper_authorize! :'admin:read', :'admin:read:reports' }, only: [:index, :show]
-  before_action -> { doorkeeper_authorize! :'admin:write', :'admin:write:reports' }, except: [:index, :show]
+  before_action -> { authorize_if_got_token! :'admin:read', :'admin:read:reports' }, only: [:index, :show]
+  before_action -> { authorize_if_got_token! :'admin:write', :'admin:write:reports' }, except: [:index, :show]
   before_action :require_staff!
   before_action :set_reports, only: :index
   before_action :set_report, except: :index
@@ -32,6 +34,12 @@ class Api::V1::Admin::ReportsController < Api::BaseController
     render json: @report, serializer: REST::Admin::ReportSerializer
   end
 
+  def update
+    authorize @report, :update?
+    @report.update!(report_params)
+    render json: @report, serializer: REST::Admin::ReportSerializer
+  end
+
   def assign_to_self
     authorize @report, :update?
     @report.update!(assigned_account_id: current_account.id)
@@ -74,6 +82,10 @@ class Api::V1::Admin::ReportsController < Api::BaseController
     ReportFilter.new(filter_params).results
   end
 
+  def report_params
+    params.permit(:category, rule_ids: [])
+  end
+
   def filter_params
     params.permit(*FILTER_PARAMS)
   end
diff --git a/app/controllers/api/v1/admin/retention_controller.rb b/app/controllers/api/v1/admin/retention_controller.rb
index a8ff64f21d06e388cf11886c2a96c72d27ab9fea..4af5a5c4dcc23d2f823d9ff6ba954db163d27f75 100644
--- a/app/controllers/api/v1/admin/retention_controller.rb
+++ b/app/controllers/api/v1/admin/retention_controller.rb
@@ -3,6 +3,7 @@
 class Api::V1::Admin::RetentionController < Api::BaseController
   protect_from_forgery with: :exception
 
+  before_action -> { authorize_if_got_token! :'admin:read' }
   before_action :require_staff!
   before_action :set_cohorts
 
diff --git a/app/controllers/api/v1/admin/trends/tags_controller.rb b/app/controllers/api/v1/admin/trends/tags_controller.rb
index 3653d1dd13bdd6cb11cbf70404e63c57c1be4a81..4815af31ea9cb210c6ba5b1ecbc6a3ca1bd94c40 100644
--- a/app/controllers/api/v1/admin/trends/tags_controller.rb
+++ b/app/controllers/api/v1/admin/trends/tags_controller.rb
@@ -1,6 +1,9 @@
 # frozen_string_literal: true
 
 class Api::V1::Admin::Trends::TagsController < Api::BaseController
+  protect_from_forgery with: :exception
+
+  before_action -> { authorize_if_got_token! :'admin:read' }
   before_action :require_staff!
   before_action :set_tags
 
diff --git a/app/helpers/admin/filter_helper.rb b/app/helpers/admin/filter_helper.rb
index 5f69f176a6a1e5008b6d9c839b5bcdd7d28802a8..907529b37231e9e0b8d94790099b1e9fb6e3fc10 100644
--- a/app/helpers/admin/filter_helper.rb
+++ b/app/helpers/admin/filter_helper.rb
@@ -13,6 +13,7 @@ module Admin::FilterHelper
     RelationshipFilter::KEYS,
     AnnouncementFilter::KEYS,
     Admin::ActionLogFilter::KEYS,
+    Admin::StatusFilter::KEYS,
   ].flatten.freeze
 
   def filter_link_to(text, link_to_params, link_class_params = link_to_params)
diff --git a/app/javascript/mastodon/components/admin/ReportReasonSelector.js b/app/javascript/mastodon/components/admin/ReportReasonSelector.js
new file mode 100644
index 0000000000000000000000000000000000000000..1f91d25175a02bf78039848f6db206bc19f42a20
--- /dev/null
+++ b/app/javascript/mastodon/components/admin/ReportReasonSelector.js
@@ -0,0 +1,159 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import api from 'mastodon/api';
+import { injectIntl, defineMessages } from 'react-intl';
+import classNames from 'classnames';
+
+const messages = defineMessages({
+  other: { id: 'report.categories.other', defaultMessage: 'Other' },
+  spam: { id: 'report.categories.spam', defaultMessage: 'Spam' },
+  violation: { id: 'report.categories.violation', defaultMessage: 'Content violates one or more server rules' },
+});
+
+class Category extends React.PureComponent {
+
+  static propTypes = {
+    id: PropTypes.string.isRequired,
+    text: PropTypes.string.isRequired,
+    selected: PropTypes.bool,
+    disabled: PropTypes.bool,
+    onSelect: PropTypes.func,
+    children: PropTypes.node,
+  };
+
+  handleClick = () => {
+    const { id, disabled, onSelect } = this.props;
+
+    if (!disabled) {
+      onSelect(id);
+    }
+  };
+
+  render () {
+    const { id, text, disabled, selected, children } = this.props;
+
+    return (
+      <div tabIndex='0' role='button' className={classNames('report-reason-selector__category', { selected, disabled })} onClick={this.handleClick}>
+        {selected && <input type='hidden' name='report[category]' value={id} />}
+
+        <div className='report-reason-selector__category__label'>
+          <span className={classNames('poll__input', { active: selected, disabled })} />
+          {text}
+        </div>
+
+        {(selected && children) && (
+          <div className='report-reason-selector__category__rules'>
+            {children}
+          </div>
+        )}
+      </div>
+    );
+  }
+
+}
+
+class Rule extends React.PureComponent {
+
+  static propTypes = {
+    id: PropTypes.string.isRequired,
+    text: PropTypes.string.isRequired,
+    selected: PropTypes.bool,
+    disabled: PropTypes.bool,
+    onToggle: PropTypes.func,
+  };
+
+  handleClick = () => {
+    const { id, disabled, onToggle } = this.props;
+
+    if (!disabled) {
+      onToggle(id);
+    }
+  };
+
+  render () {
+    const { id, text, disabled, selected } = this.props;
+
+    return (
+      <div tabIndex='0' role='button' className={classNames('report-reason-selector__rule', { selected, disabled })} onClick={this.handleClick}>
+        <span className={classNames('poll__input', { checkbox: true, active: selected, disabled })} />
+        {selected && <input type='hidden' name='report[rule_ids][]' value={id} />}
+        {text}
+      </div>
+    );
+  }
+
+}
+
+export default @injectIntl
+class ReportReasonSelector extends React.PureComponent {
+
+  static propTypes = {
+    id: PropTypes.string.isRequired,
+    category: PropTypes.string.isRequired,
+    rule_ids: PropTypes.arrayOf(PropTypes.string),
+    disabled: PropTypes.bool,
+    intl: PropTypes.object.isRequired,
+  };
+
+  state = {
+    category: this.props.category,
+    rule_ids: this.props.rule_ids || [],
+    rules: [],
+  };
+
+  componentDidMount() {
+    api().get('/api/v1/instance').then(res => {
+      this.setState({
+        rules: res.data.rules,
+      });
+    }).catch(err => {
+      console.error(err);
+    });
+  }
+
+  _save = () => {
+    const { id, disabled } = this.props;
+    const { category, rule_ids } = this.state;
+
+    if (disabled) {
+      return;
+    }
+
+    api().put(`/api/v1/admin/reports/${id}`, {
+      category,
+      rule_ids,
+    }).catch(err => {
+      console.error(err);
+    });
+  };
+
+  handleSelect = id => {
+    this.setState({ category: id }, () => this._save());
+  };
+
+  handleToggle = id => {
+    const { rule_ids } = this.state;
+
+    if (rule_ids.includes(id)) {
+      this.setState({ rule_ids: rule_ids.filter(x => x !== id ) }, () => this._save());
+    } else {
+      this.setState({ rule_ids: [...rule_ids, id] }, () => this._save());
+    }
+  };
+
+  render () {
+    const { disabled, intl } = this.props;
+    const { rules, category, rule_ids } = this.state;
+
+    return (
+      <div className='report-reason-selector'>
+        <Category id='other' text={intl.formatMessage(messages.other)} selected={category === 'other'} onSelect={this.handleSelect} disabled={disabled} />
+        <Category id='spam' text={intl.formatMessage(messages.spam)} selected={category === 'spam'} onSelect={this.handleSelect} disabled={disabled} />
+        <Category id='violation' text={intl.formatMessage(messages.violation)} selected={category === 'violation'} onSelect={this.handleSelect} disabled={disabled}>
+          {rules.map(rule => <Rule key={rule.id} id={rule.id} text={rule.text} selected={rule_ids.includes(rule.id)} onToggle={this.handleToggle} disabled={disabled} />)}
+        </Category>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/mastodon/components/status_action_bar.js b/app/javascript/mastodon/components/status_action_bar.js
index d125359e9e71d1c57aac07c3616f84e00028d0ea..4e19cc0e46dff53b6a100cad81b4251011943d79 100644
--- a/app/javascript/mastodon/components/status_action_bar.js
+++ b/app/javascript/mastodon/components/status_action_bar.js
@@ -291,7 +291,7 @@ class StatusActionBar extends ImmutablePureComponent {
       if (isStaff) {
         menu.push(null);
         menu.push({ text: intl.formatMessage(messages.admin_account, { name: account.get('username') }), href: `/admin/accounts/${status.getIn(['account', 'id'])}` });
-        menu.push({ text: intl.formatMessage(messages.admin_status), href: `/admin/accounts/${status.getIn(['account', 'id'])}/statuses/${status.get('id')}` });
+        menu.push({ text: intl.formatMessage(messages.admin_status), href: `/admin/accounts/${status.getIn(['account', 'id'])}/statuses?id=${status.get('id')}` });
       }
     }
 
diff --git a/app/javascript/mastodon/features/status/components/action_bar.js b/app/javascript/mastodon/features/status/components/action_bar.js
index e60119bc47a0c754a8e0f09bb320bdbe779fba3b..a15a4d567a867235ace9804ebd6c078c8aecec29 100644
--- a/app/javascript/mastodon/features/status/components/action_bar.js
+++ b/app/javascript/mastodon/features/status/components/action_bar.js
@@ -245,7 +245,7 @@ class ActionBar extends React.PureComponent {
       if (isStaff) {
         menu.push(null);
         menu.push({ text: intl.formatMessage(messages.admin_account, { name: status.getIn(['account', 'username']) }), href: `/admin/accounts/${status.getIn(['account', 'id'])}` });
-        menu.push({ text: intl.formatMessage(messages.admin_status), href: `/admin/accounts/${status.getIn(['account', 'id'])}/statuses/${status.get('id')}` });
+        menu.push({ text: intl.formatMessage(messages.admin_status), href: `/admin/accounts/${status.getIn(['account', 'id'])}/statuses?id=${status.get('id')}` });
       }
     }
 
diff --git a/app/javascript/styles/mailer.scss b/app/javascript/styles/mailer.scss
index 92c02e847d9ca8939bb0da77e58590d36269222f..34852178e35990def802c6e2a4613e6592ec6bf9 100644
--- a/app/javascript/styles/mailer.scss
+++ b/app/javascript/styles/mailer.scss
@@ -533,6 +533,10 @@ ul {
   }
 }
 
+ul.rules-list {
+  padding-top: 0;
+}
+
 @media only screen and (min-device-width: 768px) and (max-device-width: 1024px) and (orientation: landscape) {
   body {
     min-height: 1024px !important;
diff --git a/app/javascript/styles/mastodon/admin.scss b/app/javascript/styles/mastodon/admin.scss
index dbf8a6e7a167fe75fc12bc0f105477ac65a19edf..c20762fba488b410abf67a8cbe7a50c785458274 100644
--- a/app/javascript/styles/mastodon/admin.scss
+++ b/app/javascript/styles/mastodon/admin.scss
@@ -579,39 +579,44 @@ body,
 
 .log-entry {
   line-height: 20px;
-  padding: 15px 0;
+  padding: 15px;
+  padding-left: 15px * 2 + 40px;
   background: $ui-base-color;
-  border-bottom: 1px solid lighten($ui-base-color, 4%);
+  border-bottom: 1px solid darken($ui-base-color, 8%);
+  position: relative;
+
+  &:first-child {
+    border-top-left-radius: 4px;
+    border-top-right-radius: 4px;
+  }
 
   &:last-child {
+    border-bottom-left-radius: 4px;
+    border-bottom-right-radius: 4px;
     border-bottom: 0;
   }
 
+  &:hover {
+    background: lighten($ui-base-color, 4%);
+  }
+
   &__header {
-    display: flex;
-    justify-content: flex-start;
-    align-items: center;
     color: $darker-text-color;
     font-size: 14px;
-    padding: 0 10px;
   }
 
   &__avatar {
-    margin-right: 10px;
+    position: absolute;
+    left: 15px;
+    top: 15px;
 
     .avatar {
-      display: block;
-      margin: 0;
-      border-radius: 50%;
+      border-radius: 4px;
       width: 40px;
       height: 40px;
     }
   }
 
-  &__content {
-    max-width: calc(100% - 90px);
-  }
-
   &__title {
     word-wrap: break-word;
   }
@@ -627,6 +632,14 @@ body,
     text-decoration: none;
     font-weight: 500;
   }
+
+  a {
+    &:hover,
+    &:focus,
+    &:active {
+      text-decoration: underline;
+    }
+  }
 }
 
 a.name-tag,
@@ -655,8 +668,9 @@ a.inline-name-tag,
 
 a.name-tag,
 .name-tag {
-  display: flex;
+  display: inline-flex;
   align-items: center;
+  vertical-align: top;
 
   .avatar {
     display: block;
@@ -1114,3 +1128,287 @@ a.sparkline {
     }
   }
 }
+
+.report-reason-selector {
+  border-radius: 4px;
+  background: $ui-base-color;
+  margin-bottom: 20px;
+
+  &__category {
+    cursor: pointer;
+    border-bottom: 1px solid darken($ui-base-color, 8%);
+
+    &:last-child {
+      border-bottom: 0;
+    }
+
+    &__label {
+      padding: 15px;
+    }
+
+    &__rules {
+      margin-left: 30px;
+    }
+  }
+
+  &__rule {
+    cursor: pointer;
+    padding: 15px;
+  }
+}
+
+.report-header {
+  display: grid;
+  grid-gap: 15px;
+  grid-template-columns: minmax(0, 1fr) 300px;
+
+  &__details {
+    &__item {
+      border-bottom: 1px solid lighten($ui-base-color, 8%);
+      padding: 15px 0;
+
+      &:last-child {
+        border-bottom: 0;
+      }
+
+      &__header {
+        font-weight: 600;
+        padding: 4px 0;
+      }
+    }
+
+    &--horizontal {
+      display: grid;
+      grid-auto-columns: minmax(0, 1fr);
+      grid-auto-flow: column;
+
+      .report-header__details__item {
+        border-bottom: 0;
+      }
+    }
+  }
+}
+
+.account-card {
+  background: $ui-base-color;
+  border-radius: 4px;
+
+  &__header {
+    padding: 4px;
+    border-radius: 4px;
+    height: 128px;
+
+    img {
+      display: block;
+      margin: 0;
+      width: 100%;
+      height: 100%;
+      object-fit: cover;
+      background: darken($ui-base-color, 8%);
+    }
+  }
+
+  &__title {
+    margin-top: -25px;
+    display: flex;
+    align-items: flex-end;
+
+    &__avatar {
+      padding: 15px;
+
+      img {
+        display: block;
+        margin: 0;
+        width: 56px;
+        height: 56px;
+        background: darken($ui-base-color, 8%);
+        border-radius: 8px;
+      }
+    }
+
+    .display-name {
+      color: $darker-text-color;
+      padding-bottom: 15px;
+      font-size: 15px;
+
+      bdi {
+        display: block;
+        color: $primary-text-color;
+        font-weight: 500;
+      }
+    }
+  }
+
+  &__bio {
+    padding: 0 15px;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    word-wrap: break-word;
+    max-height: 18px * 2;
+    position: relative;
+
+    &::after {
+      display: block;
+      content: "";
+      width: 50px;
+      height: 18px;
+      position: absolute;
+      bottom: 0;
+      right: 15px;
+      background: linear-gradient(to left, $ui-base-color, transparent);
+      pointer-events: none;
+    }
+  }
+
+  &__actions {
+    display: flex;
+    align-items: center;
+    padding-top: 10px;
+
+    &__button {
+      flex: 0 0 auto;
+      padding: 0 15px;
+    }
+  }
+
+  &__counters {
+    flex: 1 1 auto;
+    display: grid;
+    grid-auto-columns: minmax(0, 1fr);
+    grid-auto-flow: column;
+
+    &__item {
+      padding: 15px;
+      text-align: center;
+      color: $primary-text-color;
+      font-weight: 600;
+      font-size: 15px;
+
+      small {
+        display: block;
+        color: $darker-text-color;
+        font-weight: 400;
+        font-size: 13px;
+      }
+    }
+  }
+}
+
+.report-notes {
+  margin-bottom: 20px;
+
+  &__item {
+    background: $ui-base-color;
+    position: relative;
+    padding: 15px;
+    padding-left: 15px * 2 + 40px;
+    border-bottom: 1px solid darken($ui-base-color, 8%);
+
+    &:first-child {
+      border-top-left-radius: 4px;
+      border-top-right-radius: 4px;
+    }
+
+    &:last-child {
+      border-bottom-left-radius: 4px;
+      border-bottom-right-radius: 4px;
+      border-bottom: 0;
+    }
+
+    &:hover {
+      background-color: lighten($ui-base-color, 4%);
+    }
+
+    &__avatar {
+      position: absolute;
+      left: 15px;
+      top: 15px;
+      border-radius: 4px;
+      width: 40px;
+      height: 40px;
+    }
+
+    &__header {
+      color: $darker-text-color;
+      font-size: 15px;
+      line-height: 20px;
+      margin-bottom: 4px;
+
+      .username a {
+        color: $primary-text-color;
+        font-weight: 500;
+        text-decoration: none;
+        margin-right: 5px;
+
+        &:hover,
+        &:focus,
+        &:active {
+          text-decoration: underline;
+        }
+      }
+
+      time {
+        margin-left: 5px;
+        vertical-align: baseline;
+      }
+    }
+
+    &__content {
+      font-size: 15px;
+      line-height: 20px;
+      word-wrap: break-word;
+      font-weight: 400;
+      color: $primary-text-color;
+
+      p {
+        margin-bottom: 20px;
+        white-space: pre-wrap;
+        unicode-bidi: plaintext;
+
+        &:last-child {
+          margin-bottom: 0;
+        }
+      }
+    }
+
+    &__actions {
+      position: absolute;
+      top: 15px;
+      right: 15px;
+      text-align: right;
+    }
+  }
+}
+
+.report-actions {
+  border: 1px solid darken($ui-base-color, 8%);
+
+  &__item {
+    display: flex;
+    align-items: center;
+    line-height: 18px;
+    border-bottom: 1px solid darken($ui-base-color, 8%);
+
+    &:last-child {
+      border-bottom: 0;
+    }
+
+    &__button {
+      flex: 0 0 auto;
+      width: 100px;
+      padding: 15px;
+      padding-right: 0;
+
+      .button {
+        display: block;
+        width: 100%;
+      }
+    }
+
+    &__description {
+      padding: 15px;
+      font-size: 14px;
+      color: $dark-text-color;
+    }
+  }
+}
diff --git a/app/javascript/styles/mastodon/polls.scss b/app/javascript/styles/mastodon/polls.scss
index ad70889828b8a817219b4acef63cb59021c202c6..e33fc798359e98a085d9304677f6491051e84583 100644
--- a/app/javascript/styles/mastodon/polls.scss
+++ b/app/javascript/styles/mastodon/polls.scss
@@ -143,6 +143,21 @@
     &:active {
       outline: 0 !important;
     }
+
+    &.disabled {
+      border-color: $dark-text-color;
+
+      &.active {
+        background: $dark-text-color;
+      }
+
+      &:active,
+      &:focus,
+      &:hover {
+        border-color: $dark-text-color;
+        border-width: 1px;
+      }
+    }
   }
 
   &__number {
diff --git a/app/lib/admin/metrics/measure/resolved_reports_measure.rb b/app/lib/admin/metrics/measure/resolved_reports_measure.rb
index 0dcecbbad68035d2026457ad07a459b8bdb2ea27..00cb24f7e1d805026b58ffeeb06ea85faa297b1b 100644
--- a/app/lib/admin/metrics/measure/resolved_reports_measure.rb
+++ b/app/lib/admin/metrics/measure/resolved_reports_measure.rb
@@ -6,11 +6,11 @@ class Admin::Metrics::Measure::ResolvedReportsMeasure < Admin::Metrics::Measure:
   end
 
   def total
-    Report.resolved.where(updated_at: time_period).count
+    Report.resolved.where(action_taken_at: time_period).count
   end
 
   def previous_total
-    Report.resolved.where(updated_at: previous_time_period).count
+    Report.resolved.where(action_taken_at: previous_time_period).count
   end
 
   def data
@@ -19,8 +19,7 @@ class Admin::Metrics::Measure::ResolvedReportsMeasure < Admin::Metrics::Measure:
         WITH resolved_reports AS (
           SELECT reports.id
           FROM reports
-          WHERE action_taken
-            AND date_trunc('day', reports.updated_at)::date = axis.period
+          WHERE date_trunc('day', reports.action_taken_at)::date = axis.period
         )
         SELECT count(*) FROM resolved_reports
       ) AS value
diff --git a/app/mailers/user_mailer.rb b/app/mailers/user_mailer.rb
index 68d1c4507e8554e48dfc100bb74fb16ea3b496b2..5221a48928ecebc249bf80fd6175a5f346b2b125 100644
--- a/app/mailers/user_mailer.rb
+++ b/app/mailers/user_mailer.rb
@@ -160,11 +160,11 @@ class UserMailer < Devise::Mailer
     end
   end
 
-  def warning(user, warning, status_ids = nil)
+  def warning(user, warning)
     @resource = user
     @warning  = warning
     @instance = Rails.configuration.x.local_domain
-    @statuses = Status.where(id: status_ids).includes(:account) if status_ids.is_a?(Array)
+    @statuses = @warning.statuses.includes(:account, :preloadable_poll, :media_attachments, active_mentions: [:account])
 
     I18n.with_locale(@resource.locale || I18n.default_locale) do
       mail to: @resource.email,
diff --git a/app/models/account_warning.rb b/app/models/account_warning.rb
index 5efc924d5f7ae36a7fb038ff4c2fbfe06a157416..fc0d988fdc058730b4057422c40875691b46e6c6 100644
--- a/app/models/account_warning.rb
+++ b/app/models/account_warning.rb
@@ -10,14 +10,30 @@
 #  text              :text             default(""), not null
 #  created_at        :datetime         not null
 #  updated_at        :datetime         not null
+#  report_id         :bigint(8)
+#  status_ids        :string           is an Array
 #
 
 class AccountWarning < ApplicationRecord
-  enum action: %i(none disable sensitive silence suspend), _suffix: :action
+  enum action: {
+    none:            0,
+    disable:         1_000,
+    delete_statuses: 1_500,
+    sensitive:       2_000,
+    silence:         3_000,
+    suspend:         4_000,
+  }, _suffix: :action
 
   belongs_to :account, inverse_of: :account_warnings
-  belongs_to :target_account, class_name: 'Account', inverse_of: :targeted_account_warnings
+  belongs_to :target_account, class_name: 'Account', inverse_of: :strikes
+  belongs_to :report, optional: true
 
-  scope :latest, -> { order(created_at: :desc) }
+  has_one :appeal, dependent: :destroy
+
+  scope :latest, -> { order(id: :desc) }
   scope :custom, -> { where.not(text: '') }
+
+  def statuses
+    Status.with_discarded.where(id: status_ids || [])
+  end
 end
diff --git a/app/models/admin/account_action.rb b/app/models/admin/account_action.rb
index bf222391f7e9c114bab81a36676d95421001cd21..d3be4be3fdae16ddee66f51c31ade0caf36428c4 100644
--- a/app/models/admin/account_action.rb
+++ b/app/models/admin/account_action.rb
@@ -33,7 +33,7 @@ class Admin::AccountAction
   def save!
     ApplicationRecord.transaction do
       process_action!
-      process_warning!
+      process_strike!
     end
 
     process_email!
@@ -74,20 +74,14 @@ class Admin::AccountAction
     end
   end
 
-  def process_warning!
-    return unless warnable?
-
-    authorize(target_account, :warn?)
-
-    @warning = AccountWarning.create!(target_account: target_account,
-                                      account: current_account,
-                                      action: type,
-                                      text: text_for_warning)
-
-    # A log entry is only interesting if the warning contains
-    # custom text from someone. Otherwise it's just noise.
-
-    log_action(:create, warning) if warning.text.present?
+  def process_strike!
+    @warning = target_account.strikes.create!(
+      account: current_account,
+      report: report,
+      action: type,
+      text: text_for_warning,
+      status_ids: status_ids
+    )
   end
 
   def process_reports!
@@ -143,7 +137,7 @@ class Admin::AccountAction
   end
 
   def process_email!
-    UserMailer.warning(target_account.user, warning, status_ids).deliver_later! if warnable?
+    UserMailer.warning(target_account.user, warning).deliver_later! if warnable?
   end
 
   def warnable?
@@ -151,7 +145,7 @@ class Admin::AccountAction
   end
 
   def status_ids
-    report.status_ids if report && include_statuses
+    report.status_ids if with_report? && include_statuses
   end
 
   def reports
diff --git a/app/models/admin/status_batch_action.rb b/app/models/admin/status_batch_action.rb
new file mode 100644
index 0000000000000000000000000000000000000000..319deff98e8857e93e1938f0a5d1062c40b3002f
--- /dev/null
+++ b/app/models/admin/status_batch_action.rb
@@ -0,0 +1,92 @@
+# frozen_string_literal: true
+
+class Admin::StatusBatchAction
+  include ActiveModel::Model
+  include AccountableConcern
+  include Authorization
+
+  attr_accessor :current_account, :type,
+                :status_ids, :report_id
+
+  def save!
+    process_action!
+  end
+
+  private
+
+  def statuses
+    Status.with_discarded.where(id: status_ids)
+  end
+
+  def process_action!
+    return if status_ids.empty?
+
+    case type
+    when 'delete'
+      handle_delete!
+    when 'report'
+      handle_report!
+    when 'remove_from_report'
+      handle_remove_from_report!
+    end
+  end
+
+  def handle_delete!
+    statuses.each { |status| authorize(status, :destroy?) }
+
+    ApplicationRecord.transaction do
+      statuses.each do |status|
+        status.discard
+        log_action(:destroy, status)
+      end
+
+      if with_report?
+        report.resolve!(current_account)
+        log_action(:resolve, report)
+      end
+
+      @warning = target_account.strikes.create!(
+        action: :delete_statuses,
+        account: current_account,
+        report: report,
+        status_ids: status_ids
+      )
+
+      statuses.each { |status| Tombstone.find_or_create_by(uri: status.uri, account: status.account, by_moderator: true) } unless target_account.local?
+    end
+
+    UserMailer.warning(target_account.user, @warning).deliver_later! if target_account.local?
+    RemovalWorker.push_bulk(status_ids) { |status_id| [status_id, preserve: target_account.local?, immediate: !target_account.local?] }
+  end
+
+  def handle_report!
+    @report = Report.new(report_params) unless with_report?
+    @report.status_ids = (@report.status_ids + status_ids.map(&:to_i)).uniq
+    @report.save!
+
+    @report_id = @report.id
+  end
+
+  def handle_remove_from_report!
+    return unless with_report?
+
+    report.status_ids -= status_ids.map(&:to_i)
+    report.save!
+  end
+
+  def report
+    @report ||= Report.find(report_id) if report_id.present?
+  end
+
+  def with_report?
+    !report.nil?
+  end
+
+  def target_account
+    @target_account ||= statuses.first.account
+  end
+
+  def report_params
+    { account: current_account, target_account: target_account }
+  end
+end
diff --git a/app/models/admin/status_filter.rb b/app/models/admin/status_filter.rb
new file mode 100644
index 0000000000000000000000000000000000000000..ce5bb5f4610bd19eeebf75c31a8d2231c181cef0
--- /dev/null
+++ b/app/models/admin/status_filter.rb
@@ -0,0 +1,41 @@
+# frozen_string_literal: true
+
+class Admin::StatusFilter
+  KEYS = %i(
+    media
+    id
+    report_id
+  ).freeze
+
+  attr_reader :params
+
+  def initialize(account, params)
+    @account = account
+    @params  = params
+  end
+
+  def results
+    scope = @account.statuses.where(visibility: [:public, :unlisted])
+
+    params.each do |key, value|
+      next if %w(page report_id).include?(key.to_s)
+
+      scope.merge!(scope_for(key, value.to_s.strip)) if value.present?
+    end
+
+    scope
+  end
+
+  private
+
+  def scope_for(key, value)
+    case key.to_s
+    when 'media'
+      Status.joins(:media_attachments).merge(@account.media_attachments.reorder(nil)).group(:id)
+    when 'id'
+      Status.where(id: value)
+    else
+      raise "Unknown filter: #{key}"
+    end
+  end
+end
diff --git a/app/models/concerns/account_associations.rb b/app/models/concerns/account_associations.rb
index f9e7a3bea7730b87680570ff5e9cf14c42e35936..bbe269e8f0b91d888d77a3065956fe57fa4c90a6 100644
--- a/app/models/concerns/account_associations.rb
+++ b/app/models/concerns/account_associations.rb
@@ -42,7 +42,7 @@ module AccountAssociations
     has_many :account_moderation_notes, dependent: :destroy, inverse_of: :account
     has_many :targeted_moderation_notes, class_name: 'AccountModerationNote', foreign_key: :target_account_id, dependent: :destroy, inverse_of: :target_account
     has_many :account_warnings, dependent: :destroy, inverse_of: :account
-    has_many :targeted_account_warnings, class_name: 'AccountWarning', foreign_key: :target_account_id, dependent: :destroy, inverse_of: :target_account
+    has_many :strikes, class_name: 'AccountWarning', foreign_key: :target_account_id, dependent: :destroy, inverse_of: :target_account
 
     # Lists (that the account is on, not owned by the account)
     has_many :list_accounts, inverse_of: :account, dependent: :destroy
diff --git a/app/models/form/status_batch.rb b/app/models/form/status_batch.rb
deleted file mode 100644
index c4943a7eabd73a73c21b6024102e71f2c8e21a1a..0000000000000000000000000000000000000000
--- a/app/models/form/status_batch.rb
+++ /dev/null
@@ -1,45 +0,0 @@
-# frozen_string_literal: true
-
-class Form::StatusBatch
-  include ActiveModel::Model
-  include AccountableConcern
-
-  attr_accessor :status_ids, :action, :current_account
-
-  def save
-    case action
-    when 'nsfw_on', 'nsfw_off'
-      change_sensitive(action == 'nsfw_on')
-    when 'delete'
-      delete_statuses
-    end
-  end
-
-  private
-
-  def change_sensitive(sensitive)
-    media_attached_status_ids = MediaAttachment.where(status_id: status_ids).pluck(:status_id)
-
-    ApplicationRecord.transaction do
-      Status.where(id: media_attached_status_ids).reorder(nil).find_each do |status|
-        status.update!(sensitive: sensitive)
-        log_action :update, status
-      end
-    end
-
-    true
-  rescue ActiveRecord::RecordInvalid
-    false
-  end
-
-  def delete_statuses
-    Status.where(id: status_ids).reorder(nil).find_each do |status|
-      status.discard
-      RemovalWorker.perform_async(status.id, immediate: true)
-      Tombstone.find_or_create_by(uri: status.uri, account: status.account, by_moderator: true)
-      log_action :destroy, status
-    end
-
-    true
-  end
-end
diff --git a/app/models/report.rb b/app/models/report.rb
index ef41547d99c61d94aed07a572f159041e23b5d36..ceb15133b1bdc0d511265ec48c2fbac8283260a3 100644
--- a/app/models/report.rb
+++ b/app/models/report.rb
@@ -6,7 +6,6 @@
 #  id                         :bigint(8)        not null, primary key
 #  status_ids                 :bigint(8)        default([]), not null, is an Array
 #  comment                    :text             default(""), not null
-#  action_taken               :boolean          default(FALSE), not null
 #  created_at                 :datetime         not null
 #  updated_at                 :datetime         not null
 #  account_id                 :bigint(8)        not null
@@ -15,9 +14,14 @@
 #  assigned_account_id        :bigint(8)
 #  uri                        :string
 #  forwarded                  :boolean
+#  category                   :integer          default("other"), not null
+#  action_taken_at            :datetime
+#  rule_ids                   :bigint(8)        is an Array
 #
 
 class Report < ApplicationRecord
+  self.ignored_columns = %w(action_taken)
+
   include Paginable
   include RateLimitable
 
@@ -30,11 +34,17 @@ class Report < ApplicationRecord
 
   has_many :notes, class_name: 'ReportNote', foreign_key: :report_id, inverse_of: :report, dependent: :destroy
 
-  scope :unresolved, -> { where(action_taken: false) }
-  scope :resolved,   -> { where(action_taken: true) }
+  scope :unresolved, -> { where(action_taken_at: nil) }
+  scope :resolved,   -> { where.not(action_taken_at: nil) }
   scope :with_accounts, -> { includes([:account, :target_account, :action_taken_by_account, :assigned_account].index_with({ user: [:invite_request, :invite] })) }
 
-  validates :comment, length: { maximum: 1000 }
+  validates :comment, length: { maximum: 1_000 }
+
+  enum category: {
+    other: 0,
+    spam: 1_000,
+    violation: 2_000,
+  }
 
   def local?
     false # Force uri_for to use uri attribute
@@ -47,13 +57,17 @@ class Report < ApplicationRecord
   end
 
   def statuses
-    Status.with_discarded.where(id: status_ids).includes(:account, :media_attachments, :mentions)
+    Status.with_discarded.where(id: status_ids)
   end
 
   def media_attachments
     MediaAttachment.where(status_id: status_ids)
   end
 
+  def rules
+    Rule.with_discarded.where(id: rule_ids)
+  end
+
   def assign_to_self!(current_account)
     update!(assigned_account_id: current_account.id)
   end
@@ -63,22 +77,19 @@ class Report < ApplicationRecord
   end
 
   def resolve!(acting_account)
-    if account_id == -99 && target_account.trust_level == Account::TRUST_LEVELS[:untrusted]
-      # This is an automated report and it is being dismissed, so it's
-      # a false positive, in which case update the account's trust level
-      # to prevent further spam checks
-
-      target_account.update(trust_level: Account::TRUST_LEVELS[:trusted])
-    end
-
-    RemovalWorker.push_bulk(Status.with_discarded.discarded.where(id: status_ids).pluck(:id)) { |status_id| [status_id, { immediate: true }] }
-    update!(action_taken: true, action_taken_by_account_id: acting_account.id)
+    update!(action_taken_at: Time.now.utc, action_taken_by_account_id: acting_account.id)
   end
 
   def unresolve!
-    update!(action_taken: false, action_taken_by_account_id: nil)
+    update!(action_taken_at: nil, action_taken_by_account_id: nil)
+  end
+
+  def action_taken?
+    action_taken_at.present?
   end
 
+  alias action_taken action_taken?
+
   def unresolved?
     !action_taken?
   end
@@ -88,29 +99,24 @@ class Report < ApplicationRecord
   end
 
   def history
-    time_range = created_at..updated_at
-
-    sql = [
+    subquery = [
       Admin::ActionLog.where(
         target_type: 'Report',
-        target_id: id,
-        created_at: time_range
-      ).unscope(:order),
+        target_id: id
+      ).unscope(:order).arel,
 
       Admin::ActionLog.where(
         target_type: 'Account',
-        target_id: target_account_id,
-        created_at: time_range
-      ).unscope(:order),
+        target_id: target_account_id
+      ).unscope(:order).arel,
 
       Admin::ActionLog.where(
         target_type: 'Status',
-        target_id: status_ids,
-        created_at: time_range
-      ).unscope(:order),
-    ].map { |query| "(#{query.to_sql})" }.join(' UNION ALL ')
+        target_id: status_ids
+      ).unscope(:order).arel,
+    ].reduce { |union, query| Arel::Nodes::UnionAll.new(union, query) }
 
-    Admin::ActionLog.from("(#{sql}) AS admin_action_logs")
+    Admin::ActionLog.from(Arel::Nodes::As.new(subquery, Admin::ActionLog.arel_table))
   end
 
   def set_uri
diff --git a/app/models/report_filter.rb b/app/models/report_filter.rb
index a91a6baeb25f823879afbd52ce94997a61577719..dc444a5520f6b2f3a0eaff6febce7db68a17ab0f 100644
--- a/app/models/report_filter.rb
+++ b/app/models/report_filter.rb
@@ -19,7 +19,7 @@ class ReportFilter
     scope = Report.unresolved
 
     params.each do |key, value|
-      scope = scope.merge scope_for(key, value)
+      scope = scope.merge scope_for(key, value), rewhere: true
     end
 
     scope
diff --git a/app/serializers/rest/admin/report_serializer.rb b/app/serializers/rest/admin/report_serializer.rb
index 7a77132c038858615793d569a15bef781e3e264b..74bc0c5202869515bb6bf27cd070ae7d0c1a7cf6 100644
--- a/app/serializers/rest/admin/report_serializer.rb
+++ b/app/serializers/rest/admin/report_serializer.rb
@@ -1,7 +1,7 @@
 # frozen_string_literal: true
 
 class REST::Admin::ReportSerializer < ActiveModel::Serializer
-  attributes :id, :action_taken, :comment, :created_at, :updated_at
+  attributes :id, :action_taken, :category, :comment, :created_at, :updated_at
 
   has_one :account, serializer: REST::Admin::AccountSerializer
   has_one :target_account, serializer: REST::Admin::AccountSerializer
@@ -9,8 +9,13 @@ class REST::Admin::ReportSerializer < ActiveModel::Serializer
   has_one :action_taken_by_account, serializer: REST::Admin::AccountSerializer
 
   has_many :statuses, serializer: REST::StatusSerializer
+  has_many :rules, serializer: REST::RuleSerializer
 
   def id
     object.id.to_s
   end
+
+  def statuses
+    object.statuses.with_includes
+  end
 end
diff --git a/app/services/remove_status_service.rb b/app/services/remove_status_service.rb
index f9c3dcf78590643ae39bfb2f821c54c1e478bb28..3535b503be3b43f8914bd2ef459bf7e40cad76b4 100644
--- a/app/services/remove_status_service.rb
+++ b/app/services/remove_status_service.rb
@@ -9,6 +9,7 @@ class RemoveStatusService < BaseService
   # @param   [Hash] options
   # @option  [Boolean] :redraft
   # @option  [Boolean] :immediate
+  # @option  [Boolean] :preserve
   # @option  [Boolean] :original_removed
   def call(status, **options)
     @payload  = Oj.dump(event: :delete, payload: status.id.to_s)
@@ -43,7 +44,7 @@ class RemoveStatusService < BaseService
           remove_media
         end
 
-        @status.destroy! if @options[:immediate] || !@status.reported?
+        @status.destroy! if permanently?
       else
         raise Mastodon::RaceConditionError
       end
@@ -135,11 +136,15 @@ class RemoveStatusService < BaseService
   end
 
   def remove_media
-    return if @options[:redraft] || (!@options[:immediate] && @status.reported?)
+    return if @options[:redraft] || !permanently?
 
     @status.media_attachments.destroy_all
   end
 
+  def permanently?
+    @options[:immediate] || !(@options[:preserve] || @status.reported?)
+  end
+
   def lock_options
     { redis: Redis.current, key: "distribute:#{@status.id}", autorelease: 5.minutes.seconds }
   end
diff --git a/app/views/admin/action_logs/index.html.haml b/app/views/admin/action_logs/index.html.haml
index f7f73150b4de440204eaa56d2ab5a116832686f7..f611bfe9df073b672dc99c865adebf774220b26b 100644
--- a/app/views/admin/action_logs/index.html.haml
+++ b/app/views/admin/action_logs/index.html.haml
@@ -22,7 +22,7 @@
   %div.muted-hint.center-text
     = t 'admin.action_logs.empty'
 - else
-  .announcements-list
+  .report-notes
     = render partial: 'action_log', collection: @action_logs
 
 = paginate @action_logs
diff --git a/app/views/admin/report_notes/_report_note.html.haml b/app/views/admin/report_notes/_report_note.html.haml
index d34dc3d1575aa8a6213a43c19c260efa435ceb71..428b6cf59c66d1eaf5a5804abf5425249fb86b2a 100644
--- a/app/views/admin/report_notes/_report_note.html.haml
+++ b/app/views/admin/report_notes/_report_note.html.haml
@@ -1,7 +1,18 @@
-.speech-bubble
-  .speech-bubble__bubble
+.report-notes__item
+  = image_tag report_note.account.avatar.url, class: 'report-notes__item__avatar'
+
+  .report-notes__item__header
+    %span.username
+      = link_to display_name(report_note.account), admin_account_path(report_note.account_id)
+    %time{ datetime: report_note.created_at.iso8601, title: l(report_note.created_at) }
+      - if report_note.created_at.today?
+        = t('admin.report_notes.today_at', time: l(report_note.created_at, format: :time))
+      - else
+        = l report_note.created_at.to_date
+
+  .report-notes__item__content
     = simple_format(h(report_note.content))
-  .speech-bubble__owner
-    = admin_account_link_to report_note.account
-    %time.formatted{ datetime: report_note.created_at.iso8601 }= l report_note.created_at
-    = table_link_to 'trash', t('admin.reports.notes.delete'), admin_report_note_path(report_note), method: :delete if can?(:destroy, report_note)
+
+  - if can?(:destroy, report_note)
+    .report-notes__item__actions
+      = table_link_to 'trash', t('admin.reports.notes.delete'), admin_report_note_path(report_note), method: :delete
diff --git a/app/views/admin/reports/_action_log.html.haml b/app/views/admin/reports/_action_log.html.haml
deleted file mode 100644
index 0f7d058679237e290cd81e5bed87f259134e55e2..0000000000000000000000000000000000000000
--- a/app/views/admin/reports/_action_log.html.haml
+++ /dev/null
@@ -1,6 +0,0 @@
-.speech-bubble.positive
-  .speech-bubble__bubble
-    = t("admin.action_logs.actions.#{action_log.action}_#{action_log.target_type.underscore}_html", name: content_tag(:span, action_log.account.username, class: 'username'), target: content_tag(:span, log_target(action_log), class: 'target'))
-  .speech-bubble__owner
-    = admin_account_link_to(action_log.account)
-    %time.formatted{ datetime: action_log.created_at.iso8601 }= l action_log.created_at
diff --git a/app/views/admin/reports/_status.html.haml b/app/views/admin/reports/_status.html.haml
index ada6dd2bc549f9b3ab43ae7e1369b7a4826ee0ff..924b0e9c2551d5d86bffbbe49441c1b7a2d6d0ff 100644
--- a/app/views/admin/reports/_status.html.haml
+++ b/app/views/admin/reports/_status.html.haml
@@ -22,6 +22,9 @@
         = react_component :media_gallery, height: 343, sensitive: status.proper.sensitive?, visible: false, media: status.proper.media_attachments.map { |a| ActiveModelSerializers::SerializableResource.new(a, serializer: REST::MediaAttachmentSerializer).as_json }
 
     .detailed-status__meta
+      - if status.application
+        = status.application.name
+        ·
       = link_to ActivityPub::TagManager.instance.url_for(status), class: 'detailed-status__datetime', target: stream_link_target, rel: 'noopener noreferrer' do
         %time.formatted{ datetime: status.created_at.iso8601, title: l(status.created_at) }= l(status.created_at)
       - if status.discarded?
diff --git a/app/views/admin/reports/show.html.haml b/app/views/admin/reports/show.html.haml
index b060c553f09df03ee32f470074ba10c768a516b4..4f513dd398d6a30d2dfce93b552f9d45e7e0c090 100644
--- a/app/views/admin/reports/show.html.haml
+++ b/app/views/admin/reports/show.html.haml
@@ -1,5 +1,6 @@
 - content_for :header_tags do
   = javascript_pack_tag 'admin', async: true, crossorigin: 'anonymous'
+  = javascript_pack_tag 'public', async: true, crossorigin: 'anonymous'
 
 - content_for :page_title do
   = t('admin.reports.report', id: @report.id)
@@ -10,122 +11,199 @@
   - else
     = link_to t('admin.reports.mark_as_unresolved'), reopen_admin_report_path(@report), method: :post, class: 'button'
 
-.table-wrapper
-  %table.table.inline-table
-    %tbody
-      %tr
-        %th= t('admin.reports.reported_account')
-        %td= admin_account_link_to @report.target_account
-        %td= table_link_to 'flag', t('admin.reports.account.reports', count: @report.target_account.targeted_reports.count), admin_reports_path(target_account_id: @report.target_account.id)
-        %td= table_link_to 'file', t('admin.reports.account.notes', count: @report.target_account.targeted_moderation_notes.count), admin_reports_path(target_account_id: @report.target_account.id)
-      %tr
-        %th= t('admin.reports.reported_by')
+.report-header
+  .report-header__card
+    .account-card
+      .account-card__header
+        = image_tag @report.target_account.header.url, alt: ''
+      .account-card__title
+        .account-card__title__avatar
+          = image_tag @report.target_account.avatar.url, alt: ''
+        .display-name
+          %bdi
+            %strong.emojify.p-name= display_name(@report.target_account, custom_emojify: true)
+          %span
+            = acct(@report.target_account)
+            = fa_icon('lock') if @report.target_account.locked?
+      - if @report.target_account.note.present?
+        .account-card__bio.emojify
+          = Formatter.instance.simplified_format(@report.target_account, custom_emojify: true)
+      .account-card__actions
+        .account-card__counters
+          .account-card__counters__item
+            = friendly_number_to_human @report.target_account.statuses_count
+            %small= t('accounts.posts', count: @report.target_account.statuses_count).downcase
+          .account-card__counters__item
+            = friendly_number_to_human @report.target_account.followers_count
+            %small= t('accounts.followers', count: @report.target_account.followers_count).downcase
+          .account-card__counters__item
+            = friendly_number_to_human @report.target_account.following_count
+            %small= t('accounts.following', count: @report.target_account.following_count).downcase
+        .account-card__actions__button
+          = link_to t('admin.reports.view_profile'), admin_account_path(@report.target_account_id), class: 'button'
+    .report-header__details.report-header__details--horizontal
+      .report-header__details__item
+        .report-header__details__item__header
+          %strong= t('admin.accounts.joined')
+        .report-header__details__item__content
+          %time.time-ago{ datetime: @report.target_account.created_at.iso8601, title: l(@report.target_account.created_at) }= l @report.target_account.created_at
+      .report-header__details__item
+        .report-header__details__item__header
+          %strong= t('accounts.last_active')
+        .report-header__details__item__content
+          - if @report.target_account.last_status_at.present?
+            %time.time-ago{ datetime: @report.target_account.last_status_at.to_date.iso8601, title: l(@report.target_account.last_status_at.to_date) }= l @report.target_account.last_status_at
+      .report-header__details__item
+        .report-header__details__item__header
+          %strong= t('admin.accounts.strikes')
+        .report-header__details__item__content
+          = @report.target_account.strikes.count
+
+  .report-header__details
+    .report-header__details__item
+      .report-header__details__item__header
+        %strong= t('admin.reports.created_at')
+      .report-header__details__item__content
+        %time.formatted{ datetime: @report.created_at.iso8601 }
+    .report-header__details__item
+      .report-header__details__item__header
+        %strong= t('admin.reports.reported_by')
+      .report-header__details__item__content
         - if @report.account.instance_actor?
-          %td{ colspan: 3 }= site_hostname
+          = site_hostname
         - elsif @report.account.local?
-          %td= admin_account_link_to @report.account
-          %td= table_link_to 'flag', t('admin.reports.account.reports', count: @report.account.targeted_reports.count), admin_reports_path(target_account_id: @report.account.id)
-          %td= table_link_to 'file', t('admin.reports.account.notes', count: @report.account.targeted_moderation_notes.count), admin_reports_path(target_account_id: @report.account.id)
+          = admin_account_link_to @report.account
+        - else
+          = @report.account.domain
+    .report-header__details__item
+      .report-header__details__item__header
+        %strong= t('admin.reports.status')
+      .report-header__details__item__content
+        - if @report.action_taken?
+          = t('admin.reports.resolved')
         - else
-          %td{ colspan: 3 }= @report.account.domain
-      %tr
-        %th= t('admin.reports.created_at')
-        %td{ colspan: 3 }
-          %time.formatted{ datetime: @report.created_at.iso8601 }
-      %tr
-        %th= t('admin.reports.updated_at')
-        %td{ colspan: 3 }
-          %time.formatted{ datetime: @report.updated_at.iso8601 }
-      %tr
-        %th= t('admin.reports.status')
-        %td
-          - if @report.action_taken?
-            = t('admin.reports.resolved')
+          = t('admin.reports.unresolved')
+    - unless @report.target_account.local?
+      .report-header__details__item
+        .report-header__details__item__header
+          %strong= t('admin.reports.forwarded')
+        .report-header__details__item__content
+          - if @report.forwarded?
+            = t('simple_form.yes')
           - else
-            = t('admin.reports.unresolved')
-        %td{ colspan: 2 }
-          - if @report.action_taken?
-            = table_link_to 'envelope-open', t('admin.reports.reopen'), admin_report_path(@report, outcome: 'reopen'), method: :put
-      - unless @report.target_account.local?
-        %tr
-          %th= t('admin.reports.forwarded')
-          %td{ colspan: 3 }
-            - if @report.forwarded.nil?
-              \-
-            - elsif @report.forwarded?
-              = t('simple_form.yes')
-            - else
-              = t('simple_form.no')
-      - if !@report.action_taken_by_account.nil?
-        %tr
-          %th= t('admin.reports.action_taken_by')
-          %td{ colspan: 3 }
-            = admin_account_link_to @report.action_taken_by_account
-      - else
-        %tr
-          %th= t('admin.reports.assigned')
-          %td
-            - if @report.assigned_account.nil?
-              \-
-            - else
-              = admin_account_link_to @report.assigned_account
-          %td
-            - if @report.assigned_account != current_user.account
-              = table_link_to 'user', t('admin.reports.assign_to_self'), assign_to_self_admin_report_path(@report), method: :post
-          %td
-            - if !@report.assigned_account.nil?
-              = table_link_to 'trash', t('admin.reports.unassign'), unassign_admin_report_path(@report), method: :post
+            = t('simple_form.no')
+    - if !@report.action_taken_by_account.nil?
+      .report-header__details__item
+        .report-header__details__item__header
+          %strong= t('admin.reports.action_taken_by')
+        .report-header__details__item__content
+          = admin_account_link_to @report.action_taken_by_account
+    - else
+      .report-header__details__item
+        .report-header__details__item__header
+          %strong= t('admin.reports.assigned')
+        .report-header__details__item__content
+          - if @report.assigned_account.nil?
+            = t 'admin.reports.no_one_assigned'
+          - else
+            = admin_account_link_to @report.assigned_account
+          —
+          - if @report.assigned_account != current_user.account
+            = table_link_to 'user', t('admin.reports.assign_to_self'), assign_to_self_admin_report_path(@report), method: :post
+          - elsif !@report.assigned_account.nil?
+            = table_link_to 'trash', t('admin.reports.unassign'), unassign_admin_report_path(@report), method: :post
 
 %hr.spacer
 
-%div.action-buttons
-  %div
+%h3= t 'admin.reports.category'
 
-  - if @report.unresolved?
-    %div
-      - if @report.target_account.local?
-        = link_to t('admin.accounts.warn'), new_admin_account_action_path(@report.target_account_id, type: 'none', report_id: @report.id), class: 'button'
-        = link_to t('admin.accounts.disable'), new_admin_account_action_path(@report.target_account_id, type: 'disable', report_id: @report.id), class: 'button button--destructive'
-      = link_to t('admin.accounts.silence'), new_admin_account_action_path(@report.target_account_id, type: 'silence', report_id: @report.id), class: 'button button--destructive'
-      = link_to t('admin.accounts.perform_full_suspension'), new_admin_account_action_path(@report.target_account_id, type: 'suspend', report_id: @report.id), class: 'button button--destructive'
+%p= t 'admin.reports.category_description_html'
 
-%hr.spacer
+= react_admin_component :report_reason_selector, id: @report.id, category: @report.category, rule_ids: @report.rule_ids&.map(&:to_s), disabled: @report.action_taken?
 
-.speech-bubble
-  .speech-bubble__bubble= simple_format(@report.comment.presence || t('admin.reports.comment.none'))
-  .speech-bubble__owner
-    - if @report.account.local?
-      = admin_account_link_to @report.account
-    - else
-      = @report.account.domain
-      %br/
-    %time.formatted{ datetime: @report.created_at.iso8601 }
+- if @report.comment.present?
+  %p= t('admin.reports.comment_description_html', name: content_tag(:strong, @report.account.username, class: 'username'))
+
+  .report-notes__item
+    = image_tag @report.account.avatar.url, class: 'report-notes__item__avatar'
+
+    .report-notes__item__header
+      %span.username
+        = link_to display_name(@report.account), admin_account_path(@report.account_id)
+      %time{ datetime: @report.created_at.iso8601, title: l(@report.created_at) }
+        - if @report.created_at.today?
+          = t('admin.report_notes.today_at', time: l(@report.created_at, format: :time))
+        - else
+          = l @report.created_at.to_date
+
+    .report-notes__item__content
+      = simple_format(h(@report.comment))
+
+%hr.spacer/
 
-- unless @report.statuses.empty?
+%h3= t 'admin.reports.statuses'
+
+%p
+  = t 'admin.reports.statuses_description_html'
+  —
+  = link_to safe_join([fa_icon('plus'), t('admin.reports.add_to_report')]), admin_account_statuses_path(@report.target_account_id, report_id: @report.id), class: 'table-action-link'
+
+= form_for(@form, url: batch_admin_account_statuses_path(@report.target_account_id, report_id: @report.id)) do |f|
+  .batch-table
+    .batch-table__toolbar
+      %label.batch-table__toolbar__select.batch-checkbox-all
+        = check_box_tag :batch_checkbox_all, nil, false
+      .batch-table__toolbar__actions
+        - if !@statuses.empty? && @report.unresolved?
+          = f.button safe_join([fa_icon('times'), t('admin.statuses.batch.remove_from_report')]), name: :remove_from_report, class: 'table-action-link', type: :submit
+          = f.button safe_join([fa_icon('trash'), t('admin.reports.delete_and_resolve')]), name: :delete, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') }
+        - else
+    .batch-table__body
+      - if @statuses.empty?
+        = nothing_here 'nothing-here--under-tabs'
+      - else
+        = render partial: 'admin/reports/status', collection: @statuses, locals: { f: f }
+
+- if @report.unresolved?
   %hr.spacer/
 
-  = form_for(@form, url: admin_report_reported_statuses_path(@report.id)) do |f|
-    .batch-table
-      .batch-table__toolbar
-        %label.batch-table__toolbar__select.batch-checkbox-all
-          = check_box_tag :batch_checkbox_all, nil, false
-        .batch-table__toolbar__actions
-          = f.button safe_join([fa_icon('eye-slash'), t('admin.statuses.batch.nsfw_on')]), name: :nsfw_on, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') }
-          = f.button safe_join([fa_icon('eye'), t('admin.statuses.batch.nsfw_off')]), name: :nsfw_off, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') }
-          = f.button safe_join([fa_icon('trash'), t('admin.statuses.batch.delete')]), name: :delete, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') }
-      .batch-table__body
-        = render partial: 'admin/reports/status', collection: @report.statuses, locals: { f: f }
+  %p= t 'admin.reports.actions_description_html'
+
+  .report-actions
+    .report-actions__item
+      .report-actions__item__button
+        = link_to t('admin.accounts.silence'), new_admin_account_action_path(@report.target_account_id, type: 'silence', report_id: @report.id), class: 'button button--destructive'
+      .report-actions__item__description
+        = t('admin.reports.actions.silence_description_html')
+    .report-actions__item
+      .report-actions__item__button
+        = link_to t('admin.accounts.perform_full_suspension'), new_admin_account_action_path(@report.target_account_id, report_id: @report.id, type: 'suspend'), class: 'button button--destructive'
+      .report-actions__item__description
+        = t('admin.reports.actions.suspend_description_html')
+    .report-actions__item
+      .report-actions__item__button
+        = link_to t('admin.accounts.custom'), new_admin_account_action_path(@report.target_account_id, report_id: @report.id), class: 'button'
+      .report-actions__item__description
+        = t('admin.reports.actions.other_description_html')
+
+- unless @action_logs.empty?
+  %hr.spacer/
+
+  %h3= t 'admin.reports.action_log'
+
+  .report-notes
+    = render @action_logs
 
 %hr.spacer/
 
-- @report_notes.each do |item|
-  - if item.is_a?(Admin::ActionLog)
-    = render partial: 'action_log', locals: { action_log: item }
-  - else
-    = render item
+%h3= t 'admin.reports.notes.title'
+
+%p= t 'admin.reports.notes_description_html'
+
+.report-notes
+  = render @report_notes
 
 = simple_form_for @report_note, url: admin_report_notes_path do |f|
-  = render 'shared/error_messages', object: @report_note
   = f.input :report_id, as: :hidden
 
   .field-group
diff --git a/app/views/admin/statuses/index.html.haml b/app/views/admin/statuses/index.html.haml
index c39ba9071fce14f297c87150e0f089ba1eb27404..7e2114cc2baa2b7677eb403a842dc9e1165c0232 100644
--- a/app/views/admin/statuses/index.html.haml
+++ b/app/views/admin/statuses/index.html.haml
@@ -10,28 +10,37 @@
   .filter-subset
     %strong= t('admin.statuses.media.title')
     %ul
-      %li= link_to t('admin.statuses.no_media'), admin_account_statuses_path(@account.id, current_params.merge(media: nil)), class: !params[:media] && 'selected'
-      %li= link_to t('admin.statuses.with_media'), admin_account_statuses_path(@account.id, current_params.merge(media: true)), class: params[:media] && 'selected'
+      %li= filter_link_to t('generic.all'), media: nil, id: nil
+      %li= filter_link_to t('admin.statuses.with_media'), media: '1'
   .back-link
-    = link_to admin_account_path(@account.id) do
-      = fa_icon 'chevron-left fw'
-      = t('admin.statuses.back_to_account')
+    - if params[:report_id]
+      = link_to admin_report_path(params[:report_id].to_i) do
+        = fa_icon 'chevron-left fw'
+        = t('admin.statuses.back_to_report')
+    - else
+      = link_to admin_account_path(@account.id) do
+        = fa_icon 'chevron-left fw'
+        = t('admin.statuses.back_to_account')
 
 %hr.spacer/
 
-= form_for(@form, url: admin_account_statuses_path(@account.id)) do |f|
-  = hidden_field_tag :page, params[:page]
-  = hidden_field_tag :media, params[:media]
+= form_for(@status_batch_action, url: batch_admin_account_statuses_path(@account.id)) do |f|
+  = hidden_field_tag :page, params[:page] || 1
+
+  - Admin::StatusFilter::KEYS.each do |key|
+    = hidden_field_tag key, params[key] if params[key].present?
 
   .batch-table
     .batch-table__toolbar
       %label.batch-table__toolbar__select.batch-checkbox-all
         = check_box_tag :batch_checkbox_all, nil, false
       .batch-table__toolbar__actions
-        = f.button safe_join([fa_icon('eye-slash'), t('admin.statuses.batch.nsfw_on')]), name: :nsfw_on, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') }
-        = f.button safe_join([fa_icon('eye'), t('admin.statuses.batch.nsfw_off')]), name: :nsfw_off, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') }
-        = f.button safe_join([fa_icon('trash'), t('admin.statuses.batch.delete')]), name: :delete, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') }
+        - unless @statuses.empty?
+          = f.button safe_join([fa_icon('flag'), t('admin.statuses.batch.report')]), name: :report, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') }
     .batch-table__body
-      = render partial: 'admin/reports/status', collection: @statuses, locals: { f: f }
+      - if @statuses.empty?
+        = nothing_here 'nothing-here--under-tabs'
+      - else
+        = render partial: 'admin/reports/status', collection: @statuses, locals: { f: f }
 
 = paginate @statuses
diff --git a/app/views/admin/statuses/show.html.haml b/app/views/admin/statuses/show.html.haml
deleted file mode 100644
index e2470198d66665f8422cf19f88b549ec838402fb..0000000000000000000000000000000000000000
--- a/app/views/admin/statuses/show.html.haml
+++ /dev/null
@@ -1,27 +0,0 @@
-- content_for :page_title do
-  = t('admin.statuses.title')
-  \-
-  = "@#{@account.acct}"
-
-.filters
-  .back-link
-    = link_to admin_account_path(@account.id) do
-      %i.fa.fa-chevron-left.fa-fw
-      = t('admin.statuses.back_to_account')
-
-%hr.spacer/
-
-= form_for(@form, url: admin_account_statuses_path(@account.id)) do |f|
-  = hidden_field_tag :page, params[:page]
-  = hidden_field_tag :media, params[:media]
-
-  .batch-table
-    .batch-table__toolbar
-      %label.batch-table__toolbar__select.batch-checkbox-all
-        = check_box_tag :batch_checkbox_all, nil, false
-      .batch-table__toolbar__actions
-        = f.button safe_join([fa_icon('eye-slash'), t('admin.statuses.batch.nsfw_on')]), name: :nsfw_on, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') }
-        = f.button safe_join([fa_icon('eye'), t('admin.statuses.batch.nsfw_off')]), name: :nsfw_off, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') }
-        = f.button safe_join([fa_icon('trash'), t('admin.statuses.batch.delete')]), name: :delete, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') }
-    .batch-table__body
-      = render partial: 'admin/reports/status', collection: @statuses, locals: { f: f }
diff --git a/app/views/notification_mailer/_status.text.erb b/app/views/notification_mailer/_status.text.erb
index 8999a1f8ea54136515fe6306f1aba8900103a66d..c43f32d9f33c1af5d2c4ab43ae389b88e652cc80 100644
--- a/app/views/notification_mailer/_status.text.erb
+++ b/app/views/notification_mailer/_status.text.erb
@@ -1,8 +1,8 @@
 <% if status.spoiler_text? %>
-<%= raw status.spoiler_text %>
-----
-
+> <%= raw word_wrap(status.spoiler_text, break_sequence: "\n> ") %>
+> ----
+>
 <% end %>
-<%= raw Formatter.instance.plaintext(status) %>
+> <%= raw word_wrap(Formatter.instance.plaintext(status), break_sequence: "\n> ") %>
 
 <%= raw t('application_mailer.view')%> <%= web_url("statuses/#{status.id}") %>
diff --git a/app/views/user_mailer/warning.html.haml b/app/views/user_mailer/warning.html.haml
index 5a2911ecba574e3ab7808089df7c7527ed267a45..bda1fef6cfa44cf59c0e3638dd0ad018c21cdcb2 100644
--- a/app/views/user_mailer/warning.html.haml
+++ b/app/views/user_mailer/warning.html.haml
@@ -37,16 +37,26 @@
                           %tr
                             %td.column-cell.text-center
                               - unless @warning.none_action?
-                                %p= t "user_mailer.warning.explanation.#{@warning.action}"
+                                %p= t "user_mailer.warning.explanation.#{@warning.action}", instance: @instance
 
                               - unless @warning.text.blank?
                                 = Formatter.instance.linkify(@warning.text)
 
-                              - if !@statuses.nil? && !@statuses.empty?
+                              - if @warning.report && !@warning.report.other?
+                                %p
+                                  %strong= t('user_mailer.warning.reason')
+                                  = t("user_mailer.warning.categories.#{@warning.report.category}")
+
+                                - if @warning.report.violation? && @warning.report.rule_ids.present?
+                                  %ul.rules-list
+                                    - @warning.report.rules.each do |rule|
+                                      %li= rule.text
+
+                              - unless @statuses.empty?
                                 %p
                                   %strong= t('user_mailer.warning.statuses')
 
-- if !@statuses.nil? && !@statuses.empty?
+- unless @statuses.empty?
   - @statuses.each_with_index do |status, i|
     = render 'notification_mailer/status', status: status, i: i + 1, highlighted: true
 
diff --git a/app/views/user_mailer/warning.text.erb b/app/views/user_mailer/warning.text.erb
index bb6610c79bd2476a71b6ce510b87c0f7137d44e2..31d7308aea545f18e970b7224e613f1da5bcc4da 100644
--- a/app/views/user_mailer/warning.text.erb
+++ b/app/views/user_mailer/warning.text.erb
@@ -3,11 +3,24 @@
 ===
 
 <% unless @warning.none_action? %>
-<%= t "user_mailer.warning.explanation.#{@warning.action}" %>
+<%= t "user_mailer.warning.explanation.#{@warning.action}", instance: @instance %>
 
 <% end %>
+<% if @warning.text.present? %>
 <%= @warning.text %>
-<% if !@statuses.nil? && !@statuses.empty? %>
+
+<% end %>
+<% if @warning.report && !@warning.report.other? %>
+**<%= t('user_mailer.warning.reason') %>** <%= t("user_mailer.warning.categories.#{@warning.report.category}") %>
+
+<% if @warning.report.violation? && @warning.report.rule_ids.present? %>
+<% @warning.report.rules.each do |rule| %>
+- <%= rule.text %>
+<% end %>
+
+<% end %>
+<% end %>
+<% if !@statuses.empty? %>
 <%= t('user_mailer.warning.statuses') %>
 
 <% @statuses.each do |status| %>
diff --git a/app/workers/scheduler/user_cleanup_scheduler.rb b/app/workers/scheduler/user_cleanup_scheduler.rb
index be0c4277dab44498f4b55de4b690e21f5d77e7c4..d06b637f916d13110dba395570de9eb2a8e7365b 100644
--- a/app/workers/scheduler/user_cleanup_scheduler.rb
+++ b/app/workers/scheduler/user_cleanup_scheduler.rb
@@ -8,6 +8,7 @@ class Scheduler::UserCleanupScheduler
   def perform
     clean_unconfirmed_accounts!
     clean_suspended_accounts!
+    clean_discarded_statuses!
   end
 
   private
@@ -24,4 +25,12 @@ class Scheduler::UserCleanupScheduler
       Admin::AccountDeletionWorker.perform_async(deletion_request.account_id)
     end
   end
+
+  def clean_discarded_statuses!
+    Status.discarded.where('deleted_at <= ?', 30.days.ago).find_in_batches do |statuses|
+      RemovalWorker.push_bulk(statuses) do |status|
+        [status.id, { immediate: true }]
+      end
+    end
+  end
 end
diff --git a/config/locales/en.yml b/config/locales/en.yml
index 693a7b40084c8ddbb0695edee5d16bd4b09936f7..36ac896643fbde18e112b9242c2e39a0b00a4d55 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -113,6 +113,7 @@ en:
       confirm: Confirm
       confirmed: Confirmed
       confirming: Confirming
+      custom: Custom
       delete: Delete data
       deleted: Deleted
       demote: Demote
@@ -203,6 +204,7 @@ en:
       silence: Limit
       silenced: Limited
       statuses: Posts
+      strikes: Previous strikes
       subscribe: Subscribe
       suspended: Suspended
       suspension_irreversible: The data of this account has been irreversibly deleted. You can unsuspend the account to make it usable but it will not recover any data it previously had.
@@ -549,32 +551,44 @@ en:
     report_notes:
       created_msg: Report note successfully created!
       destroyed_msg: Report note successfully deleted!
+      today_at: Today at %{time}
     reports:
       account:
         notes:
           one: "%{count} note"
           other: "%{count} notes"
-        reports:
-          one: "%{count} report"
-          other: "%{count} reports"
+      action_log: Audit log
       action_taken_by: Action taken by
+      actions:
+        other_description_html: See more options for controlling the account's behaviour and customize communication to the reported account.
+        silence_description_html: The profile will be visible only to those who already follow it or manually look it up, severely limiting its reach. Can always be reverted.
+        suspend_description_html: The profile and all its contents will become inaccessible until it is eventually deleted. Interacting with the account will be impossible. Reversible within 30 days.
+      actions_description_html: 'If removing the offending content above is insufficient:'
+      add_to_report: Add more to report
       are_you_sure: Are you sure?
       assign_to_self: Assign to me
       assigned: Assigned moderator
       by_target_domain: Domain of reported account
+      category: Category
+      category_description_html: The reason this account and/or content was reported will be cited in communication with the reported account
       comment:
         none: None
+      comment_description_html: 'To provide more information, %{name} wrote:'
       created_at: Reported
+      delete_and_resolve: Delete and resolve
       forwarded: Forwarded
       forwarded_to: Forwarded to %{domain}
       mark_as_resolved: Mark as resolved
       mark_as_unresolved: Mark as unresolved
+      no_one_assigned: No one
       notes:
         create: Add note
         create_and_resolve: Resolve with note
         create_and_unresolve: Reopen with note
         delete: Delete
         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
       reopen: Reopen report
       report: 'Report #%{id}'
       reported_account: Reported account
@@ -582,11 +596,14 @@ en:
       resolved: Resolved
       resolved_msg: Report successfully resolved!
       status: Status
+      statuses: Reported content
+      statuses_description_html: Offending content will be cited in communication with the reported account
       target_origin: Origin of reported account
       title: Reports
       unassign: Unassign
       unresolved: Unresolved
       updated_at: Updated
+      view_profile: View profile
     rules:
       add_new: Add rule
       delete: Delete
@@ -688,15 +705,13 @@ en:
       destroyed_msg: Site upload successfully deleted!
     statuses:
       back_to_account: Back to account page
+      back_to_report: Back to report page
       batch:
-        delete: Delete
-        nsfw_off: Mark as not sensitive
-        nsfw_on: Mark as sensitive
+        remove_from_report: Remove from report
+        report: Report
       deleted: Deleted
-      failed_to_execute: Failed to execute
       media:
         title: Media
-      no_media: No media
       no_status_selected: No posts were changed as none were selected
       title: Account posts
       with_media: With media
@@ -1457,6 +1472,7 @@ en:
     formats:
       default: "%b %d, %Y, %H:%M"
       month: "%b %Y"
+      time: "%H:%M"
   two_factor_authentication:
     add: Add
     disable: Disable 2FA
@@ -1484,24 +1500,31 @@ en:
       subject: Please confirm attempted sign in
       title: Sign in attempt
     warning:
+      categories:
+        spam: Spam
+        violation: Content violates the following community guidelines
       explanation:
-        disable: You can no longer login to your account or use it in any other way, but your profile and other data remains intact.
-        sensitive: Your uploaded media files and linked media will be treated as sensitive.
-        silence: You can still use your account but only people who are already following you will see your posts on this server, and you may be excluded from various public listings. However, others may still manually follow you.
-        suspend: You can no longer use your account, and your profile and other data are no longer accessible. You can still login to request a backup of your data until the data is fully removed, but we will retain some data to prevent you from evading the suspension.
-      get_in_touch: You can reply to this e-mail to get in touch with the staff of %{instance}.
+        delete_statuses: Some of your posts have been found to violate one or more community guidelines and have been subsequently removed by the moderators of %{instance}. Future violations may result in harsher punitive actions against your account.
+        disable: You can no longer use your account, but your profile and other data remains intact. You can request a backup of your data, change account settings or delete your account.
+        sensitive: From now on, all your uploaded media files will be marked as sensitive and hidden behind a click-through warning.
+        silence: You can still use your account but only people who are already following you will see your posts on this server, and you may be excluded from various discovery features. However, others may still manually follow you.
+        suspend: You can no longer use your account, and your profile and other data are no longer accessible. You can still login to request a backup of your data until the data is fully removed in about 30 days, but we will retain some basic data to prevent you from evading the suspension.
+      get_in_touch: If you believe this is an error, you can reply to this e-mail to get in touch with the staff of %{instance}.
+      reason: 'Reason:'
       review_server_policies: Review server policies
-      statuses: 'Specifically, for:'
+      statuses: 'Posts that have been found in violation:'
       subject:
+        delete_statuses: Your posts on %{acct} have been removed
         disable: Your account %{acct} has been frozen
         none: Warning for %{acct}
-        sensitive: Your account %{acct} posting media has been marked as sensitive
+        sensitive: Your media files on %{acct} will be marked as sensitive from now on
         silence: Your account %{acct} has been limited
         suspend: Your account %{acct} has been suspended
       title:
+        delete_statuses: Posts removed
         disable: Account frozen
         none: Warning
-        sensitive: Your media has been marked as sensitive
+        sensitive: Media hidden
         silence: Account limited
         suspend: Account suspended
     welcome:
diff --git a/config/routes.rb b/config/routes.rb
index 2357ab6c74b47e0cf5e7a070937f01c77448754a..41ba453791c5abed71842ad2a47ed140f7f95df1 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -231,8 +231,6 @@ Rails.application.routes.draw do
         post :reopen
         post :resolve
       end
-
-      resources :reported_statuses, only: [:create]
     end
 
     resources :report_notes, only: [:create, :destroy]
@@ -259,7 +257,13 @@ Rails.application.routes.draw do
       resource :change_email, only: [:show, :update]
       resource :reset, only: [:create]
       resource :action, only: [:new, :create], controller: 'account_actions'
-      resources :statuses, only: [:index, :show, :create, :update, :destroy]
+
+      resources :statuses, only: [:index] do
+        collection do
+          post :batch
+        end
+      end
+
       resources :relationships, only: [:index]
 
       resource :confirmation, only: [:create] do
@@ -514,7 +518,7 @@ Rails.application.routes.draw do
           resource :action, only: [:create], controller: 'account_actions'
         end
 
-        resources :reports, only: [:index, :show] do
+        resources :reports, only: [:index, :update, :show] do
           member do
             post :assign_to_self
             post :unassign
diff --git a/db/migrate/20211231080958_add_category_to_reports.rb b/db/migrate/20211231080958_add_category_to_reports.rb
new file mode 100644
index 0000000000000000000000000000000000000000..c2b495c6354509cbc87ff01783d17628fec8bc2d
--- /dev/null
+++ b/db/migrate/20211231080958_add_category_to_reports.rb
@@ -0,0 +1,21 @@
+require Rails.root.join('lib', 'mastodon', 'migration_helpers')
+
+class AddCategoryToReports < ActiveRecord::Migration[6.1]
+  include Mastodon::MigrationHelpers
+
+  disable_ddl_transaction!
+
+  def up
+    safety_assured { add_column_with_default :reports, :category, :int, default: 0, allow_null: false }
+    add_column :reports, :action_taken_at, :datetime
+    add_column :reports, :rule_ids, :bigint, array: true
+    safety_assured { execute 'UPDATE reports SET action_taken_at = updated_at WHERE action_taken = TRUE' }
+  end
+
+  def down
+    safety_assured { execute 'UPDATE reports SET action_taken = TRUE WHERE action_taken_at IS NOT NULL' }
+    remove_column :reports, :category
+    remove_column :reports, :action_taken_at
+    remove_column :reports, :rule_ids
+  end
+end
diff --git a/db/migrate/20220115125126_add_report_id_to_account_warnings.rb b/db/migrate/20220115125126_add_report_id_to_account_warnings.rb
new file mode 100644
index 0000000000000000000000000000000000000000..a1c20c99ef82157b0ca047f0bb7e38873cef9ab2
--- /dev/null
+++ b/db/migrate/20220115125126_add_report_id_to_account_warnings.rb
@@ -0,0 +1,6 @@
+class AddReportIdToAccountWarnings < ActiveRecord::Migration[6.1]
+  def change
+    safety_assured { add_reference :account_warnings, :report, foreign_key: { on_delete: :cascade }, index: false }
+    add_column :account_warnings, :status_ids, :string, array: true
+  end
+end
diff --git a/db/migrate/20220115125341_fix_account_warning_actions.rb b/db/migrate/20220115125341_fix_account_warning_actions.rb
new file mode 100644
index 0000000000000000000000000000000000000000..25cc17fd39272f27e7d0d0ddf9899a2e7d9c6286
--- /dev/null
+++ b/db/migrate/20220115125341_fix_account_warning_actions.rb
@@ -0,0 +1,21 @@
+class FixAccountWarningActions < ActiveRecord::Migration[6.1]
+  disable_ddl_transaction!
+
+  def up
+    safety_assured do
+      execute 'UPDATE account_warnings SET action = 1000 WHERE action = 1'
+      execute 'UPDATE account_warnings SET action = 2000 WHERE action = 2'
+      execute 'UPDATE account_warnings SET action = 3000 WHERE action = 3'
+      execute 'UPDATE account_warnings SET action = 4000 WHERE action = 4'
+    end
+  end
+
+  def down
+    safety_assured do
+      execute 'UPDATE account_warnings SET action = 1 WHERE action = 1000'
+      execute 'UPDATE account_warnings SET action = 2 WHERE action = 2000'
+      execute 'UPDATE account_warnings SET action = 3 WHERE action = 3000'
+      execute 'UPDATE account_warnings SET action = 4 WHERE action = 4000'
+    end
+  end
+end
diff --git a/db/migrate/20220116202951_add_deleted_at_index_on_statuses.rb b/db/migrate/20220116202951_add_deleted_at_index_on_statuses.rb
new file mode 100644
index 0000000000000000000000000000000000000000..dc33625528139288b90212de3ec1e2c00bc720e2
--- /dev/null
+++ b/db/migrate/20220116202951_add_deleted_at_index_on_statuses.rb
@@ -0,0 +1,7 @@
+class AddDeletedAtIndexOnStatuses < ActiveRecord::Migration[6.1]
+  disable_ddl_transaction!
+
+  def change
+    add_index :statuses, :deleted_at, where: 'deleted_at IS NOT NULL', algorithm: :concurrently
+  end
+end
diff --git a/db/post_migrate/20220109213908_remove_action_taken_from_reports.rb b/db/post_migrate/20220109213908_remove_action_taken_from_reports.rb
new file mode 100644
index 0000000000000000000000000000000000000000..73e6ad6f43f984bdd5963401ecce4bd69ba55f89
--- /dev/null
+++ b/db/post_migrate/20220109213908_remove_action_taken_from_reports.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+class RemoveActionTakenFromReports < ActiveRecord::Migration[5.2]
+  disable_ddl_transaction!
+
+  def change
+    safety_assured { remove_column :reports, :action_taken, :boolean, default: false, null: false }
+  end
+end
diff --git a/db/schema.rb b/db/schema.rb
index d1446c652318cfeedbde8775248242174943b9ce..ed615a1ee03fc62852368c26c17d14e2778f2c25 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
 #
 # It's strongly recommended that you check this file into your version control system.
 
-ActiveRecord::Schema.define(version: 2021_12_13_040746) do
+ActiveRecord::Schema.define(version: 2022_01_16_202951) do
 
   # These are extensions that must be enabled in order to support this database
   enable_extension "plpgsql"
@@ -133,6 +133,8 @@ ActiveRecord::Schema.define(version: 2021_12_13_040746) do
     t.text "text", default: "", null: false
     t.datetime "created_at", null: false
     t.datetime "updated_at", null: false
+    t.bigint "report_id"
+    t.string "status_ids", array: true
     t.index ["account_id"], name: "index_account_warnings_on_account_id"
     t.index ["target_account_id"], name: "index_account_warnings_on_target_account_id"
   end
@@ -747,7 +749,6 @@ ActiveRecord::Schema.define(version: 2021_12_13_040746) do
   create_table "reports", force: :cascade do |t|
     t.bigint "status_ids", default: [], null: false, array: true
     t.text "comment", default: "", null: false
-    t.boolean "action_taken", default: false, null: false
     t.datetime "created_at", null: false
     t.datetime "updated_at", null: false
     t.bigint "account_id", null: false
@@ -756,6 +757,9 @@ ActiveRecord::Schema.define(version: 2021_12_13_040746) do
     t.bigint "assigned_account_id"
     t.string "uri"
     t.boolean "forwarded"
+    t.integer "category", default: 0, null: false
+    t.datetime "action_taken_at"
+    t.bigint "rule_ids", array: true
     t.index ["account_id"], name: "index_reports_on_account_id"
     t.index ["target_account_id"], name: "index_reports_on_target_account_id"
   end
@@ -851,6 +855,7 @@ ActiveRecord::Schema.define(version: 2021_12_13_040746) do
     t.bigint "poll_id"
     t.datetime "deleted_at"
     t.index ["account_id", "id", "visibility", "updated_at"], name: "index_statuses_20190820", order: { id: :desc }, where: "(deleted_at IS NULL)"
+    t.index ["deleted_at"], name: "index_statuses_on_deleted_at", where: "(deleted_at IS NOT NULL)"
     t.index ["id", "account_id"], name: "index_statuses_local_20190824", order: { id: :desc }, where: "((local OR (uri IS NULL)) AND (deleted_at IS NULL) AND (visibility = 0) AND (reblog_of_id IS NULL) AND ((NOT reply) OR (in_reply_to_account_id = account_id)))"
     t.index ["id", "account_id"], name: "index_statuses_public_20200119", order: { id: :desc }, where: "((deleted_at IS NULL) AND (visibility = 0) AND (reblog_of_id IS NULL) AND ((NOT reply) OR (in_reply_to_account_id = account_id)))"
     t.index ["in_reply_to_account_id"], name: "index_statuses_on_in_reply_to_account_id"
@@ -1008,6 +1013,7 @@ ActiveRecord::Schema.define(version: 2021_12_13_040746) do
   add_foreign_key "account_statuses_cleanup_policies", "accounts", on_delete: :cascade
   add_foreign_key "account_warnings", "accounts", column: "target_account_id", on_delete: :cascade
   add_foreign_key "account_warnings", "accounts", on_delete: :nullify
+  add_foreign_key "account_warnings", "reports", on_delete: :cascade
   add_foreign_key "accounts", "accounts", column: "moved_to_account_id", on_delete: :nullify
   add_foreign_key "admin_action_logs", "accounts", on_delete: :cascade
   add_foreign_key "announcement_mutes", "accounts", on_delete: :cascade
diff --git a/spec/controllers/admin/report_notes_controller_spec.rb b/spec/controllers/admin/report_notes_controller_spec.rb
index ec5872c7d2a360cf54dbd7f88bf548a92d92f209..c0013f41aec85b2c0444b4ec47c4670a609ab86b 100644
--- a/spec/controllers/admin/report_notes_controller_spec.rb
+++ b/spec/controllers/admin/report_notes_controller_spec.rb
@@ -12,11 +12,11 @@ describe Admin::ReportNotesController do
   describe 'POST #create' do
     subject { post :create, params: params }
 
-    let(:report) { Fabricate(:report, action_taken: action_taken, action_taken_by_account_id: account_id) }
+    let(:report) { Fabricate(:report, action_taken_at: action_taken, action_taken_by_account_id: account_id) }
 
     context 'when parameter is valid' do
       context 'when report is unsolved' do
-        let(:action_taken) { false }
+        let(:action_taken) { nil }
         let(:account_id) { nil }
 
         context 'when create_and_resolve flag is on' do
@@ -41,7 +41,7 @@ describe Admin::ReportNotesController do
       end
 
       context 'when report is resolved' do
-        let(:action_taken) { true }
+        let(:action_taken) { Time.now.utc }
         let(:account_id) { user.account.id }
 
         context 'when create_and_unresolve flag is on' do
@@ -68,7 +68,7 @@ describe Admin::ReportNotesController do
 
     context 'when parameter is invalid' do
       let(:params) { { report_note: { content: '', report_id: report.id } } }
-      let(:action_taken) { false }
+      let(:action_taken) { nil }
       let(:account_id) { nil }
 
       it 'renders admin/reports/show' do
diff --git a/spec/controllers/admin/reported_statuses_controller_spec.rb b/spec/controllers/admin/reported_statuses_controller_spec.rb
deleted file mode 100644
index 2a1598123c1db962163d979d4d0838af54f6878b..0000000000000000000000000000000000000000
--- a/spec/controllers/admin/reported_statuses_controller_spec.rb
+++ /dev/null
@@ -1,59 +0,0 @@
-require 'rails_helper'
-
-describe Admin::ReportedStatusesController do
-  render_views
-
-  let(:user) { Fabricate(:user, admin: true) }
-  let(:report) { Fabricate(:report, status_ids: [status.id]) }
-  let(:status) { Fabricate(:status) }
-
-  before do
-    sign_in user, scope: :user
-  end
-
-  describe 'POST #create' do
-    subject do
-      -> { post :create, params: { :report_id => report, action => '', :form_status_batch => { status_ids: status_ids } } }
-    end
-
-    let(:action) { 'nsfw_on' }
-    let(:status_ids) { [status.id] }
-    let(:status) { Fabricate(:status, sensitive: !sensitive) }
-    let(:sensitive) { true }
-    let!(:media_attachment) { Fabricate(:media_attachment, status: status) }
-
-    context 'when action is nsfw_on' do
-      it 'updates sensitive column' do
-        is_expected.to change {
-          status.reload.sensitive
-        }.from(false).to(true)
-      end
-    end
-
-    context 'when action is nsfw_off' do
-      let(:action) { 'nsfw_off' }
-      let(:sensitive) { false }
-
-      it 'updates sensitive column' do
-        is_expected.to change {
-          status.reload.sensitive
-        }.from(true).to(false)
-      end
-    end
-
-    context 'when action is delete' do
-      let(:action) { 'delete' }
-
-      it 'removes a status' do
-        allow(RemovalWorker).to receive(:perform_async)
-        subject.call
-        expect(RemovalWorker).to have_received(:perform_async).with(status_ids.first, immediate: true)
-      end
-    end
-
-    it 'redirects to report page' do
-      subject.call
-      expect(response).to redirect_to(admin_report_path(report))
-    end
-  end
-end
diff --git a/spec/controllers/admin/reports_controller_spec.rb b/spec/controllers/admin/reports_controller_spec.rb
index 49d3e970743bef57926859ce405d5d23742eaf50..d421f0739c951ae3e84e5277dffe294fca7bf2aa 100644
--- a/spec/controllers/admin/reports_controller_spec.rb
+++ b/spec/controllers/admin/reports_controller_spec.rb
@@ -10,8 +10,8 @@ describe Admin::ReportsController do
 
   describe 'GET #index' do
     it 'returns http success with no filters' do
-      specified = Fabricate(:report, action_taken: false)
-      Fabricate(:report, action_taken: true)
+      specified = Fabricate(:report, action_taken_at: nil)
+      Fabricate(:report, action_taken_at: Time.now.utc)
 
       get :index
 
@@ -22,10 +22,10 @@ describe Admin::ReportsController do
     end
 
     it 'returns http success with resolved filter' do
-      specified = Fabricate(:report, action_taken: true)
-      Fabricate(:report, action_taken: false)
+      specified = Fabricate(:report, action_taken_at: Time.now.utc)
+      Fabricate(:report, action_taken_at: nil)
 
-      get :index, params: { resolved: 1 }
+      get :index, params: { resolved: '1' }
 
       reports = assigns(:reports).to_a
       expect(reports.size).to eq 1
@@ -54,15 +54,7 @@ describe Admin::ReportsController do
       expect(response).to redirect_to(admin_reports_path)
       report.reload
       expect(report.action_taken_by_account).to eq user.account
-      expect(report.action_taken).to eq true
-    end
-
-    it 'sets trust level when the report is an antispam one' do
-      report = Fabricate(:report, account: Account.representative)
-
-      put :resolve, params: { id: report }
-      report.reload
-      expect(report.target_account.trust_level).to eq Account::TRUST_LEVELS[:trusted]
+      expect(report.action_taken?).to eq true
     end
   end
 
@@ -74,7 +66,7 @@ describe Admin::ReportsController do
       expect(response).to redirect_to(admin_report_path(report))
       report.reload
       expect(report.action_taken_by_account).to eq nil
-      expect(report.action_taken).to eq false
+      expect(report.action_taken?).to eq false
     end
   end
 
diff --git a/spec/controllers/admin/statuses_controller_spec.rb b/spec/controllers/admin/statuses_controller_spec.rb
index e388caae205ed86bf2278478762fbc0cb2db988a..de32fd18e1dc688ec00bba0200226e6aa047c601 100644
--- a/spec/controllers/admin/statuses_controller_spec.rb
+++ b/spec/controllers/admin/statuses_controller_spec.rb
@@ -18,65 +18,46 @@ describe Admin::StatusesController do
   end
 
   describe 'GET #index' do
-    it 'returns http success with no media' do
-      get :index, params: { account_id: account.id }
+    context do
+      before do
+        get :index, params: { account_id: account.id }
+      end
 
-      statuses = assigns(:statuses).to_a
-      expect(statuses.size).to eq 4
-      expect(statuses.first.id).to eq last_status.id
-      expect(response).to have_http_status(200)
+      it 'returns http success' do
+        expect(response).to have_http_status(200)
+      end
     end
 
-    it 'returns http success with media' do
-      get :index, params: { account_id: account.id, media: true }
+    context 'filtering by media' do
+      before do
+        get :index, params: { account_id: account.id, media: '1' }
+      end
 
-      statuses = assigns(:statuses).to_a
-      expect(statuses.size).to eq 2
-      expect(statuses.first.id).to eq last_media_attached_status.id
-      expect(response).to have_http_status(200)
+      it 'returns http success' do
+        expect(response).to have_http_status(200)
+      end
     end
   end
 
-  describe 'POST #create' do
-    subject do
-      -> { post :create, params: { :account_id => account.id, action => '', :form_status_batch => { status_ids: status_ids } } }
+  describe 'POST #batch' do
+    before do
+      post :batch, params: { :account_id => account.id, action => '', :admin_status_batch_action => { status_ids: status_ids } }
     end
 
-    let(:action) { 'nsfw_on' }
     let(:status_ids) { [media_attached_status.id] }
 
-    context 'when action is nsfw_on' do
-      it 'updates sensitive column' do
-        is_expected.to change {
-          media_attached_status.reload.sensitive
-        }.from(false).to(true)
-      end
-    end
+    context 'when action is report' do
+      let(:action) { 'report' }
 
-    context 'when action is nsfw_off' do
-      let(:action) { 'nsfw_off' }
-      let(:sensitive) { false }
-
-      it 'updates sensitive column' do
-        is_expected.to change {
-          media_attached_status.reload.sensitive
-        }.from(true).to(false)
+      it 'creates a report' do
+        report = Report.last
+        expect(report.target_account_id).to eq account.id
+        expect(report.status_ids).to eq status_ids
       end
-    end
-
-    context 'when action is delete' do
-      let(:action) { 'delete' }
 
-      it 'removes a status' do
-        allow(RemovalWorker).to receive(:perform_async)
-        subject.call
-        expect(RemovalWorker).to have_received(:perform_async).with(status_ids.first, immediate: true)
+      it 'redirects to report page' do
+        expect(response).to redirect_to(admin_report_path(Report.last.id))
       end
     end
-
-    it 'redirects to account statuses page' do
-      subject.call
-      expect(response).to redirect_to(admin_account_statuses_path(account.id))
-    end
   end
 end
diff --git a/spec/fabricators/report_fabricator.rb b/spec/fabricators/report_fabricator.rb
index 5bd4a63f0246e8cb5fa1062a21d8ad574551f04f..2c7101e094aaf7aa08524a0de6baba049876c2fc 100644
--- a/spec/fabricators/report_fabricator.rb
+++ b/spec/fabricators/report_fabricator.rb
@@ -1,6 +1,6 @@
 Fabricator(:report) do
   account
-  target_account { Fabricate(:account) }
-  comment      "You nasty"
-  action_taken false
+  target_account  { Fabricate(:account) }
+  comment         "You nasty"
+  action_taken_at nil
 end
diff --git a/spec/mailers/previews/user_mailer_preview.rb b/spec/mailers/previews/user_mailer_preview.rb
index 6d87fd706e0be25bbc7135147bfcfa20a2d1a35f..69b9b971eec44425d64977631c5c9556e513e918 100644
--- a/spec/mailers/previews/user_mailer_preview.rb
+++ b/spec/mailers/previews/user_mailer_preview.rb
@@ -79,7 +79,7 @@ class UserMailerPreview < ActionMailer::Preview
 
   # Preview this email at http://localhost:3000/rails/mailers/user_mailer/warning
   def warning
-    UserMailer.warning(User.first, AccountWarning.new(text: '', action: :silence), [Status.first.id])
+    UserMailer.warning(User.first, AccountWarning.last)
   end
 
   # Preview this email at http://localhost:3000/rails/mailers/user_mailer/sign_in_token
diff --git a/spec/models/form/status_batch_spec.rb b/spec/models/form/status_batch_spec.rb
deleted file mode 100644
index 68d84a7379b8c6104c2a40ccd498c909d6a2c446..0000000000000000000000000000000000000000
--- a/spec/models/form/status_batch_spec.rb
+++ /dev/null
@@ -1,52 +0,0 @@
-require 'rails_helper'
-
-describe Form::StatusBatch do
-  let(:form) { Form::StatusBatch.new(action: action, status_ids: status_ids) }
-  let(:status) { Fabricate(:status) }
-
-  describe 'with nsfw action' do
-    let(:status_ids) { [status.id, nonsensitive_status.id, sensitive_status.id] }
-    let(:nonsensitive_status) { Fabricate(:status, sensitive: false) }
-    let(:sensitive_status) { Fabricate(:status, sensitive: true) }
-    let!(:shown_media_attachment) { Fabricate(:media_attachment, status: nonsensitive_status) }
-    let!(:hidden_media_attachment) { Fabricate(:media_attachment, status: sensitive_status) }
-
-    context 'nsfw_on' do
-      let(:action) { 'nsfw_on' }
-
-      it { expect(form.save).to be true }
-      it { expect { form.save }.to change { nonsensitive_status.reload.sensitive }.from(false).to(true) }
-      it { expect { form.save }.not_to change { sensitive_status.reload.sensitive } }
-      it { expect { form.save }.not_to change { status.reload.sensitive } }
-    end
-
-    context 'nsfw_off' do
-      let(:action) { 'nsfw_off' }
-
-      it { expect(form.save).to be true }
-      it { expect { form.save }.to change { sensitive_status.reload.sensitive }.from(true).to(false) }
-      it { expect { form.save }.not_to change { nonsensitive_status.reload.sensitive } }
-      it { expect { form.save }.not_to change { status.reload.sensitive } }
-    end
-  end
-
-  describe 'with delete action' do
-    let(:status_ids) { [status.id] }
-    let(:action) { 'delete' }
-    let!(:another_status) { Fabricate(:status) }
-
-    before do
-      allow(RemovalWorker).to receive(:perform_async)
-    end
-
-    it 'call RemovalWorker' do
-      form.save
-      expect(RemovalWorker).to have_received(:perform_async).with(status.id, immediate: true)
-    end
-
-    it 'do not call RemovalWorker' do
-      form.save
-      expect(RemovalWorker).not_to have_received(:perform_async).with(another_status.id, immediate: true)
-    end
-  end
-end
diff --git a/spec/models/report_spec.rb b/spec/models/report_spec.rb
index 312954c9dc9f4bdd27f740d134ef8eee1cdf7de2..3d29c021954f007c69cc153fde14a4a9784b6c75 100644
--- a/spec/models/report_spec.rb
+++ b/spec/models/report_spec.rb
@@ -54,7 +54,7 @@ describe Report do
   end
 
   describe 'resolve!' do
-    subject(:report) { Fabricate(:report, action_taken: false, action_taken_by_account_id: nil) }
+    subject(:report) { Fabricate(:report, action_taken_at: nil, action_taken_by_account_id: nil) }
 
     let(:acting_account) { Fabricate(:account) }
 
@@ -63,12 +63,13 @@ describe Report do
     end
 
     it 'records action taken' do
-      expect(report).to have_attributes(action_taken: true, action_taken_by_account_id: acting_account.id)
+      expect(report.action_taken?).to be true
+      expect(report.action_taken_by_account_id).to eq acting_account.id
     end
   end
 
   describe 'unresolve!' do
-    subject(:report) { Fabricate(:report, action_taken: true, action_taken_by_account_id: acting_account.id) }
+    subject(:report) { Fabricate(:report, action_taken_at: Time.now.utc, action_taken_by_account_id: acting_account.id) }
 
     let(:acting_account) { Fabricate(:account) }
 
@@ -77,23 +78,24 @@ describe Report do
     end
 
     it 'unresolves' do
-      expect(report).to have_attributes(action_taken: false, action_taken_by_account_id: nil)
+      expect(report.action_taken?).to be false
+      expect(report.action_taken_by_account_id).to be_nil
     end
   end
 
   describe 'unresolved?' do
     subject { report.unresolved? }
 
-    let(:report) { Fabricate(:report, action_taken: action_taken) }
+    let(:report) { Fabricate(:report, action_taken_at: action_taken) }
 
     context 'if action is taken' do
-      let(:action_taken) { true }
+      let(:action_taken) { Time.now.utc }
 
       it { is_expected.to be false }
     end
 
     context 'if action not is taken' do
-      let(:action_taken) { false }
+      let(:action_taken) { nil }
 
       it { is_expected.to be true }
     end