about summary refs log tree commit diff
path: root/app
diff options
context:
space:
mode:
authorClaire <claire.github-309c@sitedethib.com>2022-02-17 10:58:25 +0100
committerClaire <claire.github-309c@sitedethib.com>2022-02-17 10:58:44 +0100
commitf224237862b009ad4b008a8730c58111f103145b (patch)
tree097c08663c6348914fdf95d2ac9ce57ee2a3307c /app
parentec4f9066189fbab4368a275e9cd654dc7ad48217 (diff)
parentac99f586bb4138e083676579097d951434e90515 (diff)
Merge branch 'main' into glitch-soc/merge-upstream
Conflicts:
- `db/schema.rb`:
  Conflict due to glitch-soc adding the `content_type` column on status edits
  and thus having a different schema version number.
  Solved by taking upstream's schema version number, as it is higher than
  glitch-soc's.
Diffstat (limited to 'app')
-rw-r--r--app/controllers/admin/accounts_controller.rb4
-rw-r--r--app/controllers/admin/dashboard_controller.rb1
-rw-r--r--app/controllers/admin/disputes/appeals_controller.rb40
-rw-r--r--app/controllers/auth/registrations_controller.rb11
-rw-r--r--app/controllers/disputes/appeals_controller.rb26
-rw-r--r--app/controllers/disputes/base_controller.rb18
-rw-r--r--app/controllers/disputes/strikes_controller.rb17
-rw-r--r--app/helpers/admin/account_moderation_notes_helper.rb4
-rw-r--r--app/helpers/admin/action_logs_helper.rb2
-rw-r--r--app/helpers/admin/trends/statuses_helper.rb20
-rw-r--r--app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js15
-rw-r--r--app/javascript/packs/public.js8
-rw-r--r--app/javascript/styles/mastodon/admin.scss93
-rw-r--r--app/javascript/styles/mastodon/footer.scss17
-rw-r--r--app/lib/activitypub/activity/announce.rb1
-rw-r--r--app/lib/feed_manager.rb2
-rw-r--r--app/lib/search_query_transformer.rb37
-rw-r--r--app/mailers/admin_mailer.rb10
-rw-r--r--app/mailers/user_mailer.rb20
-rw-r--r--app/models/account.rb4
-rw-r--r--app/models/account_filter.rb4
-rw-r--r--app/models/account_warning.rb8
-rw-r--r--app/models/admin/action_log_filter.rb2
-rw-r--r--app/models/admin/appeal_filter.rb49
-rw-r--r--app/models/admin/status_filter.rb2
-rw-r--r--app/models/appeal.rb60
-rw-r--r--app/models/user.rb6
-rw-r--r--app/policies/account_warning_policy.rb17
-rw-r--r--app/policies/appeal_policy.rb13
-rw-r--r--app/services/appeal_service.rb28
-rw-r--r--app/services/approve_appeal_service.rb74
-rw-r--r--app/views/admin/account_moderation_notes/_account_moderation_note.html.haml7
-rw-r--r--app/views/admin/account_warnings/_account_warning.html.haml30
-rw-r--r--app/views/admin/accounts/show.html.haml21
-rw-r--r--app/views/admin/dashboard/index.html.haml7
-rw-r--r--app/views/admin/disputes/appeals/_appeal.html.haml21
-rw-r--r--app/views/admin/disputes/appeals/index.html.haml22
-rw-r--r--app/views/admin/report_notes/_report_note.html.haml2
-rw-r--r--app/views/admin/reports/show.html.haml2
-rw-r--r--app/views/admin_mailer/new_appeal.text.erb9
-rw-r--r--app/views/auth/registrations/_account_warning.html.haml20
-rw-r--r--app/views/auth/registrations/_status.html.haml31
-rw-r--r--app/views/disputes/strikes/show.html.haml127
-rw-r--r--app/views/layouts/public.html.haml4
-rw-r--r--app/views/settings/preferences/notifications/show.html.haml1
-rw-r--r--app/views/statuses/_detailed_status.html.haml2
-rw-r--r--app/views/user_mailer/appeal_approved.html.haml59
-rw-r--r--app/views/user_mailer/appeal_approved.text.erb7
-rw-r--r--app/views/user_mailer/appeal_rejected.html.haml59
-rw-r--r--app/views/user_mailer/appeal_rejected.text.erb7
-rw-r--r--app/views/user_mailer/warning.html.haml6
51 files changed, 984 insertions, 73 deletions
diff --git a/app/controllers/admin/accounts_controller.rb b/app/controllers/admin/accounts_controller.rb
index e7f56e243..e0ae71b9f 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.strikes.custom.latest
+      @warnings                = @account.strikes.includes(:target_account, :account, :appeal).latest
       @domain_block            = DomainBlock.rule_for(@account.domain)
     end
 
@@ -146,7 +146,7 @@ module Admin
     end
 
     def filter_params
-      params.slice(*AccountFilter::KEYS).permit(*AccountFilter::KEYS)
+      params.slice(:page, *AccountFilter::KEYS).permit(:page, *AccountFilter::KEYS)
     end
 
     def form_account_batch_params
diff --git a/app/controllers/admin/dashboard_controller.rb b/app/controllers/admin/dashboard_controller.rb
index f0a935411..e376baab2 100644
--- a/app/controllers/admin/dashboard_controller.rb
+++ b/app/controllers/admin/dashboard_controller.rb
@@ -8,6 +8,7 @@ module Admin
       @pending_users_count   = User.pending.count
       @pending_reports_count = Report.unresolved.count
       @pending_tags_count    = Tag.pending_review.count
+      @pending_appeals_count = Appeal.pending.count
     end
 
     private
diff --git a/app/controllers/admin/disputes/appeals_controller.rb b/app/controllers/admin/disputes/appeals_controller.rb
new file mode 100644
index 000000000..32e5e2f6f
--- /dev/null
+++ b/app/controllers/admin/disputes/appeals_controller.rb
@@ -0,0 +1,40 @@
+# frozen_string_literal: true
+
+class Admin::Disputes::AppealsController < Admin::BaseController
+  before_action :set_appeal, except: :index
+
+  def index
+    authorize :appeal, :index?
+
+    @appeals = filtered_appeals.page(params[:page])
+  end
+
+  def approve
+    authorize @appeal, :approve?
+    log_action :approve, @appeal
+    ApproveAppealService.new.call(@appeal, current_account)
+    redirect_to disputes_strike_path(@appeal.strike)
+  end
+
+  def reject
+    authorize @appeal, :approve?
+    log_action :reject, @appeal
+    @appeal.reject!(current_account)
+    UserMailer.appeal_rejected(@appeal.account.user, @appeal)
+    redirect_to disputes_strike_path(@appeal.strike)
+  end
+
+  private
+
+  def filtered_appeals
+    Admin::AppealFilter.new(filter_params.with_defaults(status: 'pending')).results.includes(strike: :account)
+  end
+
+  def filter_params
+    params.slice(:page, *Admin::AppealFilter::KEYS).permit(:page, *Admin::AppealFilter::KEYS)
+  end
+
+  def set_appeal
+    @appeal = Appeal.find(params[:id])
+  end
+end
diff --git a/app/controllers/auth/registrations_controller.rb b/app/controllers/auth/registrations_controller.rb
index 6b1f3fa82..5d32fe66e 100644
--- a/app/controllers/auth/registrations_controller.rb
+++ b/app/controllers/auth/registrations_controller.rb
@@ -10,6 +10,7 @@ class Auth::RegistrationsController < Devise::RegistrationsController
   before_action :configure_sign_up_params, only: [:create]
   before_action :set_pack
   before_action :set_sessions, only: [:edit, :update]
+  before_action :set_strikes, only: [:edit, :update]
   before_action :set_instance_presenter, only: [:new, :create, :update]
   before_action :set_body_classes, only: [:new, :create, :edit, :update]
   before_action :require_not_suspended!, only: [:update]
@@ -116,8 +117,10 @@ class Auth::RegistrationsController < Devise::RegistrationsController
   end
 
   def set_invite
-    invite = invite_code.present? ? Invite.find_by(code: invite_code) : nil
-    @invite = invite&.valid_for_use? ? invite : nil
+    @invite = begin
+      invite = Invite.find_by(code: invite_code) if invite_code.present?
+      invite if invite&.valid_for_use?
+    end
   end
 
   def determine_layout
