about summary refs log tree commit diff
path: root/app
diff options
context:
space:
mode:
Diffstat (limited to 'app')
-rw-r--r--app/controllers/admin/change_emails_controller.rb49
-rw-r--r--app/controllers/admin/report_notes_controller.rb17
-rw-r--r--app/controllers/admin/reports_controller.rb20
-rw-r--r--app/helpers/admin/action_logs_helper.rb4
-rw-r--r--app/javascript/mastodon/components/load_gap.js33
-rw-r--r--app/javascript/mastodon/components/status.js2
-rw-r--r--app/javascript/mastodon/components/status_action_bar.js7
-rw-r--r--app/javascript/mastodon/components/status_list.js20
-rw-r--r--app/javascript/mastodon/containers/status_container.js5
-rw-r--r--app/javascript/mastodon/features/notifications/index.js20
-rw-r--r--app/javascript/mastodon/features/status/components/action_bar.js8
-rw-r--r--app/javascript/mastodon/features/status/index.js6
-rw-r--r--app/javascript/mastodon/locales/defaultMessages.json8
-rw-r--r--app/javascript/mastodon/locales/en.json1
-rw-r--r--app/javascript/mastodon/locales/pl.json1
-rw-r--r--app/javascript/mastodon/reducers/compose.js20
-rw-r--r--app/javascript/styles/mastodon/admin.scss35
-rw-r--r--app/javascript/styles/mastodon/components.scss4
-rw-r--r--app/models/account.rb1
-rw-r--r--app/models/admin/action_log.rb5
-rw-r--r--app/models/concerns/status_threading_concern.rb16
-rw-r--r--app/models/custom_emoji.rb4
-rw-r--r--app/models/custom_emoji_filter.rb2
-rw-r--r--app/models/media_attachment.rb5
-rw-r--r--app/models/report.rb46
-rw-r--r--app/models/report_note.rb2
-rw-r--r--app/policies/user_policy.rb4
-rw-r--r--app/services/post_status_service.rb2
-rw-r--r--app/views/admin/accounts/show.html.haml6
-rw-r--r--app/views/admin/change_emails/show.html.haml7
-rw-r--r--app/views/admin/report_notes/_report_note.html.haml12
-rw-r--r--app/views/admin/reports/show.html.haml47
32 files changed, 314 insertions, 105 deletions
diff --git a/app/controllers/admin/change_emails_controller.rb b/app/controllers/admin/change_emails_controller.rb
new file mode 100644
index 000000000..a689d3a53
--- /dev/null
+++ b/app/controllers/admin/change_emails_controller.rb
@@ -0,0 +1,49 @@
+# frozen_string_literal: true
+
+module Admin
+  class ChangeEmailsController < BaseController
+    before_action :set_account
+    before_action :require_local_account!
+
+    def show
+      authorize @user, :change_email?
+    end
+
+    def update
+      authorize @user, :change_email?
+
+      new_email = resource_params.fetch(:unconfirmed_email)
+
+      if new_email != @user.email
+        @user.update!(
+          unconfirmed_email: new_email,
+          # Regenerate the confirmation token:
+          confirmation_token: nil
+        )
+
+        log_action :change_email, @user
+
+        @user.send_confirmation_instructions
+      end
+
+      redirect_to admin_account_path(@account.id), notice: I18n.t('admin.accounts.change_email.changed_msg')
+    end
+
+    private
+
+    def set_account
+      @account = Account.find(params[:account_id])
+      @user = @account.user
+    end
+
+    def require_local_account!
+      redirect_to admin_account_path(@account.id) unless @account.local? && @account.user.present?
+    end
+
+    def resource_params
+      params.require(:user).permit(
+        :unconfirmed_email
+      )
+    end
+  end
+end
diff --git a/app/controllers/admin/report_notes_controller.rb b/app/controllers/admin/report_notes_controller.rb
index ef8c0f469..bcb3f2026 100644
--- a/app/controllers/admin/report_notes_controller.rb
+++ b/app/controllers/admin/report_notes_controller.rb
@@ -8,19 +8,26 @@ module Admin
       authorize ReportNote, :create?
 
       @report_note = current_account.report_notes.new(resource_params)
+      @report = @report_note.report
 
       if @report_note.save
         if params[:create_and_resolve]
-          @report_note.report.update!(action_taken: true, action_taken_by_account_id: current_account.id)
-          log_action :resolve, @report_note.report
+          @report.resolve!(current_account)
+          log_action :resolve, @report
 
           redirect_to admin_reports_path, notice: I18n.t('admin.reports.resolved_msg')
-        else
-          redirect_to admin_report_path(@report_note.report_id), notice: I18n.t('admin.report_notes.created_msg')
+          return
         end
+
+        if 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')
       else
-        @report       = @report_note.report
         @report_notes = @report.notes.latest
+        @report_history = @report.history
         @form = Form::StatusBatch.new
 
         render template: 'admin/reports/show'
