about summary refs log tree commit diff
path: root/app
diff options
context:
space:
mode:
Diffstat (limited to 'app')
-rw-r--r--app/controllers/api/v1/reports_controller.rb3
-rw-r--r--app/controllers/application_controller.rb5
-rw-r--r--app/controllers/auth/registrations_controller.rb5
-rw-r--r--app/controllers/home_controller.rb12
-rw-r--r--app/controllers/settings/two_factor_authentications_controller.rb20
-rw-r--r--app/helpers/settings_helper.rb12
-rw-r--r--app/javascript/mastodon/actions/reports.js18
-rw-r--r--app/javascript/mastodon/components/column_collapsable.js50
-rw-r--r--app/javascript/mastodon/components/column_header.js2
-rw-r--r--app/javascript/mastodon/components/media_gallery.js18
-rw-r--r--app/javascript/mastodon/components/permalink.js2
-rw-r--r--app/javascript/mastodon/components/status.js2
-rw-r--r--app/javascript/mastodon/components/status_action_bar.js1
-rw-r--r--app/javascript/mastodon/features/account_timeline/components/header.js1
-rw-r--r--app/javascript/mastodon/features/compose/components/compose_form.js6
-rw-r--r--app/javascript/mastodon/features/favourited_statuses/index.js5
-rw-r--r--app/javascript/mastodon/features/notifications/index.js25
-rw-r--r--app/javascript/mastodon/features/status/components/action_bar.js1
-rw-r--r--app/javascript/mastodon/features/ui/components/image_loader.js116
-rw-r--r--app/javascript/mastodon/features/ui/components/modal_root.js2
-rw-r--r--app/javascript/mastodon/features/ui/components/onboarding_modal.js5
-rw-r--r--app/javascript/mastodon/features/ui/components/report_modal.js (renamed from app/javascript/mastodon/features/report/index.js)72
-rw-r--r--app/javascript/mastodon/features/ui/index.js3
-rw-r--r--app/javascript/mastodon/locales/defaultMessages.json17
-rw-r--r--app/javascript/mastodon/locales/en.json4
-rw-r--r--app/javascript/mastodon/locales/fr.json6
-rw-r--r--app/javascript/mastodon/locales/pl.json4
-rw-r--r--app/javascript/styles/admin.scss5
-rw-r--r--app/javascript/styles/components.scss187
-rw-r--r--app/javascript/styles/forms.scss1
-rw-r--r--app/javascript/styles/lists.scss1
-rw-r--r--app/javascript/styles/tables.scss12
-rw-r--r--app/mailers/admin_mailer.rb13
-rw-r--r--app/mailers/application_mailer.rb8
-rw-r--r--app/mailers/notification_mailer.rb8
-rw-r--r--app/models/session_activation.rb80
-rw-r--r--app/models/user.rb6
-rw-r--r--app/services/send_interaction_service.rb3
-rw-r--r--app/views/admin_mailer/new_report.text.erb5
-rw-r--r--app/views/auth/registrations/_sessions.html.haml23
-rw-r--r--app/views/auth/registrations/edit.html.haml4
-rw-r--r--app/views/settings/two_factor_authentication/recovery_codes/index.html.haml2
-rw-r--r--app/views/settings/two_factor_authentications/show.html.haml42
-rw-r--r--app/views/user_mailer/password_change.en.html.erb2
-rw-r--r--app/views/user_mailer/password_change.en.text.erb2
-rw-r--r--app/views/user_mailer/reset_password_instructions.en.html.erb2
-rw-r--r--app/views/user_mailer/reset_password_instructions.en.text.erb2
-rw-r--r--app/workers/scheduler/doorkeeper_cleanup_scheduler.rb11
48 files changed, 498 insertions, 338 deletions
diff --git a/app/controllers/api/v1/reports_controller.rb b/app/controllers/api/v1/reports_controller.rb
index 71df76e92..8e7070d07 100644
--- a/app/controllers/api/v1/reports_controller.rb
+++ b/app/controllers/api/v1/reports_controller.rb
@@ -17,6 +17,9 @@ class Api::V1::ReportsController < Api::BaseController
       status_ids: reported_status_ids,
       comment: report_params[:comment]
     )
+
+    User.admins.includes(:account).each { |u| AdminMailer.new_report(u.account, @report).deliver_later }
+
     render :show
   end
 
diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
index 9cb397aa8..865fcd125 100644
--- a/app/controllers/application_controller.rb
+++ b/app/controllers/application_controller.rb
@@ -11,6 +11,7 @@ class ApplicationController < ActionController::Base
   include UserTrackingConcern
 
   helper_method :current_account
+  helper_method :current_session
   helper_method :single_user_mode?
 
   rescue_from ActionController::RoutingError, with: :not_found
@@ -68,6 +69,10 @@ class ApplicationController < ActionController::Base
     @current_account ||= current_user.try(:account)
   end
 
+  def current_session
+    @current_session ||= SessionActivation.find_by(session_id: session['auth_id'])
+  end
+
   def cache_collection(raw, klass)
     return raw unless klass.respond_to?(:with_includes)
 
diff --git a/app/controllers/auth/registrations_controller.rb b/app/controllers/auth/registrations_controller.rb
index d385c08e1..60ace04d7 100644
--- a/app/controllers/auth/registrations_controller.rb
+++ b/app/controllers/auth/registrations_controller.rb
@@ -5,6 +5,7 @@ class Auth::RegistrationsController < Devise::RegistrationsController
 
   before_action :check_enabled_registrations, only: [:new, :create]
   before_action :configure_sign_up_params, only: [:create]
+  before_action :set_sessions, only: [:edit, :update]
 
   def destroy
     not_found
@@ -41,4 +42,8 @@ class Auth::RegistrationsController < Devise::RegistrationsController
   def determine_layout
     %w(edit update).include?(action_name) ? 'admin' : 'auth'
   end
+
+  def set_sessions
+    @sessions = current_user.session_activations
+  end
 end
diff --git a/app/controllers/home_controller.rb b/app/controllers/home_controller.rb
index 1d41892cd..6209a3ae9 100644
--- a/app/controllers/home_controller.rb
+++ b/app/controllers/home_controller.rb
@@ -5,7 +5,7 @@ class HomeController < ApplicationController
 
   def index
     @body_classes           = 'app-body'
-    @token                  = find_or_create_access_token.token
+    @token                  = current_session.token
     @web_settings           = Web::Setting.find_by(user: current_user)&.data || {}
     @admin                  = Account.find_local(Setting.site_contact_username)
     @streaming_api_base_url = Rails.configuration.x.streaming_api_base_url
@@ -16,14 +16,4 @@ class HomeController < ApplicationController
   def authenticate_user!
     redirect_to(single_user_mode? ? account_path(Account.first) : about_path) unless user_signed_in?
   end
-
-  def find_or_create_access_token
-    Doorkeeper::AccessToken.find_or_create_for(
-      Doorkeeper::Application.where(superapp: true).first,
-      current_user.id,
-      Doorkeeper::OAuth::Scopes.from_string('read write follow'),
-      Doorkeeper.configuration.access_token_expires_in,
-      Doorkeeper.configuration.refresh_token_enabled?
-    )
-  end
 end
diff --git a/app/controllers/settings/two_factor_authentications_controller.rb b/app/controllers/settings/two_factor_authentications_controller.rb
index f66c3a908..983483881 100644
--- a/app/controllers/settings/two_factor_authentications_controller.rb
+++ b/app/controllers/settings/two_factor_authentications_controller.rb
@@ -7,7 +7,9 @@ module Settings
     before_action :authenticate_user!
     before_action :verify_otp_required, only: [:create]
 
-    def show; end
+    def show
+      @confirmation = Form::TwoFactorConfirmation.new
+    end
 
     def create
       current_user.otp_secret = User.generate_otp_secret(32)
@@ -16,13 +18,23 @@ module Settings
     end
 
     def destroy