@@ -128,6 +131,10 @@ class Auth::RegistrationsController < Devise::RegistrationsController
     @sessions = current_user.session_activations
   end
 
+  def set_strikes
+    @strikes = current_account.strikes.active.latest
+  end
+
   def require_not_suspended!
     forbidden if current_account.suspended?
   end
diff --git a/app/controllers/disputes/appeals_controller.rb b/app/controllers/disputes/appeals_controller.rb
new file mode 100644
index 000000000..eefd92b5a
--- /dev/null
+++ b/app/controllers/disputes/appeals_controller.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+class Disputes::AppealsController < Disputes::BaseController
+  before_action :set_strike
+
+  def create
+    authorize @strike, :appeal?
+
+    @appeal = AppealService.new.call(@strike, appeal_params[:text])
+
+    redirect_to disputes_strike_path(@strike), notice: I18n.t('disputes.strikes.appealed_msg')
+  rescue ActiveRecord::RecordInvalid => e
+    @appeal = e.record
+    render template: 'disputes/strikes/show'
+  end
+
+  private
+
+  def set_strike
+    @strike = current_account.strikes.find(params[:strike_id])
+  end
+
+  def appeal_params
+    params.require(:appeal).permit(:text)
+  end
+end
diff --git a/app/controllers/disputes/base_controller.rb b/app/controllers/disputes/base_controller.rb
new file mode 100644
index 000000000..865146b5c
--- /dev/null
+++ b/app/controllers/disputes/base_controller.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+class Disputes::BaseController < ApplicationController
+  include Authorization
+
+  layout 'admin'
+
+  skip_before_action :require_functional!
+
+  before_action :set_body_classes
+  before_action :authenticate_user!
+
+  private
+
+  def set_body_classes
+    @body_classes = 'admin'
+  end
+end
diff --git a/app/controllers/disputes/strikes_controller.rb b/app/controllers/disputes/strikes_controller.rb
new file mode 100644
index 000000000..d41c5c727
--- /dev/null
+++ b/app/controllers/disputes/strikes_controller.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+class Disputes::StrikesController < Disputes::BaseController
+  before_action :set_strike
+
+  def show
+    authorize @strike, :show?
+
+    @appeal = @strike.appeal || @strike.build_appeal
+  end
+
+  private
+
+  def set_strike
+    @strike = AccountWarning.find(params[:id])
+  end
+end
diff --git a/app/helpers/admin/account_moderation_notes_helper.rb b/app/helpers/admin/account_moderation_notes_helper.rb
index 40b2a5289..2f08538ca 100644
--- a/app/helpers/admin/account_moderation_notes_helper.rb
+++ b/app/helpers/admin/account_moderation_notes_helper.rb
@@ -1,10 +1,10 @@
 # frozen_string_literal: true
 
 module Admin::AccountModerationNotesHelper
-  def admin_account_link_to(account)
+  def admin_account_link_to(account, path: nil)
     return if account.nil?
 
-    link_to admin_account_path(account.id), class: name_tag_classes(account), title: account.acct do
+    link_to path || admin_account_path(account.id), class: name_tag_classes(account), title: account.acct do
       safe_join([
                   image_tag(account.avatar.url, width: 15, height: 15, alt: display_name(account), class: 'avatar'),
                   content_tag(:span, account.acct, class: 'username'),
diff --git a/app/helpers/admin/action_logs_helper.rb b/app/helpers/admin/action_logs_helper.rb
index f3aa4be4f..47eeeaac3 100644
--- a/app/helpers/admin/action_logs_helper.rb
+++ b/app/helpers/admin/action_logs_helper.rb
@@ -33,6 +33,8 @@ module Admin::ActionLogsHelper
       "#{record.ip}/#{record.ip.prefix} (#{I18n.t("simple_form.labels.ip_block.severities.#{record.severity}")})"
     when 'Instance'
       record.domain
+    when 'Appeal'
+      link_to record.account.acct, disputes_strike_path(record.strike)
     end
   end
 
diff --git a/app/helpers/admin/trends/statuses_helper.rb b/app/helpers/admin/trends/statuses_helper.rb
new file mode 100644
index 000000000..d16e3dd12
--- /dev/null
+++ b/app/helpers/admin/trends/statuses_helper.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+module Admin::Trends::StatusesHelper
+  def one_line_preview(status)
+    text = begin
+      if status.local?
+        status.text.split("\n").first
+      else
+        Nokogiri::HTML(status.text).css('html > body > *').first&.text
+      end
+    end
+
+    return '' if text.blank?
+
+    html = Formatter.instance.send(:encode, text)
+    html = Formatter.instance.send(:encode_custom_emojis, html, status.emojis, prefers_autoplay?)
+
+    html.html_safe # rubocop:disable Rails/OutputSafety
+  end
+end
diff --git a/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js b/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js
index 4a87714e6..f433e4de9 100644
--- a/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js
+++ b/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js
@@ -170,7 +170,7 @@ class EmojiPickerMenu extends React.PureComponent {
 
   state = {
     modifierOpen: false,
-    placement: null,
+    readyToFocus: false,
   };
 
   handleDocumentClick = e => {
@@ -182,6 +182,16 @@ class EmojiPickerMenu extends React.PureComponent {
   componentDidMount () {
     document.addEventListener('click', this.handleDocumentClick, false);
     document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);
+
+    // Because of https://github.com/react-bootstrap/react-bootstrap/issues/2614 we need
+    // to wait for a frame before focusing
+    requestAnimationFrame(() => {
+      this.setState({ readyToFocus: true });
+      if (this.node) {
+        const element = this.node.querySelector('input[type="search"]');
+        if (element) element.focus();
+      }
+    });
   }
 
   componentWillUnmount () {
@@ -281,7 +291,7 @@ class EmojiPickerMenu extends React.PureComponent {
           showSkinTones={false}
           backgroundImageFn={backgroundImageFn}
           notFound={notFoundFn}
-          autoFocus
+          autoFocus={this.state.readyToFocus}
           emojiTooltip
         />
 
@@ -314,6 +324,7 @@ class EmojiPickerDropdown extends React.PureComponent {
   state = {
     active: false,
     loading: false,
+    placement: null,
   };
 
   setRef = (c) => {
diff --git a/app/javascript/packs/public.js b/app/javascript/packs/public.js
index 7ebe8b4d0..be467a8e2 100644
--- a/app/javascript/packs/public.js
+++ b/app/javascript/packs/public.js
@@ -151,13 +151,7 @@ function main() {
   });
 
   delegate(document, '.sidebar__toggle__icon', 'click', () => {
-    const target = document.querySelector('.sidebar ul');
-
-    if (target.style.display === 'block') {
-      target.style.display = 'none';
-    } else {
-      target.style.display = 'block';
-    }
+    document.querySelector('.sidebar ul').classList.toggle('visible');
   });
 
   // Empty the honeypot fields in JS in case something like an extension
diff --git a/app/javascript/styles/mastodon/admin.scss b/app/javascript/styles/mastodon/admin.scss
index 66ce92ce2..33e115c1a 100644
--- a/app/javascript/styles/mastodon/admin.scss
+++ b/app/javascript/styles/mastodon/admin.scss
@@ -322,6 +322,10 @@ $content-width: 840px;
 
       & > ul {
         display: none;
+
+        &.visible {
+          display: block;
+        }
       }
 
       ul a,
@@ -594,12 +598,16 @@ body,
 }
 
 .log-entry {
+  display: block;
   line-height: 20px;
   padding: 15px;
   padding-left: 15px * 2 + 40px;
   background: $ui-base-color;
   border-bottom: 1px solid darken($ui-base-color, 8%);
   position: relative;
+  text-decoration: none;
+  color: $darker-text-color;
+  font-size: 14px;
 
   &:first-child {
     border-top-left-radius: 4px;
@@ -612,15 +620,12 @@ body,
     border-bottom: 0;
   }
 
-  &:hover {
+  &:hover,
+  &:focus,
+  &:active {
     background: lighten($ui-base-color, 4%);
   }
 
-  &__header {
-    color: $darker-text-color;
-    font-size: 14px;
-  }
-
   &__avatar {
     position: absolute;
     left: 15px;
@@ -656,6 +661,18 @@ body,
       text-decoration: underline;
     }
   }
+
+  &--inactive {
+    .log-entry__title {
+      text-decoration: line-through;
+    }
+
+    a,
+    .username,
+    .target {
+      color: $darker-text-color;
+    }
+  }
 }
 
 a.name-tag,
@@ -1191,6 +1208,17 @@ a.sparkline {
         font-weight: 600;
         padding: 4px 0;
       }
+
+      a {
+        color: $ui-highlight-color;
+        text-decoration: none;
+
+        &:hover,
+        &:focus,
+        &:active {
+          text-decoration: underline;
+        }
+      }
     }
 
     &--horizontal {
@@ -1467,3 +1495,56 @@ a.sparkline {
     }
   }
 }
+
+.strike-card {
+  padding: 15px;
+  border-radius: 4px;
+  background: $ui-base-color;
+  font-size: 15px;
+  line-height: 20px;
+  word-wrap: break-word;
+  font-weight: 400;
+  color: $primary-text-color;
+
+  p {
+    margin-bottom: 20px;
+    unicode-bidi: plaintext;
+
+    &:last-child {
+      margin-bottom: 0;
+    }
+  }
+
+  &__statuses-list {
+    border-radius: 4px;
+    border: 1px solid darken($ui-base-color, 8%);
+    font-size: 13px;
+    line-height: 18px;
+    overflow: hidden;
+
+    &__item {
+      padding: 16px;
+      background: lighten($ui-base-color, 2%);
+      border-bottom: 1px solid darken($ui-base-color, 8%);
+
+      &:last-child {
+        border-bottom: 0;
+      }
+
+      &__meta {
+        color: $darker-text-color;
+      }
+
+      a {
+        color: inherit;
+        text-decoration: none;
+
+        &:hover,
+        &:focus,
+        &:active {
+          text-decoration: underline;
+        }
+      }
+    }
+  }
+}
diff --git a/app/javascript/styles/mastodon/footer.scss b/app/javascript/styles/mastodon/footer.scss
index 00d290883..073ebda7e 100644
--- a/app/javascript/styles/mastodon/footer.scss
+++ b/app/javascript/styles/mastodon/footer.scss
@@ -90,6 +90,20 @@
         .column-4 {
           display: none;
         }
+
+        .column-2 h4 {
+          display: none;
+        }
+      }
+    }
+
+    .legal-xs {
+      display: none;
+      text-align: center;
+      padding-top: 20px;
+
+      @media screen and (max-width: $no-gap-breakpoint) {
+        display: block;
       }
     }
 
@@ -105,7 +119,8 @@
       }
     }
 
