about summary refs log tree commit diff
path: root/app
diff options
context:
space:
mode:
Diffstat (limited to 'app')
-rw-r--r--app/controllers/admin/settings_controller.rb2
-rw-r--r--app/controllers/api/v1/accounts/relationships_controller.rb2
-rw-r--r--app/controllers/api/v1/media_controller.rb2
-rw-r--r--app/controllers/application_controller.rb2
-rw-r--r--app/controllers/auth/sessions_controller.rb9
-rw-r--r--app/controllers/settings/exports_controller.rb12
-rw-r--r--app/javascript/images/icon_file_download.svg4
-rw-r--r--app/javascript/images/mailer/icon_file_download.pngbin0 -> 271 bytes
-rw-r--r--app/javascript/images/reticle.pngbin0 -> 3053 bytes
-rw-r--r--app/javascript/mastodon/actions/compose.js4
-rw-r--r--app/javascript/mastodon/components/media_gallery.js71
-rw-r--r--app/javascript/mastodon/features/compose/components/upload.js20
-rw-r--r--app/javascript/mastodon/features/compose/containers/upload_container.js7
-rw-r--r--app/javascript/mastodon/features/ui/components/focal_point_modal.js122
-rw-r--r--app/javascript/mastodon/features/ui/components/modal_root.js2
-rw-r--r--app/javascript/mastodon/features/video/index.js6
-rw-r--r--app/javascript/mastodon/reducers/compose.js8
-rw-r--r--app/javascript/styles/mastodon/about.scss637
-rw-r--r--app/javascript/styles/mastodon/components.scss96
-rw-r--r--app/javascript/styles/mastodon/containers.scss2
-rw-r--r--app/lib/activitypub/activity/create.rb2
-rw-r--r--app/lib/activitypub/adapter.rb1
-rw-r--r--app/lib/fast_geometry_parser.rb11
-rw-r--r--app/mailers/user_mailer.rb12
-rw-r--r--app/models/backup.rb22
-rw-r--r--app/models/concerns/account_avatar.rb12
-rw-r--r--app/models/concerns/account_header.rb12
-rw-r--r--app/models/concerns/omniauthable.rb7
-rw-r--r--app/models/media_attachment.rb48
-rw-r--r--app/models/preview_card.rb13
-rw-r--r--app/models/site_upload.rb4
-rw-r--r--app/models/status.rb2
-rw-r--r--app/models/user.rb1
-rw-r--r--app/policies/application_policy.rb4
-rw-r--r--app/policies/backup_policy.rb9
-rw-r--r--app/presenters/account_relationships_presenter.rb2
-rw-r--r--app/presenters/instance_presenter.rb4
-rw-r--r--app/serializers/activitypub/collection_serializer.rb4
-rw-r--r--app/serializers/activitypub/image_serializer.rb9
-rw-r--r--app/services/backup_service.rb128
-rw-r--r--app/views/about/_forms.html.haml14
-rw-r--r--app/views/about/_links.html.haml2
-rw-r--r--app/views/about/_registration.html.haml2
-rw-r--r--app/views/about/more.html.haml16
-rw-r--r--app/views/about/show.html.haml116
-rw-r--r--app/views/about/terms.html.haml2
-rw-r--r--app/views/admin/settings/edit.html.haml1
-rw-r--r--app/views/layouts/auth.html.haml2
-rw-r--r--app/views/layouts/modal.html.haml2
-rw-r--r--app/views/layouts/public.html.haml2
-rw-r--r--app/views/settings/exports/show.html.haml23
-rw-r--r--app/views/user_mailer/backup_ready.html.haml59
-rw-r--r--app/views/user_mailer/backup_ready.text.erb7
-rw-r--r--app/workers/backup_worker.rb17
-rw-r--r--app/workers/scheduler/backup_cleanup_scheduler.rb16
55 files changed, 1232 insertions, 364 deletions
diff --git a/app/controllers/admin/settings_controller.rb b/app/controllers/admin/settings_controller.rb
index a6214dc3f..ce3208209 100644
--- a/app/controllers/admin/settings_controller.rb
+++ b/app/controllers/admin/settings_controller.rb
@@ -16,6 +16,7 @@ module Admin
       show_staff_badge
       bootstrap_timeline_accounts
       thumbnail
+      hero
       min_invite_role
       activity_api_enabled
       peers_api_enabled
@@ -34,6 +35,7 @@ module Admin
 
     UPLOAD_SETTINGS = %w(
       thumbnail
+      hero
     ).freeze
 
     def edit
diff --git a/app/controllers/api/v1/accounts/relationships_controller.rb b/app/controllers/api/v1/accounts/relationships_controller.rb
index 6cc3da498..70236d1a8 100644
--- a/app/controllers/api/v1/accounts/relationships_controller.rb
+++ b/app/controllers/api/v1/accounts/relationships_controller.rb
@@ -21,6 +21,6 @@ class Api::V1::Accounts::RelationshipsController < Api::BaseController
   end
 
   def account_ids
-    @_account_ids ||= Array(params[:id]).map(&:to_i)
+    Array(params[:id]).map(&:to_i)
   end
 end
diff --git a/app/controllers/api/v1/media_controller.rb b/app/controllers/api/v1/media_controller.rb
index 9f330f0df..d4e6337e7 100644
--- a/app/controllers/api/v1/media_controller.rb
+++ b/app/controllers/api/v1/media_controller.rb
@@ -27,7 +27,7 @@ class Api::V1::MediaController < Api::BaseController
   private
 
   def media_params
-    params.permit(:file, :description)
+    params.permit(:file, :description, :focus)
   end
 
   def file_type_error
diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
index 7534b5375..a296d96db 100644
--- a/app/controllers/application_controller.rb
+++ b/app/controllers/application_controller.rb
@@ -36,7 +36,7 @@ class ApplicationController < ActionController::Base
   end
 
   def store_current_location
-    store_location_for(:user, request.url)
+    store_location_for(:user, request.url) unless request.format == :json
   end
 
   def require_admin!
diff --git a/app/controllers/auth/sessions_controller.rb b/app/controllers/auth/sessions_controller.rb
index ce9cf98d7..475cd540a 100644
--- a/app/controllers/auth/sessions_controller.rb
+++ b/app/controllers/auth/sessions_controller.rb
@@ -11,6 +11,15 @@ class Auth::SessionsController < Devise::SessionsController
   prepend_before_action :set_pack
   before_action :set_instance_presenter, only: [:new]
 
+  def new
+    Devise.omniauth_configs.each do |provider, config|
+      if config.strategy.redirect_at_sign_in
+        return redirect_to(omniauth_authorize_path(resource_name, provider))
+      end
+    end
+    super
+  end
+
   def create
     super do |resource|
       remember_me(resource)
diff --git a/app/controllers/settings/exports_controller.rb b/app/controllers/settings/exports_controller.rb
index 9c03ece86..6369a3aeb 100644
--- a/app/controllers/settings/exports_controller.rb
+++ b/app/controllers/settings/exports_controller.rb
@@ -2,6 +2,16 @@
 
 class Settings::ExportsController < Settings::BaseController
   def show
-    @export = Export.new(current_account)
+    @export  = Export.new(current_account)
+    @backups = current_user.backups
+  end
+
+  def create
+    authorize :backup, :create?
+
+    backup = current_user.backups.create!
+    BackupWorker.perform_async(backup.id)
+
+    redirect_to settings_export_path
   end
 end
diff --git a/app/javascript/images/icon_file_download.svg b/app/javascript/images/icon_file_download.svg
new file mode 100644
index 000000000..53e97e4f8
--- /dev/null
+++ b/app/javascript/images/icon_file_download.svg
@@ -0,0 +1,4 @@
+<svg fill="#FFFFFF" height="24" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg">
+    <path d="M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z"/>
+    <path d="M0 0h24v24H0z" fill="none"/>
+</svg>
\ No newline at end of file
diff --git a/app/javascript/images/mailer/icon_file_download.png b/app/javascript/images/mailer/icon_file_download.png
new file mode 100644
index 000000000..8a6a8673b
--- /dev/null
+++ b/app/javascript/images/mailer/icon_file_download.png
Binary files differdiff --git a/app/javascript/images/reticle.png b/app/javascript/images/reticle.png
new file mode 100644
index 000000000..998994f5c
--- /dev/null
+++ b/app/javascript/images/reticle.png
Binary files differdiff --git a/app/javascript/mastodon/actions/compose.js b/app/javascript/mastodon/actions/compose.js
index 8a35049b3..1732ff189 100644
--- a/app/javascript/mastodon/actions/compose.js
+++ b/app/javascript/mastodon/actions/compose.js
@@ -178,11 +178,11 @@ export function uploadCompose(files) {
   };
 };
 