diff --git a/app/controllers/admin/reports_controller.rb b/app/controllers/admin/reports_controller.rb
index fc3785e3b..a4ae9507d 100644
--- a/app/controllers/admin/reports_controller.rb
+++ b/app/controllers/admin/reports_controller.rb
@@ -13,6 +13,7 @@ module Admin
       authorize @report, :show?
       @report_note = @report.notes.new
       @report_notes = @report.notes.latest
+      @report_history = @report.history
       @form = Form::StatusBatch.new
     end
 
@@ -38,36 +39,33 @@ module Admin
         @report.update!(assigned_account_id: nil)
         log_action :unassigned, @report
       when 'reopen'
-        @report.update!(action_taken: false, action_taken_by_account_id: nil)
+        @report.unresolve!
         log_action :reopen, @report
       when 'resolve'
-        @report.update!(action_taken_by_current_attributes)
+        @report.resolve!(current_account)
         log_action :resolve, @report
       when 'suspend'
         Admin::SuspensionWorker.perform_async(@report.target_account.id)
+
         log_action :resolve, @report
         log_action :suspend, @report.target_account
+
         resolve_all_target_account_reports
-        @report.reload
       when 'silence'
         @report.target_account.update!(silenced: true)
+
         log_action :resolve, @report
         log_action :silence, @report.target_account
+
         resolve_all_target_account_reports
-        @report.reload
       else
         raise ActiveRecord::RecordNotFound
       end
-    end
-
-    def action_taken_by_current_attributes
-      { action_taken: true, action_taken_by_account_id: current_account.id }
+      @report.reload
     end
 
     def resolve_all_target_account_reports
-      unresolved_reports_for_target_account.update_all(
-        action_taken_by_current_attributes
-      )
+      unresolved_reports_for_target_account.update_all(action_taken: true, action_taken_by_account_id: current_account.id)
     end
 
     def unresolved_reports_for_target_account
diff --git a/app/helpers/admin/action_logs_helper.rb b/app/helpers/admin/action_logs_helper.rb
index 7c26c0b05..4c663211e 100644
--- a/app/helpers/admin/action_logs_helper.rb
+++ b/app/helpers/admin/action_logs_helper.rb
@@ -45,6 +45,8 @@ module Admin::ActionLogsHelper
       log.recorded_changes.slice('domain', 'visible_in_picker')
     elsif log.target_type == 'User' && [:promote, :demote].include?(log.action)
       log.recorded_changes.slice('moderator', 'admin')
+    elsif log.target_type == 'User' && [:change_email].include?(log.action)
+      log.recorded_changes.slice('email', 'unconfirmed_email')
     elsif log.target_type == 'DomainBlock'
       log.recorded_changes.slice('severity', 'reject_media')
     elsif log.target_type == 'Status' && log.action == :update
@@ -84,7 +86,7 @@ module Admin::ActionLogsHelper
       'positive'
     when :create
       opposite_verbs?(log) ? 'negative' : 'positive'
-    when :update, :reset_password, :disable_2fa, :memorialize
+    when :update, :reset_password, :disable_2fa, :memorialize, :change_email
       'neutral'
     when :demote, :silence, :disable, :suspend, :remove_avatar, :reopen
       'negative'