-      current_user.otp_required_for_login = false
-      current_user.save!
-      redirect_to settings_two_factor_authentication_path
+      if current_user.validate_and_consume_otp!(confirmation_params[:code])
+        current_user.otp_required_for_login = false
+        current_user.save!
+        redirect_to settings_two_factor_authentication_path
+      else
+        flash.now[:alert] = I18n.t('two_factor_authentication.wrong_code')
+        @confirmation = Form::TwoFactorConfirmation.new
+        render :show
+      end
     end
 
     private
 
+    def confirmation_params
+      params.require(:form_two_factor_confirmation).permit(:code)
+    end
+
     def verify_otp_required
       redirect_to settings_two_factor_authentication_path if current_user.otp_required_for_login?
     end
diff --git a/app/helpers/settings_helper.rb b/app/helpers/settings_helper.rb
index 172ef33ca..847eff2e7 100644
--- a/app/helpers/settings_helper.rb
+++ b/app/helpers/settings_helper.rb
@@ -41,4 +41,16 @@ module SettingsHelper
   def hash_to_object(hash)
     HashObject.new(hash)
   end
+
+  def session_device_icon(session)
+    device = session.detection.device
+
+    if device.mobile?
+      'mobile'
+    elsif device.tablet?
+      'tablet'
+    else
+      'desktop'
+    end
+  end
 end
diff --git a/app/javascript/mastodon/actions/reports.js b/app/javascript/mastodon/actions/reports.js
index 9b632be74..b19a07285 100644
--- a/app/javascript/mastodon/actions/reports.js
+++ b/app/javascript/mastodon/actions/reports.js
@@ -1,4 +1,5 @@
 import api from '../api';
+import { openModal, closeModal } from './modal';
 
 export const REPORT_INIT   = 'REPORT_INIT';
 export const REPORT_CANCEL = 'REPORT_CANCEL';
@@ -11,10 +12,14 @@ export const REPORT_STATUS_TOGGLE  = 'REPORT_STATUS_TOGGLE';
 export const REPORT_COMMENT_CHANGE = 'REPORT_COMMENT_CHANGE';
 
 export function initReport(account, status) {
-  return {
-    type: REPORT_INIT,
-    account,
-    status,
+  return dispatch => {
+    dispatch({
+      type: REPORT_INIT,
+      account,
+      status,
+    });
+
+    dispatch(openModal('REPORT'));
   };
 };
 
@@ -40,7 +45,10 @@ export function submitReport() {
       account_id: getState().getIn(['reports', 'new', 'account_id']),
       status_ids: getState().getIn(['reports', 'new', 'status_ids']),
       comment: getState().getIn(['reports', 'new', 'comment']),
-    }).then(response => dispatch(submitReportSuccess(response.data))).catch(error => dispatch(submitReportFail(error)));
+    }).then(response => {
+      dispatch(closeModal());
+      dispatch(submitReportSuccess(response.data));
+    }).catch(error => dispatch(submitReportFail(error)));
   };
 };
 