-export function changeUploadCompose(id, description) {
+export function changeUploadCompose(id, params) {
   return (dispatch, getState) => {
     dispatch(changeUploadComposeRequest());
 
-    api(getState).put(`/api/v1/media/${id}`, { description }).then(response => {
+    api(getState).put(`/api/v1/media/${id}`, params).then(response => {
       dispatch(changeUploadComposeSuccess(response.data));
     }).catch(error => {
       dispatch(changeUploadComposeFail(id, error));
diff --git a/app/javascript/mastodon/components/media_gallery.js b/app/javascript/mastodon/components/media_gallery.js
index a3ffc45ea..9e1bb77c2 100644
--- a/app/javascript/mastodon/components/media_gallery.js
+++ b/app/javascript/mastodon/components/media_gallery.js
@@ -12,6 +12,26 @@ const messages = defineMessages({
   toggle_visible: { id: 'media_gallery.toggle_visible', defaultMessage: 'Toggle visibility' },
 });
 
+const shiftToPoint = (containerToImageRatio, containerSize, imageSize, focusSize, toMinus) => {
+  const containerCenter = Math.floor(containerSize / 2);
+  const focusFactor     = (focusSize + 1) / 2;
+  const scaledImage     = Math.floor(imageSize / containerToImageRatio);
+
+  let focus = Math.floor(focusFactor * scaledImage);
+
+  if (toMinus) focus = scaledImage - focus;
+
+  let focusOffset = focus - containerCenter;
+
+  const remainder = scaledImage - focus;
+  const containerRemainder = containerSize - containerCenter;
+
+  if (remainder < containerRemainder) focusOffset -= containerRemainder - remainder;
+  if (focusOffset < 0) focusOffset = 0;
+
+  return (focusOffset * -100 / containerSize) + '%';
+};
+
 class Item extends React.PureComponent {
 
   static contextTypes = {
@@ -24,6 +44,8 @@ class Item extends React.PureComponent {
     index: PropTypes.number.isRequired,
     size: PropTypes.number.isRequired,
     onClick: PropTypes.func.isRequired,
+    containerWidth: PropTypes.number,
+    containerHeight: PropTypes.number,
   };
 
   static defaultProps = {
@@ -62,7 +84,7 @@ class Item extends React.PureComponent {
   }
 
   render () {
-    const { attachment, index, size, standalone } = this.props;
+    const { attachment, index, size, standalone, containerWidth, containerHeight } = this.props;
 
     let width  = 50;
     let height = 100;
@@ -116,16 +138,40 @@ class Item extends React.PureComponent {
     let thumbnail = '';
 
     if (attachment.get('type') === 'image') {
-      const previewUrl = attachment.get('preview_url');
+      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 originalUrl    = attachment.get('url');
+      const originalWidth  = attachment.getIn(['meta', 'original', 'width']);
+      const originalHeight = attachment.getIn(['meta', 'original', 'height']);
 
       const hasSize = typeof originalWidth === 'number' && typeof previewWidth === 'number';
 
       const srcSet = hasSize ? `${originalUrl} ${originalWidth}w, ${previewUrl} ${previewWidth}w` : null;
-      const sizes = hasSize ? `(min-width: 1025px) ${320 * (width / 100)}px, ${width}vw` : null;
+      const sizes  = hasSize ? `(min-width: 1025px) ${320 * (width / 100)}px, ${width}vw` : null;
+
+      const focusX     = attachment.getIn(['meta', 'focus', 'x']);
+      const focusY     = attachment.getIn(['meta', 'focus', 'y']);
+      const imageStyle = {};
+
+      if (originalWidth && originalHeight && containerWidth && containerHeight && focusX && focusY) {
+        const widthRatio  = originalWidth / (containerWidth * (width / 100));
+        const heightRatio = originalHeight / (containerHeight * (height / 100));
+
+        let hShift = 0;
+        let vShift = 0;
+
+        if (widthRatio > heightRatio) {
+          hShift = shiftToPoint(heightRatio, (containerWidth * (width / 100)), originalWidth, focusX);
+        } else if(widthRatio < heightRatio) {
+          vShift = shiftToPoint(widthRatio, (containerHeight * (height / 100)), originalHeight, focusY, true);
+        }
+
+        imageStyle.top  = vShift;
+        imageStyle.left = hShift;
+      } else {
+        imageStyle.height = '100%';
+      }
 
       thumbnail = (
         <a
@@ -134,7 +180,14 @@ class Item extends React.PureComponent {
           onClick={this.handleClick}
           target='_blank'
         >
-          <img src={previewUrl} srcSet={srcSet} sizes={sizes} alt={attachment.get('description')} title={attachment.get('description')} />
+          <img
+            src={previewUrl}
+            srcSet={srcSet}
+            sizes={sizes}
+            alt={attachment.get('description')}
+            title={attachment.get('description')}
+            style={imageStyle}
+          />
         </a>
       );
     } else if (attachment.get('type') === 'gifv') {
@@ -205,7 +258,7 @@ export default class MediaGallery extends React.PureComponent {
   }
 
   handleRef = (node) => {
-    if (node && this.isStandaloneEligible()) {
+    if (node /*&& this.isStandaloneEligible()*/) {
       // offsetWidth triggers a layout, so only calculate when we need to
       this.setState({
         width: node.offsetWidth,
@@ -256,12 +309,12 @@ export default class MediaGallery extends React.PureComponent {
       if (this.isStandaloneEligible()) {
         children = <Item standalone onClick={this.handleClick} attachment={media.get(0)} />;
       } else {
-        children = media.take(4).map((attachment, i) => <Item key={attachment.get('id')} onClick={this.handleClick} attachment={attachment} index={i} size={size} />);
+        children = media.take(4).map((attachment, i) => <Item key={attachment.get('id')} onClick={this.handleClick} attachment={attachment} index={i} size={size} containerWidth={width} containerHeight={height} />);
       }
     }
 
     return (
-      <div className='media-gallery' style={style}>
+      <div className='media-gallery' style={style} ref={this.handleRef}>
         <div className={classNames('spoiler-button', { 'spoiler-button--visible': visible })}>
           <IconButton title={intl.formatMessage(messages.toggle_visible)} icon={visible ? 'eye' : 'eye-slash'} overlay onClick={this.handleOpen} />
         </div>
diff --git a/app/javascript/mastodon/features/compose/components/upload.js b/app/javascript/mastodon/features/compose/components/upload.js
index 3a3d17710..61b2d19e0 100644
--- a/app/javascript/mastodon/features/compose/components/upload.js
+++ b/app/javascript/mastodon/features/compose/components/upload.js
@@ -1,15 +1,13 @@
 import React from 'react';
 import ImmutablePropTypes from 'react-immutable-proptypes';
 import PropTypes from 'prop-types';
-import IconButton from '../../../components/icon_button';
 import Motion from '../../ui/util/optional_motion';
 import spring from 'react-motion/lib/spring';
 import ImmutablePureComponent from 'react-immutable-pure-component';
-import { defineMessages, injectIntl } from 'react-intl';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
 import classNames from 'classnames';
 
 const messages = defineMessages({
-  undo: { id: 'upload_form.undo', defaultMessage: 'Undo' },
   description: { id: 'upload_form.description', defaultMessage: 'Describe for the visually impaired' },
 });
 
@@ -21,6 +19,7 @@ export default class Upload extends ImmutablePureComponent {
     intl: PropTypes.object.isRequired,
     onUndo: PropTypes.func.isRequired,
     onDescriptionChange: PropTypes.func.isRequired,
+    onOpenFocalPoint: PropTypes.func.isRequired,
   };
 
   state = {
@@ -33,6 +32,10 @@ export default class Upload extends ImmutablePureComponent {
     this.props.onUndo(this.props.media.get('id'));
   }
 
+  handleFocalPointClick = () => {
+    this.props.onOpenFocalPoint(this.props.media.get('id'));
+  }
+
   handleInputChange = e => {
     this.setState({ dirtyDescription: e.target.value });
   }
@@ -63,13 +66,20 @@ export default class Upload extends ImmutablePureComponent {
     const { intl, media } = this.props;
     const active          = this.state.hovered || this.state.focused;
     const description     = this.state.dirtyDescription || (this.state.dirtyDescription !== '' && media.get('description')) || '';
+    const focusX = media.getIn(['meta', 'focus', 'x']);
+    const focusY = media.getIn(['meta', 'focus', 'y']);
+    const x = ((focusX /  2) + .5) * 100;
+    const y = ((focusY / -2) + .5) * 100;
 
     return (
       <div className='compose-form__upload' onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}>
         <Motion defaultStyle={{ scale: 0.8 }} style={{ scale: spring(1, { stiffness: 180, damping: 12 }) }}>
           {({ scale }) => (
-            <div className='compose-form__upload-thumbnail' style={{ transform: `scale(${scale})`, backgroundImage: `url(${media.get('preview_url')})` }}>
-              <IconButton icon='times' title={intl.formatMessage(messages.undo)} size={36} onClick={this.handleUndoClick} />
+            <div className='compose-form__upload-thumbnail' style={{ transform: `scale(${scale})`, backgroundImage: `url(${media.get('preview_url')})`, backgroundPosition: `${x}% ${y}%` }}>
+              <div className={classNames('compose-form__upload__actions', { active })}>
+                <button className='icon-button' onClick={this.handleUndoClick}><i className='fa fa-times' /> <FormattedMessage id='upload_form.undo' defaultMessage='Undo' /></button>
+                {media.get('type') === 'image' && <button className='icon-button' onClick={this.handleFocalPointClick}><i className='fa fa-crosshairs' /> <FormattedMessage id='upload_form.focus' defaultMessage='Crop' /></button>}
+              </div>
 
               <div className={classNames('compose-form__upload-description', { active })}>
                 <label>
diff --git a/app/javascript/mastodon/features/compose/containers/upload_container.js b/app/javascript/mastodon/features/compose/containers/upload_container.js
index ca9c3b704..d6b57e5ff 100644
--- a/app/javascript/mastodon/features/compose/containers/upload_container.js
+++ b/app/javascript/mastodon/features/compose/containers/upload_container.js
@@ -1,6 +1,7 @@
 import { connect } from 'react-redux';
 import Upload from '../components/upload';
 import { undoUploadCompose, changeUploadCompose } from '../../../actions/compose';
+import { openModal } from '../../../actions/modal';
 
 const mapStateToProps = (state, { id }) => ({
   media: state.getIn(['compose', 'media_attachments']).find(item => item.get('id') === id),
@@ -13,7 +14,11 @@ const mapDispatchToProps = dispatch => ({
   },
 
   onDescriptionChange: (id, description) => {
-    dispatch(changeUploadCompose(id, description));
+    dispatch(changeUploadCompose(id, { description }));
+  },
+
+  onOpenFocalPoint: id => {
+    dispatch(openModal('FOCAL_POINT', { id }));
   },
 
 });
diff --git a/app/javascript/mastodon/features/ui/components/focal_point_modal.js b/app/javascript/mastodon/features/ui/components/focal_point_modal.js
new file mode 100644
index 000000000..ee5c791d4
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/components/focal_point_modal.js
@@ -0,0 +1,122 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import { connect } from 'react-redux';
+import ImageLoader from './image_loader';
+import classNames from 'classnames';
+import { changeUploadCompose } from '../../../actions/compose';
+import { getPointerPosition } from '../../video';
+
+const mapStateToProps = (state, { id }) => ({
+  media: state.getIn(['compose', 'media_attachments']).find(item => item.get('id') === id),
+});
+
+const mapDispatchToProps = (dispatch, { id }) => ({
+
+  onSave: (x, y) => {
+    dispatch(changeUploadCompose(id, { focus: `${x.toFixed(2)},${y.toFixed(2)}` }));
+  },
+
+});
+
+@connect(mapStateToProps, mapDispatchToProps)
+export default class FocalPointModal extends ImmutablePureComponent {
+
+  static propTypes = {
+    media: ImmutablePropTypes.map.isRequired,
+  };
+
+  state = {
+    x: 0,
+    y: 0,
+    focusX: 0,
+    focusY: 0,
+    dragging: false,
+  };
+
+  componentWillMount () {
+    this.updatePositionFromMedia(this.props.media);
+  }
+
+  componentWillReceiveProps (nextProps) {
+    if (this.props.media.get('id') !== nextProps.media.get('id')) {
+      this.updatePositionFromMedia(nextProps.media);
+    }
+  }
+
+  componentWillUnmount () {
+    document.removeEventListener('mousemove', this.handleMouseMove);
+    document.removeEventListener('mouseup', this.handleMouseUp);
+  }
+
+  handleMouseDown = e => {
+    document.addEventListener('mousemove', this.handleMouseMove);
+    document.addEventListener('mouseup', this.handleMouseUp);
+
+    this.updatePosition(e);
+    this.setState({ dragging: true });
+  }
+
+  handleMouseMove = e => {
+    this.updatePosition(e);
+  }
+
+  handleMouseUp = () => {
+    document.removeEventListener('mousemove', this.handleMouseMove);
+    document.removeEventListener('mouseup', this.handleMouseUp);
+
+    this.setState({ dragging: false });
+    this.props.onSave(this.state.focusX, this.state.focusY);
+  }
+
+  updatePosition = e => {
+    const { x, y } = getPointerPosition(this.node, e);
+    const focusX   = (x - .5) *  2;
+    const focusY   = (y - .5) * -2;
+
+    this.setState({ x, y, focusX, focusY });
+  }
+
+  updatePositionFromMedia = media => {
+    const focusX = media.getIn(['meta', 'focus', 'x']);
+    const focusY = media.getIn(['meta', 'focus', 'y']);
+
+    if (focusX && focusY) {
+      const x = (focusX /  2) + .5;
+      const y = (focusY / -2) + .5;
+
+      this.setState({ x, y, focusX, focusY });
+    } else {
+      this.setState({ x: 0.5, y: 0.5, focusX: 0, focusY: 0 });
+    }
+  }
+
+  setRef = c => {
+    this.node = c;
+  }
+
+  render () {
+    const { media } = this.props;
+    const { x, y, dragging } = this.state;
+
+    const width  = media.getIn(['meta', 'original', 'width']) || null;
+    const height = media.getIn(['meta', 'original', 'height']) || null;
+
+    return (
+      <div className='modal-root__modal media-modal'>
+        <div className={classNames('media-modal__content focal-point', { dragging })} ref={this.setRef}>
+          <ImageLoader
+            previewSrc={media.get('preview_url')}
+            src={media.get('url')}
+            width={width}
+            height={height}
+          />
+
+          <div className='focal-point__reticle' style={{ top: `${y * 100}%`, left: `${x * 100}%` }} />
+          <div className='focal-point__overlay' onMouseDown={this.handleMouseDown} />
+        </div>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/mastodon/features/ui/components/modal_root.js b/app/javascript/mastodon/features/ui/components/modal_root.js
index 5839ba40a..20bf21153 100644
--- a/app/javascript/mastodon/features/ui/components/modal_root.js
+++ b/app/javascript/mastodon/features/ui/components/modal_root.js
@@ -8,6 +8,7 @@ import MediaModal from './media_modal';
 import VideoModal from './video_modal';
 import BoostModal from './boost_modal';
 import ConfirmationModal from './confirmation_modal';
+import FocalPointModal from './focal_point_modal';
 import {
   OnboardingModal,
   MuteModal,
@@ -27,6 +28,7 @@ const MODAL_COMPONENTS = {
   'ACTIONS': () => Promise.resolve({ default: ActionsModal }),
   'EMBED': EmbedModal,
   'LIST_EDITOR': ListEditor,
+  'FOCAL_POINT': () => Promise.resolve({ default: FocalPointModal }),
 };
 
 export default class ModalRoot extends React.PureComponent {
diff --git a/app/javascript/mastodon/features/video/index.js b/app/javascript/mastodon/features/video/index.js
index 6335d84b6..c81a5cb5f 100644
--- a/app/javascript/mastodon/features/video/index.js
+++ b/app/javascript/mastodon/features/video/index.js
@@ -30,7 +30,7 @@ const formatTime = secondsNum => {
   return (hours === '00' ? '' : `${hours}:`) + `${minutes}:${seconds}`;
 };
 
-const findElementPosition = el => {
+export const findElementPosition = el => {
   let box;
 
   if (el.getBoundingClientRect && el.parentNode) {
@@ -61,7 +61,7 @@ const findElementPosition = el => {
   };
 };
 
-const getPointerPosition = (el, event) => {
+export const getPointerPosition = (el, event) => {
   const position = {};
   const box = findElementPosition(el);
   const boxW = el.offsetWidth;
@@ -77,7 +77,7 @@ const getPointerPosition = (el, event) => {
     pageY = event.changedTouches[0].pageY;
   }
 
-  position.y = Math.max(0, Math.min(1, ((boxY - pageY) + boxH) / boxH));
+  position.y = Math.max(0, Math.min(1, (pageY - boxY) / boxH));
   position.x = Math.max(0, Math.min(1, (pageX - boxX) / boxW));
 
   return position;
diff --git a/app/javascript/mastodon/reducers/compose.js b/app/javascript/mastodon/reducers/compose.js
index c709fb88c..1358fb4aa 100644
--- a/app/javascript/mastodon/reducers/compose.js
+++ b/app/javascript/mastodon/reducers/compose.js
@@ -34,7 +34,7 @@ import uuid from '../uuid';
 import { me } from '../initial_state';
 
 const initialState = ImmutableMap({
-  mounted: false,
+  mounted: 0,
   sensitive: false,
   spoiler: false,
   spoiler_text: '',
@@ -159,10 +159,10 @@ export default function compose(state = initialState, action) {
   case STORE_HYDRATE:
     return hydrate(state, action.state.get('compose'));
   case COMPOSE_MOUNT:
-    return state.set('mounted', true);
+    return state.set('mounted', state.get('mounted') + 1);
   case COMPOSE_UNMOUNT:
     return state
-      .set('mounted', false)
+      .set('mounted', Math.max(state.get('mounted') - 1, 0))
       .set('is_composing', false);
   case COMPOSE_SENSITIVITY_CHANGE:
     return state.withMutations(map => {
@@ -265,7 +265,7 @@ export default function compose(state = initialState, action) {
       .set('is_submitting', false)
       .update('media_attachments', list => list.map(item => {
         if (item.get('id') === action.media.id) {
-          return item.set('description', action.media.description);
+          return fromJS(action.media);
         }
 
         return item;
diff --git a/app/javascript/styles/mastodon/about.scss b/app/javascript/styles/mastodon/about.scss
index 0806171be..a95b75984 100644
--- a/app/javascript/styles/mastodon/about.scss
+++ b/app/javascript/styles/mastodon/about.scss
@@ -1,3 +1,130 @@
+$maximum-width: 1235px;
+$fluid-breakpoint: $maximum-width + 20px;
+$column-breakpoint: 700px;
+$small-breakpoint: 960px;
+
+.container {
+  box-sizing: border-box;
+  max-width: $maximum-width;
+  margin: 0 auto;
+  position: relative;
+
+  @media screen and (max-width: $fluid-breakpoint) {
+    width: 100%;
+    padding: 0 10px;
+  }
+}
+
+.show-xs,
+.show-sm {
+  display: none;
+}
+
+.show-m {
+  display: block;
+}
+
+@media screen and (max-width: $small-breakpoint) {
+  .hide-sm {
+    display: none !important;
+  }
+
+  .show-sm {
+    display: block !important;
+  }
+}
+
+@media screen and (max-width: $column-breakpoint) {
+  .hide-xs {
+    display: none !important;
+  }
+
+  .show-xs {
+    display: block !important;
+  }
+}
+
+.row {
+  display: flex;
+  flex-wrap: wrap;
+  margin: 0 -5px;
+
+  @for $i from 1 through 15 {
+    .column-#{$i} {
+      box-sizing: border-box;
+      min-height: 1px;
+      flex: 0 0 percentage($i / 15);
+      max-width: percentage($i / 15);
+      padding: 0 5px;
+
+      @media screen and (max-width: $small-breakpoint) {
+        &-sm {
+          box-sizing: border-box;
+          min-height: 1px;
+          flex: 0 0 percentage($i / 15);
+          max-width: percentage($i / 15);
+          padding: 0 5px;
+
+          @media screen and (max-width: $column-breakpoint) {
+            max-width: 100%;
+            flex: 0 0 100%;
+            margin-bottom: 10px;
+
+            &:last-child {
+              margin-bottom: 0;
+            }
+          }
+        }
+      }
+
+      @media screen and (max-width: $column-breakpoint) {
+        max-width: 100%;
+        flex: 0 0 100%;
+        margin-bottom: 10px;
+
+        &:last-child {
+          margin-bottom: 0;
+        }
+      }
+    }
+  }
+}
+
+.column-flex {
+  display: flex;
+  flex-direction: column;
+}
+
+.separator-or {
+  position: relative;
+  margin: 40px 0;
+  text-align: center;
+
+  &::before {
+    content: "";
+    display: block;
+    width: 100%;
+    height: 0;
+    border-bottom: 1px solid rgba($ui-base-lighter-color, .6);
+    position: absolute;
+    top: 50%;
+    left: 0;
+  }
+
+  span {
+    display: inline-block;
+    background: $ui-base-color;
+    font-size: 12px;
+    font-weight: 500;
+    color: $ui-primary-color;
+    text-transform: uppercase;
+    position: relative;
+    z-index: 1;
+    padding: 0 8px;
+    cursor: default;
+  }
+}
+
 .landing-page {
   p,
   li {
@@ -116,10 +243,14 @@
   }
 
   hr {
-    border-color: rgba($ui-base-lighter-color, .6);
+    width: 100%;
+    height: 0;
+    border: 0;
+    border-bottom: 1px solid rgba($ui-base-lighter-color, .6);
+    margin: 20px 0;
   }
 
-  .container {
+  .container-alt {
     width: 100%;
     box-sizing: border-box;
     max-width: 800px;
@@ -152,24 +283,20 @@
         }
       }
     }
+  }
 
-    .mascot-container {
-      max-width: 800px;
-      margin: 0 auto;
-      position: absolute;
-      top: 0;
-      left: 0;
-      right: 0;
-      height: 100%;
+  .brand {
+    a {
+      padding-left: 0;
+      padding-right: 0;
+      color: $white;
     }
 
-    .mascot {
-      position: absolute;
-      bottom: -14px;
-      width: auto;
-      height: auto;
-      left: 60px;
-      z-index: 3;
+    img {
+      height: 32px;
+      position: relative;
+      top: 4px;
+      left: -10px;
     }
   }
 
@@ -177,7 +304,7 @@
     line-height: 30px;
     overflow: hidden;
 
-    .container {
+    .container-alt {
       display: flex;
       justify-content: space-between;
     }
@@ -203,21 +330,6 @@
         }
       }
 
-      .brand {
-        a {
-          padding-left: 0;
-          padding-right: 0;
-          color: $white;
-        }
-
-        img {
-          height: 32px;
-          position: relative;
-          top: 4px;
-          left: -10px;
-        }
-      }
-
       ul {
         list-style: none;
         margin: 0;
@@ -243,53 +355,6 @@
       align-items: center;
       position: relative;
 
-      .floats {
-        position: absolute;
-        width: 100%;
-        height: 100%;
-        top: 0;
-        left: 0;
-
-        div {
-          position: absolute;
-          transition: all 0.1s linear;
-          animation-name: floating;
-          animation-iteration-count: infinite;
-          animation-direction: alternate;
-          animation-timing-function: ease-in-out;
-          z-index: 2;
-        }
-
-        .float-1 {
-          width: 324px;
-          height: 170px;
-          right: -120px;
-          bottom: 0;
-          animation-duration: 3s;
-          background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 447.1875 234.375" height="170" width="324"><path fill="#{hex-color($ui-base-lighter-color)}" d="M21.69 233.366c-6.45-1.268-13.347-5.63-16.704-10.564-10.705-15.734-1.513-37.724 18.632-44.57l4.8-1.632.173-17.753c.146-14.77.515-19.063 2.2-25.55 6.736-25.944 24.46-46.032 47.766-54.137 11.913-4.143 19.558-5.366 34.178-5.47l13.828-.096V71.12c0-4.755 2.853-17.457 5.238-23.327 8.588-21.137 26.735-35.957 52.153-42.593 23.248-6.07 50.153-6.415 71.863-.923 11.14 2.82 25.686 9.957 33.857 16.615 19.335 15.756 31.82 41.05 35.183 71.275.59 5.305.672 5.435 3.11 4.926 11.833-2.474 30.4-3.132 40.065-1.42 24.388 4.32 40.568 19.076 47.214 43.058 2.16 7.8 3.953 23.894 3.59 32.237l-.24 5.498 5.156 1.317c6.392 1.633 14.55 7.098 18.003 12.062 1.435 2.062 3.305 6.597 4.156 10.078 1.428 5.84 1.43 6.8.04 12.44-1.807 7.318-5.672 13.252-10.872 16.694-8.508 5.63 3.756 5.33-211.916 5.216-108.56-.056-199.22-.464-201.47-.906z"/></svg>');
-        }
-
-        .float-2 {
-          width: 241px;
-          height: 100px;
-          right: 210px;
-          bottom: 0;
-          animation-duration: 3.5s;
-          animation-delay: 0.2s;
-          background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 536.25 222.1875" height="100" width="241"><path fill="#{hex-color($ui-base-lighter-color)}" d="M42.626 221.23c-14.104-1.174-26.442-5.133-32.825-10.534-4.194-3.548-7.684-10.66-8.868-18.075-1.934-12.102.633-22.265 7.528-29.81 7.61-8.328 19.998-12.76 39.855-14.257l8.47-.638-2.08-6.223c-4.826-14.422-6.357-24.813-6.37-43.255-.012-14.923.28-18.513 2.1-25.724 2.283-9.048 8.483-23.034 13.345-30.1 14.76-21.45 43.505-38.425 70.535-41.65 30.628-3.655 64.47 12.073 89.668 41.673l5.955 6.995 2.765-4.174c1.52-2.296 5.74-6.93 9.376-10.295 18.382-17.02 43.436-20.676 73.352-10.705 12.158 4.052 21.315 9.53 29.64 17.733 12.752 12.562 18.16 25.718 18.19 44.26l.02 10.98 2.312-3.01c15.64-20.365 42.29-20.485 62.438-.28 3.644 3.653 7.558 8.593 8.697 10.976 4.895 10.24 5.932 25.688 2.486 37.046-.76 2.507-1.388 4.816-1.393 5.13-.006.316 6.845.87 15.224 1.234 53.06 2.297 76.356 12.98 81.817 37.526 3.554 15.973-3.71 28.604-19.566 34.02-4.554 1.555-17.922 1.655-234.517 1.757-126.327.06-233.497-.21-238.154-.597z"/></svg>');
-        }
-
-        .float-3 {
-          width: 267px;
-          height: 140px;
-          right: 110px;
-          top: -30px;
-          animation-duration: 4s;
-          animation-delay: 0.5s;
-          background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 388.125 202.5" height="140" width="267"><path fill="#{hex-color($ui-base-lighter-color)}" d="M181.37 201.458c-17.184-1.81-36.762-8.944-49.523-18.05l-5.774-4.12-8.074 2.63c-11.468 3.738-21.382 4.962-35.815 4.422-14.79-.554-24.577-2.845-36.716-8.594-15.483-7.332-28.498-19.98-35.985-34.968C2.44 128.675-.94 108.435.9 91.356c3.362-31.234 18.197-53.698 43.63-66.074 12.803-6.23 22.384-8.55 37.655-9.122 14.433-.54 24.347.684 35.814 4.42l8.073 2.633 5.635-4.01c24.81-17.656 60.007-23.332 92.914-14.985 10.11 2.565 25.498 9.62 33.102 15.178l5.068 3.704 7.632-2.564c10.89-3.66 21.086-4.916 35.516-4.376 45.816 1.716 76.422 30.03 81.285 75.196 1.84 17.08-1.54 37.32-8.585 51.422-7.487 14.99-20.502 27.636-35.984 34.968-12.14 5.75-21.926 8.04-36.716 8.593-14.43.54-24.626-.716-35.516-4.376l-7.632-2.564-5.068 3.704c-12.844 9.387-32.714 16.488-51.545 18.42-10.607 1.09-13.916 1.08-24.81-.066z"/></svg>');
-        }
-      }
-
       .heading {
         position: relative;
         z-index: 4;
@@ -346,18 +411,18 @@
     background: darken($ui-base-color, 4%);
     padding: 20px 0;
 
-    .container {
+    .container-alt {
       position: relative;
       padding-right: 280px + 15px;
     }
 
-    .information-board-sections {
+    &__sections {
       display: flex;
       justify-content: space-between;
       flex-wrap: wrap;
     }
 
-    .section {
+    &__section {
       flex: 1 0 0;
       font-family: 'mastodon-font-sans-serif', sans-serif;
       font-size: 16px;
@@ -382,6 +447,10 @@
         font-size: 32px;
         line-height: 48px;
       }
+
+      @media screen and (max-width: $column-breakpoint) {
+        text-align: center;
+      }
     }
 
     .panel {
@@ -460,111 +529,282 @@
     }
   }
 
-  .features {
-    padding: 50px 0;
+  &.alternative {
+    padding: 10px 0;
 
-    .container {
-      display: flex;
-    }
+    .brand {
+      text-align: center;
+      padding: 30px 0;
+      margin-bottom: 10px;
 
-    #mastodon-timeline {
-      display: flex;
-      -webkit-overflow-scrolling: touch;
-      -ms-overflow-style: -ms-autohiding-scrollbar;
-      font-family: 'mastodon-font-sans-serif', sans-serif;
-      font-size: 13px;
-      line-height: 18px;
-      font-weight: 400;
-      color: $primary-text-color;
-      width: 330px;
-      margin-right: 30px;
-      flex: 0 0 auto;
-      background: $ui-base-color;
-      overflow: hidden;
-      border-radius: 4px;
-      box-shadow: 0 0 6px rgba($black, 0.1);
+      img {
+        position: static;
+      }
 
-      .column-header {
-        color: inherit;
-        font-family: inherit;
-        font-size: 16px;
-        line-height: inherit;
-        font-weight: inherit;
-        margin: 0;
-        padding: 0;
+      @media screen and (max-width: $small-breakpoint) {
+        padding: 15px 0;
       }
 
-      .column {
+      @media screen and (max-width: $column-breakpoint) {
         padding: 0;
-        border-radius: 4px;
-        overflow: hidden;
+        margin-bottom: -10px;
       }
+    }
+  }
 
-      .scrollable {
-        height: 400px;
-      }
+  &__information,
+  &__forms {
+    padding: 20px;
+  }
+
+  &__call-to-action {
+    margin-bottom: 10px;
+    background: darken($ui-base-color, 4%);
+    border-radius: 4px;
+    padding: 25px 40px;
+    overflow: hidden;
+
+    .row {
+      align-items: center;
+    }
+
+    .information-board__section {
+      padding: 0;
+    }
+  }
+
+  &__logo {
+    margin-right: 20px;
+
+    img {
+      height: 50px;
+      width: auto;
+      mix-blend-mode: lighten;
+    }
+  }
+
+  &__information {
+    padding: 45px 40px;
+    margin-bottom: 10px;
 
-      p {
-        font-size: inherit;
-        line-height: inherit;
-        font-weight: inherit;
-        color: $primary-text-color;
+    &:last-child {
+      margin-bottom: 0;
+    }
+
+    @media screen and (max-width: $column-breakpoint) {
+      padding: 25px 20px;
+    }
+  }
+
+  &__information,
+  &__forms,
+  #mastodon-timeline {
+    box-sizing: border-box;
+    background: $ui-base-color;
+    border-radius: 4px;
+    box-shadow: 0 0 6px rgba($black, 0.1);
+  }
+
+  &__mascot {
+    height: 104px;
+    position: relative;
+    left: -40px;
+    bottom: 25px;
+
+    img {
+      height: 190px;
+      width: auto;
+    }
+  }
+
+  &__short-description {
+    .row {
+      align-items: center;
+      margin-bottom: 40px;
+    }
+
+    @media screen and (max-width: $column-breakpoint) {
+      .row {
         margin-bottom: 20px;
+      }
+    }
 
-        &:last-child {
-          margin-bottom: 0;
-        }
+    p a {
+      color: $ui-secondary-color;
+    }
 
-        a {
+    h1 {
+      font-weight: 500;
+      color: $primary-text-color;
+      margin-bottom: 0;
+
+      small {
+        color: $ui-primary-color;
+
+        span {
           color: $ui-secondary-color;
-          text-decoration: none;
         }
       }
     }
 
-    .about-mastodon {
-      max-width: 675px;
+    p:last-child {
+      margin-bottom: 0;
+    }
+  }
 
-      p {
-        margin-bottom: 20px;
+  &__hero {
+    margin-bottom: 10px;
+
+    img {
+      display: block;
+      margin: 0;
+      max-width: 100%;
+      height: auto;
+      border-radius: 4px;
+    }
+  }
+
+  &__forms {
+    height: 100%;
+
+    @media screen and (max-width: $small-breakpoint) {
+      margin-bottom: 10px;
+      height: auto;
+    }
+
+    @media screen and (max-width: $column-breakpoint) {
+      background: transparent;
+      box-shadow: none;
+      padding: 0 20px;
+      margin-top: 30px;
+      margin-bottom: 40px;
+
+      .separator-or {
+        span {
+          background: darken($ui-base-color, 8%);
+        }
       }
+    }
 
-      .features-list {
-        margin-top: 20px;
+    hr {
+      margin: 40px 0;
+    }
 
-        .features-list__row {
-          display: flex;
-          padding: 10px 0;
-          justify-content: space-between;
+    .button {
+      display: block;
+    }
 
-          &:first-child {
-            padding-top: 0;
-          }
+    .subtle-hint a {
+      text-decoration: none;
 
-          .visual {
-            flex: 0 0 auto;
-            display: flex;
-            align-items: center;
-            margin-left: 15px;
-
-            .fa {
-              display: block;
-              color: $ui-primary-color;
-              font-size: 48px;
-            }
-          }
+      &:hover,
+      &:focus,
+      &:active {
+        text-decoration: underline;
+      }
+    }
+  }
 
-          .text {
-            font-size: 16px;
-            line-height: 30px;
-            color: $ui-primary-color;
+  #mastodon-timeline {
+    display: flex;
+    -webkit-overflow-scrolling: touch;
+    -ms-overflow-style: -ms-autohiding-scrollbar;
+    font-family: 'mastodon-font-sans-serif', sans-serif;
+    font-size: 13px;
+    line-height: 18px;
+    font-weight: 400;
+    color: $primary-text-color;
+    width: 100%;
+    flex: 1 1 auto;
+    overflow: hidden;
 
-            h6 {
-              font-size: inherit;
-              line-height: inherit;
-              margin-bottom: 0;
-            }
-          }
+    .column-header {
+      color: inherit;
+      font-family: inherit;
+      font-size: 16px;
+      line-height: inherit;
+      font-weight: inherit;
+      margin: 0;
+      padding: 0;
+    }
+
+    .column {
+      padding: 0;
+      border-radius: 4px;
+      overflow: hidden;
+      width: 100%;
+    }
+
+    .scrollable {
+      height: 400px;
+    }
+
+    p {
+      font-size: inherit;
+      line-height: inherit;
+      font-weight: inherit;
+      color: $primary-text-color;
+      margin-bottom: 20px;
+
+      &:last-child {
+        margin-bottom: 0;
+      }
+
+      a {
+        color: $ui-secondary-color;
+        text-decoration: none;
+      }
+    }
+
+    @media screen and (max-width: $column-breakpoint) {
+      height: 90vh;
+    }
+  }
+
+  &__features {
+    .features-list {
+      margin: 40px 0 !important;
+    }
+
+    &__action {
+      text-align: center;
+    }
+  }
+
+  .features-list {
+    margin-top: 20px;
+
+    .features-list__row {
+      display: flex;
+      padding: 10px 0;
+      justify-content: space-between;
+
+      &:first-child {
+        padding-top: 0;
+      }
+
+      .visual {
+        flex: 0 0 auto;
+        display: flex;
+        align-items: center;
+        margin-left: 15px;
+
+        .fa {
+          display: block;
+          color: $ui-primary-color;
+          font-size: 48px;
+        }
+      }
+
+      .text {
+        font-size: 16px;
+        line-height: 30px;
+        color: $ui-primary-color;
+
+        h6 {
+          font-size: inherit;
+          line-height: inherit;
+          margin-bottom: 0;
         }
       }
     }
@@ -600,21 +840,31 @@
     }
   }
 
+  &__footer {
+    margin-top: 10px;
+    text-align: center;
+    color: $ui-base-lighter-color;
+
+    p {
+      font-size: 14px;
+
+      a {
+        color: inherit;
+        text-decoration: underline;
+      }
+    }
+  }
+
   @media screen and (max-width: 840px) {
-    .container {
+    .container-alt {
       padding: 0 20px;
     }
 
     .information-board {
-
-      .container {
+      .container-alt {
         padding-right: 20px;
       }
 
-      .section {
-        text-align: center;
-      }
-
       .panel {
         position: static;
         margin-top: 20px;
@@ -626,16 +876,6 @@
         }
       }
     }
-
-    .header-wrapper .mascot {
-      left: 20px;
-    }
-  }
-
-  @media screen and (max-width: 689px) {
-    .header-wrapper .mascot {
-      display: none;
-    }
   }
 
   @media screen and (max-width: 675px) {
@@ -651,13 +891,12 @@
       }
     }
 
-    .header .container,
-    .features .container {
+    .header .container-alt,
+    .features .container-alt {
       display: block;
     }
 
     .header {
-
       .links {
         padding-top: 15px;
         background: darken($ui-base-color, 4%);
@@ -682,10 +921,6 @@
         margin-top: 30px;
         padding: 0;
 
-        .floats {
-          display: none;
-        }
-
         .heading {
           padding: 30px 20px;
           text-align: center;
@@ -700,16 +935,6 @@
         }
       }
     }
-
-    .features #mastodon-timeline {
-      height: 70vh;
-      width: 100%;
-      margin-bottom: 50px;
-
-      .column {
-        width: 100%;
-      }
-    }
   }
 
   .cta {
@@ -720,7 +945,7 @@
     .features {
       padding: 30px 0;
 
-      .container {
+      .container-alt {
         max-width: 820px;
 
         #mastodon-timeline {
@@ -772,7 +997,7 @@
       .features {
         padding: 10px 0;
 
-        .container {
+        .container-alt {
           display: flex;
           flex-direction: column;
 
@@ -808,17 +1033,3 @@
     }
   }
 }
-
-@keyframes floating {
-  from {
-    transform: translate(0, 0);
-  }
-
-  65% {
-    transform: translate(0, 4px);
-  }
-
-  to {
-    transform: translate(0, -0);
-  }
-}
diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss
index 66e4adc2b..09b38859b 100644
--- a/app/javascript/styles/mastodon/components.scss
+++ b/app/javascript/styles/mastodon/components.scss
@@ -40,14 +40,20 @@
     cursor: default;
   }
 
-  &.button-alternative {
+  &.button-primary,
+  &.button-alternative,
+  &.button-secondary,
+  &.button-alternative-2 {
     font-size: 16px;
     line-height: 36px;
     height: auto;
-    color: $ui-base-color;
-    background: $ui-primary-color;
     text-transform: none;
     padding: 4px 16px;
+  }
+
+  &.button-alternative {
+    color: $ui-base-color;
+    background: $ui-primary-color;
 
     &:active,
     &:focus,
@@ -56,15 +62,20 @@
     }
   }
 
+  &.button-alternative-2 {
+    background: $ui-base-lighter-color;
+
+    &:active,
+    &:focus,
+    &:hover {
+      background-color: lighten($ui-base-lighter-color, 4%);
+    }
+  }
+
   &.button-secondary {
-    font-size: 16px;
-    line-height: 36px;
-    height: auto;
     color: $ui-primary-color;
-    text-transform: none;
     background: transparent;
     padding: 3px 15px;
-    border-radius: 4px;
     border: 1px solid $ui-primary-color;
 
     &:active,
@@ -433,6 +444,34 @@
       min-width: 40%;
       margin: 5px;
 
+      &__actions {
+        background: linear-gradient(180deg, rgba($base-shadow-color, 0.8) 0, rgba($base-shadow-color, 0.35) 80%, transparent);
+        display: flex;
+        align-items: flex-start;
+        justify-content: space-between;
+        opacity: 0;
+        transition: opacity .1s ease;
+
+        .icon-button {
+          flex: 0 1 auto;
+          color: $ui-secondary-color;
+          font-size: 14px;
+          font-weight: 500;
+          padding: 10px;
+          font-family: inherit;
+
+          &:hover,
+          &:focus,
+          &:active {
+            color: lighten($ui-secondary-color, 4%);
+          }
+        }
+
+        &.active {
+          opacity: 1;
+        }
+      }
+
       &-description {
         position: absolute;
         z-index: 2;
@@ -470,10 +509,6 @@
           opacity: 1;
         }
       }
-
-      .icon-button {
-        mix-blend-mode: difference;
-      }
     }
 
     .compose-form__upload-thumbnail {
@@ -481,8 +516,9 @@
       background-position: center;
       background-size: cover;
       background-repeat: no-repeat;
-      height: 100px;
+      height: 140px;
       width: 100%;
+      overflow: hidden;
     }
   }
 
@@ -4133,8 +4169,12 @@ a.status-card {
   &,
   img {
     width: 100%;
-    height: 100%;
+  }
+
+  img {
+    position: relative;
     object-fit: cover;
+    height: auto;
   }
 }
 
@@ -4842,3 +4882,31 @@ noscript {
     margin-bottom: 0;
   }
 }
+
+.focal-point {
+  position: relative;
+  cursor: pointer;
+  overflow: hidden;
+
+  &.dragging {
+    cursor: move;
+  }
+
+  &__reticle {
+    position: absolute;
+    width: 100px;
+    height: 100px;
+    transform: translate(-50%, -50%);
+    background: url('../images/reticle.png') no-repeat 0 0;
+    border-radius: 50%;
+    box-shadow: 0 0 0 9999em rgba($base-shadow-color, 0.35);
+  }
+
+  &__overlay {
+    position: absolute;
+    width: 100%;
+    height: 100%;
+    top: 0;
+    left: 0;
+  }
+}
diff --git a/app/javascript/styles/mastodon/containers.scss b/app/javascript/styles/mastodon/containers.scss
index af2589e23..6fa1fa38f 100644
--- a/app/javascript/styles/mastodon/containers.scss
+++ b/app/javascript/styles/mastodon/containers.scss
@@ -1,4 +1,4 @@
-.container {
+.container-alt {
   width: 700px;
   margin: 0 auto;
   margin-top: 40px;
diff --git a/app/lib/activitypub/activity/create.rb b/app/lib/activitypub/activity/create.rb
index 64c429420..a7afbb859 100644
--- a/app/lib/activitypub/activity/create.rb
+++ b/app/lib/activitypub/activity/create.rb
@@ -116,7 +116,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
       next if unsupported_media_type?(attachment['mediaType']) || attachment['url'].blank?
 
       href             = Addressable::URI.parse(attachment['url']).normalize.to_s
-      media_attachment = MediaAttachment.create(account: @account, remote_url: href, description: attachment['name'].presence)
+      media_attachment = MediaAttachment.create(account: @account, remote_url: href, description: attachment['name'].presence, focus: attachment['focalPoint'])
       media_attachments << media_attachment
 
       next if skip_download?
diff --git a/app/lib/activitypub/adapter.rb b/app/lib/activitypub/adapter.rb
index 90d589d90..8198ac580 100644
--- a/app/lib/activitypub/adapter.rb
+++ b/app/lib/activitypub/adapter.rb
@@ -17,6 +17,7 @@ class ActivityPub::Adapter < ActiveModelSerializers::Adapter::Base
         'conversation'              => 'ostatus:conversation',
         'toot'                      => 'http://joinmastodon.org/ns#',
         'Emoji'                     => 'toot:Emoji',
+        'focalPoint'                => { '@container' => '@list', '@id' => 'toot:focalPoint' },
       },
     ],
   }.freeze
diff --git a/app/lib/fast_geometry_parser.rb b/app/lib/fast_geometry_parser.rb
new file mode 100644
index 000000000..5209c2bc5
--- /dev/null
+++ b/app/lib/fast_geometry_parser.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+class FastGeometryParser
+  def self.from_file(file)
+    width, height = FastImage.size(file.path)
+
+    raise Paperclip::Errors::NotIdentifiedByImageMagickError if width.nil?
+
+    Paperclip::Geometry.new(width, height)
+  end
+end
diff --git a/app/mailers/user_mailer.rb b/app/mailers/user_mailer.rb
index 2fc9caba3..9848c34a2 100644
--- a/app/mailers/user_mailer.rb
+++ b/app/mailers/user_mailer.rb
@@ -66,4 +66,16 @@ class UserMailer < Devise::Mailer
       mail to: @resource.email, subject: I18n.t('user_mailer.welcome.subject')
     end
   end
+
+  def backup_ready(user, backup)
+    @resource = user
+    @instance = Rails.configuration.x.local_domain
+    @backup   = backup
+
+    return if @resource.disabled?
+
+    I18n.with_locale(@resource.locale || I18n.default_locale) do
+      mail to: @resource.email, subject: I18n.t('user_mailer.backup_ready.subject')
+    end
+  end
 end
diff --git a/app/models/backup.rb b/app/models/backup.rb
new file mode 100644
index 000000000..5a7e6a14d
--- /dev/null
+++ b/app/models/backup.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+# == Schema Information
+#
+# Table name: backups
+#
+#  id                :integer          not null, primary key
+#  user_id           :integer
+#  dump_file_name    :string
+#  dump_content_type :string
+#  dump_file_size    :integer
+#  dump_updated_at   :datetime
+#  processed         :boolean          default(FALSE), not null
+#  created_at        :datetime         not null
+#  updated_at        :datetime         not null
+#
+
+class Backup < ApplicationRecord
+  belongs_to :user, inverse_of: :backups
+
+  has_attached_file :dump
+  do_not_validate_attachment_file_type :dump
+end
diff --git a/app/models/concerns/account_avatar.rb b/app/models/concerns/account_avatar.rb
index 53d0d876f..7712a29fd 100644
--- a/app/models/concerns/account_avatar.rb
+++ b/app/models/concerns/account_avatar.rb
@@ -7,15 +7,9 @@ module AccountAvatar
 
   class_methods do
     def avatar_styles(file)
-      styles   = {}
-      geometry = Paperclip::Geometry.from_file(file)
-
-      styles[:original] = '120x120#' if geometry.width != geometry.height || geometry.width > 120 || geometry.height > 120
-      styles[:static]   = { format: 'png', convert_options: '-coalesce' } if file.content_type == 'image/gif'
-
+      styles = { original: { geometry: '120x120#', file_geometry_parser: FastGeometryParser } }
+      styles[:static] = { geometry: '120x120#', format: 'png', convert_options: '-coalesce', file_geometry_parser: FastGeometryParser } if file.content_type == 'image/gif'
       styles
-    rescue Paperclip::Errors::NotIdentifiedByImageMagickError
-      {}
     end
 
     private :avatar_styles
@@ -23,7 +17,7 @@ module AccountAvatar
 
   included do
     # Avatar upload
-    has_attached_file :avatar, styles: ->(f) { avatar_styles(f) }, convert_options: { all: '-strip' }
+    has_attached_file :avatar, styles: ->(f) { avatar_styles(f) }, convert_options: { all: '-strip' }, processors: [:lazy_thumbnail]
     validates_attachment_content_type :avatar, content_type: IMAGE_MIME_TYPES
     validates_attachment_size :avatar, less_than: 2.megabytes
   end
diff --git a/app/models/concerns/account_header.rb b/app/models/concerns/account_header.rb
index 991473d8c..04c576b28 100644
--- a/app/models/concerns/account_header.rb
+++ b/app/models/concerns/account_header.rb
@@ -7,15 +7,9 @@ module AccountHeader
 
   class_methods do
     def header_styles(file)
-      styles   = {}
-      geometry = Paperclip::Geometry.from_file(file)
-
-      styles[:original] = '700x335#' unless geometry.width == 700 && geometry.height == 335
-      styles[:static]   = { format: 'png', convert_options: '-coalesce' } if file.content_type == 'image/gif'
-
+      styles = { original: { geometry: '700x335#', file_geometry_parser: FastGeometryParser } }
+      styles[:static] = { geometry: '700x335#', format: 'png', convert_options: '-coalesce', file_geometry_parser: FastGeometryParser } if file.content_type == 'image/gif'
       styles
-    rescue Paperclip::Errors::NotIdentifiedByImageMagickError
-      {}
     end
 
     private :header_styles
@@ -23,7 +17,7 @@ module AccountHeader
 
   included do
     # Header upload
-    has_attached_file :header, styles: ->(f) { header_styles(f) }, convert_options: { all: '-strip' }
+    has_attached_file :header, styles: ->(f) { header_styles(f) }, convert_options: { all: '-strip' }, processors: [:lazy_thumbnail]
     validates_attachment_content_type :header, content_type: IMAGE_MIME_TYPES
     validates_attachment_size :header, less_than: 2.megabytes
   end
diff --git a/app/models/concerns/omniauthable.rb b/app/models/concerns/omniauthable.rb
index a3d55108d..87d93c1fd 100644
--- a/app/models/concerns/omniauthable.rb
+++ b/app/models/concerns/omniauthable.rb
@@ -53,8 +53,11 @@ module Omniauthable
     private
 
     def user_params_from_auth(auth)
-      email_is_verified = auth.info.email && (auth.info.verified || auth.info.verified_email)
-      email             = auth.info.email if email_is_verified && !User.exists?(email: auth.info.email)
+      strategy          = Devise.omniauth_configs[auth.provider.to_sym].strategy
+      assume_verified   = strategy.try(:security).try(:assume_email_is_verified)
+      email_is_verified = auth.info.verified || auth.info.verified_email || assume_verified
+      email             = auth.info.verified_email || auth.info.email
+      email             = email_is_verified && !User.exists?(email: auth.info.email) && email
 
       {
         email: email ? email : "#{TEMP_EMAIL_PREFIX}-#{auth.uid}-#{auth.provider}.com",
diff --git a/app/models/media_attachment.rb b/app/models/media_attachment.rb
index 6f17363c8..283d0e714 100644
--- a/app/models/media_attachment.rb
+++ b/app/models/media_attachment.rb
@@ -34,7 +34,18 @@ class MediaAttachment < ApplicationRecord
   VIDEO_MIME_TYPES = ['video/webm', 'video/mp4'].freeze
   AUDIO_MIME_TYPES = ['audio/mpeg', 'audio/mp4', 'audio/vnd.wav', 'audio/wav', 'audio/x-wav', 'audio/x-wave', 'audio/ogg',].freeze
 
-  IMAGE_STYLES = { original: '1280x1280>', small: '400x400>' }.freeze
+  IMAGE_STYLES = {
+    original: {
+      geometry: '1280x1280>',
+      file_geometry_parser: FastGeometryParser,
+    },
+
+    small: {
+      geometry: '400x400>',
+      file_geometry_parser: FastGeometryParser,
+    },
+  }.freeze
+
   AUDIO_STYLES = {
     original: {
       format: 'mp4',
@@ -50,6 +61,7 @@ class MediaAttachment < ApplicationRecord
       },
     },
   }.freeze
+
   VIDEO_STYLES = {
     small: {
       convert_options: {
@@ -97,6 +109,24 @@ class MediaAttachment < ApplicationRecord
     shortcode
   end
 
+  def focus=(point)
+    return if point.blank?
+
+    x, y = (point.is_a?(Enumerable) ? point : point.split(',')).map(&:to_f)
+
+    meta = file.instance_read(:meta) || {}
+    meta['focus'] = { 'x' => x, 'y' => y }
+
+    file.instance_write(:meta, meta)
+  end
+
+  def focus
+    x = file.meta['focus']['x']
+    y = file.meta['focus']['y']
+
+    "#{x},#{y}"
+  end
+
   before_create :prepare_description, unless: :local?
   before_create :set_shortcode
   before_post_process :set_type_and_extension
@@ -178,7 +208,7 @@ class MediaAttachment < ApplicationRecord
   end
 
   def populate_meta
-    meta = {}
+    meta = file.instance_read(:meta) || {}
 
     file.queued_for_write.each do |style, file|
       meta[style] = style == :small || image? ? image_geometry(file) : video_metadata(file)
@@ -188,16 +218,16 @@ class MediaAttachment < ApplicationRecord
   end
 
   def image_geometry(file)
-    geo = Paperclip::Geometry.from_file file
+    width, height = FastImage.size(file.path)
+
+    return {} if width.nil?
 
     {
-      width:  geo.width.to_i,
-      height: geo.height.to_i,
-      size: "#{geo.width.to_i}x#{geo.height.to_i}",
-      aspect: geo.width.to_f / geo.height.to_f,
+      width:  width,
+      height: height,
+      size: "#{width}x#{height}",
+      aspect: width.to_f / height.to_f,
     }
-  rescue Paperclip::Errors::NotIdentifiedByImageMagickError
-    {}
   end
 
   def video_metadata(file)
diff --git a/app/models/preview_card.rb b/app/models/preview_card.rb
index 716b82243..86eecdfe5 100644
--- a/app/models/preview_card.rb
+++ b/app/models/preview_card.rb
@@ -33,7 +33,7 @@ class PreviewCard < ApplicationRecord
 
   has_and_belongs_to_many :statuses
 
-  has_attached_file :image, styles: { original: '400x400>' }, convert_options: { all: '-quality 80 -strip' }
+  has_attached_file :image, styles: { original: { geometry: '400x400>', file_geometry_parser: FastGeometryParser } }, convert_options: { all: '-quality 80 -strip' }
 
   include Attachmentable
   include Remotable
@@ -58,10 +58,11 @@ class PreviewCard < ApplicationRecord
 
     return if file.nil?
 
-    geo         = Paperclip::Geometry.from_file(file)
-    self.width  = geo.width.to_i
-    self.height = geo.height.to_i
-  rescue Paperclip::Errors::NotIdentifiedByImageMagickError
-    nil
+    width, height = FastImage.size(file.path)
+
+    return nil if width.nil?
+
+    self.width  = width
+    self.height = height
   end
 end
diff --git a/app/models/site_upload.rb b/app/models/site_upload.rb
index 8ffdc8313..641128adf 100644
--- a/app/models/site_upload.rb
+++ b/app/models/site_upload.rb
@@ -34,8 +34,8 @@ class SiteUpload < ApplicationRecord
 
     return if tempfile.nil?
 
-    geometry  = Paperclip::Geometry.from_file(tempfile)
-    self.meta = { width: geometry.width.to_i, height: geometry.height.to_i }
+    width, height = FastImage.size(tempfile.path)
+    self.meta = { width: width, height: height }
   end
 
   def clear_cache
diff --git a/app/models/status.rb b/app/models/status.rb
index 86bf3deba..125fa1bb5 100644
--- a/app/models/status.rb
+++ b/app/models/status.rb
@@ -79,7 +79,7 @@ class Status < ApplicationRecord
 
   scope :not_local_only, -> { where(local_only: [false, nil]) }
 
-  cache_associated :account, :application, :media_attachments, :tags, :stream_entry, mentions: :account, reblog: [:account, :application, :stream_entry, :tags, :media_attachments, mentions: :account], thread: :account
+  cache_associated :account, :application, :media_attachments, :conversation, :tags, :stream_entry, mentions: :account, reblog: [:account, :application, :stream_entry, :tags, :media_attachments, :conversation, mentions: :account], thread: :account
 
   delegate :domain, to: :account, prefix: true
 
diff --git a/app/models/user.rb b/app/models/user.rb
index af54efded..197799294 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -60,6 +60,7 @@ class User < ApplicationRecord
   accepts_nested_attributes_for :account
 
   has_many :applications, class_name: 'Doorkeeper::Application', as: :owner
+  has_many :backups, inverse_of: :user
 
   validates :locale, inclusion: I18n.available_locales.map(&:to_s), if: :locale?
   validates_with BlacklistedEmailValidator, if: :email_changed?
diff --git a/app/policies/application_policy.rb b/app/policies/application_policy.rb
index 3e617001f..d1de5e81a 100644
--- a/app/policies/application_policy.rb
+++ b/app/policies/application_policy.rb
@@ -15,4 +15,8 @@ class ApplicationPolicy
   def current_user
     current_account&.user
   end
+
+  def user_signed_in?
+    !current_user.nil?
+  end
 end
diff --git a/app/policies/backup_policy.rb b/app/policies/backup_policy.rb
new file mode 100644
index 000000000..0ef89a8d0
--- /dev/null
+++ b/app/policies/backup_policy.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+class BackupPolicy < ApplicationPolicy
+  MIN_AGE = 1.week
+
+  def create?
+    user_signed_in? && current_user.backups.where('created_at >= ?', MIN_AGE.ago).count.zero?
+  end
+end
diff --git a/app/presenters/account_relationships_presenter.rb b/app/presenters/account_relationships_presenter.rb
index d27fb7b01..b1e99b31b 100644
--- a/app/presenters/account_relationships_presenter.rb
+++ b/app/presenters/account_relationships_presenter.rb
@@ -45,7 +45,7 @@ class AccountRelationshipsPresenter
       maps_for_account = Rails.cache.read("relationship:#{@current_account_id}:#{account_id}")
 
       if maps_for_account.is_a?(Hash)
-        @cached.merge!(maps_for_account)
+        @cached.deep_merge!(maps_for_account)
       else
         @uncached_account_ids << account_id
       end
diff --git a/app/presenters/instance_presenter.rb b/app/presenters/instance_presenter.rb
index 1c08fb3bc..db288d5db 100644
--- a/app/presenters/instance_presenter.rb
+++ b/app/presenters/instance_presenter.rb
@@ -48,4 +48,8 @@ class InstancePresenter
   def thumbnail
     @thumbnail ||= Rails.cache.fetch('site_uploads/thumbnail') { SiteUpload.find_by(var: 'thumbnail') }
   end
+
+  def hero
+    @hero ||= Rails.cache.fetch('site_uploads/hero') { SiteUpload.find_by(var: 'hero') }
+  end
 end
diff --git a/app/serializers/activitypub/collection_serializer.rb b/app/serializers/activitypub/collection_serializer.rb
index 9832133fc..d43af3f8e 100644
--- a/app/serializers/activitypub/collection_serializer.rb
+++ b/app/serializers/activitypub/collection_serializer.rb
@@ -13,8 +13,8 @@ class ActivityPub::CollectionSerializer < ActiveModel::Serializer
   attribute :part_of, if: -> { object.part_of.present? }
 
   has_one :first, if: -> { object.first.present? }
-  has_many :items, key: :items, if: -> { (object.items.present? || page?) && !ordered? }
-  has_many :items, key: :ordered_items, if: -> { (object.items.present? || page?) && ordered? }
+  has_many :items, key: :items, if: -> { (!object.items.nil? || page?) && !ordered? }
+  has_many :items, key: :ordered_items, if: -> { (!object.items.nil? || page?) && ordered? }
 
   def type
     if page?
diff --git a/app/serializers/activitypub/image_serializer.rb b/app/serializers/activitypub/image_serializer.rb
index a015c6b1b..3c08f77e8 100644
--- a/app/serializers/activitypub/image_serializer.rb
+++ b/app/serializers/activitypub/image_serializer.rb
@@ -4,6 +4,7 @@ class ActivityPub::ImageSerializer < ActiveModel::Serializer
   include RoutingHelper
 
   attributes :type, :media_type, :url
+  attribute :focal_point, if: :focal_point?
 
   def type
     'Image'
@@ -16,4 +17,12 @@ class ActivityPub::ImageSerializer < ActiveModel::Serializer
   def media_type
     object.content_type
   end
+
+  def focal_point?
+    object.respond_to?(:meta) && object.meta.is_a?(Hash) && object.meta['focus'].is_a?(Hash)
+  end
+
+  def focal_point
+    [object.meta['focus']['x'], object.meta['focus']['y']]
+  end
 end
diff --git a/app/services/backup_service.rb b/app/services/backup_service.rb
new file mode 100644
index 000000000..fadc24a82
--- /dev/null
+++ b/app/services/backup_service.rb
@@ -0,0 +1,128 @@
+# frozen_string_literal: true
+
+require 'rubygems/package'
+
+class BackupService < BaseService
+  attr_reader :account, :backup, :collection
+
+  def call(backup)
+    @backup  = backup
+    @account = backup.user.account
+
+    build_json!
+    build_archive!
+  end
+
+  private
+
+  def build_json!
+    @collection = serialize(collection_presenter, ActivityPub::CollectionSerializer)
+
+    account.statuses.with_includes.find_in_batches do |statuses|
+      statuses.each do |status|
+        item = serialize(status, ActivityPub::ActivitySerializer)
+        item.delete(:'@context')
+
+        unless item[:type] == 'Announce' || item[:object][:attachment].blank?
+          item[:object][:attachment].each do |attachment|
+            attachment[:url] = Addressable::URI.parse(attachment[:url]).path.gsub(/\A\/system\//, '')
+          end
+        end
+
+        @collection[:orderedItems] << item
+      end
+
+      GC.start
+    end
+  end
+
+  def build_archive!
+    tmp_file = Tempfile.new(%w(archive .tar.gz))
+
+    File.open(tmp_file, 'wb') do |file|
+      Zlib::GzipWriter.wrap(file) do |gz|
+        Gem::Package::TarWriter.new(gz) do |tar|
+          dump_media_attachments!(tar)
+          dump_outbox!(tar)
+          dump_actor!(tar)
+        end
+      end
+    end
+
+    archive_filename = ['archive', Time.now.utc.strftime('%Y%m%d%H%M%S'), SecureRandom.hex(2)].join('-') + '.tar.gz'
+
+    @backup.dump      = ActionDispatch::Http::UploadedFile.new(tempfile: tmp_file, filename: archive_filename)
+    @backup.processed = true
+    @backup.save!
+  ensure
+    tmp_file.close
+    tmp_file.unlink
+  end
+
+  def dump_media_attachments!(tar)
+    MediaAttachment.attached.where(account: account).find_in_batches do |media_attachments|
+      media_attachments.each do |m|
+        download_to_tar(tar, m.file, m.file.path)
+      end
+
+      GC.start
+    end
+  end
+
+  def dump_outbox!(tar)
+    json = Oj.dump(collection)
+
+    tar.add_file_simple('outbox.json', 0o444, json.bytesize) do |io|
+      io.write(json)
+    end
+  end
+
+  def dump_actor!(tar)
+    actor = serialize(account, ActivityPub::ActorSerializer)
+
+    actor[:icon][:url]  = 'avatar' + File.extname(actor[:icon][:url])  if actor[:icon]
+    actor[:image][:url] = 'header' + File.extname(actor[:image][:url]) if actor[:image]
+
+    download_to_tar(tar, account.avatar, 'avatar' + File.extname(account.avatar.path)) if account.avatar.exists?
+    download_to_tar(tar, account.header, 'header' + File.extname(account.header.path)) if account.header.exists?
+
+    json = Oj.dump(actor)
+
+    tar.add_file_simple('actor.json', 0o444, json.bytesize) do |io|
+      io.write(json)
+    end
+
+    tar.add_file_simple('key.pem', 0o444, account.private_key.bytesize) do |io|
+      io.write(account.private_key)
+    end
+  end
+
+  def collection_presenter
+    ActivityPub::CollectionPresenter.new(
+      id: account_outbox_url(account),
+      type: :ordered,
+      size: account.statuses_count,
+      items: []
+    )
+  end
+
+  def serialize(object, serializer)
+    ActiveModelSerializers::SerializableResource.new(
+      object,
+      serializer: serializer,
+      adapter: ActivityPub::Adapter
+    ).as_json
+  end
+
+  CHUNK_SIZE = 1.megabyte
+
+  def download_to_tar(tar, attachment, filename)
+    adapter = Paperclip.io_adapters.for(attachment)
+
+    tar.add_file_simple(filename, 0o444, adapter.size) do |io|
+      while (buffer = adapter.read(CHUNK_SIZE))
+        io.write(buffer)
+      end
+    end
+  end
+end
diff --git a/app/views/about/_forms.html.haml b/app/views/about/_forms.html.haml
new file mode 100644
index 000000000..9916b6bf4
--- /dev/null
+++ b/app/views/about/_forms.html.haml
@@ -0,0 +1,14 @@
+- if @instance_presenter.open_registrations
+  = render 'registration'
+- else
+  - if @instance_presenter.closed_registrations_message.blank?
+    %p= t('about.closed_registrations')
+  - else
+    = @instance_presenter.closed_registrations_message.html_safe
+
+  = link_to t('auth.register'), 'https://joinmastodon.org', class: 'button button-primary'
+
+.separator-or
+  %span= t('auth.or')
+
+= link_to t('auth.login'), new_user_session_path, class: 'button button-alternative-2 webapp-btn'
diff --git a/app/views/about/_links.html.haml b/app/views/about/_links.html.haml
index ccf4f08b9..f79c37e65 100644
--- a/app/views/about/_links.html.haml
+++ b/app/views/about/_links.html.haml
@@ -1,4 +1,4 @@
-.container.links
+.container-alt.links
   .brand
     = link_to root_url do
       = image_tag asset_pack_path('logo_full.svg'), alt: 'Mastodon'
diff --git a/app/views/about/_registration.html.haml b/app/views/about/_registration.html.haml
index 7a28f9738..6ca1d7129 100644
--- a/app/views/about/_registration.html.haml
+++ b/app/views/about/_registration.html.haml
@@ -10,6 +10,6 @@
   = f.input :password_confirmation, placeholder: t('simple_form.labels.defaults.confirm_password'), required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.confirm_password'), :autocomplete => 'off' }
 
   .actions
-    = f.button :button, t('auth.register'), type: :submit, class: 'button button-alternative'
+    = f.button :button, t('auth.register'), type: :submit, class: 'button button-primary'
 
   %p.hint.subtle-hint=t('auth.agreement_html', rules_path: about_more_path, terms_path: terms_path)
diff --git a/app/views/about/more.html.haml b/app/views/about/more.html.haml
index 84daadba8..f86051fbf 100644
--- a/app/views/about/more.html.haml
+++ b/app/views/about/more.html.haml
@@ -9,34 +9,34 @@
     .header
       = render 'links'
 
-      .container.hero
+      .container-alt.hero
         .heading
           %h3= t('about.description_headline', domain: site_hostname)
           %p= @instance_presenter.site_description.html_safe.presence || t('about.generic_description', domain: site_hostname)
 
   .information-board
-    .container
-      .information-board-sections
-        .section
+    .container-alt
+      .information-board__sections
+        .information-board__section
           %span= t 'about.user_count_before'
           %strong= number_with_delimiter @instance_presenter.user_count
           %span= t 'about.user_count_after'
-        .section
+        .information-board__section
           %span= t 'about.status_count_before'
           %strong= number_with_delimiter @instance_presenter.status_count
           %span= t 'about.status_count_after'
-        .section
+        .information-board__section
           %span= t 'about.domain_count_before'
           %strong= number_with_delimiter @instance_presenter.domain_count
           %span= t 'about.domain_count_after'
       = render 'contact', contact: @instance_presenter
 
   .extended-description
-    .container
+    .container-alt
       = @instance_presenter.site_extended_description.html_safe.presence || t('about.extended_description_html')
 
   .footer-links
-    .container
+    .container-alt
       %p
         = link_to t('about.source_code'), @instance_presenter.source_url
         - if @instance_presenter.commit_hash == ""
diff --git a/app/views/about/show.html.haml b/app/views/about/show.html.haml
index 487c8429b..bc357e522 100644
--- a/app/views/about/show.html.haml
+++ b/app/views/about/show.html.haml
@@ -5,62 +5,74 @@
   %script#initial-state{ type: 'application/json' }!= json_escape(@initial_state_json)
   = render partial: 'shared/og'
 
-.landing-page
-  .header-wrapper
-    .mascot-container
-      = image_tag asset_pack_path('elephant-fren.png'), alt: '', role: 'presentation', class: 'mascot'
+.landing-page.alternative
+  .container
+    .row
+      .column-4.hide-sm.show-xs.show-m
+        .landing-page__forms
+          .brand
+            = link_to root_url do
+              = image_tag asset_pack_path('logo_full.svg'), alt: 'Mastodon'
 
-    .header
-      = render 'links'
+          .hide-xs
+            = render 'forms'
 
-      .container.hero
-        .floats
-          %div{ role: 'presentation', class: 'float-1' }
-          %div{ role: 'presentation', class: 'float-2' }
-          %div{ role: 'presentation', class: 'float-3' }
-        .heading
-          %h1
-            = @instance_presenter.site_title
-            %small= t 'about.hosted_on', domain: site_hostname
-        - if @instance_presenter.open_registrations
-          = render 'registration'
-        - else
-          .closed-registrations-message
-            %div
-              - if @instance_presenter.closed_registrations_message.blank?
-                %p= t('about.closed_registrations')
-              - else
-                = @instance_presenter.closed_registrations_message.html_safe
+      .column-7.column-9-sm
+        .landing-page__hero
+          = image_tag @instance_presenter.hero&.file&.url || @instance_presenter.thumbnail&.file&.url || asset_pack_path('preview.jpg'), alt: @instance_presenter.site_title
 
-            = simple_form_for(:user, html: { style: 'margin-left: -20px' }, url: session_path(:user)) do |f|
-              = f.input :email, autofocus: true, placeholder: t('simple_form.labels.defaults.email'), required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.email') }
-              = f.input :password, placeholder: t('simple_form.labels.defaults.password'), required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.password'), :autocomplete => 'off' }
+        .landing-page__information
+          .landing-page__short-description
+            .row
+              .landing-page__logo.hide-xs
+                = image_tag asset_pack_path('logo_transparent.svg'), alt: 'Mastodon'
 
-              .actions
-                = f.button :button, t('auth.login'), type: :submit
-            = link_to t('about.find_another_instance'), 'https://joinmastodon.org/', class: 'button button-alternative button--block'
+              %h1
+                = @instance_presenter.site_title
+                %small!= t 'about.hosted_on', domain: content_tag(:span, site_hostname)
 
-  .about-short
-    .container
-      %h3= t('about.description_headline', domain: site_hostname)
-      %p= @instance_presenter.site_description.html_safe.presence || t('about.generic_description', domain: site_hostname)
+            %p= @instance_presenter.site_description.html_safe.presence || t('about.generic_description', domain: site_hostname)
 
-  .features
-    .container
-      - if Setting.timeline_preview
-        #mastodon-timeline{ data: { props: Oj.dump(default_props) } }
+        .show-xs
+          .landing-page__forms
+            = render 'forms'
+        .landing-page__call-to-action.hide-xs
+          .row
+            .column-5
+              .landing-page__mascot
+                = image_tag asset_pack_path('elephant_ui_plane.svg')
+            .column-5
+              .information-board__section
+                %span= t 'about.user_count_before'
+                %strong= number_with_delimiter @instance_presenter.user_count
+                %span= t 'about.user_count_after'
+            .column-5
+              .information-board__section
+                %span= t 'about.status_count_before'
+                %strong= number_with_delimiter @instance_presenter.status_count
+                %span= t 'about.status_count_after'
+        .landing-page__information
+          .landing-page__features
+            %h3= t 'about.what_is_mastodon'
+            %p= t 'about.about_mastodon_html'
 
-      .about-mastodon
-        %h3= t 'about.what_is_mastodon'
-        %p= t 'about.about_mastodon_html'
-        = link_to t('about.learn_more'), 'https://joinmastodon.org/', class: 'button button-secondary'
-        = render 'features'
-  .footer-links
-    .container
-      %p
-        = link_to t('about.source_code'), @instance_presenter.source_url
-        - if @instance_presenter.commit_hash == ""
-          %strong= " (#{@instance_presenter.version_number})"
-        - else
-          %strong= " (#{@instance_presenter.version_number}, "
-          %strong= " #{@instance_presenter.commit_hash})"
+            = render 'features'
+
+            .landing-page__features__action
+              = link_to t('about.learn_more'), 'https://joinmastodon.org/', class: 'button button-alternative'
+
+          .landing-page__footer
+            %p
+              = link_to t('about.source_code'), @instance_presenter.source_url
+              = " (#{@instance_presenter.version_number})"
+
+      .column-4.column-6-sm.column-flex
+        .show-sm.hide-xs
+          .landing-page__forms
+            .brand
+              = link_to root_url do
+                = image_tag asset_pack_path('logo_full.svg'), alt: 'Mastodon'
+
+            = render 'forms'
+        - if Setting.timeline_preview
+          #mastodon-timeline{ data: { props: Oj.dump(default_props) } }
diff --git a/app/views/about/terms.html.haml b/app/views/about/terms.html.haml
index ba780759c..c7d36ed47 100644
--- a/app/views/about/terms.html.haml
+++ b/app/views/about/terms.html.haml
@@ -7,5 +7,5 @@
       = render 'links'
 
   .extended-description
-    .container
+    .container-alt
       = @instance_presenter.site_terms.html_safe.presence || t('terms.body_html')
diff --git a/app/views/admin/settings/edit.html.haml b/app/views/admin/settings/edit.html.haml
index 73fd5642e..08d05d738 100644
--- a/app/views/admin/settings/edit.html.haml
+++ b/app/views/admin/settings/edit.html.haml
@@ -12,6 +12,7 @@
 
   .fields-group
     = f.input :thumbnail, as: :file, wrapper: :with_block_label, label: t('admin.settings.thumbnail.title'), hint: t('admin.settings.thumbnail.desc_html')
+    = f.input :hero, as: :file, wrapper: :with_block_label, label: t('admin.settings.hero.title'), hint: t('admin.settings.hero.desc_html')
 
   %hr/
 
diff --git a/app/views/layouts/auth.html.haml b/app/views/layouts/auth.html.haml
index f4812ac6a..ca9c13945 100644
--- a/app/views/layouts/auth.html.haml
+++ b/app/views/layouts/auth.html.haml
@@ -1,5 +1,5 @@
 - content_for :content do
-  .container
+  .container-alt
     .logo-container
       %h1
         = link_to root_path do
diff --git a/app/views/layouts/modal.html.haml b/app/views/layouts/modal.html.haml
index a5d79f5c0..e808593cd 100644
--- a/app/views/layouts/modal.html.haml
+++ b/app/views/layouts/modal.html.haml
@@ -8,7 +8,7 @@
       = link_to destroy_user_session_path, method: :delete, class: 'logout-link icon-button' do
         = fa_icon 'sign-out'
 
-  .container= yield
+  .container-alt= yield
   .modal-layout__mastodon
     %div
 
diff --git a/app/views/layouts/public.html.haml b/app/views/layouts/public.html.haml
index b3795eaad..07441a77d 100644
--- a/app/views/layouts/public.html.haml
+++ b/app/views/layouts/public.html.haml
@@ -1,5 +1,5 @@
 - content_for :content do
-  .container= yield
+  .container-alt= yield
   .footer
     - if !user_signed_in? && single_user_mode?
       %span.single-user-login
diff --git a/app/views/settings/exports/show.html.haml b/app/views/settings/exports/show.html.haml
index e0df1c480..89d768d3f 100644
--- a/app/views/settings/exports/show.html.haml
+++ b/app/views/settings/exports/show.html.haml
@@ -20,3 +20,26 @@
         %th= t('exports.mutes')
         %td= @export.total_mutes
         %td= table_link_to 'download', t('exports.csv'), settings_exports_mutes_path(format: :csv)
+
+%p.muted-hint= t('exports.archive_takeout.hint_html')
+
+- if policy(:backup).create?
+  %p= link_to t('exports.archive_takeout.request'), settings_export_path, class: 'button', method: :post
+
+- unless @backups.empty?
+  .table-wrapper
+    %table.table
+      %thead
+        %tr
+          %th= t('exports.archive_takeout.date')
+          %th= t('exports.archive_takeout.size')
+          %th
+      %tbody
+        - @backups.each do |backup|
+          %tr
+            %td= l backup.created_at
+            - if backup.processed?
+              %td= number_to_human_size backup.dump_file_size
+              %td= table_link_to 'download', t('exports.archive_takeout.download'), backup.dump.url
+            - else
+              %td{ colspan: 2 }= t('exports.archive_takeout.in_progress')
diff --git a/app/views/user_mailer/backup_ready.html.haml b/app/views/user_mailer/backup_ready.html.haml
new file mode 100644
index 000000000..d5a4b8b48
--- /dev/null
+++ b/app/views/user_mailer/backup_ready.html.haml
@@ -0,0 +1,59 @@
+%table.email-table{ cellspacing: 0, cellpadding: 0 }
+  %tbody
+    %tr
+      %td.email-body
+        .email-container
+          %table.content-section{ cellspacing: 0, cellpadding: 0 }
+            %tbody
+              %tr
+                %td.content-cell.hero
+                  .email-row
+                    .col-6
+                      %table.column{ cellspacing: 0, cellpadding: 0 }
+                        %tbody
+                          %tr
+                            %td.column-cell.text-center.padded
+                              %table.hero-icon{ align: 'center', cellspacing: 0, cellpadding: 0 }
+                                %tbody
+                                  %tr
+                                    %td
+                                      = image_tag full_pack_url('icon_file_download.png'), alt: ''
+
+                              %h1= t 'user_mailer.backup_ready.title'
+
+%table.email-table{ cellspacing: 0, cellpadding: 0 }
+  %tbody
+    %tr
+      %td.email-body
+        .email-container
+          %table.content-section{ cellspacing: 0, cellpadding: 0 }
+            %tbody
+              %tr
+                %td.content-cell.content-start
+                  .email-row
+                    .col-6
+                      %table.column{ cellspacing: 0, cellpadding: 0 }
+                        %tbody
+                          %tr
+                            %td.column-cell.text-center
+                              %p= t 'user_mailer.backup_ready.explanation'
+
+%table.email-table{ cellspacing: 0, cellpadding: 0 }
+  %tbody
+    %tr
+      %td.email-body
+        .email-container
+          %table.content-section{ cellspacing: 0, cellpadding: 0 }
+            %tbody
+              %tr
+                %td.content-cell
+                  %table.column{ cellspacing: 0, cellpadding: 0 }
+                    %tbody
+                      %tr
+                        %td.column-cell.button-cell
+                          %table.button{ align: 'center', cellspacing: 0, cellpadding: 0 }
+                            %tbody
+                              %tr
+                                %td.button-primary
+                                  = link_to full_asset_url(@backup.dump.url) do
+                                    %span= t 'exports.archive_takeout.download'
diff --git a/app/views/user_mailer/backup_ready.text.erb b/app/views/user_mailer/backup_ready.text.erb
new file mode 100644
index 000000000..eb89e7d74
--- /dev/null
+++ b/app/views/user_mailer/backup_ready.text.erb
@@ -0,0 +1,7 @@
+<%= t 'user_mailer.backup_ready.title' %>
+
+===
+
+<%= t 'user_mailer.backup_ready.explanation' %>
+
+=> <%= full_asset_url(@backup.dump.url) %>
diff --git a/app/workers/backup_worker.rb b/app/workers/backup_worker.rb
new file mode 100644
index 000000000..ec6db4e9e
--- /dev/null
+++ b/app/workers/backup_worker.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+class BackupWorker
+  include Sidekiq::Worker
+
+  sidekiq_options queue: 'pull'
+
+  def perform(backup_id)
+    backup = Backup.find(backup_id)
+    user   = backup.user
+
+    BackupService.new.call(backup)
+
+    user.backups.where.not(id: backup.id).destroy_all
+    UserMailer.backup_ready(user, backup).deliver_later
+  end
+end
diff --git a/app/workers/scheduler/backup_cleanup_scheduler.rb b/app/workers/scheduler/backup_cleanup_scheduler.rb
new file mode 100644
index 000000000..7a9d4f894
--- /dev/null
+++ b/app/workers/scheduler/backup_cleanup_scheduler.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+require 'sidekiq-scheduler'
+
+class Scheduler::BackupCleanupScheduler
+  include Sidekiq::Worker
+
+  def perform
+    old_backups.find_each(&:destroy!)
+  end
+
+  private
+
+  def old_backups
+    Backup.where('created_at < ?', 7.days.ago)
+  end
+end