about summary refs log tree commit diff
diff options
context:
space:
mode:
-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
-rw-r--r--config/initializers/rack_attack_logging.rb4
-rw-r--r--config/initializers/sidekiq.rb6
-rw-r--r--config/locales/en.yml20
-rw-r--r--config/locales/pl.yml77
-rw-r--r--config/routes.rb1
-rw-r--r--public/headers/original/missing.pngbin14573 -> 81 bytes
-rw-r--r--spec/controllers/admin/change_email_controller_spec.rb47
-rw-r--r--spec/models/custom_emoji_spec.rb24
40 files changed, 488 insertions, 110 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
diff --git a/config/initializers/rack_attack_logging.rb b/config/initializers/rack_attack_logging.rb
new file mode 100644
index 000000000..2ddbfb99c
--- /dev/null
+++ b/config/initializers/rack_attack_logging.rb
@@ -0,0 +1,4 @@
+ActiveSupport::Notifications.subscribe('rack.attack') do |_name, _start, _finish, _request_id, req|
+  next unless [:throttle, :blacklist].include? req.env['rack.attack.match_type']
+  Rails.logger.info("Rate limit hit (#{req.env['rack.attack.match_type']}): #{req.ip} #{req.request_method} #{req.fullpath}")
+end
diff --git a/config/initializers/sidekiq.rb b/config/initializers/sidekiq.rb
index f875fbd95..05c804100 100644
--- a/config/initializers/sidekiq.rb
+++ b/config/initializers/sidekiq.rb
@@ -1,10 +1,10 @@
 # frozen_string_literal: true
 
-namespace = ENV.fetch('REDIS_NAMESPACE') { nil }
+namespace    = ENV.fetch('REDIS_NAMESPACE') { nil }
 redis_params = { url: ENV['REDIS_URL'] }
 
 if namespace
-  redis_params [:namespace] = namespace
+  redis_params[:namespace] = namespace
 end
 
 Sidekiq.configure_server do |config|
@@ -18,3 +18,5 @@ end
 Sidekiq.configure_client do |config|
   config.redis = redis_params
 end
+
+Sidekiq::Logging.logger.level = ::Logger::const_get(ENV.fetch('RAILS_LOG_LEVEL', 'info').upcase.to_s)
diff --git a/config/locales/en.yml b/config/locales/en.yml
index bde96d28a..65ae9182f 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -63,6 +63,13 @@ en:
       are_you_sure: Are you sure?
       avatar: Avatar
       by_domain: Domain
+      change_email:
+        changed_msg: Account email successfully changed!
+        current_email: Current Email
+        label: Change Email
+        new_email: New Email
+        submit: Change Email
+        title: Change Email for %{username}
       confirm: Confirm
       confirmed: Confirmed
       demote: Demote
@@ -131,6 +138,7 @@ en:
       statuses: Statuses
       subscribe: Subscribe
       title: Accounts
+      unconfirmed_email: Unconfirmed E-mail
       undo_silenced: Undo silence
       undo_suspension: Undo suspension
       unsubscribe: Unsubscribe
@@ -139,6 +147,7 @@ en:
     action_logs:
       actions:
         assigned_to_self_report: "%{name} assigned report %{target} to themselves"
+        change_email_user: "%{name} changed the e-mail address of user %{target}"
         confirm_user: "%{name} confirmed e-mail address of user %{target}"
         create_custom_emoji: "%{name} uploaded new emoji %{target}"
         create_domain_block: "%{name} blocked domain %{target}"
@@ -247,8 +256,8 @@ en:
         title: Filter
       title: Invites
     report_notes:
-      created_msg: Moderation note successfully created!
-      destroyed_msg: Moderation note successfully destroyed!
+      created_msg: Report note successfully created!
+      destroyed_msg: Report note successfully deleted!
     reports:
       action_taken_by: Action taken by
       are_you_sure: Are you sure?
@@ -257,15 +266,20 @@ en:
       comment:
         label: Report Comment
         none: None
+      created_at: Reported
       delete: Delete
+      history: Moderation History
       id: ID
       mark_as_resolved: Mark as resolved
       mark_as_unresolved: Mark as unresolved
       notes:
         create: Add Note
         create_and_resolve: Resolve with Note
+        create_and_unresolve: Reopen with Note
         delete: Delete
