about summary refs log tree commit diff
path: root/app/javascript/flavours/glitch
diff options
context:
space:
mode:
authorReverite <github@reverite.sh>2019-05-06 18:17:05 -0700
committerReverite <github@reverite.sh>2019-05-06 18:17:05 -0700
commit5b85256b334b13fad26a2bc073a874750a3cdc2e (patch)
tree2d523aa8266e42ae31ab82c7fc2533cf4a90ff0d /app/javascript/flavours/glitch
parente10a9794f4ed7c90e3190f285359f55dd00da435 (diff)
parent89d2859296bc5a57a8db07be86239cc938a3f691 (diff)
Merge remote-tracking branch 'glitch/master' into production
Diffstat (limited to 'app/javascript/flavours/glitch')
-rw-r--r--app/javascript/flavours/glitch/actions/compose.js7
-rw-r--r--app/javascript/flavours/glitch/actions/suggestions.js52
-rw-r--r--app/javascript/flavours/glitch/actions/timelines.js2
-rw-r--r--app/javascript/flavours/glitch/components/account.js14
-rw-r--r--app/javascript/flavours/glitch/components/autosuggest_emoji.js42
-rw-r--r--app/javascript/flavours/glitch/components/autosuggest_input.js229
-rw-r--r--app/javascript/flavours/glitch/components/autosuggest_textarea.js230
-rw-r--r--app/javascript/flavours/glitch/components/column_back_button.js9
-rw-r--r--app/javascript/flavours/glitch/components/column_back_button_slim.js9
-rw-r--r--app/javascript/flavours/glitch/components/column_header.js13
-rw-r--r--app/javascript/flavours/glitch/components/media_gallery.js109
-rw-r--r--app/javascript/flavours/glitch/components/modal_root.js2
-rw-r--r--app/javascript/flavours/glitch/components/permalink.js4
-rw-r--r--app/javascript/flavours/glitch/components/status.js34
-rw-r--r--app/javascript/flavours/glitch/components/status_action_bar.js4
-rw-r--r--app/javascript/flavours/glitch/components/status_content.js4
-rw-r--r--app/javascript/flavours/glitch/components/status_list.js14
-rw-r--r--app/javascript/flavours/glitch/containers/mastodon.js7
-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/account_timeline/components/moved_note.js4
-rw-r--r--app/javascript/flavours/glitch/features/community_timeline/containers/column_settings_container.js1
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/autosuggest_account.js24
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/compose_form.js378
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/dropdown.js (renamed from app/javascript/flavours/glitch/features/composer/options/dropdown/index.js)4
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/dropdown_menu.js219
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/header.js124
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/navigation_bar.js36
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/options.js (renamed from app/javascript/flavours/glitch/features/composer/options/index.js)136
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/poll_form.js (renamed from app/javascript/flavours/glitch/features/composer/poll_form/components/poll_form.js)33
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/publisher.js115
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/reply_indicator.js (renamed from app/javascript/flavours/glitch/features/composer/reply/index.js)46
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/search.js158
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/search_results.js106
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/textarea_icons.js59
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/upload.js131
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/upload_form.js33
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/upload_progress.js42
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/warning.js26
-rw-r--r--app/javascript/flavours/glitch/features/compose/containers/autosuggest_account_container.js15
-rw-r--r--app/javascript/flavours/glitch/features/compose/containers/compose_form_container.js136
-rw-r--r--app/javascript/flavours/glitch/features/compose/containers/header_container.js21
-rw-r--r--app/javascript/flavours/glitch/features/compose/containers/navigation_container.js11
-rw-r--r--app/javascript/flavours/glitch/features/compose/containers/options_container.js52
-rw-r--r--app/javascript/flavours/glitch/features/compose/containers/poll_form_container.js (renamed from app/javascript/flavours/glitch/features/composer/poll_form/index.js)23
-rw-r--r--app/javascript/flavours/glitch/features/compose/containers/reply_indicator_container.js22
-rw-r--r--app/javascript/flavours/glitch/features/compose/containers/search_container.js35
-rw-r--r--app/javascript/flavours/glitch/features/compose/containers/search_results_container.js15
-rw-r--r--app/javascript/flavours/glitch/features/compose/containers/sensitive_button_container.js54
-rw-r--r--app/javascript/flavours/glitch/features/compose/containers/upload_container.js31
-rw-r--r--app/javascript/flavours/glitch/features/compose/containers/upload_form_container.js8
-rw-r--r--app/javascript/flavours/glitch/features/compose/containers/upload_progress_container.js9
-rw-r--r--app/javascript/flavours/glitch/features/compose/containers/warning_container.js44
-rw-r--r--app/javascript/flavours/glitch/features/compose/index.js84
-rw-r--r--app/javascript/flavours/glitch/features/composer/direct_warning/index.js55
-rw-r--r--app/javascript/flavours/glitch/features/composer/hashtag_warning/index.js49
-rw-r--r--app/javascript/flavours/glitch/features/composer/index.js600
-rw-r--r--app/javascript/flavours/glitch/features/composer/options/dropdown/content/index.js146
-rw-r--r--app/javascript/flavours/glitch/features/composer/options/dropdown/content/item/index.js129
-rw-r--r--app/javascript/flavours/glitch/features/composer/publisher/index.js122
-rw-r--r--app/javascript/flavours/glitch/features/composer/spoiler/index.js107
-rw-r--r--app/javascript/flavours/glitch/features/composer/textarea/icons/index.js60
-rw-r--r--app/javascript/flavours/glitch/features/composer/textarea/index.js312
-rw-r--r--app/javascript/flavours/glitch/features/composer/textarea/suggestions/index.js43
-rw-r--r--app/javascript/flavours/glitch/features/composer/textarea/suggestions/item/index.js118
-rw-r--r--app/javascript/flavours/glitch/features/composer/upload_form/index.js60
-rw-r--r--app/javascript/flavours/glitch/features/composer/upload_form/item/index.js202
-rw-r--r--app/javascript/flavours/glitch/features/composer/upload_form/progress/index.js52
-rw-r--r--app/javascript/flavours/glitch/features/composer/warning/index.js59
-rw-r--r--app/javascript/flavours/glitch/features/drawer/account/index.js76
-rw-r--r--app/javascript/flavours/glitch/features/drawer/header/index.js127
-rw-r--r--app/javascript/flavours/glitch/features/drawer/index.js175
-rw-r--r--app/javascript/flavours/glitch/features/drawer/results/index.js117
-rw-r--r--app/javascript/flavours/glitch/features/drawer/search/index.js152
-rw-r--r--app/javascript/flavours/glitch/features/drawer/search/popout/index.js109
-rw-r--r--app/javascript/flavours/glitch/features/keyboard_shortcuts/index.js19
-rw-r--r--app/javascript/flavours/glitch/features/local_settings/page/index.js9
-rw-r--r--app/javascript/flavours/glitch/features/notifications/index.js14
-rw-r--r--app/javascript/flavours/glitch/features/report/components/status_check_box.js1
-rw-r--r--app/javascript/flavours/glitch/features/standalone/compose/index.js4
-rw-r--r--app/javascript/flavours/glitch/features/status/components/detailed_status.js17
-rw-r--r--app/javascript/flavours/glitch/features/status/index.js31
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/actions_modal.js2
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/boost_modal.js4
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/columns_area.js4
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/favourite_modal.js4
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/media_modal.js26
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/onboarding_modal.js21
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/video_modal.js19
-rw-r--r--app/javascript/flavours/glitch/features/ui/index.js19
-rw-r--r--app/javascript/flavours/glitch/features/video/index.js59
-rw-r--r--app/javascript/flavours/glitch/reducers/compose.js14
-rw-r--r--app/javascript/flavours/glitch/reducers/index.js2
-rw-r--r--app/javascript/flavours/glitch/reducers/local_settings.js1
-rw-r--r--app/javascript/flavours/glitch/reducers/suggestions.js30
-rw-r--r--app/javascript/flavours/glitch/styles/accessibility.scss22
-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.scss32
-rw-r--r--app/javascript/flavours/glitch/styles/components/drawer.scss7
-rw-r--r--app/javascript/flavours/glitch/styles/components/index.scss53
-rw-r--r--app/javascript/flavours/glitch/styles/components/media.scss61
-rw-r--r--app/javascript/flavours/glitch/styles/components/modal.scss30
-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.scss74
-rw-r--r--app/javascript/flavours/glitch/styles/forms.scss11
-rw-r--r--app/javascript/flavours/glitch/styles/mastodon-light/diff.scss4
-rw-r--r--app/javascript/flavours/glitch/styles/polls.scss8
-rw-r--r--app/javascript/flavours/glitch/styles/widgets.scss1
-rw-r--r--app/javascript/flavours/glitch/theme.yml2
-rw-r--r--app/javascript/flavours/glitch/util/async-components.js4
112 files changed, 3493 insertions, 3295 deletions
diff --git a/app/javascript/flavours/glitch/actions/compose.js b/app/javascript/flavours/glitch/actions/compose.js
index ac09adceb..f117ce771 100644
--- a/app/javascript/flavours/glitch/actions/compose.js
+++ b/app/javascript/flavours/glitch/actions/compose.js
@@ -227,8 +227,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));
@@ -248,6 +248,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 }){
@@ -405,7 +407,7 @@ export function readyComposeSuggestionsAccounts(token, accounts) {
   };
 };
 
