about summary refs log tree commit diff
path: root/app/javascript/flavours
diff options
context:
space:
mode:
authorThibG <thib@sitedethib.com>2019-05-05 20:53:07 +0200
committerGitHub <noreply@github.com>2019-05-05 20:53:07 +0200
commite4ca5414a37b7973247a283a364b27052c8f9d1c (patch)
treedcb7769320c5a35b229ef579a20aa2fff209ea94 /app/javascript/flavours
parent2c2f649200ba5b742ba9d17ac5ba553c752b59aa (diff)
parenteef8802325d75b2668b131a765c5555f4be8c2ce (diff)
Merge pull request #1032 from ThibG/glitch-soc/merge-upstream
Merge upstream changes
Diffstat (limited to 'app/javascript/flavours')
-rw-r--r--app/javascript/flavours/glitch/actions/timelines.js2
-rw-r--r--app/javascript/flavours/glitch/components/media_gallery.js107
-rw-r--r--app/javascript/flavours/glitch/components/status.js1
-rw-r--r--app/javascript/flavours/glitch/features/account_gallery/components/media_item.js167
-rw-r--r--app/javascript/flavours/glitch/features/account_gallery/index.js67
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/options.js41
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/upload_form.js3
-rw-r--r--app/javascript/flavours/glitch/features/compose/containers/options_container.js5
-rw-r--r--app/javascript/flavours/glitch/features/compose/containers/sensitive_button_container.js54
-rw-r--r--app/javascript/flavours/glitch/features/report/components/status_check_box.js1
-rw-r--r--app/javascript/flavours/glitch/features/status/components/detailed_status.js1
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/media_modal.js26
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/video_modal.js19
-rw-r--r--app/javascript/flavours/glitch/features/video/index.js59
-rw-r--r--app/javascript/flavours/glitch/styles/admin.scss2
-rw-r--r--app/javascript/flavours/glitch/styles/components/accounts.scss50
-rw-r--r--app/javascript/flavours/glitch/styles/components/composer.scss10
-rw-r--r--app/javascript/flavours/glitch/styles/components/drawer.scss1
-rw-r--r--app/javascript/flavours/glitch/styles/components/index.scss53
-rw-r--r--app/javascript/flavours/glitch/styles/components/media.scss60
-rw-r--r--app/javascript/flavours/glitch/styles/components/search.scss1
-rw-r--r--app/javascript/flavours/glitch/styles/components/sensitive.scss2
-rw-r--r--app/javascript/flavours/glitch/styles/components/status.scss9
-rw-r--r--app/javascript/flavours/glitch/styles/forms.scss11
-rw-r--r--app/javascript/flavours/glitch/styles/widgets.scss1
25 files changed, 532 insertions, 221 deletions
diff --git a/app/javascript/flavours/glitch/actions/timelines.js b/app/javascript/flavours/glitch/actions/timelines.js
index f218ee06b..cca571583 100644
--- a/app/javascript/flavours/glitch/actions/timelines.js
+++ b/app/javascript/flavours/glitch/actions/timelines.js
@@ -97,7 +97,7 @@ export const expandCommunityTimeline       = ({ maxId, onlyMedia } = {}, done =
 export const expandDirectTimeline          = ({ maxId } = {}, done = noOp) => expandTimeline('direct', '/api/v1/timelines/direct', { max_id: maxId }, 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) => {
diff --git a/app/javascript/flavours/glitch/components/media_gallery.js b/app/javascript/flavours/glitch/components/media_gallery.js
index b7360bae4..194800d52 100644
--- a/app/javascript/flavours/glitch/components/media_gallery.js
+++ b/app/javascript/flavours/glitch/components/media_gallery.js
@@ -7,6 +7,7 @@ import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
 import { isIOS } from 'flavours/glitch/util/is_mobile';
 import classNames from 'classnames';
 import { autoPlayGif, displayMedia } from 'flavours/glitch/util/initial_state';
+import { decode } from 'blurhash';
 
 const messages = defineMessages({
   hidden: {
@@ -41,6 +42,7 @@ class Item extends React.PureComponent {
     letterbox: PropTypes.bool,
     onClick: PropTypes.func.isRequired,
     displayWidth: PropTypes.number,
+    visible: PropTypes.bool.isRequired,
   };
 
   static defaultProps = {
@@ -49,6 +51,10 @@ class Item extends React.PureComponent {
     size: 1,
   };
 
+  state = {
+    loaded: false,
+  };
+
   handleMouseEnter = (e) => {
     if (this.hoverToPlay()) {
       e.target.play();
@@ -82,8 +88,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, letterbox, displayWidth } = this.props;
+    const { attachment, index, size, standalone, letterbox, displayWidth, visible } = this.props;
 
     let width  = 50;
     let height = 100;
@@ -136,12 +174,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';
 
@@ -168,6 +214,7 @@ class Item extends React.PureComponent {
             alt={attachment.get('description')}
             title={attachment.get('description')}
             style={{ objectPosition: letterbox ? null : `${x}% ${y}%` }}
+            onLoad={this.handleImageLoad}
           />
         </a>
       );
@@ -197,7 +244,8 @@ class Item extends React.PureComponent {
 
     return (
       <div className={classNames('media-gallery__item', { standalone, letterbox })} 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>
     );
   }
@@ -257,6 +305,7 @@ export default class MediaGallery extends React.PureComponent {
     this.node = node;
     if (node && node.offsetWidth && node.offsetWidth != this.state.width) {
       if (this.props.cacheWidth) this.props.cacheWidth(node.offsetWidth);
+
       this.setState({
         width: node.offsetWidth,
       });
@@ -275,7 +324,7 @@ export default class MediaGallery extends React.PureComponent {
 
     const width = this.state.width || defaultWidth;
 
-    let children;
+    let children, spoilerButton;
 
     const style = {};
 
@@ -289,40 +338,32 @@ export default class MediaGallery extends React.PureComponent {
       return (<div className={computedClass} ref={this.handleRef}></div>);
     }
 
-    if (!visible) {
-      let warning = <FormattedMessage {...(sensitive ? messages.warning : messages.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} letterbox={letterbox} displayWidth={width} visible={visible} />);
+    }
 
-      children = (
-        <button className='media-spoiler' type='button' onClick={this.handleOpen}>
-          <span className='media-spoiler__warning'>{warning}</span>
-          <span className='media-spoiler__trigger'><FormattedMessage {...messages.toggle} /></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 {
-      if (this.isStandaloneEligible()) {
-        children = <Item standalone attachment={media.get(0)} onClick={this.handleClick} displayWidth={width} />;
-      } else {
-        children = media.take(4).map((attachment, i) => <Item key={attachment.get('id')} onClick={this.handleClick} attachment={attachment} index={i} size={size} letterbox={letterbox} displayWidth={width} />);
-      }
     }
 
     return (
       <div className={computedClass} style={style} ref={this.handleRef}>
-        {visible ? (
-          <div className='sensitive-info'>
-            <IconButton
-              icon='eye'
-              onClick={this.handleOpen}
-              overlay
-              title={intl.formatMessage(messages.toggle_visible)}
-            />
-            {sensitive ? (
-              <span className='sensitive-marker'>
-                <FormattedMessage {...messages.sensitive} />
-              </span>
-            ) : null}
-          </div>
-        ) : null}
+        <div className={classNames('spoiler-button', { 'spoiler-button--minified': visible })}>
+          {spoilerButton}
+          {visible && sensitive && (
+            <span className='sensitive-marker'>
+              <FormattedMessage {...messages.sensitive} />
+            </span>
+          )}
+        </div>
 
         {children}
       </div>
diff --git a/app/javascript/flavours/glitch/components/status.js b/app/javascript/flavours/glitch/components/status.js
index 94297260b..5f10e0c52 100644
--- a/app/javascript/flavours/glitch/components/status.js
+++ b/app/javascript/flavours/glitch/components/status.js
@@ -479,6 +479,7 @@ export default class Status extends ImmutablePureComponent {
           <Bundle fetchComponent={Video} loading={this.renderLoadingVideoPlayer} >
             {Component => (<Component
               preview={video.get('preview_url')}
+              blurhash={video.get('blurhash')}
               src={video.get('url')}
               alt={video.get('description')}
               inline
diff --git a/app/javascript/flavours/glitch/features/account_gallery/components/media_item.js b/app/javascript/flavours/glitch/features/account_gallery/components/media_item.js
index 89778e123..026136b2c 100644
--- a/app/javascript/flavours/glitch/features/account_gallery/components/media_item.js
+++ b/app/javascript/flavours/glitch/features/account_gallery/components/media_item.js
@@ -1,69 +1,148 @@
 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 'flavours/glitch/components/permalink';
-import { displayMedia } from 'flavours/glitch/util/initial_state';
+import { autoPlayGif, displayMedia } from 'flavours/glitch/util/initial_state';
+import classNames from 'classnames';
+import { decode } from 'blurhash';
+import { isIOS } from 'flavours/glitch/util/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, title;
-
-    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;
+    }
+  }
 
-    if (visible) {
-      style.backgroundImage    = `url(${media.get('preview_url')})`;
-      style.backgroundPosition = `${x}% ${y}%`;
-      title                    = media.get('description');
-    } else {
-      icon = (
-        <span className='account-gallery__item__icons'>
-          <i className='fa fa-eye-slash' />
-        </span>
+  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');
+    const title = status.get('spoiler_text') || attachment.get('description');
+
+    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
+          />
+          <span className='media-gallery__gifv__label'>GIF</span>
+        </div>
       );
-      title = status.get('spoiler_text') || media.get('description');
     }
 
+    const icon = (
+      <span className='account-gallery__item__icons'>
+        <i className='fa fa-eye-slash' />
+      </span>
+    );
+
     return (
-      <div className='account-gallery__item'>
-        <Permalink
-          to={`/statuses/${status.get('id')}`}
-          href={status.get('url')}
-          style={style}
-          title={title}
-          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' onClick={this.handleClick} title={title}>
+          <canvas width={32} height={32} ref={this.setCanvasRef} className={classNames('media-gallery__preview', { 'media-gallery__preview--hidden': visible && loaded })} />
+          {visible ? thumbnail : icon}
+        </a>
       </div>
     );
   }
diff --git a/app/javascript/flavours/glitch/features/account_gallery/index.js b/app/javascript/flavours/glitch/features/account_gallery/index.js
index 3b1af108f..3e4421306 100644
--- a/app/javascript/flavours/glitch/features/account_gallery/index.js
+++ b/app/javascript/flavours/glitch/features/account_gallery/index.js
@@ -14,12 +14,13 @@ import HeaderContainer from 'flavours/glitch/features/account_timeline/container
 import { ScrollContainer } from 'react-router-scroll-4';
 import LoadMore from 'flavours/glitch/components/load_more';
 import MissingIndicator from 'flavours/glitch/components/missing_indicator';
+import { openModal } from 'flavours/glitch/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 {
@@ -50,12 +51,16 @@ export default 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));
@@ -74,11 +79,11 @@ export default 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;
 
@@ -91,7 +96,7 @@ export default class AccountGallery extends ImmutablePureComponent {
     this.props.dispatch(expandAccountMediaTimeline(this.props.params.accountId, { maxId }));
   };
 
-  handleLoadOlder = (e) => {
+  handleLoadOlder = e => {
     e.preventDefault();
     this.handleScrollToBottom();
   }
@@ -101,12 +106,30 @@ export default class AccountGallery extends ImmutablePureComponent {
     return !(location.state && location.state.mastodonModalOpen);
   }
 
-  setRef = c => {
+  setColumnRef = c => {
     this.column = c;
   }
 
+  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, isLoading, hasMore, isAccount } = this.props;
+    const { attachments, isLoading, hasMore, isAccount } = this.props;
+    const { width } = this.state;
 
     if (!isAccount) {
       return (
@@ -116,9 +139,7 @@ export default class AccountGallery extends ImmutablePureComponent {
       );
     }
 
-    let loadOlder = null;
-
-    if (!medias && isLoading) {
+    if (!attachments && isLoading) {
       return (
         <Column>
           <LoadingIndicator />
@@ -126,35 +147,31 @@ export default 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} />;
     }
 
     return (
-      <Column ref={this.setRef}>
+      <Column ref={this.setColumnRef}>
         <ProfileColumnHeader onClick={this.handleHeaderClick} />
 
         <ScrollContainer scrollKey='account_gallery' shouldUpdateScroll={this.shouldUpdateScroll}>
           <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/flavours/glitch/features/compose/components/options.js b/app/javascript/flavours/glitch/features/compose/components/options.js
index 8a760bd15..ee9730961 100644
--- a/app/javascript/flavours/glitch/features/compose/components/options.js
+++ b/app/javascript/flavours/glitch/features/compose/components/options.js
@@ -65,10 +65,6 @@ const messages = defineMessages({
     defaultMessage: 'Public',
     id: 'privacy.public.short',
   },
-  sensitive: {
-    defaultMessage: 'Mark media as sensitive',
-    id: 'compose_form.sensitive',
-  },
   spoiler: {
     defaultMessage: 'Hide text behind warning',
     id: 'compose_form.spoiler',
@@ -116,7 +112,6 @@ class ComposerOptions extends ImmutablePureComponent {
     hasPoll: PropTypes.bool,
     intl: PropTypes.object.isRequired,
     onChangeAdvancedOption: PropTypes.func,
-    onChangeSensitivity: PropTypes.func,
     onChangeVisibility: PropTypes.func,
     onTogglePoll: PropTypes.func,
     onDoodleOpen: PropTypes.func,
@@ -126,7 +121,6 @@ class ComposerOptions extends ImmutablePureComponent {
     onUpload: PropTypes.func,
     privacy: PropTypes.string,
     resetFileKey: PropTypes.number,
-    sensitive: PropTypes.bool,
     spoiler: PropTypes.bool,
   };
 
@@ -175,7 +169,6 @@ class ComposerOptions extends ImmutablePureComponent {
       hasPoll,
       intl,
       onChangeAdvancedOption,
-      onChangeSensitivity,
       onChangeVisibility,
       onTogglePoll,
       onModalClose,
@@ -183,7 +176,6 @@ class ComposerOptions extends ImmutablePureComponent {
       onToggleSpoiler,
       privacy,
       resetFileKey,
-      sensitive,
       spoiler,
     } = this.props;
 
@@ -264,39 +256,6 @@ class ComposerOptions extends ImmutablePureComponent {
             title={intl.formatMessage(hasPoll ? messages.remove_poll : messages.add_poll)}
           />
         )}
-        <Motion
-          defaultStyle={{ scale: 0.87 }}
-          style={{
-            scale: spring(hasMedia ? 1 : 0.87, {
-              stiffness: 200,
-              damping: 3,
-            }),
-          }}
-        >
-          {({ scale }) => (
-            <div
-              style={{
-                display: hasMedia ? null : 'none',
-                transform: `scale(${scale})`,
-              }}
-            >
-              <IconButton
-                active={sensitive}
-                className='sensitive'
-                disabled={spoiler}
-                icon={sensitive ? 'eye-slash' : 'eye'}
-                inverted
-                onClick={onChangeSensitivity}
-                size={18}
-                style={{
-                  height: null,
-                  lineHeight: null,
-                }}
-                title={intl.formatMessage(messages.sensitive)}
-              />
-            </div>
-          )}
-        </Motion>
         <hr />
         <Dropdown
           disabled={disabled}
diff --git a/app/javascript/flavours/glitch/features/compose/components/upload_form.js b/app/javascript/flavours/glitch/features/compose/components/upload_form.js
index 4864043a8..35880ddcc 100644
--- a/app/javascript/flavours/glitch/features/compose/components/upload_form.js
+++ b/app/javascript/flavours/glitch/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 {
   static propTypes = {
@@ -23,6 +24,8 @@ export default class UploadForm extends ImmutablePureComponent {
             ))}
           </div>
         )}
+
+        {!mediaIds.isEmpty() && <SensitiveButtonContainer />}
       </div>
     );
   }
diff --git a/app/javascript/flavours/glitch/features/compose/containers/options_container.js b/app/javascript/flavours/glitch/features/compose/containers/options_container.js
index e846cfbd5..2ac7ab8d8 100644
--- a/app/javascript/flavours/glitch/features/compose/containers/options_container.js
+++ b/app/javascript/flavours/glitch/features/compose/containers/options_container.js
@@ -2,7 +2,6 @@ import { connect } from 'react-redux';
 import Options from '../components/options';
 import {
   changeComposeAdvancedOption,
-  changeComposeSensitivity,
 } from 'flavours/glitch/actions/compose';
 import { addPoll, removePoll } from 'flavours/glitch/actions/compose';
 import { closeModal, openModal } from 'flavours/glitch/actions/modal';
@@ -27,10 +26,6 @@ const mapDispatchToProps = (dispatch) => ({
     dispatch(changeComposeAdvancedOption(option, value));
   },
 
-  onChangeSensitivity() {
-    dispatch(changeComposeSensitivity());
-  },
-
   onTogglePoll() {
     dispatch((_, getState) => {
       if (getState().getIn(['compose', 'poll'])) {
diff --git a/app/javascript/flavours/glitch/features/compose/containers/sensitive_button_container.js b/app/javascript/flavours/glitch/features/compose/containers/sensitive_button_container.js
new file mode 100644
index 000000000..8f163979f
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/containers/sensitive_button_container.js
@@ -0,0 +1,54 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import classNames from 'classnames';
+import { changeComposeSensitivity } from 'flavours/glitch/actions/compose';
+import { injectIntl, defineMessages, FormattedMessage } from 'react-intl';
+import Icon from 'flavours/glitch/components/icon';
+
+const messages = defineMessages({
+  marked: { id: 'compose_form.sensitive.marked', defaultMessage: 'Media is marked as sensitive' },
+  unmarked: { id: 'compose_form.sensitive.unmarked', defaultMessage: 'Media is not marked as sensitive' },
+});
+
+const mapStateToProps = state => {
+  const spoilersAlwaysOn = state.getIn(['local_settings', 'always_show_spoilers_field']);
+  const spoilerText = state.getIn(['compose', 'spoiler_text']);
+  return {
+    active: state.getIn(['compose', 'sensitive']) || (spoilersAlwaysOn && spoilerText && spoilerText.length > 0),
+    disabled: state.getIn(['compose', 'spoiler']),
+  };
+};
+
+const mapDispatchToProps = dispatch => ({
+
+  onClick () {
+    dispatch(changeComposeSensitivity());
+  },
+
+});
+
+class SensitiveButton extends React.PureComponent {
+
+  static propTypes = {
+    active: PropTypes.bool,
+    disabled: PropTypes.bool,
+    onClick: PropTypes.func.isRequired,
+    intl: PropTypes.object.isRequired,
+  };
+
+  render () {
+    const { active, disabled, onClick, intl } = this.props;
+
+    return (
+      <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 icon='eye-slash' /> <FormattedMessage id='compose_form.sensitive.hide' defaultMessage='Mark media as sensitive' />
+        </button>
+      </div>
+    );
+  }
+
+}
+
+export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(SensitiveButton));
diff --git a/app/javascript/flavours/glitch/features/report/components/status_check_box.js b/app/javascript/flavours/glitch/features/report/components/status_check_box.js
index d674eecf9..cc49042fc 100644
--- a/app/javascript/flavours/glitch/features/report/components/status_check_box.js
+++ b/app/javascript/flavours/glitch/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/flavours/glitch/features/status/components/detailed_status.js b/app/javascript/flavours/glitch/features/status/components/detailed_status.js
index 8b35de0d4..0b162ed36 100644
--- a/app/javascript/flavours/glitch/features/status/components/detailed_status.js
+++ b/app/javascript/flavours/glitch/features/status/components/detailed_status.js
@@ -134,6 +134,7 @@ export default class DetailedStatus extends ImmutablePureComponent {
         media = (
           <Video
             preview={video.get('preview_url')}
+            blurhash={video.get('blurhash')}
             src={video.get('url')}
             alt={video.get('description')}
             inline
diff --git a/app/javascript/flavours/glitch/features/ui/components/media_modal.js b/app/javascript/flavours/glitch/features/ui/components/media_modal.js
index 1f3ac18ea..ce6660480 100644
--- a/app/javascript/flavours/glitch/features/ui/components/media_modal.js
+++ b/app/javascript/flavours/glitch/features/ui/components/media_modal.js
@@ -5,7 +5,7 @@ import PropTypes from 'prop-types';
 import Video from 'flavours/glitch/features/video';
 import ExtendedVideoPlayer from 'flavours/glitch/components/extended_video_player';
 import classNames from 'classnames';
-import { defineMessages, injectIntl } from 'react-intl';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
 import IconButton from 'flavours/glitch/components/icon_button';
 import ImmutablePureComponent from 'react-immutable-pure-component';
 import ImageLoader from './image_loader';
@@ -19,8 +19,13 @@ const messages = defineMessages({
 @injectIntl
 export default class MediaModal extends ImmutablePureComponent {
 
+  static contextTypes = {
+    router: PropTypes.object,
+  };
+
   static propTypes = {
     media: ImmutablePropTypes.list.isRequired,
+    status: ImmutablePropTypes.map,
     index: PropTypes.number.isRequired,
     onClose: PropTypes.func.isRequired,
     intl: PropTypes.object.isRequired,
@@ -81,8 +86,15 @@ export default 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();
@@ -123,6 +135,7 @@ export default 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')}
@@ -185,10 +198,19 @@ export default 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/flavours/glitch/features/ui/components/video_modal.js b/app/javascript/flavours/glitch/features/ui/components/video_modal.js
index 69e0ee46e..3f742c260 100644
--- a/app/javascript/flavours/glitch/features/ui/components/video_modal.js
+++ b/app/javascript/flavours/glitch/features/ui/components/video_modal.js
@@ -3,26 +3,43 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
 import PropTypes from 'prop-types';
 import Video from 'flavours/glitch/features/video';
 import ImmutablePureComponent from 'react-immutable-pure-component';
+import { FormattedMessage } from 'react-intl';
 
 export default class VideoModal extends ImmutablePureComponent {
 
+  static contextTypes = {
+    router: PropTypes.object,
+  };
+
   static propTypes = {
     media: ImmutablePropTypes.map.isRequired,
+    status: ImmutablePropTypes.map,
     time: PropTypes.number,
     onClose: PropTypes.func.isRequired,
   };
 
+  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/flavours/glitch/features/video/index.js b/app/javascript/flavours/glitch/features/video/index.js
index e3ed799c7..8291ff3c8 100644
--- a/app/javascript/flavours/glitch/features/video/index.js
+++ b/app/javascript/flavours/glitch/features/video/index.js
@@ -6,6 +6,7 @@ import { throttle } from 'lodash';
 import classNames from 'classnames';
 import { isFullscreen, requestFullscreen, exitFullscreen } from 'flavours/glitch/util/fullscreen';
 import { displayMedia } from 'flavours/glitch/util/initial_state';
+import { decode } from 'blurhash';
 
 const messages = defineMessages({
   play: { id: 'video.play', defaultMessage: 'Play' },
@@ -104,6 +105,8 @@ export default class Video extends React.PureComponent {
     preventPlayback: PropTypes.bool,
     intl: PropTypes.object.isRequired,
     cacheWidth: PropTypes.func,
+    blurhash: PropTypes.string,
+    link: PropTypes.node,
   };
 
   state = {
@@ -147,6 +150,7 @@ export default class Video extends React.PureComponent {
 
   setVideoRef = c => {
     this.video = c;
+
     if (this.video) {
       this.setState({ volume: this.video.volume, muted: this.video.muted });
     }
@@ -160,6 +164,10 @@ export default class Video extends React.PureComponent {
     this.volume = c;
   }
 
+  setCanvasRef = c => {
+    this.canvas = c;
+  }
+
   handleMouseDownRoot = e => {
     e.preventDefault();
     e.stopPropagation();
@@ -181,7 +189,6 @@ export default 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);
@@ -201,7 +208,6 @@ export default 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.
 
@@ -272,6 +278,10 @@ export default 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 () {
@@ -293,6 +303,24 @@ export default class Video extends React.PureComponent {
     }
   }
 
+  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() });
   }
@@ -337,6 +365,7 @@ export default class Video extends React.PureComponent {
 
   handleOpenVideo = () => {
     const { src, preview, width, height, alt } = this.props;
+
     const media = fromJS({
       type: 'video',
       url: src,
@@ -356,7 +385,7 @@ export default class Video extends React.PureComponent {
   }
 
   render () {
-    const { preview, src, inline, startTime, onOpenVideo, onCloseVideo, intl, alt, letterbox, fullwidth, detailed, sensitive } = this.props;
+    const { preview, src, inline, startTime, onOpenVideo, onCloseVideo, intl, alt, letterbox, fullwidth, detailed, sensitive, link } = this.props;
     const { containerWidth, currentTime, duration, volume, buffer, dragging, paused, fullscreen, hovered, muted, revealed } = this.state;
     const progress = (currentTime / duration) * 100;
     const playerStyle = {};
@@ -385,6 +414,7 @@ export default class Video extends React.PureComponent {
     }
 
     let preload;
+
     if (startTime || fullscreen || dragging) {
       preload = 'auto';
     } else if (detailed) {
@@ -403,7 +433,9 @@ export default class Video extends React.PureComponent {
         onMouseDown={this.handleMouseDownRoot}
         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}
@@ -423,12 +455,13 @@ export default 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}>
@@ -455,17 +488,19 @@ export default 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}><i className='fa fa-fw fa-eye' /></button>}
+              {!onCloseVideo && <button type='button' aria-label={intl.formatMessage(messages.hide)} onClick={this.toggleReveal}><i className='fa fa-fw fa-eye-slash' /></button>}
               {(!fullscreen && onOpenVideo) && <button type='button' aria-label={intl.formatMessage(messages.expand)} onClick={this.handleOpenVideo}><i className='fa fa-fw fa-expand' /></button>}
               {onCloseVideo && <button type='button' aria-label={intl.formatMessage(messages.close)} onClick={this.handleCloseVideo}><i className='fa fa-fw fa-compress' /></button>}
               <button type='button' aria-label={intl.formatMessage(fullscreen ? messages.exit_fullscreen : messages.fullscreen)} onClick={this.toggleFullscreen}><i className={classNames('fa fa-fw', { 'fa-arrows-alt': !fullscreen, 'fa-compress': fullscreen })} /></button>
diff --git a/app/javascript/flavours/glitch/styles/admin.scss b/app/javascript/flavours/glitch/styles/admin.scss
index 05c7821e4..74f91599a 100644
--- a/app/javascript/flavours/glitch/styles/admin.scss
+++ b/app/javascript/flavours/glitch/styles/admin.scss
@@ -50,6 +50,7 @@ $content-width: 840px;
         color: $darker-text-color;
         text-decoration: none;
         transition: all 200ms linear;
+        transition-property: color, background-color;
         border-radius: 4px 0 0 4px;
 
         i.fa {
@@ -60,6 +61,7 @@ $content-width: 840px;
           color: $primary-text-color;
           background-color: darken($ui-base-color, 5%);
           transition: all 100ms linear;
+          transition-property: color, background-color;
         }
 
         &.selected {
diff --git a/app/javascript/flavours/glitch/styles/components/accounts.scss b/app/javascript/flavours/glitch/styles/components/accounts.scss
index 00380c575..c0340e3f8 100644
--- a/app/javascript/flavours/glitch/styles/components/accounts.scss
+++ b/app/javascript/flavours/glitch/styles/components/accounts.scss
@@ -331,54 +331,18 @@
 
 .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: $ui-primary-color;
-    text-decoration: none;
-    border-radius: 4px;
-
-    &:hover,
-    &:active,
-    &:focus {
-      outline: 0;
-      color: $ui-secondary-color;
-
-      &::before {
-        content: "";
-        display: block;
-        width: 100%;
-        height: 100%;
-        background: rgba($base-overlay-background, 0.3);
-        border-radius: 4px;
-      }
-    }
-  }
+  border-radius: 4px;
+  overflow: hidden;
+  margin: 2px;
 
   &__icons {
     position: absolute;
diff --git a/app/javascript/flavours/glitch/styles/components/composer.scss b/app/javascript/flavours/glitch/styles/components/composer.scss
index e5eb6e64f..86041da20 100644
--- a/app/javascript/flavours/glitch/styles/components/composer.scss
+++ b/app/javascript/flavours/glitch/styles/components/composer.scss
@@ -57,6 +57,16 @@
   }
 }
 
+.compose-form__sensitive-button {
+  padding: 10px;
+  padding-top: 0;
+
+  .icon-button {
+    font-size: 14px;
+    font-weight: 500;
+  }
+}
+
 .composer--reply {
   margin: 0 0 10px;
   border-radius: 4px;
diff --git a/app/javascript/flavours/glitch/styles/components/drawer.scss b/app/javascript/flavours/glitch/styles/components/drawer.scss
index 41c794790..9f426448f 100644
--- a/app/javascript/flavours/glitch/styles/components/drawer.scss
+++ b/app/javascript/flavours/glitch/styles/components/drawer.scss
@@ -125,6 +125,7 @@
       cursor: default;
       pointer-events: none;
       transition: all 100ms linear;
+      transition-property: color, transform, opacity;
     }
 
     .fa-search {
diff --git a/app/javascript/flavours/glitch/styles/components/index.scss b/app/javascript/flavours/glitch/styles/components/index.scss
index b098676b0..f12f8b7fa 100644
--- a/app/javascript/flavours/glitch/styles/components/index.scss
+++ b/app/javascript/flavours/glitch/styles/components/index.scss
@@ -25,6 +25,7 @@
   text-decoration: none;
   text-overflow: ellipsis;
   transition: all 100ms ease-in;
+  transition-property: background-color;
   white-space: nowrap;
   width: auto;
 
@@ -33,6 +34,7 @@
   &:hover {
     background-color: lighten($ui-highlight-color, 7%);
     transition: all 200ms ease-out;
+    transition-property: background-color;
   }
 
   &--destructive {
@@ -564,6 +566,7 @@
   font-weight: 500;
   border-bottom: 2px solid lighten($ui-base-color, 8%);
   transition: all 200ms linear;
+  transition-property: background;
 
   .fa {
     font-weight: 400;
@@ -581,6 +584,7 @@
     @include multi-columns('screen and (min-width: 631px)') {
       background: lighten($ui-base-color, 14%);
       transition: all 100ms linear;
+      transition-property: background;
     }
   }
 
@@ -664,7 +668,7 @@
   padding: 0;
   border-radius: 30px;
   background-color: $ui-base-color;
-  transition: all 0.2s ease;
+  transition: background-color 0.2s ease;
 }
 
 .react-toggle:hover:not(.react-toggle--disabled) .react-toggle-track {
@@ -717,7 +721,6 @@
 }
 
 .react-toggle-thumb {
-  transition: all 0.5s cubic-bezier(0.23, 1, 0.32, 1) 0ms;
   position: absolute;
   top: 1px;
   left: 1px;
@@ -728,6 +731,7 @@
   background-color: darken($simple-background-color, 2%);
   box-sizing: border-box;
   transition: all 0.25s ease;
+  transition-property: border-color, left;
 }
 
 .react-toggle--checked .react-toggle-thumb {
@@ -1066,15 +1070,50 @@
 }
 
 .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: flex;
+    left: 4px;
+    top: 4px;
+    width: auto;
+    height: auto;
+    align-items: center;
+  }
+
+  &--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);
+      }
+    }
   }
 }
 
diff --git a/app/javascript/flavours/glitch/styles/components/media.scss b/app/javascript/flavours/glitch/styles/components/media.scss
index fabef2a56..e5927057e 100644
--- a/app/javascript/flavours/glitch/styles/components/media.scss
+++ b/app/javascript/flavours/glitch/styles/components/media.scss
@@ -58,6 +58,7 @@
   pointer-events: none;
   opacity: 0.9;
   transition: opacity 0.1s ease;
+  line-height: 18px;
 }
 
 .media-gallery__gifv {
@@ -117,6 +118,8 @@
   text-decoration: none;
   color: $secondary-text-color;
   line-height: 0;
+  position: relative;
+  z-index: 1;
 
   &,
   img {
@@ -131,6 +134,21 @@
   }
 }
 
+.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;
@@ -252,6 +270,31 @@
   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;
 }
@@ -501,6 +544,23 @@
     }
   }
 
+  &__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;
diff --git a/app/javascript/flavours/glitch/styles/components/search.scss b/app/javascript/flavours/glitch/styles/components/search.scss
index 3746fbad2..f59ef019e 100644
--- a/app/javascript/flavours/glitch/styles/components/search.scss
+++ b/app/javascript/flavours/glitch/styles/components/search.scss
@@ -18,6 +18,7 @@
     display: inline-block;
     opacity: 0;
     transition: all 100ms linear;
+    transition-property: transform, opacity;
     font-size: 18px;
     width: 18px;
     height: 18px;
diff --git a/app/javascript/flavours/glitch/styles/components/sensitive.scss b/app/javascript/flavours/glitch/styles/components/sensitive.scss
index b0a7dfe03..67b01c886 100644
--- a/app/javascript/flavours/glitch/styles/components/sensitive.scss
+++ b/app/javascript/flavours/glitch/styles/components/sensitive.scss
@@ -15,7 +15,7 @@
   color: rgba($primary-text-color, 0.8);
   background: rgba($base-overlay-background, 0.5);
   font-size: 12px;
-  line-height: 15px;
+  line-height: 18px;
   text-transform: uppercase;
   opacity: .9;
   transition: opacity .1s ease;
diff --git a/app/javascript/flavours/glitch/styles/components/status.scss b/app/javascript/flavours/glitch/styles/components/status.scss
index b656f0baf..fb031258f 100644
--- a/app/javascript/flavours/glitch/styles/components/status.scss
+++ b/app/javascript/flavours/glitch/styles/components/status.scss
@@ -705,7 +705,7 @@
 
     & > div {
       background: rgba($base-shadow-color, 0.6);
-      border-radius: 4px;
+      border-radius: 8px;
       padding: 12px 9px;
       flex: 0 0 auto;
       display: flex;
@@ -716,19 +716,18 @@
     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;
       }
     }
 
diff --git a/app/javascript/flavours/glitch/styles/forms.scss b/app/javascript/flavours/glitch/styles/forms.scss
index 91888d305..2b8d7a682 100644
--- a/app/javascript/flavours/glitch/styles/forms.scss
+++ b/app/javascript/flavours/glitch/styles/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/javascript/flavours/glitch/styles/widgets.scss b/app/javascript/flavours/glitch/styles/widgets.scss
index e736d7a7e..acaf5b024 100644
--- a/app/javascript/flavours/glitch/styles/widgets.scss
+++ b/app/javascript/flavours/glitch/styles/widgets.scss
@@ -4,7 +4,6 @@
 
   &__img {
     width: 100%;
-    height: 167px;
     position: relative;
     overflow: hidden;
     border-radius: 4px 4px 0 0;