-        label: Notes
+        label: Moderator Notes
+        new_label: Add Moderator Note
+        placeholder: Describe what actions have been taken, or any other updates to this report…
       nsfw:
         'false': Unhide media attachments
         'true': Hide media attachments
diff --git a/config/locales/pl.yml b/config/locales/pl.yml
index 53ce7f81b..a71f48758 100644
--- a/config/locales/pl.yml
+++ b/config/locales/pl.yml
@@ -711,6 +711,83 @@ pl:
     reblogged: podbił
     sensitive_content: Wrażliwa zawartość
   terms:
+    body_html: |
+      <h2>Polityka prywatności</h2>
+      <h3 id="collect">Jakie informacje zbieramy?</h3>
+
+      <ul>
+        <li><em>Podstawowe informacje o koncie</em>: Podczas rejestracji na tym serwerze, możesz zostać poproszony o wprowadzenie nazwy użytkownika, adresu e-mail i hasła. Możesz także wprowadzić dodatkowe informacje o profilu, takie jak nazwa wyświetlana i biografia oraz wysłać awatar i obraz nagłówka. Nazwa użytkownika, nazwa wyświetlana, biografia, awatar i obraz nagłówka są zawsze widoczne dla wszystkich.</li>
+        <li><em>Wpisy, śledzenie i inne publiczne informacje</em>: Lista osób które śledzisz jest widoczna publicznie, tak jak lista osób, które Cię śledzą. Jeżeli dodasz wpis, data i czas jego utworzenia i aplikacja, z której go wysłano są przechowywane. Wiadomości mogą zawierać załączniki multimedialne, takie jak zdjęcia i filmy. Publiczne i niewidoczne wpisy są dostępne publicznie. Udostępniony wpis również jest widoczny publicznie. Twoje wpisy są dostarczane obserwującym, co oznacza że jego kopie mogą zostać dostarczone i być przechowywane na innych serwerach. Kiedy usuniesz wpis, przestaje być widoczny również dla osób śledzących Cię. „Podbijanie” i dodanie do ulubionych jest zawsze publiczne.</li>
+        <li><em>Wpisy bezpośrednie i tylko dla śledzących</em>: Wszystkie wpisy są przechowywane i przetwarzane na serwerze. Wpisy przeznaczone tylko dla śledzących są widoczne tylko dla nich i osób wspomnianych we wpisie, a wpisy bezpośrednie tylko dla wspimnianych. W wielu przypadkach oznacza to, że ich kopie są dostarczane i przechowywane na innych serwerach. Staramy się ograniczać zasięg tych wpisów wyłącznie do właściwych odbiorców, ale inne serwery mogą tego nie robić. Ważne jest, aby sprawdzać jakich serwerów używają osoby, które Cię śledzą. Możesz aktywować opcję pozwalającą na ręczne akceptowanie i odrzucanie nowych śledzących. <em>Pamiętaj, że właściciele serwerów mogą zobaczyć te wiadomości</em>, a odbiorcy mogą wykonać zrzut ekranu, skopiować lub udostępniać ten wpis. <em>Nie udostępniaj wrażliwych danych z użyciem Mastodona.</em></li>
+        <li><em>Adresy IP i inne metadane</em>: Kiedy zalogujesz się, przechowujemy adres IP użyty w trakcie logowania wraz z nazwą używanej przeglądarki. Wszystkie aktywne sesje możesz zobaczyć (i wygasić) w ustawieniach. Ostatnio używany adres IP jest przechowywany przez nas do 12 miesięcy. Możemy również przechowywać adresy IP wykorzystywane przy każdym działaniu na serwerze.</li>
+      </ul>
+
+      <hr class="spacer" />
+
+      <h3 id="use">W jakim celu wykorzystujecie informacje?</h3>
+
+      <p>Zebrane informacje mogą zostać użyte w następujące sposoby:</p>
+
+      <ul>
+        <li>Aby dostarczyć podstawową funkcjonalność Mastodona. Możesz wchodzić w interakcje z zawartością tworzoną przez innych tylko gdy jesteś zalogowany. Na przykład, możesz śledzić innych, aby widzieć ich wpisy w dostosowanej osi czasu.</li>
+        <li>Aby wspomóc moderację społeczności, na przykład porównując Twój adres IP ze znanymi, aby rozpoznać próbę obejścia blokady i inne naruszenia.</li>
+        <li>Adres e-mail może zostać wykorzystany, aby wysyłać Ci informacje, powiadomienia o osobach wchodzących w interakcje z tworzoną przez Ciebie zawartością, wysyłających Ci wiadomości, odpowiadać na zgłoszenia i inne żądania lub zapytania.</li>
+      </ul>
+
+      <hr class="spacer" />
+
+      <h3 id="protect">W jaki sposób chronimy Twoje dane?</h3>
+
+      <p>Wykorzystujemy różne zabezpieczenia, aby zapewnić bezpieczeństwo informacji, które wprowadzasz, wysyłasz lub do których uzyskujesz dostęp. Poza tym, sesja przeglądarki oraz ruch pomiędzy aplikacją a API jest zabezpieczany z użyciem SSL, a hasło jest hashowane z użyciem silnego algorytmu. Możesz też aktywować uwierzytelnianie dwustopniowe, aby lepiej zabezpieczyć dostęp do konta.</p>
+
+      <hr class="spacer" />
+
+      <h3 id="data-retention">Jaka jest nasza polityka przechowywania danych?</h3>
+
+      <p>Staramy się:</p>
+
+      <ul>
+        <li>Przechowywać logi zawierające adresy IP używane przy każdym żądaniu do serwera przez nie dłużej niż 90 dni.</li>
+        <li>Przechowywać adresy IP przypisane do użytkowników przez nie dłużej niż 12 miesięcy.</li>
+      </ul>
+
+      <p>Możesz zażądać i pobrać archiwum tworzonej zawartości, wliczając Twoje wpisy, załączniki multimedialne, awatar i zdjęcie nagłówka.</p>
+
+      <p>Możesz nieodwracalnie usunąć konto w każdej chwili.</p>
+
+      <hr class="spacer"/>
+
+      <h3 id="cookies">Czy używany plików cookies?</h3>
+
+      <p>Tak. Pliki cookies są małymi plikami, które strona lub dostawca jej usługi dostarcza na dysk twardy komputera z użyciem przeglądarki internetowej (jeżeli na to pozwoli). Pliki cookies pozwalają na rozpoznanie przeglądarki i – jeśli jesteś zarejestrowany – przypisanie jej do konta.</p>
+
+      <p>Wykorzystujemy pliki cookies, aby przechowywać preferencję użytkowników na przyszłe wizyty.</p>
+
+      <hr class="spacer" />
+
+      <h3 id="disclose">Czy przekazujemy informacje osobom trzecim?</h3>
+
+      <p>Nie sprzedajemy, nie wymieniamy i nie przekazujemy osobom trzecim informacji pozwalających na identyfikację Ciebie. Nie dotyczy to zaufanym dostawcom pomagającym w prowadzeniu lub obsługiwaniu użytkowników, jeżeli zgadzają się, aby nie przekazywać dalej tych informacji. Możemy również udostępnić informacje, jeżeli uważany to za wymagane przez prawo, konieczne do wypełnienia polityki strony, przestrzegania naszych lub cudzych praw, własności i bezpieczeństwa.</p>
+
+      <p>Twoja publiczna zawartość może zostać pobrana przez inne serwery w sieci. Wpisy publiczne i tylko dla śledzących są dostarczane na serwery, na których znajdują się śledzący Cię, a wiadomości bezpośrednie trafiają na serwery adresatów, jeżeli są oni użytkownikami innego serwera.</p>
+
+      <p>Kiedy pozwolisz aplikacji na dostęp do Twojego konta, w zależności od nadanych jej pozwoleń, może uzyskać dostęp do publicznych informacji, listy śledzonych, Twoich list, wszystkich wpisów i ulubionych. Aplikacje nie mogą uzyskać dostępu do Twojego adresu e-mail i hasła.</p>
+
+      <hr class="spacer" />
+
+      <h3 id="coppa">Children's Online Privacy Protection Act Compliance</h3>
+
+      <p>Ta strona, produkty i usługi są przeznaczone dla osób, które ukończyły 13 lat. Jeżeli serwer znajduje się w USA, a nie ukończyłeś 13 roku życia, zgodnie z wymogami COPPA (<a href="https://pl.wikipedia.org/wiki/Children%27s_Online_Privacy_Protection_Act">Prawo o Ochronie Prywatności Dzieci w Internecie</a>), nie używaj tej strony.</p>
+
+      <hr class="spacer" />
+
+      <h3 id="changes">Zmiany w naszej polityce prywatności</h3>
+
+      <p>Jeżeli zdecydujemy się na zmiany w polityce prywatności, pojawią się na tej stronie.</p>
+
+      <p>Dokument jest dostępny na licencji CC-BY-SA. Ostatnio zmodyfikowano go 7 marca 2018, przetłumaczono 9 kwietnia 2018. Tłumaczenie (mimo dołożenia wszelkich starań) może nie być w pełni poprawne.</p>
+
+      <p>Bazowano na <a href="https://github.com/discourse/discourse">polityce prywatności Discourse</a>.</p>
     title: Zasady korzystania i polityka prywatności %{instance}
   time:
     formats:
