about summary refs log tree commit diff
path: root/app
diff options
context:
space:
mode:
authorThibaut Girka <thib@sitedethib.com>2019-05-04 16:37:26 +0200
committerThibaut Girka <thib@sitedethib.com>2019-05-04 16:37:26 +0200
commit33c80e07838d932efc6214cb1642cefaeb624b67 (patch)
tree51cae2d9096888cc27ae38ec4b0ceceb1853bb73 /app
parent2c2f649200ba5b742ba9d17ac5ba553c752b59aa (diff)
parentc88d9e524b02cba895de9bdb7cba0e29e37703d5 (diff)
Merge branch 'master' into glitch-soc/merge-upstream
Conflicts:
- app/models/media_attachment.rb
Diffstat (limited to 'app')
-rw-r--r--app/controllers/admin/domain_blocks_controller.rb22
-rw-r--r--app/controllers/api/base_controller.rb6
-rw-r--r--app/controllers/api/v1/custom_emojis_controller.rb2
-rw-r--r--app/controllers/api/v1/instances/activity_controller.rb1
-rw-r--r--app/controllers/api/v1/instances/peers_controller.rb1
-rw-r--r--app/controllers/api/v1/instances_controller.rb1
-rw-r--r--app/controllers/auth/registrations_controller.rb3
-rw-r--r--app/controllers/settings/notifications_controller.rb2
-rw-r--r--app/javascript/mastodon/actions/compose.js4
-rw-r--r--app/javascript/mastodon/actions/timelines.js2
-rw-r--r--app/javascript/mastodon/components/media_gallery.js96
-rw-r--r--app/javascript/mastodon/components/status.js3
-rw-r--r--app/javascript/mastodon/components/status_list.js14
-rw-r--r--app/javascript/mastodon/features/account_gallery/components/media_item.js154
-rw-r--r--app/javascript/mastodon/features/account_gallery/index.js73
-rw-r--r--app/javascript/mastodon/features/compose/components/compose_form.js2
-rw-r--r--app/javascript/mastodon/features/compose/components/upload_form.js3
-rw-r--r--app/javascript/mastodon/features/compose/containers/sensitive_button_container.js40
-rw-r--r--app/javascript/mastodon/features/direct_timeline/components/conversations_list.js14
-rw-r--r--app/javascript/mastodon/features/notifications/index.js14
-rw-r--r--app/javascript/mastodon/features/report/components/status_check_box.js1
-rw-r--r--app/javascript/mastodon/features/status/components/detailed_status.js6
-rw-r--r--app/javascript/mastodon/features/status/index.js22
-rw-r--r--app/javascript/mastodon/features/ui/components/media_modal.js32
-rw-r--r--app/javascript/mastodon/features/ui/components/video_modal.js45
-rw-r--r--app/javascript/mastodon/features/ui/index.js9
-rw-r--r--app/javascript/mastodon/features/video/index.js61
-rw-r--r--app/javascript/mastodon/locales/hy.json4
-rw-r--r--app/javascript/styles/mastodon/components.scss181
-rw-r--r--app/javascript/styles/mastodon/forms.scss11
-rw-r--r--app/lib/activitypub/activity/create.rb7
-rw-r--r--app/lib/activitypub/adapter.rb1
-rw-r--r--app/models/concerns/ldap_authenticable.rb1
-rw-r--r--app/models/concerns/omniauthable.rb1
-rw-r--r--app/models/concerns/pam_authenticable.rb1
-rw-r--r--app/models/domain_block.rb7
-rw-r--r--app/models/media_attachment.rb15
-rw-r--r--app/models/user.rb11
-rw-r--r--app/serializers/activitypub/note_serializer.rb4
-rw-r--r--app/serializers/rest/media_attachment_serializer.rb2
-rw-r--r--app/services/block_service.rb1
-rw-r--r--app/validators/blacklisted_email_validator.rb5
-rw-r--r--app/views/stream_entries/_detailed_status.html.haml2
-rw-r--r--app/views/stream_entries/_simple_status.html.haml2
-rw-r--r--app/workers/activitypub/processing_worker.rb2
45 files changed, 635 insertions, 256 deletions
diff --git a/app/controllers/admin/domain_blocks_controller.rb b/app/controllers/admin/domain_blocks_controller.rb
index 5f307ddee..dd3f83389 100644
--- a/app/controllers/admin/domain_blocks_controller.rb
+++ b/app/controllers/admin/domain_blocks_controller.rb
@@ -13,13 +13,25 @@ module Admin
       authorize :domain_block, :create?
 
       @domain_block = DomainBlock.new(resource_params)
+      existing_domain_block = resource_params[:domain].present? ? DomainBlock.find_by(domain: resource_params[:domain]) : nil
 
-      if @domain_block.save
-        DomainBlockWorker.perform_async(@domain_block.id)
-        log_action :create, @domain_block
-        redirect_to admin_instances_path(limited: '1'), notice: I18n.t('admin.domain_blocks.created_msg')
-      else
+      if existing_domain_block.present? && !@domain_block.stricter_than?(existing_domain_block)
+        @domain_block.save
+        flash[:alert] = I18n.t('admin.domain_blocks.existing_domain_block_html', name: existing_domain_block.domain, unblock_url: admin_domain_block_path(existing_domain_block)).html_safe # rubocop:disable Rails/OutputSafety
+        @domain_block.errors[:domain].clear
         render :new
+      else
+        if existing_domain_block.present?
+          @domain_block = existing_domain_block
+          @domain_block.update(resource_params)
+        end
+        if @domain_block.save
+          DomainBlockWorker.perform_async(@domain_block.id)
+          log_action :create, @domain_block
+          redirect_to admin_instances_path(limited: '1'), notice: I18n.t('admin.domain_blocks.created_msg')
+        else
+          render :new
+        end
       end
     end
 
diff --git a/app/controllers/api/base_controller.rb b/app/controllers/api/base_controller.rb
index 3a92ee4e4..eca558f42 100644
--- a/app/controllers/api/base_controller.rb
+++ b/app/controllers/api/base_controller.rb
@@ -9,6 +9,8 @@ class Api::BaseController < ApplicationController
   skip_before_action :store_current_location
   skip_before_action :check_user_permissions
 
+  before_action :set_cache_headers
+
   protect_from_forgery with: :null_session
 
   rescue_from ActiveRecord::RecordInvalid, Mastodon::ValidationError do |e|
@@ -88,4 +90,8 @@ class Api::BaseController < ApplicationController
   def authorize_if_got_token!(*scopes)
     doorkeeper_authorize!(*scopes) if doorkeeper_token
   end
+
+  def set_cache_headers
+    response.headers['Cache-Control'] = 'no-cache, no-store, max-age=0, must-revalidate'
+  end
 end
diff --git a/app/controllers/api/v1/custom_emojis_controller.rb b/app/controllers/api/v1/custom_emojis_controller.rb
index 7bac27da4..1bb19a09d 100644
--- a/app/controllers/api/v1/custom_emojis_controller.rb
+++ b/app/controllers/api/v1/custom_emojis_controller.rb
@@ -3,6 +3,8 @@
 class Api::V1::CustomEmojisController < Api::BaseController
   respond_to :json
 
+  skip_before_action :set_cache_headers
+
   def index
     render_cached_json('api:v1:custom_emojis', expires_in: 1.minute) do
       ActiveModelSerializers::SerializableResource.new(CustomEmoji.local.where(disabled: false), each_serializer: REST::CustomEmojiSerializer)
diff --git a/app/controllers/api/v1/instances/activity_controller.rb b/app/controllers/api/v1/instances/activity_controller.rb
index e14e0aee8..09edfe365 100644
--- a/app/controllers/api/v1/instances/activity_controller.rb
+++ b/app/controllers/api/v1/instances/activity_controller.rb
@@ -2,6 +2,7 @@
 
 class Api::V1::Instances::ActivityController < Api::BaseController
   before_action :require_enabled_api!
+  skip_before_action :set_cache_headers
 
   respond_to :json
 
diff --git a/app/controllers/api/v1/instances/peers_controller.rb b/app/controllers/api/v1/instances/peers_controller.rb
index 2070c487d..a8891d126 100644
--- a/app/controllers/api/v1/instances/peers_controller.rb
+++ b/app/controllers/api/v1/instances/peers_controller.rb
@@ -2,6 +2,7 @@
 
 class Api::V1::Instances::PeersController < Api::BaseController
   before_action :require_enabled_api!
+  skip_before_action :set_cache_headers
 
   respond_to :json
 
diff --git a/app/controllers/api/v1/instances_controller.rb b/app/controllers/api/v1/instances_controller.rb
index 5686e8d7c..8c83a1801 100644
--- a/app/controllers/api/v1/instances_controller.rb
+++ b/app/controllers/api/v1/instances_controller.rb
@@ -2,6 +2,7 @@
 
 class Api::V1::InstancesController < Api::BaseController
   respond_to :json
+  skip_before_action :set_cache_headers
 
   def show
     render_cached_json('api:v1:instances', expires_in: 5.minutes) do
diff --git a/app/controllers/auth/registrations_controller.rb b/app/controllers/auth/registrations_controller.rb
index 84099bd96..c56728464 100644
--- a/app/controllers/auth/registrations_controller.rb
+++ b/app/controllers/auth/registrations_controller.rb
@@ -96,7 +96,8 @@ class Auth::RegistrationsController < Devise::RegistrationsController
   end
 
   def set_invite
-    @invite = invite_code.present? ? Invite.find_by(code: invite_code) : nil
+    invite = invite_code.present? ? Invite.find_by(code: invite_code) : nil
+    @invite = invite&.valid_for_use? ? invite : nil
   end
 
   def determine_layout
diff --git a/app/controllers/settings/notifications_controller.rb b/app/controllers/settings/notifications_controller.rb
index 68ebddfc9..7d7e237fb 100644
--- a/app/controllers/settings/notifications_controller.rb
+++ b/app/controllers/settings/notifications_controller.rb
@@ -21,7 +21,7 @@ class Settings::NotificationsController < Settings::BaseController
 
   def user_settings_params
     params.require(:user).permit(
-      notification_emails: %i(follow follow_request reblog favourite mention digest report),
+      notification_emails: %i(follow follow_request reblog favourite mention digest report pending_account),
       interactions: %i(must_be_follower must_be_following must_be_following_dm)
     )
   end