-export function selectComposeSuggestion(position, token, suggestion) {
+export function selectComposeSuggestion(position, token, suggestion, path) {
   return (dispatch, getState) => {
     let completion;
     if (typeof suggestion === 'object' && suggestion.id) {
@@ -422,6 +424,7 @@ export function selectComposeSuggestion(position, token, suggestion) {
       position,
       token,
       completion,
+      path,
     });
   };
 };
diff --git a/app/javascript/flavours/glitch/actions/suggestions.js b/app/javascript/flavours/glitch/actions/suggestions.js
new file mode 100644
index 000000000..3687136ff
--- /dev/null
+++ b/app/javascript/flavours/glitch/actions/suggestions.js
@@ -0,0 +1,52 @@
+import api from 'flavours/glitch/util/api';
+import { importFetchedAccounts } from './importer';
+
+export const SUGGESTIONS_FETCH_REQUEST = 'SUGGESTIONS_FETCH_REQUEST';
+export const SUGGESTIONS_FETCH_SUCCESS = 'SUGGESTIONS_FETCH_SUCCESS';
+export const SUGGESTIONS_FETCH_FAIL    = 'SUGGESTIONS_FETCH_FAIL';
+
+export const SUGGESTIONS_DISMISS = 'SUGGESTIONS_DISMISS';
+
+export function fetchSuggestions() {
+  return (dispatch, getState) => {
+    dispatch(fetchSuggestionsRequest());
+
+    api(getState).get('/api/v1/suggestions').then(response => {
+      dispatch(importFetchedAccounts(response.data));
+      dispatch(fetchSuggestionsSuccess(response.data));
+    }).catch(error => dispatch(fetchSuggestionsFail(error)));
+  };
+};
+
+export function fetchSuggestionsRequest() {
+  return {
+    type: SUGGESTIONS_FETCH_REQUEST,
+    skipLoading: true,
+  };
+};
+
+export function fetchSuggestionsSuccess(accounts) {
+  return {
+    type: SUGGESTIONS_FETCH_SUCCESS,
+    accounts,
+    skipLoading: true,
+  };
+};
+
+export function fetchSuggestionsFail(error) {
+  return {
+    type: SUGGESTIONS_FETCH_FAIL,
+    error,
+    skipLoading: true,
+    skipAlert: true,
+  };
+};
+
+export const dismissSuggestion = accountId => (dispatch, getState) => {
+  dispatch({
+    type: SUGGESTIONS_DISMISS,
+    id: accountId,
+  });
+
+  api(getState).delete(`/api/v1/suggestions/${accountId}`);
+};
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/account.js b/app/javascript/flavours/glitch/components/account.js
index 4fcafc509..3fc18cb72 100644
--- a/app/javascript/flavours/glitch/components/account.js
+++ b/app/javascript/flavours/glitch/components/account.js
@@ -31,6 +31,9 @@ export default class Account extends ImmutablePureComponent {
     intl: PropTypes.object.isRequired,
     hidden: PropTypes.bool,
     small: PropTypes.bool,
+    actionIcon: PropTypes.string,
+    actionTitle: PropTypes.string,
+    onActionClick: PropTypes.func,
   };
 
   handleFollow = () => {
@@ -53,12 +56,19 @@ export default class Account extends ImmutablePureComponent {
     this.props.onMuteNotifications(this.props.account, false);
   }
 
+  handleAction = () => {
+    this.props.onActionClick(this.props.account);
+  }
+
   render () {
     const {
       account,
       hidden,
       intl,
       small,
+      onActionClick,
+      actionIcon,
+      actionTitle,
     } = this.props;
 
     if (!account) {
@@ -76,7 +86,9 @@ export default class Account extends ImmutablePureComponent {
 
     let buttons;
 
-    if (account.get('id') !== me && !small && account.get('relationship', null) !== null) {
+    if (onActionClick && actionIcon) {
+      buttons = <IconButton icon={actionIcon} title={actionTitle} onClick={this.handleAction} />;
+    } else if (account.get('id') !== me && !small && account.get('relationship', null) !== null) {
       const following = account.getIn(['relationship', 'following']);
       const requested = account.getIn(['relationship', 'requested']);
       const blocking  = account.getIn(['relationship', 'blocking']);
diff --git a/app/javascript/flavours/glitch/components/autosuggest_emoji.js b/app/javascript/flavours/glitch/components/autosuggest_emoji.js
new file mode 100644
index 000000000..c8609e48f
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/autosuggest_emoji.js
@@ -0,0 +1,42 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import unicodeMapping from 'flavours/glitch/util/emoji/emoji_unicode_mapping_light';
+
+const assetHost = process.env.CDN_HOST || '';
+
+export default class AutosuggestEmoji extends React.PureComponent {
+
+  static propTypes = {
+    emoji: PropTypes.object.isRequired,
+  };
+
+  render () {
+    const { emoji } = this.props;
+    let url;
+
+    if (emoji.custom) {
+      url = emoji.imageUrl;
+    } else {
+      const mapping = unicodeMapping[emoji.native] || unicodeMapping[emoji.native.replace(/\uFE0F$/, '')];
+
+      if (!mapping) {
+        return null;
+      }
+
+      url = `${assetHost}/emoji/${mapping.filename}.svg`;
+    }
+
+    return (
+      <div className='emoji'>
+        <img
+          className='emojione'
+          src={url}
+          alt={emoji.native || emoji.colons}
+        />
+
+        {emoji.colons}
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/components/autosuggest_input.js b/app/javascript/flavours/glitch/components/autosuggest_input.js
new file mode 100644
index 000000000..ca0dcb64f
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/autosuggest_input.js
@@ -0,0 +1,229 @@
+import React from 'react';
+import AutosuggestAccountContainer from 'flavours/glitch/features/compose/containers/autosuggest_account_container';
+import AutosuggestEmoji from './autosuggest_emoji';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import { isRtl } from 'flavours/glitch/util/rtl';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import classNames from 'classnames';
+import { List as ImmutableList } from 'immutable';
+
+const textAtCursorMatchesToken = (str, caretPosition, searchTokens) => {
+  let word;
+
+  let left  = str.slice(0, caretPosition).search(/[^\s\u200B]+$/);
+  let right = str.slice(caretPosition).search(/[\s\u200B]/);
+
+  if (right < 0) {
+    word = str.slice(left);
+  } else {
+    word = str.slice(left, right + caretPosition);
+  }
+
+  if (!word || word.trim().length < 3 || searchTokens.indexOf(word[0]) === -1) {
+    return [null, null];
+  }
+
+  word = word.trim().toLowerCase();
+
+  if (word.length > 0) {
+    return [left, word];
+  } else {
+    return [null, null];
+  }
+};
+
+export default class AutosuggestInput extends ImmutablePureComponent {
+
+  static propTypes = {
+    value: PropTypes.string,
+    suggestions: ImmutablePropTypes.list,
+    disabled: PropTypes.bool,
+    placeholder: PropTypes.string,
+    onSuggestionSelected: PropTypes.func.isRequired,
+    onSuggestionsClearRequested: PropTypes.func.isRequired,
+    onSuggestionsFetchRequested: PropTypes.func.isRequired,
+    onChange: PropTypes.func.isRequired,
+    onKeyUp: PropTypes.func,
+    onKeyDown: PropTypes.func,
+    autoFocus: PropTypes.bool,
+    className: PropTypes.string,
+    id: PropTypes.string,
+    searchTokens: PropTypes.list,
+    maxLength: PropTypes.number,
+  };
+
+  static defaultProps = {
+    autoFocus: true,
+    searchTokens: ImmutableList(['@', ':', '#']),
+  };
+
+  state = {
+    suggestionsHidden: true,
+    focused: false,
+    selectedSuggestion: 0,
+    lastToken: null,
+    tokenStart: 0,
+  };
+
+  onChange = (e) => {
+    const [ tokenStart, token ] = textAtCursorMatchesToken(e.target.value, e.target.selectionStart, this.props.searchTokens);
+
+    if (token !== null && this.state.lastToken !== token) {
+      this.setState({ lastToken: token, selectedSuggestion: 0, tokenStart });
+      this.props.onSuggestionsFetchRequested(token);
+    } else if (token === null) {
+      this.setState({ lastToken: null });
+      this.props.onSuggestionsClearRequested();
+    }
+
+    this.props.onChange(e);
+  }
+
+  onKeyDown = (e) => {
+    const { suggestions, disabled } = this.props;
+    const { selectedSuggestion, suggestionsHidden } = this.state;
+
+    if (disabled) {
+      e.preventDefault();
+      return;
+    }
+
+    if (e.which === 229 || e.isComposing) {
+      // Ignore key events during text composition
+      // e.key may be a name of the physical key even in this case (e.x. Safari / Chrome on Mac)
+      return;
+    }
+
+    switch(e.key) {
+    case 'Escape':
+      if (suggestions.size === 0 || suggestionsHidden) {
+        document.querySelector('.ui').parentElement.focus();
+      } else {
+        e.preventDefault();
+        this.setState({ suggestionsHidden: true });
+      }
+
+      break;
+    case 'ArrowDown':
+      if (suggestions.size > 0 && !suggestionsHidden) {
+        e.preventDefault();
+        this.setState({ selectedSuggestion: Math.min(selectedSuggestion + 1, suggestions.size - 1) });
+      }
+
+      break;
+    case 'ArrowUp':
+      if (suggestions.size > 0 && !suggestionsHidden) {
+        e.preventDefault();
+        this.setState({ selectedSuggestion: Math.max(selectedSuggestion - 1, 0) });
+      }
+
+      break;
+    case 'Enter':
+    case 'Tab':
+      // Select suggestion
+      if (this.state.lastToken !== null && suggestions.size > 0 && !suggestionsHidden) {
+        e.preventDefault();
+        e.stopPropagation();
+        this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestions.get(selectedSuggestion));
+      }
+
+      break;
+    }
+
+    if (e.defaultPrevented || !this.props.onKeyDown) {
+      return;
+    }
+
+    this.props.onKeyDown(e);
+  }
+
+  onBlur = () => {
+    this.setState({ suggestionsHidden: true, focused: false });
+  }
+
+  onFocus = () => {
+    this.setState({ focused: true });
+  }
+
+  onSuggestionClick = (e) => {
+    const suggestion = this.props.suggestions.get(e.currentTarget.getAttribute('data-index'));
+    e.preventDefault();
+    this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestion);
+    this.input.focus();
+  }
+
+  componentWillReceiveProps (nextProps) {
+    if (nextProps.suggestions !== this.props.suggestions && nextProps.suggestions.size > 0 && this.state.suggestionsHidden && this.state.focused) {
+      this.setState({ suggestionsHidden: false });
+    }
+  }
+
+  setInput = (c) => {
+    this.input = c;
+  }
+
+  renderSuggestion = (suggestion, i) => {
+    const { selectedSuggestion } = this.state;
+    let inner, key;
+
+    if (typeof suggestion === 'object') {
+      inner = <AutosuggestEmoji emoji={suggestion} />;
+      key   = suggestion.id;
+    } else if (suggestion[0] === '#') {
+      inner = suggestion;
+      key   = suggestion;
+    } else {
+      inner = <AutosuggestAccountContainer id={suggestion} />;
+      key   = suggestion;
+    }
+
+    return (
+      <div role='button' tabIndex='0' key={key} data-index={i} className={classNames('autosuggest-textarea__suggestions__item', { selected: i === selectedSuggestion })} onMouseDown={this.onSuggestionClick}>
+        {inner}
+      </div>
+    );
+  }
+
+  render () {
+    const { value, suggestions, disabled, placeholder, onKeyUp, autoFocus, className, id, maxLength } = this.props;
+    const { suggestionsHidden } = this.state;
+    const style = { direction: 'ltr' };
+
+    if (isRtl(value)) {
+      style.direction = 'rtl';
+    }
+
+    return (
+      <div className='autosuggest-input'>
+        <label>
+          <span style={{ display: 'none' }}>{placeholder}</span>
+
+          <input
+            type='text'
+            ref={this.setInput}
+            disabled={disabled}
+            placeholder={placeholder}
+            autoFocus={autoFocus}
+            value={value}
+            onChange={this.onChange}
+            onKeyDown={this.onKeyDown}
+            onKeyUp={onKeyUp}
+            onFocus={this.onFocus}
+            onBlur={this.onBlur}
+            style={style}
+            aria-autocomplete='list'
+            id={id}
+            className={className}
+            maxLength={maxLength}
+          />
+        </label>
+
+        <div className={`autosuggest-textarea__suggestions ${suggestionsHidden || suggestions.isEmpty() ? '' : 'autosuggest-textarea__suggestions--visible'}`}>
+          {suggestions.map(this.renderSuggestion)}
+        </div>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/components/autosuggest_textarea.js b/app/javascript/flavours/glitch/components/autosuggest_textarea.js
new file mode 100644
index 000000000..e1ded2b3a
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/autosuggest_textarea.js
@@ -0,0 +1,230 @@
+import React from 'react';
+import AutosuggestAccountContainer from 'flavours/glitch/features/compose/containers/autosuggest_account_container';
+import AutosuggestEmoji from './autosuggest_emoji';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import { isRtl } from 'flavours/glitch/util/rtl';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import Textarea from 'react-textarea-autosize';
+import classNames from 'classnames';
+
+const textAtCursorMatchesToken = (str, caretPosition) => {
+  let word;
+
+  let left  = str.slice(0, caretPosition).search(/[^\s\u200B]+$/);
+  let right = str.slice(caretPosition).search(/[\s\u200B]/);
+
+  if (right < 0) {
+    word = str.slice(left);
+  } else {
+    word = str.slice(left, right + caretPosition);
+  }
+
+  if (!word || word.trim().length < 3 || ['@', ':', '#'].indexOf(word[0]) === -1) {
+    return [null, null];
+  }
+
+  word = word.trim().toLowerCase();
+
+  if (word.length > 0) {
+    return [left, word];
+  } else {
+    return [null, null];
+  }
+};
+
+export default class AutosuggestTextarea extends ImmutablePureComponent {
+
+  static propTypes = {
+    value: PropTypes.string,
+    suggestions: ImmutablePropTypes.list,
+    disabled: PropTypes.bool,
+    placeholder: PropTypes.string,
+    onSuggestionSelected: PropTypes.func.isRequired,
+    onSuggestionsClearRequested: PropTypes.func.isRequired,
+    onSuggestionsFetchRequested: PropTypes.func.isRequired,
+    onChange: PropTypes.func.isRequired,
+    onKeyUp: PropTypes.func,
+    onKeyDown: PropTypes.func,
+    onPaste: PropTypes.func.isRequired,
+    autoFocus: PropTypes.bool,
+  };
+
+  static defaultProps = {
+    autoFocus: true,
+  };
+
+  state = {
+    suggestionsHidden: true,
+    focused: false,
+    selectedSuggestion: 0,
+    lastToken: null,
+    tokenStart: 0,
+  };
+
+  onChange = (e) => {
+    const [ tokenStart, token ] = textAtCursorMatchesToken(e.target.value, e.target.selectionStart);
+
+    if (token !== null && this.state.lastToken !== token) {
+      this.setState({ lastToken: token, selectedSuggestion: 0, tokenStart });
+      this.props.onSuggestionsFetchRequested(token);
+    } else if (token === null) {
+      this.setState({ lastToken: null });
+      this.props.onSuggestionsClearRequested();
+    }
+
+    this.props.onChange(e);
+  }
+
+  onKeyDown = (e) => {
+    const { suggestions, disabled } = this.props;
+    const { selectedSuggestion, suggestionsHidden } = this.state;
+
+    if (disabled) {
+      e.preventDefault();
+      return;
+    }
+
+    if (e.which === 229 || e.isComposing) {
+      // Ignore key events during text composition
+      // e.key may be a name of the physical key even in this case (e.x. Safari / Chrome on Mac)
+      return;
+    }
+
+    switch(e.key) {
+    case 'Escape':
+      if (suggestions.size === 0 || suggestionsHidden) {
+        document.querySelector('.ui').parentElement.focus();
+      } else {
+        e.preventDefault();
+        this.setState({ suggestionsHidden: true });
+      }
+
+      break;
+    case 'ArrowDown':
+      if (suggestions.size > 0 && !suggestionsHidden) {
+        e.preventDefault();
+        this.setState({ selectedSuggestion: Math.min(selectedSuggestion + 1, suggestions.size - 1) });
+      }
+
+      break;
+    case 'ArrowUp':
+      if (suggestions.size > 0 && !suggestionsHidden) {
+        e.preventDefault();
+        this.setState({ selectedSuggestion: Math.max(selectedSuggestion - 1, 0) });
+      }
+
+      break;
+    case 'Enter':
+    case 'Tab':
+      // Select suggestion
+      if (this.state.lastToken !== null && suggestions.size > 0 && !suggestionsHidden) {
+        e.preventDefault();
+        e.stopPropagation();
+        this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestions.get(selectedSuggestion));
+      }
+
+      break;
+    }
+
+    if (e.defaultPrevented || !this.props.onKeyDown) {
+      return;
+    }
+
+    this.props.onKeyDown(e);
+  }
+
+  onBlur = () => {
+    this.setState({ suggestionsHidden: true, focused: false });
+  }
+
+  onFocus = () => {
+    this.setState({ focused: true });
+  }
+
+  onSuggestionClick = (e) => {
+    const suggestion = this.props.suggestions.get(e.currentTarget.getAttribute('data-index'));
+    e.preventDefault();
+    this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestion);
+    this.textarea.focus();
+  }
+
+  componentWillReceiveProps (nextProps) {
+    if (nextProps.suggestions !== this.props.suggestions && nextProps.suggestions.size > 0 && this.state.suggestionsHidden && this.state.focused) {
+      this.setState({ suggestionsHidden: false });
+    }
+  }
+
+  setTextarea = (c) => {
+    this.textarea = c;
+  }
+
+  onPaste = (e) => {
+    if (e.clipboardData && e.clipboardData.files.length === 1) {
+      this.props.onPaste(e.clipboardData.files);
+      e.preventDefault();
+    }
+  }
+
+  renderSuggestion = (suggestion, i) => {
+    const { selectedSuggestion } = this.state;
+    let inner, key;
+
+    if (typeof suggestion === 'object') {
+      inner = <AutosuggestEmoji emoji={suggestion} />;
+      key   = suggestion.id;
+    } else if (suggestion[0] === '#') {
+      inner = suggestion;
+      key   = suggestion;
+    } else {
+      inner = <AutosuggestAccountContainer id={suggestion} />;
+      key   = suggestion;
+    }
+
+    return (
+      <div role='button' tabIndex='0' key={key} data-index={i} className={classNames('autosuggest-textarea__suggestions__item', { selected: i === selectedSuggestion })} onMouseDown={this.onSuggestionClick}>
+        {inner}
+      </div>
+    );
+  }
+
+  render () {
+    const { value, suggestions, disabled, placeholder, onKeyUp, autoFocus } = this.props;
+    const { suggestionsHidden } = this.state;
+    const style = { direction: 'ltr' };
+
+    if (isRtl(value)) {
+      style.direction = 'rtl';
+    }
+
+    return (
+      <div className='autosuggest-textarea'>
+        <label>
+          <span style={{ display: 'none' }}>{placeholder}</span>
+
+          <Textarea
+            inputRef={this.setTextarea}
+            className='autosuggest-textarea__textarea'
+            disabled={disabled}
+            placeholder={placeholder}
+            autoFocus={autoFocus}
+            value={value}
+            onChange={this.onChange}
+            onKeyDown={this.onKeyDown}
+            onKeyUp={onKeyUp}
+            onFocus={this.onFocus}
+            onBlur={this.onBlur}
+            onPaste={this.onPaste}
+            style={style}
+            aria-autocomplete='list'
+          />
+        </label>
+
+        <div className={`autosuggest-textarea__suggestions ${suggestionsHidden || suggestions.isEmpty() ? '' : 'autosuggest-textarea__suggestions--visible'}`}>
+          {suggestions.map(this.renderSuggestion)}
+        </div>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/components/column_back_button.js b/app/javascript/flavours/glitch/components/column_back_button.js
index a562ef9b9..82556d22e 100644
--- a/app/javascript/flavours/glitch/components/column_back_button.js
+++ b/app/javascript/flavours/glitch/components/column_back_button.js
@@ -8,10 +8,15 @@ export default class ColumnBackButton extends React.PureComponent {
     router: PropTypes.object,
   };
 
-  handleClick = () => {
+  handleClick = (event) => {
     // if history is exhausted, or we would leave mastodon, just go to root.
     if (window.history.state) {
-      this.context.router.history.goBack();
+      const state = this.context.router.history.location.state;
+      if (event.shiftKey && state && state.mastodonBackSteps) {
+        this.context.router.history.go(-state.mastodonBackSteps);
+      } else {
+        this.context.router.history.goBack();
+      }
     } else {
       this.context.router.history.push('/');
     }
diff --git a/app/javascript/flavours/glitch/components/column_back_button_slim.js b/app/javascript/flavours/glitch/components/column_back_button_slim.js
index c99c202af..38afd3df3 100644
--- a/app/javascript/flavours/glitch/components/column_back_button_slim.js
+++ b/app/javascript/flavours/glitch/components/column_back_button_slim.js
@@ -8,10 +8,15 @@ export default class ColumnBackButtonSlim extends React.PureComponent {
     router: PropTypes.object,
   };
 
-  handleClick = () => {
+  handleClick = (event) => {
     // if history is exhausted, or we would leave mastodon, just go to root.
     if (window.history.state) {
-      this.context.router.history.goBack();
+      const state = this.context.router.history.location.state;
+      if (event.shiftKey && state && state.mastodonBackSteps) {
+        this.context.router.history.go(-state.mastodonBackSteps);
+      } else {
+        this.context.router.history.goBack();
+      }
     } else {
       this.context.router.history.push('/');
     }
diff --git a/app/javascript/flavours/glitch/components/column_header.js b/app/javascript/flavours/glitch/components/column_header.js
index 87e848a59..a0ff09986 100644
--- a/app/javascript/flavours/glitch/components/column_header.js
+++ b/app/javascript/flavours/glitch/components/column_header.js
@@ -47,10 +47,15 @@ export default class ColumnHeader extends React.PureComponent {
     animatingNCD: false,
   };
 
-  historyBack = () => {
+  historyBack = (skip) => {
     // if history is exhausted, or we would leave mastodon, just go to root.
     if (window.history.state) {
-      this.context.router.history.goBack();
+      const state = this.context.router.history.location.state;
+      if (skip && state && state.mastodonBackSteps) {
+        this.context.router.history.go(-state.mastodonBackSteps);
+      } else {
+        this.context.router.history.goBack();
+      }
     } else {
       this.context.router.history.push('/');
     }
@@ -73,8 +78,8 @@ export default class ColumnHeader extends React.PureComponent {
     this.props.onMove(1);
   }
 
-  handleBackClick = () => {
-    this.historyBack();
+  handleBackClick = (event) => {
+    this.historyBack(event.shiftKey);
   }
 
   handleTransitionEnd = () => {
diff --git a/app/javascript/flavours/glitch/components/media_gallery.js b/app/javascript/flavours/glitch/components/media_gallery.js
index 6be2b4700..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,13 +88,40 @@ class Item extends React.PureComponent {
     e.stopPropagation();
   }
 
-  handleMouseDown = (e) => {
-    e.preventDefault();
-    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;
@@ -141,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';
 
@@ -173,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>
       );
@@ -190,7 +232,6 @@ class Item extends React.PureComponent {
             onClick={this.handleClick}
             onMouseEnter={this.handleMouseEnter}
             onMouseLeave={this.handleMouseLeave}
-            onMouseDown={this.handleMouseDown}
             autoPlay={autoPlay}
             loop
             muted
@@ -203,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>
     );
   }
@@ -263,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,
       });
@@ -281,7 +324,7 @@ export default class MediaGallery extends React.PureComponent {
 
     const width = this.state.width || defaultWidth;
 
-    let children;
+    let children, spoilerButton;
 
     const style = {};
 
@@ -295,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/modal_root.js b/app/javascript/flavours/glitch/components/modal_root.js
index 7a90e6b8a..4e8648b49 100644
--- a/app/javascript/flavours/glitch/components/modal_root.js
+++ b/app/javascript/flavours/glitch/components/modal_root.js
@@ -40,7 +40,7 @@ export default class ModalRoot extends React.PureComponent {
       this.setState({ revealed: false });
     }
     if (!nextProps.children && !!this.props.children) {
-      this.activeElement.focus();
+      this.activeElement.focus({ preventScroll: true });
       this.activeElement = null;
     }
   }
diff --git a/app/javascript/flavours/glitch/components/permalink.js b/app/javascript/flavours/glitch/components/permalink.js
index 1ea6a2915..718b02115 100644
--- a/app/javascript/flavours/glitch/components/permalink.js
+++ b/app/javascript/flavours/glitch/components/permalink.js
@@ -24,7 +24,9 @@ export default class Permalink extends React.PureComponent {
 
       if (this.context.router) {
         e.preventDefault();
-        this.context.router.history.push(this.props.to);
+        let state = {...this.context.router.history.location.state};
+        state.mastodonBackSteps = (state.mastodonBackSteps || 0) + 1;
+        this.context.router.history.push(this.props.to, state);
       }
     }
   }
diff --git a/app/javascript/flavours/glitch/components/status.js b/app/javascript/flavours/glitch/components/status.js
index c8bf75f79..5f10e0c52 100644
--- a/app/javascript/flavours/glitch/components/status.js
+++ b/app/javascript/flavours/glitch/components/status.js
@@ -53,6 +53,7 @@ export default class Status extends ImmutablePureComponent {
     onReply: PropTypes.func,
     onFavourite: PropTypes.func,
     onReblog: PropTypes.func,
+    onBookmark: PropTypes.func,
     onDelete: PropTypes.func,
     onDirect: PropTypes.func,
     onMention: PropTypes.func,
@@ -295,7 +296,11 @@ export default class Status extends ImmutablePureComponent {
       else if (e.shiftKey) {
         this.setCollapsed(true);
         document.getSelection().removeAllRanges();
-      } else router.history.push(destination);
+      } else {
+        let state = {...router.history.location.state};
+        state.mastodonBackSteps = (state.mastodonBackSteps || 0) + 1;
+        router.history.push(destination, state);
+      }
       e.preventDefault();
     }
   }
@@ -304,7 +309,9 @@ export default class Status extends ImmutablePureComponent {
     if (this.context.router && e.button === 0) {
       const id = e.currentTarget.getAttribute('data-id');
       e.preventDefault();
-      this.context.router.history.push(`/accounts/${id}`);
+      let state = {...this.context.router.history.location.state};
+      state.mastodonBackSteps = (state.mastodonBackSteps || 0) + 1;
+      this.context.router.history.push(`/accounts/${id}`, state);
     }
   }
 
@@ -331,17 +338,25 @@ export default class Status extends ImmutablePureComponent {
     this.props.onReblog(this.props.status, e);
   }
 
+  handleHotkeyBookmark = e => {
+    this.props.onBookmark(this.props.status, e);
+  }
+
   handleHotkeyMention = e => {
     e.preventDefault();
     this.props.onMention(this.props.status.get('account'), this.context.router.history);
   }
 
   handleHotkeyOpen = () => {
-    this.context.router.history.push(`/statuses/${this.props.status.get('id')}`);
+    let state = {...this.context.router.history.location.state};
+    state.mastodonBackSteps = (state.mastodonBackSteps || 0) + 1;
+    this.context.router.history.push(`/statuses/${this.props.status.get('id')}`, state);
   }
 
   handleHotkeyOpenProfile = () => {
-    this.context.router.history.push(`/accounts/${this.props.status.getIn(['account', 'id'])}`);
+    let state = {...this.context.router.history.location.state};
+    state.mastodonBackSteps = (state.mastodonBackSteps || 0) + 1;
+    this.context.router.history.push(`/accounts/${this.props.status.getIn(['account', 'id'])}`, state);
   }
 
   handleHotkeyMoveUp = e => {
@@ -352,6 +367,14 @@ export default class Status extends ImmutablePureComponent {
     this.props.onMoveDown(this.props.containerId || this.props.id, e.target.getAttribute('data-featured'));
   }
 
+  handleHotkeyCollapse = e => {
+    if (!this.props.settings.getIn(['collapsed', 'enabled']))
+      return;
+
+    this.setCollapsed(!this.state.isCollapsed);
+  }
+
+
   handleRef = c => {
     this.node = c;
   }
@@ -456,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
@@ -540,6 +564,8 @@ export default class Status extends ImmutablePureComponent {
       moveUp: this.handleHotkeyMoveUp,
       moveDown: this.handleHotkeyMoveDown,
       toggleSpoiler: this.handleExpandedToggle,
+      bookmark: this.handleHotkeyBookmark,
+      toggleCollapse: this.handleHotkeyCollapse,
     };
 
     const computedClass = classNames('status', `status-${status.get('visibility')}`, {
diff --git a/app/javascript/flavours/glitch/components/status_action_bar.js b/app/javascript/flavours/glitch/components/status_action_bar.js
index e0cc652d2..6d1f54c60 100644
--- a/app/javascript/flavours/glitch/components/status_action_bar.js
+++ b/app/javascript/flavours/glitch/components/status_action_bar.js
@@ -150,7 +150,9 @@ export default class StatusActionBar extends ImmutablePureComponent {
   }
 
   handleOpen = () => {
-    this.context.router.history.push(`/statuses/${this.props.status.get('id')}`);
+    let state = {...this.context.router.history.location.state};
+    state.mastodonBackSteps = (state.mastodonBackSteps || 0) + 1;
+    this.context.router.history.push(`/statuses/${this.props.status.get('id')}`, state);
   }
 
   handleEmbed = () => {
diff --git a/app/javascript/flavours/glitch/components/status_content.js b/app/javascript/flavours/glitch/components/status_content.js
index ae14c949a..07a0d1d5d 100644
--- a/app/javascript/flavours/glitch/components/status_content.js
+++ b/app/javascript/flavours/glitch/components/status_content.js
@@ -213,6 +213,7 @@ export default class StatusContent extends React.PureComponent {
               style={directionStyle}
               tabIndex={!hidden ? 0 : null}
               dangerouslySetInnerHTML={content}
+              className='status__content__text'
               lang={status.get('language')}
             />
             {media}
@@ -233,6 +234,7 @@ export default class StatusContent extends React.PureComponent {
             ref={this.setRef}
             dangerouslySetInnerHTML={content}
             lang={status.get('language')}
+            className='status__content__text'
             tabIndex='0'
           />
           {media}
@@ -245,7 +247,7 @@ export default class StatusContent extends React.PureComponent {
           style={directionStyle}
           tabIndex='0'
         >
-          <div ref={this.setRef} dangerouslySetInnerHTML={content} lang={status.get('language')} tabIndex='0' />
+          <div ref={this.setRef} className='status__content__text' dangerouslySetInnerHTML={content} lang={status.get('language')} tabIndex='0' />
           {media}
         </div>
       );
diff --git a/app/javascript/flavours/glitch/components/status_list.js b/app/javascript/flavours/glitch/components/status_list.js
index a7629bd54..c1f51b307 100644
--- a/app/javascript/flavours/glitch/components/status_list.js
+++ b/app/javascript/flavours/glitch/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/flavours/glitch/containers/mastodon.js b/app/javascript/flavours/glitch/containers/mastodon.js
index 4fb6be476..59eef6636 100644
--- a/app/javascript/flavours/glitch/containers/mastodon.js
+++ b/app/javascript/flavours/glitch/containers/mastodon.js
@@ -39,13 +39,6 @@ export default class Mastodon extends React.PureComponent {
       window.setTimeout(() => Notification.requestPermission(), 60 * 1000);
     }
 
-    // Protocol handler
-    // Ask after 5 minutes
-    if (typeof navigator.registerProtocolHandler !== 'undefined') {
-      const handlerUrl = window.location.protocol + '//' + window.location.host + '/intent?uri=%s';
-      window.setTimeout(() => navigator.registerProtocolHandler('web+mastodon', handlerUrl, 'Mastodon'), 5 * 60 * 1000);
-    }
-
     store.dispatch(showOnboardingOnce());
   }
 
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/account_timeline/components/moved_note.js b/app/javascript/flavours/glitch/features/account_timeline/components/moved_note.js
index 280389bba..1fab083db 100644
--- a/app/javascript/flavours/glitch/features/account_timeline/components/moved_note.js
+++ b/app/javascript/flavours/glitch/features/account_timeline/components/moved_note.js
@@ -20,7 +20,9 @@ export default class MovedNote extends ImmutablePureComponent {
   handleAccountClick = e => {
     if (e.button === 0) {
       e.preventDefault();
-      this.context.router.history.push(`/accounts/${this.props.to.get('id')}`);
+      let state = {...this.context.router.history.location.state};
+      state.mastodonBackSteps = (state.mastodonBackSteps || 0) + 1;
+      this.context.router.history.push(`/accounts/${this.props.to.get('id')}`, state);
     }
 
     e.stopPropagation();
diff --git a/app/javascript/flavours/glitch/features/community_timeline/containers/column_settings_container.js b/app/javascript/flavours/glitch/features/community_timeline/containers/column_settings_container.js
index 16a963dde..b892f08ad 100644
--- a/app/javascript/flavours/glitch/features/community_timeline/containers/column_settings_container.js
+++ b/app/javascript/flavours/glitch/features/community_timeline/containers/column_settings_container.js
@@ -1,5 +1,6 @@
 import { connect } from 'react-redux';
 import ColumnSettings from '../components/column_settings';
+import { changeColumnParams } from 'flavours/glitch/actions/columns';
 import { changeSetting } from 'flavours/glitch/actions/settings';
 
 const mapStateToProps = (state, { columnId }) => {
diff --git a/app/javascript/flavours/glitch/features/compose/components/autosuggest_account.js b/app/javascript/flavours/glitch/features/compose/components/autosuggest_account.js
new file mode 100644
index 000000000..fb9bb5035
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/components/autosuggest_account.js
@@ -0,0 +1,24 @@
+import React from 'react';
+import Avatar from 'flavours/glitch/components/avatar';
+import DisplayName from 'flavours/glitch/components/display_name';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+export default class AutosuggestAccount extends ImmutablePureComponent {
+
+  static propTypes = {
+    account: ImmutablePropTypes.map.isRequired,
+  };
+
+  render () {
+    const { account } = this.props;
+
+    return (
+      <div className='account small' title={account.get('acct')}>
+        <div className='account__avatar-wrapper'><Avatar account={account} size={24} /></div>
+        <DisplayName account={account} inline />
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/compose/components/compose_form.js b/app/javascript/flavours/glitch/features/compose/components/compose_form.js
new file mode 100644
index 000000000..e8f000b1e
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/components/compose_form.js
@@ -0,0 +1,378 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import ReplyIndicatorContainer from '../containers/reply_indicator_container';
+import AutosuggestTextarea from '../../../components/autosuggest_textarea';
+import AutosuggestInput from '../../../components/autosuggest_input';
+import { defineMessages, injectIntl } from 'react-intl';
+import EmojiPicker from 'flavours/glitch/features/emoji_picker';
+import PollFormContainer from '../containers/poll_form_container';
+import UploadFormContainer from '../containers/upload_form_container';
+import WarningContainer from '../containers/warning_container';
+import { isMobile } from 'flavours/glitch/util/is_mobile';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import { countableText } from 'flavours/glitch/util/counter';
+import OptionsContainer from '../containers/options_container';
+import Publisher from './publisher';
+import TextareaIcons from './textarea_icons';
+
+const messages = defineMessages({
+  placeholder: { id: 'compose_form.placeholder', defaultMessage: 'What is on your mind?' },
+  missingDescriptionMessage: {  id: 'confirmations.missing_media_description.message',
+                                defaultMessage: 'At least one media attachment is lacking a description. Consider describing all media attachments for the visually impaired before sending your toot.' },
+  missingDescriptionConfirm: {  id: 'confirmations.missing_media_description.confirm',
+                                defaultMessage: 'Send anyway' },
+  spoiler_placeholder: { id: 'compose_form.spoiler_placeholder', defaultMessage: 'Write your warning here' },
+});
+
+export default @injectIntl
+class ComposeForm extends ImmutablePureComponent {
+
+  static contextTypes = {
+    router: PropTypes.object,
+  };
+
+  static propTypes = {
+    intl: PropTypes.object.isRequired,
+    text: PropTypes.string,
+    suggestions: ImmutablePropTypes.list,
+    spoiler: PropTypes.bool,
+    privacy: PropTypes.string,
+    spoilerText: PropTypes.string,
+    focusDate: PropTypes.instanceOf(Date),
+    caretPosition: PropTypes.number,
+    preselectDate: PropTypes.instanceOf(Date),
+    isSubmitting: PropTypes.bool,
+    isChangingUpload: PropTypes.bool,
+    isUploading: PropTypes.bool,
+    onChange: PropTypes.func,
+    onSubmit: PropTypes.func,
+    onClearSuggestions: PropTypes.func,
+    onFetchSuggestions: PropTypes.func,
+    onSuggestionSelected: PropTypes.func,
+    onChangeSpoilerText: PropTypes.func,
+    onPaste: PropTypes.func,
+    onPickEmoji: PropTypes.func,
+    showSearch: PropTypes.bool,
+    anyMedia: PropTypes.bool,
+
+    advancedOptions: ImmutablePropTypes.map,
+    layout: PropTypes.string,
+    media: ImmutablePropTypes.list,
+    sideArm: PropTypes.string,
+    sensitive: PropTypes.bool,
+    spoilersAlwaysOn: PropTypes.bool,
+    mediaDescriptionConfirmation: PropTypes.bool,
+    preselectOnReply: PropTypes.bool,
+    onChangeSpoilerness: PropTypes.func,
+    onChangeVisibility: PropTypes.func,
+    onMount: PropTypes.func,
+    onUnmount: PropTypes.func,
+    onPaste: PropTypes.func,
+    onMediaDescriptionConfirm: PropTypes.func,
+  };
+
+  static defaultProps = {
+    showSearch: false,
+  };
+
+  handleChange = (e) => {
+    this.props.onChange(e.target.value);
+  }
+
+  handleKeyDown = ({ ctrlKey, keyCode, metaKey, altKey }) => {
+    //  We submit the status on control/meta + enter.
+    if (keyCode === 13 && (ctrlKey || metaKey)) {
+      this.handleSubmit();
+    }
+
+    // Submit the status with secondary visibility on alt + enter.
+    if (keyCode === 13 && altKey) {
+      this.handleSecondarySubmit();
+    }
+  }
+
+  handleSubmit = () => {
+    const { textarea: { value }, uploadForm } = this;
+    const {
+      onChange,
+      onSubmit,
+      isSubmitting,
+      isChangingUpload,
+      isUploading,
+      media,
+      anyMedia,
+      text,
+      mediaDescriptionConfirmation,
+      onMediaDescriptionConfirm,
+    } = this.props;
+
+    //  If something changes inside the textarea, then we update the
+    //  state before submitting.
+    if (onChange && text !== value) {
+      onChange(value);
+    }
+
+    // Submit disabled:
+    if (isSubmitting || isUploading || isChangingUpload || (!text.trim().length && !anyMedia)) {
+      return;
+    }
+
+    // Submit unless there are media with missing descriptions
+    if (mediaDescriptionConfirmation && onMediaDescriptionConfirm && media && media.some(item => !item.get('description'))) {
+      const firstWithoutDescription = media.findIndex(item => !item.get('description'));
+      if (uploadForm) {
+        const inputs = uploadForm.querySelectorAll('.composer--upload_form--item input');
+        if (inputs.length == media.size && firstWithoutDescription !== -1) {
+          inputs[firstWithoutDescription].focus();
+        }
+      }
+      onMediaDescriptionConfirm(this.context.router ? this.context.router.history : null);
+    } else if (onSubmit) {
+      onSubmit(this.context.router ? this.context.router.history : null);
+    }
+  }
+
+  //  Changes the text value of the spoiler.
+  handleChangeSpoiler = ({ target: { value } }) => {
+    const { onChangeSpoilerText } = this.props;
+    if (onChangeSpoilerText) {
+      onChangeSpoilerText(value);
+    }
+  }
+
+  //  Inserts an emoji at the caret.
+  handleEmoji = (data) => {
+    const { textarea: { selectionStart } } = this;
+    const { onPickEmoji } = this.props;
+    if (onPickEmoji) {
+      onPickEmoji(selectionStart, data);
+    }
+  }
+
+  //  Handles the secondary submit button.
+  handleSecondarySubmit = () => {
+    const {
+      onChangeVisibility,
+      sideArm,
+    } = this.props;
+    if (sideArm !== 'none' && onChangeVisibility) {
+      onChangeVisibility(sideArm);
+    }
+    this.handleSubmit();
+  }
+
+  //  Selects a suggestion from the autofill.
+  onSuggestionSelected = (tokenStart, token, value) => {
+    this.props.onSuggestionSelected(tokenStart, token, value, ['text']);
+  }
+
+  onSpoilerSuggestionSelected = (tokenStart, token, value) => {
+    this.props.onSuggestionSelected(tokenStart, token, value, ['spoiler_text']);
+  }
+
+  //  When the escape key is released, we focus the UI.
+  handleKeyUp = ({ key }) => {
+    if (key === 'Escape') {
+      document.querySelector('.ui').parentElement.focus();
+    }
+  }
+
+  //  Sets a reference to the textarea.
+  setAutosuggestTextarea = (textareaComponent) => {
+    if (textareaComponent) {
+      this.textarea = textareaComponent.textarea;
+    }
+  }
+
+  //  Sets a reference to the CW field.
+  handleRefSpoilerText = (spoilerComponent) => {
+    if (spoilerComponent) {
+      this.spoilerText = spoilerComponent.input;
+    }
+  }
+
+  //  Tells our state the composer has been mounted.
+  componentDidMount () {
+    const { onMount } = this.props;
+    if (onMount) {
+      onMount();
+    }
+  }
+
+  //  Tells our state the composer has been unmounted.
+  componentWillUnmount () {
+    const { onUnmount } = this.props;
+    if (onUnmount) {
+      onUnmount();
+    }
+  }
+
+  //  This statement does several things:
+  //  - If we're beginning a reply, and,
+  //      - Replying to zero or one users, places the cursor at the end
+  //        of the textbox.
+  //      - Replying to more than one user, selects any usernames past
+  //        the first; this provides a convenient shortcut to drop
+  //        everyone else from the conversation.
+  componentDidUpdate (prevProps) {
+    const {
+      textarea,
+      spoilerText,
+    } = this;
+    const {
+      focusDate,
+      caretPosition,
+      isSubmitting,
+      preselectDate,
+      text,
+      preselectOnReply,
+    } = this.props;
+    let selectionEnd, selectionStart;
+
+    //  Caret/selection handling.
+    if (focusDate !== prevProps.focusDate) {
+      switch (true) {
+      case preselectDate !== prevProps.preselectDate && preselectOnReply:
+        selectionStart = text.search(/\s/) + 1;
+        selectionEnd = text.length;
+        break;
+      case !isNaN(caretPosition) && caretPosition !== null:
+        selectionStart = selectionEnd = caretPosition;
+        break;
+      default:
+        selectionStart = selectionEnd = text.length;
+      }
+      if (textarea) {
+        textarea.setSelectionRange(selectionStart, selectionEnd);
+        textarea.focus();
+        textarea.scrollIntoView();
+      }
+
+    //  Refocuses the textarea after submitting.
+    } else if (textarea && prevProps.isSubmitting && !isSubmitting) {
+      textarea.focus();
+    } else if (this.props.spoiler !== prevProps.spoiler) {
+      if (this.props.spoiler) {
+        if (spoilerText) {
+          spoilerText.focus();
+        }
+      } else {
+        if (textarea) {
+          textarea.focus();
+        }
+      }
+    }
+  }
+
+
+  render () {
+    const {
+      handleEmoji,
+      handleSecondarySubmit,
+      handleSelect,
+      handleSubmit,
+      handleRefTextarea,
+    } = this;
+    const {
+      advancedOptions,
+      anyMedia,
+      intl,
+      isSubmitting,
+      isChangingUpload,
+      isUploading,
+      layout,
+      media,
+      onChangeSpoilerness,
+      onChangeVisibility,
+      onClearSuggestions,
+      onFetchSuggestions,
+      onPaste,
+      privacy,
+      sensitive,
+      showSearch,
+      sideArm,
+      spoiler,
+      spoilerText,
+      suggestions,
+      text,
+      spoilersAlwaysOn,
+    } = this.props;
+
+    let disabledButton = isSubmitting || isUploading || isChangingUpload || (!text.trim().length && !anyMedia);
+
+    return (
+      <div className='composer'>
+        <WarningContainer />
+
+        <ReplyIndicatorContainer />
+
+        <div className={`composer--spoiler ${spoiler ? 'composer--spoiler--visible' : ''}`}>
+          <AutosuggestInput
+            placeholder={intl.formatMessage(messages.spoiler_placeholder)}
+            value={spoilerText}
+            onChange={this.handleChangeSpoiler}
+            onKeyDown={this.handleKeyDown}
+            onKeyUp={this.handleKeyUp}
+            disabled={!spoiler}
+            ref={this.handleRefSpoilerText}
+            suggestions={this.props.suggestions}
+            onSuggestionsFetchRequested={onFetchSuggestions}
+            onSuggestionsClearRequested={onClearSuggestions}
+            onSuggestionSelected={this.onSpoilerSuggestionSelected}
+            searchTokens={[':']}
+            id='glitch.composer.spoiler.input'
+            className='spoiler-input__input'
+          />
+        </div>
+
+        <div className='composer--textarea'>
+          <TextareaIcons advancedOptions={advancedOptions} />
+
+          <AutosuggestTextarea
+            ref={this.setAutosuggestTextarea}
+            placeholder={intl.formatMessage(messages.placeholder)}
+            disabled={isSubmitting}
+            value={this.props.text}
+            onChange={this.handleChange}
+            suggestions={this.props.suggestions}
+            onKeyDown={this.handleKeyDown}
+            onSuggestionsFetchRequested={onFetchSuggestions}
+            onSuggestionsClearRequested={onClearSuggestions}
+            onSuggestionSelected={this.onSuggestionSelected}
+            onPaste={onPaste}
+            autoFocus={!showSearch && !isMobile(window.innerWidth, layout)}
+          />
+
+          <EmojiPicker onPickEmoji={handleEmoji} />
+        </div>
+
+        <div className='compose-form__modifiers'>
+          <UploadFormContainer />
+          <PollFormContainer />
+        </div>
+
+        <OptionsContainer
+          advancedOptions={advancedOptions}
+          disabled={isSubmitting}
+          onChangeVisibility={onChangeVisibility}
+          onToggleSpoiler={spoilersAlwaysOn ? null : onChangeSpoilerness}
+          onUpload={onPaste}
+          privacy={privacy}
+          sensitive={sensitive || (spoilersAlwaysOn && spoilerText && spoilerText.length > 0)}
+          spoiler={spoilersAlwaysOn ? (spoilerText && spoilerText.length > 0) : spoiler}
+        />
+
+        <Publisher
+          countText={`${spoilerText}${countableText(text)}${advancedOptions && advancedOptions.get('do_not_federate') ? ' 👁️' : ''}`}
+          disabled={disabledButton}
+          onSecondarySubmit={handleSecondarySubmit}
+          onSubmit={handleSubmit}
+          privacy={privacy}
+          sideArm={sideArm}
+        />
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/composer/options/dropdown/index.js b/app/javascript/flavours/glitch/features/compose/components/dropdown.js
index 7817cc964..8d982208f 100644
--- a/app/javascript/flavours/glitch/features/composer/options/dropdown/index.js
+++ b/app/javascript/flavours/glitch/features/compose/components/dropdown.js
@@ -6,7 +6,7 @@ import Overlay from 'react-overlays/lib/Overlay';
 
 //  Components.
 import IconButton from 'flavours/glitch/components/icon_button';
-import ComposerOptionsDropdownContent from './content';
+import DropdownMenu from './dropdown_menu';
 
 //  Utils.
 import { isUserTouching } from 'flavours/glitch/util/is_mobile';
@@ -196,7 +196,7 @@ export default class ComposerOptionsDropdown extends React.PureComponent {
           show={open}
           target={this}
         >
-          <ComposerOptionsDropdownContent
+          <DropdownMenu
             items={items}
             onChange={onChange}
             onClose={handleClose}
diff --git a/app/javascript/flavours/glitch/features/compose/components/dropdown_menu.js b/app/javascript/flavours/glitch/features/compose/components/dropdown_menu.js
new file mode 100644
index 000000000..19d35a8f4
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/components/dropdown_menu.js
@@ -0,0 +1,219 @@
+//  Package imports.
+import PropTypes from 'prop-types';
+import React from 'react';
+import spring from 'react-motion/lib/spring';
+import Toggle from 'react-toggle';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import classNames from 'classnames';
+
+//  Components.
+import Icon from 'flavours/glitch/components/icon';
+
+//  Utils.
+import { withPassive } from 'flavours/glitch/util/dom_helpers';
+import Motion from 'flavours/glitch/util/optional_motion';
+import { assignHandlers } from 'flavours/glitch/util/react_helpers';
+
+class ComposerOptionsDropdownContentItem extends ImmutablePureComponent {
+
+  static propTypes = {
+    active: PropTypes.bool,
+    name: PropTypes.string,
+    onChange: PropTypes.func,
+    onClose: PropTypes.func,
+    options: PropTypes.shape({
+      icon: PropTypes.string,
+      meta: PropTypes.node,
+      on: PropTypes.bool,
+      text: PropTypes.node,
+    }),
+  };
+
+  handleActivate = (e) => {
+    const {
+      name,
+      onChange,
+      onClose,
+      options: { on },
+    } = this.props;
+
+    //  If the escape key was pressed, we close the dropdown.
+    if (e.key === 'Escape' && onClose) {
+      onClose();
+
+    //  Otherwise, we both close the dropdown and change the value.
+    } else if (onChange && (!e.key || e.key === 'Enter')) {
+      e.preventDefault();  //  Prevents change in focus on click
+      if ((on === null || typeof on === 'undefined') && onClose) {
+        onClose();
+      }
+      onChange(name);
+    }
+  }
+
+  //  Rendering.
+  render () {
+    const {
+      active,
+      options: {
+        icon,
+        meta,
+        on,
+        text,
+      },
+    } = this.props;
+    const computedClass = classNames('composer--options--dropdown--content--item', {
+      active,
+      lengthy: meta,
+      'toggled-off': !on && on !== null && typeof on !== 'undefined',
+      'toggled-on': on,
+      'with-icon': icon,
+    });
+
+    let prefix = null;
+
+    if (on !== null && typeof on !== 'undefined') {
+      prefix = <Toggle checked={on} onChange={this.handleActivate} />;
+    } else if (icon) {
+      prefix = <Icon className='icon' fullwidth icon={icon} />
+    }
+
+    //  The result.
+    return (
+      <div
+        className={computedClass}
+        onClick={this.handleActivate}
+        onKeyDown={this.handleActivate}
+        role='button'
+        tabIndex='0'
+      >
+        {prefix}
+
+        <div className='content'>
+          <strong>{text}</strong>
+          {meta}
+        </div>
+      </div>
+    );
+  }
+
+};
+
+//  The spring to use with our motion.
+const springMotion = spring(1, {
+  damping: 35,
+  stiffness: 400,
+});
+
+//  The component.
+export default class ComposerOptionsDropdownContent extends React.PureComponent {
+
+  static propTypes = {
+    items: PropTypes.arrayOf(PropTypes.shape({
+      icon: PropTypes.string,
+      meta: PropTypes.node,
+      name: PropTypes.string.isRequired,
+      on: PropTypes.bool,
+      text: PropTypes.node,
+    })),
+    onChange: PropTypes.func,
+    onClose: PropTypes.func,
+    style: PropTypes.object,
+    value: PropTypes.string,
+  };
+
+  static defaultProps = {
+    style: {},
+  };
+
+  state = {
+    mounted: false,
+  };
+
+  //  When the document is clicked elsewhere, we close the dropdown.
+  handleDocumentClick = ({ target }) => {
+    const { node } = this;
+    const { onClose } = this.props;
+    if (onClose && node && !node.contains(target)) {
+      onClose();
+    }
+  }
+
+  //  Stores our node in `this.node`.
+  handleRef = (node) => {
+    this.node = node;
+  }
+
+  //  On mounting, we add our listeners.
+  componentDidMount () {
+    document.addEventListener('click', this.handleDocumentClick, false);
+    document.addEventListener('touchend', this.handleDocumentClick, withPassive);
+    this.setState({ mounted: true });
+  }
+
+  //  On unmounting, we remove our listeners.
+  componentWillUnmount () {
+    document.removeEventListener('click', this.handleDocumentClick, false);
+    document.removeEventListener('touchend', this.handleDocumentClick, withPassive);
+  }
+
+  //  Rendering.
+  render () {
+    const { mounted } = this.state;
+    const {
+      items,
+      onChange,
+      onClose,
+      style,
+      value,
+    } = this.props;
+
+    //  The result.
+    return (
+      <Motion
+        defaultStyle={{
+          opacity: 0,
+          scaleX: 0.85,
+          scaleY: 0.75,
+        }}
+        style={{
+          opacity: springMotion,
+          scaleX: springMotion,
+          scaleY: springMotion,
+        }}
+      >
+        {({ opacity, scaleX, scaleY }) => (
+          // It should not be transformed when mounting because the resulting
+          // size will be used to determine the coordinate of the menu by
+          // react-overlays
+          <div
+            className='composer--options--dropdown--content'
+            ref={this.handleRef}
+            style={{
+              ...style,
+              opacity: opacity,
+              transform: mounted ? `scale(${scaleX}, ${scaleY})` : null,
+            }}
+          >
+            {items ? items.map(
+              ({
+                name,
+                ...rest
+              }) => (
+                <ComposerOptionsDropdownContentItem
+                  active={name === value}
+                  key={name}
+                  name={name}
+                  onChange={onChange}
+                  onClose={onClose}
+                  options={rest}
+                />
+              )
+            ) : null}
+          </div>
+        )}
+      </Motion>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/compose/components/header.js b/app/javascript/flavours/glitch/features/compose/components/header.js
new file mode 100644
index 000000000..2e29084f2
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/components/header.js
@@ -0,0 +1,124 @@
+//  Package imports.
+import PropTypes from 'prop-types';
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { injectIntl, defineMessages } from 'react-intl';
+import { Link } from 'react-router-dom';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+//  Components.
+import Icon from 'flavours/glitch/components/icon';
+
+//  Utils.
+import { conditionalRender } from 'flavours/glitch/util/react_helpers';
+import { signOutLink } from 'flavours/glitch/util/backend_links';
+
+//  Messages.
+const messages = defineMessages({
+  community: {
+    defaultMessage: 'Local timeline',
+    id: 'navigation_bar.community_timeline',
+  },
+  home_timeline: {
+    defaultMessage: 'Home',
+    id: 'tabs_bar.home',
+  },
+  logout: {
+    defaultMessage: 'Logout',
+    id: 'navigation_bar.logout',
+  },
+  notifications: {
+    defaultMessage: 'Notifications',
+    id: 'tabs_bar.notifications',
+  },
+  public: {
+    defaultMessage: 'Federated timeline',
+    id: 'navigation_bar.public_timeline',
+  },
+  settings: {
+    defaultMessage: 'App settings',
+    id: 'navigation_bar.app_settings',
+  },
+  start: {
+    defaultMessage: 'Getting started',
+    id: 'getting_started.heading',
+  },
+});
+
+export default @injectIntl
+class Header extends ImmutablePureComponent {
+  static propTypes = {
+    columns: ImmutablePropTypes.list,
+    unreadNotifications: PropTypes.number,
+    showNotificationsBadge: PropTypes.bool,
+    intl: PropTypes.object,
+    onSettingsClick: PropTypes.func,
+  };
+
+  render () {
+    const { intl, columns, unreadNotifications, showNotificationsBadge, onSettingsClick } = this.props;
+
+    //  Only renders the component if the column isn't being shown.
+    const renderForColumn = conditionalRender.bind(null,
+      columnId => !columns || !columns.some(
+        column => column.get('id') === columnId
+      )
+    );
+
+    //  The result.
+    return (
+      <nav className='drawer--header'>
+        <Link
+          aria-label={intl.formatMessage(messages.start)}
+          title={intl.formatMessage(messages.start)}
+          to='/getting-started'
+        ><Icon icon='asterisk' /></Link>
+        {renderForColumn('HOME', (
+          <Link
+            aria-label={intl.formatMessage(messages.home_timeline)}
+            title={intl.formatMessage(messages.home_timeline)}
+            to='/timelines/home'
+          ><Icon icon='home' /></Link>
+        ))}
+        {renderForColumn('NOTIFICATIONS', (
+          <Link
+            aria-label={intl.formatMessage(messages.notifications)}
+            title={intl.formatMessage(messages.notifications)}
+            to='/notifications'
+          >
+            <span className='icon-badge-wrapper'>
+              <Icon icon='bell' />
+              { showNotificationsBadge && unreadNotifications > 0 && <div className='icon-badge' />}
+            </span>
+          </Link>
+        ))}
+        {renderForColumn('COMMUNITY', (
+          <Link
+            aria-label={intl.formatMessage(messages.community)}
+            title={intl.formatMessage(messages.community)}
+            to='/timelines/public/local'
+          ><Icon icon='users' /></Link>
+        ))}
+        {renderForColumn('PUBLIC', (
+          <Link
+            aria-label={intl.formatMessage(messages.public)}
+            title={intl.formatMessage(messages.public)}
+            to='/timelines/public'
+          ><Icon icon='globe' /></Link>
+        ))}
+        <a
+          aria-label={intl.formatMessage(messages.settings)}
+          onClick={onSettingsClick}
+          href='#'
+          title={intl.formatMessage(messages.settings)}
+        ><Icon icon='cogs' /></a>
+        <a
+          aria-label={intl.formatMessage(messages.logout)}
+          data-method='delete'
+          href={ signOutLink }
+          title={intl.formatMessage(messages.logout)}
+        ><Icon icon='sign-out' /></a>
+      </nav>
+    );
+  };
+}
diff --git a/app/javascript/flavours/glitch/features/compose/components/navigation_bar.js b/app/javascript/flavours/glitch/features/compose/components/navigation_bar.js
new file mode 100644
index 000000000..59172bb23
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/components/navigation_bar.js
@@ -0,0 +1,36 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import Avatar from 'flavours/glitch/components/avatar';
+import Permalink from 'flavours/glitch/components/permalink';
+import { FormattedMessage } from 'react-intl';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import { profileLink } from 'flavours/glitch/util/backend_links';
+
+export default class NavigationBar extends ImmutablePureComponent {
+
+  static propTypes = {
+    account: ImmutablePropTypes.map.isRequired,
+  };
+
+  render () {
+    return (
+      <div className='drawer--account'>
+        <Permalink className='avatar' href={this.props.account.get('url')} to={`/accounts/${this.props.account.get('id')}`}>
+          <span style={{ display: 'none' }}>{this.props.account.get('acct')}</span>
+          <Avatar account={this.props.account} size={40} />
+        </Permalink>
+
+        <Permalink className='acct' href={this.props.account.get('url')} to={`/accounts/${this.props.account.get('id')}`}>
+          <strong>@{this.props.account.get('acct')}</strong>
+        </Permalink>
+
+        { profileLink !== undefined && (
+          <a
+            className='edit'
+            href={ profileLink }
+          ><FormattedMessage id='navigation_bar.edit_profile' defaultMessage='Edit profile' /></a>
+        )}
+      </div>
+    );
+  };
+}
diff --git a/app/javascript/flavours/glitch/features/composer/options/index.js b/app/javascript/flavours/glitch/features/compose/components/options.js
index 7c7f01dc2..ee9730961 100644
--- a/app/javascript/flavours/glitch/features/composer/options/index.js
+++ b/app/javascript/flavours/glitch/features/compose/components/options.js
@@ -2,23 +2,17 @@
 import PropTypes from 'prop-types';
 import React from 'react';
 import ImmutablePropTypes from 'react-immutable-proptypes';
-import {
-  FormattedMessage,
-  defineMessages,
-} from 'react-intl';
+import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
 import spring from 'react-motion/lib/spring';
 
 //  Components.
 import IconButton from 'flavours/glitch/components/icon_button';
 import TextIconButton from 'flavours/glitch/components/text_icon_button';
 import Dropdown from './dropdown';
+import ImmutablePureComponent from 'react-immutable-pure-component';
 
 //  Utils.
 import Motion from 'flavours/glitch/util/optional_motion';
-import {
-  assignHandlers,
-  hiddenComponent,
-} from 'flavours/glitch/util/react_helpers';
 import { pollLimits } from 'flavours/glitch/util/initial_state';
 
 //  Messages.
@@ -71,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',
@@ -109,19 +99,41 @@ const messages = defineMessages({
   },
 });
 
-//  Handlers.
-const handlers = {
+export default @injectIntl
+class ComposerOptions extends ImmutablePureComponent {
+
+  static propTypes = {
+    acceptContentTypes: PropTypes.string,
+    advancedOptions: ImmutablePropTypes.map,
+    disabled: PropTypes.bool,
+    allowMedia: PropTypes.bool,
+    hasMedia: PropTypes.bool,
+    allowPoll: PropTypes.bool,
+    hasPoll: PropTypes.bool,
+    intl: PropTypes.object.isRequired,
+    onChangeAdvancedOption: PropTypes.func,
+    onChangeVisibility: PropTypes.func,
+    onTogglePoll: PropTypes.func,
+    onDoodleOpen: PropTypes.func,
+    onModalClose: PropTypes.func,
+    onModalOpen: PropTypes.func,
+    onToggleSpoiler: PropTypes.func,
+    onUpload: PropTypes.func,
+    privacy: PropTypes.string,
+    resetFileKey: PropTypes.number,
+    spoiler: PropTypes.bool,
+  };
 
   //  Handles file selection.
-  handleChangeFiles ({ target: { files } }) {
+  handleChangeFiles = ({ target: { files } }) => {
     const { onUpload } = this.props;
     if (files.length && onUpload) {
       onUpload(files);
     }
-  },
+  }
 
   //  Handles attachment clicks.
-  handleClickAttach (name) {
+  handleClickAttach = (name) => {
     const { fileElement } = this;
     const { onDoodleOpen } = this.props;
 
@@ -138,34 +150,16 @@ const handlers = {
       }
       return;
     }
-  },
+  }
 
   //  Handles a ref to the file input.
-  handleRefFileElement (fileElement) {
+  handleRefFileElement = (fileElement) => {
     this.fileElement = fileElement;
-  },
-};
-
-//  The component.
-export default class ComposerOptions extends React.PureComponent {
-
-  //  Constructor.
-  constructor (props) {
-    super(props);
-    assignHandlers(this, handlers);
-
-    //  Instance variables.
-    this.fileElement = null;
   }
 
   //  Rendering.
   render () {
     const {
-      handleChangeFiles,
-      handleClickAttach,
-      handleRefFileElement,
-    } = this.handlers;
-    const {
       acceptContentTypes,
       advancedOptions,
       disabled,
@@ -175,7 +169,6 @@ export default class ComposerOptions extends React.PureComponent {
       hasPoll,
       intl,
       onChangeAdvancedOption,
-      onChangeSensitivity,
       onChangeVisibility,
       onTogglePoll,
       onModalClose,
@@ -183,7 +176,6 @@ export default class ComposerOptions extends React.PureComponent {
       onToggleSpoiler,
       privacy,
       resetFileKey,
-      sensitive,
       spoiler,
     } = this.props;
 
@@ -223,11 +215,11 @@ export default class ComposerOptions extends React.PureComponent {
           accept={acceptContentTypes}
           disabled={disabled || !allowMedia}
           key={resetFileKey}
-          onChange={handleChangeFiles}
-          ref={handleRefFileElement}
+          onChange={this.handleChangeFiles}
+          ref={this.handleRefFileElement}
           type='file'
           multiple
-          {...hiddenComponent}
+          style={{ display: 'none' }}
         />
         <Dropdown
           disabled={disabled || !allowMedia}
@@ -244,7 +236,7 @@ export default class ComposerOptions extends React.PureComponent {
               text: <FormattedMessage {...messages.doodle} />,
             },
           ]}
-          onChange={handleClickAttach}
+          onChange={this.handleClickAttach}
           onModalClose={onModalClose}
           onModalOpen={onModalOpen}
           title={intl.formatMessage(messages.attach)}
@@ -264,39 +256,6 @@ export default class ComposerOptions extends React.PureComponent {
             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}
@@ -350,28 +309,3 @@ export default class ComposerOptions extends React.PureComponent {
   }
 
 }
-
-//  Props.
-ComposerOptions.propTypes = {
-  acceptContentTypes: PropTypes.string,
-  advancedOptions: ImmutablePropTypes.map,
-  disabled: PropTypes.bool,
-  allowMedia: PropTypes.bool,
-  hasMedia: PropTypes.bool,
-  allowPoll: PropTypes.bool,
-  hasPoll: PropTypes.bool,
-  intl: PropTypes.object.isRequired,
-  onChangeAdvancedOption: PropTypes.func,
-  onChangeSensitivity: PropTypes.func,
-  onChangeVisibility: PropTypes.func,
-  onTogglePoll: PropTypes.func,
-  onDoodleOpen: PropTypes.func,
-  onModalClose: PropTypes.func,
-  onModalOpen: PropTypes.func,
-  onToggleSpoiler: PropTypes.func,
-  onUpload: PropTypes.func,
-  privacy: PropTypes.string,
-  resetFileKey: PropTypes.number,
-  sensitive: PropTypes.bool,
-  spoiler: PropTypes.bool,
-};
diff --git a/app/javascript/flavours/glitch/features/composer/poll_form/components/poll_form.js b/app/javascript/flavours/glitch/features/compose/components/poll_form.js
index 1915b62d5..21b5d3d73 100644
--- a/app/javascript/flavours/glitch/features/composer/poll_form/components/poll_form.js
+++ b/app/javascript/flavours/glitch/features/compose/components/poll_form.js
@@ -5,6 +5,7 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
 import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
 import IconButton from 'flavours/glitch/components/icon_button';
 import Icon from 'flavours/glitch/components/icon';
+import AutosuggestInput from 'flavours/glitch/components/autosuggest_input';
 import classNames from 'classnames';
 import { pollLimits } from 'flavours/glitch/util/initial_state';
 
@@ -29,6 +30,10 @@ class Option extends React.PureComponent {
     isPollMultiple: PropTypes.bool,
     onChange: PropTypes.func.isRequired,
     onRemove: PropTypes.func.isRequired,
+    suggestions: ImmutablePropTypes.list,
+    onClearSuggestions: PropTypes.func.isRequired,
+    onFetchSuggestions: PropTypes.func.isRequired,
+    onSuggestionSelected: PropTypes.func.isRequired,
     intl: PropTypes.object.isRequired,
   };
 
@@ -40,6 +45,18 @@ class Option extends React.PureComponent {
     this.props.onRemove(this.props.index);
   };
 
+  onSuggestionsClearRequested = () => {
+    this.props.onClearSuggestions();
+  }
+
+  onSuggestionsFetchRequested = (token) => {
+    this.props.onFetchSuggestions(token);
+  }
+
+  onSuggestionSelected = (tokenStart, token, value) => {
+    this.props.onSuggestionSelected(tokenStart, token, value, ['poll', 'options', this.props.index]);
+  }
+
   render () {
     const { isPollMultiple, title, index, intl } = this.props;
 
@@ -48,12 +65,16 @@ class Option extends React.PureComponent {
         <label className='poll__text editable'>
           <span className={classNames('poll__input', { checkbox: isPollMultiple })} />
 
-          <input
-            type='text'
+          <AutosuggestInput
             placeholder={intl.formatMessage(messages.option_placeholder, { number: index + 1 })}
             maxLength={pollLimits.max_option_chars}
             value={title}
             onChange={this.handleOptionTitleChange}
+            suggestions={this.props.suggestions}
+            onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
+            onSuggestionsClearRequested={this.onSuggestionsClearRequested}
+            onSuggestionSelected={this.onSuggestionSelected}
+            searchTokens={[':']}
           />
         </label>
 
@@ -78,6 +99,10 @@ class PollForm extends ImmutablePureComponent {
     onAddOption: PropTypes.func.isRequired,
     onRemoveOption: PropTypes.func.isRequired,
     onChangeSettings: PropTypes.func.isRequired,
+    suggestions: ImmutablePropTypes.list,
+    onClearSuggestions: PropTypes.func.isRequired,
+    onFetchSuggestions: PropTypes.func.isRequired,
+    onSuggestionSelected: PropTypes.func.isRequired,
     intl: PropTypes.object.isRequired,
   };
 
@@ -94,7 +119,7 @@ class PollForm extends ImmutablePureComponent {
   };
 
   render () {
-    const { options, expiresIn, isMultiple, onChangeOption, onRemoveOption, intl } = this.props;
+    const { options, expiresIn, isMultiple, onChangeOption, onRemoveOption, intl, ...other } = this.props;
 
     if (!options) {
       return null;
@@ -103,7 +128,7 @@ class PollForm extends ImmutablePureComponent {
     return (
       <div className='compose-form__poll-wrapper'>
         <ul>
-          {options.map((title, i) => <Option title={title} key={i} index={i} onChange={onChangeOption} onRemove={onRemoveOption} isPollMultiple={isMultiple} />)}
+          {options.map((title, i) => <Option title={title} key={i} index={i} onChange={onChangeOption} onRemove={onRemoveOption} isPollMultiple={isMultiple} {...other} />)}
           {options.size < pollLimits.max_options && (
             <label className='poll__text editable'>
               <span className={classNames('poll__input')} style={{ opacity: 0 }} />
diff --git a/app/javascript/flavours/glitch/features/compose/components/publisher.js b/app/javascript/flavours/glitch/features/compose/components/publisher.js
new file mode 100644
index 000000000..e283b32b9
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/components/publisher.js
@@ -0,0 +1,115 @@
+//  Package imports.
+import classNames from 'classnames';
+import PropTypes from 'prop-types';
+import React from 'react';
+import { defineMessages, FormattedMessage, injectIntl } from 'react-intl';
+import { length } from 'stringz';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+//  Components.
+import Button from 'flavours/glitch/components/button';
+import Icon from 'flavours/glitch/components/icon';
+
+//  Utils.
+import { maxChars } from 'flavours/glitch/util/initial_state';
+
+//  Messages.
+const messages = defineMessages({
+  publish: {
+    defaultMessage: 'Toot',
+    id: 'compose_form.publish',
+  },
+  publishLoud: {
+    defaultMessage: '{publish}!',
+    id: 'compose_form.publish_loud',
+  },
+});
+
+export default @injectIntl
+class Publisher extends ImmutablePureComponent {
+
+  static propTypes = {
+    countText: PropTypes.string,
+    disabled: PropTypes.bool,
+    intl: PropTypes.object.isRequired,
+    onSecondarySubmit: PropTypes.func,
+    onSubmit: PropTypes.func,
+    privacy: PropTypes.oneOf(['direct', 'private', 'unlisted', 'public']),
+    sideArm: PropTypes.oneOf(['none', 'direct', 'private', 'unlisted', 'public']),
+  };
+
+  render () {
+    const { countText, disabled, intl, onSecondarySubmit, onSubmit, privacy, sideArm } = this.props;
+
+    const diff = maxChars - length(countText || '');
+    const computedClass = classNames('composer--publisher', {
+      disabled: disabled || diff < 0,
+      over: diff < 0,
+    });
+
+    return (
+      <div className={computedClass}>
+        <span className='count'>{diff}</span>
+        {sideArm && sideArm !== 'none' ? (
+          <Button
+            className='side_arm'
+            disabled={disabled || diff < 0}
+            onClick={onSecondarySubmit}
+            style={{ padding: null }}
+            text={
+              <span>
+                <Icon
+                  icon={{
+                    public: 'globe',
+                    unlisted: 'unlock',
+                    private: 'lock',
+                    direct: 'envelope',
+                  }[sideArm]}
+                />
+              </span>
+            }
+            title={`${intl.formatMessage(messages.publish)}: ${intl.formatMessage({ id: `privacy.${sideArm}.short` })}`}
+          />
+        ) : null}
+        <Button
+          className='primary'
+          text={function () {
+            switch (true) {
+            case !!sideArm && sideArm !== 'none':
+            case privacy === 'direct':
+            case privacy === 'private':
+              return (
+                <span>
+                  <Icon
+                    icon={{
+                      direct: 'envelope',
+                      private: 'lock',
+                      public: 'globe',
+                      unlisted: 'unlock',
+                    }[privacy]}
+                  />
+                  {' '}
+                  <FormattedMessage {...messages.publish} />
+                </span>
+              );
+            case privacy === 'public':
+              return (
+                <span>
+                  <FormattedMessage
+                    {...messages.publishLoud}
+                    values={{ publish: <FormattedMessage {...messages.publish} /> }}
+                  />
+                </span>
+              );
+            default:
+              return <span><FormattedMessage {...messages.publish} /></span>;
+            }
+          }()}
+          title={`${intl.formatMessage(messages.publish)}: ${intl.formatMessage({ id: `privacy.${privacy}.short` })}`}
+          onClick={onSubmit}
+          disabled={disabled || diff < 0}
+        />
+      </div>
+    );
+  };
+}
diff --git a/app/javascript/flavours/glitch/features/composer/reply/index.js b/app/javascript/flavours/glitch/features/compose/components/reply_indicator.js
index 56e9e96a5..f96ea4c5e 100644
--- a/app/javascript/flavours/glitch/features/composer/reply/index.js
+++ b/app/javascript/flavours/glitch/features/compose/components/reply_indicator.js
@@ -2,7 +2,8 @@
 import PropTypes from 'prop-types';
 import React from 'react';
 import ImmutablePropTypes from 'react-immutable-proptypes';
-import { defineMessages } from 'react-intl';
+import { defineMessages, injectIntl } from 'react-intl';
+import ImmutablePureComponent from 'react-immutable-pure-component';
 
 //  Components.
 import AccountContainer from 'flavours/glitch/containers/account_container';
@@ -10,7 +11,6 @@ import IconButton from 'flavours/glitch/components/icon_button';
 import AttachmentList from 'flavours/glitch/components/attachment_list';
 
 //  Utils.
-import { assignHandlers } from 'flavours/glitch/util/react_helpers';
 import { isRtl } from 'flavours/glitch/util/rtl';
 
 //  Messages.
@@ -21,34 +21,30 @@ const messages = defineMessages({
   },
 });
 
-//  Handlers.
-const handlers = {
 
-  //  Handles a click on the "close" button.
-  handleClick () {
+export default @injectIntl
+class ReplyIndicator extends ImmutablePureComponent {
+
+  static propTypes = {
+    status: ImmutablePropTypes.map.isRequired,
+    intl: PropTypes.object.isRequired,
+    onCancel: PropTypes.func,
+  };
+
+  handleClick = () => {
     const { onCancel } = this.props;
     if (onCancel) {
       onCancel();
     }
-  },
-};
-
-//  The component.
-export default class ComposerReply extends React.PureComponent {
-
-  //  Constructor.
-  constructor (props) {
-    super(props);
-    assignHandlers(this, handlers);
   }
 
   //  Rendering.
   render () {
-    const { handleClick } = this.handlers;
-    const {
-      status,
-      intl,
-    } = this.props;
+    const { status, intl } = this.props;
+
+    if (!status) {
+      return null;
+    }
 
     const account     = status.get('account');
     const content     = status.get('content');
@@ -61,7 +57,7 @@ export default class ComposerReply extends React.PureComponent {
           <IconButton
             className='cancel'
             icon='times'
-            onClick={handleClick}
+            onClick={this.handleClick}
             title={intl.formatMessage(messages.cancel)}
             inverted
           />
@@ -88,9 +84,3 @@ export default class ComposerReply extends React.PureComponent {
   }
 
 }
-
-ComposerReply.propTypes = {
-  status: ImmutablePropTypes.map.isRequired,
-  intl: PropTypes.object.isRequired,
-  onCancel: PropTypes.func,
-};
diff --git a/app/javascript/flavours/glitch/features/compose/components/search.js b/app/javascript/flavours/glitch/features/compose/components/search.js
new file mode 100644
index 000000000..5fed1567a
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/components/search.js
@@ -0,0 +1,158 @@
+//  Package imports.
+import classNames from 'classnames';
+import PropTypes from 'prop-types';
+import React from 'react';
+import { connect } from 'react-redux';
+import spring from 'react-motion/lib/spring';
+import {
+  injectIntl,
+  FormattedMessage,
+  defineMessages,
+} from 'react-intl';
+import Overlay from 'react-overlays/lib/Overlay';
+
+//  Components.
+import Icon from 'flavours/glitch/components/icon';
+
+//  Utils.
+import { focusRoot } from 'flavours/glitch/util/dom_helpers';
+import { searchEnabled } from 'flavours/glitch/util/initial_state';
+import Motion from 'flavours/glitch/util/optional_motion';
+
+const messages = defineMessages({
+  placeholder: { id: 'search.placeholder', defaultMessage: 'Search' },
+});
+
+class SearchPopout extends React.PureComponent {
+
+  static propTypes = {
+    style: PropTypes.object,
+  };
+
+  render () {
+    const { style } = this.props;
+    const extraInformation = searchEnabled ? <FormattedMessage id='search_popout.tips.full_text' defaultMessage='Simple text returns statuses you have written, favourited, boosted, or have been mentioned in, as well as matching usernames, display names, and hashtags.' /> : <FormattedMessage id='search_popout.tips.text' defaultMessage='Simple text returns matching display names, usernames and hashtags' />;
+    return (
+      <div style={{ ...style, position: 'absolute', width: 285 }}>
+        <Motion defaultStyle={{ opacity: 0, scaleX: 0.85, scaleY: 0.75 }} style={{ opacity: spring(1, { damping: 35, stiffness: 400 }), scaleX: spring(1, { damping: 35, stiffness: 400 }), scaleY: spring(1, { damping: 35, stiffness: 400 }) }}>
+          {({ opacity, scaleX, scaleY }) => (
+            <div className='drawer--search--popout' style={{ opacity: opacity, transform: `scale(${scaleX}, ${scaleY})` }}>
+              <h4><FormattedMessage id='search_popout.search_format' defaultMessage='Advanced search format' /></h4>
+
+              <ul>
+                <li><em>#example</em> <FormattedMessage id='search_popout.tips.hashtag' defaultMessage='hashtag' /></li>
+                <li><em>@username@domain</em> <FormattedMessage id='search_popout.tips.user' defaultMessage='user' /></li>
+                <li><em>URL</em> <FormattedMessage id='search_popout.tips.user' defaultMessage='user' /></li>
+                <li><em>URL</em> <FormattedMessage id='search_popout.tips.status' defaultMessage='status' /></li>
+              </ul>
+
+              {extraInformation}
+            </div>
+          )}
+        </Motion>
+      </div>
+    );
+  }
+
+}
+
+//  The component.
+export default @injectIntl
+class Search extends React.PureComponent {
+
+  static propTypes = {
+    value: PropTypes.string.isRequired,
+    submitted: PropTypes.bool,
+    onChange: PropTypes.func.isRequired,
+    onSubmit: PropTypes.func.isRequired,
+    onClear: PropTypes.func.isRequired,
+    onShow: PropTypes.func.isRequired,
+    intl: PropTypes.object.isRequired,
+  };
+
+  state = {
+    expanded: false,
+  };
+
+  handleChange = (e) => {
+    const { onChange } = this.props;
+    if (onChange) {
+      onChange(e.target.value);
+    }
+  }
+
+  handleClear = (e) => {
+    const {
+      onClear,
+      submitted,
+      value,
+    } = this.props;
+    e.preventDefault();  //  Prevents focus change ??
+    if (onClear && (submitted || value && value.length)) {
+      onClear();
+    }
+  }
+
+  handleBlur = () => {
+    this.setState({ expanded: false });
+  }
+
+  handleFocus = () => {
+    const { onShow } = this.props;
+    this.setState({ expanded: true });
+    if (onShow) {
+      onShow();
+    }
+  }
+
+  handleKeyUp = (e) => {
+    const { onSubmit } = this.props;
+    switch (e.key) {
+    case 'Enter':
+      if (onSubmit) {
+        onSubmit();
+      }
+      break;
+    case 'Escape':
+      focusRoot();
+    }
+  }
+
+  render () {
+    const { intl, value, submitted } = this.props;
+    const { expanded } = this.state;
+    const active = value.length > 0 || submitted;
+    const computedClass = classNames('drawer--search', { active });
+
+    return (
+      <div className={computedClass}>
+        <label>
+          <span style={{ display: 'none' }}>{intl.formatMessage(messages.placeholder)}</span>
+          <input
+            type='text'
+            placeholder={intl.formatMessage(messages.placeholder)}
+            value={value || ''}
+            onChange={this.handleChange}
+            onKeyUp={this.handleKeyUp}
+            onFocus={this.handleFocus}
+            onBlur={this.handleBlur}
+          />
+        </label>
+        <div
+          aria-label={intl.formatMessage(messages.placeholder)}
+          className='icon'
+          onClick={this.handleClear}
+          role='button'
+          tabIndex='0'
+        >
+          <Icon icon='search' />
+          <Icon icon='times-circle' />
+        </div>
+        <Overlay show={expanded && !active} placement='bottom' target={this}>
+          <SearchPopout />
+        </Overlay>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/compose/components/search_results.js b/app/javascript/flavours/glitch/features/compose/components/search_results.js
new file mode 100644
index 000000000..69df8cdc9
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/components/search_results.js
@@ -0,0 +1,106 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
+import AccountContainer from 'flavours/glitch/containers/account_container';
+import StatusContainer from 'flavours/glitch/containers/status_container';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import Hashtag from 'flavours/glitch/components/hashtag';
+import Icon from 'flavours/glitch/components/icon';
+
+const messages = defineMessages({
+  dismissSuggestion: { id: 'suggestions.dismiss', defaultMessage: 'Dismiss suggestion' },
+});
+
+export default @injectIntl
+class SearchResults extends ImmutablePureComponent {
+
+  static propTypes = {
+    results: ImmutablePropTypes.map.isRequired,
+    suggestions: ImmutablePropTypes.list.isRequired,
+    fetchSuggestions: PropTypes.func.isRequired,
+    dismissSuggestion: PropTypes.func.isRequired,
+    intl: PropTypes.object.isRequired,
+  };
+
+  componentDidMount () {
+    this.props.fetchSuggestions();
+  }
+
+  render() {
+    const { intl, results, suggestions, dismissSuggestion } = this.props;
+
+    if (results.isEmpty() && !suggestions.isEmpty()) {
+      return (
+        <div className='drawer--results'>
+          <div className='trends'>
+            <div className='trends__header'>
+              <i className='fa fa-user-plus fa-fw' />
+              <FormattedMessage id='suggestions.header' defaultMessage='You might be interested in…' />
+            </div>
+
+            {suggestions && suggestions.map(accountId => (
+              <AccountContainer
+                key={accountId}
+                id={accountId}
+                actionIcon='times'
+                actionTitle={intl.formatMessage(messages.dismissSuggestion)}
+                onActionClick={dismissSuggestion}
+              />
+            ))}
+          </div>
+        </div>
+      );
+    }
+
+    let accounts, statuses, hashtags;
+    let count = 0;
+
+    if (results.get('accounts') && results.get('accounts').size > 0) {
+      count   += results.get('accounts').size;
+      accounts = (
+        <section>
+          <h5><Icon icon='users' fixedWidth /><FormattedMessage id='search_results.accounts' defaultMessage='People' /></h5>
+
+          {results.get('accounts').map(accountId => <AccountContainer id={accountId} key={accountId} />)}
+        </section>
+      );
+    }
+
+    if (results.get('statuses') && results.get('statuses').size > 0) {
+      count   += results.get('statuses').size;
+      statuses = (
+        <section>
+          <h5><Icon icon='quote-right' fixedWidth /><FormattedMessage id='search_results.statuses' defaultMessage='Toots' /></h5>
+
+          {results.get('statuses').map(statusId => <StatusContainer id={statusId} key={statusId}/>)}
+        </section>
+      );
+    }
+
+    if (results.get('hashtags') && results.get('hashtags').size > 0) {
+      count += results.get('hashtags').size;
+      hashtags = (
+        <section>
+          <h5><Icon icon='hashtag' fixedWidth /><FormattedMessage id='search_results.hashtags' defaultMessage='Hashtags' /></h5>
+
+          {results.get('hashtags').map(hashtag => <Hashtag key={hashtag.get('name')} hashtag={hashtag} />)}
+        </section>
+      );
+    }
+
+    //  The result.
+    return (
+      <div className='drawer--results'>
+        <header className='search-results__header'>
+          <Icon icon='search' fixedWidth />
+          <FormattedMessage id='search_results.total' defaultMessage='{count, number} {count, plural, one {result} other {results}}' values={{ count }} />
+        </header>
+
+        {accounts}
+        {statuses}
+        {hashtags}
+      </div>
+    );
+  };
+}
diff --git a/app/javascript/flavours/glitch/features/compose/components/textarea_icons.js b/app/javascript/flavours/glitch/features/compose/components/textarea_icons.js
new file mode 100644
index 000000000..ec696f9c3
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/components/textarea_icons.js
@@ -0,0 +1,59 @@
+//  Package imports.
+import PropTypes from 'prop-types';
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { defineMessages, injectIntl } from 'react-intl';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+//  Components.
+import Icon from 'flavours/glitch/components/icon';
+
+//  Messages.
+const messages = defineMessages({
+  localOnly: {
+    defaultMessage: 'This post is local-only',
+    id: 'advanced_options.local-only.tooltip',
+  },
+  threadedMode: {
+    defaultMessage: 'Threaded mode enabled',
+    id: 'advanced_options.threaded_mode.tooltip',
+  },
+});
+
+//  We use an array of tuples here instead of an object because it
+//  preserves order.
+const iconMap = [
+  ['do_not_federate', 'home', messages.localOnly],
+  ['threaded_mode', 'comments', messages.threadedMode],
+];
+
+export default @injectIntl
+class TextareaIcons extends ImmutablePureComponent {
+
+  static propTypes = {
+    advancedOptions: ImmutablePropTypes.map,
+    intl: PropTypes.object.isRequired,
+  };
+
+  render () {
+    const { advancedOptions, intl } = this.props;
+    return (
+      <div className='composer--textarea--icons'>
+        {advancedOptions ? iconMap.map(
+          ([key, icon, message]) => advancedOptions.get(key) ? (
+            <span
+              className='textarea_icon'
+              key={key}
+              title={intl.formatMessage(message)}
+            >
+              <Icon
+                fullwidth
+                icon={icon}
+              />
+            </span>
+          ) : null
+        ) : null}
+      </div>
+    );
+  }
+}
diff --git a/app/javascript/flavours/glitch/features/compose/components/upload.js b/app/javascript/flavours/glitch/features/compose/components/upload.js
new file mode 100644
index 000000000..84edf664e
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/components/upload.js
@@ -0,0 +1,131 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import Motion from 'flavours/glitch/util/optional_motion';
+import spring from 'react-motion/lib/spring';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import classNames from 'classnames';
+import Icon from 'flavours/glitch/components/icon';
+import { isUserTouching } from 'flavours/glitch/util/is_mobile';
+
+const messages = defineMessages({
+  description: { id: 'upload_form.description', defaultMessage: 'Describe for the visually impaired' },
+});
+
+//  The component.
+export default @injectIntl
+class Upload extends ImmutablePureComponent {
+
+  static contextTypes = {
+    router: PropTypes.object,
+  };
+
+  static propTypes = {
+    media: ImmutablePropTypes.map.isRequired,
+    intl: PropTypes.object.isRequired,
+    onUndo: PropTypes.func.isRequired,
+    onDescriptionChange: PropTypes.func.isRequired,
+    onOpenFocalPoint: PropTypes.func.isRequired,
+    onSubmit: PropTypes.func.isRequired,
+  };
+
+  state = {
+    hovered: false,
+    focused: false,
+    dirtyDescription: null,
+  };
+
+  handleKeyDown = (e) => {
+    if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) {
+      this.handleSubmit();
+    }
+  }
+
+  handleSubmit = () => {
+    this.handleInputBlur();
+    this.props.onSubmit(this.context.router.history);
+  }
+
+  handleUndoClick = e => {
+    e.stopPropagation();
+    this.props.onUndo(this.props.media.get('id'));
+  }
+
+  handleFocalPointClick = e => {
+    e.stopPropagation();
+    this.props.onOpenFocalPoint(this.props.media.get('id'));
+  }
+
+  handleInputChange = e => {
+    this.setState({ dirtyDescription: e.target.value });
+  }
+
+  handleMouseEnter = () => {
+    this.setState({ hovered: true });
+  }
+
+  handleMouseLeave = () => {
+    this.setState({ hovered: false });
+  }
+
+  handleInputFocus = () => {
+    this.setState({ focused: true });
+  }
+
+  handleClick = () => {
+    this.setState({ focused: true });
+  }
+
+  handleInputBlur = () => {
+    const { dirtyDescription } = this.state;
+
+    this.setState({ focused: false, dirtyDescription: null });
+
+    if (dirtyDescription !== null) {
+      this.props.onDescriptionChange(this.props.media.get('id'), dirtyDescription);
+    }
+  }
+
+  render () {
+    const { intl, media } = this.props;
+    const active          = this.state.hovered || this.state.focused || isUserTouching();
+    const description     = this.state.dirtyDescription || (this.state.dirtyDescription !== '' && media.get('description')) || '';
+    const computedClass   = classNames('composer--upload_form--item', { active });
+    const focusX = media.getIn(['meta', 'focus', 'x']);
+    const focusY = media.getIn(['meta', 'focus', 'y']);
+    const x = ((focusX /  2) + .5) * 100;
+    const y = ((focusY / -2) + .5) * 100;
+
+    return (
+      <div className={computedClass} tabIndex='0' onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave} onClick={this.handleClick} role='button'>
+        <Motion defaultStyle={{ scale: 0.8 }} style={{ scale: spring(1, { stiffness: 180, damping: 12, }) }}>
+          {({ scale }) => (
+            <div style={{ transform: `scale(${scale})`, backgroundImage: `url(${media.get('preview_url')})`, backgroundPosition: `${x}% ${y}%` }}>
+              <div className={classNames('composer--upload_form--actions', { active })}>
+                <button className='icon-button' onClick={this.handleUndoClick}><Icon icon='times' /> <FormattedMessage id='upload_form.undo' defaultMessage='Delete' /></button>
+                {media.get('type') === 'image' && <button className='icon-button' onClick={this.handleFocalPointClick}><Icon id='crosshairs' /> <FormattedMessage id='upload_form.focus' defaultMessage='Crop' /></button>}
+              </div>
+
+              <div className={classNames('composer--upload_form--description', { active })}>
+                <label>
+                  <span style={{ display: 'none' }}>{intl.formatMessage(messages.description)}</span>
+                  <textarea
+                    placeholder={intl.formatMessage(messages.description)}
+                    value={description}
+                    maxLength={420}
+                    onFocus={this.handleInputFocus}
+                    onChange={this.handleInputChange}
+                    onBlur={this.handleInputBlur}
+                    onKeyDown={this.handleKeyDown}
+                  />
+                </label>
+              </div>
+            </div>
+          )}
+        </Motion>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/compose/components/upload_form.js b/app/javascript/flavours/glitch/features/compose/components/upload_form.js
new file mode 100644
index 000000000..35880ddcc
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/components/upload_form.js
@@ -0,0 +1,33 @@
+import React from 'react';
+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 = {
+    mediaIds: ImmutablePropTypes.list.isRequired,
+  };
+
+  render () {
+    const { mediaIds } = this.props;
+
+    return (
+      <div className='composer--upload_form'>
+        <UploadProgressContainer />
+
+        {mediaIds.size > 0 && (
+          <div className='content'>
+            {mediaIds.map(id => (
+              <UploadContainer id={id} key={id} />
+            ))}
+          </div>
+        )}
+
+        {!mediaIds.isEmpty() && <SensitiveButtonContainer />}
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/compose/components/upload_progress.js b/app/javascript/flavours/glitch/features/compose/components/upload_progress.js
new file mode 100644
index 000000000..264c563f2
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/components/upload_progress.js
@@ -0,0 +1,42 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import Motion from 'flavours/glitch/util/optional_motion';
+import spring from 'react-motion/lib/spring';
+import { FormattedMessage } from 'react-intl';
+import Icon from 'flavours/glitch/components/icon';
+
+export default class UploadProgress extends React.PureComponent {
+
+  static propTypes = {
+    active: PropTypes.bool,
+    progress: PropTypes.number,
+  };
+
+  render () {
+    const { active, progress } = this.props;
+
+    if (!active) {
+      return null;
+    }
+
+    return (
+      <div className='composer--upload_form--progress'>
+        <Icon icon='upload' />
+
+        <div className='message'>
+          <FormattedMessage id='upload_progress.label' defaultMessage='Uploading...' />
+
+          <div className='backdrop'>
+            <Motion defaultStyle={{ width: 0 }} style={{ width: spring(progress) }}>
+              {({ width }) =>
+                (<div className='tracker' style={{ width: `${width}%` }}
+                />)
+              }
+            </Motion>
+          </div>
+        </div>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/compose/components/warning.js b/app/javascript/flavours/glitch/features/compose/components/warning.js
new file mode 100644
index 000000000..6ee3640bc
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/components/warning.js
@@ -0,0 +1,26 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import Motion from 'flavours/glitch/util/optional_motion';
+import spring from 'react-motion/lib/spring';
+
+export default class Warning extends React.PureComponent {
+
+  static propTypes = {
+    message: PropTypes.node.isRequired,
+  };
+
+  render () {
+    const { message } = this.props;
+
+    return (
+      <Motion defaultStyle={{ opacity: 0, scaleX: 0.85, scaleY: 0.75 }} style={{ opacity: spring(1, { damping: 35, stiffness: 400 }), scaleX: spring(1, { damping: 35, stiffness: 400 }), scaleY: spring(1, { damping: 35, stiffness: 400 }) }}>
+        {({ opacity, scaleX, scaleY }) => (
+          <div className='composer--warning' style={{ opacity: opacity, transform: `scale(${scaleX}, ${scaleY})` }}>
+            {message}
+          </div>
+        )}
+      </Motion>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/compose/containers/autosuggest_account_container.js b/app/javascript/flavours/glitch/features/compose/containers/autosuggest_account_container.js
new file mode 100644
index 000000000..0e1c328fe
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/containers/autosuggest_account_container.js
@@ -0,0 +1,15 @@
+import { connect } from 'react-redux';
+import AutosuggestAccount from '../components/autosuggest_account';
+import { makeGetAccount } from 'flavours/glitch/selectors';
+
+const makeMapStateToProps = () => {
+  const getAccount = makeGetAccount();
+
+  const mapStateToProps = (state, { id }) => ({
+    account: getAccount(state, id),
+  });
+
+  return mapStateToProps;
+};
+
+export default connect(makeMapStateToProps)(AutosuggestAccount);
diff --git a/app/javascript/flavours/glitch/features/compose/containers/compose_form_container.js b/app/javascript/flavours/glitch/features/compose/containers/compose_form_container.js
new file mode 100644
index 000000000..2da0770d3
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/containers/compose_form_container.js
@@ -0,0 +1,136 @@
+import { connect } from 'react-redux';
+import { defineMessages } from 'react-intl';
+import ComposeForm from '../components/compose_form';
+import {
+  changeCompose,
+  changeComposeSpoilerText,
+  changeComposeSpoilerness,
+  changeComposeVisibility,
+  clearComposeSuggestions,
+  fetchComposeSuggestions,
+  insertEmojiCompose,
+  mountCompose,
+  selectComposeSuggestion,
+  submitCompose,
+  unmountCompose,
+  uploadCompose,
+} from 'flavours/glitch/actions/compose';
+import {
+  openModal,
+} from 'flavours/glitch/actions/modal';
+import { changeLocalSetting } from 'flavours/glitch/actions/local_settings';
+
+import { privacyPreference } from 'flavours/glitch/util/privacy_preference';
+
+const messages = defineMessages({
+  missingDescriptionMessage: {  id: 'confirmations.missing_media_description.message',
+                                defaultMessage: 'At least one media attachment is lacking a description. Consider describing all media attachments for the visually impaired before sending your toot.' },
+  missingDescriptionConfirm: {  id: 'confirmations.missing_media_description.confirm',
+                                defaultMessage: 'Send anyway' },
+});
+
+//  State mapping.
+function mapStateToProps (state) {
+  const spoilersAlwaysOn = state.getIn(['local_settings', 'always_show_spoilers_field']);
+  const inReplyTo = state.getIn(['compose', 'in_reply_to']);
+  const replyPrivacy = inReplyTo ? state.getIn(['statuses', inReplyTo, 'visibility']) : null;
+  const sideArmBasePrivacy = state.getIn(['local_settings', 'side_arm']);
+  const sideArmRestrictedPrivacy = replyPrivacy ? privacyPreference(replyPrivacy, sideArmBasePrivacy) : null;
+  let sideArmPrivacy = null;
+  switch (state.getIn(['local_settings', 'side_arm_reply_mode'])) {
+    case 'copy':
+      sideArmPrivacy = replyPrivacy;
+      break;
+    case 'restrict':
+      sideArmPrivacy = sideArmRestrictedPrivacy;
+      break;
+  }
+  sideArmPrivacy = sideArmPrivacy || sideArmBasePrivacy;
+  return {
+    advancedOptions: state.getIn(['compose', 'advanced_options']),
+    focusDate: state.getIn(['compose', 'focusDate']),
+    caretPosition: state.getIn(['compose', 'caretPosition']),
+    isSubmitting: state.getIn(['compose', 'is_submitting']),
+    isChangingUpload: state.getIn(['compose', 'is_changing_upload']),
+    isUploading: state.getIn(['compose', 'is_uploading']),
+    layout: state.getIn(['local_settings', 'layout']),
+    media: state.getIn(['compose', 'media_attachments']),
+    preselectDate: state.getIn(['compose', 'preselectDate']),
+    privacy: state.getIn(['compose', 'privacy']),
+    sideArm: sideArmPrivacy,
+    sensitive: state.getIn(['compose', 'sensitive']),
+    showSearch: state.getIn(['search', 'submitted']) && !state.getIn(['search', 'hidden']),
+    spoiler: spoilersAlwaysOn || state.getIn(['compose', 'spoiler']),
+    spoilerText: state.getIn(['compose', 'spoiler_text']),
+    suggestions: state.getIn(['compose', 'suggestions']),
+    text: state.getIn(['compose', 'text']),
+    anyMedia: state.getIn(['compose', 'media_attachments']).size > 0,
+    spoilersAlwaysOn: spoilersAlwaysOn,
+    mediaDescriptionConfirmation: state.getIn(['local_settings', 'confirm_missing_media_description']),
+    preselectOnReply: state.getIn(['local_settings', 'preselect_on_reply']),
+  };
+};
+
+//  Dispatch mapping.
+const mapDispatchToProps = (dispatch, { intl }) => ({
+
+  onChange(text) {
+    dispatch(changeCompose(text));
+  },
+
+  onSubmit(routerHistory) {
+    dispatch(submitCompose(routerHistory));
+  },
+
+  onClearSuggestions() {
+    dispatch(clearComposeSuggestions());
+  },
+
+  onFetchSuggestions(token) {
+    dispatch(fetchComposeSuggestions(token));
+  },
+
+  onSuggestionSelected(position, token, suggestion, path) {
+    dispatch(selectComposeSuggestion(position, token, suggestion, path));
+  },
+
+  onChangeSpoilerText(text) {
+    dispatch(changeComposeSpoilerText(text));
+  },
+
+  onPaste(files) {
+    dispatch(uploadCompose(files));
+  },
+
+  onPickEmoji(position, emoji) {
+    dispatch(insertEmojiCompose(position, emoji));
+  },
+
+  onChangeSpoilerness() {
+    dispatch(changeComposeSpoilerness());
+  },
+
+  onChangeVisibility(value) {
+    dispatch(changeComposeVisibility(value));
+  },
+
+  onMount() {
+    dispatch(mountCompose());
+  },
+
+  onUnmount() {
+    dispatch(unmountCompose());
+  },
+
+  onMediaDescriptionConfirm(routerHistory) {
+    dispatch(openModal('CONFIRM', {
+      message: intl.formatMessage(messages.missingDescriptionMessage),
+      confirm: intl.formatMessage(messages.missingDescriptionConfirm),
+      onConfirm: () => dispatch(submitCompose(routerHistory)),
+      onDoNotAsk: () => dispatch(changeLocalSetting(['confirm_missing_media_description'], false)),
+    }));
+  },
+
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(ComposeForm);
diff --git a/app/javascript/flavours/glitch/features/compose/containers/header_container.js b/app/javascript/flavours/glitch/features/compose/containers/header_container.js
new file mode 100644
index 000000000..ce1dea319
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/containers/header_container.js
@@ -0,0 +1,21 @@
+import { openModal } from 'flavours/glitch/actions/modal';
+import { connect }   from 'react-redux';
+import Header from '../components/header';
+
+const mapStateToProps = state => {
+  return {
+    columns: state.getIn(['settings', 'columns']),
+    unreadNotifications: state.getIn(['notifications', 'unread']),
+    showNotificationsBadge: state.getIn(['local_settings', 'notifications', 'tab_badge']),
+  };
+};
+
+const mapDispatchToProps = (dispatch, { intl }) => ({
+  onSettingsClick (e) {
+    e.preventDefault();
+    e.stopPropagation();
+    dispatch(openModal('SETTINGS', {}));
+  },
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(Header);
diff --git a/app/javascript/flavours/glitch/features/compose/containers/navigation_container.js b/app/javascript/flavours/glitch/features/compose/containers/navigation_container.js
new file mode 100644
index 000000000..eb630ffbb
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/containers/navigation_container.js
@@ -0,0 +1,11 @@
+import { connect }   from 'react-redux';
+import NavigationBar from '../components/navigation_bar';
+import { me } from 'flavours/glitch/util/initial_state';
+
+const mapStateToProps = state => {
+  return {
+    account: state.getIn(['accounts', me]),
+  };
+};
+
+export default connect(mapStateToProps)(NavigationBar);
diff --git a/app/javascript/flavours/glitch/features/compose/containers/options_container.js b/app/javascript/flavours/glitch/features/compose/containers/options_container.js
new file mode 100644
index 000000000..2ac7ab8d8
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/containers/options_container.js
@@ -0,0 +1,52 @@
+import { connect } from 'react-redux';
+import Options from '../components/options';
+import {
+  changeComposeAdvancedOption,
+} from 'flavours/glitch/actions/compose';
+import { addPoll, removePoll } from 'flavours/glitch/actions/compose';
+import { closeModal, openModal } from 'flavours/glitch/actions/modal';
+
+function mapStateToProps (state) {
+  const spoilersAlwaysOn = state.getIn(['local_settings', 'always_show_spoilers_field']);
+  const poll = state.getIn(['compose', 'poll']);
+  const media = state.getIn(['compose', 'media_attachments']);
+  return {
+    acceptContentTypes: state.getIn(['media_attachments', 'accept_content_types']).toArray().join(','),
+    resetFileKey: state.getIn(['compose', 'resetFileKey']),
+    hasPoll: !!poll,
+    allowMedia: !poll && (media ? media.size < 4 && !media.some(item => item.get('type') === 'video') : true),
+    hasMedia: media && !!media.size,
+    allowPoll: !(media && !!media.size),
+  };
+};
+
+const mapDispatchToProps = (dispatch) => ({
+
+  onChangeAdvancedOption(option, value) {
+    dispatch(changeComposeAdvancedOption(option, value));
+  },
+
+  onTogglePoll() {
+    dispatch((_, getState) => {
+      if (getState().getIn(['compose', 'poll'])) {
+        dispatch(removePoll());
+      } else {
+        dispatch(addPoll());
+      }
+    });
+  },
+
+  onDoodleOpen() {
+    dispatch(openModal('DOODLE', { noEsc: true }));
+  },
+
+  onModalClose() {
+    dispatch(closeModal());
+  },
+
+  onModalOpen(props) {
+    dispatch(openModal('ACTIONS', props));
+  },
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(Options);
diff --git a/app/javascript/flavours/glitch/features/composer/poll_form/index.js b/app/javascript/flavours/glitch/features/compose/containers/poll_form_container.js
index 5232c3b31..e87e58771 100644
--- a/app/javascript/flavours/glitch/features/composer/poll_form/index.js
+++ b/app/javascript/flavours/glitch/features/compose/containers/poll_form_container.js
@@ -1,8 +1,14 @@
 import { connect } from 'react-redux';
-import PollForm from './components/poll_form';
-import { addPollOption, removePollOption, changePollOption, changePollSettings } from '../../../actions/compose';
+import PollForm from '../components/poll_form';
+import { addPollOption, removePollOption, changePollOption, changePollSettings } from 'flavours/glitch/actions/compose';
+import {
+  clearComposeSuggestions,
+  fetchComposeSuggestions,
+  selectComposeSuggestion,
+} from 'flavours/glitch/actions/compose';
 
 const mapStateToProps = state => ({
+  suggestions: state.getIn(['compose', 'suggestions']),
   options: state.getIn(['compose', 'poll', 'options']),
   expiresIn: state.getIn(['compose', 'poll', 'expires_in']),
   isMultiple: state.getIn(['compose', 'poll', 'multiple']),
@@ -24,6 +30,19 @@ const mapDispatchToProps = dispatch => ({
   onChangeSettings(expiresIn, isMultiple) {
     dispatch(changePollSettings(expiresIn, isMultiple));
   },
+
+  onClearSuggestions () {
+    dispatch(clearComposeSuggestions());
+  },
+
+  onFetchSuggestions (token) {
+    dispatch(fetchComposeSuggestions(token));
+  },
+
+  onSuggestionSelected (position, token, accountId, path) {
+    dispatch(selectComposeSuggestion(position, token, accountId, path));
+  },
+
 });
 
 export default connect(mapStateToProps, mapDispatchToProps)(PollForm);
diff --git a/app/javascript/flavours/glitch/features/compose/containers/reply_indicator_container.js b/app/javascript/flavours/glitch/features/compose/containers/reply_indicator_container.js
new file mode 100644
index 000000000..395a9aa5b
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/containers/reply_indicator_container.js
@@ -0,0 +1,22 @@
+import { connect } from 'react-redux';
+import { cancelReplyCompose } from 'flavours/glitch/actions/compose';
+import { makeGetStatus } from 'flavours/glitch/selectors';
+import ReplyIndicator from '../components/reply_indicator';
+
+function makeMapStateToProps (state) {
+  const inReplyTo = state.getIn(['compose', 'in_reply_to']);
+
+  return {
+    status: inReplyTo ? state.getIn(['statuses', inReplyTo]) : null,
+  };
+};
+
+const mapDispatchToProps = dispatch => ({
+
+  onCancel () {
+    dispatch(cancelReplyCompose());
+  },
+
+});
+
+export default connect(makeMapStateToProps, mapDispatchToProps)(ReplyIndicator);
diff --git a/app/javascript/flavours/glitch/features/compose/containers/search_container.js b/app/javascript/flavours/glitch/features/compose/containers/search_container.js
new file mode 100644
index 000000000..8f4bfcf08
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/containers/search_container.js
@@ -0,0 +1,35 @@
+import { connect } from 'react-redux';
+import {
+  changeSearch,
+  clearSearch,
+  submitSearch,
+  showSearch,
+} from 'flavours/glitch/actions/search';
+import Search from '../components/search';
+
+const mapStateToProps = state => ({
+  value: state.getIn(['search', 'value']),
+  submitted: state.getIn(['search', 'submitted']),
+});
+
+const mapDispatchToProps = dispatch => ({
+
+  onChange (value) {
+    dispatch(changeSearch(value));
+  },
+
+  onClear () {
+    dispatch(clearSearch());
+  },
+
+  onSubmit () {
+    dispatch(submitSearch());
+  },
+
+  onShow () {
+    dispatch(showSearch());
+  },
+
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(Search);
diff --git a/app/javascript/flavours/glitch/features/compose/containers/search_results_container.js b/app/javascript/flavours/glitch/features/compose/containers/search_results_container.js
new file mode 100644
index 000000000..f9637861a
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/containers/search_results_container.js
@@ -0,0 +1,15 @@
+import { connect } from 'react-redux';
+import SearchResults from '../components/search_results';
+import { fetchSuggestions, dismissSuggestion } from '../../../actions/suggestions';
+
+const mapStateToProps = state => ({
+  results: state.getIn(['search', 'results']),
+  suggestions: state.getIn(['suggestions', 'items']),
+});
+
+const mapDispatchToProps = dispatch => ({
+  fetchSuggestions: () => dispatch(fetchSuggestions()),
+  dismissSuggestion: account => dispatch(dismissSuggestion(account.get('id'))),
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(SearchResults);
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/compose/containers/upload_container.js b/app/javascript/flavours/glitch/features/compose/containers/upload_container.js
new file mode 100644
index 000000000..d6bff63ac
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/containers/upload_container.js
@@ -0,0 +1,31 @@
+import { connect } from 'react-redux';
+import Upload from '../components/upload';
+import { undoUploadCompose, changeUploadCompose } from 'flavours/glitch/actions/compose';
+import { openModal } from 'flavours/glitch/actions/modal';
+import { submitCompose } from 'flavours/glitch/actions/compose';
+
+const mapStateToProps = (state, { id }) => ({
+  media: state.getIn(['compose', 'media_attachments']).find(item => item.get('id') === id),
+});
+
+const mapDispatchToProps = dispatch => ({
+
+  onUndo: id => {
+    dispatch(undoUploadCompose(id));
+  },
+
+  onDescriptionChange: (id, description) => {
+    dispatch(changeUploadCompose(id, { description }));
+  },
+
+  onOpenFocalPoint: id => {
+    dispatch(openModal('FOCAL_POINT', { id }));
+  },
+
+  onSubmit (router) {
+    dispatch(submitCompose(router));
+  },
+
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(Upload);
diff --git a/app/javascript/flavours/glitch/features/compose/containers/upload_form_container.js b/app/javascript/flavours/glitch/features/compose/containers/upload_form_container.js
new file mode 100644
index 000000000..a6798bf51
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/containers/upload_form_container.js
@@ -0,0 +1,8 @@
+import { connect } from 'react-redux';
+import UploadForm from '../components/upload_form';
+
+const mapStateToProps = state => ({
+  mediaIds: state.getIn(['compose', 'media_attachments']).map(item => item.get('id')),
+});
+
+export default connect(mapStateToProps)(UploadForm);
diff --git a/app/javascript/flavours/glitch/features/compose/containers/upload_progress_container.js b/app/javascript/flavours/glitch/features/compose/containers/upload_progress_container.js
new file mode 100644
index 000000000..0cfee96da
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/containers/upload_progress_container.js
@@ -0,0 +1,9 @@
+import { connect } from 'react-redux';
+import UploadProgress from '../components/upload_progress';
+
+const mapStateToProps = state => ({
+  active: state.getIn(['compose', 'is_uploading']),
+  progress: state.getIn(['compose', 'progress']),
+});
+
+export default connect(mapStateToProps)(UploadProgress);
diff --git a/app/javascript/flavours/glitch/features/compose/containers/warning_container.js b/app/javascript/flavours/glitch/features/compose/containers/warning_container.js
new file mode 100644
index 000000000..fdd21f114
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/containers/warning_container.js
@@ -0,0 +1,44 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import Warning from '../components/warning';
+import PropTypes from 'prop-types';
+import { FormattedMessage } from 'react-intl';
+import { me } from 'flavours/glitch/util/initial_state';
+
+const APPROX_HASHTAG_RE = /(?:^|[^\/\)\w])#(\w*[a-zA-Z·]\w*)/i;
+
+const mapStateToProps = state => ({
+  needsLockWarning: state.getIn(['compose', 'privacy']) === 'private' && !state.getIn(['accounts', me, 'locked']),
+  hashtagWarning: state.getIn(['compose', 'privacy']) !== 'public' && APPROX_HASHTAG_RE.test(state.getIn(['compose', 'text'])),
+  directMessageWarning: state.getIn(['compose', 'privacy']) === 'direct',
+});
+
+const WarningWrapper = ({ needsLockWarning, hashtagWarning, directMessageWarning }) => {
+  if (needsLockWarning) {
+    return <Warning message={<FormattedMessage id='compose_form.lock_disclaimer' defaultMessage='Your account is not {locked}. Anyone can follow you to view your follower-only posts.' values={{ locked: <a href='/settings/profile'><FormattedMessage id='compose_form.lock_disclaimer.lock' defaultMessage='locked' /></a> }} />} />;
+  }
+
+  if (hashtagWarning) {
+    return <Warning message={<FormattedMessage id='compose_form.hashtag_warning' defaultMessage="This toot won't be listed under any hashtag as it is unlisted. Only public toots can be searched by hashtag." />} />;
+  }
+
+  if (directMessageWarning) {
+    const message = (
+      <span>
+        <FormattedMessage id='compose_form.direct_message_warning' defaultMessage='This toot will only be sent to all the mentioned users.' /> <a href='/terms' target='_blank'><FormattedMessage id='compose_form.direct_message_warning_learn_more' defaultMessage='Learn more' /></a>
+      </span>
+    );
+
+    return <Warning message={message} />;
+  }
+
+  return null;
+};
+
+WarningWrapper.propTypes = {
+  needsLockWarning: PropTypes.bool,
+  hashtagWarning: PropTypes.bool,
+  directMessageWarning: PropTypes.bool,
+};
+
+export default connect(mapStateToProps)(WarningWrapper);
diff --git a/app/javascript/flavours/glitch/features/compose/index.js b/app/javascript/flavours/glitch/features/compose/index.js
new file mode 100644
index 000000000..a7795a04d
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/index.js
@@ -0,0 +1,84 @@
+import React from 'react';
+import ComposeFormContainer from './containers/compose_form_container';
+import NavigationContainer from './containers/navigation_container';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { connect } from 'react-redux';
+import { injectIntl, defineMessages } from 'react-intl';
+import classNames from 'classnames';
+import SearchContainer from './containers/search_container';
+import Motion from 'flavours/glitch/util/optional_motion';
+import spring from 'react-motion/lib/spring';
+import SearchResultsContainer from './containers/search_results_container';
+import { me, mascot } from 'flavours/glitch/util/initial_state';
+import { cycleElefriendCompose } from 'flavours/glitch/actions/compose';
+import HeaderContainer from './containers/header_container';
+
+const messages = defineMessages({
+  compose: { id: 'navigation_bar.compose', defaultMessage: 'Compose new toot' },
+});
+
+const mapStateToProps = (state, ownProps) => ({
+  elefriend: state.getIn(['compose', 'elefriend']),
+  showSearch: ownProps.multiColumn ? state.getIn(['search', 'submitted']) && !state.getIn(['search', 'hidden']) : ownProps.isSearchPage,
+});
+
+const mapDispatchToProps = (dispatch, { intl }) => ({
+  onClickElefriend () {
+    dispatch(cycleElefriendCompose());
+  },
+});
+
+export default @connect(mapStateToProps, mapDispatchToProps)
+@injectIntl
+class Compose extends React.PureComponent {
+  static propTypes = {
+    multiColumn: PropTypes.bool,
+    showSearch: PropTypes.bool,
+    isSearchPage: PropTypes.bool,
+    elefriend: PropTypes.number,
+    onClickElefriend: PropTypes.func,
+    intl: PropTypes.object.isRequired,
+  };
+
+  render () {
+    const {
+      elefriend,
+      intl,
+      multiColumn,
+      onClickElefriend,
+      isSearchPage,
+      showSearch,
+    } = this.props;
+    const computedClass = classNames('drawer', `mbstobon-${elefriend}`);
+
+    return (
+      <div className={computedClass} role='region' aria-label={intl.formatMessage(messages.compose)}>
+        {multiColumn && <HeaderContainer />}
+
+        {(multiColumn || isSearchPage) && <SearchContainer />}
+
+        <div className='drawer__pager'>
+          {!isSearchPage && <div className='drawer__inner'>
+            <NavigationContainer />
+            <ComposeFormContainer />
+            {multiColumn && (
+              <div className='drawer__inner__mastodon'>
+                {mascot ? <img alt='' draggable='false' src={mascot} /> : <button className='mastodon' onClick={onClickElefriend} />}
+              </div>
+            )}
+          </div>}
+
+          <Motion defaultStyle={{ x: isSearchPage ? 0 : -100 }} style={{ x: spring(showSearch || isSearchPage ? 0 : -100, { stiffness: 210, damping: 20 }) }}>
+            {({ x }) => (
+              <div className='drawer__inner darker' style={{ transform: `translateX(${x}%)`, visibility: x === -100 ? 'hidden' : 'visible' }}>
+                <SearchResultsContainer />
+              </div>
+            )}
+          </Motion>
+        </div>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/composer/direct_warning/index.js b/app/javascript/flavours/glitch/features/composer/direct_warning/index.js
deleted file mode 100644
index 3b1369acd..000000000
--- a/app/javascript/flavours/glitch/features/composer/direct_warning/index.js
+++ /dev/null
@@ -1,55 +0,0 @@
-import React from 'react';
-import Motion from 'flavours/glitch/util/optional_motion';
-import spring from 'react-motion/lib/spring';
-import { defineMessages, FormattedMessage } from 'react-intl';
-import { termsLink} from 'flavours/glitch/util/backend_links';
-
-//  This is the spring used with our motion.
-const motionSpring = spring(1, { damping: 35, stiffness: 400 });
-
-//  Messages.
-const messages = defineMessages({
-  disclaimer: {
-    defaultMessage: 'This toot will only be sent to all the mentioned users.',
-    id: 'compose_form.direct_message_warning',
-  },
-  learn_more: {
-    defaultMessage: 'Learn more',
-    id: 'compose_form.direct_message_warning_learn_more'
-  }
-});
-
-//  The component.
-export default function ComposerDirectWarning () {
-  return (
-    <Motion
-      defaultStyle={{
-        opacity: 0,
-        scaleX: 0.85,
-        scaleY: 0.75,
-      }}
-      style={{
-        opacity: motionSpring,
-        scaleX: motionSpring,
-        scaleY: motionSpring,
-      }}
-    >
-      {({ opacity, scaleX, scaleY }) => (
-        <div
-          className='composer--warning'
-          style={{
-            opacity: opacity,
-            transform: `scale(${scaleX}, ${scaleY})`,
-          }}
-        >
-          <span>
-            <FormattedMessage {...messages.disclaimer} />
-            { termsLink !== undefined && <a href={termsLink} target='_blank'><FormattedMessage {...messages.learn_more} /></a> }
-          </span>
-        </div>
-      )}
-    </Motion>
-  );
-}
-
-ComposerDirectWarning.propTypes = {};
diff --git a/app/javascript/flavours/glitch/features/composer/hashtag_warning/index.js b/app/javascript/flavours/glitch/features/composer/hashtag_warning/index.js
deleted file mode 100644
index 716028e4c..000000000
--- a/app/javascript/flavours/glitch/features/composer/hashtag_warning/index.js
+++ /dev/null
@@ -1,49 +0,0 @@
-import React from 'react';
-import Motion from 'flavours/glitch/util/optional_motion';
-import spring from 'react-motion/lib/spring';
-import { defineMessages, FormattedMessage } from 'react-intl';
-
-//  This is the spring used with our motion.
-const motionSpring = spring(1, { damping: 35, stiffness: 400 });
-
-//  Messages.
-const messages = defineMessages({
-  disclaimer: {
-    defaultMessage: 'This toot won\'t be listed under any hashtag as it is unlisted. Only public toots can be searched by hashtag.',
-    id: 'compose_form.hashtag_warning',
-  },
-});
-
-//  The component.
-export default function ComposerHashtagWarning () {
-  return (
-    <Motion
-      defaultStyle={{
-        opacity: 0,
-        scaleX: 0.85,
-        scaleY: 0.75,
-      }}
-      style={{
-        opacity: motionSpring,
-        scaleX: motionSpring,
-        scaleY: motionSpring,
-      }}
-    >
-      {({ opacity, scaleX, scaleY }) => (
-        <div
-          className='composer--warning'
-          style={{
-            opacity: opacity,
-            transform: `scale(${scaleX}, ${scaleY})`,
-          }}
-        >
-          <FormattedMessage
-            {...messages.disclaimer}
-          />
-        </div>
-      )}
-    </Motion>
-  );
-}
-
-ComposerHashtagWarning.propTypes = {};
diff --git a/app/javascript/flavours/glitch/features/composer/index.js b/app/javascript/flavours/glitch/features/composer/index.js
deleted file mode 100644
index 9d2e0b3da..000000000
--- a/app/javascript/flavours/glitch/features/composer/index.js
+++ /dev/null
@@ -1,600 +0,0 @@
-//  Package imports.
-import PropTypes from 'prop-types';
-import React from 'react';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import { defineMessages } from 'react-intl';
-
-const APPROX_HASHTAG_RE = /(?:^|[^\/\)\w])#(\S+)/i;
-
-//  Actions.
-import {
-  cancelReplyCompose,
-  changeCompose,
-  changeComposeAdvancedOption,
-  changeComposeSensitivity,
-  changeComposeSpoilerText,
-  changeComposeSpoilerness,
-  changeComposeVisibility,
-  changeUploadCompose,
-  clearComposeSuggestions,
-  fetchComposeSuggestions,
-  insertEmojiCompose,
-  mountCompose,
-  selectComposeSuggestion,
-  submitCompose,
-  undoUploadCompose,
-  unmountCompose,
-  uploadCompose,
-} from 'flavours/glitch/actions/compose';
-import {
-  closeModal,
-  openModal,
-} from 'flavours/glitch/actions/modal';
-import { changeLocalSetting } from 'flavours/glitch/actions/local_settings';
-import { addPoll, removePoll } from 'flavours/glitch/actions/compose';
-
-//  Components.
-import ComposerOptions from './options';
-import ComposerPublisher from './publisher';
-import ComposerReply from './reply';
-import ComposerSpoiler from './spoiler';
-import ComposerTextarea from './textarea';
-import ComposerUploadForm from './upload_form';
-import ComposerPollForm from './poll_form';
-import ComposerWarning from './warning';
-import ComposerHashtagWarning from './hashtag_warning';
-import ComposerDirectWarning from './direct_warning';
-
-//  Utils.
-import { countableText } from 'flavours/glitch/util/counter';
-import { me } from 'flavours/glitch/util/initial_state';
-import { isMobile } from 'flavours/glitch/util/is_mobile';
-import { assignHandlers } from 'flavours/glitch/util/react_helpers';
-import { wrap } from 'flavours/glitch/util/redux_helpers';
-import { privacyPreference } from 'flavours/glitch/util/privacy_preference';
-
-const messages = defineMessages({
-  missingDescriptionMessage: {  id: 'confirmations.missing_media_description.message',
-                                defaultMessage: 'At least one media attachment is lacking a description. Consider describing all media attachments for the visually impaired before sending your toot.' },
-  missingDescriptionConfirm: {  id: 'confirmations.missing_media_description.confirm',
-                                defaultMessage: 'Send anyway' },
-});
-
-//  State mapping.
-function mapStateToProps (state) {
-  const spoilersAlwaysOn = state.getIn(['local_settings', 'always_show_spoilers_field']);
-  const inReplyTo = state.getIn(['compose', 'in_reply_to']);
-  const replyPrivacy = inReplyTo ? state.getIn(['statuses', inReplyTo, 'visibility']) : null;
-  const sideArmBasePrivacy = state.getIn(['local_settings', 'side_arm']);
-  const sideArmRestrictedPrivacy = replyPrivacy ? privacyPreference(replyPrivacy, sideArmBasePrivacy) : null;
-  let sideArmPrivacy = null;
-  switch (state.getIn(['local_settings', 'side_arm_reply_mode'])) {
-    case 'copy':
-      sideArmPrivacy = replyPrivacy;
-      break;
-    case 'restrict':
-      sideArmPrivacy = sideArmRestrictedPrivacy;
-      break;
-  }
-  sideArmPrivacy = sideArmPrivacy || sideArmBasePrivacy;
-  return {
-    acceptContentTypes: state.getIn(['media_attachments', 'accept_content_types']).toArray().join(','),
-    advancedOptions: state.getIn(['compose', 'advanced_options']),
-    amUnlocked: !state.getIn(['accounts', me, 'locked']),
-    focusDate: state.getIn(['compose', 'focusDate']),
-    caretPosition: state.getIn(['compose', 'caretPosition']),
-    isSubmitting: state.getIn(['compose', 'is_submitting']),
-    isChangingUpload: state.getIn(['compose', 'is_changing_upload']),
-    isUploading: state.getIn(['compose', 'is_uploading']),
-    layout: state.getIn(['local_settings', 'layout']),
-    media: state.getIn(['compose', 'media_attachments']),
-    preselectDate: state.getIn(['compose', 'preselectDate']),
-    privacy: state.getIn(['compose', 'privacy']),
-    progress: state.getIn(['compose', 'progress']),
-    inReplyTo: inReplyTo ? state.getIn(['statuses', inReplyTo]) : null,
-    replyAccount: inReplyTo ? state.getIn(['statuses', inReplyTo, 'account']) : null,
-    replyContent: inReplyTo ? state.getIn(['statuses', inReplyTo, 'contentHtml']) : null,
-    resetFileKey: state.getIn(['compose', 'resetFileKey']),
-    sideArm: sideArmPrivacy,
-    sensitive: state.getIn(['compose', 'sensitive']),
-    showSearch: state.getIn(['search', 'submitted']) && !state.getIn(['search', 'hidden']),
-    spoiler: spoilersAlwaysOn || state.getIn(['compose', 'spoiler']),
-    spoilerText: state.getIn(['compose', 'spoiler_text']),
-    suggestionToken: state.getIn(['compose', 'suggestion_token']),
-    suggestions: state.getIn(['compose', 'suggestions']),
-    text: state.getIn(['compose', 'text']),
-    anyMedia: state.getIn(['compose', 'media_attachments']).size > 0,
-    poll: state.getIn(['compose', 'poll']),
-    spoilersAlwaysOn: spoilersAlwaysOn,
-    mediaDescriptionConfirmation: state.getIn(['local_settings', 'confirm_missing_media_description']),
-    preselectOnReply: state.getIn(['local_settings', 'preselect_on_reply']),
-  };
-};
-
-//  Dispatch mapping.
-const mapDispatchToProps = (dispatch, { intl }) => ({
-  onCancelReply() {
-    dispatch(cancelReplyCompose());
-  },
-  onChangeAdvancedOption(option, value) {
-    dispatch(changeComposeAdvancedOption(option, value));
-  },
-  onChangeDescription(id, description) {
-    dispatch(changeUploadCompose(id, { description }));
-  },
-  onChangeSensitivity() {
-    dispatch(changeComposeSensitivity());
-  },
-  onChangeSpoilerText(text) {
-    dispatch(changeComposeSpoilerText(text));
-  },
-  onChangeSpoilerness() {
-    dispatch(changeComposeSpoilerness());
-  },
-  onChangeText(text) {
-    dispatch(changeCompose(text));
-  },
-  onChangeVisibility(value) {
-    dispatch(changeComposeVisibility(value));
-  },
-  onTogglePoll() {
-    dispatch((_, getState) => {
-      if (getState().getIn(['compose', 'poll'])) {
-        dispatch(removePoll());
-      } else {
-        dispatch(addPoll());
-      }
-    });
-  },
-  onClearSuggestions() {
-    dispatch(clearComposeSuggestions());
-  },
-  onCloseModal() {
-    dispatch(closeModal());
-  },
-  onFetchSuggestions(token) {
-    dispatch(fetchComposeSuggestions(token));
-  },
-  onInsertEmoji(position, emoji) {
-    dispatch(insertEmojiCompose(position, emoji));
-  },
-  onMount() {
-    dispatch(mountCompose());
-  },
-  onOpenActionsModal(props) {
-    dispatch(openModal('ACTIONS', props));
-  },
-  onOpenDoodleModal() {
-    dispatch(openModal('DOODLE', { noEsc: true }));
-  },
-  onOpenFocalPointModal(id) {
-    dispatch(openModal('FOCAL_POINT', { id }));
-  },
-  onSelectSuggestion(position, token, suggestion) {
-    dispatch(selectComposeSuggestion(position, token, suggestion));
-  },
-  onMediaDescriptionConfirm(routerHistory) {
-    dispatch(openModal('CONFIRM', {
-      message: intl.formatMessage(messages.missingDescriptionMessage),
-      confirm: intl.formatMessage(messages.missingDescriptionConfirm),
-      onConfirm: () => dispatch(submitCompose(routerHistory)),
-      onDoNotAsk: () => dispatch(changeLocalSetting(['confirm_missing_media_description'], false)),
-    }));
-  },
-  onSubmit(routerHistory) {
-    dispatch(submitCompose(routerHistory));
-  },
-  onUndoUpload(id) {
-    dispatch(undoUploadCompose(id));
-  },
-  onUnmount() {
-    dispatch(unmountCompose());
-  },
-  onUpload(files) {
-    dispatch(uploadCompose(files));
-  },
-});
-
-//  Handlers.
-const handlers = {
-
-  //  Changes the text value of the spoiler.
-  handleChangeSpoiler ({ target: { value } }) {
-    const { onChangeSpoilerText } = this.props;
-    if (onChangeSpoilerText) {
-      onChangeSpoilerText(value);
-    }
-  },
-
-  //  Inserts an emoji at the caret.
-  handleEmoji (data) {
-    const { textarea: { selectionStart } } = this;
-    const { onInsertEmoji } = this.props;
-    if (onInsertEmoji) {
-      onInsertEmoji(selectionStart, data);
-    }
-  },
-
-  //  Handles the secondary submit button.
-  handleSecondarySubmit () {
-    const { handleSubmit } = this.handlers;
-    const {
-      onChangeVisibility,
-      sideArm,
-    } = this.props;
-    if (sideArm !== 'none' && onChangeVisibility) {
-      onChangeVisibility(sideArm);
-    }
-    handleSubmit();
-  },
-
-  //  Selects a suggestion from the autofill.
-  handleSelect (tokenStart, token, value) {
-    const { onSelectSuggestion } = this.props;
-    if (onSelectSuggestion) {
-      onSelectSuggestion(tokenStart, token, value);
-    }
-  },
-
-  //  Submits the status.
-  handleSubmit () {
-    const { textarea: { value }, uploadForm } = this;
-    const {
-      onChangeText,
-      onSubmit,
-      isSubmitting,
-      isChangingUpload,
-      isUploading,
-      media,
-      anyMedia,
-      text,
-      mediaDescriptionConfirmation,
-      onMediaDescriptionConfirm,
-    } = this.props;
-
-    //  If something changes inside the textarea, then we update the
-    //  state before submitting.
-    if (onChangeText && text !== value) {
-      onChangeText(value);
-    }
-
-    // Submit disabled:
-    if (isSubmitting || isUploading || isChangingUpload || (!text.trim().length && !anyMedia)) {
-      return;
-    }
-
-    // Submit unless there are media with missing descriptions
-    if (mediaDescriptionConfirmation && onMediaDescriptionConfirm && media && media.some(item => !item.get('description'))) {
-      const firstWithoutDescription = media.findIndex(item => !item.get('description'));
-      if (uploadForm) {
-        const inputs = uploadForm.querySelectorAll('.composer--upload_form--item input');
-        if (inputs.length == media.size && firstWithoutDescription !== -1) {
-          inputs[firstWithoutDescription].focus();
-        }
-      }
-      onMediaDescriptionConfirm(this.context.router ? this.context.router.history : null);
-    } else if (onSubmit) {
-      onSubmit(this.context.router ? this.context.router.history : null);
-    }
-  },
-
-  //  Sets a reference to the upload form.
-  handleRefUploadForm (uploadFormComponent) {
-    this.uploadForm = uploadFormComponent;
-  },
-
-  //  Sets a reference to the textarea.
-  handleRefTextarea (textareaComponent) {
-    if (textareaComponent) {
-      this.textarea = textareaComponent.textarea;
-    }
-  },
-
-  //  Sets a reference to the CW field.
-  handleRefSpoilerText (spoilerComponent) {
-    if (spoilerComponent) {
-      this.spoilerText = spoilerComponent.spoilerText;
-    }
-  }
-};
-
-//  The component.
-class Composer extends React.Component {
-
-  //  Constructor.
-  constructor (props) {
-    super(props);
-    assignHandlers(this, handlers);
-
-    //  Instance variables.
-    this.textarea = null;
-    this.spoilerText = null;
-  }
-
-  //  Tells our state the composer has been mounted.
-  componentDidMount () {
-    const { onMount } = this.props;
-    if (onMount) {
-      onMount();
-    }
-  }
-
-  //  Tells our state the composer has been unmounted.
-  componentWillUnmount () {
-    const { onUnmount } = this.props;
-    if (onUnmount) {
-      onUnmount();
-    }
-  }
-
-  //  This statement does several things:
-  //  - If we're beginning a reply, and,
-  //      - Replying to zero or one users, places the cursor at the end
-  //        of the textbox.
-  //      - Replying to more than one user, selects any usernames past
-  //        the first; this provides a convenient shortcut to drop
-  //        everyone else from the conversation.
-  componentDidUpdate (prevProps) {
-    const {
-      textarea,
-      spoilerText,
-    } = this;
-    const {
-      focusDate,
-      caretPosition,
-      isSubmitting,
-      preselectDate,
-      text,
-      preselectOnReply,
-    } = this.props;
-    let selectionEnd, selectionStart;
-
-    //  Caret/selection handling.
-    if (focusDate !== prevProps.focusDate) {
-      switch (true) {
-      case preselectDate !== prevProps.preselectDate && preselectOnReply:
-        selectionStart = text.search(/\s/) + 1;
-        selectionEnd = text.length;
-        break;
-      case !isNaN(caretPosition) && caretPosition !== null:
-        selectionStart = selectionEnd = caretPosition;
-        break;
-      default:
-        selectionStart = selectionEnd = text.length;
-      }
-      if (textarea) {
-        textarea.setSelectionRange(selectionStart, selectionEnd);
-        textarea.focus();
-        textarea.scrollIntoView();
-      }
-
-    //  Refocuses the textarea after submitting.
-    } else if (textarea && prevProps.isSubmitting && !isSubmitting) {
-      textarea.focus();
-    } else if (this.props.spoiler !== prevProps.spoiler) {
-      if (this.props.spoiler) {
-        if (spoilerText) {
-          spoilerText.focus();
-        }
-      } else {
-        if (textarea) {
-          textarea.focus();
-        }
-      }
-    }
-  }
-
-  render () {
-    const {
-      handleChangeSpoiler,
-      handleEmoji,
-      handleSecondarySubmit,
-      handleSelect,
-      handleSubmit,
-      handleRefUploadForm,
-      handleRefTextarea,
-      handleRefSpoilerText,
-    } = this.handlers;
-    const {
-      acceptContentTypes,
-      advancedOptions,
-      amUnlocked,
-      anyMedia,
-      intl,
-      isSubmitting,
-      isChangingUpload,
-      isUploading,
-      layout,
-      media,
-      poll,
-      onCancelReply,
-      onChangeAdvancedOption,
-      onChangeDescription,
-      onChangeSensitivity,
-      onChangeSpoilerness,
-      onChangeText,
-      onChangeVisibility,
-      onTogglePoll,
-      onClearSuggestions,
-      onCloseModal,
-      onFetchSuggestions,
-      onOpenActionsModal,
-      onOpenDoodleModal,
-      onOpenFocalPointModal,
-      onUndoUpload,
-      onUpload,
-      privacy,
-      progress,
-      inReplyTo,
-      resetFileKey,
-      sensitive,
-      showSearch,
-      sideArm,
-      spoiler,
-      spoilerText,
-      suggestions,
-      text,
-      spoilersAlwaysOn,
-    } = this.props;
-
-    let disabledButton = isSubmitting || isUploading || isChangingUpload || (!text.trim().length && !anyMedia);
-
-    return (
-      <div className='composer'>
-        {privacy === 'direct' ? <ComposerDirectWarning /> : null}
-        {privacy === 'private' && amUnlocked ? <ComposerWarning /> : null}
-        {privacy !== 'public' && APPROX_HASHTAG_RE.test(text) ? <ComposerHashtagWarning /> : null}
-        {inReplyTo && (
-          <ComposerReply
-            status={inReplyTo}
-            intl={intl}
-            onCancel={onCancelReply}
-          />
-        )}
-        <ComposerSpoiler
-          hidden={!spoiler}
-          intl={intl}
-          onChange={handleChangeSpoiler}
-          onSubmit={handleSubmit}
-          onSecondarySubmit={handleSecondarySubmit}
-          text={spoilerText}
-          ref={handleRefSpoilerText}
-        />
-        <ComposerTextarea
-          advancedOptions={advancedOptions}
-          autoFocus={!showSearch && !isMobile(window.innerWidth, layout)}
-          disabled={isSubmitting}
-          intl={intl}
-          onChange={onChangeText}
-          onPaste={onUpload}
-          onPickEmoji={handleEmoji}
-          onSubmit={handleSubmit}
-          onSecondarySubmit={handleSecondarySubmit}
-          onSuggestionsClearRequested={onClearSuggestions}
-          onSuggestionsFetchRequested={onFetchSuggestions}
-          onSuggestionSelected={handleSelect}
-          ref={handleRefTextarea}
-          suggestions={suggestions}
-          value={text}
-        />
-        <div className='compose-form__modifiers'>
-          {isUploading || media && media.size ? (
-            <ComposerUploadForm
-              intl={intl}
-              media={media}
-              onChangeDescription={onChangeDescription}
-              onOpenFocalPointModal={onOpenFocalPointModal}
-              onRemove={onUndoUpload}
-              progress={progress}
-              uploading={isUploading}
-              handleRef={handleRefUploadForm}
-            />
-          ) : null}
-          {!!poll && (
-            <ComposerPollForm />
-          )}
-        </div>
-        <ComposerOptions
-          acceptContentTypes={acceptContentTypes}
-          advancedOptions={advancedOptions}
-          disabled={isSubmitting}
-          allowMedia={!poll && (media ? media.size < 4 && !media.some(
-              item => item.get('type') === 'video'
-            ) : true)}
-          hasMedia={media && !!media.size}
-          allowPoll={!(media && !!media.size)}
-          hasPoll={!!poll}
-          intl={intl}
-          onChangeAdvancedOption={onChangeAdvancedOption}
-          onChangeSensitivity={onChangeSensitivity}
-          onChangeVisibility={onChangeVisibility}
-          onTogglePoll={onTogglePoll}
-          onDoodleOpen={onOpenDoodleModal}
-          onModalClose={onCloseModal}
-          onModalOpen={onOpenActionsModal}
-          onToggleSpoiler={spoilersAlwaysOn ? null : onChangeSpoilerness}
-          onUpload={onUpload}
-          privacy={privacy}
-          resetFileKey={resetFileKey}
-          sensitive={sensitive || (spoilersAlwaysOn && spoilerText && spoilerText.length > 0)}
-          spoiler={spoilersAlwaysOn ? (spoilerText && spoilerText.length > 0) : spoiler}
-        />
-        <ComposerPublisher
-          countText={`${spoilerText}${countableText(text)}${advancedOptions && advancedOptions.get('do_not_federate') ? ' 👁️' : ''}`}
-          disabled={disabledButton}
-          intl={intl}
-          onSecondarySubmit={handleSecondarySubmit}
-          onSubmit={handleSubmit}
-          privacy={privacy}
-          sideArm={sideArm}
-        />
-      </div>
-    );
-  }
-
-}
-
-//  Props.
-Composer.propTypes = {
-  intl: PropTypes.object.isRequired,
-
-  //  State props.
-  acceptContentTypes: PropTypes.string,
-  advancedOptions: ImmutablePropTypes.map,
-  amUnlocked: PropTypes.bool,
-  focusDate: PropTypes.instanceOf(Date),
-  caretPosition: PropTypes.number,
-  isSubmitting: PropTypes.bool,
-  isChangingUpload: PropTypes.bool,
-  isUploading: PropTypes.bool,
-  layout: PropTypes.string,
-  media: ImmutablePropTypes.list,
-  preselectDate: PropTypes.instanceOf(Date),
-  privacy: PropTypes.string,
-  progress: PropTypes.number,
-  inReplyTo: ImmutablePropTypes.map,
-  resetFileKey: PropTypes.number,
-  sideArm: PropTypes.string,
-  sensitive: PropTypes.bool,
-  showSearch: PropTypes.bool,
-  spoiler: PropTypes.bool,
-  spoilerText: PropTypes.string,
-  suggestionToken: PropTypes.string,
-  suggestions: ImmutablePropTypes.list,
-  text: PropTypes.string,
-  anyMedia: PropTypes.bool,
-  spoilersAlwaysOn: PropTypes.bool,
-  mediaDescriptionConfirmation: PropTypes.bool,
-  preselectOnReply: PropTypes.bool,
-
-  //  Dispatch props.
-  onCancelReply: PropTypes.func,
-  onChangeAdvancedOption: PropTypes.func,
-  onChangeDescription: PropTypes.func,
-  onChangeSensitivity: PropTypes.func,
-  onChangeSpoilerText: PropTypes.func,
-  onChangeSpoilerness: PropTypes.func,
-  onChangeText: PropTypes.func,
-  onChangeVisibility: PropTypes.func,
-  onClearSuggestions: PropTypes.func,
-  onCloseModal: PropTypes.func,
-  onFetchSuggestions: PropTypes.func,
-  onInsertEmoji: PropTypes.func,
-  onMount: PropTypes.func,
-  onOpenActionsModal: PropTypes.func,
-  onOpenDoodleModal: PropTypes.func,
-  onSelectSuggestion: PropTypes.func,
-  onSubmit: PropTypes.func,
-  onUndoUpload: PropTypes.func,
-  onUnmount: PropTypes.func,
-  onUpload: PropTypes.func,
-  onMediaDescriptionConfirm: PropTypes.func,
-};
-
-Composer.contextTypes = {
-  router: PropTypes.object,
-};
-
-//  Connecting and export.
-export { Composer as WrappedComponent };
-export default wrap(Composer, mapStateToProps, mapDispatchToProps, true);
diff --git a/app/javascript/flavours/glitch/features/composer/options/dropdown/content/index.js b/app/javascript/flavours/glitch/features/composer/options/dropdown/content/index.js
deleted file mode 100644
index b76410561..000000000
--- a/app/javascript/flavours/glitch/features/composer/options/dropdown/content/index.js
+++ /dev/null
@@ -1,146 +0,0 @@
-//  Package imports.
-import PropTypes from 'prop-types';
-import React from 'react';
-import spring from 'react-motion/lib/spring';
-
-//  Components.
-import ComposerOptionsDropdownContentItem from './item';
-
-//  Utils.
-import { withPassive } from 'flavours/glitch/util/dom_helpers';
-import Motion from 'flavours/glitch/util/optional_motion';
-import { assignHandlers } from 'flavours/glitch/util/react_helpers';
-
-//  Handlers.
-const handlers = {
-  //  When the document is clicked elsewhere, we close the dropdown.
-  handleDocumentClick ({ target }) {
-    const { node } = this;
-    const { onClose } = this.props;
-    if (onClose && node && !node.contains(target)) {
-      onClose();
-    }
-  },
-
-  //  Stores our node in `this.node`.
-  handleRef (node) {
-    this.node = node;
-  },
-};
-
-//  The spring to use with our motion.
-const springMotion = spring(1, {
-  damping: 35,
-  stiffness: 400,
-});
-
-//  The component.
-export default class ComposerOptionsDropdownContent extends React.PureComponent {
-
-  //  Constructor.
-  constructor (props) {
-    super(props);
-    assignHandlers(this, handlers);
-
-    //  Instance variables.
-    this.node = null;
-
-    this.state = {
-      mounted: false,
-    };
-  }
-
-  //  On mounting, we add our listeners.
-  componentDidMount () {
-    const { handleDocumentClick } = this.handlers;
-    document.addEventListener('click', handleDocumentClick, false);
-    document.addEventListener('touchend', handleDocumentClick, withPassive);
-    this.setState({ mounted: true });
-  }
-
-  //  On unmounting, we remove our listeners.
-  componentWillUnmount () {
-    const { handleDocumentClick } = this.handlers;
-    document.removeEventListener('click', handleDocumentClick, false);
-    document.removeEventListener('touchend', handleDocumentClick, withPassive);
-  }
-
-  //  Rendering.
-  render () {
-    const { mounted } = this.state;
-    const { handleRef } = this.handlers;
-    const {
-      items,
-      onChange,
-      onClose,
-      style,
-      value,
-    } = this.props;
-
-    //  The result.
-    return (
-      <Motion
-        defaultStyle={{
-          opacity: 0,
-          scaleX: 0.85,
-          scaleY: 0.75,
-        }}
-        style={{
-          opacity: springMotion,
-          scaleX: springMotion,
-          scaleY: springMotion,
-        }}
-      >
-        {({ opacity, scaleX, scaleY }) => (
-          // It should not be transformed when mounting because the resulting
-          // size will be used to determine the coordinate of the menu by
-          // react-overlays
-          <div
-            className='composer--options--dropdown--content'
-            ref={handleRef}
-            style={{
-              ...style,
-              opacity: opacity,
-              transform: mounted ? `scale(${scaleX}, ${scaleY})` : null,
-            }}
-          >
-            {items ? items.map(
-              ({
-                name,
-                ...rest
-              }) => (
-                <ComposerOptionsDropdownContentItem
-                  active={name === value}
-                  key={name}
-                  name={name}
-                  onChange={onChange}
-                  onClose={onClose}
-                  options={rest}
-                />
-              )
-            ) : null}
-          </div>
-        )}
-      </Motion>
-    );
-  }
-
-}
-
-//  Props.
-ComposerOptionsDropdownContent.propTypes = {
-  items: PropTypes.arrayOf(PropTypes.shape({
-    icon: PropTypes.string,
-    meta: PropTypes.node,
-    name: PropTypes.string.isRequired,
-    on: PropTypes.bool,
-    text: PropTypes.node,
-  })),
-  onChange: PropTypes.func,
-  onClose: PropTypes.func,
-  style: PropTypes.object,
-  value: PropTypes.string,
-};
-
-//  Default props.
-ComposerOptionsDropdownContent.defaultProps = { style: {} };
diff --git a/app/javascript/flavours/glitch/features/composer/options/dropdown/content/item/index.js b/app/javascript/flavours/glitch/features/composer/options/dropdown/content/item/index.js
deleted file mode 100644
index 68a52083f..000000000
--- a/app/javascript/flavours/glitch/features/composer/options/dropdown/content/item/index.js
+++ /dev/null
@@ -1,129 +0,0 @@
-//  Package imports.
-import classNames from 'classnames';
-import PropTypes from 'prop-types';
-import React from 'react';
-import Toggle from 'react-toggle';
-
-//  Components.
-import Icon from 'flavours/glitch/components/icon';
-
-//  Utils.
-import { assignHandlers } from 'flavours/glitch/util/react_helpers';
-
-//  Handlers.
-const handlers = {
-
-  //  This function activates the dropdown item.
-  handleActivate (e) {
-    const {
-      name,
-      onChange,
-      onClose,
-      options: { on },
-    } = this.props;
-
-    //  If the escape key was pressed, we close the dropdown.
-    if (e.key === 'Escape' && onClose) {
-      onClose();
-
-    //  Otherwise, we both close the dropdown and change the value.
-    } else if (onChange && (!e.key || e.key === 'Enter')) {
-      e.preventDefault();  //  Prevents change in focus on click
-      if ((on === null || typeof on === 'undefined') && onClose) {
-        onClose();
-      }
-      onChange(name);
-    }
-  },
-};
-
-//  The component.
-export default class ComposerOptionsDropdownContentItem extends React.PureComponent {
-
-  //  Constructor.
-  constructor (props) {
-    super(props);
-    assignHandlers(this, handlers);
-  }
-
-  //  Rendering.
-  render () {
-    const { handleActivate } = this.handlers;
-    const {
-      active,
-      options: {
-        icon,
-        meta,
-        on,
-        text,
-      },
-    } = this.props;
-    const computedClass = classNames('composer--options--dropdown--content--item', {
-      active,
-      lengthy: meta,
-      'toggled-off': !on && on !== null && typeof on !== 'undefined',
-      'toggled-on': on,
-      'with-icon': icon,
-    });
-
-    //  The result.
-    return (
-      <div
-        className={computedClass}
-        onClick={handleActivate}
-        onKeyDown={handleActivate}
-        role='button'
-        tabIndex='0'
-      >
-        {function () {
-
-          //  We render a `<Toggle>` if we were provided an `on`
-          //  property, and otherwise show an `<Icon>` if available.
-          switch (true) {
-          case on !== null && typeof on !== 'undefined':
-            return (
-              <Toggle
-                checked={on}
-                onChange={handleActivate}
-              />
-            );
-          case !!icon:
-            return (
-              <Icon
-                className='icon'
-                fullwidth
-                icon={icon}
-              />
-            );
-          default:
-            return null;
-          }
-        }()}
-        {meta ? (
-          <div className='content'>
-            <strong>{text}</strong>
-            {meta}
-          </div>
-        ) :
-          <div className='content'>
-            <strong>{text}</strong>
-          </div>}
-      </div>
-    );
-  }
-
-};
-
-//  Props.
-ComposerOptionsDropdownContentItem.propTypes = {
-  active: PropTypes.bool,
-  name: PropTypes.string,
-  onChange: PropTypes.func,
-  onClose: PropTypes.func,
-  options: PropTypes.shape({
-    icon: PropTypes.string,
-    meta: PropTypes.node,
-    on: PropTypes.bool,
-    text: PropTypes.node,
-  }),
-};
diff --git a/app/javascript/flavours/glitch/features/composer/publisher/index.js b/app/javascript/flavours/glitch/features/composer/publisher/index.js
deleted file mode 100644
index dc9c8f8eb..000000000
--- a/app/javascript/flavours/glitch/features/composer/publisher/index.js
+++ /dev/null
@@ -1,122 +0,0 @@
-//  Package imports.
-import classNames from 'classnames';
-import PropTypes from 'prop-types';
-import React from 'react';
-import {
-  defineMessages,
-  FormattedMessage,
-} from 'react-intl';
-import { length } from 'stringz';
-
-//  Components.
-import Button from 'flavours/glitch/components/button';
-import Icon from 'flavours/glitch/components/icon';
-
-//  Utils.
-import { maxChars } from 'flavours/glitch/util/initial_state';
-
-//  Messages.
-const messages = defineMessages({
-  publish: {
-    defaultMessage: 'Toot',
-    id: 'compose_form.publish',
-  },
-  publishLoud: {
-    defaultMessage: '{publish}!',
-    id: 'compose_form.publish_loud',
-  },
-});
-
-//  The component.
-export default function ComposerPublisher ({
-  countText,
-  disabled,
-  intl,
-  onSecondarySubmit,
-  onSubmit,
-  privacy,
-  sideArm,
-}) {
-  const diff = maxChars - length(countText || '');
-  const computedClass = classNames('composer--publisher', {
-    disabled: disabled || diff < 0,
-    over: diff < 0,
-  });
-
-  //  The result.
-  return (
-    <div className={computedClass}>
-      <span className='count'>{diff}</span>
-      {sideArm && sideArm !== 'none' ? (
-        <Button
-          className='side_arm'
-          disabled={disabled || diff < 0}
-          onClick={onSecondarySubmit}
-          style={{ padding: null }}
-          text={
-            <span>
-              <Icon
-                icon={{
-                  public: 'globe',
-                  unlisted: 'unlock',
-                  private: 'lock',
-                  direct: 'envelope',
-                }[sideArm]}
-              />
-            </span>
-          }
-          title={`${intl.formatMessage(messages.publish)}: ${intl.formatMessage({ id: `privacy.${sideArm}.short` })}`}
-        />
-      ) : null}
-      <Button
-        className='primary'
-        text={function () {
-          switch (true) {
-          case !!sideArm && sideArm !== 'none':
-          case privacy === 'direct':
-          case privacy === 'private':
-            return (
-              <span>
-                <Icon
-                  icon={{
-                    direct: 'envelope',
-                    private: 'lock',
-                    public: 'globe',
-                    unlisted: 'unlock',
-                  }[privacy]}
-                />
-                {' '}
-                <FormattedMessage {...messages.publish} />
-              </span>
-            );
-          case privacy === 'public':
-            return (
-              <span>
-                <FormattedMessage
-                  {...messages.publishLoud}
-                  values={{ publish: <FormattedMessage {...messages.publish} /> }}
-                />
-              </span>
-            );
-          default:
-            return <span><FormattedMessage {...messages.publish} /></span>;
-          }
-        }()}
-        title={`${intl.formatMessage(messages.publish)}: ${intl.formatMessage({ id: `privacy.${privacy}.short` })}`}
-        onClick={onSubmit}
-        disabled={disabled || diff < 0}
-      />
-    </div>
-  );
-}
-
-//  Props.
-ComposerPublisher.propTypes = {
-  countText: PropTypes.string,
-  disabled: PropTypes.bool,
-  intl: PropTypes.object.isRequired,
-  onSecondarySubmit: PropTypes.func,
-  onSubmit: PropTypes.func,
-  privacy: PropTypes.oneOf(['direct', 'private', 'unlisted', 'public']),
-  sideArm: PropTypes.oneOf(['none', 'direct', 'private', 'unlisted', 'public']),
-};
diff --git a/app/javascript/flavours/glitch/features/composer/spoiler/index.js b/app/javascript/flavours/glitch/features/composer/spoiler/index.js
deleted file mode 100644
index e2f9c7021..000000000
--- a/app/javascript/flavours/glitch/features/composer/spoiler/index.js
+++ /dev/null
@@ -1,107 +0,0 @@
-//  Package imports.
-import React from 'react';
-import PropTypes from 'prop-types';
-import { defineMessages, FormattedMessage } from 'react-intl';
-
-//  Utils.
-import {
-  assignHandlers,
-  hiddenComponent,
-} from 'flavours/glitch/util/react_helpers';
-
-//  Messages.
-const messages = defineMessages({
-  placeholder: {
-    defaultMessage: 'Write your warning here',
-    id: 'compose_form.spoiler_placeholder',
-  },
-});
-
-//  Handlers.
-const handlers = {
-
-  //  Handles a keypress.
-  handleKeyDown ({
-    ctrlKey,
-    keyCode,
-    metaKey,
-    altKey,
-  }) {
-    const { onSubmit, onSecondarySubmit } = this.props;
-
-    //  We submit the status on control/meta + enter.
-    if (onSubmit && keyCode === 13 && (ctrlKey || metaKey)) {
-      onSubmit();
-    }
-
-    // Submit the status with secondary visibility on alt + enter.
-    if (onSecondarySubmit && keyCode === 13 && altKey) {
-      onSecondarySubmit();
-    }
-  },
-
-  handleRefSpoilerText (spoilerText) {
-    this.spoilerText = spoilerText;
-  },
-
-  //  When the escape key is released, we focus the UI.
-  handleKeyUp ({ key }) {
-    if (key === 'Escape') {
-      document.querySelector('.ui').parentElement.focus();
-    }
-  },
-};
-
-//  The component.
-export default class ComposerSpoiler extends React.PureComponent {
-
-  //  Constructor.
-  constructor (props) {
-    super(props);
-    assignHandlers(this, handlers);
-  }
-
-  //  Rendering.
-  render () {
-    const { handleKeyDown, handleKeyUp, handleRefSpoilerText } = this.handlers;
-    const {
-      hidden,
-      intl,
-      onChange,
-      text,
-    } = this.props;
-
-    //  The result.
-    return (
-      <div className={`composer--spoiler ${hidden ? '' : 'composer--spoiler--visible'}`}>
-        <label>
-          <span {...hiddenComponent}>
-            <FormattedMessage {...messages.placeholder} />
-          </span>
-          <input
-            id='glitch.composer.spoiler.input'
-            onChange={onChange}
-            onKeyDown={handleKeyDown}
-            onKeyUp={handleKeyUp}
-            placeholder={intl.formatMessage(messages.placeholder)}
-            type='text'
-            value={text}
-            ref={handleRefSpoilerText}
-            disabled={hidden}
-          />
-        </label>
-      </div>
-    );
-  }
-
-}
-
-//  Props.
-ComposerSpoiler.propTypes = {
-  hidden: PropTypes.bool,
-  intl: PropTypes.object.isRequired,
-  onChange: PropTypes.func,
-  onSubmit: PropTypes.func,
-  onSecondarySubmit: PropTypes.func,
-  text: PropTypes.string,
-};
diff --git a/app/javascript/flavours/glitch/features/composer/textarea/icons/index.js b/app/javascript/flavours/glitch/features/composer/textarea/icons/index.js
deleted file mode 100644
index 049cdd5cd..000000000
--- a/app/javascript/flavours/glitch/features/composer/textarea/icons/index.js
+++ /dev/null
@@ -1,60 +0,0 @@
-//  Package imports.
-import PropTypes from 'prop-types';
-import React from 'react';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import { defineMessages } from 'react-intl';
-
-//  Components.
-import Icon from 'flavours/glitch/components/icon';
-
-//  Messages.
-const messages = defineMessages({
-  localOnly: {
-    defaultMessage: 'This post is local-only',
-    id: 'advanced_options.local-only.tooltip',
-  },
-  threadedMode: {
-    defaultMessage: 'Threaded mode enabled',
-    id: 'advanced_options.threaded_mode.tooltip',
-  },
-});
-
-//  We use an array of tuples here instead of an object because it
-//  preserves order.
-const iconMap = [
-  ['do_not_federate', 'home', messages.localOnly],
-  ['threaded_mode', 'comments', messages.threadedMode],
-];
-
-//  The component.
-export default function ComposerTextareaIcons ({
-  advancedOptions,
-  intl,
-}) {
-
-  //  The result. We just map every active option to its icon.
-  return (
-    <div className='composer--textarea--icons'>
-      {advancedOptions ? iconMap.map(
-        ([key, icon, message]) => advancedOptions.get(key) ? (
-          <span
-            className='textarea_icon'
-            key={key}
-            title={intl.formatMessage(message)}
-          >
-            <Icon
-              fullwidth
-              icon={icon}
-            />
-          </span>
-        ) : null
-      ) : null}
-    </div>
-  );
-}
-
-//  Props.
-ComposerTextareaIcons.propTypes = {
-  advancedOptions: ImmutablePropTypes.map,
-  intl: PropTypes.object.isRequired,
-};
diff --git a/app/javascript/flavours/glitch/features/composer/textarea/index.js b/app/javascript/flavours/glitch/features/composer/textarea/index.js
deleted file mode 100644
index 50e46fc78..000000000
--- a/app/javascript/flavours/glitch/features/composer/textarea/index.js
+++ /dev/null
@@ -1,312 +0,0 @@
-//  Package imports.
-import PropTypes from 'prop-types';
-import React from 'react';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import {
-  defineMessages,
-  FormattedMessage,
-} from 'react-intl';
-import Textarea from 'react-textarea-autosize';
-
-//  Components.
-import EmojiPicker from 'flavours/glitch/features/emoji_picker';
-import ComposerTextareaIcons from './icons';
-import ComposerTextareaSuggestions from './suggestions';
-
-//  Utils.
-import { isRtl } from 'flavours/glitch/util/rtl';
-import {
-  assignHandlers,
-  hiddenComponent,
-} from 'flavours/glitch/util/react_helpers';
-
-//  Messages.
-const messages = defineMessages({
-  placeholder: {
-    defaultMessage: 'What is on your mind?',
-    id: 'compose_form.placeholder',
-  },
-});
-
-//  Handlers.
-const handlers = {
-
-  //  When blurring the textarea, suggestions are hidden.
-  handleBlur () {
-    this.setState({ suggestionsHidden: true });
-  },
-
-  //  When the contents of the textarea change, we have to pull up new
-  //  autosuggest suggestions if applicable, and also change the value
-  //  of the textarea in our store.
-  handleChange ({
-    target: {
-      selectionStart,
-      value,
-    },
-  }) {
-    const {
-      onChange,
-      onSuggestionsFetchRequested,
-      onSuggestionsClearRequested,
-    } = this.props;
-    const { lastToken } = this.state;
-
-    //  This gets the token at the caret location, if it begins with an
-    //  `@` (mentions) or `:` (shortcodes).
-    const left = value.slice(0, selectionStart).search(/[^\s\u200B]+$/);
-    const right = value.slice(selectionStart).search(/[\s\u200B]/);
-    const token = function () {
-      switch (true) {
-      case left < 0 || !/[@:#]/.test(value[left]):
-        return null;
-      case right < 0:
-        return value.slice(left);
-      default:
-        return value.slice(left, right + selectionStart).trim().toLowerCase();
-      }
-    }();
-
-    //  We only request suggestions for tokens which are at least 3
-    //  characters long.
-    if (onSuggestionsFetchRequested && token && token.length >= 3) {
-      if (lastToken !== token) {
-        this.setState({
-          lastToken: token,
-          selectedSuggestion: 0,
-          tokenStart: left,
-        });
-        onSuggestionsFetchRequested(token);
-      }
-    } else {
-      this.setState({ lastToken: null });
-      if (onSuggestionsClearRequested) {
-        onSuggestionsClearRequested();
-      }
-    }
-
-    //  Updates the value of the textarea.
-    if (onChange) {
-      onChange(value);
-    }
-  },
-
-  //  Handles a click on an autosuggestion.
-  handleClickSuggestion (index) {
-    const { textarea } = this;
-    const {
-      onSuggestionSelected,
-      suggestions,
-    } = this.props;
-    const {
-      lastToken,
-      tokenStart,
-    } = this.state;
-    onSuggestionSelected(tokenStart, lastToken, suggestions.get(index));
-    textarea.focus();
-  },
-
-  //  Handles a keypress.  If the autosuggestions are visible, we need
-  //  to allow keypresses to navigate and sleect them.
-  handleKeyDown (e) {
-    const {
-      disabled,
-      onSubmit,
-      onSecondarySubmit,
-      onSuggestionSelected,
-      suggestions,
-    } = this.props;
-    const {
-      lastToken,
-      suggestionsHidden,
-      selectedSuggestion,
-      tokenStart,
-    } = this.state;
-
-    //  Keypresses do nothing if the composer is disabled.
-    if (disabled) {
-      e.preventDefault();
-      return;
-    }
-
-    //  We submit the status on control/meta + enter.
-    if (onSubmit && e.keyCode === 13 && (e.ctrlKey || e.metaKey)) {
-      onSubmit();
-    }
-
-    // Submit the status with secondary visibility on alt + enter.
-    if (onSecondarySubmit && e.keyCode === 13 && e.altKey) {
-      onSecondarySubmit();
-    }
-
-    //  Switches over the pressed key.
-    switch(e.key) {
-
-    //  On arrow down, we pick the next suggestion.
-    case 'ArrowDown':
-      if (suggestions && suggestions.size > 0 && !suggestionsHidden) {
-        e.preventDefault();
-        this.setState({ selectedSuggestion: Math.min(selectedSuggestion + 1, suggestions.size - 1) });
-      }
-      return;
-
-    //  On arrow up, we pick the previous suggestion.
-    case 'ArrowUp':
-      if (suggestions && suggestions.size > 0 && !suggestionsHidden) {
-        e.preventDefault();
-        this.setState({ selectedSuggestion: Math.max(selectedSuggestion - 1, 0) });
-      }
-      return;
-
-    //  On enter or tab, we select the suggestion.
-    case 'Enter':
-    case 'Tab':
-      if (onSuggestionSelected && lastToken !== null && suggestions && suggestions.size > 0 && !suggestionsHidden) {
-        e.preventDefault();
-        e.stopPropagation();
-        onSuggestionSelected(tokenStart, lastToken, suggestions.get(selectedSuggestion));
-      }
-      return;
-    }
-  },
-
-  //  When the escape key is released, we either close the suggestions
-  //  window or focus the UI.
-  handleKeyUp ({ key }) {
-    const { suggestionsHidden } = this.state;
-    if (key === 'Escape') {
-      if (!suggestionsHidden) {
-        this.setState({ suggestionsHidden: true });
-      } else {
-        document.querySelector('.ui').parentElement.focus();
-      }
-    }
-  },
-
-  //  Handles the pasting of images into the composer.
-  handlePaste (e) {
-    const { onPaste } = this.props;
-    let d;
-    if (onPaste && (d = e.clipboardData) && (d = d.files).length === 1) {
-      onPaste(d);
-      e.preventDefault();
-    }
-  },
-
-  //  Saves a reference to the textarea.
-  handleRefTextarea (textarea) {
-    this.textarea = textarea;
-  },
-};
-
-//  The component.
-export default class ComposerTextarea extends React.Component {
-
-  //  Constructor.
-  constructor (props) {
-    super(props);
-    assignHandlers(this, handlers);
-    this.state = {
-      suggestionsHidden: false,
-      selectedSuggestion: 0,
-      lastToken: null,
-      tokenStart: 0,
-    };
-
-    //  Instance variables.
-    this.textarea = null;
-  }
-
-  //  When we receive new suggestions, we unhide the suggestions window
-  //  if we didn't have any suggestions before.
-  componentWillReceiveProps (nextProps) {
-    const { suggestions } = this.props;
-    const { suggestionsHidden } = this.state;
-    if (nextProps.suggestions && nextProps.suggestions !== suggestions && nextProps.suggestions.size > 0 && suggestionsHidden) {
-      this.setState({ suggestionsHidden: false });
-    }
-  }
-
-  //  Rendering.
-  render () {
-    const {
-      handleBlur,
-      handleChange,
-      handleClickSuggestion,
-      handleKeyDown,
-      handleKeyUp,
-      handlePaste,
-      handleRefTextarea,
-    } = this.handlers;
-    const {
-      advancedOptions,
-      autoFocus,
-      disabled,
-      intl,
-      onPickEmoji,
-      suggestions,
-      value,
-    } = this.props;
-    const {
-      selectedSuggestion,
-      suggestionsHidden,
-    } = this.state;
-
-    //  The result.
-    return (
-      <div className='composer--textarea'>
-        <label>
-          <span {...hiddenComponent}><FormattedMessage {...messages.placeholder} /></span>
-          <ComposerTextareaIcons
-            advancedOptions={advancedOptions}
-            intl={intl}
-          />
-          <Textarea
-            aria-autocomplete='list'
-            autoFocus={autoFocus}
-            className='textarea'
-            disabled={disabled}
-            inputRef={handleRefTextarea}
-            onBlur={handleBlur}
-            onChange={handleChange}
-            onKeyDown={handleKeyDown}
-            onKeyUp={handleKeyUp}
-            onPaste={handlePaste}
-            placeholder={intl.formatMessage(messages.placeholder)}
-            value={value}
-            style={{ direction: isRtl(value) ? 'rtl' : 'ltr' }}
-          />
-        </label>
-        <EmojiPicker onPickEmoji={onPickEmoji} />
-        <ComposerTextareaSuggestions
-          hidden={suggestionsHidden}
-          onSuggestionClick={handleClickSuggestion}
-          suggestions={suggestions}
-          value={selectedSuggestion}
-        />
-      </div>
-    );
-  }
-
-}
-
-//  Props.
-ComposerTextarea.propTypes = {
-  advancedOptions: ImmutablePropTypes.map,
-  autoFocus: PropTypes.bool,
-  disabled: PropTypes.bool,
-  intl: PropTypes.object.isRequired,
-  onChange: PropTypes.func,
-  onPaste: PropTypes.func,
-  onPickEmoji: PropTypes.func,
-  onSubmit: PropTypes.func,
-  onSecondarySubmit: PropTypes.func,
-  onSuggestionsClearRequested: PropTypes.func,
-  onSuggestionsFetchRequested: PropTypes.func,
-  onSuggestionSelected: PropTypes.func,
-  suggestions: ImmutablePropTypes.list,
-  value: PropTypes.string,
-};
-
-//  Default props.
-ComposerTextarea.defaultProps = { autoFocus: true };
diff --git a/app/javascript/flavours/glitch/features/composer/textarea/suggestions/index.js b/app/javascript/flavours/glitch/features/composer/textarea/suggestions/index.js
deleted file mode 100644
index dc72585f2..000000000
--- a/app/javascript/flavours/glitch/features/composer/textarea/suggestions/index.js
+++ /dev/null
@@ -1,43 +0,0 @@
-//  Package imports.
-import PropTypes from 'prop-types';
-import React from 'react';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-
-//  Components.
-import ComposerTextareaSuggestionsItem from './item';
-
-//  The component.
-export default function ComposerTextareaSuggestions ({
-  hidden,
-  onSuggestionClick,
-  suggestions,
-  value,
-}) {
-
-  //  The result.
-  return (
-    <div
-      className='composer--textarea--suggestions'
-      hidden={hidden || !suggestions || suggestions.isEmpty()}
-    >
-      {!hidden && suggestions ? suggestions.map(
-        (suggestion, index) => (
-          <ComposerTextareaSuggestionsItem
-            index={index}
-            key={typeof suggestion === 'object' ? suggestion.id : suggestion}
-            onClick={onSuggestionClick}
-            selected={index === value}
-            suggestion={suggestion}
-          />
-        )
-      ) : null}
-    </div>
-  );
-}
-
-ComposerTextareaSuggestions.propTypes = {
-  hidden: PropTypes.bool,
-  onSuggestionClick: PropTypes.func,
-  suggestions: ImmutablePropTypes.list,
-  value: PropTypes.number,
-};
diff --git a/app/javascript/flavours/glitch/features/composer/textarea/suggestions/item/index.js b/app/javascript/flavours/glitch/features/composer/textarea/suggestions/item/index.js
deleted file mode 100644
index 1b7ae8904..000000000
--- a/app/javascript/flavours/glitch/features/composer/textarea/suggestions/item/index.js
+++ /dev/null
@@ -1,118 +0,0 @@
-//  Package imports.
-import classNames from 'classnames';
-import PropTypes from 'prop-types';
-import React from 'react';
-
-//  Components.
-import AccountContainer from 'flavours/glitch/containers/account_container';
-
-//  Utils.
-import { unicodeMapping } from 'flavours/glitch/util/emoji';
-import { assignHandlers } from 'flavours/glitch/util/react_helpers';
-
-//  Gets our asset host from the environment, if available.
-const assetHost = process.env.CDN_HOST || '';
-
-//  Handlers.
-const handlers = {
-
-  //  Handles a click on a suggestion.
-  handleClick (e) {
-    const {
-      index,
-      onClick,
-    } = this.props;
-    if (onClick) {
-      e.preventDefault();
-      e.stopPropagation();  //  Prevents following account links
-      onClick(index);
-    }
-  },
-
-  //  This prevents the focus from changing, which would mess with
-  //  our suggestion code.
-  handleMouseDown (e) {
-    e.preventDefault();
-  },
-};
-
-//  The component.
-export default class ComposerTextareaSuggestionsItem extends React.Component {
-
-  //  Constructor.
-  constructor (props) {
-    super(props);
-    assignHandlers(this, handlers);
-  }
-
-  //  Rendering.
-  render () {
-    const {
-      handleMouseDown,
-      handleClick,
-    } = this.handlers;
-    const {
-      selected,
-      suggestion,
-    } = this.props;
-    const computedClass = classNames('composer--textarea--suggestions--item', { selected });
-
-    //  If the suggestion is an object, then we render an emoji.
-    //  Otherwise, we render a hashtag if it starts with #, or an account.
-    let inner;
-    if (typeof suggestion === 'object') {
-      let url;
-      if (suggestion.custom) {
-        url = suggestion.imageUrl;
-      } else {
-        const mapping = unicodeMapping[suggestion.native] || unicodeMapping[suggestion.native.replace(/\uFE0F$/, '')];
-        if (mapping) {
-          url = `${assetHost}/emoji/${mapping.filename}.svg`;
-        }
-      }
-      if (url) {
-        inner = (
-          <div className='emoji'>
-            <img
-              alt={suggestion.native || suggestion.colons}
-              className='emojione'
-              src={url}
-            />
-            {suggestion.colons}
-          </div>
-        );
-      }
-    } else if (suggestion[0] === '#') {
-      inner = suggestion;
-    } else {
-      inner = (
-        <AccountContainer
-          id={suggestion}
-          small
-        />
-      );
-    }
-
-    //  The result.
-    return (
-      <div
-        className={computedClass}
-        onMouseDown={handleMouseDown}
-        onClickCapture={handleClick}  //  Jumps in front of contents
-        role='button'
-        tabIndex='0'
-      >
-        { inner }
-      </div>
-    );
-  }
-
-}
-
-//  Props.
-ComposerTextareaSuggestionsItem.propTypes = {
-  index: PropTypes.number,
-  onClick: PropTypes.func,
-  selected: PropTypes.bool,
-  suggestion: PropTypes.oneOfType([PropTypes.object, PropTypes.string]),
-};
diff --git a/app/javascript/flavours/glitch/features/composer/upload_form/index.js b/app/javascript/flavours/glitch/features/composer/upload_form/index.js
deleted file mode 100644
index c2ff66623..000000000
--- a/app/javascript/flavours/glitch/features/composer/upload_form/index.js
+++ /dev/null
@@ -1,60 +0,0 @@
-//  Package imports.
-import classNames from 'classnames';
-import PropTypes from 'prop-types';
-import React from 'react';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-
-//  Components.
-import ComposerUploadFormItem from './item';
-import ComposerUploadFormProgress from './progress';
-
-//  The component.
-export default function ComposerUploadForm ({
-  intl,
-  media,
-  onChangeDescription,
-  onOpenFocalPointModal,
-  onRemove,
-  progress,
-  uploading,
-  handleRef,
-}) {
-  const computedClass = classNames('composer--upload_form', { uploading });
-
-  //  The result.
-  return (
-    <div className={computedClass} ref={handleRef}>
-      {uploading ? <ComposerUploadFormProgress progress={progress} /> : null}
-      {media ? (
-        <div className='content'>
-          {media.map(item => (
-            <ComposerUploadFormItem
-              description={item.get('description')}
-              key={item.get('id')}
-              id={item.get('id')}
-              intl={intl}
-              focusX={item.getIn(['meta', 'focus', 'x'])}
-              focusY={item.getIn(['meta', 'focus', 'y'])}
-              mediaType={item.get('type')}
-              preview={item.get('preview_url')}
-              onChangeDescription={onChangeDescription}
-              onOpenFocalPointModal={onOpenFocalPointModal}
-              onRemove={onRemove}
-            />
-          ))}
-        </div>
-      ) : null}
-    </div>
-  );
-}
-
-//  Props.
-ComposerUploadForm.propTypes = {
-  intl: PropTypes.object.isRequired,
-  media: ImmutablePropTypes.list,
-  onChangeDescription: PropTypes.func.isRequired,
-  onRemove: PropTypes.func.isRequired,
-  progress: PropTypes.number,
-  uploading: PropTypes.bool,
-  handleRef: PropTypes.func,
-};
diff --git a/app/javascript/flavours/glitch/features/composer/upload_form/item/index.js b/app/javascript/flavours/glitch/features/composer/upload_form/item/index.js
deleted file mode 100644
index 4f5f66f04..000000000
--- a/app/javascript/flavours/glitch/features/composer/upload_form/item/index.js
+++ /dev/null
@@ -1,202 +0,0 @@
-//  Package imports.
-import classNames from 'classnames';
-import PropTypes from 'prop-types';
-import React from 'react';
-import {
-  FormattedMessage,
-  defineMessages,
-} from 'react-intl';
-import spring from 'react-motion/lib/spring';
-
-//  Components.
-import IconButton from 'flavours/glitch/components/icon_button';
-
-//  Utils.
-import Motion from 'flavours/glitch/util/optional_motion';
-import { assignHandlers } from 'flavours/glitch/util/react_helpers';
-import { isUserTouching } from 'flavours/glitch/util/is_mobile';
-
-//  Messages.
-const messages = defineMessages({
-  undo: {
-    defaultMessage: 'Undo',
-    id: 'upload_form.undo',
-  },
-  description: {
-    defaultMessage: 'Describe for the visually impaired',
-    id: 'upload_form.description',
-  },
-  crop: {
-    defaultMessage: 'Crop',
-    id: 'upload_form.focus',
-  },
-});
-
-//  Handlers.
-const handlers = {
-
-  //  On blur, we save the description for the media item.
-  handleBlur () {
-    const {
-      id,
-      onChangeDescription,
-    } = this.props;
-    const { dirtyDescription } = this.state;
-
-    this.setState({ dirtyDescription: null, focused: false });
-
-    if (id && onChangeDescription && dirtyDescription !== null) {
-      onChangeDescription(id, dirtyDescription);
-    }
-  },
-
-  //  When the value of our description changes, we store it in the
-  //  temp value `dirtyDescription` in our state.
-  handleChange ({ target: { value } }) {
-    this.setState({ dirtyDescription: value });
-  },
-
-  //  Records focus on the media item.
-  handleFocus () {
-    this.setState({ focused: true });
-  },
-
-  //  Records the start of a hover over the media item.
-  handleMouseEnter () {
-    this.setState({ hovered: true });
-  },
-
-  //  Records the end of a hover over the media item.
-  handleMouseLeave () {
-    this.setState({ hovered: false });
-  },
-
-  //  Removes the media item.
-  handleRemove () {
-    const {
-      id,
-      onRemove,
-    } = this.props;
-    if (id && onRemove) {
-      onRemove(id);
-    }
-  },
-
-  //  Opens the focal point modal.
-  handleFocalPointClick () {
-    const {
-      id,
-      onOpenFocalPointModal,
-    } = this.props;
-    if (id && onOpenFocalPointModal) {
-      onOpenFocalPointModal(id);
-    }
-  },
-};
-
-//  The component.
-export default class ComposerUploadFormItem extends React.PureComponent {
-
-  //  Constructor.
-  constructor (props) {
-    super(props);
-    assignHandlers(this, handlers);
-    this.state = {
-      hovered: false,
-      focused: false,
-      dirtyDescription: null,
-    };
-  }
-
-  //  Rendering.
-  render () {
-    const {
-      handleBlur,
-      handleChange,
-      handleFocus,
-      handleMouseEnter,
-      handleMouseLeave,
-      handleRemove,
-      handleFocalPointClick,
-    } = this.handlers;
-    const {
-      intl,
-      preview,
-      focusX,
-      focusY,
-      mediaType,
-    } = this.props;
-    const {
-      focused,
-      hovered,
-      dirtyDescription,
-    } = this.state;
-    const active = hovered || focused || isUserTouching();
-    const computedClass = classNames('composer--upload_form--item', { active });
-    const x = ((focusX /  2) + .5) * 100;
-    const y = ((focusY / -2) + .5) * 100;
-    const description = dirtyDescription || (dirtyDescription !== '' && this.props.description) || '';
-
-    //  The result.
-    return (
-      <div
-        className={computedClass}
-        onMouseEnter={handleMouseEnter}
-        onMouseLeave={handleMouseLeave}
-      >
-        <Motion
-          defaultStyle={{ scale: 0.8 }}
-          style={{
-            scale: spring(1, {
-              stiffness: 180,
-              damping: 12,
-            }),
-          }}
-        >
-          {({ scale }) => (
-            <div
-              style={{
-                transform: `scale(${scale})`,
-                backgroundImage: preview ? `url(${preview})` : null,
-                backgroundPosition: `${x}% ${y}%`
-              }}
-            >
-              <div className={classNames('composer--upload_form--actions', { active })}>
-                <button className='icon-button' onClick={handleRemove}>
-                  <i className='fa fa-times' /> <FormattedMessage {...messages.undo} />
-                </button>
-                {mediaType === 'image' && <button className='icon-button' onClick={handleFocalPointClick}><i className='fa fa-crosshairs' /> <FormattedMessage {...messages.crop} /></button>}
-              </div>
-              <label>
-                <span style={{ display: 'none' }}><FormattedMessage {...messages.description} /></span>
-                <textarea
-                  maxLength={420}
-                  onBlur={handleBlur}
-                  onChange={handleChange}
-                  onFocus={handleFocus}
-                  placeholder={intl.formatMessage(messages.description)}
-                  value={description}
-                />
-              </label>
-            </div>
-          )}
-        </Motion>
-      </div>
-    );
-  }
-
-}
-
-//  Props.
-ComposerUploadFormItem.propTypes = {
-  description: PropTypes.string,
-  id: PropTypes.string,
-  intl: PropTypes.object.isRequired,
-  onChangeDescription: PropTypes.func.isRequired,
-  onOpenFocalPointModal: PropTypes.func.isRequired,
-  onRemove: PropTypes.func.isRequired,
-  focusX: PropTypes.number,
-  focusY: PropTypes.number,
-  mediaType: PropTypes.string,
-  preview: PropTypes.string,
-};
diff --git a/app/javascript/flavours/glitch/features/composer/upload_form/progress/index.js b/app/javascript/flavours/glitch/features/composer/upload_form/progress/index.js
deleted file mode 100644
index 8c4b0eea6..000000000
--- a/app/javascript/flavours/glitch/features/composer/upload_form/progress/index.js
+++ /dev/null
@@ -1,52 +0,0 @@
-//  Package imports.
-import PropTypes from 'prop-types';
-import React from 'react';
-import {
-  defineMessages,
-  FormattedMessage,
-} from 'react-intl';
-import spring from 'react-motion/lib/spring';
-
-//  Components.
-import Icon from 'flavours/glitch/components/icon';
-
-//  Utils.
-import Motion from 'flavours/glitch/util/optional_motion';
-
-//  Messages.
-const messages = defineMessages({
-  upload: {
-    defaultMessage: 'Uploading...',
-    id: 'upload_progress.label',
-  },
-});
-
-//  The component.
-export default function ComposerUploadFormProgress ({ progress }) {
-
-  //  The result.
-  return (
-    <div className='composer--upload_form--progress'>
-      <Icon icon='upload' />
-      <div className='message'>
-        <FormattedMessage {...messages.upload} />
-        <div className='backdrop'>
-          <Motion
-            defaultStyle={{ width: 0 }}
-            style={{ width: spring(progress) }}
-          >
-            {({ width }) =>
-              (<div
-                className='tracker'
-                style={{ width: `${width}%` }}
-              />)
-            }
-          </Motion>
-        </div>
-      </div>
-    </div>
-  );
-}
-
-//  Props.
-ComposerUploadFormProgress.propTypes = { progress: PropTypes.number };
diff --git a/app/javascript/flavours/glitch/features/composer/warning/index.js b/app/javascript/flavours/glitch/features/composer/warning/index.js
deleted file mode 100644
index 8be8acbce..000000000
--- a/app/javascript/flavours/glitch/features/composer/warning/index.js
+++ /dev/null
@@ -1,59 +0,0 @@
-import React from 'react';
-import Motion from 'flavours/glitch/util/optional_motion';
-import spring from 'react-motion/lib/spring';
-import { defineMessages, FormattedMessage } from 'react-intl';
-import { profileLink } from 'flavours/glitch/util/backend_links';
-
-//  This is the spring used with our motion.
-const motionSpring = spring(1, { damping: 35, stiffness: 400 });
-
-//  Messages.
-const messages = defineMessages({
-  disclaimer: {
-    defaultMessage: 'Your account is not {locked}. Anyone can follow you to view your follower-only posts.',
-    id: 'compose_form.lock_disclaimer',
-  },
-  locked: {
-    defaultMessage: 'locked',
-    id: 'compose_form.lock_disclaimer.lock',
-  },
-});
-
-//  The component.
-export default function ComposerWarning () {
-  let lockedLink = <FormattedMessage {...messages.locked} />;
-  if (profileLink !== undefined) {
-    lockedLink = <a href={profileLink}>{lockedLink}</a>;
-  }
-  return (
-    <Motion
-      defaultStyle={{
-        opacity: 0,
-        scaleX: 0.85,
-        scaleY: 0.75,
-      }}
-      style={{
-        opacity: motionSpring,
-        scaleX: motionSpring,
-        scaleY: motionSpring,
-      }}
-    >
-      {({ opacity, scaleX, scaleY }) => (
-        <div
-          className='composer--warning'
-          style={{
-            opacity: opacity,
-            transform: `scale(${scaleX}, ${scaleY})`,
-          }}
-        >
-          <FormattedMessage
-            {...messages.disclaimer}
-            values={{ locked: lockedLink }}
-          />
-        </div>
-      )}
-    </Motion>
-  );
-}
-
-ComposerWarning.propTypes = {};
diff --git a/app/javascript/flavours/glitch/features/drawer/account/index.js b/app/javascript/flavours/glitch/features/drawer/account/index.js
deleted file mode 100644
index 552848641..000000000
--- a/app/javascript/flavours/glitch/features/drawer/account/index.js
+++ /dev/null
@@ -1,76 +0,0 @@
-//  Package imports.
-import React from 'react';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import {
-  FormattedMessage,
-  defineMessages,
-} from 'react-intl';
-
-//  Components.
-import Avatar from 'flavours/glitch/components/avatar';
-import Permalink from 'flavours/glitch/components/permalink';
-
-//  Utils.
-import { hiddenComponent } from 'flavours/glitch/util/react_helpers';
-import { profileLink } from 'flavours/glitch/util/backend_links';
-
-//  Messages.
-const messages = defineMessages({
-  edit: {
-    defaultMessage: 'Edit profile',
-    id: 'navigation_bar.edit_profile',
-  },
-});
-
-//  The component.
-export default function DrawerAccount ({ account }) {
-
-  //  We need an account to render.
-  if (!account) {
-    return (
-      <div className='drawer--account'>
-        { profileLink !== undefined && (
-          <a
-            className='edit'
-            href={ profileLink }
-          >
-            <FormattedMessage {...messages.edit} />
-          </a>
-        )}
-      </div>
-    );
-  }
-
-  //  The result.
-  return (
-    <div className='drawer--account'>
-      <Permalink
-        className='avatar'
-        href={account.get('url')}
-        to={`/accounts/${account.get('id')}`}
-      >
-        <span {...hiddenComponent}>{account.get('acct')}</span>
-        <Avatar
-          account={account}
-          size={40}
-        />
-      </Permalink>
-      <Permalink
-        className='acct'
-        href={account.get('url')}
-        to={`/accounts/${account.get('id')}`}
-      >
-        <strong>@{account.get('acct')}</strong>
-      </Permalink>
-      { profileLink !== undefined && (
-        <a
-          className='edit'
-          href={ profileLink }
-        ><FormattedMessage {...messages.edit} /></a>
-      )}
-    </div>
-  );
-}
-
-//  Props.
-DrawerAccount.propTypes = { account: ImmutablePropTypes.map };
diff --git a/app/javascript/flavours/glitch/features/drawer/header/index.js b/app/javascript/flavours/glitch/features/drawer/header/index.js
deleted file mode 100644
index da5599732..000000000
--- a/app/javascript/flavours/glitch/features/drawer/header/index.js
+++ /dev/null
@@ -1,127 +0,0 @@
-//  Package imports.
-import PropTypes from 'prop-types';
-import React from 'react';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import { defineMessages } from 'react-intl';
-import { Link } from 'react-router-dom';
-
-//  Components.
-import Icon from 'flavours/glitch/components/icon';
-
-//  Utils.
-import { conditionalRender } from 'flavours/glitch/util/react_helpers';
-import { signOutLink } from 'flavours/glitch/util/backend_links';
-
-//  Messages.
-const messages = defineMessages({
-  community: {
-    defaultMessage: 'Local timeline',
-    id: 'navigation_bar.community_timeline',
-  },
-  home_timeline: {
-    defaultMessage: 'Home',
-    id: 'tabs_bar.home',
-  },
-  logout: {
-    defaultMessage: 'Logout',
-    id: 'navigation_bar.logout',
-  },
-  notifications: {
-    defaultMessage: 'Notifications',
-    id: 'tabs_bar.notifications',
-  },
-  public: {
-    defaultMessage: 'Federated timeline',
-    id: 'navigation_bar.public_timeline',
-  },
-  settings: {
-    defaultMessage: 'App settings',
-    id: 'navigation_bar.app_settings',
-  },
-  start: {
-    defaultMessage: 'Getting started',
-    id: 'getting_started.heading',
-  },
-});
-
-//  The component.
-export default function DrawerHeader ({
-  columns,
-  unreadNotifications,
-  showNotificationsBadge,
-  intl,
-  onSettingsClick,
-}) {
-
-  //  Only renders the component if the column isn't being shown.
-  const renderForColumn = conditionalRender.bind(null,
-    columnId => !columns || !columns.some(
-      column => column.get('id') === columnId
-    )
-  );
-
-  //  The result.
-  return (
-    <nav className='drawer--header'>
-      <Link
-        aria-label={intl.formatMessage(messages.start)}
-        title={intl.formatMessage(messages.start)}
-        to='/getting-started'
-      ><Icon icon='asterisk' /></Link>
-      {renderForColumn('HOME', (
-        <Link
-          aria-label={intl.formatMessage(messages.home_timeline)}
-          title={intl.formatMessage(messages.home_timeline)}
-          to='/timelines/home'
-        ><Icon icon='home' /></Link>
-      ))}
-      {renderForColumn('NOTIFICATIONS', (
-        <Link
-          aria-label={intl.formatMessage(messages.notifications)}
-          title={intl.formatMessage(messages.notifications)}
-          to='/notifications'
-        >
-          <span className='icon-badge-wrapper'>
-            <Icon icon='bell' />
-            { showNotificationsBadge && unreadNotifications > 0 && <div className='icon-badge' />}
-          </span>
-        </Link>
-      ))}
-      {renderForColumn('COMMUNITY', (
-        <Link
-          aria-label={intl.formatMessage(messages.community)}
-          title={intl.formatMessage(messages.community)}
-          to='/timelines/public/local'
-        ><Icon icon='users' /></Link>
-      ))}
-      {renderForColumn('PUBLIC', (
-        <Link
-          aria-label={intl.formatMessage(messages.public)}
-          title={intl.formatMessage(messages.public)}
-          to='/timelines/public'
-        ><Icon icon='globe' /></Link>
-      ))}
-      <a
-        aria-label={intl.formatMessage(messages.settings)}
-        onClick={onSettingsClick}
-        href='#'
-        title={intl.formatMessage(messages.settings)}
-      ><Icon icon='cogs' /></a>
-      <a
-        aria-label={intl.formatMessage(messages.logout)}
-        data-method='delete'
-        href={ signOutLink }
-        title={intl.formatMessage(messages.logout)}
-      ><Icon icon='sign-out' /></a>
-    </nav>
-  );
-}
-
-//  Props.
-DrawerHeader.propTypes = {
-  columns: ImmutablePropTypes.list,
-  unreadNotifications: PropTypes.number,
-  showNotificationsBadge: PropTypes.bool,
-  intl: PropTypes.object,
-  onSettingsClick: PropTypes.func,
-};
diff --git a/app/javascript/flavours/glitch/features/drawer/index.js b/app/javascript/flavours/glitch/features/drawer/index.js
deleted file mode 100644
index c8121b8e5..000000000
--- a/app/javascript/flavours/glitch/features/drawer/index.js
+++ /dev/null
@@ -1,175 +0,0 @@
-//  Package imports.
-import PropTypes from 'prop-types';
-import React from 'react';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import { defineMessages } from 'react-intl';
-import classNames from 'classnames';
-
-//  Actions.
-import { openModal } from 'flavours/glitch/actions/modal';
-import {
-  changeSearch,
-  clearSearch,
-  showSearch,
-  submitSearch,
-} from 'flavours/glitch/actions/search';
-import { cycleElefriendCompose } from 'flavours/glitch/actions/compose';
-
-//  Components.
-import Composer from 'flavours/glitch/features/composer';
-import DrawerAccount from './account';
-import DrawerHeader from './header';
-import DrawerResults from './results';
-import DrawerSearch from './search';
-
-//  Utils.
-import { me, mascot } from 'flavours/glitch/util/initial_state';
-import { wrap } from 'flavours/glitch/util/redux_helpers';
-
-//  Messages.
-const messages = defineMessages({
-  compose: { id: 'navigation_bar.compose', defaultMessage: 'Compose new toot' },
-});
-
-//  State mapping.
-const mapStateToProps = state => ({
-  account: state.getIn(['accounts', me]),
-  columns: state.getIn(['settings', 'columns']),
-  elefriend: state.getIn(['compose', 'elefriend']),
-  results: state.getIn(['search', 'results']),
-  searchHidden: state.getIn(['search', 'hidden']),
-  searchValue: state.getIn(['search', 'value']),
-  submitted: state.getIn(['search', 'submitted']),
-  unreadNotifications: state.getIn(['notifications', 'unread']),
-  showNotificationsBadge: state.getIn(['local_settings', 'notifications', 'tab_badge']),
-});
-
-//  Dispatch mapping.
-const mapDispatchToProps = (dispatch, { intl }) => ({
-  onChange (value) {
-    dispatch(changeSearch(value));
-  },
-  onClear () {
-    dispatch(clearSearch());
-  },
-  onClickElefriend () {
-    dispatch(cycleElefriendCompose());
-  },
-  onShow () {
-    dispatch(showSearch());
-  },
-  onSubmit () {
-    dispatch(submitSearch());
-  },
-  onOpenSettings (e) {
-    e.preventDefault();
-    e.stopPropagation();
-    dispatch(openModal('SETTINGS', {}));
-  },
-});
-
-//  The component.
-class Drawer extends React.Component {
-
-  //  Constructor.
-  constructor (props) {
-    super(props);
-  }
-
-  //  Rendering.
-  render () {
-    const {
-      account,
-      columns,
-      elefriend,
-      intl,
-      multiColumn,
-      onChange,
-      onClear,
-      onClickElefriend,
-      onOpenSettings,
-      onShow,
-      onSubmit,
-      results,
-      searchHidden,
-      searchValue,
-      submitted,
-      isSearchPage,
-      unreadNotifications,
-      showNotificationsBadge,
-    } = this.props;
-    const computedClass = classNames('drawer', `mbstobon-${elefriend}`);
-
-    //  The result.
-    return (
-      <div className={computedClass} role='region' aria-label={intl.formatMessage(messages.compose)}>
-        {multiColumn ? (
-          <DrawerHeader
-            columns={columns}
-            unreadNotifications={unreadNotifications}
-            showNotificationsBadge={showNotificationsBadge}
-            intl={intl}
-            onSettingsClick={onOpenSettings}
-          />
-        ) : null}
-        {(multiColumn || isSearchPage) && <DrawerSearch
-            intl={intl}
-            onChange={onChange}
-            onClear={onClear}
-            onShow={onShow}
-            onSubmit={onSubmit}
-            submitted={submitted}
-            value={searchValue}
-          /> }
-        <div className='drawer__pager'>
-          {!isSearchPage && <div className='drawer__inner'>
-            <DrawerAccount account={account} />
-            <Composer />
-            {multiColumn && (
-              <div className='drawer__inner__mastodon'>
-                {mascot ? <img alt='' draggable='false' src={mascot} /> : <button className='mastodon' onClick={onClickElefriend} />}
-              </div>
-            )}
-          </div>}
-
-          {(multiColumn || isSearchPage) &&
-            <DrawerResults
-              results={results}
-              visible={submitted && !searchHidden}
-            />}
-        </div>
-      </div>
-    );
-  }
-
-}
-
-//  Props.
-Drawer.propTypes = {
-  intl: PropTypes.object.isRequired,
-  isSearchPage: PropTypes.bool,
-  multiColumn: PropTypes.bool,
-
-  //  State props.
-  account: ImmutablePropTypes.map,
-  columns: ImmutablePropTypes.list,
-  results: ImmutablePropTypes.map,
-  elefriend: PropTypes.number,
-  searchHidden: PropTypes.bool,
-  searchValue: PropTypes.string,
-  submitted: PropTypes.bool,
-  unreadNotifications: PropTypes.number,
-  showNotificationsBadge: PropTypes.bool,
-
-  //  Dispatch props.
-  onChange: PropTypes.func,
-  onClear: PropTypes.func,
-  onClickElefriend: PropTypes.func,
-  onShow: PropTypes.func,
-  onSubmit: PropTypes.func,
-  onOpenSettings: PropTypes.func,
-};
-
-//  Connecting and export.
-export { Drawer as WrappedComponent };
-export default wrap(Drawer, mapStateToProps, mapDispatchToProps, true);
diff --git a/app/javascript/flavours/glitch/features/drawer/results/index.js b/app/javascript/flavours/glitch/features/drawer/results/index.js
deleted file mode 100644
index 4574c0e1e..000000000
--- a/app/javascript/flavours/glitch/features/drawer/results/index.js
+++ /dev/null
@@ -1,117 +0,0 @@
-//  Package imports.
-import PropTypes from 'prop-types';
-import React from 'react';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import {
-  FormattedMessage,
-  defineMessages,
-} from 'react-intl';
-import spring from 'react-motion/lib/spring';
-import { Link } from 'react-router-dom';
-
-//  Components.
-import Icon from 'flavours/glitch/components/icon';
-import AccountContainer from 'flavours/glitch/containers/account_container';
-import StatusContainer from 'flavours/glitch/containers/status_container';
-import Hashtag from 'flavours/glitch/components/hashtag';
-
-//  Utils.
-import Motion from 'flavours/glitch/util/optional_motion';
-
-//  Messages.
-const messages = defineMessages({
-  total: {
-    defaultMessage: '{count, number} {count, plural, one {result} other {results}}',
-    id: 'search_results.total',
-  },
-});
-
-//  The component.
-export default function DrawerResults ({
-  results,
-  visible,
-}) {
-  const accounts = results ? results.get('accounts') : null;
-  const statuses = results ? results.get('statuses') : null;
-  const hashtags = results ? results.get('hashtags') : null;
-
-  //  This gets the total number of items.
-  const count = [accounts, statuses, hashtags].reduce(function (size, item) {
-    if (item && item.size) {
-      return size + item.size;
-    }
-    return size;
-  }, 0);
-
-  //  The result.
-  return (
-    <Motion
-      defaultStyle={{ x: -100 }}
-      style={{
-        x: spring(visible ? 0 : -100, {
-          stiffness: 210,
-          damping: 20,
-        }),
-      }}
-    >
-      {({ x }) => (
-        <div
-          className='drawer--results'
-          style={{
-            transform: `translateX(${x}%)`,
-            visibility: x === -100 ? 'hidden' : 'visible',
-          }}
-        >
-          <header>
-            <Icon icon='search' fixedWidth />
-            <FormattedMessage
-              {...messages.total}
-              values={{ count }}
-            />
-          </header>
-          {accounts && accounts.size ? (
-            <section>
-              <h5><Icon icon='users' fixedWidth /><FormattedMessage id='search_results.accounts' defaultMessage='People' /></h5>
-
-              {accounts.map(
-                accountId => (
-                  <AccountContainer
-                    id={accountId}
-                    key={accountId}
-                  />
-                )
-              )}
-            </section>
-          ) : null}
-          {statuses && statuses.size ? (
-            <section>
-              <h5><Icon icon='quote-right' fixedWidth /><FormattedMessage id='search_results.statuses' defaultMessage='Toots' /></h5>
-
-              {statuses.map(
-                statusId => (
-                  <StatusContainer
-                    id={statusId}
-                    key={statusId}
-                  />
-                )
-              )}
-            </section>
-          ) : null}
-          {hashtags && hashtags.size ? (
-            <section>
-              <h5><Icon icon='hashtag' fixedWidth /><FormattedMessage id='search_results.hashtags' defaultMessage='Hashtags' /></h5>
-
-              {hashtags.map(hashtag => <Hashtag key={hashtag.get('name')} hashtag={hashtag} />)}
-            </section>
-          ) : null}
-        </div>
-      )}
-    </Motion>
-  );
-}
-
-//  Props.
-DrawerResults.propTypes = {
-  results: ImmutablePropTypes.map,
-  visible: PropTypes.bool,
-};
diff --git a/app/javascript/flavours/glitch/features/drawer/search/index.js b/app/javascript/flavours/glitch/features/drawer/search/index.js
deleted file mode 100644
index 8cbb0906c..000000000
--- a/app/javascript/flavours/glitch/features/drawer/search/index.js
+++ /dev/null
@@ -1,152 +0,0 @@
-//  Package imports.
-import classNames from 'classnames';
-import PropTypes from 'prop-types';
-import React from 'react';
-import {
-  FormattedMessage,
-  defineMessages,
-} from 'react-intl';
-import Overlay from 'react-overlays/lib/Overlay';
-
-//  Components.
-import Icon from 'flavours/glitch/components/icon';
-import DrawerSearchPopout from './popout';
-
-//  Utils.
-import { focusRoot } from 'flavours/glitch/util/dom_helpers';
-import {
-  assignHandlers,
-  hiddenComponent,
-} from 'flavours/glitch/util/react_helpers';
-
-//  Messages.
-const messages = defineMessages({
-  placeholder: {
-    defaultMessage: 'Search',
-    id: 'search.placeholder',
-  },
-});
-
-//  Handlers.
-const handlers = {
-
-  handleBlur () {
-    this.setState({ expanded: false });
-  },
-
-  handleChange ({ target: { value } }) {
-    const { onChange } = this.props;
-    if (onChange) {
-      onChange(value);
-    }
-  },
-
-  handleClear (e) {
-    const {
-      onClear,
-      submitted,
-      value,
-    } = this.props;
-    e.preventDefault();  //  Prevents focus change ??
-    if (onClear && (submitted || value && value.length)) {
-      onClear();
-    }
-  },
-
-  handleFocus () {
-    const { onShow } = this.props;
-    this.setState({ expanded: true });
-    if (onShow) {
-      onShow();
-    }
-  },
-
-  handleKeyUp (e) {
-    const { onSubmit } = this.props;
-    switch (e.key) {
-    case 'Enter':
-      if (onSubmit) {
-        onSubmit();
-      }
-      break;
-    case 'Escape':
-      focusRoot();
-    }
-  },
-};
-
-//  The component.
-export default class DrawerSearch extends React.PureComponent {
-
-  //  Constructor.
-  constructor (props) {
-    super(props);
-    assignHandlers(this, handlers);
-    this.state = { expanded: false };
-  }
-
-  //  Rendering.
-  render () {
-    const {
-      handleBlur,
-      handleChange,
-      handleClear,
-      handleFocus,
-      handleKeyUp,
-    } = this.handlers;
-    const {
-      intl,
-      submitted,
-      value,
-    } = this.props;
-    const { expanded } = this.state;
-    const active = value && value.length || submitted;
-    const computedClass = classNames('drawer--search', { active });
-
-    return (
-      <div className={computedClass}>
-        <label>
-          <span {...hiddenComponent}>
-            <FormattedMessage {...messages.placeholder} />
-          </span>
-          <input
-            type='text'
-            placeholder={intl.formatMessage(messages.placeholder)}
-            value={value || ''}
-            onChange={handleChange}
-            onKeyUp={handleKeyUp}
-            onFocus={handleFocus}
-            onBlur={handleBlur}
-          />
-        </label>
-        <div
-          aria-label={intl.formatMessage(messages.placeholder)}
-          className='icon'
-          onClick={handleClear}
-          role='button'
-          tabIndex='0'
-        >
-          <Icon icon='search' />
-          <Icon icon='times-circle' />
-        </div>
-        <Overlay
-          placement='bottom'
-          show={expanded && !active}
-          target={this}
-        ><DrawerSearchPopout /></Overlay>
-      </div>
-    );
-  }
-
-}
-
-//  Props.
-DrawerSearch.propTypes = {
-  value: PropTypes.string,
-  submitted: PropTypes.bool,
-  onChange: PropTypes.func,
-  onSubmit: PropTypes.func,
-  onClear: PropTypes.func,
-  onShow: PropTypes.func,
-  intl: PropTypes.object,
-};
diff --git a/app/javascript/flavours/glitch/features/drawer/search/popout/index.js b/app/javascript/flavours/glitch/features/drawer/search/popout/index.js
deleted file mode 100644
index fec090b64..000000000
--- a/app/javascript/flavours/glitch/features/drawer/search/popout/index.js
+++ /dev/null
@@ -1,109 +0,0 @@
-//  Package imports.
-import PropTypes from 'prop-types';
-import React from 'react';
-import {
-  FormattedMessage,
-  defineMessages,
-} from 'react-intl';
-import spring from 'react-motion/lib/spring';
-
-//  Utils.
-import Motion from 'flavours/glitch/util/optional_motion';
-import { searchEnabled } from 'flavours/glitch/util/initial_state';
-
-//  Messages.
-const messages = defineMessages({
-  format: {
-    defaultMessage: 'Advanced search format',
-    id: 'search_popout.search_format',
-  },
-  hashtag: {
-    defaultMessage: 'hashtag',
-    id: 'search_popout.tips.hashtag',
-  },
-  status: {
-    defaultMessage: 'status',
-    id: 'search_popout.tips.status',
-  },
-  text: {
-    defaultMessage: 'Simple text returns matching display names, usernames and hashtags',
-    id: 'search_popout.tips.text',
-  },
-  full_text: {
-    defaultMessage: 'Simple text returns statuses you have written, favourited, boosted, or have been mentioned in, as well as matching usernames, display names, and hashtags.',
-    id: 'search_popout.tips.full_text',
-  },
-  user: {
-    defaultMessage: 'user',
-    id: 'search_popout.tips.user',
-  },
-});
-
-//  The spring used by our motion.
-const motionSpring = spring(1, { damping: 35, stiffness: 400 });
-
-//  The component.
-export default function DrawerSearchPopout ({ style }) {
-
-  //  The result.
-  return (
-    <div
-      className='drawer--search--popout'
-      style={{
-        ...style,
-        position: 'absolute',
-        width: 285,
-      }}
-    >
-      <Motion
-        defaultStyle={{
-          opacity: 0,
-          scaleX: 0.85,
-          scaleY: 0.75,
-        }}
-        style={{
-          opacity: motionSpring,
-          scaleX: motionSpring,
-          scaleY: motionSpring,
-        }}
-      >
-        {({ opacity, scaleX, scaleY }) => (
-          <div
-            style={{
-              opacity: opacity,
-              transform: `scale(${scaleX}, ${scaleY})`,
-            }}
-          >
-            <h4><FormattedMessage {...messages.format} /></h4>
-            <ul>
-              <li>
-                <em>#example</em>
-                {' '}
-                <FormattedMessage {...messages.hashtag} />
-              </li>
-              <li>
-                <em>@username@domain</em>
-                {' '}
-                <FormattedMessage {...messages.user} />
-              </li>
-              <li>
-                <em>URL</em>
-                {' '}
-                <FormattedMessage {...messages.user} />
-              </li>
-              <li>
-                <em>URL</em>
-                {' '}
-                <FormattedMessage {...messages.status} />
-              </li>
-            </ul>
-            { searchEnabled ? <FormattedMessage {...messages.full_text} /> : <FormattedMessage {...messages.text} /> }
-          </div>
-        )}
-      </Motion>
-    </div>
-  );
-}
-
-//  Props.
-DrawerSearchPopout.propTypes = { style: PropTypes.object };
diff --git a/app/javascript/flavours/glitch/features/keyboard_shortcuts/index.js b/app/javascript/flavours/glitch/features/keyboard_shortcuts/index.js
index 75eb5653c..2935a6021 100644
--- a/app/javascript/flavours/glitch/features/keyboard_shortcuts/index.js
+++ b/app/javascript/flavours/glitch/features/keyboard_shortcuts/index.js
@@ -1,6 +1,7 @@
 import React from 'react';
 import Column from 'flavours/glitch/features/ui/components/column';
 import ColumnBackButtonSlim from 'flavours/glitch/components/column_back_button_slim';
+import { connect } from 'react-redux';
 import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
 import PropTypes from 'prop-types';
 import ImmutablePureComponent from 'react-immutable-pure-component';
@@ -9,16 +10,22 @@ const messages = defineMessages({
   heading: { id: 'keyboard_shortcuts.heading', defaultMessage: 'Keyboard Shortcuts' },
 });
 
+const mapStateToProps = state => ({
+  collapseEnabled: state.getIn(['local_settings', 'collapsed', 'enabled']),
+});
+
+@connect(mapStateToProps)
 @injectIntl
 export default class KeyboardShortcuts extends ImmutablePureComponent {
 
   static propTypes = {
     intl: PropTypes.object.isRequired,
     multiColumn: PropTypes.bool,
+    collapseEnabled: PropTypes.bool,
   };
 
   render () {
-    const { intl } = this.props;
+    const { intl, collapseEnabled } = this.props;
 
     return (
       <Column icon='question' heading={intl.formatMessage(messages.heading)}>
@@ -53,6 +60,10 @@ export default class KeyboardShortcuts extends ImmutablePureComponent {
                 <td><FormattedMessage id='keyboard_shortcuts.boost' defaultMessage='to boost' /></td>
               </tr>
               <tr>
+                <td><kbd>d</kbd></td>
+                <td><FormattedMessage id='keyboard_shortcuts.bookmark' defaultMessage='to bookmark' /></td>
+              </tr>
+              <tr>
                 <td><kbd>enter</kbd>, <kbd>o</kbd></td>
                 <td><FormattedMessage id='keyboard_shortcuts.enter' defaultMessage='to open status' /></td>
               </tr>
@@ -60,6 +71,12 @@ export default class KeyboardShortcuts extends ImmutablePureComponent {
                 <td><kbd>x</kbd></td>
                 <td><FormattedMessage id='keyboard_shortcuts.toggle_hidden' defaultMessage='to show/hide text behind CW' /></td>
               </tr>
+              {collapseEnabled && (
+                <tr>
+                  <td><kbd>shift</kbd>+<kbd>x</kbd></td>
+                  <td><FormattedMessage id='keyboard_shortcuts.toggle_collapse' defaultMessage='to collapse/uncollapse toots' /></td>
+                </tr>
+              )}
               <tr>
                 <td><kbd>up</kbd>, <kbd>k</kbd></td>
                 <td><FormattedMessage id='keyboard_shortcuts.up' defaultMessage='to move up in the list' /></td>
diff --git a/app/javascript/flavours/glitch/features/local_settings/page/index.js b/app/javascript/flavours/glitch/features/local_settings/page/index.js
index bc4ad359c..a13bffa3a 100644
--- a/app/javascript/flavours/glitch/features/local_settings/page/index.js
+++ b/app/javascript/flavours/glitch/features/local_settings/page/index.js
@@ -42,6 +42,15 @@ export default class LocalSettingsPage extends React.PureComponent {
         >
           <FormattedMessage id='settings.show_reply_counter' defaultMessage='Display an estimate of the reply count' />
         </LocalSettingsPageItem>
+        <LocalSettingsPageItem
+          settings={settings}
+          item={['hicolor_privacy_icons']}
+          id='mastodon-settings--hicolor_privacy_icons'
+          onChange={onChange}
+        >
+          <FormattedMessage id='settings.hicolor_privacy_icons' defaultMessage='High color privacy icons' />
+          <span className='hint'><FormattedMessage id='settings.hicolor_privacy_icons.hint' defaultMessage="Display privacy icons in bright and easily distinguishable colors" /></span>
+        </LocalSettingsPageItem>
         <section>
           <h2><FormattedMessage id='settings.notifications_opts' defaultMessage='Notifications options' /></h2>
           <LocalSettingsPageItem
diff --git a/app/javascript/flavours/glitch/features/notifications/index.js b/app/javascript/flavours/glitch/features/notifications/index.js
index 6a149927c..f2a1ccc3b 100644
--- a/app/javascript/flavours/glitch/features/notifications/index.js
+++ b/app/javascript/flavours/glitch/features/notifications/index.js
@@ -133,18 +133,24 @@ export default 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/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/standalone/compose/index.js b/app/javascript/flavours/glitch/features/standalone/compose/index.js
index a77b59448..b33c21cb5 100644
--- a/app/javascript/flavours/glitch/features/standalone/compose/index.js
+++ b/app/javascript/flavours/glitch/features/standalone/compose/index.js
@@ -1,5 +1,5 @@
 import React from 'react';
-import Composer from 'flavours/glitch/features/composer';
+import ComposeFormContainer from 'flavours/glitch/features/compose/containers/compose_form_container';
 import NotificationsContainer from 'flavours/glitch/features/ui/containers/notifications_container';
 import LoadingBarContainer from 'flavours/glitch/features/ui/containers/loading_bar_container';
 import ModalContainer from 'flavours/glitch/features/ui/containers/modal_container';
@@ -9,7 +9,7 @@ export default class Compose extends React.PureComponent {
   render () {
     return (
       <div>
-        <Composer />
+        <ComposeFormContainer />
         <NotificationsContainer />
         <ModalContainer />
         <LoadingBarContainer className='loading-bar' />
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 e9130b1b0..03d98fde8 100644
--- a/app/javascript/flavours/glitch/features/status/components/detailed_status.js
+++ b/app/javascript/flavours/glitch/features/status/components/detailed_status.js
@@ -42,7 +42,9 @@ export default class DetailedStatus extends ImmutablePureComponent {
   handleAccountClick = (e) => {
     if (e.button === 0 && !(e.ctrlKey || e.altKey || e.metaKey) && this.context.router) {
       e.preventDefault();
-      this.context.router.history.push(`/accounts/${this.props.status.getIn(['account', 'id'])}`);
+      let state = {...this.context.router.history.location.state};
+      state.mastodonBackSteps = (state.mastodonBackSteps || 0) + 1;
+      this.context.router.history.push(`/accounts/${this.props.status.getIn(['account', 'id'])}`, state);
     }
 
     e.stopPropagation();
@@ -51,7 +53,9 @@ export default class DetailedStatus extends ImmutablePureComponent {
   parseClick = (e, destination) => {
     if (e.button === 0 && !(e.ctrlKey || e.altKey || e.metaKey) && this.context.router) {
       e.preventDefault();
-      this.context.router.history.push(destination);
+      let state = {...this.context.router.history.location.state};
+      state.mastodonBackSteps = (state.mastodonBackSteps || 0) + 1;
+      this.context.router.history.push(destination, state);
     }
 
     e.stopPropagation();
@@ -108,7 +112,7 @@ export default class DetailedStatus extends ImmutablePureComponent {
       return null;
     }
 
-    let media           = '';
+    let media           = null;
     let mediaIcon       = null;
     let applicationLink = '';
     let reblogLink = '';
@@ -121,6 +125,7 @@ export default class DetailedStatus extends ImmutablePureComponent {
 
     if (status.get('poll')) {
       media = <PollContainer pollId={status.get('poll')} />;
+      mediaIcon = 'tasks';
     } 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')} />;
@@ -129,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
@@ -157,7 +163,10 @@ export default class DetailedStatus extends ImmutablePureComponent {
         );
         mediaIcon = 'picture-o';
       }
-    } else media = <Card onOpenMedia={this.props.onOpenMedia} card={status.get('card', null)} />;
+    } else if (status.get('card')) {
+      media = <Card onOpenMedia={this.props.onOpenMedia} card={status.get('card')} />;
+      mediaIcon = 'link';
+    }
 
     if (status.get('application')) {
       applicationLink = <span> · <a className='detailed-status__application' href={status.getIn(['application', 'website'])} target='_blank' rel='noopener'>{status.getIn(['application', 'name'])}</a></span>;
diff --git a/app/javascript/flavours/glitch/features/status/index.js b/app/javascript/flavours/glitch/features/status/index.js
index 7f8f02188..57d70db1a 100644
--- a/app/javascript/flavours/glitch/features/status/index.js
+++ b/app/javascript/flavours/glitch/features/status/index.js
@@ -325,28 +325,34 @@ export default class Status extends ImmutablePureComponent {
     this.handleReblogClick(this.props.status);
   }
 
+  handleHotkeyBookmark = () => {
+    this.handleBookmarkClick(this.props.status);
+  }
+
   handleHotkeyMention = e => {
     e.preventDefault();
     this.handleMentionClick(this.props.status);
   }
 
   handleHotkeyOpenProfile = () => {
-    this.context.router.history.push(`/accounts/${this.props.status.getIn(['account', 'id'])}`);
+    let state = {...this.context.router.history.location.state};
+    state.mastodonBackSteps = (state.mastodonBackSteps || 0) + 1;
+    this.context.router.history.push(`/accounts/${this.props.status.getIn(['account', 'id'])}`, state);
   }
 
   handleMoveUp = id => {
     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);
       }
     }
   }
@@ -355,23 +361,29 @@ export default 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();
     }
   }
@@ -461,6 +473,7 @@ export default class Status extends ImmutablePureComponent {
       reply: this.handleHotkeyReply,
       favourite: this.handleHotkeyFavourite,
       boost: this.handleHotkeyBoost,
+      bookmark: this.handleHotkeyBookmark,
       mention: this.handleHotkeyMention,
       openProfile: this.handleHotkeyOpenProfile,
       toggleSpoiler: this.handleExpandedToggle,
diff --git a/app/javascript/flavours/glitch/features/ui/components/actions_modal.js b/app/javascript/flavours/glitch/features/ui/components/actions_modal.js
index 9ac6dcf49..724f1c764 100644
--- a/app/javascript/flavours/glitch/features/ui/components/actions_modal.js
+++ b/app/javascript/flavours/glitch/features/ui/components/actions_modal.js
@@ -114,7 +114,7 @@ export default class ActionsModal extends ImmutablePureComponent {
       <div className='modal-root__modal actions-modal'>
         {status}
 
-        <ul>
+        <ul className={classNames({ 'with-status': !!status })}>
           {this.props.actions.map(this.renderAction)}
         </ul>
       </div>
diff --git a/app/javascript/flavours/glitch/features/ui/components/boost_modal.js b/app/javascript/flavours/glitch/features/ui/components/boost_modal.js
index 9652bcb2d..0a914dce2 100644
--- a/app/javascript/flavours/glitch/features/ui/components/boost_modal.js
+++ b/app/javascript/flavours/glitch/features/ui/components/boost_modal.js
@@ -40,7 +40,9 @@ export default class BoostModal extends ImmutablePureComponent {
     if (e.button === 0) {
       e.preventDefault();
       this.props.onClose();
-      this.context.router.history.push(`/accounts/${this.props.status.getIn(['account', 'id'])}`);
+      let state = {...this.context.router.history.location.state};
+      state.mastodonBackSteps = (state.mastodonBackSteps || 0) + 1;
+      this.context.router.history.push(`/accounts/${this.props.status.getIn(['account', 'id'])}`, state);
     }
   }
 
diff --git a/app/javascript/flavours/glitch/features/ui/components/columns_area.js b/app/javascript/flavours/glitch/features/ui/components/columns_area.js
index 83b797305..0fe580b9b 100644
--- a/app/javascript/flavours/glitch/features/ui/components/columns_area.js
+++ b/app/javascript/flavours/glitch/features/ui/components/columns_area.js
@@ -12,13 +12,13 @@ import BundleContainer from '../containers/bundle_container';
 import ColumnLoading from './column_loading';
 import DrawerLoading from './drawer_loading';
 import BundleColumnError from './bundle_column_error';
-import { Drawer, Notifications, HomeTimeline, CommunityTimeline, PublicTimeline, HashtagTimeline, DirectTimeline, FavouritedStatuses, BookmarkedStatuses, ListTimeline } from 'flavours/glitch/util/async-components';
+import { Compose, Notifications, HomeTimeline, CommunityTimeline, PublicTimeline, HashtagTimeline, DirectTimeline, FavouritedStatuses, BookmarkedStatuses, ListTimeline } from 'flavours/glitch/util/async-components';
 
 import detectPassiveEvents from 'detect-passive-events';
 import { scrollRight } from 'flavours/glitch/util/scroll';
 
 const componentMap = {
-  'COMPOSE': Drawer,
+  'COMPOSE': Compose,
   'HOME': HomeTimeline,
   'NOTIFICATIONS': Notifications,
   'PUBLIC': PublicTimeline,
diff --git a/app/javascript/flavours/glitch/features/ui/components/favourite_modal.js b/app/javascript/flavours/glitch/features/ui/components/favourite_modal.js
index 70722411d..e0037a15f 100644
--- a/app/javascript/flavours/glitch/features/ui/components/favourite_modal.js
+++ b/app/javascript/flavours/glitch/features/ui/components/favourite_modal.js
@@ -40,7 +40,9 @@ export default class FavouriteModal extends ImmutablePureComponent {
     if (e.button === 0) {
       e.preventDefault();
       this.props.onClose();
-      this.context.router.history.push(`/accounts/${this.props.status.getIn(['account', 'id'])}`);
+      let state = {...this.context.router.history.location.state};
+      state.mastodonBackSteps = (state.mastodonBackSteps || 0) + 1;
+      this.context.router.history.push(`/accounts/${this.props.status.getIn(['account', 'id'])}`, state);
     }
   }
 
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/onboarding_modal.js b/app/javascript/flavours/glitch/features/ui/components/onboarding_modal.js
index 293daf77a..3db9ec77d 100644
--- a/app/javascript/flavours/glitch/features/ui/components/onboarding_modal.js
+++ b/app/javascript/flavours/glitch/features/ui/components/onboarding_modal.js
@@ -6,12 +6,14 @@ import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
 import ReactSwipeableViews from 'react-swipeable-views';
 import classNames from 'classnames';
 import Permalink from 'flavours/glitch/components/permalink';
-import { WrappedComponent as RawComposer } from 'flavours/glitch/features/composer';
-import DrawerAccount from 'flavours/glitch/features/drawer/account';
-import DrawerSearch from 'flavours/glitch/features/drawer/search';
+import ComposeForm from 'flavours/glitch/features/compose/components/compose_form';
+import DrawerAccount from 'flavours/glitch/features/compose/components/navigation_bar';
+import Search from 'flavours/glitch/features/compose/components/search';
 import ColumnHeader from './column_header';
 import { me } from 'flavours/glitch/util/initial_state';
 
+const noop = () => { };
+
 const messages = defineMessages({
   home_title: { id: 'column.home', defaultMessage: 'Home' },
   notifications_title: { id: 'column.notifications', defaultMessage: 'Notifications' },
@@ -43,10 +45,11 @@ const PageTwo = ({ intl, myAccount }) => (
     <div className='figure non-interactive'>
       <div className='pseudo-drawer'>
         <DrawerAccount account={myAccount} />
-        <RawComposer
-          intl={intl}
+        <ComposeForm
           privacy='public'
           text='Awoo! #introductions'
+          spoilerText=''
+          suggestions={ [] }
         />
       </div>
     </div>
@@ -63,7 +66,13 @@ PageTwo.propTypes = {
 const PageThree = ({ intl, myAccount }) => (
   <div className='onboarding-modal__page onboarding-modal__page-three'>
     <div className='figure non-interactive'>
-      <DrawerSearch intl={intl} />
+      <Search
+        value=''
+        onChange={noop}
+        onSubmit={noop}
+        onClear={noop}
+        onShow={noop}
+      />
 
       <div className='pseudo-drawer'>
         <DrawerAccount account={myAccount} />
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/ui/index.js b/app/javascript/flavours/glitch/features/ui/index.js
index a19b3abf1..13c71337a 100644
--- a/app/javascript/flavours/glitch/features/ui/index.js
+++ b/app/javascript/flavours/glitch/features/ui/index.js
@@ -19,7 +19,7 @@ import ColumnsAreaContainer from './containers/columns_area_container';
 import classNames from 'classnames';
 import Favico from 'favico.js';
 import {
-  Drawer,
+  Compose,
   Status,
   GettingStarted,
   KeyboardShortcuts,
@@ -68,6 +68,7 @@ const mapStateToProps = state => ({
   dropdownMenuIsOpen: state.getIn(['dropdown_menu', 'openId']) !== null,
   unreadNotifications: state.getIn(['notifications', 'unread']),
   showFaviconBadge: state.getIn(['local_settings', 'notifications', 'favicon_badge']),
+  hicolorPrivacyIcons: state.getIn(['local_settings', 'hicolor_privacy_icons']),
 });
 
 const keyMap = {
@@ -98,6 +99,8 @@ const keyMap = {
   goToMuted: 'g m',
   goToRequests: 'g r',
   toggleSpoiler: 'x',
+  bookmark: 'd',
+  toggleCollapse: 'shift+x',
 };
 
 @connect(mapStateToProps)
@@ -341,11 +344,16 @@ export default class UI extends React.Component {
   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();
       }
     }
@@ -439,6 +447,7 @@ export default class UI extends React.Component {
       'wide': isWide,
       'system-font': this.props.systemFontUi,
       'navbar-under': navbarUnder,
+      'hicolor-privacy-icons': this.props.hicolorPrivacyIcons,
     });
 
     const handlers = {
@@ -483,9 +492,9 @@ export default class UI extends React.Component {
               <WrappedRoute path='/bookmarks' component={BookmarkedStatuses} content={children} />
               <WrappedRoute path='/pinned' component={PinnedStatuses} content={children} />
 
-              <WrappedRoute path='/search' component={Drawer} content={children} componentParams={{ isSearchPage: true }} />
+              <WrappedRoute path='/search' component={Compose} content={children} componentParams={{ isSearchPage: true }} />
 
-              <WrappedRoute path='/statuses/new' component={Drawer} content={children} />
+              <WrappedRoute path='/statuses/new' component={Compose} content={children} />
               <WrappedRoute path='/statuses/:statusId' exact component={Status} content={children} />
               <WrappedRoute path='/statuses/:statusId/reblogs' component={Reblogs} content={children} />
               <WrappedRoute path='/statuses/:statusId/favourites' component={Favourites} content={children} />
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/reducers/compose.js b/app/javascript/flavours/glitch/reducers/compose.js
index a79b0dd24..009e1fee7 100644
--- a/app/javascript/flavours/glitch/reducers/compose.js
+++ b/app/javascript/flavours/glitch/reducers/compose.js
@@ -214,13 +214,15 @@ function removeMedia(state, mediaId) {
   });
 };
 
-const insertSuggestion = (state, position, token, completion) => {
+const insertSuggestion = (state, position, token, completion, path) => {
   return state.withMutations(map => {
-    map.update('text', oldText => `${oldText.slice(0, position)}${completion}${completion[0] === ':' ? '\u200B' : ' '}${oldText.slice(position + token.length)}`);
+    map.updateIn(path, oldText => `${oldText.slice(0, position)}${completion}${completion[0] === ':' ? '\u200B' : ' '}${oldText.slice(position + token.length)}`);
     map.set('suggestion_token', null);
-    map.update('suggestions', ImmutableList(), list => list.clear());
-    map.set('focusDate', new Date());
-    map.set('caretPosition', position + completion.length + 1);
+    map.set('suggestions', ImmutableList());
+    if (path.length === 1 && path[0] === 'text') {
+      map.set('focusDate', new Date());
+      map.set('caretPosition', position + completion.length + 1);
+    }
     map.set('idempotencyKey', uuid());
   });
 };
@@ -397,7 +399,7 @@ export default function compose(state = initialState, action) {
   case COMPOSE_SUGGESTIONS_READY:
     return state.set('suggestions', ImmutableList(action.accounts ? action.accounts.map(item => item.id) : action.emojis)).set('suggestion_token', action.token);
   case COMPOSE_SUGGESTION_SELECT:
-    return insertSuggestion(state, action.position, action.token, action.completion);
+    return insertSuggestion(state, action.position, action.token, action.completion, action.path);
   case COMPOSE_SUGGESTION_TAGS_UPDATE:
     return updateSuggestionTags(state, action.token);
   case COMPOSE_TAG_HISTORY_UPDATE:
diff --git a/app/javascript/flavours/glitch/reducers/index.js b/app/javascript/flavours/glitch/reducers/index.js
index 76b38adb4..45b93b92c 100644
--- a/app/javascript/flavours/glitch/reducers/index.js
+++ b/app/javascript/flavours/glitch/reducers/index.js
@@ -28,6 +28,7 @@ import lists from './lists';
 import listEditor from './list_editor';
 import listAdder from './list_adder';
 import filters from './filters';
+import suggestions from './suggestions';
 import pinnedAccountsEditor from './pinned_accounts_editor';
 import polls from './polls';
 import identity_proofs from './identity_proofs';
@@ -63,6 +64,7 @@ const reducers = {
   listEditor,
   listAdder,
   filters,
+  suggestions,
   pinnedAccountsEditor,
   polls,
 };
diff --git a/app/javascript/flavours/glitch/reducers/local_settings.js b/app/javascript/flavours/glitch/reducers/local_settings.js
index ef694d4ea..8dea4d8f6 100644
--- a/app/javascript/flavours/glitch/reducers/local_settings.js
+++ b/app/javascript/flavours/glitch/reducers/local_settings.js
@@ -18,6 +18,7 @@ const initialState = ImmutableMap({
   confirm_before_clearing_draft: true,
   preselect_on_reply: true,
   inline_preview_cards: true,
+  hicolor_privacy_icons: false,
   content_warnings : ImmutableMap({
     auto_unfold : false,
     filter      : null,
diff --git a/app/javascript/flavours/glitch/reducers/suggestions.js b/app/javascript/flavours/glitch/reducers/suggestions.js
new file mode 100644
index 000000000..9f4b89d58
--- /dev/null
+++ b/app/javascript/flavours/glitch/reducers/suggestions.js
@@ -0,0 +1,30 @@
+import {
+  SUGGESTIONS_FETCH_REQUEST,
+  SUGGESTIONS_FETCH_SUCCESS,
+  SUGGESTIONS_FETCH_FAIL,
+  SUGGESTIONS_DISMISS,
+} from '../actions/suggestions';
+import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable';
+
+const initialState = ImmutableMap({
+  items: ImmutableList(),
+  isLoading: false,
+});
+
+export default function suggestionsReducer(state = initialState, action) {
+  switch(action.type) {
+  case SUGGESTIONS_FETCH_REQUEST:
+    return state.set('isLoading', true);
+  case SUGGESTIONS_FETCH_SUCCESS:
+    return state.withMutations(map => {
+      map.set('items', fromJS(action.accounts.map(x => x.id)));
+      map.set('isLoading', false);
+    });
+  case SUGGESTIONS_FETCH_FAIL:
+    return state.set('isLoading', false);
+  case SUGGESTIONS_DISMISS:
+    return state.update('items', list => list.filterNot(id => id === action.id));
+  default:
+    return state;
+  }
+};
diff --git a/app/javascript/flavours/glitch/styles/accessibility.scss b/app/javascript/flavours/glitch/styles/accessibility.scss
index 4fe5c8b1c..35e91da80 100644
--- a/app/javascript/flavours/glitch/styles/accessibility.scss
+++ b/app/javascript/flavours/glitch/styles/accessibility.scss
@@ -11,3 +11,25 @@ $emojis-requiring-outlines: '8ball' 'ant' 'back' 'black_circle' 'black_heart' 'b
     }
   }
 }
+
+.hicolor-privacy-icons {
+  .status__visibility-icon.fa-globe,
+  .composer--options--dropdown--content--item .fa-globe {
+    color: #1976D2;
+  }
+
+  .status__visibility-icon.fa-unlock,
+  .composer--options--dropdown--content--item .fa-unlock {
+    color: #388E3C;
+  }
+
+  .status__visibility-icon.fa-lock,
+  .composer--options--dropdown--content--item .fa-lock {
+    color: #FFA000;
+  }
+
+  .status__visibility-icon.fa-envelope,
+  .composer--options--dropdown--content--item .fa-envelope {
+    color: #D32F2F;
+  }
+}
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 f0729bedc..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;
@@ -128,11 +138,12 @@
   }
 }
 
-.composer--textarea {
+.composer--textarea,
+.autosuggest-input {
   position: relative;
 
-  & > label {
-    .textarea {
+  label {
+    .autosuggest-textarea__textarea {
       display: block;
       box-sizing: border-box;
       margin: 0;
@@ -186,7 +197,7 @@
   }
 }
 
-.composer--textarea--suggestions {
+.autosuggest-textarea__suggestions {
   display: block;
   position: absolute;
   box-sizing: border-box;
@@ -199,11 +210,14 @@
   box-shadow: 4px 4px 6px rgba($base-shadow-color, 0.4);
   font-size: 14px;
   z-index: 99;
+  display: none;
+}
 
-  &[hidden] { display: none }
+.autosuggest-textarea__suggestions--visible {
+  display: block;
 }
 
-.composer--textarea--suggestions--item {
+.autosuggest-textarea__suggestions__item {
   display: flex;
   flex-direction: row;
   align-items: center;
@@ -238,16 +252,14 @@
 }
 
 .composer--upload_form {
-  padding: 5px;
-  color: $inverted-text-color;
-  background: $simple-background-color;
-  font-size: 14px;
+  overflow: hidden;
 
   & > .content {
     display: flex;
     flex-direction: row;
     flex-wrap: wrap;
     font-family: inherit;
+    padding: 5px;
     overflow: hidden;
   }
 }
diff --git a/app/javascript/flavours/glitch/styles/components/drawer.scss b/app/javascript/flavours/glitch/styles/components/drawer.scss
index d22783b94..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 {
@@ -185,12 +186,6 @@
 }
 
 .drawer--results {
-  position: absolute;
-  top: 0;
-  bottom: 0;
-  left: 0;
-  right: 0;
-  padding: 0;
   background: $ui-base-color;
   overflow-x: hidden;
   overflow-y: auto;
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 e8011bde9..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;
@@ -147,6 +165,7 @@
   position: relative;
   z-index: 1;
   object-fit: contain;
+  user-select: none;
 
   &:not(.letterbox) {
     height: 100%;
@@ -251,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;
 }
@@ -500,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/modal.scss b/app/javascript/flavours/glitch/styles/components/modal.scss
index fece8593b..65b2e75f0 100644
--- a/app/javascript/flavours/glitch/styles/components/modal.scss
+++ b/app/javascript/flavours/glitch/styles/components/modal.scss
@@ -415,14 +415,21 @@
   position: relative;
   flex-direction: column;
 
+  .status__relative-time {
+    color: $dark-text-color;
+    float: right;
+    font-size: 14px;
+    width: auto;
+    margin: initial;
+    padding: initial;
+  }
+
   .status__display-name {
     display: flex;
   }
 
   .status__avatar {
-    height: 28px;
-    left: 10px;
-    top: 10px;
+    height: 48px;
     width: 48px;
   }
 
@@ -488,14 +495,6 @@
   font-size: 14px;
 }
 
-.confirmation-modal {
-  max-width: 85vw;
-
-  @media screen and (min-width: 480px) {
-    max-width: 380px;
-  }
-}
-
 .mute-modal {
   line-height: 24px;
 }
@@ -629,6 +628,11 @@
   ul {
     overflow-y: auto;
     flex-shrink: 0;
+    max-height: 80vh;
+
+    &.with-status {
+      max-height: calc(80vh - 75px);
+    }
 
     li:empty {
       margin: 0;
@@ -685,6 +689,10 @@
       color: darken($lighter-text-color, 4%);
     }
   }
+
+  .confirmation-modal__secondary-button {
+    flex-shrink: 1;
+  }
 }
 
 .confirmation-modal__do_not_ask_again {
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 b73dd3d09..fb031258f 100644
--- a/app/javascript/flavours/glitch/styles/components/status.scss
+++ b/app/javascript/flavours/glitch/styles/components/status.scss
@@ -22,7 +22,7 @@
     margin: -3px 0 0;
   }
 
-  p {
+  p, pre, blockquote {
     margin-bottom: 20px;
     white-space: pre-wrap;
 
@@ -31,6 +31,66 @@
     }
   }
 
+  .status__content__text {
+    h1, h2, h3, h4, h5 {
+      margin-top: 20px;
+      margin-bottom: 20px;
+    }
+
+    h1, h2 {
+      font-weight: 700;
+      font-size: 18px;
+    }
+
+    h2 {
+      font-size: 16px;
+    }
+
+    h3, h4, h5 {
+      font-weight: 500;
+    }
+
+    blockquote {
+      padding-left: 10px;
+      border-left: 3px solid $darker-text-color;
+      color: $darker-text-color;
+      white-space: normal;
+
+      p:last-child {
+        margin-bottom: 0;
+      }
+    }
+
+    b, strong {
+      font-weight: 700;
+    }
+
+    em, i {
+      font-style: italic;
+    }
+
+    sub {
+      font-size: smaller;
+      text-align: sub;
+    }
+
+    ul, ol {
+      margin-left: 1em;
+
+      p {
+        margin: 0;
+      }
+    }
+
+    ul {
+      list-style-type: disc;
+    }
+
+    ol {
+      list-style-type: decimal;
+    }
+  }
+
   a {
     color: $secondary-text-color;
     text-decoration: none;
@@ -592,7 +652,8 @@
 
 .muted {
   .status__content p,
-  .status__content a {
+  .status__content a,
+  .status__content_text {
     color: $dark-text-color;
   }
 
@@ -644,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;
@@ -655,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/mastodon-light/diff.scss b/app/javascript/flavours/glitch/styles/mastodon-light/diff.scss
index 6f105d3fa..224272f24 100644
--- a/app/javascript/flavours/glitch/styles/mastodon-light/diff.scss
+++ b/app/javascript/flavours/glitch/styles/mastodon-light/diff.scss
@@ -196,11 +196,11 @@
   border-color: $ui-base-color;
 }
 
-.composer--textarea--suggestions {
+.autosuggest-textarea__suggestions {
   background: lighten($ui-base-color, 10%)
 }
 
-.composer--textarea--suggestions--item {
+.autosuggest-textarea__suggestions__item {
   &:hover,
   &:focus,
   &:active,
diff --git a/app/javascript/flavours/glitch/styles/polls.scss b/app/javascript/flavours/glitch/styles/polls.scss
index 6030e1e98..315fd5782 100644
--- a/app/javascript/flavours/glitch/styles/polls.scss
+++ b/app/javascript/flavours/glitch/styles/polls.scss
@@ -37,11 +37,14 @@
       display: none;
     }
 
+    .autossugest-input {
+      flex: 1 1 auto;
+    }
+
     input[type=text] {
       display: block;
       box-sizing: border-box;
-      flex: 1 1 auto;
-      width: 20px;
+      width: 100%;
       font-size: 14px;
       color: $inverted-text-color;
       display: block;
@@ -64,6 +67,7 @@
     &.editable {
       display: flex;
       align-items: center;
+      overflow: visible;
     }
   }
 
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;
diff --git a/app/javascript/flavours/glitch/theme.yml b/app/javascript/flavours/glitch/theme.yml
index 587cc0f1e..06e26ade2 100644
--- a/app/javascript/flavours/glitch/theme.yml
+++ b/app/javascript/flavours/glitch/theme.yml
@@ -11,7 +11,7 @@ pack:
   home:
     filename: packs/home.js
     preload:
-    - flavours/glitch/async/drawer
+    - flavours/glitch/async/compose
     - flavours/glitch/async/getting_started
     - flavours/glitch/async/home_timeline
     - flavours/glitch/async/notifications
diff --git a/app/javascript/flavours/glitch/util/async-components.js b/app/javascript/flavours/glitch/util/async-components.js
index e96af845f..094952204 100644
--- a/app/javascript/flavours/glitch/util/async-components.js
+++ b/app/javascript/flavours/glitch/util/async-components.js
@@ -2,8 +2,8 @@ export function EmojiPicker () {
   return import(/* webpackChunkName: "flavours/glitch/async/emoji_picker" */'flavours/glitch/util/emoji/emoji_picker');
 }
 
-export function Drawer () {
-  return import(/* webpackChunkName: "flavours/glitch/async/drawer" */'flavours/glitch/features/drawer');
+export function Compose () {
+  return import(/* webpackChunkName: "flavours/glitch/async/compose" */'flavours/glitch/features/compose');
 }
 
 export function Notifications () {