diff --git a/app/javascript/mastodon/components/load_gap.js b/app/javascript/mastodon/components/load_gap.js
new file mode 100644
index 000000000..012303ae1
--- /dev/null
+++ b/app/javascript/mastodon/components/load_gap.js
@@ -0,0 +1,33 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { injectIntl, defineMessages } from 'react-intl';
+
+const messages = defineMessages({
+  load_more: { id: 'status.load_more', defaultMessage: 'Load more' },
+});
+
+@injectIntl
+export default class LoadGap extends React.PureComponent {
+
+  static propTypes = {
+    disabled: PropTypes.bool,
+    maxId: PropTypes.string,
+    onClick: PropTypes.func.isRequired,
+    intl: PropTypes.object.isRequired,
+  };
+
+  handleClick = () => {
+    this.props.onClick(this.props.maxId);
+  }
+
+  render () {
+    const { disabled, intl } = this.props;
+
+    return (
+      <button className='load-more load-gap' disabled={disabled} onClick={this.handleClick} aria-label={intl.formatMessage(messages.load_more)}>
+        <i className='fa fa-ellipsis-h' />
+      </button>
+    );
+  }
+
+}
diff --git a/app/javascript/mastodon/components/status.js b/app/javascript/mastodon/components/status.js
index a918a94f8..6129b3f1e 100644
--- a/app/javascript/mastodon/components/status.js
+++ b/app/javascript/mastodon/components/status.js
@@ -31,6 +31,8 @@ export default class Status extends ImmutablePureComponent {
     onFavourite: PropTypes.func,
     onReblog: PropTypes.func,
     onDelete: PropTypes.func,
+    onDirect: PropTypes.func,
+    onMention: PropTypes.func,
     onPin: PropTypes.func,
     onOpenMedia: PropTypes.func,
     onOpenVideo: PropTypes.func,
diff --git a/app/javascript/mastodon/components/status_action_bar.js b/app/javascript/mastodon/components/status_action_bar.js
index e036dc1da..10f34b0c7 100644
--- a/app/javascript/mastodon/components/status_action_bar.js
+++ b/app/javascript/mastodon/components/status_action_bar.js
@@ -9,6 +9,7 @@ import { me } from '../initial_state';
 
 const messages = defineMessages({
   delete: { id: 'status.delete', defaultMessage: 'Delete' },
+  direct: { id: 'status.direct', defaultMessage: 'Direct message @{name}' },
   mention: { id: 'status.mention', defaultMessage: 'Mention @{name}' },
   mute: { id: 'account.mute', defaultMessage: 'Mute @{name}' },
   block: { id: 'account.block', defaultMessage: 'Block @{name}' },
@@ -41,6 +42,7 @@ export default class StatusActionBar extends ImmutablePureComponent {
     onFavourite: PropTypes.func,
     onReblog: PropTypes.func,
     onDelete: PropTypes.func,
+    onDirect: PropTypes.func,
     onMention: PropTypes.func,
     onMute: PropTypes.func,
     onBlock: PropTypes.func,
@@ -92,6 +94,10 @@ export default class StatusActionBar extends ImmutablePureComponent {
     this.props.onMention(this.props.status.get('account'), this.context.router.history);
   }
 
+  handleDirectClick = () => {
+    this.props.onDirect(this.props.status.get('account'), this.context.router.history);
+  }
+
   handleMuteClick = () => {
     this.props.onMute(this.props.status.get('account'));
   }
@@ -149,6 +155,7 @@ export default class StatusActionBar extends ImmutablePureComponent {
       menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick });
     } else {
       menu.push({ text: intl.formatMessage(messages.mention, { name: status.getIn(['account', 'username']) }), action: this.handleMentionClick });
+      menu.push({ text: intl.formatMessage(messages.direct, { name: status.getIn(['account', 'username']) }), action: this.handleDirectClick });
       menu.push(null);
       menu.push({ text: intl.formatMessage(messages.mute, { name: status.getIn(['account', 'username']) }), action: this.handleMuteClick });
       menu.push({ text: intl.formatMessage(messages.block, { name: status.getIn(['account', 'username']) }), action: this.handleBlockClick });
diff --git a/app/javascript/mastodon/components/status_list.js b/app/javascript/mastodon/components/status_list.js
index 8c2673f30..c98d4564e 100644
--- a/app/javascript/mastodon/components/status_list.js
+++ b/app/javascript/mastodon/components/status_list.js
@@ -4,28 +4,10 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
 import PropTypes from 'prop-types';
 import StatusContainer from '../containers/status_container';
 import ImmutablePureComponent from 'react-immutable-pure-component';
-import LoadMore from './load_more';
+import LoadGap from './load_gap';
 import ScrollableList from './scrollable_list';
 import { FormattedMessage } from 'react-intl';
 
-class LoadGap extends ImmutablePureComponent {
-
-  static propTypes = {
-    disabled: PropTypes.bool,
-    maxId: PropTypes.string,
-    onClick: PropTypes.func.isRequired,
-  };
-
-  handleClick = () => {
-    this.props.onClick(this.props.maxId);
-  }
-
-  render () {
-    return <LoadMore onClick={this.handleClick} disabled={this.props.disabled} />;
-  }
-
-}
-
 export default class StatusList extends ImmutablePureComponent {
 
   static propTypes = {
diff --git a/app/javascript/mastodon/containers/status_container.js b/app/javascript/mastodon/containers/status_container.js
index 4579bd132..f22509edf 100644
--- a/app/javascript/mastodon/containers/status_container.js
+++ b/app/javascript/mastodon/containers/status_container.js
@@ -5,6 +5,7 @@ import { makeGetStatus } from '../selectors';
 import {
   replyCompose,
   mentionCompose,
+  directCompose,
 } from '../actions/compose';
 import {
   reblog,
@@ -102,6 +103,10 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
     }
   },
 
+  onDirect (account, router) {
+    dispatch(directCompose(account, router));
+  },
+
   onMention (account, router) {
     dispatch(mentionCompose(account, router));
   },
diff --git a/app/javascript/mastodon/features/notifications/index.js b/app/javascript/mastodon/features/notifications/index.js
index 9a6fb45c8..94a46b833 100644
--- a/app/javascript/mastodon/features/notifications/index.js
+++ b/app/javascript/mastodon/features/notifications/index.js
@@ -13,7 +13,7 @@ import { createSelector } from 'reselect';
 import { List as ImmutableList } from 'immutable';
 import { debounce } from 'lodash';
 import ScrollableList from '../../components/scrollable_list';
-import LoadMore from '../../components/load_more';
+import LoadGap from '../../components/load_gap';
 
 const messages = defineMessages({
   title: { id: 'column.notifications', defaultMessage: 'Notifications' },
@@ -24,24 +24,6 @@ const getNotifications = createSelector([
   state => state.getIn(['notifications', 'items']),
 ], (excludedTypes, notifications) => notifications.filterNot(item => item !== null && excludedTypes.includes(item.get('type'))));
 
-class LoadGap extends React.PureComponent {
-
-  static propTypes = {
-    disabled: PropTypes.bool,
-    maxId: PropTypes.string,
-    onClick: PropTypes.func.isRequired,
-  };
-
-  handleClick = () => {
-    this.props.onClick(this.props.maxId);
-  }
-
-  render () {
-    return <LoadMore onClick={this.handleClick} disabled={this.props.disabled} />;
-  }
-
-}
-
 const mapStateToProps = state => ({
   notifications: getNotifications(state),
   isLoading: state.getIn(['notifications', 'isLoading'], true),
diff --git a/app/javascript/mastodon/features/status/components/action_bar.js b/app/javascript/mastodon/features/status/components/action_bar.js
index 13cc10c9c..4aa6b08f2 100644
--- a/app/javascript/mastodon/features/status/components/action_bar.js
+++ b/app/javascript/mastodon/features/status/components/action_bar.js
@@ -8,6 +8,7 @@ import { me } from '../../../initial_state';
 
 const messages = defineMessages({
   delete: { id: 'status.delete', defaultMessage: 'Delete' },
+  direct: { id: 'status.direct', defaultMessage: 'Direct message @{name}' },
   mention: { id: 'status.mention', defaultMessage: 'Mention @{name}' },
   reply: { id: 'status.reply', defaultMessage: 'Reply' },
   reblog: { id: 'status.reblog', defaultMessage: 'Boost' },
@@ -37,6 +38,7 @@ export default class ActionBar extends React.PureComponent {
     onReblog: PropTypes.func.isRequired,
     onFavourite: PropTypes.func.isRequired,
     onDelete: PropTypes.func.isRequired,
+    onDirect: PropTypes.func.isRequired,
     onMention: PropTypes.func.isRequired,
     onMute: PropTypes.func,
     onMuteConversation: PropTypes.func,
@@ -63,6 +65,10 @@ export default class ActionBar extends React.PureComponent {
     this.props.onDelete(this.props.status);
   }
 
+  handleDirectClick = () => {
+    this.props.onDirect(this.props.status.get('account'), this.context.router.history);
+  }
+
   handleMentionClick = () => {
     this.props.onMention(this.props.status.get('account'), this.context.router.history);
   }
@@ -108,6 +114,7 @@ export default class ActionBar extends React.PureComponent {
 
     if (publicStatus) {
       menu.push({ text: intl.formatMessage(messages.embed), action: this.handleEmbed });
+      menu.push(null);
     }
 
     if (me === status.getIn(['account', 'id'])) {
@@ -121,6 +128,7 @@ export default class ActionBar extends React.PureComponent {
       menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick });
     } else {
       menu.push({ text: intl.formatMessage(messages.mention, { name: status.getIn(['account', 'username']) }), action: this.handleMentionClick });
+      menu.push({ text: intl.formatMessage(messages.direct, { name: status.getIn(['account', 'username']) }), action: this.handleDirectClick });
       menu.push(null);
       menu.push({ text: intl.formatMessage(messages.mute, { name: status.getIn(['account', 'username']) }), action: this.handleMuteClick });
       menu.push({ text: intl.formatMessage(messages.block, { name: status.getIn(['account', 'username']) }), action: this.handleBlockClick });
diff --git a/app/javascript/mastodon/features/status/index.js b/app/javascript/mastodon/features/status/index.js
index 2f482b292..55eff0823 100644
--- a/app/javascript/mastodon/features/status/index.js
+++ b/app/javascript/mastodon/features/status/index.js
@@ -19,6 +19,7 @@ import {
 import {
   replyCompose,
   mentionCompose,
+  directCompose,
 } from '../../actions/compose';
 import { blockAccount } from '../../actions/accounts';
 import {
@@ -148,6 +149,10 @@ export default class Status extends ImmutablePureComponent {
     }
   }
 
+  handleDirectClick = (account, router) => {
+    this.props.dispatch(directCompose(account, router));
+  }
+
   handleMentionClick = (account, router) => {
     this.props.dispatch(mentionCompose(account, router));
   }
@@ -379,6 +384,7 @@ export default class Status extends ImmutablePureComponent {
                   onFavourite={this.handleFavouriteClick}
                   onReblog={this.handleReblogClick}
                   onDelete={this.handleDeleteClick}
+                  onDirect={this.handleDirectClick}
                   onMention={this.handleMentionClick}
                   onMute={this.handleMuteClick}
                   onMuteConversation={this.handleConversationMuteClick}
diff --git a/app/javascript/mastodon/locales/defaultMessages.json b/app/javascript/mastodon/locales/defaultMessages.json
index 6b50e6f4b..6f81db13e 100644
--- a/app/javascript/mastodon/locales/defaultMessages.json
+++ b/app/javascript/mastodon/locales/defaultMessages.json
@@ -198,6 +198,10 @@
         "id": "status.delete"
       },
       {
+        "defaultMessage": "Direct message @{name}",
+        "id": "status.direct"
+      },
+      {
         "defaultMessage": "Mention @{name}",
         "id": "status.mention"
       },
@@ -1371,6 +1375,10 @@
         "id": "status.delete"
       },
       {
+        "defaultMessage": "Direct message @{name}",
+        "id": "status.direct"
+      },
+      {
         "defaultMessage": "Mention @{name}",
         "id": "status.mention"
       },
diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json
index 330db2568..4802ddfd1 100644
--- a/app/javascript/mastodon/locales/en.json
+++ b/app/javascript/mastodon/locales/en.json
@@ -247,6 +247,7 @@
   "status.block": "Block @{name}",
   "status.cannot_reblog": "This post cannot be boosted",
   "status.delete": "Delete",
+  "status.direct": "Direct message @{name}",
   "status.embed": "Embed",
   "status.favourite": "Favourite",
   "status.load_more": "Load more",
diff --git a/app/javascript/mastodon/locales/pl.json b/app/javascript/mastodon/locales/pl.json
index c0877262f..82b7070b8 100644
--- a/app/javascript/mastodon/locales/pl.json
+++ b/app/javascript/mastodon/locales/pl.json
@@ -276,6 +276,7 @@
   "tabs_bar.home": "Strona główna",
   "tabs_bar.local_timeline": "Lokalne",
   "tabs_bar.notifications": "Powiadomienia",
+  "tabs_bar.search": "Szukaj",
   "ui.beforeunload": "Utracisz tworzony wpis, jeżeli opuścisz Mastodona.",
   "upload_area.title": "Przeciągnij i upuść aby wysłać",
   "upload_button.label": "Dodaj zawartość multimedialną",
diff --git a/app/javascript/mastodon/reducers/compose.js b/app/javascript/mastodon/reducers/compose.js
index 1f4177585..87049ea79 100644
--- a/app/javascript/mastodon/reducers/compose.js
+++ b/app/javascript/mastodon/reducers/compose.js
@@ -259,16 +259,18 @@ export default function compose(state = initialState, action) {
   case COMPOSE_UPLOAD_PROGRESS:
     return state.set('progress', Math.round((action.loaded / action.total) * 100));
   case COMPOSE_MENTION:
-    return state
-      .update('text', text => `${text}@${action.account.get('acct')} `)
-      .set('focusDate', new Date())
-      .set('idempotencyKey', uuid());
+    return state.withMutations(map => {
+      map.update('text', text => [text.trim(), `@${action.account.get('acct')} `].filter((str) => str.length !== 0).join(' '));
+      map.set('focusDate', new Date());
+      map.set('idempotencyKey', uuid());
+    });
   case COMPOSE_DIRECT:
-    return state
-      .update('text', text => `@${action.account.get('acct')} `)
-      .set('privacy', 'direct')
-      .set('focusDate', new Date())
-      .set('idempotencyKey', uuid());
+    return state.withMutations(map => {
+      map.update('text', text => [text.trim(), `@${action.account.get('acct')} `].filter((str) => str.length !== 0).join(' '));
+      map.set('privacy', 'direct');
+      map.set('focusDate', new Date());
+      map.set('idempotencyKey', uuid());
+    });
   case COMPOSE_SUGGESTIONS_CLEAR:
     return state.update('suggestions', ImmutableList(), list => list.clear()).set('suggestion_token', null);
   case COMPOSE_SUGGESTIONS_READY:
diff --git a/app/javascript/styles/mastodon/admin.scss b/app/javascript/styles/mastodon/admin.scss
index e6bd0c717..6bd659030 100644
--- a/app/javascript/styles/mastodon/admin.scss
+++ b/app/javascript/styles/mastodon/admin.scss
@@ -145,6 +145,11 @@
       border: 0;
       background: transparent;
       border-bottom: 1px solid $ui-base-color;
+
+      &.section-break {
+        margin: 30px 0;
+        border-bottom: 2px solid $ui-base-lighter-color;
+      }
     }
 
     .muted-hint {
@@ -330,6 +335,36 @@
   }
 }
 
+.report-note__comment {
+  margin-bottom: 20px;
+}
+
+.report-note__form {
+  margin-bottom: 20px;
+
+  .report-note__textarea {
+    box-sizing: border-box;
+    border: 0;
+    padding: 7px 4px;
+    margin-bottom: 10px;
+    font-size: 16px;
+    color: $ui-base-color;
+    display: block;
+    width: 100%;
+    outline: 0;
+    font-family: inherit;
+    resize: vertical;
+  }
+
+  .report-note__buttons {
+    text-align: right;
+  }
+
+  .report-note__button {
+    margin: 0 0 5px 5px;
+  }
+}
+
 .batch-form-box {
   display: flex;
   flex-wrap: wrap;
diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss
index c82a760c4..94e3089f8 100644
--- a/app/javascript/styles/mastodon/components.scss
+++ b/app/javascript/styles/mastodon/components.scss
@@ -2455,6 +2455,10 @@ a.status-card {
   }
 }
 
+.load-gap {
+  border-bottom: 1px solid lighten($ui-base-color, 8%);
+}
+
 .regeneration-indicator {
   text-align: center;
   font-size: 16px;
diff --git a/app/models/account.rb b/app/models/account.rb
index 79d5bf742..31f3d5253 100644
--- a/app/models/account.rb
+++ b/app/models/account.rb
@@ -126,6 +126,7 @@ class Account < ApplicationRecord
   scope :matches_domain, ->(value) { where(arel_table[:domain].matches("%#{value}%")) }
 
   delegate :email,
+           :unconfirmed_email,
            :current_sign_in_ip,
            :current_sign_in_at,
            :confirmed?,
diff --git a/app/models/admin/action_log.rb b/app/models/admin/action_log.rb
index c437c8ee8..81f278e07 100644
--- a/app/models/admin/action_log.rb
+++ b/app/models/admin/action_log.rb
@@ -35,6 +35,11 @@ class Admin::ActionLog < ApplicationRecord
       self.recorded_changes = target.attributes
     when :update, :promote, :demote
       self.recorded_changes = target.previous_changes
+    when :change_email
+      self.recorded_changes = ActiveSupport::HashWithIndifferentAccess.new(
+        email: [target.email, nil],
+        unconfirmed_email: [nil, target.unconfirmed_email]
+      )
     end
   end
 end
diff --git a/app/models/concerns/status_threading_concern.rb b/app/models/concerns/status_threading_concern.rb
index 65f8e112e..b539ba10e 100644
--- a/app/models/concerns/status_threading_concern.rb
+++ b/app/models/concerns/status_threading_concern.rb
@@ -15,16 +15,12 @@ module StatusThreadingConcern
 
   def ancestor_ids
     Rails.cache.fetch("ancestors:#{id}") do
-      ancestors_without_self.pluck(:id)
+      ancestor_statuses.pluck(:id)
     end
   end
 
-  def ancestors_without_self
-    ancestor_statuses - [self]
-  end
-
   def ancestor_statuses
-    Status.find_by_sql([<<-SQL.squish, id: id])
+    Status.find_by_sql([<<-SQL.squish, id: in_reply_to_id])
       WITH RECURSIVE search_tree(id, in_reply_to_id, path)
       AS (
         SELECT id, in_reply_to_id, ARRAY[id]
@@ -43,11 +39,7 @@ module StatusThreadingConcern
   end
 
   def descendant_ids
-    descendants_without_self.pluck(:id)
-  end
-
-  def descendants_without_self
-    descendant_statuses - [self]
+    descendant_statuses.pluck(:id)
   end
 
   def descendant_statuses
@@ -56,7 +48,7 @@ module StatusThreadingConcern
       AS (
         SELECT id, ARRAY[id]
         FROM statuses
-        WHERE id = :id
+        WHERE in_reply_to_id = :id
         UNION ALL
         SELECT statuses.id, path || statuses.id
         FROM search_tree
diff --git a/app/models/custom_emoji.rb b/app/models/custom_emoji.rb
index 476178e86..1ec21d1a0 100644
--- a/app/models/custom_emoji.rb
+++ b/app/models/custom_emoji.rb
@@ -58,5 +58,9 @@ class CustomEmoji < ApplicationRecord
 
       where(shortcode: shortcodes, domain: domain, disabled: false)
     end
+
+    def search(shortcode)
+      where('"custom_emojis"."shortcode" ILIKE ?', "%#{shortcode}%")
+    end
   end
 end
diff --git a/app/models/custom_emoji_filter.rb b/app/models/custom_emoji_filter.rb
index 2c09ed65c..c4bc310bb 100644
--- a/app/models/custom_emoji_filter.rb
+++ b/app/models/custom_emoji_filter.rb
@@ -28,7 +28,7 @@ class CustomEmojiFilter
     when 'by_domain'
       CustomEmoji.where(domain: value)
     when 'shortcode'
-      CustomEmoji.where(shortcode: value)
+      CustomEmoji.search(value)
     else
       raise "Unknown filter: #{key}"
     end
diff --git a/app/models/media_attachment.rb b/app/models/media_attachment.rb
index 49c24ac01..3b16944ce 100644
--- a/app/models/media_attachment.rb
+++ b/app/models/media_attachment.rb
@@ -150,8 +150,9 @@ class MediaAttachment < ApplicationRecord
                 'pix_fmt'  => 'yuv420p',
                 'vf'       => 'scale=\'trunc(iw/2)*2:trunc(ih/2)*2\'',
                 'vsync'    => 'cfr',
-                'b:v'      => '1300K',
-                'maxrate'  => '500K',
+                'c:v'      => 'h264',
+                'b:v'      => '500K',
+                'maxrate'  => '1300K',
                 'bufsize'  => '1300K',
                 'crf'      => 18,
               },
diff --git a/app/models/report.rb b/app/models/report.rb
index f5b37cb6d..5b90c7bce 100644
--- a/app/models/report.rb
+++ b/app/models/report.rb
@@ -39,4 +39,50 @@ class Report < ApplicationRecord
   def media_attachments
     MediaAttachment.where(status_id: status_ids)
   end
+
+  def assign_to_self!(current_account)
+    update!(assigned_account_id: current_account.id)
+  end
+
+  def unassign!
+    update!(assigned_account_id: nil)
+  end
+
+  def resolve!(acting_account)
+    update!(action_taken: true, action_taken_by_account_id: acting_account.id)
+  end
+
+  def unresolve!
+    update!(action_taken: false, action_taken_by_account_id: nil)
+  end
+
+  def unresolved?
+    !action_taken?
+  end
+
+  def history
+    time_range = created_at..updated_at
+
+    sql = [
+      Admin::ActionLog.where(
+        target_type: 'Report',
+        target_id: id,
+        created_at: time_range
+      ).unscope(:order),
+
+      Admin::ActionLog.where(
+        target_type: 'Account',
+        target_id: target_account_id,
+        created_at: time_range
+      ).unscope(:order),
+
+      Admin::ActionLog.where(
+        target_type: 'Status',
+        target_id: status_ids,
+        created_at: time_range
+      ).unscope(:order),
+    ].map { |query| "(#{query.to_sql})" }.join(' UNION ALL ')
+
+    Admin::ActionLog.from("(#{sql}) AS admin_action_logs")
+  end
 end
diff --git a/app/models/report_note.rb b/app/models/report_note.rb
index 3d12cf7b6..6d9dec80a 100644
--- a/app/models/report_note.rb
+++ b/app/models/report_note.rb
@@ -13,7 +13,7 @@
 
 class ReportNote < ApplicationRecord
   belongs_to :account
-  belongs_to :report, inverse_of: :notes
+  belongs_to :report, inverse_of: :notes, touch: true
 
   scope :latest, -> { reorder('created_at ASC') }
 
diff --git a/app/policies/user_policy.rb b/app/policies/user_policy.rb
index aae207d06..dabdf707a 100644
--- a/app/policies/user_policy.rb
+++ b/app/policies/user_policy.rb
@@ -5,6 +5,10 @@ class UserPolicy < ApplicationPolicy
     staff? && !record.staff?
   end
 
+  def change_email?
+    staff? && !record.staff?
+  end
+
   def disable_2fa?
     admin? && !record.staff?
   end
diff --git a/app/services/post_status_service.rb b/app/services/post_status_service.rb
index 74b4cba0c..fe03c044c 100644
--- a/app/services/post_status_service.rb
+++ b/app/services/post_status_service.rb
@@ -28,7 +28,7 @@ class PostStatusService < BaseService
       status = account.statuses.create!(text: text,
                                         media_attachments: media || [],
                                         thread: in_reply_to,
-                                        sensitive: options[:sensitive],
+                                        sensitive: (options[:sensitive].nil? ? account.user&.setting_default_sensitive : options[:sensitive]),
                                         spoiler_text: options[:spoiler_text] || '',
                                         visibility: options[:visibility] || account.user&.setting_default_privacy,
                                         language: LanguageDetector.instance.detect(text, account),
diff --git a/app/views/admin/accounts/show.html.haml b/app/views/admin/accounts/show.html.haml
index fecfd6cc8..7312618ee 100644
--- a/app/views/admin/accounts/show.html.haml
+++ b/app/views/admin/accounts/show.html.haml
@@ -36,9 +36,13 @@
           %th= t('admin.accounts.email')
           %td
             = @account.user_email
-
             - if @account.user_confirmed?
               = fa_icon('check')
+            = table_link_to 'edit', t('admin.accounts.change_email.label'), admin_account_change_email_path(@account.id) if can?(:change_email, @account.user)
+        - if @account.user_unconfirmed_email.present?
+          %th= t('admin.accounts.unconfirmed_email')
+          %td
+            = @account.user_unconfirmed_email
         %tr
           %th= t('admin.accounts.login_status')
           %td
diff --git a/app/views/admin/change_emails/show.html.haml b/app/views/admin/change_emails/show.html.haml
new file mode 100644
index 000000000..a661b1ad6
--- /dev/null
+++ b/app/views/admin/change_emails/show.html.haml
@@ -0,0 +1,7 @@
+- content_for :page_title do
+  = t('admin.accounts.change_email.title', username: @account.acct)
+
+= simple_form_for @user, url: admin_account_change_email_path(@account.id) do |f|
+  = f.input :email, wrapper: :with_label, disabled: true, label: t('admin.accounts.change_email.current_email')
+  = f.input :unconfirmed_email, wrapper: :with_label, label: t('admin.accounts.change_email.new_email')
+  = f.button :submit, class: "button", value: t('admin.accounts.change_email.submit')
diff --git a/app/views/admin/report_notes/_report_note.html.haml b/app/views/admin/report_notes/_report_note.html.haml
index 60ac5d0d5..1f621e0d3 100644
--- a/app/views/admin/report_notes/_report_note.html.haml
+++ b/app/views/admin/report_notes/_report_note.html.haml
@@ -1,11 +1,9 @@
-%tr
-  %td
-    %p
-      %strong= report_note.account.acct
-      on
+%li
+  %h4
+    = report_note.account.acct
+    %div{ style: 'float: right' }
       %time.formatted{ datetime: report_note.created_at.iso8601, title: l(report_note.created_at) }
         = 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)
-      %br/
-      %br/
+  %div{ class: 'report-note__comment' }
     = simple_format(h(report_note.content))
diff --git a/app/views/admin/reports/show.html.haml b/app/views/admin/reports/show.html.haml
index 12a52eb33..a0c1ca283 100644
--- a/app/views/admin/reports/show.html.haml
+++ b/app/views/admin/reports/show.html.haml
@@ -2,7 +2,7 @@
   = t('admin.reports.report', id: @report.id)
 
 %div{ style: 'overflow: hidden; margin-bottom: 20px' }
-  - if !@report.action_taken?
+  - if @report.unresolved?
     %div{ style: 'float: right' }
       = link_to t('admin.reports.silence_account'), admin_report_path(@report, outcome: 'silence'), method: :put, class: 'button'
       = link_to t('admin.reports.suspend_account'), admin_report_path(@report, outcome: 'suspend'), method: :put, class: 'button'
@@ -15,21 +15,28 @@
   %table.table.inline-table
     %tbody
       %tr
+        %th= t('admin.reports.created_at')
+        %td{colspan: 2}
+          %time.formatted{ datetime: @report.created_at.iso8601 }
+      %tr
         %th= t('admin.reports.updated_at')
         %td{colspan: 2}
           %time.formatted{ datetime: @report.updated_at.iso8601 }
       %tr
         %th= t('admin.reports.status')
-        %td{colspan: 2}
+        %td
           - if @report.action_taken?
             = t('admin.reports.resolved')
-            = table_link_to 'envelope-open', t('admin.reports.reopen'), admin_report_path(@report, outcome: 'reopen'), method: :put
           - else
             = t('admin.reports.unresolved')
+        %td{style: "text-align: right; overflow: hidden;"}
+          - if @report.action_taken?
+            = table_link_to 'envelope-open', t('admin.reports.reopen'), admin_report_path(@report, outcome: 'reopen'), method: :put
       - if !@report.action_taken_by_account.nil?
         %tr
           %th= t('admin.reports.action_taken_by')
-          %td= @report.action_taken_by_account.acct
+          %td{colspan: 2}
+            = @report.action_taken_by_account.acct
       - else
         %tr
           %th= t('admin.reports.assigned')
@@ -44,6 +51,8 @@
             - if !@report.assigned_account.nil?
               = table_link_to 'trash', t('admin.reports.unassign'), admin_report_path(@report, outcome: 'unassign'), method: :put
 
+%hr{ class: "section-break"}/
+
 .report-accounts
   .report-accounts__item
     %h3= t('admin.reports.reported_account')
@@ -85,22 +94,28 @@
           = link_to admin_report_reported_status_path(@report, status), method: :delete, class: 'icon-button trash-button', title: t('admin.reports.delete'), data: { confirm: t('admin.reports.are_you_sure') }, remote: true do
             = fa_icon 'trash'
 
-%hr/
+%hr{ class: "section-break"}/
 
 %h3= t('admin.reports.notes.label')
 
 - if @report_notes.length > 0
-  .table-wrapper
-    %table.table
-      %thead
-        %tr
-          %th
-      %tbody
-        = render @report_notes
+  %ul
+    = render @report_notes
 
-= simple_form_for @report_note, url: admin_report_notes_path do |f|
+%h4= t('admin.reports.notes.new_label')
+= form_for @report_note, url: admin_report_notes_path, html: { class: 'report-note__form' } do |f|
   = render 'shared/error_messages', object: @report_note
-  = f.input :content
+  = f.text_area :content, placeholder: t('admin.reports.notes.placeholder'), rows: 6, class: 'report-note__textarea'
   = f.hidden_field :report_id
-  = f.button :button, t('admin.reports.notes.create'), type: :submit
-  = f.button :button, t('admin.reports.notes.create_and_resolve'), type: :submit, name: :create_and_resolve
+  %div{ class: 'report-note__buttons' }
+    - if @report.unresolved?
+      = f.submit t('admin.reports.notes.create_and_resolve'), name: :create_and_resolve, class: 'button report-note__button'
+    - else
+      = f.submit t('admin.reports.notes.create_and_unresolve'), name: :create_and_unresolve, class: 'button report-note__button'
+    = f.submit t('admin.reports.notes.create'), class: 'button report-note__button'
+
+- if @report_history.length > 0
+  %h3= t('admin.reports.history')
+
+  %ul
+    = render @report_history