diff --git a/app/javascript/mastodon/actions/compose.js b/app/javascript/mastodon/actions/compose.js
index d65d41048..0ee663766 100644
--- a/app/javascript/mastodon/actions/compose.js
+++ b/app/javascript/mastodon/actions/compose.js
@@ -203,8 +203,8 @@ export function uploadCompose(files) {
   return function (dispatch, getState) {
     const uploadLimit = 4;
     const media  = getState().getIn(['compose', 'media_attachments']);
-    const total = Array.from(files).reduce((a, v) => a + v.size, 0);
     const progress = new Array(files.length).fill(0);
+    let total = Array.from(files).reduce((a, v) => a + v.size, 0);
 
     if (files.length + media.size > uploadLimit) {
       dispatch(showAlert(undefined, messages.uploadErrorLimit));
@@ -224,6 +224,8 @@ export function uploadCompose(files) {
       resizeImage(f).then(file => {
         const data = new FormData();
         data.append('file', file);
+        // Account for disparity in size of original image and resized data
+        total += file.size - f.size;
 
         return api(getState).post('/api/v1/media', data, {
           onUploadProgress: function({ loaded }){
diff --git a/app/javascript/mastodon/actions/timelines.js b/app/javascript/mastodon/actions/timelines.js
index d92385e95..06c21b96b 100644
--- a/app/javascript/mastodon/actions/timelines.js
+++ b/app/javascript/mastodon/actions/timelines.js
@@ -96,7 +96,7 @@ export const expandPublicTimeline          = ({ maxId, onlyMedia } = {}, done =
 export const expandCommunityTimeline       = ({ maxId, onlyMedia } = {}, done = noOp) => expandTimeline(`community${onlyMedia ? ':media' : ''}`, '/api/v1/timelines/public', { local: true, max_id: maxId, only_media: !!onlyMedia }, done);
 export const expandAccountTimeline         = (accountId, { maxId, withReplies } = {}) => expandTimeline(`account:${accountId}${withReplies ? ':with_replies' : ''}`, `/api/v1/accounts/${accountId}/statuses`, { exclude_replies: !withReplies, max_id: maxId });
 export const expandAccountFeaturedTimeline = accountId => expandTimeline(`account:${accountId}:pinned`, `/api/v1/accounts/${accountId}/statuses`, { pinned: true });
-export const expandAccountMediaTimeline    = (accountId, { maxId } = {}) => expandTimeline(`account:${accountId}:media`, `/api/v1/accounts/${accountId}/statuses`, { max_id: maxId, only_media: true });
+export const expandAccountMediaTimeline    = (accountId, { maxId } = {}) => expandTimeline(`account:${accountId}:media`, `/api/v1/accounts/${accountId}/statuses`, { max_id: maxId, only_media: true, limit: 40 });
 export const expandListTimeline            = (id, { maxId } = {}, done = noOp) => expandTimeline(`list:${id}`, `/api/v1/timelines/list/${id}`, { max_id: maxId }, done);
 export const expandHashtagTimeline         = (hashtag, { maxId, tags } = {}, done = noOp) => {
   return expandTimeline(`hashtag:${hashtag}`, `/api/v1/timelines/tag/${hashtag}`, {
diff --git a/app/javascript/mastodon/components/media_gallery.js b/app/javascript/mastodon/components/media_gallery.js
index a2bc95255..abd17647e 100644
--- a/app/javascript/mastodon/components/media_gallery.js
+++ b/app/javascript/mastodon/components/media_gallery.js
@@ -7,6 +7,7 @@ import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
 import { isIOS } from '../is_mobile';
 import classNames from 'classnames';
 import { autoPlayGif, displayMedia } from '../initial_state';
+import { decode } from 'blurhash';
 
 const messages = defineMessages({
   toggle_visible: { id: 'media_gallery.toggle_visible', defaultMessage: 'Toggle visibility' },
@@ -21,6 +22,7 @@ class Item extends React.PureComponent {
     size: PropTypes.number.isRequired,
     onClick: PropTypes.func.isRequired,
     displayWidth: PropTypes.number,
+    visible: PropTypes.bool.isRequired,
   };
 
   static defaultProps = {
@@ -29,6 +31,10 @@ class Item extends React.PureComponent {
     size: 1,
   };
 
+  state = {
+    loaded: false,
+  };
+
   handleMouseEnter = (e) => {
     if (this.hoverToPlay()) {
       e.target.play();
@@ -62,8 +68,40 @@ class Item extends React.PureComponent {
     e.stopPropagation();
   }
 
+  componentDidMount () {
+    if (this.props.attachment.get('blurhash')) {
+      this._decode();
+    }
+  }
+
+  componentDidUpdate (prevProps) {
+    if (prevProps.attachment.get('blurhash') !== this.props.attachment.get('blurhash') && this.props.attachment.get('blurhash')) {
+      this._decode();
+    }
+  }
+
+  _decode () {
+    const hash   = this.props.attachment.get('blurhash');
+    const pixels = decode(hash, 32, 32);
+
+    if (pixels) {
+      const ctx       = this.canvas.getContext('2d');
+      const imageData = new ImageData(pixels, 32, 32);
+
+      ctx.putImageData(imageData, 0, 0);
+    }
+  }
+
+  setCanvasRef = c => {
+    this.canvas = c;
+  }
+
+  handleImageLoad = () => {
+    this.setState({ loaded: true });
+  }
+
   render () {
-    const { attachment, index, size, standalone, displayWidth } = this.props;
+    const { attachment, index, size, standalone, displayWidth, visible } = this.props;
 
     let width  = 50;
     let height = 100;
@@ -116,12 +154,20 @@ class Item extends React.PureComponent {
 
     let thumbnail = '';
 
-    if (attachment.get('type') === 'image') {
+    if (attachment.get('type') === 'unknown') {
+      return (
+        <div className={classNames('media-gallery__item', { standalone })} key={attachment.get('id')} style={{ left: left, top: top, right: right, bottom: bottom, width: `${width}%`, height: `${height}%` }}>
+          <a className='media-gallery__item-thumbnail' href={attachment.get('remote_url')} target='_blank' style={{ cursor: 'pointer' }}>
+            <canvas width={32} height={32} ref={this.setCanvasRef} className='media-gallery__preview' />
+          </a>
+        </div>
+      );
+    } else 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 originalUrl   = attachment.get('url');
+      const originalWidth = attachment.getIn(['meta', 'original', 'width']);
 
       const hasSize = typeof originalWidth === 'number' && typeof previewWidth === 'number';
 
@@ -147,6 +193,7 @@ class Item extends React.PureComponent {
             alt={attachment.get('description')}
             title={attachment.get('description')}
             style={{ objectPosition: `${x}% ${y}%` }}
+            onLoad={this.handleImageLoad}
           />
         </a>
       );
@@ -176,7 +223,8 @@ class Item extends React.PureComponent {
 
     return (
       <div className={classNames('media-gallery__item', { standalone })} key={attachment.get('id')} style={{ left: left, top: top, right: right, bottom: bottom, width: `${width}%`, height: `${height}%` }}>
-        {thumbnail}
+        <canvas width={32} height={32} ref={this.setCanvasRef} className={classNames('media-gallery__preview', { 'media-gallery__preview--hidden': visible && this.state.loaded })} />
+        {visible && thumbnail}
       </div>
     );
   }
@@ -225,6 +273,7 @@ class MediaGallery extends React.PureComponent {
     if (node /*&& this.isStandaloneEligible()*/) {
       // offsetWidth triggers a layout, so only calculate when we need to
       if (this.props.cacheWidth) this.props.cacheWidth(node.offsetWidth);
+
       this.setState({
         width: node.offsetWidth,
       });
@@ -242,7 +291,7 @@ class MediaGallery extends React.PureComponent {
 
     const width = this.state.width || defaultWidth;
 
-    let children;
+    let children, spoilerButton;
 
     const style = {};
 
@@ -256,35 +305,28 @@ class MediaGallery extends React.PureComponent {
       style.height = height;
     }
 
-    if (!visible) {
-      let warning;
+    const size = media.take(4).size;
 
-      if (sensitive) {
-        warning = <FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' />;
-      } else {
-        warning = <FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' />;
-      }
+    if (this.isStandaloneEligible()) {
+      children = <Item standalone onClick={this.handleClick} attachment={media.get(0)} displayWidth={width} visible={visible} />;
+    } else {
+      children = media.take(4).map((attachment, i) => <Item key={attachment.get('id')} onClick={this.handleClick} attachment={attachment} index={i} size={size} displayWidth={width} visible={visible} />);
+    }
 
-      children = (
-        <button type='button' className='media-spoiler' onClick={this.handleOpen} style={style} ref={this.handleRef}>
-          <span className='media-spoiler__warning'>{warning}</span>
-          <span className='media-spoiler__trigger'><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span>
+    if (visible) {
+      spoilerButton = <IconButton title={intl.formatMessage(messages.toggle_visible)} icon='eye-slash' overlay onClick={this.handleOpen} />;
+    } else {
+      spoilerButton = (
+        <button type='button' onClick={this.handleOpen} className='spoiler-button__overlay'>
+          <span className='spoiler-button__overlay__label'>{sensitive ? <FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' /> : <FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' />}</span>
         </button>
       );
-    } else {
-      const size = media.take(4).size;
-
-      if (this.isStandaloneEligible()) {
-        children = <Item standalone onClick={this.handleClick} attachment={media.get(0)} displayWidth={width} />;
-      } else {
-        children = media.take(4).map((attachment, i) => <Item key={attachment.get('id')} onClick={this.handleClick} attachment={attachment} index={i} size={size} displayWidth={width} />);
-      }
     }
 
     return (
       <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 className={classNames('spoiler-button', { 'spoiler-button--minified': visible })}>
+          {spoilerButton}
         </div>
 
         {children}
diff --git a/app/javascript/mastodon/components/status.js b/app/javascript/mastodon/components/status.js
index cea9a0c2e..95ca4a548 100644
--- a/app/javascript/mastodon/components/status.js
+++ b/app/javascript/mastodon/components/status.js
@@ -274,7 +274,7 @@ class Status extends ImmutablePureComponent {
     if (status.get('poll')) {
       media = <PollContainer pollId={status.get('poll')} />;
     } else if (status.get('media_attachments').size > 0) {
-      if (this.props.muted || status.get('media_attachments').some(item => item.get('type') === 'unknown')) {
+      if (this.props.muted) {
         media = (
           <AttachmentList
             compact
@@ -289,6 +289,7 @@ class Status extends ImmutablePureComponent {
             {Component => (
               <Component
                 preview={video.get('preview_url')}
+                blurhash={video.get('blurhash')}
                 src={video.get('url')}
                 alt={video.get('description')}
                 width={this.props.cachedMediaWidth}
diff --git a/app/javascript/mastodon/components/status_list.js b/app/javascript/mastodon/components/status_list.js
index e417f9a2b..745e6422d 100644
--- a/app/javascript/mastodon/components/status_list.js
+++ b/app/javascript/mastodon/components/status_list.js
@@ -46,22 +46,28 @@ export default class StatusList extends ImmutablePureComponent {
 
   handleMoveUp = (id, featured) => {
     const elementIndex = this.getCurrentStatusIndex(id, featured) - 1;
-    this._selectChild(elementIndex);
+    this._selectChild(elementIndex, true);
   }
 
   handleMoveDown = (id, featured) => {
     const elementIndex = this.getCurrentStatusIndex(id, featured) + 1;
-    this._selectChild(elementIndex);
+    this._selectChild(elementIndex, false);
   }
 
   handleLoadOlder = debounce(() => {
     this.props.onLoadMore(this.props.statusIds.size > 0 ? this.props.statusIds.last() : undefined);
   }, 300, { leading: true })
 
-  _selectChild (index) {
-    const element = this.node.node.querySelector(`article:nth-of-type(${index + 1}) .focusable`);
+  _selectChild (index, align_top) {
+    const container = this.node.node;
+    const element = container.querySelector(`article:nth-of-type(${index + 1}) .focusable`);
 
     if (element) {
+      if (align_top && container.scrollTop > element.offsetTop) {
+        element.scrollIntoView(true);
+      } else if (!align_top && container.scrollTop + container.clientHeight < element.offsetTop + element.offsetHeight) {
+        element.scrollIntoView(false);
+      }
       element.focus();
     }
   }
diff --git a/app/javascript/mastodon/features/account_gallery/components/media_item.js b/app/javascript/mastodon/features/account_gallery/components/media_item.js
index 80ac9d9ec..8d462996e 100644
--- a/app/javascript/mastodon/features/account_gallery/components/media_item.js
+++ b/app/javascript/mastodon/features/account_gallery/components/media_item.js
@@ -1,62 +1,142 @@
 import React from 'react';
+import PropTypes from 'prop-types';
 import ImmutablePropTypes from 'react-immutable-proptypes';
 import ImmutablePureComponent from 'react-immutable-pure-component';
-import Permalink from '../../../components/permalink';
-import { displayMedia } from '../../../initial_state';
-import Icon from 'mastodon/components/icon';
+import { autoPlayGif, displayMedia } from 'mastodon/initial_state';
+import classNames from 'classnames';
+import { decode } from 'blurhash';
+import { isIOS } from 'mastodon/is_mobile';
 
 export default class MediaItem extends ImmutablePureComponent {
 
   static propTypes = {
-    media: ImmutablePropTypes.map.isRequired,
+    attachment: ImmutablePropTypes.map.isRequired,
+    displayWidth: PropTypes.number.isRequired,
+    onOpenMedia: PropTypes.func.isRequired,
   };
 
   state = {
-    visible: displayMedia !== 'hide_all' && !this.props.media.getIn(['status', 'sensitive']) || displayMedia === 'show_all',
+    visible: displayMedia !== 'hide_all' && !this.props.attachment.getIn(['status', 'sensitive']) || displayMedia === 'show_all',
+    loaded: false,
   };
 
-  handleClick = () => {
-    if (!this.state.visible) {
-      this.setState({ visible: true });
-      return true;
+  componentDidMount () {
+    if (this.props.attachment.get('blurhash')) {
+      this._decode();
     }
+  }
 
-    return false;
+  componentDidUpdate (prevProps) {
+    if (prevProps.attachment.get('blurhash') !== this.props.attachment.get('blurhash') && this.props.attachment.get('blurhash')) {
+      this._decode();
+    }
   }
 
-  render () {
-    const { media } = this.props;
-    const { visible } = this.state;
-    const status = media.get('status');
-    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;
-    const style = {};
-
-    let label, icon;
-
-    if (media.get('type') === 'gifv') {
-      label = <span className='media-gallery__gifv__label'>GIF</span>;
+  _decode () {
+    const hash   = this.props.attachment.get('blurhash');
+    const pixels = decode(hash, 32, 32);
+
+    if (pixels) {
+      const ctx       = this.canvas.getContext('2d');
+      const imageData = new ImageData(pixels, 32, 32);
+
+      ctx.putImageData(imageData, 0, 0);
+    }
+  }
+
+  setCanvasRef = c => {
+    this.canvas = c;
+  }
+
+  handleImageLoad = () => {
+    this.setState({ loaded: true });
+  }
+
+  handleMouseEnter = e => {
+    if (this.hoverToPlay()) {
+      e.target.play();
+    }
+  }
+
+  handleMouseLeave = e => {
+    if (this.hoverToPlay()) {
+      e.target.pause();
+      e.target.currentTime = 0;
     }
+  }
+
+  hoverToPlay () {
+    return !autoPlayGif && ['gifv', 'video'].indexOf(this.props.attachment.get('type')) !== -1;
+  }
+
+  handleClick = e => {
+    if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
+      e.preventDefault();
+
+      if (this.state.visible) {
+        this.props.onOpenMedia(this.props.attachment);
+      } else {
+        this.setState({ visible: true });
+      }
+    }
+  }
+
+  render () {
+    const { attachment, displayWidth } = this.props;
+    const { visible, loaded } = this.state;
+
+    const width  = `${Math.floor((displayWidth - 4) / 3) - 4}px`;
+    const height = width;
+    const status = attachment.get('status');
+
+    let thumbnail = '';
+
+    if (attachment.get('type') === 'unknown') {
+      // Skip
+    } else if (attachment.get('type') === 'image') {
+      const focusX = attachment.getIn(['meta', 'focus', 'x']) || 0;
+      const focusY = attachment.getIn(['meta', 'focus', 'y']) || 0;
+      const x      = ((focusX /  2) + .5) * 100;
+      const y      = ((focusY / -2) + .5) * 100;
+
+      thumbnail = (
+        <img
+          src={attachment.get('preview_url')}
+          alt={attachment.get('description')}
+          title={attachment.get('description')}
+          style={{ objectPosition: `${x}% ${y}%` }}
+          onLoad={this.handleImageLoad}
+        />
+      );
+    } else if (['gifv', 'video'].indexOf(attachment.get('type')) !== -1) {
+      const autoPlay = !isIOS() && autoPlayGif;
+
+      thumbnail = (
+        <div className={classNames('media-gallery__gifv', { autoplay: autoPlay })}>
+          <video
+            className='media-gallery__item-gifv-thumbnail'
+            aria-label={attachment.get('description')}
+            title={attachment.get('description')}
+            role='application'
+            src={attachment.get('url')}
+            onMouseEnter={this.handleMouseEnter}
+            onMouseLeave={this.handleMouseLeave}
+            autoPlay={autoPlay}
+            loop
+            muted
+          />
 
-    if (visible) {
-      style.backgroundImage    = `url(${media.get('preview_url')})`;
-      style.backgroundPosition = `${x}% ${y}%`;
-    } else {
-      icon = (
-        <span className='account-gallery__item__icons'>
-          <Icon id='eye-slash' />
-        </span>
+          <span className='media-gallery__gifv__label'>GIF</span>
+        </div>
       );
     }
 
     return (
-      <div className='account-gallery__item'>
-        <Permalink to={`/statuses/${status.get('id')}`} href={status.get('url')} style={style} onInterceptClick={this.handleClick}>
-          {icon}
-          {label}
-        </Permalink>
+      <div className='account-gallery__item' style={{ width, height }}>
+        <a className='media-gallery__item-thumbnail' href={status.get('url')} target='_blank' style={{ cursor: 'pointer' }} onClick={this.handleClick}>
+          <canvas width={32} height={32} ref={this.setCanvasRef} className={classNames('media-gallery__preview', { 'media-gallery__preview--hidden': visible && loaded })} />
+          {visible && thumbnail}
+        </a>
       </div>
     );
   }
diff --git a/app/javascript/mastodon/features/account_gallery/index.js b/app/javascript/mastodon/features/account_gallery/index.js
index 73be58d6a..5d6a53e18 100644
--- a/app/javascript/mastodon/features/account_gallery/index.js
+++ b/app/javascript/mastodon/features/account_gallery/index.js
@@ -2,24 +2,25 @@ import React from 'react';
 import { connect } from 'react-redux';
 import ImmutablePropTypes from 'react-immutable-proptypes';
 import PropTypes from 'prop-types';
-import { fetchAccount } from '../../actions/accounts';
+import { fetchAccount } from 'mastodon/actions/accounts';
 import { expandAccountMediaTimeline } from '../../actions/timelines';
-import LoadingIndicator from '../../components/loading_indicator';
+import LoadingIndicator from 'mastodon/components/loading_indicator';
 import Column from '../ui/components/column';
-import ColumnBackButton from '../../components/column_back_button';
+import ColumnBackButton from 'mastodon/components/column_back_button';
 import ImmutablePureComponent from 'react-immutable-pure-component';
-import { getAccountGallery } from '../../selectors';
+import { getAccountGallery } from 'mastodon/selectors';
 import MediaItem from './components/media_item';
 import HeaderContainer from '../account_timeline/containers/header_container';
 import { ScrollContainer } from 'react-router-scroll-4';
-import LoadMore from '../../components/load_more';
+import LoadMore from 'mastodon/components/load_more';
 import MissingIndicator from 'mastodon/components/missing_indicator';
+import { openModal } from 'mastodon/actions/modal';
 
 const mapStateToProps = (state, props) => ({
   isAccount: !!state.getIn(['accounts', props.params.accountId]),
-  medias: getAccountGallery(state, props.params.accountId),
+  attachments: getAccountGallery(state, props.params.accountId),
   isLoading: state.getIn(['timelines', `account:${props.params.accountId}:media`, 'isLoading']),
-  hasMore:   state.getIn(['timelines', `account:${props.params.accountId}:media`, 'hasMore']),
+  hasMore: state.getIn(['timelines', `account:${props.params.accountId}:media`, 'hasMore']),
 });
 
 class LoadMoreMedia extends ImmutablePureComponent {
@@ -51,12 +52,16 @@ class AccountGallery extends ImmutablePureComponent {
   static propTypes = {
     params: PropTypes.object.isRequired,
     dispatch: PropTypes.func.isRequired,
-    medias: ImmutablePropTypes.list.isRequired,
+    attachments: ImmutablePropTypes.list.isRequired,
     isLoading: PropTypes.bool,
     hasMore: PropTypes.bool,
     isAccount: PropTypes.bool,
   };
 
+  state = {
+    width: 323,
+  };
+
   componentDidMount () {
     this.props.dispatch(fetchAccount(this.props.params.accountId));
     this.props.dispatch(expandAccountMediaTimeline(this.props.params.accountId));
@@ -71,11 +76,11 @@ class AccountGallery extends ImmutablePureComponent {
 
   handleScrollToBottom = () => {
     if (this.props.hasMore) {
-      this.handleLoadMore(this.props.medias.size > 0 ? this.props.medias.last().getIn(['status', 'id']) : undefined);
+      this.handleLoadMore(this.props.attachments.size > 0 ? this.props.attachments.last().getIn(['status', 'id']) : undefined);
     }
   }
 
-  handleScroll = (e) => {
+  handleScroll = e => {
     const { scrollTop, scrollHeight, clientHeight } = e.target;
     const offset = scrollHeight - scrollTop - clientHeight;
 
@@ -88,13 +93,31 @@ class AccountGallery extends ImmutablePureComponent {
     this.props.dispatch(expandAccountMediaTimeline(this.props.params.accountId, { maxId }));
   };
 
-  handleLoadOlder = (e) => {
+  handleLoadOlder = e => {
     e.preventDefault();
     this.handleScrollToBottom();
   }
 
+  handleOpenMedia = attachment => {
+    if (attachment.get('type') === 'video') {
+      this.props.dispatch(openModal('VIDEO', { media: attachment, status: attachment.get('status') }));
+    } else {
+      const media = attachment.getIn(['status', 'media_attachments']);
+      const index = media.findIndex(x => x.get('id') === attachment.get('id'));
+
+      this.props.dispatch(openModal('MEDIA', { media, index, status: attachment.get('status') }));
+    }
+  }
+
+  handleRef = c => {
+    if (c) {
+      this.setState({ width: c.offsetWidth });
+    }
+  }
+
   render () {
-    const { medias, shouldUpdateScroll, isLoading, hasMore, isAccount } = this.props;
+    const { attachments, shouldUpdateScroll, isLoading, hasMore, isAccount } = this.props;
+    const { width } = this.state;
 
     if (!isAccount) {
       return (
@@ -104,9 +127,7 @@ class AccountGallery extends ImmutablePureComponent {
       );
     }
 
-    let loadOlder = null;
-
-    if (!medias && isLoading) {
+    if (!attachments && isLoading) {
       return (
         <Column>
           <LoadingIndicator />
@@ -114,7 +135,9 @@ class AccountGallery extends ImmutablePureComponent {
       );
     }
 
-    if (hasMore && !(isLoading && medias.size === 0)) {
+    let loadOlder = null;
+
+    if (hasMore && !(isLoading && attachments.size === 0)) {
       loadOlder = <LoadMore visible={!isLoading} onClick={this.handleLoadOlder} />;
     }
 
@@ -126,23 +149,17 @@ class AccountGallery extends ImmutablePureComponent {
           <div className='scrollable scrollable--flex' onScroll={this.handleScroll}>
             <HeaderContainer accountId={this.props.params.accountId} />
 
-            <div role='feed' className='account-gallery__container'>
-              {medias.map((media, index) => media === null ? (
-                <LoadMoreMedia
-                  key={'more:' + medias.getIn(index + 1, 'id')}
-                  maxId={index > 0 ? medias.getIn(index - 1, 'id') : null}
-                  onLoadMore={this.handleLoadMore}
-                />
+            <div role='feed' className='account-gallery__container' ref={this.handleRef}>
+              {attachments.map((attachment, index) => attachment === null ? (
+                <LoadMoreMedia key={'more:' + attachments.getIn(index + 1, 'id')} maxId={index > 0 ? attachments.getIn(index - 1, 'id') : null} onLoadMore={this.handleLoadMore} />
               ) : (
-                <MediaItem
-                  key={media.get('id')}
-                  media={media}
-                />
+                <MediaItem key={attachment.get('id')} attachment={attachment} displayWidth={width} onOpenMedia={this.handleOpenMedia} />
               ))}
+
               {loadOlder}
             </div>
 
-            {isLoading && medias.size === 0 && (
+            {isLoading && attachments.size === 0 && (
               <div className='scrollable__append'>
                 <LoadingIndicator />
               </div>
diff --git a/app/javascript/mastodon/features/compose/components/compose_form.js b/app/javascript/mastodon/features/compose/components/compose_form.js
index 13514fa0f..03738f1de 100644
--- a/app/javascript/mastodon/features/compose/components/compose_form.js
+++ b/app/javascript/mastodon/features/compose/components/compose_form.js
@@ -10,7 +10,6 @@ import UploadButtonContainer from '../containers/upload_button_container';
 import { defineMessages, injectIntl } from 'react-intl';
 import SpoilerButtonContainer from '../containers/spoiler_button_container';
 import PrivacyDropdownContainer from '../containers/privacy_dropdown_container';
-import SensitiveButtonContainer from '../containers/sensitive_button_container';
 import EmojiPickerDropdown from '../containers/emoji_picker_dropdown_container';
 import PollFormContainer from '../containers/poll_form_container';
 import UploadFormContainer from '../containers/upload_form_container';
@@ -215,7 +214,6 @@ class ComposeForm extends ImmutablePureComponent {
             <UploadButtonContainer />
             <PollButtonContainer />
             <PrivacyDropdownContainer />
-            <SensitiveButtonContainer />
             <SpoilerButtonContainer />
           </div>
           <div className='character-counter__wrapper'><CharacterCounter max={maxChars} text={text} /></div>
diff --git a/app/javascript/mastodon/features/compose/components/upload_form.js b/app/javascript/mastodon/features/compose/components/upload_form.js
index b7f112205..9ff2aa0fa 100644
--- a/app/javascript/mastodon/features/compose/components/upload_form.js
+++ b/app/javascript/mastodon/features/compose/components/upload_form.js
@@ -3,6 +3,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
 import UploadProgressContainer from '../containers/upload_progress_container';
 import ImmutablePureComponent from 'react-immutable-pure-component';
 import UploadContainer from '../containers/upload_container';
+import SensitiveButtonContainer from '../containers/sensitive_button_container';
 
 export default class UploadForm extends ImmutablePureComponent {
 
@@ -22,6 +23,8 @@ export default class UploadForm extends ImmutablePureComponent {
             <UploadContainer id={id} key={id} />
           ))}
         </div>
+
+        {!mediaIds.isEmpty() && <SensitiveButtonContainer />}
       </div>
     );
   }
diff --git a/app/javascript/mastodon/features/compose/containers/sensitive_button_container.js b/app/javascript/mastodon/features/compose/containers/sensitive_button_container.js
index 43de8f213..50612b086 100644
--- a/app/javascript/mastodon/features/compose/containers/sensitive_button_container.js
+++ b/app/javascript/mastodon/features/compose/containers/sensitive_button_container.js
@@ -2,11 +2,9 @@ import React from 'react';
 import { connect } from 'react-redux';
 import PropTypes from 'prop-types';
 import classNames from 'classnames';
-import IconButton from '../../../components/icon_button';
-import { changeComposeSensitivity } from '../../../actions/compose';
-import Motion from '../../ui/util/optional_motion';
-import spring from 'react-motion/lib/spring';
-import { injectIntl, defineMessages } from 'react-intl';
+import { changeComposeSensitivity } from 'mastodon/actions/compose';
+import { injectIntl, defineMessages, FormattedMessage } from 'react-intl';
+import Icon from 'mastodon/components/icon';
 
 const messages = defineMessages({
   marked: { id: 'compose_form.sensitive.marked', defaultMessage: 'Media is marked as sensitive' },
@@ -14,7 +12,6 @@ const messages = defineMessages({
 });
 
 const mapStateToProps = state => ({
-  visible: state.getIn(['compose', 'media_attachments']).size > 0,
   active: state.getIn(['compose', 'sensitive']),
   disabled: state.getIn(['compose', 'spoiler']),
 });
@@ -30,7 +27,6 @@ const mapDispatchToProps = dispatch => ({
 class SensitiveButton extends React.PureComponent {
 
   static propTypes = {
-    visible: PropTypes.bool,
     active: PropTypes.bool,
     disabled: PropTypes.bool,
     onClick: PropTypes.func.isRequired,
@@ -38,32 +34,14 @@ class SensitiveButton extends React.PureComponent {
   };
 
   render () {
-    const { visible, active, disabled, onClick, intl } = this.props;
+    const { active, disabled, onClick, intl } = this.props;
 
     return (
-      <Motion defaultStyle={{ scale: 0.87 }} style={{ scale: spring(visible ? 1 : 0.87, { stiffness: 200, damping: 3 }) }}>
-        {({ scale }) => {
-          const icon = active ? 'eye-slash' : 'eye';
-          const className = classNames('compose-form__sensitive-button', {
-            'compose-form__sensitive-button--visible': visible,
-          });
-          return (
-            <div className={className} style={{ transform: `scale(${scale})` }}>
-              <IconButton
-                className='compose-form__sensitive-button__icon'
-                title={intl.formatMessage(active ? messages.marked : messages.unmarked)}
-                icon={icon}
-                onClick={onClick}
-                size={18}
-                active={active}
-                disabled={disabled}
-                style={{ lineHeight: null, height: null }}
-                inverted
-              />
-            </div>
-          );
-        }}
-      </Motion>
+      <div className='compose-form__sensitive-button'>
+        <button className={classNames('icon-button', { active })} onClick={onClick} disabled={disabled} title={intl.formatMessage(active ? messages.marked : messages.unmarked)}>
+          <Icon id='eye-slash' /> <FormattedMessage id='compose_form.sensitive.hide' defaultMessage='Mark media as sensitive' />
+        </button>
+      </div>
     );
   }
 
diff --git a/app/javascript/mastodon/features/direct_timeline/components/conversations_list.js b/app/javascript/mastodon/features/direct_timeline/components/conversations_list.js
index 635c03c1d..8867bbd73 100644
--- a/app/javascript/mastodon/features/direct_timeline/components/conversations_list.js
+++ b/app/javascript/mastodon/features/direct_timeline/components/conversations_list.js
@@ -20,18 +20,24 @@ export default class ConversationsList extends ImmutablePureComponent {
 
   handleMoveUp = id => {
     const elementIndex = this.getCurrentIndex(id) - 1;
-    this._selectChild(elementIndex);
+    this._selectChild(elementIndex, true);
   }
 
   handleMoveDown = id => {
     const elementIndex = this.getCurrentIndex(id) + 1;
-    this._selectChild(elementIndex);
+    this._selectChild(elementIndex, false);
   }
 
-  _selectChild (index) {
-    const element = this.node.node.querySelector(`article:nth-of-type(${index + 1}) .focusable`);
+  _selectChild (index, align_top) {
+    const container = this.node.node;
+    const element = container.querySelector(`article:nth-of-type(${index + 1}) .focusable`);
 
     if (element) {
+      if (align_top && container.scrollTop > element.offsetTop) {
+        element.scrollIntoView(true);
+      } else if (!align_top && container.scrollTop + container.clientHeight < element.offsetTop + element.offsetHeight) {
+        element.scrollIntoView(false);
+      }
       element.focus();
     }
   }
diff --git a/app/javascript/mastodon/features/notifications/index.js b/app/javascript/mastodon/features/notifications/index.js
index 9430b2050..006c45657 100644
--- a/app/javascript/mastodon/features/notifications/index.js
+++ b/app/javascript/mastodon/features/notifications/index.js
@@ -113,18 +113,24 @@ class Notifications extends React.PureComponent {
 
   handleMoveUp = id => {
     const elementIndex = this.props.notifications.findIndex(item => item !== null && item.get('id') === id) - 1;
-    this._selectChild(elementIndex);
+    this._selectChild(elementIndex, true);
   }
 
   handleMoveDown = id => {
     const elementIndex = this.props.notifications.findIndex(item => item !== null && item.get('id') === id) + 1;
-    this._selectChild(elementIndex);
+    this._selectChild(elementIndex, false);
   }
 
-  _selectChild (index) {
-    const element = this.column.node.querySelector(`article:nth-of-type(${index + 1}) .focusable`);
+  _selectChild (index, align_top) {
+    const container = this.column.node;
+    const element = container.querySelector(`article:nth-of-type(${index + 1}) .focusable`);
 
     if (element) {
+      if (align_top && container.scrollTop > element.offsetTop) {
+        element.scrollIntoView(true);
+      } else if (!align_top && container.scrollTop + container.clientHeight < element.offsetTop + element.offsetHeight) {
+        element.scrollIntoView(false);
+      }
       element.focus();
     }
   }
diff --git a/app/javascript/mastodon/features/report/components/status_check_box.js b/app/javascript/mastodon/features/report/components/status_check_box.js
index 2552d94d8..c29e517da 100644
--- a/app/javascript/mastodon/features/report/components/status_check_box.js
+++ b/app/javascript/mastodon/features/report/components/status_check_box.js
@@ -35,6 +35,7 @@ export default class StatusCheckBox extends React.PureComponent {
             {Component => (
               <Component
                 preview={video.get('preview_url')}
+                blurhash={video.get('blurhash')}
                 src={video.get('url')}
                 alt={video.get('description')}
                 width={239}
diff --git a/app/javascript/mastodon/features/status/components/detailed_status.js b/app/javascript/mastodon/features/status/components/detailed_status.js
index 5c79f9f19..84471f9a3 100644
--- a/app/javascript/mastodon/features/status/components/detailed_status.js
+++ b/app/javascript/mastodon/features/status/components/detailed_status.js
@@ -5,7 +5,6 @@ import Avatar from '../../../components/avatar';
 import DisplayName from '../../../components/display_name';
 import StatusContent from '../../../components/status_content';
 import MediaGallery from '../../../components/media_gallery';
-import AttachmentList from '../../../components/attachment_list';
 import { Link } from 'react-router-dom';
 import { FormattedDate, FormattedNumber } from 'react-intl';
 import Card from './card';
@@ -109,14 +108,13 @@ export default class DetailedStatus extends ImmutablePureComponent {
     if (status.get('poll')) {
       media = <PollContainer pollId={status.get('poll')} />;
     } else if (status.get('media_attachments').size > 0) {
-      if (status.get('media_attachments').some(item => item.get('type') === 'unknown')) {
-        media = <AttachmentList media={status.get('media_attachments')} />;
-      } else if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
+      if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
         const video = status.getIn(['media_attachments', 0]);
 
         media = (
           <Video
             preview={video.get('preview_url')}
+            blurhash={video.get('blurhash')}
             src={video.get('url')}
             alt={video.get('description')}
             width={300}
diff --git a/app/javascript/mastodon/features/status/index.js b/app/javascript/mastodon/features/status/index.js
index 567af6be9..6279bb468 100644
--- a/app/javascript/mastodon/features/status/index.js
+++ b/app/javascript/mastodon/features/status/index.js
@@ -316,15 +316,15 @@ class Status extends ImmutablePureComponent {
     const { status, ancestorsIds, descendantsIds } = this.props;
 
     if (id === status.get('id')) {
-      this._selectChild(ancestorsIds.size - 1);
+      this._selectChild(ancestorsIds.size - 1, true);
     } else {
       let index = ancestorsIds.indexOf(id);
 
       if (index === -1) {
         index = descendantsIds.indexOf(id);
-        this._selectChild(ancestorsIds.size + index);
+        this._selectChild(ancestorsIds.size + index, true);
       } else {
-        this._selectChild(index - 1);
+        this._selectChild(index - 1, true);
       }
     }
   }
@@ -333,23 +333,29 @@ class Status extends ImmutablePureComponent {
     const { status, ancestorsIds, descendantsIds } = this.props;
 
     if (id === status.get('id')) {
-      this._selectChild(ancestorsIds.size + 1);
+      this._selectChild(ancestorsIds.size + 1, false);
     } else {
       let index = ancestorsIds.indexOf(id);
 
       if (index === -1) {
         index = descendantsIds.indexOf(id);
-        this._selectChild(ancestorsIds.size + index + 2);
+        this._selectChild(ancestorsIds.size + index + 2, false);
       } else {
-        this._selectChild(index + 1);
+        this._selectChild(index + 1, false);
       }
     }
   }
 
-  _selectChild (index) {
-    const element = this.node.querySelectorAll('.focusable')[index];
+  _selectChild (index, align_top) {
+    const container = this.node;
+    const element = container.querySelectorAll('.focusable')[index];
 
     if (element) {
+      if (align_top && container.scrollTop > element.offsetTop) {
+        element.scrollIntoView(true);
+      } else if (!align_top && container.scrollTop + container.clientHeight < element.offsetTop + element.offsetHeight) {
+        element.scrollIntoView(false);
+      }
       element.focus();
     }
   }
diff --git a/app/javascript/mastodon/features/ui/components/media_modal.js b/app/javascript/mastodon/features/ui/components/media_modal.js
index 2120746da..da2ac5f26 100644
--- a/app/javascript/mastodon/features/ui/components/media_modal.js
+++ b/app/javascript/mastodon/features/ui/components/media_modal.js
@@ -2,11 +2,11 @@ import React from 'react';
 import ReactSwipeableViews from 'react-swipeable-views';
 import ImmutablePropTypes from 'react-immutable-proptypes';
 import PropTypes from 'prop-types';
-import Video from '../../video';
-import ExtendedVideoPlayer from '../../../components/extended_video_player';
+import Video from 'mastodon/features/video';
+import ExtendedVideoPlayer from 'mastodon/components/extended_video_player';
 import classNames from 'classnames';
-import { defineMessages, injectIntl } from 'react-intl';
-import IconButton from '../../../components/icon_button';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import IconButton from 'mastodon/components/icon_button';
 import ImmutablePureComponent from 'react-immutable-pure-component';
 import ImageLoader from './image_loader';
 import Icon from 'mastodon/components/icon';
@@ -24,6 +24,7 @@ class MediaModal extends ImmutablePureComponent {
 
   static propTypes = {
     media: ImmutablePropTypes.list.isRequired,
+    status: ImmutablePropTypes.map,
     index: PropTypes.number.isRequired,
     onClose: PropTypes.func.isRequired,
     intl: PropTypes.object.isRequired,
@@ -72,9 +73,12 @@ class MediaModal extends ImmutablePureComponent {
 
   componentDidMount () {
     window.addEventListener('keydown', this.handleKeyDown, false);
+
     if (this.context.router) {
       const history = this.context.router.history;
+
       history.push(history.location.pathname, previewState);
+
       this.unlistenHistory = history.listen(() => {
         this.props.onClose();
       });
@@ -83,6 +87,7 @@ class MediaModal extends ImmutablePureComponent {
 
   componentWillUnmount () {
     window.removeEventListener('keydown', this.handleKeyDown);
+
     if (this.context.router) {
       this.unlistenHistory();
 
@@ -102,8 +107,15 @@ class MediaModal extends ImmutablePureComponent {
     }));
   };
 
+  handleStatusClick = e => {
+    if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
+      e.preventDefault();
+      this.context.router.history.push(`/statuses/${this.props.status.get('id')}`);
+    }
+  }
+
   render () {
-    const { media, intl, onClose } = this.props;
+    const { media, status, intl, onClose } = this.props;
     const { navigationHidden } = this.state;
 
     const index = this.getIndex();
@@ -144,6 +156,7 @@ class MediaModal extends ImmutablePureComponent {
         return (
           <Video
             preview={image.get('preview_url')}
+            blurhash={image.get('blurhash')}
             src={image.get('url')}
             width={image.get('width')}
             height={image.get('height')}
@@ -206,10 +219,19 @@ class MediaModal extends ImmutablePureComponent {
             {content}
           </ReactSwipeableViews>
         </div>
+
         <div className={navigationClassName}>
           <IconButton className='media-modal__close' title={intl.formatMessage(messages.close)} icon='times' onClick={onClose} size={40} />
+
           {leftNav}
           {rightNav}
+
+          {status && (
+            <div className={classNames('media-modal__meta', { 'media-modal__meta--shifted': media.size > 1 })}>
+              <a href={status.get('url')} onClick={this.handleStatusClick}><FormattedMessage id='lightbox.view_context' defaultMessage='View context' /></a>
+            </div>
+          )}
+
           <ul className='media-modal__pagination'>
             {pagination}
           </ul>
diff --git a/app/javascript/mastodon/features/ui/components/video_modal.js b/app/javascript/mastodon/features/ui/components/video_modal.js
index 7cf3eb4d4..213d31316 100644
--- a/app/javascript/mastodon/features/ui/components/video_modal.js
+++ b/app/javascript/mastodon/features/ui/components/video_modal.js
@@ -1,28 +1,69 @@
 import React from 'react';
 import ImmutablePropTypes from 'react-immutable-proptypes';
 import PropTypes from 'prop-types';
-import Video from '../../video';
+import Video from 'mastodon/features/video';
 import ImmutablePureComponent from 'react-immutable-pure-component';
+import { FormattedMessage } from 'react-intl';
+
+export const previewState = 'previewVideoModal';
 
 export default class VideoModal extends ImmutablePureComponent {
 
   static propTypes = {
     media: ImmutablePropTypes.map.isRequired,
+    status: ImmutablePropTypes.map,
     time: PropTypes.number,
     onClose: PropTypes.func.isRequired,
   };
 
+  static contextTypes = {
+    router: PropTypes.object,
+  };
+
+  componentDidMount () {
+    if (this.context.router) {
+      const history = this.context.router.history;
+
+      history.push(history.location.pathname, previewState);
+
+      this.unlistenHistory = history.listen(() => {
+        this.props.onClose();
+      });
+    }
+  }
+
+  componentWillUnmount () {
+    if (this.context.router) {
+      this.unlistenHistory();
+
+      if (this.context.router.history.location.state === previewState) {
+        this.context.router.history.goBack();
+      }
+    }
+  }
+
+  handleStatusClick = e => {
+    if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
+      e.preventDefault();
+      this.context.router.history.push(`/statuses/${this.props.status.get('id')}`);
+    }
+  }
+
   render () {
-    const { media, time, onClose } = this.props;
+    const { media, status, time, onClose } = this.props;
+
+    const link = status && <a href={status.get('url')} onClick={this.handleStatusClick}><FormattedMessage id='lightbox.view_context' defaultMessage='View context' /></a>;
 
     return (
       <div className='modal-root__modal video-modal'>
         <div>
           <Video
             preview={media.get('preview_url')}
+            blurhash={media.get('blurhash')}
             src={media.get('url')}
             startTime={time}
             onCloseVideo={onClose}
+            link={link}
             detailed
             alt={media.get('description')}
           />
diff --git a/app/javascript/mastodon/features/ui/index.js b/app/javascript/mastodon/features/ui/index.js
index 93e45678f..c14eba992 100644
--- a/app/javascript/mastodon/features/ui/index.js
+++ b/app/javascript/mastodon/features/ui/index.js
@@ -367,11 +367,16 @@ class UI extends React.PureComponent {
   handleHotkeyFocusColumn = e => {
     const index  = (e.key * 1) + 1; // First child is drawer, skip that
     const column = this.node.querySelector(`.column:nth-child(${index})`);
+    if (!column) return;
+    const container = column.querySelector('.scrollable');
 
-    if (column) {
-      const status = column.querySelector('.focusable');
+    if (container) {
+      const status = container.querySelector('.focusable');
 
       if (status) {
+        if (container.scrollTop > status.offsetTop) {
+          status.scrollIntoView(true);
+        }
         status.focus();
       }
     }
diff --git a/app/javascript/mastodon/features/video/index.js b/app/javascript/mastodon/features/video/index.js
index 55dd249e1..00a63a3d9 100644
--- a/app/javascript/mastodon/features/video/index.js
+++ b/app/javascript/mastodon/features/video/index.js
@@ -7,6 +7,7 @@ import classNames from 'classnames';
 import { isFullscreen, requestFullscreen, exitFullscreen } from '../ui/util/fullscreen';
 import { displayMedia } from '../../initial_state';
 import Icon from 'mastodon/components/icon';
+import { decode } from 'blurhash';
 
 const messages = defineMessages({
   play: { id: 'video.play', defaultMessage: 'Play' },
@@ -102,6 +103,8 @@ class Video extends React.PureComponent {
     inline: PropTypes.bool,
     cacheWidth: PropTypes.func,
     intl: PropTypes.object.isRequired,
+    blurhash: PropTypes.string,
+    link: PropTypes.node,
   };
 
   state = {
@@ -139,6 +142,7 @@ class Video extends React.PureComponent {
 
   setVideoRef = c => {
     this.video = c;
+
     if (this.video) {
       this.setState({ volume: this.video.volume, muted: this.video.muted });
     }
@@ -152,6 +156,10 @@ class Video extends React.PureComponent {
     this.volume = c;
   }
 
+  setCanvasRef = c => {
+    this.canvas = c;
+  }
+
   handleClickRoot = e => e.stopPropagation();
 
   handlePlay = () => {
@@ -170,7 +178,6 @@ class Video extends React.PureComponent {
   }
 
   handleVolumeMouseDown = e => {
-
     document.addEventListener('mousemove', this.handleMouseVolSlide, true);
     document.addEventListener('mouseup', this.handleVolumeMouseUp, true);
     document.addEventListener('touchmove', this.handleMouseVolSlide, true);
@@ -190,7 +197,6 @@ class Video extends React.PureComponent {
   }
 
   handleMouseVolSlide = throttle(e => {
-
     const rect = this.volume.getBoundingClientRect();
     const x = (e.clientX - rect.left) / this.volWidth; //x position within the element.
 
@@ -261,6 +267,10 @@ class Video extends React.PureComponent {
     document.addEventListener('webkitfullscreenchange', this.handleFullscreenChange, true);
     document.addEventListener('mozfullscreenchange', this.handleFullscreenChange, true);
     document.addEventListener('MSFullscreenChange', this.handleFullscreenChange, true);
+
+    if (this.props.blurhash) {
+      this._decode();
+    }
   }
 
   componentWillUnmount () {
@@ -270,6 +280,24 @@ class Video extends React.PureComponent {
     document.removeEventListener('MSFullscreenChange', this.handleFullscreenChange, true);
   }
 
+  componentDidUpdate (prevProps) {
+    if (prevProps.blurhash !== this.props.blurhash && this.props.blurhash) {
+      this._decode();
+    }
+  }
+
+  _decode () {
+    const hash   = this.props.blurhash;
+    const pixels = decode(hash, 32, 32);
+
+    if (pixels) {
+      const ctx       = this.canvas.getContext('2d');
+      const imageData = new ImageData(pixels, 32, 32);
+
+      ctx.putImageData(imageData, 0, 0);
+    }
+  }
+
   handleFullscreenChange = () => {
     this.setState({ fullscreen: isFullscreen() });
   }
@@ -314,6 +342,7 @@ class Video extends React.PureComponent {
 
   handleOpenVideo = () => {
     const { src, preview, width, height, alt } = this.props;
+
     const media = fromJS({
       type: 'video',
       url: src,
@@ -333,7 +362,7 @@ class Video extends React.PureComponent {
   }
 
   render () {
-    const { preview, src, inline, startTime, onOpenVideo, onCloseVideo, intl, alt, detailed, sensitive } = this.props;
+    const { preview, src, inline, startTime, onOpenVideo, onCloseVideo, intl, alt, detailed, sensitive, link } = this.props;
     const { containerWidth, currentTime, duration, volume, buffer, dragging, paused, fullscreen, hovered, muted, revealed } = this.state;
     const progress = (currentTime / duration) * 100;
 
@@ -351,6 +380,7 @@ class Video extends React.PureComponent {
     }
 
     let preload;
+
     if (startTime || fullscreen || dragging) {
       preload = 'auto';
     } else if (detailed) {
@@ -360,6 +390,7 @@ class Video extends React.PureComponent {
     }
 
     let warning;
+
     if (sensitive) {
       warning = <FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' />;
     } else {
@@ -377,7 +408,9 @@ class Video extends React.PureComponent {
         onClick={this.handleClickRoot}
         tabIndex={0}
       >
-        <video
+        <canvas width={32} height={32} ref={this.setCanvasRef} className={classNames('media-gallery__preview', { 'media-gallery__preview--hidden': revealed })} />
+
+        {revealed && <video
           ref={this.setVideoRef}
           src={src}
           poster={preview}
@@ -397,12 +430,13 @@ class Video extends React.PureComponent {
           onLoadedData={this.handleLoadedData}
           onProgress={this.handleProgress}
           onVolumeChange={this.handleVolumeChange}
-        />
+        />}
 
-        <button type='button' className={classNames('video-player__spoiler', { active: !revealed })} onClick={this.toggleReveal}>
-          <span className='video-player__spoiler__title'>{warning}</span>
-          <span className='video-player__spoiler__subtitle'><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span>
-        </button>
+        <div className={classNames('spoiler-button', { 'spoiler-button--hidden': revealed })}>
+          <button type='button' className='spoiler-button__overlay' onClick={this.toggleReveal}>
+            <span className='spoiler-button__overlay__label'>{warning}</span>
+          </button>
+        </div>
 
         <div className={classNames('video-player__controls', { active: paused || hovered })}>
           <div className='video-player__seek' onMouseDown={this.handleMouseDown} ref={this.setSeekRef}>
@@ -420,6 +454,7 @@ class Video extends React.PureComponent {
             <div className='video-player__buttons left'>
               <button type='button' aria-label={intl.formatMessage(paused ? messages.play : messages.pause)} onClick={this.togglePlay}><Icon id={paused ? 'play' : 'pause'} fixedWidth /></button>
               <button type='button' aria-label={intl.formatMessage(muted ? messages.unmute : messages.mute)} onClick={this.toggleMute}><Icon id={muted ? 'volume-off' : 'volume-up'} fixedWidth /></button>
+
               <div className='video-player__volume' onMouseDown={this.handleVolumeMouseDown} ref={this.setVolumeRef}>
                 <div className='video-player__volume__current' style={{ width: `${volumeWidth}px` }} />
                 <span
@@ -429,17 +464,19 @@ class Video extends React.PureComponent {
                 />
               </div>
 
-              {(detailed || fullscreen) &&
+              {(detailed || fullscreen) && (
                 <span>
                   <span className='video-player__time-current'>{formatTime(currentTime)}</span>
                   <span className='video-player__time-sep'>/</span>
                   <span className='video-player__time-total'>{formatTime(duration)}</span>
                 </span>
-              }
+              )}
+
+              {link && <span className='video-player__link'>{link}</span>}
             </div>
 
             <div className='video-player__buttons right'>
-              {!onCloseVideo && <button type='button' aria-label={intl.formatMessage(messages.hide)} onClick={this.toggleReveal}><Icon id='eye' fixedWidth /></button>}
+              {!onCloseVideo && <button type='button' aria-label={intl.formatMessage(messages.hide)} onClick={this.toggleReveal}><Icon id='eye-slash' fixedWidth /></button>}
               {(!fullscreen && onOpenVideo) && <button type='button' aria-label={intl.formatMessage(messages.expand)} onClick={this.handleOpenVideo}><Icon id='expand' fixedWidth /></button>}
               {onCloseVideo && <button type='button' aria-label={intl.formatMessage(messages.close)} onClick={this.handleCloseVideo}><Icon id='compress' fixedWidth /></button>}
               <button type='button' aria-label={intl.formatMessage(fullscreen ? messages.exit_fullscreen : messages.fullscreen)} onClick={this.toggleFullscreen}><Icon id={fullscreen ? 'compress' : 'arrows-alt'} fixedWidth /></button>
diff --git a/app/javascript/mastodon/locales/hy.json b/app/javascript/mastodon/locales/hy.json
index d155619c9..ca7732d85 100644
--- a/app/javascript/mastodon/locales/hy.json
+++ b/app/javascript/mastodon/locales/hy.json
@@ -243,7 +243,7 @@
   "navigation_bar.pins": "Ամրացված թթեր",
   "navigation_bar.preferences": "Նախապատվություններ",
   "navigation_bar.public_timeline": "Դաշնային հոսք",
-  "navigation_bar.security": "Security",
+  "navigation_bar.security": "Անվտանգություն",
   "notification.favourite": "{name} հավանեց թութդ",
   "notification.follow": "{name} սկսեց հետեւել քեզ",
   "notification.mention": "{name} նշեց քեզ",
@@ -309,7 +309,7 @@
   "search_results.accounts": "People",
   "search_results.hashtags": "Hashtags",
   "search_results.statuses": "Toots",
-  "search_results.total": "{count, number} {count, plural, one {result} other {results}}",
+  "search_results.total": "{count, number} {count, plural, one {արդյունք} other {արդյունք}}",
   "status.admin_account": "Open moderation interface for @{name}",
   "status.admin_status": "Open this status in the moderation interface",
   "status.block": "Արգելափակել @{name}֊ին",
diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss
index 7ea4ad07c..5c6f1a6f6 100644
--- a/app/javascript/styles/mastodon/components.scss
+++ b/app/javascript/styles/mastodon/components.scss
@@ -264,6 +264,16 @@
 .compose-form {
   padding: 10px;
 
+  &__sensitive-button {
+    padding: 10px;
+    padding-top: 0;
+
+    .icon-button {
+      font-size: 14px;
+      font-weight: 500;
+    }
+  }
+
   .compose-form__warning {
     color: $inverted-text-color;
     margin-bottom: 10px;
@@ -2412,7 +2422,7 @@ a.account__display-name {
 
     & > div {
       background: rgba($base-shadow-color, 0.6);
-      border-radius: 4px;
+      border-radius: 8px;
       padding: 12px 9px;
       flex: 0 0 auto;
       display: flex;
@@ -2423,19 +2433,18 @@ a.account__display-name {
     button,
     a {
       display: inline;
-      color: $primary-text-color;
+      color: $secondary-text-color;
       background: transparent;
       border: 0;
-      padding: 0 5px;
+      padding: 0 8px;
       text-decoration: none;
-      opacity: 0.6;
       font-size: 18px;
       line-height: 18px;
 
       &:hover,
       &:active,
       &:focus {
-        opacity: 1;
+        color: $primary-text-color;
       }
     }
 
@@ -2932,15 +2941,49 @@ a.status-card.compact:hover {
 }
 
 .spoiler-button {
-  display: none;
-  left: 4px;
+  top: 0;
+  left: 0;
+  width: 100%;
+  height: 100%;
   position: absolute;
-  text-shadow: 0 1px 1px $base-shadow-color, 1px 0 1px $base-shadow-color;
-  top: 4px;
   z-index: 100;
 
-  &.spoiler-button--visible {
+  &--minified {
     display: block;
+    left: 4px;
+    top: 4px;
+    width: auto;
+    height: auto;
+  }
+
+  &--hidden {
+    display: none;
+  }
+
+  &__overlay {
+    display: block;
+    background: transparent;
+    width: 100%;
+    height: 100%;
+    border: 0;
+
+    &__label {
+      display: inline-block;
+      background: rgba($base-overlay-background, 0.5);
+      border-radius: 8px;
+      padding: 8px 12px;
+      color: $primary-text-color;
+      font-weight: 500;
+      font-size: 14px;
+    }
+
+    &:hover,
+    &:focus,
+    &:active {
+      .spoiler-button__overlay__label {
+        background: rgba($base-overlay-background, 0.8);
+      }
+    }
   }
 }
 
@@ -3728,6 +3771,31 @@ a.status-card.compact:hover {
   pointer-events: none;
 }
 
+.media-modal__meta {
+  text-align: center;
+  position: absolute;
+  left: 0;
+  bottom: 20px;
+  width: 100%;
+  pointer-events: none;
+
+  &--shifted {
+    bottom: 62px;
+  }
+
+  a {
+    text-decoration: none;
+    font-weight: 500;
+    color: $ui-secondary-color;
+
+    &:hover,
+    &:focus,
+    &:active {
+      text-decoration: underline;
+    }
+  }
+}
+
 .media-modal__page-dot {
   display: inline-block;
 }
@@ -4200,6 +4268,7 @@ a.status-card.compact:hover {
   pointer-events: none;
   opacity: 0.9;
   transition: opacity 0.1s ease;
+  line-height: 18px;
 }
 
 .media-gallery__gifv {
@@ -4313,6 +4382,8 @@ a.status-card.compact:hover {
   text-decoration: none;
   color: $secondary-text-color;
   line-height: 0;
+  position: relative;
+  z-index: 1;
 
   &,
   img {
@@ -4325,6 +4396,21 @@ a.status-card.compact:hover {
   }
 }
 
+.media-gallery__preview {
+  width: 100%;
+  height: 100%;
+  object-fit: cover;
+  position: absolute;
+  top: 0;
+  left: 0;
+  z-index: 0;
+  background: $base-overlay-background;
+
+  &--hidden {
+    display: none;
+  }
+}
+
 .media-gallery__gifv {
   height: 100%;
   overflow: hidden;
@@ -4620,6 +4706,23 @@ a.status-card.compact:hover {
     }
   }
 
+  &__link {
+    padding: 2px 10px;
+
+    a {
+      text-decoration: none;
+      font-size: 14px;
+      font-weight: 500;
+      color: $white;
+
+      &:hover,
+      &:active,
+      &:focus {
+        text-decoration: underline;
+      }
+    }
+  }
+
   &__seek {
     cursor: pointer;
     height: 24px;
@@ -4712,62 +4815,18 @@ a.status-card.compact:hover {
 
 .account-gallery__container {
   display: flex;
-  justify-content: center;
   flex-wrap: wrap;
-  padding: 2px;
+  padding: 4px 2px;
 }
 
 .account-gallery__item {
-  flex-grow: 1;
-  width: 50%;
-  overflow: hidden;
+  border: none;
+  box-sizing: border-box;
+  display: block;
   position: relative;
-
-  &::before {
-    content: "";
-    display: block;
-    padding-top: 100%;
-  }
-
-  a {
-    display: block;
-    width: calc(100% - 4px);
-    height: calc(100% - 4px);
-    margin: 2px;
-    top: 0;
-    left: 0;
-    background-color: $base-overlay-background;
-    background-size: cover;
-    background-position: center;
-    position: absolute;
-    color: $darker-text-color;
-    text-decoration: none;
-    border-radius: 4px;
-
-    &:hover,
-    &:active,
-    &:focus {
-      outline: 0;
-      color: $secondary-text-color;
-
-      &::before {
-        content: "";
-        display: block;
-        width: 100%;
-        height: 100%;
-        background: rgba($base-overlay-background, 0.3);
-        border-radius: 4px;
-      }
-    }
-  }
-
-  &__icons {
-    position: absolute;
-    top: 50%;
-    left: 50%;
-    transform: translate(-50%, -50%);
-    font-size: 24px;
-  }
+  border-radius: 4px;
+  overflow: hidden;
+  margin: 2px;
 }
 
 .notification__filter-bar,
diff --git a/app/javascript/styles/mastodon/forms.scss b/app/javascript/styles/mastodon/forms.scss
index 91888d305..2b8d7a682 100644
--- a/app/javascript/styles/mastodon/forms.scss
+++ b/app/javascript/styles/mastodon/forms.scss
@@ -533,6 +533,17 @@ code {
     color: $error-value-color;
   }
 
+  a {
+    display: inline-block;
+    color: $darker-text-color;
+    text-decoration: none;
+
+    &:hover {
+      color: $primary-text-color;
+      text-decoration: underline;
+    }
+  }
+
   p {
     margin-bottom: 15px;
   }
diff --git a/app/lib/activitypub/activity/create.rb b/app/lib/activitypub/activity/create.rb
index dabdcbcf7..6b16c9986 100644
--- a/app/lib/activitypub/activity/create.rb
+++ b/app/lib/activitypub/activity/create.rb
@@ -194,7 +194,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
       next if 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, focus: attachment['focalPoint'])
+      media_attachment = MediaAttachment.create(account: @account, remote_url: href, description: attachment['name'].presence, focus: attachment['focalPoint'], blurhash: supported_blurhash?(attachment['blurhash']) ? attachment['blurhash'] : nil)
       media_attachments << media_attachment
 
       next if unsupported_media_type?(attachment['mediaType']) || skip_download?
@@ -369,6 +369,11 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
     mime_type.present? && !(MediaAttachment::IMAGE_MIME_TYPES + MediaAttachment::VIDEO_MIME_TYPES).include?(mime_type)
   end
 
+  def supported_blurhash?(blurhash)
+    components = blurhash.blank? ? nil : Blurhash.components(blurhash)
+    components.present? && components.none? { |comp| comp > 5 }
+  end
+
   def skip_download?
     return @skip_download if defined?(@skip_download)
     @skip_download ||= DomainBlock.find_by(domain: @account.domain)&.reject_media?
diff --git a/app/lib/activitypub/adapter.rb b/app/lib/activitypub/adapter.rb
index 94eb2899c..c259c96f4 100644
--- a/app/lib/activitypub/adapter.rb
+++ b/app/lib/activitypub/adapter.rb
@@ -19,6 +19,7 @@ class ActivityPub::Adapter < ActiveModelSerializers::Adapter::Base
     conversation: { 'ostatus' => 'http://ostatus.org#', 'inReplyToAtomUri' => 'ostatus:inReplyToAtomUri', 'conversation' => 'ostatus:conversation' },
     focal_point: { 'toot' => 'http://joinmastodon.org/ns#', 'focalPoint' => { '@container' => '@list', '@id' => 'toot:focalPoint' } },
     identity_proof: { 'toot' => 'http://joinmastodon.org/ns#', 'IdentityProof' => 'toot:IdentityProof' },
+    blurhash: { 'toot' => 'http://joinmastodon.org/ns#', 'blurhash' => 'toot:blurhash' },
   }.freeze
 
   def self.default_key_transform
diff --git a/app/models/concerns/ldap_authenticable.rb b/app/models/concerns/ldap_authenticable.rb
index e1b5e3832..84ff84c4b 100644
--- a/app/models/concerns/ldap_authenticable.rb
+++ b/app/models/concerns/ldap_authenticable.rb
@@ -6,6 +6,7 @@ module LdapAuthenticable
   def ldap_setup(_attributes)
     self.confirmed_at = Time.now.utc
     self.admin        = false
+    self.external     = true
 
     save!
   end
diff --git a/app/models/concerns/omniauthable.rb b/app/models/concerns/omniauthable.rb
index 1b28b8162..283033083 100644
--- a/app/models/concerns/omniauthable.rb
+++ b/app/models/concerns/omniauthable.rb
@@ -66,6 +66,7 @@ module Omniauthable
         email: email || "#{TEMP_EMAIL_PREFIX}-#{auth.uid}-#{auth.provider}.com",
         password: Devise.friendly_token[0, 20],
         agreement: true,
+        external: true,
         account_attributes: {
           username: ensure_unique_username(auth.uid),
           display_name: display_name,
diff --git a/app/models/concerns/pam_authenticable.rb b/app/models/concerns/pam_authenticable.rb
index 2f651c1a3..6169d4dfa 100644
--- a/app/models/concerns/pam_authenticable.rb
+++ b/app/models/concerns/pam_authenticable.rb
@@ -34,6 +34,7 @@ module PamAuthenticable
       self.confirmed_at = Time.now.utc
       self.admin        = false
       self.account      = account
+      self.external     = true
 
       account.destroy! unless save
     end
diff --git a/app/models/domain_block.rb b/app/models/domain_block.rb
index 069cda367..0b12617c6 100644
--- a/app/models/domain_block.rb
+++ b/app/models/domain_block.rb
@@ -29,4 +29,11 @@ class DomainBlock < ApplicationRecord
   def self.blocked?(domain)
     where(domain: domain, severity: :suspend).exists?
   end
+
+  def stricter_than?(other_block)
+    return true if suspend?
+    return false if other_block.suspend? && (silence? || noop?)
+    return false if other_block.silence? && noop?
+    (reject_media || !other_block.reject_media) && (reject_reports || !other_block.reject_reports)
+  end
 end
diff --git a/app/models/media_attachment.rb b/app/models/media_attachment.rb
index 81397a18e..65f00e1c8 100644
--- a/app/models/media_attachment.rb
+++ b/app/models/media_attachment.rb
@@ -18,6 +18,7 @@
 #  account_id          :bigint(8)
 #  description         :text
 #  scheduled_status_id :bigint(8)
+#  blurhash            :string
 #
 
 class MediaAttachment < ApplicationRecord
@@ -34,6 +35,11 @@ class MediaAttachment < ApplicationRecord
   VIDEO_CONVERTIBLE_MIME_TYPES = ['video/webm', 'video/quicktime'].freeze
   AUDIO_MIME_TYPES             = ['audio/mpeg', 'audio/mp4', 'audio/vnd.wav', 'audio/wav', 'audio/x-wav', 'audio/x-wave', 'audio/ogg',].freeze
 
+  BLURHASH_OPTIONS = {
+    x_comp: 4,
+    y_comp: 4,
+  }.freeze
+
   IMAGE_STYLES = {
     original: {
       pixels: 1_638_400, # 1280x1280px
@@ -43,6 +49,7 @@ class MediaAttachment < ApplicationRecord
     small: {
       pixels: 160_000, # 400x400px
       file_geometry_parser: FastGeometryParser,
+      blurhash: BLURHASH_OPTIONS,
     },
   }.freeze
 
@@ -71,6 +78,8 @@ class MediaAttachment < ApplicationRecord
       },
       format: 'png',
       time: 0,
+      file_geometry_parser: FastGeometryParser,
+      blurhash: BLURHASH_OPTIONS,
     },
   }.freeze
 
@@ -186,13 +195,13 @@ class MediaAttachment < ApplicationRecord
 
     def file_processors(f)
       if f.file_content_type == 'image/gif'
-        [:gif_transcoder]
+        [:gif_transcoder, :blurhash_transcoder]
       elsif VIDEO_MIME_TYPES.include? f.file_content_type
-        [:video_transcoder]
+        [:video_transcoder, :blurhash_transcoder]
       elsif AUDIO_MIME_TYPES.include? f.file_content_type
         [:audio_transcoder]
       else
-        [:lazy_thumbnail]
+        [:lazy_thumbnail, :blurhash_transcoder]
       end
     end
   end
diff --git a/app/models/user.rb b/app/models/user.rb
index b2fb820af..77e6a33b5 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -78,7 +78,7 @@ class User < ApplicationRecord
   accepts_nested_attributes_for :invite_request, reject_if: ->(attributes) { attributes['text'].blank? }
 
   validates :locale, inclusion: I18n.available_locales.map(&:to_s), if: :locale?
-  validates_with BlacklistedEmailValidator, if: :email_changed?
+  validates_with BlacklistedEmailValidator, on: :create
   validates_with EmailMxValidator, if: :validate_email_dns?
   validates :agreement, acceptance: { allow_nil: false, accept: [true, 'true', '1'] }, on: :create
 
@@ -107,13 +107,14 @@ class User < ApplicationRecord
            :expand_spoilers, :default_language, :aggregate_reblogs, :show_application, to: :settings, prefix: :setting, allow_nil: false
 
   attr_reader :invite_code
+  attr_writer :external
 
   def confirmed?
     confirmed_at.present?
   end
 
   def invited?
-    invite_id.present?
+    invite_id.present? && invite.valid_for_use?
   end
 
   def disable!
@@ -273,13 +274,17 @@ class User < ApplicationRecord
   private
 
   def set_approved
-    self.approved = open_registrations? || invited?
+    self.approved = open_registrations? || invited? || external?
   end
 
   def open_registrations?
     Setting.registrations_mode == 'open'
   end
 
+  def external?
+    !!@external
+  end
+
   def sanitize_languages
     return if chosen_languages.nil?
     chosen_languages.reject!(&:blank?)
diff --git a/app/serializers/activitypub/note_serializer.rb b/app/serializers/activitypub/note_serializer.rb
index d11cfa59a..67f596e78 100644
--- a/app/serializers/activitypub/note_serializer.rb
+++ b/app/serializers/activitypub/note_serializer.rb
@@ -2,7 +2,7 @@
 
 class ActivityPub::NoteSerializer < ActivityPub::Serializer
   context_extensions :atom_uri, :conversation, :sensitive,
-                     :hashtag, :emoji, :focal_point
+                     :hashtag, :emoji, :focal_point, :blurhash
 
   attributes :id, :type, :summary,
              :in_reply_to, :published, :url,
@@ -153,7 +153,7 @@ class ActivityPub::NoteSerializer < ActivityPub::Serializer
   class MediaAttachmentSerializer < ActivityPub::Serializer
     include RoutingHelper
 
-    attributes :type, :media_type, :url, :name
+    attributes :type, :media_type, :url, :name, :blurhash
     attribute :focal_point, if: :focal_point?
 
     def type
diff --git a/app/serializers/rest/media_attachment_serializer.rb b/app/serializers/rest/media_attachment_serializer.rb
index 51011788b..1b3498ea4 100644
--- a/app/serializers/rest/media_attachment_serializer.rb
+++ b/app/serializers/rest/media_attachment_serializer.rb
@@ -5,7 +5,7 @@ class REST::MediaAttachmentSerializer < ActiveModel::Serializer
 
   attributes :id, :type, :url, :preview_url,
              :remote_url, :text_url, :meta,
-             :description
+             :description, :blurhash
 
   def id
     object.id.to_s
diff --git a/app/services/block_service.rb b/app/services/block_service.rb
index 140b238df..10ed470e0 100644
--- a/app/services/block_service.rb
+++ b/app/services/block_service.rb
@@ -6,6 +6,7 @@ class BlockService < BaseService
 
     UnfollowService.new.call(account, target_account) if account.following?(target_account)
     UnfollowService.new.call(target_account, account) if target_account.following?(account)
+    RejectFollowService.new.call(account, target_account) if target_account.requested?(account)
 
     block = account.block!(target_account)
 
diff --git a/app/validators/blacklisted_email_validator.rb b/app/validators/blacklisted_email_validator.rb
index a2061fdd3..a288c20ef 100644
--- a/app/validators/blacklisted_email_validator.rb
+++ b/app/validators/blacklisted_email_validator.rb
@@ -2,7 +2,10 @@
 
 class BlacklistedEmailValidator < ActiveModel::Validator
   def validate(user)
+    return if user.invited?
+
     @email = user.email
+
     user.errors.add(:email, I18n.t('users.invalid_email')) if blocked_email?
   end
 
@@ -13,7 +16,7 @@ class BlacklistedEmailValidator < ActiveModel::Validator
   end
 
   def on_blacklist?
-    return true if EmailDomainBlock.block?(@email)
+    return true  if EmailDomainBlock.block?(@email)
     return false if Rails.configuration.x.email_domains_blacklist.blank?
 
     domains = Rails.configuration.x.email_domains_blacklist.gsub('.', '\.')
diff --git a/app/views/stream_entries/_detailed_status.html.haml b/app/views/stream_entries/_detailed_status.html.haml
index 4459581d9..23f2920d8 100644
--- a/app/views/stream_entries/_detailed_status.html.haml
+++ b/app/views/stream_entries/_detailed_status.html.haml
@@ -28,7 +28,7 @@
   - elsif !status.media_attachments.empty?
     - if status.media_attachments.first.video?
       - video = status.media_attachments.first
-      = react_component :video, src: video.file.url(:original), preview: video.file.url(:small), sensitive: !current_account&.user&.show_all_media? && status.sensitive? || current_account&.user&.hide_all_media?, width: 670, height: 380, detailed: true, inline: true, alt: video.description do
+      = react_component :video, src: video.file.url(:original), preview: video.file.url(:small), blurhash: video.blurhash, sensitive: !current_account&.user&.show_all_media? && status.sensitive? || current_account&.user&.hide_all_media?, width: 670, height: 380, detailed: true, inline: true, alt: video.description do
         = render partial: 'stream_entries/attachment_list', locals: { attachments: status.media_attachments }
     - else
       = react_component :media_gallery, height: 380, sensitive: !current_account&.user&.show_all_media? && status.sensitive? || current_account&.user&.hide_all_media?, standalone: true, 'autoPlayGif': current_account&.user&.setting_auto_play_gif || autoplay, 'reduceMotion': current_account&.user&.setting_reduce_motion, media: status.media_attachments.map { |a| ActiveModelSerializers::SerializableResource.new(a, serializer: REST::MediaAttachmentSerializer).as_json } do
diff --git a/app/views/stream_entries/_simple_status.html.haml b/app/views/stream_entries/_simple_status.html.haml
index 6d2a408ea..4df1a0cdf 100644
--- a/app/views/stream_entries/_simple_status.html.haml
+++ b/app/views/stream_entries/_simple_status.html.haml
@@ -33,7 +33,7 @@
   - elsif !status.media_attachments.empty?
     - if status.media_attachments.first.video?
       - video = status.media_attachments.first
-      = react_component :video, src: video.file.url(:original), preview: video.file.url(:small), sensitive: !current_account&.user&.show_all_media? && status.sensitive? || current_account&.user&.hide_all_media?, width: 610, height: 343, inline: true, alt: video.description do
+      = react_component :video, src: video.file.url(:original), preview: video.file.url(:small), blurhash: video.blurhash, sensitive: !current_account&.user&.show_all_media? && status.sensitive? || current_account&.user&.hide_all_media?, width: 610, height: 343, inline: true, alt: video.description do
         = render partial: 'stream_entries/attachment_list', locals: { attachments: status.media_attachments }
     - else
       = react_component :media_gallery, height: 343, sensitive: !current_account&.user&.show_all_media? && status.sensitive? || current_account&.user&.hide_all_media?, 'autoPlayGif': current_account&.user&.setting_auto_play_gif || autoplay, media: status.media_attachments.map { |a| ActiveModelSerializers::SerializableResource.new(a, serializer: REST::MediaAttachmentSerializer).as_json } do
diff --git a/app/workers/activitypub/processing_worker.rb b/app/workers/activitypub/processing_worker.rb
index a3abe72cf..05139f616 100644
--- a/app/workers/activitypub/processing_worker.rb
+++ b/app/workers/activitypub/processing_worker.rb
@@ -7,5 +7,7 @@ class ActivityPub::ProcessingWorker
 
   def perform(account_id, body, delivered_to_account_id = nil)
     ActivityPub::ProcessCollectionService.new.call(body, Account.find(account_id), override_timestamps: true, delivered_to_account_id: delivered_to_account_id, delivery: true)
+  rescue ActiveRecord::RecordInvalid => e
+    Rails.logger.debug "Error processing incoming ActivityPub object: #{e}"
   end
 end