diff --git a/config/routes.rb b/config/routes.rb
index 6effa01e1..092a14f47 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -160,6 +160,7 @@ Rails.application.routes.draw do
         post :memorialize
       end
 
+      resource :change_email, only: [:show, :update]
       resource :reset, only: [:create]
       resource :silence, only: [:create, :destroy]
       resource :suspension, only: [:create, :destroy]
diff --git a/public/headers/original/missing.png b/public/headers/original/missing.png
index fdc34289d..26b59e75a 100644
--- a/public/headers/original/missing.png
+++ b/public/headers/original/missing.png
Binary files differdiff --git a/spec/controllers/admin/change_email_controller_spec.rb b/spec/controllers/admin/change_email_controller_spec.rb
new file mode 100644
index 000000000..50f94f835
--- /dev/null
+++ b/spec/controllers/admin/change_email_controller_spec.rb
@@ -0,0 +1,47 @@
+require 'rails_helper'
+
+RSpec.describe Admin::ChangeEmailsController, type: :controller do
+  render_views
+
+  let(:admin) { Fabricate(:user, admin: true) }
+
+  before do
+    sign_in admin
+  end
+
+  describe "GET #show" do
+    it "returns http success" do
+      account = Fabricate(:account)
+      user = Fabricate(:user, account: account)
+
+      get :show, params: { account_id: account.id }
+
+      expect(response).to have_http_status(:success)
+    end
+  end
+
+  describe "GET #update" do
+    before do
+      allow(UserMailer).to receive(:confirmation_instructions).and_return(double('email', deliver_later: nil))
+    end
+
+    it "returns http success" do
+      account = Fabricate(:account)
+      user = Fabricate(:user, account: account)
+
+      previous_email = user.email
+
+      post :update, params: { account_id: account.id, user: { unconfirmed_email: 'test@example.com' } }
+
+      user.reload
+
+      expect(user.email).to eq previous_email
+      expect(user.unconfirmed_email).to eq 'test@example.com'
+      expect(user.confirmation_token).not_to be_nil
+
+      expect(UserMailer).to have_received(:confirmation_instructions).with(user, user.confirmation_token, { to: 'test@example.com' })
+
+      expect(response).to redirect_to(admin_account_path(account.id))
+    end
+  end
+end
diff --git a/spec/models/custom_emoji_spec.rb b/spec/models/custom_emoji_spec.rb
index bb150b837..87367df50 100644
--- a/spec/models/custom_emoji_spec.rb
+++ b/spec/models/custom_emoji_spec.rb
@@ -1,6 +1,30 @@
 require 'rails_helper'
 
 RSpec.describe CustomEmoji, type: :model do
+  describe '#search' do
+    let(:custom_emoji) { Fabricate(:custom_emoji, shortcode: shortcode) }
+
+    subject  { described_class.search(search_term) }
+
+    context 'shortcode is exact' do
+      let(:shortcode) { 'blobpats' }
+      let(:search_term) { 'blobpats' }
+
+      it 'finds emoji' do
+        is_expected.to include(custom_emoji)
+      end
+    end
+
+    context 'shortcode is partial' do
+      let(:shortcode) { 'blobpats' }
+      let(:search_term) { 'blob' }
+
+      it 'finds emoji' do
+        is_expected.to include(custom_emoji)
+      end
+    end
+  end
+
   describe '#local?' do
     let(:custom_emoji) { Fabricate(:custom_emoji, domain: domain) }