-    ul a {
+    ul a,
+    .legal-xs a {
       text-decoration: none;
       color: lighten($ui-base-color, 34%);
 
diff --git a/app/lib/activitypub/activity/announce.rb b/app/lib/activitypub/activity/announce.rb
index 1f9319290..12fad8da4 100644
--- a/app/lib/activitypub/activity/announce.rb
+++ b/app/lib/activitypub/activity/announce.rb
@@ -8,6 +8,7 @@ class ActivityPub::Activity::Announce < ActivityPub::Activity
       original_status = status_from_object
 
       return reject_payload! if original_status.nil? || !announceable?(original_status)
+      return if requested_through_relay?
 
       @status = Status.find_by(account: @account, reblog: original_status)
 
diff --git a/app/lib/feed_manager.rb b/app/lib/feed_manager.rb
index 0713aa471..7f2bc42d3 100644
--- a/app/lib/feed_manager.rb
+++ b/app/lib/feed_manager.rb
@@ -499,7 +499,7 @@ class FeedManager
 
     return false if active_filters.empty?
 
-    combined_regex = active_filters.reduce { |memo, obj| Regexp.union(memo, obj) }
+    combined_regex = Regexp.union(active_filters)
     status         = status.reblog if status.reblog?
 
     combined_text = [
diff --git a/app/lib/search_query_transformer.rb b/app/lib/search_query_transformer.rb
index e07ebfffe..c685d7b6f 100644
--- a/app/lib/search_query_transformer.rb
+++ b/app/lib/search_query_transformer.rb
@@ -2,19 +2,21 @@
 
 class SearchQueryTransformer < Parslet::Transform
   class Query
-    attr_reader :should_clauses, :must_not_clauses, :must_clauses
+    attr_reader :should_clauses, :must_not_clauses, :must_clauses, :filter_clauses
 
     def initialize(clauses)
       grouped = clauses.chunk(&:operator).to_h
       @should_clauses = grouped.fetch(:should, [])
       @must_not_clauses = grouped.fetch(:must_not, [])
       @must_clauses = grouped.fetch(:must, [])
+      @filter_clauses = grouped.fetch(:filter, [])
     end
 
     def apply(search)
       should_clauses.each { |clause| search = search.query.should(clause_to_query(clause)) }
       must_clauses.each { |clause| search = search.query.must(clause_to_query(clause)) }
       must_not_clauses.each { |clause| search = search.query.must_not(clause_to_query(clause)) }
+      filter_clauses.each { |clause| search = search.filter(**clause_to_filter(clause)) }
       search.query.minimum_should_match(1)
     end
 
@@ -30,6 +32,15 @@ class SearchQueryTransformer < Parslet::Transform
         raise "Unexpected clause type: #{clause}"
       end
     end
+
+    def clause_to_filter(clause)
+      case clause
+      when PrefixClause
+        { term: { clause.filter => clause.term } }
+      else
+        raise "Unexpected clause type: #{clause}"
+      end
+    end
   end
 
   class Operator
@@ -69,11 +80,33 @@ class SearchQueryTransformer < Parslet::Transform
     end
   end
 
+  class PrefixClause
+    attr_reader :filter, :operator, :term
+
+    def initialize(prefix, term)
+      @operator = :filter
+      case prefix
+      when 'from'
+        @filter = :account_id
+        username, domain = term.split('@')
+        account = Account.find_remote(username, domain)
+
+        raise "Account not found: #{term}" unless account
+
+        @term = account.id
+      else
+        raise "Unknown prefix: #{prefix}"
+      end
+    end
+  end
+
   rule(clause: subtree(:clause)) do
     prefix   = clause[:prefix][:term].to_s if clause[:prefix]
     operator = clause[:operator]&.to_s
 
-    if clause[:term]
+    if clause[:prefix]
+      PrefixClause.new(prefix, clause[:term].to_s)
+    elsif clause[:term]
       TermClause.new(prefix, operator, clause[:term].to_s)
     elsif clause[:shortcode]
       TermClause.new(prefix, operator, ":#{clause[:term]}:")
diff --git a/app/mailers/admin_mailer.rb b/app/mailers/admin_mailer.rb
index b23bd1296..a9d00c000 100644
--- a/app/mailers/admin_mailer.rb
+++ b/app/mailers/admin_mailer.rb
@@ -15,6 +15,16 @@ class AdminMailer < ApplicationMailer
     end
   end
 
+  def new_appeal(recipient, appeal)
+    @appeal   = appeal
+    @me       = recipient
+    @instance = Rails.configuration.x.local_domain
+
+    locale_for_account(@me) do
+      mail to: @me.user_email, subject: I18n.t('admin_mailer.new_appeal.subject', instance: @instance, username: @appeal.account.username)
+    end
+  end
+
   def new_pending_account(recipient, user)
     @account  = user.account
     @me       = recipient
diff --git a/app/mailers/user_mailer.rb b/app/mailers/user_mailer.rb
index 5221a4892..583c948b0 100644
--- a/app/mailers/user_mailer.rb
+++ b/app/mailers/user_mailer.rb
@@ -173,6 +173,26 @@ class UserMailer < Devise::Mailer
     end
   end
 
+  def appeal_approved(user, appeal)
+    @resource = user
+    @instance = Rails.configuration.x.local_domain
+    @appeal   = appeal
+
+    I18n.with_locale(@resource.locale || I18n.default_locale) do
+      mail to: @resource.email, subject: I18n.t('user_mailer.appeal_approved.subject', date: l(@appeal.created_at))
+    end
+  end
+
+  def appeal_rejected(user, appeal)
+    @resource = user
+    @instance = Rails.configuration.x.local_domain
+    @appeal   = appeal
+
+    I18n.with_locale(@resource.locale || I18n.default_locale) do
+      mail to: @resource.email, subject: I18n.t('user_mailer.appeal_rejected.subject', date: l(@appeal.created_at))
+    end
+  end
+
   def sign_in_token(user, remote_ip, user_agent, timestamp)
     @resource   = user
     @instance   = Rails.configuration.x.local_domain
diff --git a/app/models/account.rb b/app/models/account.rb
index e41fdf003..8f6663e7c 100644
--- a/app/models/account.rb
+++ b/app/models/account.rb
@@ -274,6 +274,10 @@ class Account < ApplicationRecord
     true
   end
 
+  def previous_strikes_count
+    strikes.where(overruled_at: nil).count
+  end
+
   def keypair
     @keypair ||= OpenSSL::PKey::RSA.new(private_key || public_key)
   end
diff --git a/app/models/account_filter.rb b/app/models/account_filter.rb
index dcb174122..9da1522dd 100644
--- a/app/models/account_filter.rb
+++ b/app/models/account_filter.rb
@@ -24,6 +24,8 @@ class AccountFilter
     scope = Account.includes(:account_stat, user: [:ips, :invite_request]).without_instance_actor.reorder(nil)
 
     params.each do |key, value|
+      next if key.to_s == 'page'
+
       scope.merge!(scope_for(key, value.to_s.strip)) if value.present?
     end
 
@@ -49,7 +51,7 @@ class AccountFilter
     when 'email'
       accounts_with_users.merge(User.matches_email(value))
     when 'ip'
-      valid_ip?(value) ? accounts_with_users.merge(User.matches_ip(value)) : Account.none
+      valid_ip?(value) ? accounts_with_users.merge(User.matches_ip(value).group('users.id, accounts.id')) : Account.none
     when 'invited_by'
       invited_by_scope(value)
     when 'order'
diff --git a/app/models/account_warning.rb b/app/models/account_warning.rb
index fc0d988fd..05d01942d 100644
--- a/app/models/account_warning.rb
+++ b/app/models/account_warning.rb
@@ -12,6 +12,7 @@
 #  updated_at        :datetime         not null
 #  report_id         :bigint(8)
 #  status_ids        :string           is an Array
+#  overruled_at      :datetime
 #
 
 class AccountWarning < ApplicationRecord
@@ -28,12 +29,17 @@ class AccountWarning < ApplicationRecord
   belongs_to :target_account, class_name: 'Account', inverse_of: :strikes
   belongs_to :report, optional: true
 
-  has_one :appeal, dependent: :destroy
+  has_one :appeal, dependent: :destroy, inverse_of: :strike
 
   scope :latest, -> { order(id: :desc) }
   scope :custom, -> { where.not(text: '') }
+  scope :active, -> { where(overruled_at: nil).or(where('account_warnings.overruled_at >= ?', 30.days.ago)) }
 
   def statuses
     Status.with_discarded.where(id: status_ids || [])
   end
+
+  def overruled?
+    overruled_at.present?
+  end
 end
diff --git a/app/models/admin/action_log_filter.rb b/app/models/admin/action_log_filter.rb
index 12136223b..0f2f712a2 100644
--- a/app/models/admin/action_log_filter.rb
+++ b/app/models/admin/action_log_filter.rb
@@ -8,6 +8,8 @@ class Admin::ActionLogFilter
   ).freeze
 
   ACTION_TYPE_MAP = {
+    approve_appeal: { target_type: 'Appeal', action: 'approve' }.freeze,
+    reject_appeal: { target_type: 'Appeal', action: 'reject' }.freeze,
     assigned_to_self_report: { target_type: 'Report', action: 'assigned_to_self' }.freeze,
     change_email_user: { target_type: 'User', action: 'change_email' }.freeze,
     confirm_user: { target_type: 'User', action: 'confirm' }.freeze,
diff --git a/app/models/admin/appeal_filter.rb b/app/models/admin/appeal_filter.rb
new file mode 100644
index 000000000..b163d2e56
--- /dev/null
+++ b/app/models/admin/appeal_filter.rb
@@ -0,0 +1,49 @@
+# frozen_string_literal: true
+
+class Admin::AppealFilter
+  KEYS = %i(
+    status
+  ).freeze
+
+  attr_reader :params
+
+  def initialize(params)
+    @params = params
+  end
+
+  def results
+    scope = Appeal.order(id: :desc)
+
+    params.each do |key, value|
+      next if %w(page).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 'status'
+      status_scope(value)
+    else
+      raise "Unknown filter: #{key}"
+    end
+  end
+
+  def status_scope(value)
+    case value
+    when 'approved'
+      Appeal.approved
+    when 'rejected'
+      Appeal.rejected
+    when 'pending'
+      Appeal.pending
+    else
+      raise "Unknown status: #{value}"
+    end
+  end
+end
diff --git a/app/models/admin/status_filter.rb b/app/models/admin/status_filter.rb
index ce5bb5f46..4fba612a6 100644
--- a/app/models/admin/status_filter.rb
+++ b/app/models/admin/status_filter.rb
@@ -31,7 +31,7 @@ class Admin::StatusFilter
   def scope_for(key, value)
     case key.to_s
     when 'media'
-      Status.joins(:media_attachments).merge(@account.media_attachments.reorder(nil)).group(:id)
+      Status.joins(:media_attachments).merge(@account.media_attachments.reorder(nil)).group(:id).reorder('statuses.id desc')
     when 'id'
       Status.where(id: value)
     else
diff --git a/app/models/appeal.rb b/app/models/appeal.rb
new file mode 100644
index 000000000..1f32cfa8b
--- /dev/null
+++ b/app/models/appeal.rb
@@ -0,0 +1,60 @@
+# frozen_string_literal: true
+
+# == Schema Information
+#
+# Table name: appeals
+#
+#  id                     :bigint(8)        not null, primary key
+#  account_id             :bigint(8)        not null
+#  account_warning_id     :bigint(8)        not null
+#  text                   :text             default(""), not null
+#  approved_at            :datetime
+#  approved_by_account_id :bigint(8)
+#  rejected_at            :datetime
+#  rejected_by_account_id :bigint(8)
+#  created_at             :datetime         not null
+#  updated_at             :datetime         not null
+#
+class Appeal < ApplicationRecord
+  MAX_STRIKE_AGE = 20.days
+
+  belongs_to :account
+  belongs_to :strike, class_name: 'AccountWarning', foreign_key: 'account_warning_id'
+  belongs_to :approved_by_account, class_name: 'Account', optional: true
+  belongs_to :rejected_by_account, class_name: 'Account', optional: true
+
+  validates :text, presence: true, length: { maximum: 2_000 }
+  validates :account_warning_id, uniqueness: true
+
+  validate :validate_time_frame, on: :create
+
+  scope :approved, -> { where.not(approved_at: nil) }
+  scope :rejected, -> { where.not(rejected_at: nil) }
+  scope :pending, -> { where(approved_at: nil, rejected_at: nil) }
+
+  def pending?
+    !approved? && !rejected?
+  end
+
+  def approved?
+    approved_at.present?
+  end
+
+  def rejected?
+    rejected_at.present?
+  end
+
+  def approve!(current_account)
+    update!(approved_at: Time.now.utc, approved_by_account: current_account)
+  end
+
+  def reject!(current_account)
+    update!(rejected_at: Time.now.utc, rejected_by_account: current_account)
+  end
+
+  private
+
+  def validate_time_frame
+    errors.add(:base, I18n.t('strikes.errors.too_late')) if strike.created_at < MAX_STRIKE_AGE.ago
+  end
+end
diff --git a/app/models/user.rb b/app/models/user.rb
index ee20e293e..a21e96ae5 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -111,7 +111,7 @@ class User < ApplicationRecord
   scope :inactive, -> { where(arel_table[:current_sign_in_at].lt(ACTIVE_DURATION.ago)) }
   scope :active, -> { confirmed.where(arel_table[:current_sign_in_at].gteq(ACTIVE_DURATION.ago)).joins(:account).where(accounts: { suspended_at: nil }) }
   scope :matches_email, ->(value) { where(arel_table[:email].matches("#{value}%")) }
-  scope :matches_ip, ->(value) { left_joins(:ips).where('user_ips.ip <<= ?', value) }
+  scope :matches_ip, ->(value) { left_joins(:ips).where('user_ips.ip <<= ?', value).group('users.id') }
   scope :emailable, -> { confirmed.enabled.joins(:account).merge(Account.searchable) }
 
   before_validation :sanitize_languages
@@ -265,6 +265,10 @@ class User < ApplicationRecord
     settings.notification_emails['pending_account']
   end
 
+  def allows_appeal_emails?
+    settings.notification_emails['appeal']
+  end
+
   def allows_trending_tag_emails?
     settings.notification_emails['trending_tag']
   end
diff --git a/app/policies/account_warning_policy.rb b/app/policies/account_warning_policy.rb
new file mode 100644
index 000000000..65707dfa7
--- /dev/null
+++ b/app/policies/account_warning_policy.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+class AccountWarningPolicy < ApplicationPolicy
+  def show?
+    target? || staff?
+  end
+
+  def appeal?
+    target? && record.created_at >= Appeal::MAX_STRIKE_AGE.ago
+  end
+
+  private
+
+  def target?
+    record.target_account_id == current_account&.id
+  end
+end
diff --git a/app/policies/appeal_policy.rb b/app/policies/appeal_policy.rb
new file mode 100644
index 000000000..a25187172
--- /dev/null
+++ b/app/policies/appeal_policy.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+class AppealPolicy < ApplicationPolicy
+  def index?
+    staff?
+  end
+
+  def approve?
+    record.pending? && staff?
+  end
+
+  alias reject? approve?
+end
diff --git a/app/services/appeal_service.rb b/app/services/appeal_service.rb
new file mode 100644
index 000000000..1397c50f5
--- /dev/null
+++ b/app/services/appeal_service.rb
@@ -0,0 +1,28 @@
+# frozen_string_literal: true
+
+class AppealService < BaseService
+  def call(strike, text)
+    @strike = strike
+    @text   = text
+
+    create_appeal!
+    notify_staff!
+
+    @appeal
+  end
+
+  private
+
+  def create_appeal!
+    @appeal = @strike.create_appeal!(
+      text: @text,
+      account: @strike.target_account
+    )
+  end
+
+  def notify_staff!
+    User.staff.includes(:account).each do |u|
+      AdminMailer.new_appeal(u.account, @appeal).deliver_later if u.allows_appeal_emails?
+    end
+  end
+end
diff --git a/app/services/approve_appeal_service.rb b/app/services/approve_appeal_service.rb
new file mode 100644
index 000000000..f76bf8943
--- /dev/null
+++ b/app/services/approve_appeal_service.rb
@@ -0,0 +1,74 @@
+# frozen_string_literal: true
+
+class ApproveAppealService < BaseService
+  def call(appeal, current_account)
+    @appeal          = appeal
+    @strike          = appeal.strike
+    @current_account = current_account
+
+    ApplicationRecord.transaction do
+      undo_strike_action!
+      mark_strike_as_appealed!
+    end
+
+    queue_workers!
+    notify_target_account!
+  end
+
+  private
+
+  def target_account
+    @strike.target_account
+  end
+
+  def undo_strike_action!
+    case @strike.action
+    when 'disable'
+      undo_disable!
+    when 'delete_statuses'
+      undo_delete_statuses!
+    when 'sensitive'
+      undo_sensitive!
+    when 'silence'
+      undo_silence!
+    when 'suspend'
+      undo_suspend!
+    end
+  end
+
+  def mark_strike_as_appealed!
+    @appeal.approve!(@current_account)
+    @strike.touch(:overruled_at)
+  end
+
+  def undo_disable!
+    target_account.user.enable!
+  end
+
+  def undo_delete_statuses!
+    # Cannot be undone
+  end
+
+  def undo_sensitive!
+    target_account.unsensitize!
+  end
+
+  def undo_silence!
+    target_account.unsilence!
+  end
+
+  def undo_suspend!
+    target_account.unsuspend!
+  end
+
+  def queue_workers!
+    case @strike.action
+    when 'suspend'
+      Admin::UnsuspensionWorker.perform_async(target_account.id)
+    end
+  end
+
+  def notify_target_account!
+    UserMailer.appeal_approved(target_account.user, @appeal).deliver_later
+  end
+end
diff --git a/app/views/admin/account_moderation_notes/_account_moderation_note.html.haml b/app/views/admin/account_moderation_notes/_account_moderation_note.html.haml
deleted file mode 100644
index 432fb79a6..000000000
--- a/app/views/admin/account_moderation_notes/_account_moderation_note.html.haml
+++ /dev/null
@@ -1,7 +0,0 @@
-.speech-bubble
-  .speech-bubble__bubble
-    = simple_format(h(account_moderation_note.content))
-  .speech-bubble__owner
-    = admin_account_link_to account_moderation_note.account
-    %time.formatted{ datetime: account_moderation_note.created_at.iso8601 }= l account_moderation_note.created_at
-    = table_link_to 'trash', t('admin.account_moderation_notes.delete'), admin_account_moderation_note_path(account_moderation_note), method: :delete if can?(:destroy, account_moderation_note)
diff --git a/app/views/admin/account_warnings/_account_warning.html.haml b/app/views/admin/account_warnings/_account_warning.html.haml
index 8c9c9679c..ef23c3b77 100644
--- a/app/views/admin/account_warnings/_account_warning.html.haml
+++ b/app/views/admin/account_warnings/_account_warning.html.haml
@@ -1,6 +1,24 @@
-.speech-bubble.warning
-  .speech-bubble__bubble
-    = Formatter.instance.linkify(account_warning.text)
-  .speech-bubble__owner
-    = admin_account_link_to account_warning.account
-    %time.formatted{ datetime: account_warning.created_at.iso8601 }= l account_warning.created_at
+= link_to disputes_strike_path(account_warning), class: ['log-entry', account_warning.overruled? && 'log-entry--inactive'] do
+  .log-entry__header
+    .log-entry__avatar
+      = image_tag account_warning.target_account.avatar.url(:original), alt: '', width: 40, height: 40, class: 'avatar'
+    .log-entry__content
+      .log-entry__title
+        = t(account_warning.action, scope: 'admin.strikes.actions', name: content_tag(:span, account_warning.account.username, class: 'username'), target: content_tag(:span, account_warning.target_account.acct, class: 'target')).html_safe
+      .log-entry__timestamp
+        %time.formatted{ datetime: account_warning.created_at.iso8601 }
+          = l(account_warning.created_at)
+
+        - if account_warning.report_id.present?
+          ·
+          = t('admin.reports.title', id: account_warning.report_id)
+
+        - if account_warning.overruled?
+          ·
+          %span.positive-hint= t('admin.strikes.appeal_approved')
+        - elsif account_warning.appeal&.pending?
+          ·
+          %span.warning-hint= t('admin.strikes.appeal_pending')
+        - elsif account_warning.appeal&.rejected?
+          ·
+          %span.negative-hint= t('admin.strikes.appeal_rejected')
diff --git a/app/views/admin/accounts/show.html.haml b/app/views/admin/accounts/show.html.haml
index f3853d629..9a1f07a06 100644
--- a/app/views/admin/accounts/show.html.haml
+++ b/app/views/admin/accounts/show.html.haml
@@ -246,18 +246,29 @@
   %hr.spacer/
 
   - unless @warnings.empty?
-    = render @warnings
+
+    %h3= t 'admin.accounts.previous_strikes'
+
+    %p= t('admin.accounts.previous_strikes_description_html', count: @account.previous_strikes_count)
+
+    .account-strikes
+      = render @warnings
 
     %hr.spacer/
 
-  = render @moderation_notes
+  %h3= t 'admin.reports.notes.title'
 
-  = simple_form_for @account_moderation_note, url: admin_account_moderation_notes_path do |f|
-    = render 'shared/error_messages', object: @account_moderation_note
+  %p= t 'admin.reports.notes_description_html'
 
-    = f.input :content, placeholder: t('admin.reports.notes.placeholder'), rows: 6
+  .report-notes
+    = render partial: 'admin/report_notes/report_note', collection: @moderation_notes
+
+  = simple_form_for @account_moderation_note, url: admin_account_moderation_notes_path do |f|
     = f.hidden_field :target_account_id
 
+    .field-group
+      = f.input :content, placeholder: t('admin.reports.notes.placeholder'), rows: 6
+
     .actions
       = f.button :button, t('admin.account_moderation_notes.create'), type: :submit
 
diff --git a/app/views/admin/dashboard/index.html.haml b/app/views/admin/dashboard/index.html.haml
index 2ee13b9e2..66e0c0251 100644
--- a/app/views/admin/dashboard/index.html.haml
+++ b/app/views/admin/dashboard/index.html.haml
@@ -16,10 +16,10 @@
 
 .dashboard
   .dashboard__item
-    = react_admin_component :counter, measure: 'new_users', start_at: @time_period.first, end_at: @time_period.last, label: t('admin.dashboard.new_users'), href: admin_accounts_path
+    = react_admin_component :counter, measure: 'new_users', start_at: @time_period.first, end_at: @time_period.last, label: t('admin.dashboard.new_users'), href: admin_accounts_path(origin: 'local')
 
   .dashboard__item
-    = react_admin_component :counter, measure: 'active_users', start_at: @time_period.first, end_at: @time_period.last, label: t('admin.dashboard.active_users'), href: admin_accounts_path
+    = react_admin_component :counter, measure: 'active_users', start_at: @time_period.first, end_at: @time_period.last, label: t('admin.dashboard.active_users'), href: admin_accounts_path(origin: 'local')
 
   .dashboard__item
     = react_admin_component :counter, measure: 'interactions', start_at: @time_period.first, end_at: @time_period.last, label: t('admin.dashboard.interactions')
@@ -43,6 +43,9 @@
       %span= t('admin.dashboard.pending_tags_html', count: @pending_tags_count)
       = fa_icon 'chevron-right fw'
 
+    = link_to admin_disputes_appeals_path(status: 'pending'), class: 'dashboard__quick-access' do
+      %span= t('admin.dashboard.pending_appeals_html', count: @pending_appeals_count)
+      = fa_icon 'chevron-right fw'
   .dashboard__item
     = react_admin_component :dimension, dimension: 'sources', start_at: @time_period.first, end_at: @time_period.last, limit: 8, label: t('admin.dashboard.sources')
 
diff --git a/app/views/admin/disputes/appeals/_appeal.html.haml b/app/views/admin/disputes/appeals/_appeal.html.haml
new file mode 100644
index 000000000..02b8777e1
--- /dev/null
+++ b/app/views/admin/disputes/appeals/_appeal.html.haml
@@ -0,0 +1,21 @@
+= link_to disputes_strike_path(appeal.strike), class: ['log-entry', appeal.approved? && 'log-entry--inactive'] do
+  .log-entry__header
+    .log-entry__avatar
+      = image_tag appeal.account.avatar.url(:original), alt: '', width: 40, height: 40, class: 'avatar'
+    .log-entry__content
+      .log-entry__title
+        = t(appeal.strike.action, scope: 'admin.strikes.actions', name: content_tag(:span, appeal.strike.account.username, class: 'username'), target: content_tag(:span, appeal.account.acct, class: 'target')).html_safe
+      .log-entry__timestamp
+        %time.formatted{ datetime: appeal.strike.created_at.iso8601 }
+          = l(appeal.strike.created_at)
+
+        - if appeal.strike.report_id.present?
+          ·
+          = t('admin.reports.title', id: appeal.strike.report_id)
+        ·
+        - if appeal.approved?
+          %span.positive-hint= t('admin.strikes.appeal_approved')
+        - elsif appeal.rejected?
+          %span.negative-hint= t('admin.strikes.appeal_rejected')
+        - else
+          %span.warning-hint= t('admin.strikes.appeal_pending')
diff --git a/app/views/admin/disputes/appeals/index.html.haml b/app/views/admin/disputes/appeals/index.html.haml
new file mode 100644
index 000000000..dd6a6f403
--- /dev/null
+++ b/app/views/admin/disputes/appeals/index.html.haml
@@ -0,0 +1,22 @@
+- content_for :page_title do
+  = t('admin.disputes.appeals.title')
+
+- content_for :header_tags do
+  = javascript_pack_tag 'admin', async: true, crossorigin: 'anonymous'
+
+.filters
+  .filter-subset
+    %strong= t('admin.tags.review')
+    %ul
+      %li= filter_link_to safe_join([t('admin.accounts.moderation.pending'), "(#{Appeal.pending.count})"], ' '), status: 'pending'
+      %li= filter_link_to t('admin.trends.approved'), status: 'approved'
+      %li= filter_link_to t('admin.trends.rejected'), status: 'rejected'
+
+- if @appeals.empty?
+  %div.muted-hint.center-text
+    = t 'admin.disputes.appeals.empty'
+- else
+  .announcements-list
+    = render partial: 'appeal', collection: @appeals
+
+= paginate @appeals
diff --git a/app/views/admin/report_notes/_report_note.html.haml b/app/views/admin/report_notes/_report_note.html.haml
index 428b6cf59..f9d57c2ae 100644
--- a/app/views/admin/report_notes/_report_note.html.haml
+++ b/app/views/admin/report_notes/_report_note.html.haml
@@ -3,7 +3,7 @@
 
   .report-notes__item__header
     %span.username
-      = link_to display_name(report_note.account), admin_account_path(report_note.account_id)
+      = link_to report_note.account.username, 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))
diff --git a/app/views/admin/reports/show.html.haml b/app/views/admin/reports/show.html.haml
index 018a0c54a..abcbec949 100644
--- a/app/views/admin/reports/show.html.haml
+++ b/app/views/admin/reports/show.html.haml
@@ -53,7 +53,7 @@
         .report-header__details__item__header
           %strong= t('admin.accounts.strikes')
         .report-header__details__item__content
-          = @report.target_account.strikes.count
+          = @report.target_account.previous_strikes_count
 
   .report-header__details
     .report-header__details__item
diff --git a/app/views/admin_mailer/new_appeal.text.erb b/app/views/admin_mailer/new_appeal.text.erb
new file mode 100644
index 000000000..db4529eb7
--- /dev/null
+++ b/app/views/admin_mailer/new_appeal.text.erb
@@ -0,0 +1,9 @@
+<%= raw t('application_mailer.salutation', name: display_name(@me)) %>
+
+<%= raw t('admin_mailer.new_appeal.body', target: @appeal.account.username, action_taken_by: @appeal.strike.account.username, date: l(@appeal.strike.created_at), type: t(@appeal.strike.action, scope: 'admin_mailer.new_appeal.actions')) %>
+
+> <%= raw word_wrap(@appeal.text, break_sequence: "\n> ") %>
+
+<%= raw t('admin_mailer.new_appeal.next_steps') %>
+
+<%= raw t('application_mailer.view')%> <%= disputes_strike_url(@appeal.strike) %>
diff --git a/app/views/auth/registrations/_account_warning.html.haml b/app/views/auth/registrations/_account_warning.html.haml
new file mode 100644
index 000000000..40e7e1296
--- /dev/null
+++ b/app/views/auth/registrations/_account_warning.html.haml
@@ -0,0 +1,20 @@
+= link_to disputes_strike_path(account_warning), class: 'log-entry' do
+  .log-entry__header
+    .log-entry__avatar
+      .indicator-icon{ class: account_warning.overruled? ? 'success' : 'failure' }
+        = fa_icon 'warning'
+    .log-entry__content
+      .log-entry__title
+        = t('disputes.strikes.title', action: t(account_warning.action, scope: 'disputes.strikes.title_actions'), date: l(account_warning.created_at.to_date))
+      .log-entry__timestamp
+        %time.formatted{ datetime: account_warning.created_at.iso8601 }= l(account_warning.created_at)
+
+        - if account_warning.overruled?
+          ·
+          %span.positive-hint= t('disputes.strikes.your_appeal_approved')
+        - elsif account_warning.appeal&.pending?
+          ·
+          %span.warning-hint= t('disputes.strikes.your_appeal_pending')
+        - elsif account_warning.appeal&.rejected?
+          ·
+          %span.negative-hint= t('disputes.strikes.your_appeal_rejected')
diff --git a/app/views/auth/registrations/_status.html.haml b/app/views/auth/registrations/_status.html.haml
index 47112dae0..3546510b2 100644
--- a/app/views/auth/registrations/_status.html.haml
+++ b/app/views/auth/registrations/_status.html.haml
@@ -1,22 +1,17 @@
+- if !@user.confirmed?
+  .flash-message.warning
+    = t('auth.status.confirming')
+    = link_to t('auth.didnt_get_confirmation'), new_user_confirmation_path
+- elsif !@user.approved?
+  .flash-message.warning
+    = t('auth.status.pending')
+- elsif @user.account.moved_to_account_id.present?
+  .flash-message.warning
+    = t('auth.status.redirecting_to', acct: @user.account.moved_to_account.acct)
+    = link_to t('migrations.cancel'), settings_migration_path
+
 %h3= t('auth.status.account_status')
 
-.simple_form
-  %p.hint
-    - if @user.account.suspended?
-      %span.negative-hint= t('user_mailer.warning.explanation.suspend')
-    - elsif @user.disabled?
-      %span.negative-hint= t('user_mailer.warning.explanation.disable')
-    - elsif @user.account.silenced?
-      %span.warning-hint= t('user_mailer.warning.explanation.silence')
-    - elsif !@user.confirmed?
-      %span.warning-hint= t('auth.status.confirming')
-      = link_to t('auth.didnt_get_confirmation'), new_user_confirmation_path
-    - elsif !@user.approved?
-      %span.warning-hint= t('auth.status.pending')
-    - elsif @user.account.moved_to_account_id.present?
-      %span.positive-hint= t('auth.status.redirecting_to', acct: @user.account.moved_to_account.acct)
-      = link_to t('migrations.cancel'), settings_migration_path
-    - else
-      %span.positive-hint= t('auth.status.functional')
+= render partial: 'account_warning', collection: @strikes
 
 %hr.spacer/
diff --git a/app/views/disputes/strikes/show.html.haml b/app/views/disputes/strikes/show.html.haml
new file mode 100644
index 000000000..3dcb19016
--- /dev/null
+++ b/app/views/disputes/strikes/show.html.haml
@@ -0,0 +1,127 @@
+- content_for :page_title do
+  = t('disputes.strikes.title', action: t(@strike.action, scope: 'disputes.strikes.title_actions'), date: l(@strike.created_at.to_date))
+
+- content_for :heading_actions do
+  - if @appeal.persisted?
+    = link_to t('admin.accounts.approve'), approve_admin_disputes_appeal_path(@appeal), method: :post, class: 'button' if can?(:approve, @appeal)
+    = link_to t('admin.accounts.reject'), reject_admin_disputes_appeal_path(@appeal), method: :post, class: 'button button--destructive' if can?(:reject, @appeal)
+
+- if @strike.overruled?
+  %p.hint
+    %span.positive-hint
+      = fa_icon 'check'
+      = ' '
+      = t 'disputes.strikes.appeal_approved'
+- elsif @appeal.persisted? && @appeal.rejected?
+  %p.hint
+    %span.negative-hint
+      = fa_icon 'times'
+      = ' '
+      = t 'disputes.strikes.appeal_rejected'
+
+.report-header
+  .report-header__card
+    .strike-card
+      - unless @strike.none_action?
+        %p= t "user_mailer.warning.explanation.#{@strike.action}"
+
+      - unless @strike.text.blank?
+        = Formatter.instance.linkify(@strike.text)
+
+      - if @strike.report && !@strike.report.other?
+        %p
+          %strong= t('user_mailer.warning.reason')
+          = t("user_mailer.warning.categories.#{@strike.report.category}")
+
+        - if @strike.report.violation? && @strike.report.rule_ids.present?
+          %ul.rules-list
+            - @strike.report.rules.each do |rule|
+              %li= rule.text
+
+      - if @strike.status_ids.present? && !@strike.status_ids.empty?
+        %p
+          %strong= t('user_mailer.warning.statuses')
+
+        .strike-card__statuses-list
+          - status_map = @strike.statuses.includes(:application, :media_attachments).index_by(&:id)
+
+          - @strike.status_ids.each do |status_id|
+            .strike-card__statuses-list__item
+              - if (status = status_map[status_id.to_i])
+                .one-liner
+                  = link_to short_account_status_url(@strike.target_account, status_id), class: 'emojify' do
+                    = one_line_preview(status)
+
+                    - status.media_attachments.each do |media_attachment|
+                      %abbr{ title: media_attachment.description }
+                        = fa_icon 'link'
+                        = media_attachment.file_file_name
+                .strike-card__statuses-list__item__meta
+                  %time.formatted{ datetime: status.created_at.iso8601, title: l(status.created_at) }= l(status.created_at)
+                  ·
+                  = status.application.name
+              - else
+                .one-liner= t('disputes.strikes.status', id: status_id)
+                .strike-card__statuses-list__item__meta
+                  = t('disputes.strikes.status_removed')
+
+  .report-header__details
+    .report-header__details__item
+      .report-header__details__item__header
+        %strong= t('disputes.strikes.created_at')
+      .report-header__details__item__content
+        %time.formatted{ datetime: @strike.created_at.iso8601, title: l(@strike.created_at) }= l(@strike.created_at)
+    .report-header__details__item
+      .report-header__details__item__header
+        %strong= t('disputes.strikes.recipient')
+      .report-header__details__item__content
+        = admin_account_link_to @strike.target_account, path: can?(:show, @strike.target_account) ? admin_account_path(@strike.target_account_id) : ActivityPub::TagManager.instance.url_for(@strike.target_account)
+    .report-header__details__item
+      .report-header__details__item__header
+        %strong= t('disputes.strikes.action_taken')
+      .report-header__details__item__content
+        - if @strike.overruled?
+          %del= t(@strike.action, scope: 'user_mailer.warning.title')
+        - else
+          = t(@strike.action, scope: 'user_mailer.warning.title')
+    - if @strike.report && can?(:show, @strike.report)
+      .report-header__details__item
+        .report-header__details__item__header
+          %strong= t('disputes.strikes.associated_report')
+        .report-header__details__item__content
+          = link_to t('admin.reports.report', id: @strike.report.id), admin_report_path(@strike.report)
+    - if @appeal.persisted?
+      .report-header__details__item
+        .report-header__details__item__header
+          %strong= t('disputes.strikes.appeal_submitted_at')
+        .report-header__details__item__content
+          %time.formatted{ datetime: @appeal.created_at.iso8601, title: l(@appeal.created_at) }= l(@appeal.created_at)
+%hr.spacer/
+
+- if @appeal.persisted?
+  %h3= t('disputes.strikes.appeal')
+
+  .report-notes
+    .report-notes__item
+      = image_tag @appeal.account.avatar.url, class: 'report-notes__item__avatar'
+
+      .report-notes__item__header
+        %span.username
+          = link_to @appeal.account.username, can?(:show, @appeal.account) ? admin_account_path(@appeal.account_id) : short_account_url(@appeal.account)
+        %time{ datetime: @appeal.created_at.iso8601, title: l(@appeal.created_at) }
+          - if @appeal.created_at.today?
+            = t('admin.report_notes.today_at', time: l(@appeal.created_at, format: :time))
+          - else
+            = l @appeal.created_at.to_date
+
+      .report-notes__item__content
+        = simple_format(h(@appeal.text))
+- elsif can?(:appeal, @strike)
+  %h3= t('disputes.strikes.appeals.submit')
+
+  = simple_form_for(@appeal, url: disputes_strike_appeal_path(@strike)) do |f|
+    .fields-group
+      = f.input :text, wrapper: :with_label, input_html: { maxlength: 500 }
+
+    .actions
+      = f.button :button, t('disputes.strikes.appeals.submit'), type: :submit
diff --git a/app/views/layouts/public.html.haml b/app/views/layouts/public.html.haml
index 61198171d..1a789cef8 100644
--- a/app/views/layouts/public.html.haml
+++ b/app/views/layouts/public.html.haml
@@ -53,5 +53,9 @@
             %ul
               %li= link_to t('about.source_code'), Mastodon::Version.source_url
               %li= link_to t('about.apps'), 'https://joinmastodon.org/apps'
+        .legal-xs
+          = link_to "v#{Mastodon::Version.to_s}", Mastodon::Version.source_url
+          ·
+          = link_to t('about.privacy_policy'), terms_path
 
 = render template: 'layouts/application'
diff --git a/app/views/settings/preferences/notifications/show.html.haml b/app/views/settings/preferences/notifications/show.html.haml
index d7cc1ed5d..223e5d740 100644
--- a/app/views/settings/preferences/notifications/show.html.haml
+++ b/app/views/settings/preferences/notifications/show.html.haml
@@ -21,6 +21,7 @@
 
       - if current_user.staff?
         = ff.input :report, as: :boolean, wrapper: :with_label
+        = ff.input :appeal, as: :boolean, wrapper: :with_label
         = ff.input :pending_account, as: :boolean, wrapper: :with_label
         = ff.input :trending_tag, as: :boolean, wrapper: :with_label
 
diff --git a/app/views/statuses/_detailed_status.html.haml b/app/views/statuses/_detailed_status.html.haml
index cd5ed52af..1922f53ce 100644
--- a/app/views/statuses/_detailed_status.html.haml
+++ b/app/views/statuses/_detailed_status.html.haml
@@ -49,7 +49,7 @@
     %span.detailed-status__visibility-icon
       = visibility_icon status
     ·
-    - if status.application && @account.user&.setting_show_application
+    - if status.application && status.account.user&.setting_show_application
       - if status.application.website.blank?
         %strong.detailed-status__application= status.application.name
       - else
diff --git a/app/views/user_mailer/appeal_approved.html.haml b/app/views/user_mailer/appeal_approved.html.haml
new file mode 100644
index 000000000..962cab2e2
--- /dev/null
+++ b/app/views/user_mailer/appeal_approved.html.haml
@@ -0,0 +1,59 @@
+%table.email-table{ cellspacing: 0, cellpadding: 0 }
+  %tbody
+    %tr
+      %td.email-body
+        .email-container
+          %table.content-section{ cellspacing: 0, cellpadding: 0 }
+            %tbody
+              %tr
+                %td.content-cell.hero
+                  .email-row
+                    .col-6
+                      %table.column{ cellspacing: 0, cellpadding: 0 }
+                        %tbody
+                          %tr
+                            %td.column-cell.text-center.padded
+                              %table.hero-icon{ align: 'center', cellspacing: 0, cellpadding: 0 }
+                                %tbody
+                                  %tr
+                                    %td
+                                      = image_tag full_pack_url('media/images/mailer/icon_done.png'), alt: ''
+
+                              %h1= t 'user_mailer.appeal_approved.title'
+
+%table.email-table{ cellspacing: 0, cellpadding: 0 }
+  %tbody
+    %tr
+      %td.email-body
+        .email-container
+          %table.content-section{ cellspacing: 0, cellpadding: 0 }
+            %tbody
+              %tr
+                %td.content-cell.content-start
+                  .email-row
+                    .col-6
+                      %table.column{ cellspacing: 0, cellpadding: 0 }
+                        %tbody
+                          %tr
+                            %td.column-cell.text-center
+                              %p= t 'user_mailer.appeal_approved.explanation', appeal_date: l(@appeal.created_at), strike_date: l(@appeal.strike.created_at)
+
+%table.email-table{ cellspacing: 0, cellpadding: 0 }
+  %tbody
+    %tr
+      %td.email-body
+        .email-container
+          %table.content-section{ cellspacing: 0, cellpadding: 0 }
+            %tbody
+              %tr
+                %td.content-cell
+                  %table.column{ cellspacing: 0, cellpadding: 0 }
+                    %tbody
+                      %tr
+                        %td.column-cell.button-cell
+                          %table.button{ align: 'center', cellspacing: 0, cellpadding: 0 }
+                            %tbody
+                              %tr
+                                %td.button-primary
+                                  = link_to root_url do
+                                    %span= t 'user_mailer.appeal_approved.action'
diff --git a/app/views/user_mailer/appeal_approved.text.erb b/app/views/user_mailer/appeal_approved.text.erb
new file mode 100644
index 000000000..290fa24c3
--- /dev/null
+++ b/app/views/user_mailer/appeal_approved.text.erb
@@ -0,0 +1,7 @@
+<%= t 'user_mailer.appeal_approved.title' %>
+
+===
+
+<%= t 'user_mailer.appeal_approved.explanation', appeal_date: l(@appeal.created_at), strike_date: l(@appeal.strike.created_at) %>
+
+=> <%= root_url %>
diff --git a/app/views/user_mailer/appeal_rejected.html.haml b/app/views/user_mailer/appeal_rejected.html.haml
new file mode 100644
index 000000000..75cd9d023
--- /dev/null
+++ b/app/views/user_mailer/appeal_rejected.html.haml
@@ -0,0 +1,59 @@
+%table.email-table{ cellspacing: 0, cellpadding: 0 }
+  %tbody
+    %tr
+      %td.email-body
+        .email-container
+          %table.content-section{ cellspacing: 0, cellpadding: 0 }
+            %tbody
+              %tr
+                %td.content-cell.hero
+                  .email-row
+                    .col-6
+                      %table.column{ cellspacing: 0, cellpadding: 0 }
+                        %tbody
+                          %tr
+                            %td.column-cell.text-center.padded
+                              %table.hero-icon{ align: 'center', cellspacing: 0, cellpadding: 0 }
+                                %tbody
+                                  %tr
+                                    %td
+                                      = image_tag full_pack_url('media/images/mailer/icon_warning.png'), alt: ''
+
+                              %h1= t 'user_mailer.appeal_rejected.title'
+
+%table.email-table{ cellspacing: 0, cellpadding: 0 }
+  %tbody
+    %tr
+      %td.email-body
+        .email-container
+          %table.content-section{ cellspacing: 0, cellpadding: 0 }
+            %tbody
+              %tr
+                %td.content-cell.content-start
+                  .email-row
+                    .col-6
+                      %table.column{ cellspacing: 0, cellpadding: 0 }
+                        %tbody
+                          %tr
+                            %td.column-cell.text-center
+                              %p= t 'user_mailer.appeal_rejected.explanation', appeal_date: l(@appeal.created_at), strike_date: l(@appeal.strike.created_at)
+
+%table.email-table{ cellspacing: 0, cellpadding: 0 }
+  %tbody
+    %tr
+      %td.email-body
+        .email-container
+          %table.content-section{ cellspacing: 0, cellpadding: 0 }
+            %tbody
+              %tr
+                %td.content-cell
+                  %table.column{ cellspacing: 0, cellpadding: 0 }
+                    %tbody
+                      %tr
+                        %td.column-cell.button-cell
+                          %table.button{ align: 'center', cellspacing: 0, cellpadding: 0 }
+                            %tbody
+                              %tr
+                                %td.button-primary
+                                  = link_to root_url do
+                                    %span= t 'user_mailer.appeal_approved.action'
diff --git a/app/views/user_mailer/appeal_rejected.text.erb b/app/views/user_mailer/appeal_rejected.text.erb
new file mode 100644
index 000000000..f47a76818
--- /dev/null
+++ b/app/views/user_mailer/appeal_rejected.text.erb
@@ -0,0 +1,7 @@
+<%= t 'user_mailer.appeal_rejected.title' %>
+
+===
+
+<%= t 'user_mailer.appeal_rejected.explanation', appeal_date: l(@appeal.created_at), strike_date: l(@appeal.strike.created_at) %>
+
+=> <%= root_url %>
diff --git a/app/views/user_mailer/warning.html.haml b/app/views/user_mailer/warning.html.haml
index bda1fef6c..b308e18f7 100644
--- a/app/views/user_mailer/warning.html.haml
+++ b/app/views/user_mailer/warning.html.haml
@@ -77,8 +77,8 @@
                             %tbody
                               %tr
                                 %td.button-primary
-                                  = link_to about_more_url do
-                                    %span= t 'user_mailer.warning.review_server_policies'
+                                  = link_to disputes_strike_url(@warning) do
+                                    %span= t 'user_mailer.warning.appeal'
 
 %table.email-table{ cellspacing: 0, cellpadding: 0 }
   %tbody
@@ -95,4 +95,4 @@
                         %tbody
                           %tr
                             %td.column-cell.text-center
-                              %p= t 'user_mailer.warning.get_in_touch', instance: @instance
+                              %p= t 'user_mailer.warning.appeal_description', instance: @instance