diff --git a/app/javascript/mastodon/components/column_collapsable.js b/app/javascript/mastodon/components/column_collapsable.js
deleted file mode 100644
index d6b4edb9f..000000000
--- a/app/javascript/mastodon/components/column_collapsable.js
+++ /dev/null
@@ -1,50 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-
-export default class ColumnCollapsable extends React.PureComponent {
-
-  static propTypes = {
-    icon: PropTypes.string.isRequired,
-    title: PropTypes.string,
-    fullHeight: PropTypes.number.isRequired,
-    children: PropTypes.node,
-    onCollapse: PropTypes.func,
-  };
-
-  state = {
-    collapsed: true,
-    animating: false,
-  };
-
-  handleToggleCollapsed = () => {
-    const currentState = this.state.collapsed;
-
-    this.setState({ collapsed: !currentState, animating: true });
-
-    if (!currentState && this.props.onCollapse) {
-      this.props.onCollapse();
-    }
-  }
-
-  handleTransitionEnd = () => {
-    this.setState({ animating: false });
-  }
-
-  render () {
-    const { icon, title, fullHeight, children } = this.props;
-    const { collapsed, animating } = this.state;
-
-    return (
-      <div className={`column-collapsable ${collapsed ? 'collapsed' : ''}`} onTransitionEnd={this.handleTransitionEnd}>
-        <div role='button' tabIndex='0' title={`${title}`} className='column-collapsable__button column-icon' onClick={this.handleToggleCollapsed}>
-          <i className={`fa fa-${icon}`} />
-        </div>
-
-        <div className='column-collapsable__content' style={{ height: `${fullHeight}px` }}>
-          {(!collapsed || animating) && children}
-        </div>
-      </div>
-    );
-  }
-
-}
diff --git a/app/javascript/mastodon/components/column_header.js b/app/javascript/mastodon/components/column_header.js
index a309f74e8..ec9379320 100644
--- a/app/javascript/mastodon/components/column_header.js
+++ b/app/javascript/mastodon/components/column_header.js
@@ -132,7 +132,7 @@ export default class ColumnHeader extends React.PureComponent {
         </div>
 
         <div className={collapsibleClassName} onTransitionEnd={this.handleTransitionEnd}>
-          <div>
+          <div className='column-header__collapsible-inner'>
             {(!collapsed || animating) && collapsedContent}
           </div>
         </div>
diff --git a/app/javascript/mastodon/components/media_gallery.js b/app/javascript/mastodon/components/media_gallery.js
index 78ff35130..2cb1ce51c 100644
--- a/app/javascript/mastodon/components/media_gallery.js
+++ b/app/javascript/mastodon/components/media_gallery.js
@@ -85,14 +85,24 @@ class Item extends React.PureComponent {
     let thumbnail = '';
 
     if (attachment.get('type') === 'image') {
+      const previewUrl = attachment.get('preview_url');
+      const previewWidth = attachment.getIn(['meta', 'small', 'width']);
+
+      const originalUrl = attachment.get('url');
+      const originalWidth = attachment.getIn(['meta', 'original', 'width']);
+
+      const srcSet = `${originalUrl} ${originalWidth}w, ${previewUrl} ${previewWidth}w`;
+      const sizes = `(min-width: 1025px) ${320 * (width / 100)}px, ${width}vw`;
+
       thumbnail = (
-        <a // eslint-disable-line jsx-a11y/anchor-has-content
+        <a
           className='media-gallery__item-thumbnail'
-          href={attachment.get('remote_url') || attachment.get('url')}
+          href={attachment.get('remote_url') || originalUrl}
           onClick={this.handleClick}
           target='_blank'
-          style={{ backgroundImage: `url(${attachment.get('preview_url')})` }}
-        />
+        >
+          <img src={previewUrl} srcSet={srcSet} sizes={sizes} alt='' />
+        </a>
       );
     } else if (attachment.get('type') === 'gifv') {
       const autoPlay = !isIOS() && this.props.autoPlayGif;
diff --git a/app/javascript/mastodon/components/permalink.js b/app/javascript/mastodon/components/permalink.js
index 5d3e4738d..0b7d0a65a 100644
--- a/app/javascript/mastodon/components/permalink.js
+++ b/app/javascript/mastodon/components/permalink.js
@@ -25,7 +25,7 @@ export default class Permalink extends React.PureComponent {
     const { href, children, className, ...other } = this.props;
 
     return (
-      <a href={href} onClick={this.handleClick} {...other} className={'permalink ' + className}>
+      <a href={href} onClick={this.handleClick} {...other} className={`permalink${className ? ' ' + className : ''}`}>
         {children}
       </a>
     );
diff --git a/app/javascript/mastodon/components/status.js b/app/javascript/mastodon/components/status.js
index 0077a9191..9e9e1c3c7 100644
--- a/app/javascript/mastodon/components/status.js
+++ b/app/javascript/mastodon/components/status.js
@@ -149,7 +149,7 @@ class StatusUnextended extends ImmutablePureComponent {
 
   saveHeight = () => {
     if (this.node && this.node.children.length !== 0) {
-      this.height = this.node.clientHeight;
+      this.height = this.node.getBoundingClientRect().height;
     }
   }
 
diff --git a/app/javascript/mastodon/components/status_action_bar.js b/app/javascript/mastodon/components/status_action_bar.js
index a1e1a135a..6693548c7 100644
--- a/app/javascript/mastodon/components/status_action_bar.js
+++ b/app/javascript/mastodon/components/status_action_bar.js
@@ -88,7 +88,6 @@ export default class StatusActionBar extends ImmutablePureComponent {
 
   handleReport = () => {
     this.props.onReport(this.props.status);
-    this.context.router.history.push('/report');
   }
 
   handleConversationMuteClick = () => {
diff --git a/app/javascript/mastodon/features/account_timeline/components/header.js b/app/javascript/mastodon/features/account_timeline/components/header.js
index 7f80e39e8..167a2097e 100644
--- a/app/javascript/mastodon/features/account_timeline/components/header.js
+++ b/app/javascript/mastodon/features/account_timeline/components/header.js
@@ -38,7 +38,6 @@ export default class Header extends ImmutablePureComponent {
 
   handleReport = () => {
     this.props.onReport(this.props.account);
-    this.context.router.history.push('/report');
   }
 
   handleMute = () => {
diff --git a/app/javascript/mastodon/features/compose/components/compose_form.js b/app/javascript/mastodon/features/compose/components/compose_form.js
index c379c1855..f7eeedc69 100644
--- a/app/javascript/mastodon/features/compose/components/compose_form.js
+++ b/app/javascript/mastodon/features/compose/components/compose_form.js
@@ -67,6 +67,12 @@ export default class ComposeForm extends ImmutablePureComponent {
   }
 
   handleSubmit = () => {
+    if (this.props.text !== this.autosuggestTextarea.textarea.value) {
+      // Something changed the text inside the textarea (e.g. browser extensions like Grammarly)
+      // Update the state to match the current text
+      this.props.onChange(this.autosuggestTextarea.textarea.value);
+    }
+
     this.props.onSubmit();
   }
 
diff --git a/app/javascript/mastodon/features/favourited_statuses/index.js b/app/javascript/mastodon/features/favourited_statuses/index.js
index 137e55089..8cef6a1e4 100644
--- a/app/javascript/mastodon/features/favourited_statuses/index.js
+++ b/app/javascript/mastodon/features/favourited_statuses/index.js
@@ -1,6 +1,7 @@
 import React from 'react';
 import { connect } from 'react-redux';
 import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
 import LoadingIndicator from '../../components/loading_indicator';
 import { fetchFavouritedStatuses, expandFavouritedStatuses } from '../../actions/favourites';
 import Column from '../ui/components/column';
@@ -14,7 +15,9 @@ const messages = defineMessages({
 });
 
 const mapStateToProps = state => ({
+  statusIds: state.getIn(['status_lists', 'favourites', 'items']),
   loaded: state.getIn(['status_lists', 'favourites', 'loaded']),
+  me: state.getIn(['meta', 'me']),
 });
 
 @connect(mapStateToProps)
@@ -23,8 +26,10 @@ export default class Favourites extends ImmutablePureComponent {
 
   static propTypes = {
     dispatch: PropTypes.func.isRequired,
+    statusIds: ImmutablePropTypes.list.isRequired,
     loaded: PropTypes.bool,
     intl: PropTypes.object.isRequired,
+    me: PropTypes.number.isRequired,
   };
 
   componentWillMount () {
diff --git a/app/javascript/mastodon/features/notifications/index.js b/app/javascript/mastodon/features/notifications/index.js
index 1dd1b9a71..ed4b3ad98 100644
--- a/app/javascript/mastodon/features/notifications/index.js
+++ b/app/javascript/mastodon/features/notifications/index.js
@@ -13,6 +13,7 @@ import ColumnSettingsContainer from './containers/column_settings_container';
 import { createSelector } from 'reselect';
 import Immutable from 'immutable';
 import LoadMore from '../../components/load_more';
+import { debounce } from 'lodash';
 
 const messages = defineMessages({
   title: { id: 'column.notifications', defaultMessage: 'Notifications' },
@@ -50,19 +51,27 @@ export default class Notifications extends React.PureComponent {
     trackScroll: true,
   };
 
+  dispatchExpandNotifications = debounce(() => {
+    this.props.dispatch(expandNotifications());
+  }, 300, { leading: true });
+
+  dispatchScrollToTop = debounce((top) => {
+    this.props.dispatch(scrollTopNotifications(top));
+  }, 100);
+
   handleScroll = (e) => {
     const { scrollTop, scrollHeight, clientHeight } = e.target;
     const offset = scrollHeight - scrollTop - clientHeight;
     this._oldScrollPosition = scrollHeight - scrollTop;
 
-    if (250 > offset && !this.props.isLoading) {
-      if (this.props.hasMore) {
-        this.props.dispatch(expandNotifications());
-      }
-    } else if (scrollTop < 100) {
-      this.props.dispatch(scrollTopNotifications(true));
+    if (250 > offset && this.props.hasMore && !this.props.isLoading) {
+      this.dispatchExpandNotifications();
+    }
+
+    if (scrollTop < 100) {
+      this.dispatchScrollToTop(true);
     } else {
-      this.props.dispatch(scrollTopNotifications(false));
+      this.dispatchScrollToTop(false);
     }
   }
 
@@ -74,7 +83,7 @@ export default class Notifications extends React.PureComponent {
 
   handleLoadMore = (e) => {
     e.preventDefault();
-    this.props.dispatch(expandNotifications());
+    this.dispatchExpandNotifications();
   }
 
   handlePin = () => {
diff --git a/app/javascript/mastodon/features/status/components/action_bar.js b/app/javascript/mastodon/features/status/components/action_bar.js
index 29080529d..5e150842e 100644
--- a/app/javascript/mastodon/features/status/components/action_bar.js
+++ b/app/javascript/mastodon/features/status/components/action_bar.js
@@ -56,7 +56,6 @@ export default class ActionBar extends React.PureComponent {
 
   handleReport = () => {
     this.props.onReport(this.props.status);
-    this.context.router.history.push('/report');
   }
 
   render () {
diff --git a/app/javascript/mastodon/features/ui/components/image_loader.js b/app/javascript/mastodon/features/ui/components/image_loader.js
index 5c3879970..52c3a898b 100644
--- a/app/javascript/mastodon/features/ui/components/image_loader.js
+++ b/app/javascript/mastodon/features/ui/components/image_loader.js
@@ -1,5 +1,6 @@
 import React from 'react';
 import PropTypes from 'prop-types';
+import classNames from 'classnames';
 
 export default class ImageLoader extends React.PureComponent {
 
@@ -20,46 +21,121 @@ export default class ImageLoader extends React.PureComponent {
     error: false,
   }
 
-  componentWillMount() {
-    this._loadImage(this.props.src);
+  removers = [];
+
+  get canvasContext() {
+    if (!this.canvas) {
+      return null;
+    }
+    this._canvasContext = this._canvasContext || this.canvas.getContext('2d');
+    return this._canvasContext;
+  }
+
+  componentDidMount () {
+    this.loadImage(this.props);
+  }
+
+  componentWillReceiveProps (nextProps) {
+    if (this.props.src !== nextProps.src) {
+      this.loadImage(nextProps);
+    }
   }
 
-  componentWillReceiveProps(props) {
-    this._loadImage(props.src);
+  loadImage (props) {
+    this.removeEventListeners();
+    this.setState({ loading: true, error: false });
+    Promise.all([
+      this.loadPreviewCanvas(props),
+      this.loadOriginalImage(props),
+    ])
+      .then(() => {
+        this.setState({ loading: false, error: false });
+        this.clearPreviewCanvas();
+      })
+      .catch(() => this.setState({ loading: false, error: true }));
   }
 
-  _loadImage(src) {
+  loadPreviewCanvas = ({ previewSrc, width, height }) => new Promise((resolve, reject) => {
     const image = new Image();
+    const removeEventListeners = () => {
+      image.removeEventListener('error', handleError);
+      image.removeEventListener('load', handleLoad);
+    };
+    const handleError = () => {
+      removeEventListeners();
+      reject();
+    };
+    const handleLoad = () => {
+      removeEventListeners();
+      this.canvasContext.drawImage(image, 0, 0, width, height);
+      resolve();
+    };
+    image.addEventListener('error', handleError);
+    image.addEventListener('load', handleLoad);
+    image.src = previewSrc;
+    this.removers.push(removeEventListeners);
+  })
 
-    image.onerror = () => this.setState({ loading: false, error: true });
-    image.onload  = () => this.setState({ loading: false, error: false });
+  clearPreviewCanvas () {
+    const { width, height } = this.canvas;
+    this.canvasContext.clearRect(0, 0, width, height);
+  }
 
+  loadOriginalImage = ({ src }) => new Promise((resolve, reject) => {
+    const image = new Image();
+    const removeEventListeners = () => {
+      image.removeEventListener('error', handleError);
+      image.removeEventListener('load', handleLoad);
+    };
+    const handleError = () => {
+      removeEventListeners();
+      reject();
+    };
+    const handleLoad = () => {
+      removeEventListeners();
+      resolve();
+    };
+    image.addEventListener('error', handleError);
+    image.addEventListener('load', handleLoad);
     image.src = src;
+    this.removers.push(removeEventListeners);
+  });
 
-    this.setState({ loading: true });
+  removeEventListeners () {
+    this.removers.forEach(listeners => listeners());
+    this.removers = [];
   }
 
-  render() {
-    const { alt, src, previewSrc, width, height } = this.props;
+  setCanvasRef = c => {
+    this.canvas = c;
+  }
+
+  render () {
+    const { alt, src, width, height } = this.props;
     const { loading } = this.state;
 
+    const className = classNames('image-loader', {
+      'image-loader--loading': loading,
+    });
+
     return (
-      <div className='image-loader'>
-        <img
-          alt={alt}
-          className='image-loader__img'
-          src={src}
+      <div className={className}>
+        <canvas
+          className='image-loader__preview-canvas'
           width={width}
           height={height}
+          ref={this.setCanvasRef}
         />
 
-        {loading &&
+        {!loading && (
           <img
-            alt=''
-            src={previewSrc}
-            className='image-loader__preview-img'
+            alt={alt}
+            className='image-loader__img'
+            src={src}
+            width={width}
+            height={height}
           />
-        }
+        )}
       </div>
     );
   }
diff --git a/app/javascript/mastodon/features/ui/components/modal_root.js b/app/javascript/mastodon/features/ui/components/modal_root.js
index 2e4f9876d..48b048eb7 100644
--- a/app/javascript/mastodon/features/ui/components/modal_root.js
+++ b/app/javascript/mastodon/features/ui/components/modal_root.js
@@ -5,6 +5,7 @@ import OnboardingModal from './onboarding_modal';
 import VideoModal from './video_modal';
 import BoostModal from './boost_modal';
 import ConfirmationModal from './confirmation_modal';
+import ReportModal from './report_modal';
 import TransitionMotion from 'react-motion/lib/TransitionMotion';
 import spring from 'react-motion/lib/spring';
 
@@ -14,6 +15,7 @@ const MODAL_COMPONENTS = {
   'VIDEO': VideoModal,
   'BOOST': BoostModal,
   'CONFIRM': ConfirmationModal,
+  'REPORT': ReportModal,
 };
 
 export default class ModalRoot extends React.PureComponent {
diff --git a/app/javascript/mastodon/features/ui/components/onboarding_modal.js b/app/javascript/mastodon/features/ui/components/onboarding_modal.js
index 4c1c0f7c1..dab5e47ea 100644
--- a/app/javascript/mastodon/features/ui/components/onboarding_modal.js
+++ b/app/javascript/mastodon/features/ui/components/onboarding_modal.js
@@ -3,6 +3,7 @@ import { connect } from 'react-redux';
 import PropTypes from 'prop-types';
 import ImmutablePropTypes from 'react-immutable-proptypes';
 import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import ReactSwipeable from 'react-swipeable';
 import classNames from 'classnames';
 import Permalink from '../../../components/permalink';
 import TransitionMotion from 'react-motion/lib/TransitionMotion';
@@ -274,7 +275,7 @@ export default class OnboardingModal extends React.PureComponent {
       <div className='modal-root__modal onboarding-modal'>
         <TransitionMotion styles={styles}>
           {interpolatedStyles => (
-            <div className='onboarding-modal__pager'>
+            <ReactSwipeable onSwipedRight={this.handlePrev} onSwipedLeft={this.handleNext} className='onboarding-modal__pager'>
               {interpolatedStyles.map(({ key, data, style }, i) => {
                 const className = classNames('onboarding-modal__page__wrapper', {
                   'onboarding-modal__page__wrapper--active': i === currentIndex,
@@ -283,7 +284,7 @@ export default class OnboardingModal extends React.PureComponent {
                   <div key={key} style={style} className={className}>{data}</div>
                 );
               })}
-            </div>
+            </ReactSwipeable>
           )}
         </TransitionMotion>
 
diff --git a/app/javascript/mastodon/features/report/index.js b/app/javascript/mastodon/features/ui/components/report_modal.js
index bfb09e193..c989d2c9b 100644
--- a/app/javascript/mastodon/features/report/index.js
+++ b/app/javascript/mastodon/features/ui/components/report_modal.js
@@ -1,19 +1,17 @@
 import React from 'react';
 import { connect } from 'react-redux';
-import { changeReportComment, submitReport } from '../../actions/reports';
-import { refreshAccountTimeline } from '../../actions/timelines';
+import { changeReportComment, submitReport } from '../../../actions/reports';
+import { refreshAccountTimeline } from '../../../actions/timelines';
 import PropTypes from 'prop-types';
 import ImmutablePropTypes from 'react-immutable-proptypes';
-import Column from '../ui/components/column';
-import Button from '../../components/button';
-import { makeGetAccount } from '../../selectors';
+import { makeGetAccount } from '../../../selectors';
 import { defineMessages, FormattedMessage, injectIntl } from 'react-intl';
-import StatusCheckBox from './containers/status_check_box_container';
+import StatusCheckBox from '../../report/containers/status_check_box_container';
 import Immutable from 'immutable';
-import ColumnBackButtonSlim from '../../components/column_back_button_slim';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import Button from '../../../components/button';
 
 const messages = defineMessages({
-  heading: { id: 'report.heading', defaultMessage: 'New report' },
   placeholder: { id: 'report.placeholder', defaultMessage: 'Additional comments' },
   submit: { id: 'report.submit', defaultMessage: 'Submit' },
 });
@@ -37,11 +35,7 @@ const makeMapStateToProps = () => {
 
 @connect(makeMapStateToProps)
 @injectIntl
-export default class Report extends React.PureComponent {
-
-  static contextTypes = {
-    router: PropTypes.object,
-  };
+export default class ReportModal extends ImmutablePureComponent {
 
   static propTypes = {
     isSubmitting: PropTypes.bool,
@@ -52,17 +46,15 @@ export default class Report extends React.PureComponent {
     intl: PropTypes.object.isRequired,
   };
 
-  componentWillMount () {
-    if (!this.props.account) {
-      this.context.router.history.replace('/');
-    }
+  handleCommentChange = (e) => {
+    this.props.dispatch(changeReportComment(e.target.value));
   }
 
-  componentDidMount () {
-    if (!this.props.account) {
-      return;
-    }
+  handleSubmit = () => {
+    this.props.dispatch(submitReport());
+  }
 
+  componentDidMount () {
     this.props.dispatch(refreshAccountTimeline(this.props.account.get('id')));
   }
 
@@ -72,15 +64,6 @@ export default class Report extends React.PureComponent {
     }
   }
 
-  handleCommentChange = (e) => {
-    this.props.dispatch(changeReportComment(e.target.value));
-  }
-
-  handleSubmit = () => {
-    this.props.dispatch(submitReport());
-    this.context.router.history.replace('/');
-  }
-
   render () {
     const { account, comment, intl, statusIds, isSubmitting } = this.props;
 
@@ -89,36 +72,33 @@ export default class Report extends React.PureComponent {
     }
 
     return (
-      <Column heading={intl.formatMessage(messages.heading)} icon='flag'>
-        <ColumnBackButtonSlim />
-
-        <div className='report scrollable'>
-          <div className='report__target'>
-            <FormattedMessage id='report.target' defaultMessage='Reporting' />
-            <strong>{account.get('acct')}</strong>
-          </div>
+      <div className='modal-root__modal report-modal'>
+        <div className='report-modal__target'>
+          <FormattedMessage id='report.target' defaultMessage='Report {target}' values={{ target: <strong>{account.get('acct')}</strong> }} />
+        </div>
 
-          <div className='scrollable report__statuses'>
+        <div className='report-modal__container'>
+          <div className='report-modal__statuses'>
             <div>
               {statusIds.map(statusId => <StatusCheckBox id={statusId} key={statusId} disabled={isSubmitting} />)}
             </div>
           </div>
 
-          <div className='report__textarea-wrapper'>
+          <div className='report-modal__comment'>
             <textarea
-              className='report__textarea'
+              className='setting-text light'
               placeholder={intl.formatMessage(messages.placeholder)}
               value={comment}
               onChange={this.handleCommentChange}
               disabled={isSubmitting}
             />
-
-            <div className='report__submit'>
-              <div className='report__submit-button'><Button disabled={isSubmitting} text={intl.formatMessage(messages.submit)} onClick={this.handleSubmit} /></div>
-            </div>
           </div>
         </div>
-      </Column>
+
+        <div className='report-modal__action-bar'>
+          <Button disabled={isSubmitting} text={intl.formatMessage(messages.submit)} onClick={this.handleSubmit} />
+        </div>
+      </div>
     );
   }
 
diff --git a/app/javascript/mastodon/features/ui/index.js b/app/javascript/mastodon/features/ui/index.js
index e5915ffe0..4d38c2677 100644
--- a/app/javascript/mastodon/features/ui/index.js
+++ b/app/javascript/mastodon/features/ui/index.js
@@ -15,7 +15,6 @@ import { refreshHomeTimeline } from '../../actions/timelines';
 import { refreshNotifications } from '../../actions/notifications';
 import UploadArea from './components/upload_area';
 import ColumnsAreaContainer from './containers/columns_area_container';
-
 import Status from '../../features/status';
 import GettingStarted from '../../features/getting_started';
 import PublicTimeline from '../../features/public_timeline';
@@ -35,7 +34,6 @@ import GenericNotFound from '../../features/generic_not_found';
 import FavouritedStatuses from '../../features/favourited_statuses';
 import Blocks from '../../features/blocks';
 import Mutes from '../../features/mutes';
-import Report from '../../features/report';
 
 // Small wrapper to pass multiColumn to the route components
 const WrappedSwitch = ({ multiColumn, children }) => (
@@ -222,7 +220,6 @@ export default class UI extends React.PureComponent {
             <WrappedRoute path='/follow_requests' component={FollowRequests} content={children} />
             <WrappedRoute path='/blocks' component={Blocks} content={children} />
             <WrappedRoute path='/mutes' component={Mutes} content={children} />
-            <WrappedRoute path='/report' component={Report} content={children} />
 
             <WrappedRoute component={GenericNotFound} content={children} />
           </WrappedSwitch>
diff --git a/app/javascript/mastodon/locales/defaultMessages.json b/app/javascript/mastodon/locales/defaultMessages.json
index 45353e9a3..5ab914477 100644
--- a/app/javascript/mastodon/locales/defaultMessages.json
+++ b/app/javascript/mastodon/locales/defaultMessages.json
@@ -1154,6 +1154,23 @@
   {
     "descriptors": [
       {
+        "defaultMessage": "Additional comments",
+        "id": "report.placeholder"
+      },
+      {
+        "defaultMessage": "Submit",
+        "id": "report.submit"
+      },
+      {
+        "defaultMessage": "Report {target}",
+        "id": "report.target"
+      }
+    ],
+    "path": "app/javascript/mastodon/features/ui/components/report_modal.json"
+  },
+  {
+    "descriptors": [
+      {
         "defaultMessage": "Compose",
         "id": "tabs_bar.compose"
       },
diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json
index 3b2d198b1..d0c0ca137 100644
--- a/app/javascript/mastodon/locales/en.json
+++ b/app/javascript/mastodon/locales/en.json
@@ -140,10 +140,10 @@
   "privacy.unlisted.long": "Do not post to public timelines",
   "privacy.unlisted.short": "Unlisted",
   "reply_indicator.cancel": "Cancel",
-  "report.heading": "New report",
+  "report.heading": "Report {target}",
   "report.placeholder": "Additional comments",
   "report.submit": "Submit",
-  "report.target": "Reporting",
+  "report.target": "Reporting {target}",
   "search.placeholder": "Search",
   "search_results.total": "{count, number} {count, plural, one {result} other {results}}",
   "status.cannot_reblog": "This post cannot be boosted",
diff --git a/app/javascript/mastodon/locales/fr.json b/app/javascript/mastodon/locales/fr.json
index 52d6b0f87..1a69235c8 100644
--- a/app/javascript/mastodon/locales/fr.json
+++ b/app/javascript/mastodon/locales/fr.json
@@ -27,8 +27,8 @@
   "column.notifications": "Notifications",
   "column.public": "Fil public global",
   "column_back_button.label": "Retour",
-  "column_header.pin": "Pin",
-  "column_header.unpin": "Unpin",
+  "column_header.pin": "Épingler",
+  "column_header.unpin": "Retirer",
   "column_subheading.navigation": "Navigation",
   "column_subheading.settings": "Paramètres",
   "compose_form.lock_disclaimer": "Votre compte n'est pas {locked}. Tout le monde peut vous suivre et voir vos pouets restreints.",
@@ -101,7 +101,7 @@
   "notifications.clear_confirmation": "Voulez-vous vraiment supprimer toutes vos notifications ?",
   "notifications.column_settings.alert": "Notifications locales",
   "notifications.column_settings.favourite": "Favoris :",
-  "notifications.column_settings.follow": "Nouveaux abonné⋅e⋅s :",
+  "notifications.column_settings.follow": "Nouveaux⋅elles abonn⋅é⋅s :",
   "notifications.column_settings.mention": "Mentions :",
   "notifications.column_settings.reblog": "Partages :",
   "notifications.column_settings.show": "Afficher dans la colonne",
diff --git a/app/javascript/mastodon/locales/pl.json b/app/javascript/mastodon/locales/pl.json
index 6dcb872df..bf425501f 100644
--- a/app/javascript/mastodon/locales/pl.json
+++ b/app/javascript/mastodon/locales/pl.json
@@ -136,10 +136,10 @@
   "privacy.unlisted.long": "Niewidoczne na publicznych osiach czasu",
   "privacy.unlisted.short": "Niewidoczne",
   "reply_indicator.cancel": "Anuluj",
-  "report.heading": "Nowe zgłoszenie",
+  "report.heading": "Zgłoś {target}",
   "report.placeholder": "Dodatkowe komentarze",
   "report.submit": "Wyślij",
-  "report.target": "Zgłaszanie",
+  "report.target": "Zgłaszanie {target}",
   "search.placeholder": "Szukaj",
   "search_results.total": "{count, number} {count, plural, one {wynik} more {wyniki}}",
   "status.cannot_reblog": "Ten post nie może zostać podbity",
diff --git a/app/javascript/styles/admin.scss b/app/javascript/styles/admin.scss
index c2bfc10a0..3bc713566 100644
--- a/app/javascript/styles/admin.scss
+++ b/app/javascript/styles/admin.scss
@@ -129,6 +129,11 @@
         color: $ui-primary-color;
       }
     }
+
+    .positive-hint {
+      color: $valid-value-color;
+      font-weight: 500;
+    }
   }
 
   .simple_form {
diff --git a/app/javascript/styles/components.scss b/app/javascript/styles/components.scss
index 85566a653..a7c982cb2 100644
--- a/app/javascript/styles/components.scss
+++ b/app/javascript/styles/components.scss
@@ -58,37 +58,6 @@
   position: relative;
 }
 
-.column-collapsable {
-  position: relative;
-
-  .column-collapsable__content {
-    overflow: auto;
-    transition: 300ms ease;
-    opacity: 1;
-    max-height: 70vh;
-  }
-
-  &.collapsed .column-collapsable__content {
-    height: 0 !important;
-    opacity: 0;
-  }
-
-  .column-collapsable__button {
-    color: $primary-text-color;
-    background: lighten($ui-base-color, 8%);
-
-    &:hover {
-      color: $primary-text-color;
-      background: lighten($ui-base-color, 8%);
-    }
-  }
-
-  &.collapsed .column-collapsable__button {
-    color: $ui-primary-color;
-    background: lighten($ui-base-color, 4%);
-  }
-}
-
 .column-icon {
   background: lighten($ui-base-color, 4%);
   color: $ui-primary-color;
@@ -670,13 +639,15 @@
 }
 
 .status-check-box {
-  border-bottom: 1px solid lighten($ui-base-color, 8%);
+  border-bottom: 1px solid $ui-secondary-color;
   display: flex;
 
   .status__content {
-    background: lighten($ui-base-color, 4%);
     flex: 1 1 auto;
     padding: 10px;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
   }
 }
 
@@ -1233,20 +1204,22 @@
 
 .image-loader {
   position: relative;
-}
 
-.image-loader__preview-img {
-  position: absolute;
-  top: 0;
-  left: 0;
-  width: 100%;
-  height: 100%;
-  filter: blur(2px);
-}
+  &.image-loader--loading {
+    .image-loader__preview-canvas {
+      filter: blur(2px);
+    }
+  }
 
-.media-modal img.image-loader__preview-img {
-  width: 100%;
-  height: 100%;
+  .image-loader__img {
+    position: absolute;
+    top: 0;
+    left: 0;
+    right: 0;
+    width: 100%;
+    height: 100%;
+    background-image: none;
+  }
 }
 
 .navigation-bar {
@@ -1980,6 +1953,17 @@
   @include limited-single-column('screen and (max-width: 600px)') {
     font-size: 16px;
   }
+
+  &.light {
+    color: $ui-base-color;
+    border-bottom: 2px solid lighten($ui-base-color, 27%);
+
+    &:focus,
+    &:active {
+      color: $ui-base-color;
+      border-bottom-color: $ui-highlight-color;
+    }
+  }
 }
 
 @import 'boost';
@@ -2231,11 +2215,6 @@ button.icon-button.active i.fa-retweet {
   transition: max-height 150ms ease-in-out, opacity 300ms linear;
   opacity: 1;
 
-  & > div {
-    background: lighten($ui-base-color, 8%);
-    padding: 15px;
-  }
-
   &.collapsed {
     max-height: 0;
     opacity: 0.5;
@@ -2246,6 +2225,11 @@ button.icon-button.active i.fa-retweet {
   }
 }
 
+.column-header__collapsible-inner {
+  background: lighten($ui-base-color, 8%);
+  padding: 15px;
+}
+
 .column-header__setting-btn {
   &:hover {
     color: lighten($ui-primary-color, 4%);
@@ -2437,67 +2421,6 @@ button.icon-button.active i.fa-retweet {
   vertical-align: middle;
 }
 
-.report.scrollable {
-  box-sizing: border-box;
-  display: flex;
-  flex-direction: column;
-  max-height: 100%;
-}
-
-.report__target {
-  border-bottom: 1px solid lighten($ui-base-color, 4%);
-  color: $ui-secondary-color;
-  flex: 0 0 auto;
-  padding: 10px;
-
-  strong {
-    display: block;
-    color: $primary-text-color;
-    font-weight: 500;
-  }
-}
-
-.report__statuses {
-  flex: 1 1 auto;
-}
-
-.report__textarea-wrapper {
-  flex: 0 0 100px;
-  padding: 10px;
-}
-
-.report__textarea {
-  background: transparent;
-  box-sizing: border-box;
-  border: 0;
-  border-bottom: 2px solid $ui-primary-color;
-  border-radius: 2px 2px 0 0;
-  color: $primary-text-color;
-  display: block;
-  font-family: inherit;
-  font-size: 14px;
-  margin-bottom: 10px;
-  outline: 0;
-  padding: 7px 4px;
-  resize: vertical;
-  width: 100%;
-
-  &:active,
-  &:focus {
-    border-bottom-color: $ui-highlight-color;
-    background: rgba($base-overlay-background, 0.1);
-  }
-}
-
-.report__submit {
-  margin-top: 10px;
-  overflow: hidden;
-}
-
-.report__submit-button {
-  float: right;
-}
-
 .empty-column-indicator {
   color: lighten($ui-base-color, 20%);
   background: $ui-base-color;
@@ -3086,6 +3009,7 @@ button.icon-button.active i.fa-retweet {
   position: relative;
 
   img,
+  canvas,
   video {
     max-width: 80vw;
     max-height: 80vh;
@@ -3093,7 +3017,8 @@ button.icon-button.active i.fa-retweet {
     height: auto;
   }
 
-  img {
+  img,
+  canvas {
     display: block;
     background: url('../images/void.png') repeat;
   }
@@ -3279,6 +3204,7 @@ button.icon-button.active i.fa-retweet {
 @media screen and (max-width: 400px) {
   .onboarding-modal__page-one {
     flex-direction: column;
+    align-items: normal;
   }
 
   .onboarding-modal__page-one__elephant-friend {
@@ -3393,7 +3319,8 @@ button.icon-button.active i.fa-retweet {
 }
 
 .boost-modal,
-.confirmation-modal {
+.confirmation-modal,
+.report-modal {
   background: lighten($ui-secondary-color, 8%);
   color: $ui-base-color;
   border-radius: 8px;
@@ -3429,7 +3356,8 @@ button.icon-button.active i.fa-retweet {
 }
 
 .boost-modal__action-bar,
-.confirmation-modal__action-bar {
+.confirmation-modal__action-bar,
+.report-modal__action-bar {
   display: flex;
   justify-content: space-between;
   background: $ui-secondary-color;
@@ -3465,6 +3393,23 @@ button.icon-button.active i.fa-retweet {
   }
 }
 
+.report-modal__statuses,
+.report-modal__comment {
+  padding: 10px;
+}
+
+.report-modal__statuses {
+  min-height: 20vh;
+  overflow-y: auto;
+  overflow-x: hidden;
+}
+
+.report-modal__comment {
+  .setting-text {
+    margin-top: 10px;
+  }
+}
+
 .confirmation-modal__action-bar {
   .confirmation-modal__cancel-button {
     background-color: transparent;
@@ -3480,7 +3425,8 @@ button.icon-button.active i.fa-retweet {
   }
 }
 
-.confirmation-modal__container {
+.confirmation-modal__container,
+.report-modal__target {
   padding: 30px;
   font-size: 16px;
   text-align: center;
@@ -3601,10 +3547,15 @@ button.icon-button.active i.fa-retweet {
   background-repeat: no-repeat;
   background-size: cover;
   cursor: zoom-in;
-  display: block;
-  height: 100%;
+  display: flex;
+  align-items: center;
   text-decoration: none;
-  width: 100%;
+  height: 100%;
+
+  &,
+  img {
+    width: 100%;
+  }
 }
 
 .media-gallery__gifv {
diff --git a/app/javascript/styles/forms.scss b/app/javascript/styles/forms.scss
index 059c4a7d8..7a181f36b 100644
--- a/app/javascript/styles/forms.scss
+++ b/app/javascript/styles/forms.scss
@@ -358,7 +358,6 @@ code {
 }
 
 .user_filtered_languages {
-
   & > label {
     font-family: inherit;
     font-size: 16px;
diff --git a/app/javascript/styles/lists.scss b/app/javascript/styles/lists.scss
index 47805663f..6019cd800 100644
--- a/app/javascript/styles/lists.scss
+++ b/app/javascript/styles/lists.scss
@@ -10,7 +10,6 @@
 .recovery-codes {
   list-style: none;
   margin: 0 auto;
-  text-align: center;
 
   li {
     font-size: 125%;
diff --git a/app/javascript/styles/tables.scss b/app/javascript/styles/tables.scss
index f7def8cf3..6e54c59c0 100644
--- a/app/javascript/styles/tables.scss
+++ b/app/javascript/styles/tables.scss
@@ -42,6 +42,18 @@
   strong {
     font-weight: 500;
   }
+
+  &.inline-table {
+    td,
+    th {
+      padding: 8px 0;
+    }
+
+    & > tbody > tr:nth-child(odd) > td,
+    & > tbody > tr:nth-child(odd) > th {
+      background: transparent;
+    }
+  }
 }
 
 samp {
diff --git a/app/mailers/admin_mailer.rb b/app/mailers/admin_mailer.rb
new file mode 100644
index 000000000..fc19a6d40
--- /dev/null
+++ b/app/mailers/admin_mailer.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+class AdminMailer < ApplicationMailer
+  def new_report(recipient, report)
+    @report   = report
+    @me       = recipient
+    @instance = Rails.configuration.x.local_domain
+
+    locale_for_account(@me) do
+      mail to: @me.user_email, subject: I18n.t('admin_mailer.new_report.subject', instance: @instance, id: @report.id)
+    end
+  end
+end
diff --git a/app/mailers/application_mailer.rb b/app/mailers/application_mailer.rb
index e5dbfeeda..2e730c19b 100644
--- a/app/mailers/application_mailer.rb
+++ b/app/mailers/application_mailer.rb
@@ -4,4 +4,12 @@ class ApplicationMailer < ActionMailer::Base
   default from: ENV.fetch('SMTP_FROM_ADDRESS') { 'notifications@localhost' }
   layout 'mailer'
   helper :instance
+
+  protected
+
+  def locale_for_account(account)
+    I18n.with_locale(account.user_locale || I18n.default_locale) do
+      yield
+    end
+  end
 end
diff --git a/app/mailers/notification_mailer.rb b/app/mailers/notification_mailer.rb
index a944db137..12b92bf45 100644
--- a/app/mailers/notification_mailer.rb
+++ b/app/mailers/notification_mailer.rb
@@ -67,12 +67,4 @@ class NotificationMailer < ApplicationMailer
            )
     end
   end
-
-  private
-
-  def locale_for_account(account)
-    I18n.with_locale(account.user_locale || I18n.default_locale) do
-      yield
-    end
-  end
 end
diff --git a/app/models/session_activation.rb b/app/models/session_activation.rb
index 71e9f023c..02a918e8a 100644
--- a/app/models/session_activation.rb
+++ b/app/models/session_activation.rb
@@ -3,36 +3,78 @@
 #
 # Table name: session_activations
 #
-#  id         :integer          not null, primary key
-#  user_id    :integer          not null
-#  session_id :string           not null
-#  created_at :datetime         not null
-#  updated_at :datetime         not null
+#  id              :integer          not null, primary key
+#  user_id         :integer          not null
+#  session_id      :string           not null
+#  created_at      :datetime         not null
+#  updated_at      :datetime         not null
+#  user_agent      :string           default(""), not null
+#  ip              :inet
+#  access_token_id :integer
 #
 
 class SessionActivation < ApplicationRecord
-  LIMIT = Rails.configuration.x.max_session_activations
+  belongs_to :access_token, class_name: 'Doorkeeper::AccessToken', dependent: :destroy
 
-  def self.active?(id)
-    id && where(session_id: id).exists?
+  delegate :token,
+           to: :access_token,
+           allow_nil: true
+
+  def detection
+    @detection ||= Browser.new(user_agent)
   end
 
-  def self.activate(id)
-    activation = create!(session_id: id)
-    purge_old
-    activation
+  def browser
+    detection.id
   end
 
-  def self.deactivate(id)
-    return unless id
-    where(session_id: id).destroy_all
+  def platform
+    detection.platform.id
   end
 
-  def self.purge_old
-    order('created_at desc').offset(LIMIT).destroy_all
+  before_create :assign_access_token
+  before_save   :assign_user_agent
+
+  class << self
+    def active?(id)
+      id && where(session_id: id).exists?
+    end
+
+    def activate(options = {})
+      activation = create!(options)
+      purge_old
+      activation
+    end
+
+    def deactivate(id)
+      return unless id
+      where(session_id: id).destroy_all
+    end
+
+    def purge_old
+      order('created_at desc').offset(Rails.configuration.x.max_session_activations).destroy_all
+    end
+
+    def exclusive(id)
+      where('session_id != ?', id).destroy_all
+    end
   end
 
-  def self.exclusive(id)
-    where('session_id != ?', id).destroy_all
+  private
+
+  def assign_user_agent
+    self.user_agent = '' if user_agent.nil?
+  end
+
+  def assign_access_token
+    superapp = Doorkeeper::Application.find_by(superapp: true)
+
+    return if superapp.nil?
+
+    self.access_token = Doorkeeper::AccessToken.create!(application_id: superapp.id,
+                                                        resource_owner_id: user_id,
+                                                        scopes: 'read write follow',
+                                                        expires_in: Doorkeeper.configuration.access_token_expires_in,
+                                                        use_refresh_token: Doorkeeper.configuration.refresh_token_enabled?)
   end
 end
diff --git a/app/models/user.rb b/app/models/user.rb
index fccf1089b..c31a0c644 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -91,8 +91,10 @@ class User < ApplicationRecord
     settings.auto_play_gif
   end
 
-  def activate_session
-    session_activations.activate(SecureRandom.hex).session_id
+  def activate_session(request)
+    session_activations.activate(session_id: SecureRandom.hex,
+                                 user_agent: request.user_agent,
+                                 ip: request.ip).session_id
   end
 
   def exclusive_session(id)
diff --git a/app/services/send_interaction_service.rb b/app/services/send_interaction_service.rb
index 504f41c72..34c8f9e34 100644
--- a/app/services/send_interaction_service.rb
+++ b/app/services/send_interaction_service.rb
@@ -13,7 +13,8 @@ class SendInteractionService < BaseService
     return if block_notification?
 
     envelope = salmon.pack(@xml, @source_account.keypair)
-    salmon.post(@target_account.salmon_url, envelope)
+    delivery = salmon.post(@target_account.salmon_url, envelope)
+    raise "Delivery failed for #{target_account.salmon_url}: HTTP #{delivery.code}" unless delivery.code > 199 && delivery.code < 300
   end
 
   private
diff --git a/app/views/admin_mailer/new_report.text.erb b/app/views/admin_mailer/new_report.text.erb
new file mode 100644
index 000000000..6fa744bc3
--- /dev/null
+++ b/app/views/admin_mailer/new_report.text.erb
@@ -0,0 +1,5 @@
+<%= display_name(@me) %>,
+
+<%= raw t('admin_mailer.new_report.body', target: @report.target_account.acct, reporter: @report.account.acct) %>
+
+<%= raw t('application_mailer.view')%> <%= admin_report_url(@report) %>
diff --git a/app/views/auth/registrations/_sessions.html.haml b/app/views/auth/registrations/_sessions.html.haml
new file mode 100644
index 000000000..11c0d4e31
--- /dev/null
+++ b/app/views/auth/registrations/_sessions.html.haml
@@ -0,0 +1,23 @@
+%h6= t 'sessions.title'
+%p.muted-hint= t 'sessions.explanation'
+
+%table.table.inline-table
+  %thead
+    %tr
+      %th= t 'sessions.browser'
+      %th= t 'sessions.ip'
+      %th= t 'sessions.activity'
+  %tbody
+    - @sessions.each do |session|
+      %tr
+        %td
+          %span{ title: session.user_agent }= fa_icon session_device_icon(session)
+          = ' '
+          = t 'sessions.description', browser: t("sessions.browsers.#{session.browser}"), platform: t("sessions.platforms.#{session.platform}")
+        %td
+          %samp= session.ip
+        %td
+          - if request.session['auth_id'] == session.session_id
+            = t 'sessions.current_session'
+          - else
+            %time.time-ago{ datetime: session.updated_at.iso8601, title: l(session.updated_at) }= l(session.updated_at)
diff --git a/app/views/auth/registrations/edit.html.haml b/app/views/auth/registrations/edit.html.haml
index 38d4349cb..fbc8d017b 100644
--- a/app/views/auth/registrations/edit.html.haml
+++ b/app/views/auth/registrations/edit.html.haml
@@ -12,6 +12,10 @@
   .actions
     = f.button :button, t('generic.save_changes'), type: :submit
 
+%hr/
+
+= render 'sessions'
+
 - if open_deletion?
   %hr/
 
diff --git a/app/views/settings/two_factor_authentication/recovery_codes/index.html.haml b/app/views/settings/two_factor_authentication/recovery_codes/index.html.haml
index 7d409826e..d47ee840e 100644
--- a/app/views/settings/two_factor_authentication/recovery_codes/index.html.haml
+++ b/app/views/settings/two_factor_authentication/recovery_codes/index.html.haml
@@ -1,7 +1,7 @@
 - content_for :page_title do
   = t('settings.two_factor_authentication')
 
-%p.hint= t('two_factor_authentication.recovery_instructions')
+%p.hint= t('two_factor_authentication.recovery_instructions_html')
 
 %ol.recovery-codes
   - @recovery_codes.each do |code|
diff --git a/app/views/settings/two_factor_authentications/show.html.haml b/app/views/settings/two_factor_authentications/show.html.haml
index 88b5bd20e..8ba42a101 100644
--- a/app/views/settings/two_factor_authentications/show.html.haml
+++ b/app/views/settings/two_factor_authentications/show.html.haml
@@ -1,26 +1,34 @@
 - content_for :page_title do
   = t('settings.two_factor_authentication')
 
-.simple_form
-  %p.hint
-    = t('two_factor_authentication.description_html')
+- if current_user.otp_required_for_login
+  %p.positive-hint
+    = fa_icon 'check'
+    = ' '
+    = t 'two_factor_authentication.enabled'
 
-  - if current_user.otp_required_for_login
-    = link_to t('two_factor_authentication.disable'),
-      settings_two_factor_authentication_path,
-      data: { method: :delete },
-      class: 'block-button'
-  - else
-    = link_to t('two_factor_authentication.setup'),
-      settings_two_factor_authentication_path,
-      data: { method: :post },
-      class: 'block-button'
+  %hr/
 
-- if current_user.otp_required_for_login
-  .simple_form
-    %p.hint
-      = t('two_factor_authentication.lost_recovery_codes')
+  = simple_form_for @confirmation, url: settings_two_factor_authentication_path, method: :delete do |f|
+    = f.input :code, hint: t('two_factor_authentication.code_hint'), placeholder: t('simple_form.labels.defaults.otp_attempt')
+
+    .actions
+      = f.button :button, t('two_factor_authentication.disable'), type: :submit
+
+  %hr/
+
+  %h6= t('two_factor_authentication.recovery_codes')
+  %p.muted-hint
+    = t('two_factor_authentication.lost_recovery_codes')
     = link_to t('two_factor_authentication.generate_recovery_codes'),
       settings_two_factor_authentication_recovery_codes_path,
+      data: { method: :post }
+
+- else
+  .simple_form
+    %p.hint= t('two_factor_authentication.description_html')
+
+    = link_to t('two_factor_authentication.setup'),
+      settings_two_factor_authentication_path,
       data: { method: :post },
       class: 'block-button'
diff --git a/app/views/user_mailer/password_change.en.html.erb b/app/views/user_mailer/password_change.en.html.erb
index a1bc77463..414e05a29 100644
--- a/app/views/user_mailer/password_change.en.html.erb
+++ b/app/views/user_mailer/password_change.en.html.erb
@@ -1,3 +1,3 @@
 <p>Hello <%= @resource.email %>!</p>
 
-<p>We're contacting you to notify you that your password on Mastodon has been changed.</p>
+<p>We're contacting you to notify you that your password on <%= @instance %> has been changed.</p>
diff --git a/app/views/user_mailer/password_change.en.text.erb b/app/views/user_mailer/password_change.en.text.erb
index 27581e604..3ae461c97 100644
--- a/app/views/user_mailer/password_change.en.text.erb
+++ b/app/views/user_mailer/password_change.en.text.erb
@@ -1,3 +1,3 @@
 Hello <%= @resource.email %>!
 
-We're contacting you to notify you that your password on Mastodon has been changed.
+We're contacting you to notify you that your password on <%= @instance %> has been changed.
diff --git a/app/views/user_mailer/reset_password_instructions.en.html.erb b/app/views/user_mailer/reset_password_instructions.en.html.erb
index 643b43319..cfb129e22 100644
--- a/app/views/user_mailer/reset_password_instructions.en.html.erb
+++ b/app/views/user_mailer/reset_password_instructions.en.html.erb
@@ -1,6 +1,6 @@
 <p>Hello <%= @resource.email %>!</p>
 
-<p>Someone has requested a link to change your password on Mastodon. You can do this through the link below.</p>
+<p>Someone has requested a link to change your password on <%= @instance %>. You can do this through the link below.</p>
 
 <p><%= link_to 'Change my password', edit_password_url(@resource, reset_password_token: @token) %></p>
 
diff --git a/app/views/user_mailer/reset_password_instructions.en.text.erb b/app/views/user_mailer/reset_password_instructions.en.text.erb
index fe73b0165..7ed22dc2c 100644
--- a/app/views/user_mailer/reset_password_instructions.en.text.erb
+++ b/app/views/user_mailer/reset_password_instructions.en.text.erb
@@ -1,6 +1,6 @@
 Hello <%= @resource.email %>!
 
-Someone has requested a link to change your password on Mastodon. You can do this through the link below.
+Someone has requested a link to change your password on <%= @instance %>. You can do this through the link below.
 
 <%= edit_password_url(@resource, reset_password_token: @token) %>
 
diff --git a/app/workers/scheduler/doorkeeper_cleanup_scheduler.rb b/app/workers/scheduler/doorkeeper_cleanup_scheduler.rb
new file mode 100644
index 000000000..6488798cd
--- /dev/null
+++ b/app/workers/scheduler/doorkeeper_cleanup_scheduler.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+require 'sidekiq-scheduler'
+
+class Scheduler::DoorkeeperCleanupScheduler
+  include Sidekiq::Worker
+
+  def perform
+    Doorkeeper::AccessToken.where('revoked_at IS NOT NULL').where('revoked_at < NOW()').delete_all
+    Doorkeeper::AccessGrant.where('revoked_at IS NOT NULL').where('revoked_at < NOW()').delete_all
+  end
+end