about summary refs log tree commit diff
path: root/app/javascript
diff options
context:
space:
mode:
authorkibigo! <marrus-sh@users.noreply.github.com>2017-12-23 22:16:45 -0800
committerkibigo! <marrus-sh@users.noreply.github.com>2018-01-04 18:21:59 -0800
commit924ffe81d477a8cf890c8117efb94b908760bccc (patch)
treeacefef7362929f4495424fbb037c3be59cca318f /app/javascript
parentfc884d015a1a2d6c31976af3d63039390fa15939 (diff)
WIPgit status <Compose> Refactor; <Composer> ed.
Diffstat (limited to 'app/javascript')
-rw-r--r--app/javascript/flavours/glitch/actions/compose.js17
-rw-r--r--app/javascript/flavours/glitch/components/account.js26
-rw-r--r--app/javascript/flavours/glitch/components/autosuggest_emoji.js42
-rw-r--r--app/javascript/flavours/glitch/components/autosuggest_textarea.js223
-rw-r--r--app/javascript/flavours/glitch/components/icon.js26
-rw-r--r--app/javascript/flavours/glitch/components/text_icon_button.js (renamed from app/javascript/flavours/glitch/features/compose/components/text_icon_button.js)0
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/advanced_options.js62
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/advanced_options_toggle.js35
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/attach_options.js131
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/autosuggest_account.js24
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/character_counter.js25
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/compose_form.js286
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/dropdown.js77
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/privacy_dropdown.js200
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/reply_indicator.js67
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/upload.js96
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/upload_button.js77
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/upload_form.js29
-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/advanced_options_container.js20
-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.js71
-rw-r--r--app/javascript/flavours/glitch/features/compose/containers/emoji_picker_dropdown_container.js82
-rw-r--r--app/javascript/flavours/glitch/features/compose/containers/privacy_dropdown_container.js24
-rw-r--r--app/javascript/flavours/glitch/features/compose/containers/reply_indicator_container.js24
-rw-r--r--app/javascript/flavours/glitch/features/compose/containers/sensitive_button_container.js71
-rw-r--r--app/javascript/flavours/glitch/features/compose/containers/spoiler_button_container.js25
-rw-r--r--app/javascript/flavours/glitch/features/compose/containers/upload_button_container.js18
-rw-r--r--app/javascript/flavours/glitch/features/compose/containers/upload_container.js21
-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.js24
-rw-r--r--app/javascript/flavours/glitch/features/compose/index.js126
-rw-r--r--app/javascript/flavours/glitch/features/composer/index.js440
-rw-r--r--app/javascript/flavours/glitch/features/composer/options/dropdown/index.js243
-rw-r--r--app/javascript/flavours/glitch/features/composer/options/dropdown/item/index.js126
-rw-r--r--app/javascript/flavours/glitch/features/composer/options/index.js321
-rw-r--r--app/javascript/flavours/glitch/features/composer/publisher/index.js119
-rw-r--r--app/javascript/flavours/glitch/features/composer/reply/index.js106
-rw-r--r--app/javascript/flavours/glitch/features/composer/spoiler/index.js92
-rw-r--r--app/javascript/flavours/glitch/features/composer/textarea/index.js297
-rw-r--r--app/javascript/flavours/glitch/features/composer/textarea/suggestions/index.js41
-rw-r--r--app/javascript/flavours/glitch/features/composer/textarea/suggestions/item/index.js101
-rw-r--r--app/javascript/flavours/glitch/features/composer/upload_form/index.js54
-rw-r--r--app/javascript/flavours/glitch/features/composer/upload_form/item/index.js176
-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.js54
-rw-r--r--app/javascript/flavours/glitch/features/drawer/components/navigation_bar.js (renamed from app/javascript/flavours/glitch/features/compose/components/navigation_bar.js)0
-rw-r--r--app/javascript/flavours/glitch/features/drawer/components/search.js (renamed from app/javascript/flavours/glitch/features/compose/components/search.js)0
-rw-r--r--app/javascript/flavours/glitch/features/drawer/components/search_results.js (renamed from app/javascript/flavours/glitch/features/compose/components/search_results.js)0
-rw-r--r--app/javascript/flavours/glitch/features/drawer/containers/navigation_container.js (renamed from app/javascript/flavours/glitch/features/compose/containers/navigation_container.js)0
-rw-r--r--app/javascript/flavours/glitch/features/drawer/containers/search_container.js (renamed from app/javascript/flavours/glitch/features/compose/containers/search_container.js)0
-rw-r--r--app/javascript/flavours/glitch/features/drawer/containers/search_results_container.js (renamed from app/javascript/flavours/glitch/features/compose/containers/search_results_container.js)0
-rw-r--r--app/javascript/flavours/glitch/features/drawer/index.js198
-rw-r--r--app/javascript/flavours/glitch/features/emoji_picker/index.js (renamed from app/javascript/flavours/glitch/features/compose/components/emoji_picker_dropdown.js)80
-rw-r--r--app/javascript/flavours/glitch/features/standalone/compose/index.js4
-rw-r--r--app/javascript/flavours/glitch/reducers/compose.js2
-rw-r--r--app/javascript/flavours/glitch/styles/components/compose.scss0
-rw-r--r--app/javascript/flavours/glitch/styles/components/drawer.scss0
-rw-r--r--app/javascript/flavours/glitch/util/dom_helpers.js6
-rw-r--r--app/javascript/flavours/glitch/util/initial_state.js1
-rw-r--r--app/javascript/flavours/glitch/util/react_helpers.js21
-rw-r--r--app/javascript/flavours/glitch/util/redux_helpers.js7
64 files changed, 2588 insertions, 2002 deletions
diff --git a/app/javascript/flavours/glitch/actions/compose.js b/app/javascript/flavours/glitch/actions/compose.js
index 32746f27b..d87786008 100644
--- a/app/javascript/flavours/glitch/actions/compose.js
+++ b/app/javascript/flavours/glitch/actions/compose.js
@@ -316,21 +316,14 @@ export function readyComposeSuggestionsAccounts(token, accounts) {
 
 export function selectComposeSuggestion(position, token, suggestion) {
   return (dispatch, getState) => {
-    let completion, startPosition;
-
-    if (typeof suggestion === 'object' && suggestion.id) {
-      completion    = suggestion.native || suggestion.colons;
-      startPosition = position - 1;
-
-      dispatch(useEmoji(suggestion));
-    } else {
-      completion    = getState().getIn(['accounts', suggestion, 'acct']);
-      startPosition = position;
-    }
+    const completion = typeof suggestion === 'object' && suggestion.id ? (
+      dispatch(useEmoji(suggestion)),
+      suggestion.native || suggestion.colons
+    ) : '@' + getState().getIn(['accounts', suggestion, 'acct']);
 
     dispatch({
       type: COMPOSE_SUGGESTION_SELECT,
-      position: startPosition,
+      position,
       token,
       completion,
     });
diff --git a/app/javascript/flavours/glitch/components/account.js b/app/javascript/flavours/glitch/components/account.js
index a1f075491..ced18b348 100644
--- a/app/javascript/flavours/glitch/components/account.js
+++ b/app/javascript/flavours/glitch/components/account.js
@@ -30,6 +30,7 @@ export default class Account extends ImmutablePureComponent {
     onMuteNotifications: PropTypes.func.isRequired,
     intl: PropTypes.object.isRequired,
     hidden: PropTypes.bool,
+    small: PropTypes.bool,
   };
 
   handleFollow = () => {
@@ -53,7 +54,12 @@ export default class Account extends ImmutablePureComponent {
   }
 
   render () {
-    const { account, intl, hidden } = this.props;
+    const {
+      account,
+      hidden,
+      intl,
+      small,
+    } = this.props;
 
     if (!account) {
       return <div />;
@@ -70,7 +76,7 @@ export default class Account extends ImmutablePureComponent {
 
     let buttons;
 
-    if (account.get('id') !== me && account.get('relationship', null) !== null) {
+    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']);
@@ -98,17 +104,23 @@ export default class Account extends ImmutablePureComponent {
       }
     }
 
-    return (
+    return small ? (
+      <div className='account small'>
+        <div className='account__avatar-wrapper'><Avatar account={account} size={18} /></div>
+        <DisplayName account={account} />
+      </div>
+    ) : (
       <div className='account'>
         <div className='account__wrapper'>
           <Permalink key={account.get('id')} className='account__display-name' href={account.get('url')} to={`/accounts/${account.get('id')}`}>
             <div className='account__avatar-wrapper'><Avatar account={account} size={36} /></div>
             <DisplayName account={account} />
           </Permalink>
-
-          <div className='account__relationship'>
-            {buttons}
-          </div>
+          {buttons ?
+            <div className='account__relationship'>
+              {buttons}
+            </div>
+          : null}
         </div>
       </div>
     );
diff --git a/app/javascript/flavours/glitch/components/autosuggest_emoji.js b/app/javascript/flavours/glitch/components/autosuggest_emoji.js
deleted file mode 100644
index 79e113d9c..000000000
--- a/app/javascript/flavours/glitch/components/autosuggest_emoji.js
+++ /dev/null
@@ -1,42 +0,0 @@
-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='autosuggest-emoji'>
-        <img
-          className='emojione'
-          src={url}
-          alt={emoji.native || emoji.colons}
-        />
-
-        {emoji.colons}
-      </div>
-    );
-  }
-
-}
diff --git a/app/javascript/flavours/glitch/components/autosuggest_textarea.js b/app/javascript/flavours/glitch/components/autosuggest_textarea.js
deleted file mode 100644
index a29b2c9c5..000000000
--- a/app/javascript/flavours/glitch/components/autosuggest_textarea.js
+++ /dev/null
@@ -1,223 +0,0 @@
-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 + 1, 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: 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;
-    }
-
-    switch(e.key) {
-    case 'Escape':
-      if (!suggestionsHidden) {
-        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);
-  }
-
-  onKeyUp = e => {
-    if (e.key === 'Escape' && this.state.suggestionsHidden) {
-      document.querySelector('.ui').parentElement.focus();
-    }
-
-    if (this.props.onKeyUp) {
-      this.props.onKeyUp(e);
-    }
-  }
-
-  onBlur = () => {
-    this.setState({ suggestionsHidden: 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.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 {
-      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, 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={this.onKeyUp}
-            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/icon.js b/app/javascript/flavours/glitch/components/icon.js
new file mode 100644
index 000000000..8f55a0115
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/icon.js
@@ -0,0 +1,26 @@
+//  Package imports.
+import classNames from 'classnames';
+import PropTypes from 'prop-types';
+import React from 'react';
+
+//  This just renders a FontAwesome icon.
+export default function Icon ({
+  className,
+  fullwidth,
+  icon,
+}) {
+  const computedClass = classNames('icon', 'fa', { 'fa-fw': fullwidth }, `fa-${icon}`, className);
+  return icon ? (
+    <span
+      aria-hidden='true'
+      className={computedClass}
+    />
+  ) : null;
+}
+
+//  Props.
+Icon.propTypes = {
+  className: PropTypes.string,
+  fullwidth: PropTypes.bool,
+  icon: PropTypes.string,
+};
diff --git a/app/javascript/flavours/glitch/features/compose/components/text_icon_button.js b/app/javascript/flavours/glitch/components/text_icon_button.js
index 9c8ffab1f..9c8ffab1f 100644
--- a/app/javascript/flavours/glitch/features/compose/components/text_icon_button.js
+++ b/app/javascript/flavours/glitch/components/text_icon_button.js
diff --git a/app/javascript/flavours/glitch/features/compose/components/advanced_options.js b/app/javascript/flavours/glitch/features/compose/components/advanced_options.js
deleted file mode 100644
index 045bad2e5..000000000
--- a/app/javascript/flavours/glitch/features/compose/components/advanced_options.js
+++ /dev/null
@@ -1,62 +0,0 @@
-//  Package imports.
-import React from 'react';
-import PropTypes from 'prop-types';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import { injectIntl, defineMessages } from 'react-intl';
-
-//  Our imports.
-import ComposeAdvancedOptionsToggle from './advanced_options_toggle';
-import ComposeDropdown from './dropdown';
-
-const messages = defineMessages({
-  local_only_short            :
-    { id: 'advanced-options.local-only.short', defaultMessage: 'Local-only' },
-  local_only_long             :
-    { id: 'advanced-options.local-only.long', defaultMessage: 'Do not post to other instances' },
-  advanced_options_icon_title :
-    { id: 'advanced_options.icon_title', defaultMessage: 'Advanced options' },
-});
-
-@injectIntl
-export default class ComposeAdvancedOptions extends React.PureComponent {
-
-  static propTypes = {
-    values   : ImmutablePropTypes.contains({
-      do_not_federate : PropTypes.bool.isRequired,
-    }).isRequired,
-    onChange : PropTypes.func.isRequired,
-    intl     : PropTypes.object.isRequired,
-  };
-
-  render () {
-    const { intl, values } = this.props;
-    const options = [
-      { icon: 'wifi', shortText: messages.local_only_short, longText: messages.local_only_long, name: 'do_not_federate' },
-    ];
-    const anyEnabled = values.some((enabled) => enabled);
-
-    const optionElems = options.map((option) => {
-      return (
-        <ComposeAdvancedOptionsToggle
-          onChange={this.props.onChange}
-          active={values.get(option.name)}
-          key={option.name}
-          name={option.name}
-          shortText={intl.formatMessage(option.shortText)}
-          longText={intl.formatMessage(option.longText)}
-        />
-      );
-    });
-
-    return (
-      <ComposeDropdown
-        title={intl.formatMessage(messages.advanced_options_icon_title)}
-        icon='home'
-        highlight={anyEnabled}
-      >
-        {optionElems}
-      </ComposeDropdown>
-    );
-  }
-
-}
diff --git a/app/javascript/flavours/glitch/features/compose/components/advanced_options_toggle.js b/app/javascript/flavours/glitch/features/compose/components/advanced_options_toggle.js
deleted file mode 100644
index 98b3b6a44..000000000
--- a/app/javascript/flavours/glitch/features/compose/components/advanced_options_toggle.js
+++ /dev/null
@@ -1,35 +0,0 @@
-//  Package imports.
-import React from 'react';
-import PropTypes from 'prop-types';
-import Toggle from 'react-toggle';
-
-export default class ComposeAdvancedOptionsToggle extends React.PureComponent {
-
-  static propTypes = {
-    onChange: PropTypes.func.isRequired,
-    active: PropTypes.bool.isRequired,
-    name: PropTypes.string.isRequired,
-    shortText: PropTypes.string.isRequired,
-    longText: PropTypes.string.isRequired,
-  }
-
-  onToggle = () => {
-    this.props.onChange(this.props.name);
-  }
-
-  render() {
-    const { active, shortText, longText } = this.props;
-    return (
-      <div role='button' tabIndex='0' className='advanced-options-dropdown__option' onClick={this.onToggle}>
-        <div className='advanced-options-dropdown__option__toggle'>
-          <Toggle checked={active} onChange={this.onToggle} />
-        </div>
-        <div className='advanced-options-dropdown__option__content'>
-          <strong>{shortText}</strong>
-          {longText}
-        </div>
-      </div>
-    );
-  }
-
-}
diff --git a/app/javascript/flavours/glitch/features/compose/components/attach_options.js b/app/javascript/flavours/glitch/features/compose/components/attach_options.js
deleted file mode 100644
index 6c7a1f55f..000000000
--- a/app/javascript/flavours/glitch/features/compose/components/attach_options.js
+++ /dev/null
@@ -1,131 +0,0 @@
-//  Package imports  //
-import React from 'react';
-import PropTypes from 'prop-types';
-import { connect } from 'react-redux';
-import { injectIntl, defineMessages } from 'react-intl';
-
-//  Our imports  //
-import ComposeDropdown from './dropdown';
-import { uploadCompose } from 'flavours/glitch/actions/compose';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import ImmutablePureComponent from 'react-immutable-pure-component';
-import { openModal } from 'flavours/glitch/actions/modal';
-
-const messages = defineMessages({
-  upload :
-    { id: 'compose.attach.upload', defaultMessage: 'Upload a file' },
-  doodle :
-    { id: 'compose.attach.doodle', defaultMessage: 'Draw something' },
-  attach :
-    { id: 'compose.attach', defaultMessage: 'Attach...' },
-});
-
-const mapStateToProps = state => ({
-  // This horrible expression is copied from vanilla upload_button_container
-  disabled: state.getIn(['compose', 'is_uploading']) || (state.getIn(['compose', 'media_attachments']).size > 3 || state.getIn(['compose', 'media_attachments']).some(m => m.get('type') === 'video')),
-  resetFileKey: state.getIn(['compose', 'resetFileKey']),
-  acceptContentTypes: state.getIn(['media_attachments', 'accept_content_types']),
-});
-
-const mapDispatchToProps = dispatch => ({
-  onSelectFile (files) {
-    dispatch(uploadCompose(files));
-  },
-  onOpenDoodle () {
-    dispatch(openModal('DOODLE', { noEsc: true }));
-  },
-});
-
-@injectIntl
-@connect(mapStateToProps, mapDispatchToProps)
-export default class ComposeAttachOptions extends ImmutablePureComponent {
-
-  static propTypes = {
-    intl     : PropTypes.object.isRequired,
-    resetFileKey: PropTypes.number,
-    acceptContentTypes: ImmutablePropTypes.listOf(PropTypes.string).isRequired,
-    disabled: PropTypes.bool,
-    onSelectFile: PropTypes.func.isRequired,
-    onOpenDoodle: PropTypes.func.isRequired,
-  };
-
-  handleItemClick = bt => {
-    if (bt === 'upload') {
-      this.fileElement.click();
-    }
-
-    if (bt === 'doodle') {
-      this.props.onOpenDoodle();
-    }
-
-    this.dropdown.setState({ open: false });
-  };
-
-  handleFileChange = (e) => {
-    if (e.target.files.length > 0) {
-      this.props.onSelectFile(e.target.files);
-    }
-  }
-
-  setFileRef = (c) => {
-    this.fileElement = c;
-  }
-
-  setDropdownRef = (c) => {
-    this.dropdown = c;
-  }
-
-  render () {
-    const { intl, resetFileKey, disabled, acceptContentTypes } = this.props;
-
-    const options = [
-      { icon: 'cloud-upload', text: messages.upload, name: 'upload' },
-      { icon: 'paint-brush', text: messages.doodle, name: 'doodle' },
-    ];
-
-    const optionElems = options.map((item) => {
-      const hdl = () => this.handleItemClick(item.name);
-      return (
-        <div
-          role='button'
-          tabIndex='0'
-          key={item.name}
-          onClick={hdl}
-          className='privacy-dropdown__option'
-        >
-          <div className='privacy-dropdown__option__icon'>
-            <i className={`fa fa-fw fa-${item.icon}`} />
-          </div>
-
-          <div className='privacy-dropdown__option__content'>
-            <strong>{intl.formatMessage(item.text)}</strong>
-          </div>
-        </div>
-      );
-    });
-
-    return (
-      <div>
-        <ComposeDropdown
-          title={intl.formatMessage(messages.attach)}
-          icon='paperclip'
-          disabled={disabled}
-          ref={this.setDropdownRef}
-        >
-          {optionElems}
-        </ComposeDropdown>
-        <input
-          key={resetFileKey}
-          ref={this.setFileRef}
-          type='file'
-          multiple={false}
-          accept={acceptContentTypes.toArray().join(',')}
-          onChange={this.handleFileChange}
-          disabled={disabled}
-          style={{ display: 'none' }}
-        />
-      </div>
-    );
-  }
-
-}
diff --git a/app/javascript/flavours/glitch/features/compose/components/autosuggest_account.js b/app/javascript/flavours/glitch/features/compose/components/autosuggest_account.js
deleted file mode 100644
index 3d474af30..000000000
--- a/app/javascript/flavours/glitch/features/compose/components/autosuggest_account.js
+++ /dev/null
@@ -1,24 +0,0 @@
-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='autosuggest-account'>
-        <div className='autosuggest-account-icon'><Avatar account={account} size={18} /></div>
-        <DisplayName account={account} />
-      </div>
-    );
-  }
-
-}
diff --git a/app/javascript/flavours/glitch/features/compose/components/character_counter.js b/app/javascript/flavours/glitch/features/compose/components/character_counter.js
deleted file mode 100644
index 0ecfc9141..000000000
--- a/app/javascript/flavours/glitch/features/compose/components/character_counter.js
+++ /dev/null
@@ -1,25 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import { length } from 'stringz';
-
-export default class CharacterCounter extends React.PureComponent {
-
-  static propTypes = {
-    text: PropTypes.string.isRequired,
-    max: PropTypes.number.isRequired,
-  };
-
-  checkRemainingText (diff) {
-    if (diff < 0) {
-      return <span className='character-counter character-counter--over'>{diff}</span>;
-    }
-
-    return <span className='character-counter'>{diff}</span>;
-  }
-
-  render () {
-    const diff = this.props.max - length(this.props.text);
-    return this.checkRemainingText(diff);
-  }
-
-}
diff --git a/app/javascript/flavours/glitch/features/compose/components/compose_form.js b/app/javascript/flavours/glitch/features/compose/components/compose_form.js
deleted file mode 100644
index 67ce935f4..000000000
--- a/app/javascript/flavours/glitch/features/compose/components/compose_form.js
+++ /dev/null
@@ -1,286 +0,0 @@
-import React from 'react';
-import CharacterCounter from './character_counter';
-import Button from 'flavours/glitch/components/button';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import PropTypes from 'prop-types';
-import ReplyIndicatorContainer from '../containers/reply_indicator_container';
-import AutosuggestTextarea from 'flavours/glitch/components/autosuggest_textarea';
-import { defineMessages, injectIntl } from 'react-intl';
-import Collapsable from 'flavours/glitch/components/collapsable';
-import SpoilerButtonContainer from '../containers/spoiler_button_container';
-import PrivacyDropdownContainer from '../containers/privacy_dropdown_container';
-import ComposeAdvancedOptionsContainer from '../containers/advanced_options_container';
-import SensitiveButtonContainer from '../containers/sensitive_button_container';
-import EmojiPickerDropdown from '../containers/emoji_picker_dropdown_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 { length } from 'stringz';
-import { countableText } from 'flavours/glitch/util/counter';
-import ComposeAttachOptions from './attach_options';
-import initialState from 'flavours/glitch/util/initial_state';
-
-const maxChars = initialState.max_toot_chars;
-
-const messages = defineMessages({
-  placeholder: { id: 'compose_form.placeholder', defaultMessage: 'What is on your mind?' },
-  spoiler_placeholder: { id: 'compose_form.spoiler_placeholder', defaultMessage: 'Write your warning here' },
-  publish: { id: 'compose_form.publish', defaultMessage: 'Toot' },
-  publishLoud: { id: 'compose_form.publish_loud', defaultMessage: '{publish}!' },
-});
-
-@injectIntl
-export default class ComposeForm extends ImmutablePureComponent {
-
-  static propTypes = {
-    intl: PropTypes.object.isRequired,
-    text: PropTypes.string.isRequired,
-    suggestion_token: PropTypes.string,
-    suggestions: ImmutablePropTypes.list,
-    spoiler: PropTypes.bool,
-    privacy: PropTypes.string,
-    advanced_options: ImmutablePropTypes.contains({
-      do_not_federate: PropTypes.bool,
-    }),
-    spoiler_text: PropTypes.string,
-    focusDate: PropTypes.instanceOf(Date),
-    preselectDate: PropTypes.instanceOf(Date),
-    is_submitting: PropTypes.bool,
-    is_uploading: PropTypes.bool,
-    onChange: PropTypes.func.isRequired,
-    onSubmit: PropTypes.func.isRequired,
-    onClearSuggestions: PropTypes.func.isRequired,
-    onFetchSuggestions: PropTypes.func.isRequired,
-    onPrivacyChange: PropTypes.func.isRequired,
-    onSuggestionSelected: PropTypes.func.isRequired,
-    onChangeSpoilerText: PropTypes.func.isRequired,
-    onPaste: PropTypes.func.isRequired,
-    onPickEmoji: PropTypes.func.isRequired,
-    showSearch: PropTypes.bool,
-    settings : ImmutablePropTypes.map.isRequired,
-  };
-
-  static defaultProps = {
-    showSearch: false,
-  };
-
-  handleChange = (e) => {
-    this.props.onChange(e.target.value);
-  }
-
-  handleKeyDown = (e) => {
-    if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) {
-      this.handleSubmit();
-    }
-  }
-
-  handleSubmit2 = () => {
-    this.props.onPrivacyChange(this.props.settings.get('side_arm'));
-    this.handleSubmit();
-  }
-
-  handleSubmit = () => {
-    if (this.props.text !== this.autosuggestTextarea.textarea.value) {
-      // Something changed the text inside the textarea (e.g. browser extensions like Grammarly)
-      // Update the state to match the current text
-      this.props.onChange(this.autosuggestTextarea.textarea.value);
-    }
-
-    this.props.onSubmit();
-  }
-
-  onSuggestionsClearRequested = () => {
-    this.props.onClearSuggestions();
-  }
-
-  onSuggestionsFetchRequested = (token) => {
-    this.props.onFetchSuggestions(token);
-  }
-
-  onSuggestionSelected = (tokenStart, token, value) => {
-    this._restoreCaret = null;
-    this.props.onSuggestionSelected(tokenStart, token, value);
-  }
-
-  handleChangeSpoilerText = (e) => {
-    this.props.onChangeSpoilerText(e.target.value);
-  }
-
-  componentWillReceiveProps (nextProps) {
-    // If this is the update where we've finished uploading,
-    // save the last caret position so we can restore it below!
-    if (!nextProps.is_uploading && this.props.is_uploading) {
-      this._restoreCaret = this.autosuggestTextarea.textarea.selectionStart;
-    }
-  }
-
-  componentDidUpdate (prevProps) {
-    // 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.
-    // - If we've just finished uploading an image, and have a saved caret position,
-    //   restores the cursor to that position after the text changes!
-    if (this.props.focusDate !== prevProps.focusDate || (prevProps.is_uploading && !this.props.is_uploading && typeof this._restoreCaret === 'number')) {
-      let selectionEnd, selectionStart;
-
-      if (this.props.preselectDate !== prevProps.preselectDate) {
-        selectionEnd   = this.props.text.length;
-        selectionStart = this.props.text.search(/\s/) + 1;
-      } else if (typeof this._restoreCaret === 'number') {
-        selectionStart = this._restoreCaret;
-        selectionEnd   = this._restoreCaret;
-      } else {
-        selectionEnd   = this.props.text.length;
-        selectionStart = selectionEnd;
-      }
-
-      this.autosuggestTextarea.textarea.setSelectionRange(selectionStart, selectionEnd);
-      this.autosuggestTextarea.textarea.focus();
-    } else if(prevProps.is_submitting && !this.props.is_submitting) {
-      this.autosuggestTextarea.textarea.focus();
-    }
-  }
-
-  setAutosuggestTextarea = (c) => {
-    this.autosuggestTextarea = c;
-  }
-
-  handleEmojiPick = (data) => {
-    const position     = this.autosuggestTextarea.textarea.selectionStart;
-    const emojiChar    = data.native;
-    this._restoreCaret = position + emojiChar.length + 1;
-    this.props.onPickEmoji(position, data);
-  }
-
-  render () {
-    const { intl, onPaste, showSearch } = this.props;
-    const disabled = this.props.is_submitting;
-    const maybeEye = (this.props.advanced_options && this.props.advanced_options.do_not_federate) ? ' 👁️' : '';
-    const text     = [this.props.spoiler_text, countableText(this.props.text), maybeEye].join('');
-
-    const secondaryVisibility = this.props.settings.get('side_arm');
-    let showSideArm = secondaryVisibility !== 'none';
-
-    let publishText = '';
-    let publishText2 = '';
-    let title = '';
-    let title2 = '';
-
-    const privacyIcons = {
-      none: '',
-      public: 'globe',
-      unlisted: 'unlock-alt',
-      private: 'lock',
-      direct: 'envelope',
-    };
-
-    title = `${intl.formatMessage(messages.publish)}: ${intl.formatMessage({ id: `privacy.${this.props.privacy}.short` })}`;
-
-    if (showSideArm) {
-      // Enhanced behavior with dual toot buttons
-      publishText = (
-        <span>
-          {
-            <i
-              className={`fa fa-${privacyIcons[this.props.privacy]}`}
-              style={{ paddingRight: '5px' }}
-            />
-          }{intl.formatMessage(messages.publish)}
-        </span>
-      );
-
-      title2 = `${intl.formatMessage(messages.publish)}: ${intl.formatMessage({ id: `privacy.${secondaryVisibility}.short` })}`;
-      publishText2 = (
-        <i
-          className={`fa fa-${privacyIcons[secondaryVisibility]}`}
-          aria-label={title2}
-        />
-      );
-    } else {
-      // Original vanilla behavior - no icon if public or unlisted
-      if (this.props.privacy === 'private' || this.props.privacy === 'direct') {
-        publishText = <span className='compose-form__publish-private'><i className='fa fa-lock' /> {intl.formatMessage(messages.publish)}</span>;
-      } else {
-        publishText = this.props.privacy !== 'unlisted' ? intl.formatMessage(messages.publishLoud, { publish: intl.formatMessage(messages.publish) }) : intl.formatMessage(messages.publish);
-      }
-    }
-
-    const submitDisabled = disabled || this.props.is_uploading || length(text) > maxChars || (text.length !== 0 && text.trim().length === 0);
-
-    return (
-      <div className='compose-form'>
-        <Collapsable isVisible={this.props.spoiler} fullHeight={50}>
-          <div className='spoiler-input'>
-            <label>
-              <span style={{ display: 'none' }}>{intl.formatMessage(messages.spoiler_placeholder)}</span>
-              <input placeholder={intl.formatMessage(messages.spoiler_placeholder)} value={this.props.spoiler_text} onChange={this.handleChangeSpoilerText} onKeyDown={this.handleKeyDown} type='text' className='spoiler-input__input'  id='cw-spoiler-input' />
-            </label>
-          </div>
-        </Collapsable>
-
-        <WarningContainer />
-
-        <ReplyIndicatorContainer />
-
-        <div className='compose-form__autosuggest-wrapper'>
-          <AutosuggestTextarea
-            ref={this.setAutosuggestTextarea}
-            placeholder={intl.formatMessage(messages.placeholder)}
-            disabled={disabled}
-            value={this.props.text}
-            onChange={this.handleChange}
-            suggestions={this.props.suggestions}
-            onKeyDown={this.handleKeyDown}
-            onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
-            onSuggestionsClearRequested={this.onSuggestionsClearRequested}
-            onSuggestionSelected={this.onSuggestionSelected}
-            onPaste={onPaste}
-            autoFocus={!showSearch && !isMobile(window.innerWidth)}
-          />
-
-          <EmojiPickerDropdown onPickEmoji={this.handleEmojiPick} />
-        </div>
-
-        <div className='compose-form__modifiers'>
-          <UploadFormContainer />
-        </div>
-
-        <div className='compose-form__buttons'>
-          <ComposeAttachOptions />
-          <SensitiveButtonContainer />
-          <div className='compose-form__buttons-separator' />
-          <PrivacyDropdownContainer />
-          <SpoilerButtonContainer />
-          <ComposeAdvancedOptionsContainer />
-        </div>
-
-        <div className='compose-form__publish'>
-          <div className='character-counter__wrapper'><CharacterCounter max={maxChars} text={text} /></div>
-          <div className='compose-form__publish-button-wrapper'>
-            {
-              showSideArm ?
-                <Button
-                  className='compose-form__publish__side-arm'
-                  text={publishText2}
-                  title={title2}
-                  onClick={this.handleSubmit2}
-                  disabled={submitDisabled}
-                /> : ''
-            }
-            <Button
-              className='compose-form__publish__primary'
-              text={publishText}
-              title={title}
-              onClick={this.handleSubmit}
-              disabled={submitDisabled}
-            />
-          </div>
-        </div>
-      </div>
-    );
-  }
-
-}
diff --git a/app/javascript/flavours/glitch/features/compose/components/dropdown.js b/app/javascript/flavours/glitch/features/compose/components/dropdown.js
deleted file mode 100644
index 1b0000fb7..000000000
--- a/app/javascript/flavours/glitch/features/compose/components/dropdown.js
+++ /dev/null
@@ -1,77 +0,0 @@
-//  Package imports.
-import React from 'react';
-import PropTypes from 'prop-types';
-
-//  Our imports.
-import IconButton from 'flavours/glitch/components/icon_button';
-
-const iconStyle = {
-  height     : null,
-  lineHeight : '27px',
-};
-
-export default class ComposeDropdown extends React.PureComponent {
-
-  static propTypes = {
-    title: PropTypes.string.isRequired,
-    icon: PropTypes.string,
-    highlight: PropTypes.bool,
-    disabled: PropTypes.bool,
-    children: PropTypes.arrayOf(PropTypes.node).isRequired,
-  };
-
-  state = {
-    open: false,
-  };
-
-  onGlobalClick = (e) => {
-    if (e.target !== this.node && !this.node.contains(e.target) && this.state.open) {
-      this.setState({ open: false });
-    }
-  };
-
-  componentDidMount () {
-    window.addEventListener('click', this.onGlobalClick);
-    window.addEventListener('touchstart', this.onGlobalClick);
-  }
-  componentWillUnmount () {
-    window.removeEventListener('click', this.onGlobalClick);
-    window.removeEventListener('touchstart', this.onGlobalClick);
-  }
-
-  onToggleDropdown = () => {
-    if (this.props.disabled) return;
-    this.setState({ open: !this.state.open });
-  };
-
-  setRef = (c) => {
-    this.node = c;
-  };
-
-  render () {
-    const { open } = this.state;
-    let { highlight, title, icon, disabled } = this.props;
-
-    if (!icon) icon = 'ellipsis-h';
-
-    return (
-      <div ref={this.setRef} className={`advanced-options-dropdown ${open ?  'open' : ''} ${highlight ? 'active' : ''} `}>
-        <div className='advanced-options-dropdown__value'>
-          <IconButton
-            className={'inverted'}
-            title={title}
-            icon={icon} active={open || highlight}
-            size={18}
-            style={iconStyle}
-            disabled={disabled}
-            onClick={this.onToggleDropdown}
-          />
-        </div>
-        <div className='advanced-options-dropdown__dropdown'>
-          {this.props.children}
-        </div>
-      </div>
-    );
-  }
-
-}
diff --git a/app/javascript/flavours/glitch/features/compose/components/privacy_dropdown.js b/app/javascript/flavours/glitch/features/compose/components/privacy_dropdown.js
deleted file mode 100644
index 90f062f8f..000000000
--- a/app/javascript/flavours/glitch/features/compose/components/privacy_dropdown.js
+++ /dev/null
@@ -1,200 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import { injectIntl, defineMessages } from 'react-intl';
-import IconButton from 'flavours/glitch/components/icon_button';
-import Overlay from 'react-overlays/lib/Overlay';
-import Motion from 'flavours/glitch/util/optional_motion';
-import spring from 'react-motion/lib/spring';
-import detectPassiveEvents from 'detect-passive-events';
-import classNames from 'classnames';
-
-const messages = defineMessages({
-  public_short: { id: 'privacy.public.short', defaultMessage: 'Public' },
-  public_long: { id: 'privacy.public.long', defaultMessage: 'Post to public timelines' },
-  unlisted_short: { id: 'privacy.unlisted.short', defaultMessage: 'Unlisted' },
-  unlisted_long: { id: 'privacy.unlisted.long', defaultMessage: 'Do not show in public timelines' },
-  private_short: { id: 'privacy.private.short', defaultMessage: 'Followers-only' },
-  private_long: { id: 'privacy.private.long', defaultMessage: 'Post to followers only' },
-  direct_short: { id: 'privacy.direct.short', defaultMessage: 'Direct' },
-  direct_long: { id: 'privacy.direct.long', defaultMessage: 'Post to mentioned users only' },
-  change_privacy: { id: 'privacy.change', defaultMessage: 'Adjust status privacy' },
-});
-
-const listenerOptions = detectPassiveEvents.hasSupport ? { passive: true } : false;
-
-class PrivacyDropdownMenu extends React.PureComponent {
-
-  static propTypes = {
-    style: PropTypes.object,
-    items: PropTypes.array.isRequired,
-    value: PropTypes.string.isRequired,
-    onClose: PropTypes.func.isRequired,
-    onChange: PropTypes.func.isRequired,
-  };
-
-  handleDocumentClick = e => {
-    if (this.node && !this.node.contains(e.target)) {
-      this.props.onClose();
-    }
-  }
-
-  handleClick = e => {
-    if (e.key === 'Escape') {
-      this.props.onClose();
-    } else if (!e.key || e.key === 'Enter') {
-      const value = e.currentTarget.getAttribute('data-index');
-
-      e.preventDefault();
-
-      this.props.onClose();
-      this.props.onChange(value);
-    }
-  }
-
-  componentDidMount () {
-    document.addEventListener('click', this.handleDocumentClick, false);
-    document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);
-  }
-
-  componentWillUnmount () {
-    document.removeEventListener('click', this.handleDocumentClick, false);
-    document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions);
-  }
-
-  setRef = c => {
-    this.node = c;
-  }
-
-  render () {
-    const { style, items, value } = 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='privacy-dropdown__dropdown' style={{ ...style, opacity: opacity, transform: `scale(${scaleX}, ${scaleY})` }} ref={this.setRef}>
-            {items.map(item =>
-              <div role='button' tabIndex='0' key={item.value} data-index={item.value} onKeyDown={this.handleClick} onClick={this.handleClick} className={classNames('privacy-dropdown__option', { active: item.value === value })}>
-                <div className='privacy-dropdown__option__icon'>
-                  <i className={`fa fa-fw fa-${item.icon}`} />
-                </div>
-
-                <div className='privacy-dropdown__option__content'>
-                  <strong>{item.text}</strong>
-                  {item.meta}
-                </div>
-              </div>
-            )}
-          </div>
-        )}
-      </Motion>
-    );
-  }
-
-}
-
-@injectIntl
-export default class PrivacyDropdown extends React.PureComponent {
-
-  static propTypes = {
-    isUserTouching: PropTypes.func,
-    isModalOpen: PropTypes.bool.isRequired,
-    onModalOpen: PropTypes.func,
-    onModalClose: PropTypes.func,
-    value: PropTypes.string.isRequired,
-    onChange: PropTypes.func.isRequired,
-    intl: PropTypes.object.isRequired,
-  };
-
-  state = {
-    open: false,
-  };
-
-  handleToggle = () => {
-    if (this.props.isUserTouching()) {
-      if (this.state.open) {
-        this.props.onModalClose();
-      } else {
-        this.props.onModalOpen({
-          actions: this.options.map(option => ({ ...option, active: option.value === this.props.value })),
-          onClick: this.handleModalActionClick,
-        });
-      }
-    } else {
-      this.setState({ open: !this.state.open });
-    }
-  }
-
-  handleModalActionClick = (e) => {
-    e.preventDefault();
-
-    const { value } = this.options[e.currentTarget.getAttribute('data-index')];
-
-    this.props.onModalClose();
-    this.props.onChange(value);
-  }
-
-  handleKeyDown = e => {
-    switch(e.key) {
-    case 'Enter':
-      this.handleToggle();
-      break;
-    case 'Escape':
-      this.handleClose();
-      break;
-    }
-  }
-
-  handleClose = () => {
-    this.setState({ open: false });
-  }
-
-  handleChange = value => {
-    this.props.onChange(value);
-  }
-
-  componentWillMount () {
-    const { intl: { formatMessage } } = this.props;
-
-    this.options = [
-      { icon: 'globe', value: 'public', text: formatMessage(messages.public_short), meta: formatMessage(messages.public_long) },
-      { icon: 'unlock-alt', value: 'unlisted', text: formatMessage(messages.unlisted_short), meta: formatMessage(messages.unlisted_long) },
-      { icon: 'lock', value: 'private', text: formatMessage(messages.private_short), meta: formatMessage(messages.private_long) },
-      { icon: 'envelope', value: 'direct', text: formatMessage(messages.direct_short), meta: formatMessage(messages.direct_long) },
-    ];
-  }
-
-  render () {
-    const { value, intl } = this.props;
-    const { open } = this.state;
-
-    const valueOption = this.options.find(item => item.value === value);
-
-    return (
-      <div className={classNames('privacy-dropdown', { active: open })} onKeyDown={this.handleKeyDown}>
-        <div className={classNames('privacy-dropdown__value', { active: this.options.indexOf(valueOption) === 0 })}>
-          <IconButton
-            className='privacy-dropdown__value-icon'
-            icon={valueOption.icon}
-            title={intl.formatMessage(messages.change_privacy)}
-            size={18}
-            expanded={open}
-            active={open}
-            inverted
-            onClick={this.handleToggle}
-            style={{ height: null, lineHeight: '27px' }}
-          />
-        </div>
-
-        <Overlay show={open} placement='bottom' target={this}>
-          <PrivacyDropdownMenu
-            items={this.options}
-            value={value}
-            onClose={this.handleClose}
-            onChange={this.handleChange}
-          />
-        </Overlay>
-      </div>
-    );
-  }
-
-}
diff --git a/app/javascript/flavours/glitch/features/compose/components/reply_indicator.js b/app/javascript/flavours/glitch/features/compose/components/reply_indicator.js
deleted file mode 100644
index 3048d591b..000000000
--- a/app/javascript/flavours/glitch/features/compose/components/reply_indicator.js
+++ /dev/null
@@ -1,67 +0,0 @@
-import React from 'react';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import PropTypes from 'prop-types';
-import Avatar from 'flavours/glitch/components/avatar';
-import IconButton from 'flavours/glitch/components/icon_button';
-import DisplayName from 'flavours/glitch/components/display_name';
-import { defineMessages, injectIntl } from 'react-intl';
-import ImmutablePureComponent from 'react-immutable-pure-component';
-import { isRtl } from 'flavours/glitch/util/rtl';
-
-const messages = defineMessages({
-  cancel: { id: 'reply_indicator.cancel', defaultMessage: 'Cancel' },
-});
-
-@injectIntl
-export default class ReplyIndicator extends ImmutablePureComponent {
-
-  static contextTypes = {
-    router: PropTypes.object,
-  };
-
-  static propTypes = {
-    status: ImmutablePropTypes.map,
-    onCancel: PropTypes.func.isRequired,
-    intl: PropTypes.object.isRequired,
-  };
-
-  handleClick = () => {
-    this.props.onCancel();
-  }
-
-  handleAccountClick = (e) => {
-    if (e.button === 0) {
-      e.preventDefault();
-      this.context.router.history.push(`/accounts/${this.props.status.getIn(['account', 'id'])}`);
-    }
-  }
-
-  render () {
-    const { status, intl } = this.props;
-
-    if (!status) {
-      return null;
-    }
-
-    const content = { __html: status.get('contentHtml') };
-    const style   = {
-      direction: isRtl(status.get('search_index')) ? 'rtl' : 'ltr',
-    };
-
-    return (
-      <div className='reply-indicator'>
-        <div className='reply-indicator__header'>
-          <div className='reply-indicator__cancel'><IconButton title={intl.formatMessage(messages.cancel)} icon='times' onClick={this.handleClick} /></div>
-
-          <a href={status.getIn(['account', 'url'])} onClick={this.handleAccountClick} className='reply-indicator__display-name'>
-            <div className='reply-indicator__display-avatar'><Avatar account={status.get('account')} size={24} /></div>
-            <DisplayName account={status.get('account')} />
-          </a>
-        </div>
-
-        <div className='reply-indicator__content' style={style} dangerouslySetInnerHTML={content} />
-      </div>
-    );
-  }
-
-}
diff --git a/app/javascript/flavours/glitch/features/compose/components/upload.js b/app/javascript/flavours/glitch/features/compose/components/upload.js
deleted file mode 100644
index a1fc93234..000000000
--- a/app/javascript/flavours/glitch/features/compose/components/upload.js
+++ /dev/null
@@ -1,96 +0,0 @@
-import React from 'react';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import PropTypes from 'prop-types';
-import IconButton from 'flavours/glitch/components/icon_button';
-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 } from 'react-intl';
-import classNames from 'classnames';
-
-const messages = defineMessages({
-  undo: { id: 'upload_form.undo', defaultMessage: 'Undo' },
-  description: { id: 'upload_form.description', defaultMessage: 'Describe for the visually impaired' },
-});
-
-@injectIntl
-export default class Upload extends ImmutablePureComponent {
-
-  static propTypes = {
-    media: ImmutablePropTypes.map.isRequired,
-    intl: PropTypes.object.isRequired,
-    onUndo: PropTypes.func.isRequired,
-    onDescriptionChange: PropTypes.func.isRequired,
-  };
-
-  state = {
-    hovered: false,
-    focused: false,
-    dirtyDescription: null,
-  };
-
-  handleUndoClick = () => {
-    this.props.onUndo(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 });
-  }
-
-  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;
-    const description     = this.state.dirtyDescription || media.get('description') || '';
-
-    return (
-      <div className='compose-form__upload' onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}>
-        <Motion defaultStyle={{ scale: 0.8 }} style={{ scale: spring(1, { stiffness: 180, damping: 12 }) }}>
-          {({ scale }) => (
-            <div className='compose-form__upload-thumbnail' style={{ transform: `scale(${scale})`, backgroundImage: `url(${media.get('preview_url')})` }}>
-              <IconButton icon='times' title={intl.formatMessage(messages.undo)} size={36} onClick={this.handleUndoClick} />
-
-              <div className={classNames('compose-form__upload-description', { active })}>
-                <label>
-                  <span style={{ display: 'none' }}>{intl.formatMessage(messages.description)}</span>
-
-                  <input
-                    placeholder={intl.formatMessage(messages.description)}
-                    type='text'
-                    value={description}
-                    maxLength={420}
-                    onFocus={this.handleInputFocus}
-                    onChange={this.handleInputChange}
-                    onBlur={this.handleInputBlur}
-                  />
-                </label>
-              </div>
-            </div>
-          )}
-        </Motion>
-      </div>
-    );
-  }
-
-}
diff --git a/app/javascript/flavours/glitch/features/compose/components/upload_button.js b/app/javascript/flavours/glitch/features/compose/components/upload_button.js
deleted file mode 100644
index f06167a2a..000000000
--- a/app/javascript/flavours/glitch/features/compose/components/upload_button.js
+++ /dev/null
@@ -1,77 +0,0 @@
-import React from 'react';
-import IconButton from 'flavours/glitch/components/icon_button';
-import PropTypes from 'prop-types';
-import { defineMessages, injectIntl } from 'react-intl';
-import { connect } from 'react-redux';
-import ImmutablePureComponent from 'react-immutable-pure-component';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-
-const messages = defineMessages({
-  upload: { id: 'upload_button.label', defaultMessage: 'Add media' },
-});
-
-const makeMapStateToProps = () => {
-  const mapStateToProps = state => ({
-    acceptContentTypes: state.getIn(['media_attachments', 'accept_content_types']),
-  });
-
-  return mapStateToProps;
-};
-
-const iconStyle = {
-  height: null,
-  lineHeight: '27px',
-};
-
-@connect(makeMapStateToProps)
-@injectIntl
-export default class UploadButton extends ImmutablePureComponent {
-
-  static propTypes = {
-    disabled: PropTypes.bool,
-    onSelectFile: PropTypes.func.isRequired,
-    style: PropTypes.object,
-    resetFileKey: PropTypes.number,
-    acceptContentTypes: ImmutablePropTypes.listOf(PropTypes.string).isRequired,
-    intl: PropTypes.object.isRequired,
-  };
-
-  handleChange = (e) => {
-    if (e.target.files.length > 0) {
-      this.props.onSelectFile(e.target.files);
-    }
-  }
-
-  handleClick = () => {
-    this.fileElement.click();
-  }
-
-  setRef = (c) => {
-    this.fileElement = c;
-  }
-
-  render () {
-
-    const { intl, resetFileKey, disabled, acceptContentTypes } = this.props;
-
-    return (
-      <div className='compose-form__upload-button'>
-        <IconButton icon='camera' title={intl.formatMessage(messages.upload)} disabled={disabled} onClick={this.handleClick} className='compose-form__upload-button-icon' size={18} inverted style={iconStyle} />
-        <label>
-          <span style={{ display: 'none' }}>{intl.formatMessage(messages.upload)}</span>
-          <input
-            key={resetFileKey}
-            ref={this.setRef}
-            type='file'
-            multiple={false}
-            accept={acceptContentTypes.toArray().join(',')}
-            onChange={this.handleChange}
-            disabled={disabled}
-            style={{ display: 'none' }}
-          />
-        </label>
-      </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
deleted file mode 100644
index b7f112205..000000000
--- a/app/javascript/flavours/glitch/features/compose/components/upload_form.js
+++ /dev/null
@@ -1,29 +0,0 @@
-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';
-
-export default class UploadForm extends ImmutablePureComponent {
-
-  static propTypes = {
-    mediaIds: ImmutablePropTypes.list.isRequired,
-  };
-
-  render () {
-    const { mediaIds } = this.props;
-
-    return (
-      <div className='compose-form__upload-wrapper'>
-        <UploadProgressContainer />
-
-        <div className='compose-form__uploads-wrapper'>
-          {mediaIds.map(id => (
-            <UploadContainer id={id} key={id} />
-          ))}
-        </div>
-      </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
deleted file mode 100644
index 2a3b8ceb4..000000000
--- a/app/javascript/flavours/glitch/features/compose/components/upload_progress.js
+++ /dev/null
@@ -1,42 +0,0 @@
-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';
-
-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='upload-progress'>
-        <div className='upload-progress__icon'>
-          <i className='fa fa-upload' />
-        </div>
-
-        <div className='upload-progress__message'>
-          <FormattedMessage id='upload_progress.label' defaultMessage='Uploading...' />
-
-          <div className='upload-progress__backdrop'>
-            <Motion defaultStyle={{ width: 0 }} style={{ width: spring(progress) }}>
-              {({ width }) =>
-                <div className='upload-progress__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
deleted file mode 100644
index 4962e76c8..000000000
--- a/app/javascript/flavours/glitch/features/compose/components/warning.js
+++ /dev/null
@@ -1,26 +0,0 @@
-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='compose-form__warning' style={{ opacity: opacity, transform: `scale(${scaleX}, ${scaleY})` }}>
-            {message}
-          </div>
-        )}
-      </Motion>
-    );
-  }
-
-}
diff --git a/app/javascript/flavours/glitch/features/compose/containers/advanced_options_container.js b/app/javascript/flavours/glitch/features/compose/containers/advanced_options_container.js
deleted file mode 100644
index da381568b..000000000
--- a/app/javascript/flavours/glitch/features/compose/containers/advanced_options_container.js
+++ /dev/null
@@ -1,20 +0,0 @@
-//  Package imports.
-import { connect } from 'react-redux';
-
-//  Our imports.
-import { toggleComposeAdvancedOption } from 'flavours/glitch/actions/compose';
-import ComposeAdvancedOptions from '../components/advanced_options';
-
-const mapStateToProps = state => ({
-  values: state.getIn(['compose', 'advanced_options']),
-});
-
-const mapDispatchToProps = dispatch => ({
-
-  onChange (option) {
-    dispatch(toggleComposeAdvancedOption(option));
-  },
-
-});
-
-export default connect(mapStateToProps, mapDispatchToProps)(ComposeAdvancedOptions);
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
deleted file mode 100644
index 0e1c328fe..000000000
--- a/app/javascript/flavours/glitch/features/compose/containers/autosuggest_account_container.js
+++ /dev/null
@@ -1,15 +0,0 @@
-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
deleted file mode 100644
index e2e93e44b..000000000
--- a/app/javascript/flavours/glitch/features/compose/containers/compose_form_container.js
+++ /dev/null
@@ -1,71 +0,0 @@
-import { connect } from 'react-redux';
-import ComposeForm from '../components/compose_form';
-import { changeComposeVisibility, uploadCompose } from 'flavours/glitch/actions/compose';
-import {
-  changeCompose,
-  submitCompose,
-  clearComposeSuggestions,
-  fetchComposeSuggestions,
-  selectComposeSuggestion,
-  changeComposeSpoilerText,
-  insertEmojiCompose,
-} from 'flavours/glitch/actions/compose';
-
-const mapStateToProps = state => ({
-  text: state.getIn(['compose', 'text']),
-  suggestion_token: state.getIn(['compose', 'suggestion_token']),
-  suggestions: state.getIn(['compose', 'suggestions']),
-  advanced_options: state.getIn(['compose', 'advanced_options']),
-  spoiler: state.getIn(['compose', 'spoiler']),
-  spoiler_text: state.getIn(['compose', 'spoiler_text']),
-  privacy: state.getIn(['compose', 'privacy']),
-  focusDate: state.getIn(['compose', 'focusDate']),
-  preselectDate: state.getIn(['compose', 'preselectDate']),
-  is_submitting: state.getIn(['compose', 'is_submitting']),
-  is_uploading: state.getIn(['compose', 'is_uploading']),
-  showSearch: state.getIn(['search', 'submitted']) && !state.getIn(['search', 'hidden']),
-  settings: state.get('local_settings'),
-  filesAttached: state.getIn(['compose', 'media_attachments']).size > 0,
-});
-
-const mapDispatchToProps = (dispatch) => ({
-
-  onChange (text) {
-    dispatch(changeCompose(text));
-  },
-
-  onPrivacyChange (value) {
-    dispatch(changeComposeVisibility(value));
-  },
-
-  onSubmit () {
-    dispatch(submitCompose());
-  },
-
-  onClearSuggestions () {
-    dispatch(clearComposeSuggestions());
-  },
-
-  onFetchSuggestions (token) {
-    dispatch(fetchComposeSuggestions(token));
-  },
-
-  onSuggestionSelected (position, token, accountId) {
-    dispatch(selectComposeSuggestion(position, token, accountId));
-  },
-
-  onChangeSpoilerText (checked) {
-    dispatch(changeComposeSpoilerText(checked));
-  },
-
-  onPaste (files) {
-    dispatch(uploadCompose(files));
-  },
-
-  onPickEmoji (position, data) {
-    dispatch(insertEmojiCompose(position, data));
-  },
-
-});
-
-export default connect(mapStateToProps, mapDispatchToProps)(ComposeForm);
diff --git a/app/javascript/flavours/glitch/features/compose/containers/emoji_picker_dropdown_container.js b/app/javascript/flavours/glitch/features/compose/containers/emoji_picker_dropdown_container.js
deleted file mode 100644
index ba85edd87..000000000
--- a/app/javascript/flavours/glitch/features/compose/containers/emoji_picker_dropdown_container.js
+++ /dev/null
@@ -1,82 +0,0 @@
-import { connect } from 'react-redux';
-import EmojiPickerDropdown from '../components/emoji_picker_dropdown';
-import { changeSetting } from 'flavours/glitch/actions/settings';
-import { createSelector } from 'reselect';
-import { Map as ImmutableMap } from 'immutable';
-import { useEmoji } from 'flavours/glitch/actions/emojis';
-
-const perLine = 8;
-const lines   = 2;
-
-const DEFAULTS = [
-  '+1',
-  'grinning',
-  'kissing_heart',
-  'heart_eyes',
-  'laughing',
-  'stuck_out_tongue_winking_eye',
-  'sweat_smile',
-  'joy',
-  'yum',
-  'disappointed',
-  'thinking_face',
-  'weary',
-  'sob',
-  'sunglasses',
-  'heart',
-  'ok_hand',
-];
-
-const getFrequentlyUsedEmojis = createSelector([
-  state => state.getIn(['settings', 'frequentlyUsedEmojis'], ImmutableMap()),
-], emojiCounters => {
-  let emojis = emojiCounters
-    .keySeq()
-    .sort((a, b) => emojiCounters.get(a) - emojiCounters.get(b))
-    .reverse()
-    .slice(0, perLine * lines)
-    .toArray();
-
-  if (emojis.length < DEFAULTS.length) {
-    emojis = emojis.concat(DEFAULTS.slice(0, DEFAULTS.length - emojis.length));
-  }
-
-  return emojis;
-});
-
-const getCustomEmojis = createSelector([
-  state => state.get('custom_emojis'),
-], emojis => emojis.filter(e => e.get('visible_in_picker')).sort((a, b) => {
-  const aShort = a.get('shortcode').toLowerCase();
-  const bShort = b.get('shortcode').toLowerCase();
-
-  if (aShort < bShort) {
-    return -1;
-  } else if (aShort > bShort ) {
-    return 1;
-  } else {
-    return 0;
-  }
-}));
-
-const mapStateToProps = state => ({
-  custom_emojis: getCustomEmojis(state),
-  skinTone: state.getIn(['settings', 'skinTone']),
-  frequentlyUsedEmojis: getFrequentlyUsedEmojis(state),
-});
-
-const mapDispatchToProps = (dispatch, { onPickEmoji }) => ({
-  onSkinTone: skinTone => {
-    dispatch(changeSetting(['skinTone'], skinTone));
-  },
-
-  onPickEmoji: emoji => {
-    dispatch(useEmoji(emoji));
-
-    if (onPickEmoji) {
-      onPickEmoji(emoji);
-    }
-  },
-});
-
-export default connect(mapStateToProps, mapDispatchToProps)(EmojiPickerDropdown);
diff --git a/app/javascript/flavours/glitch/features/compose/containers/privacy_dropdown_container.js b/app/javascript/flavours/glitch/features/compose/containers/privacy_dropdown_container.js
deleted file mode 100644
index cb94fcc80..000000000
--- a/app/javascript/flavours/glitch/features/compose/containers/privacy_dropdown_container.js
+++ /dev/null
@@ -1,24 +0,0 @@
-import { connect } from 'react-redux';
-import PrivacyDropdown from '../components/privacy_dropdown';
-import { changeComposeVisibility } from 'flavours/glitch/actions/compose';
-import { openModal, closeModal } from 'flavours/glitch/actions/modal';
-import { isUserTouching } from 'flavours/glitch/util/is_mobile';
-
-const mapStateToProps = state => ({
-  isModalOpen: state.get('modal').modalType === 'ACTIONS',
-  value: state.getIn(['compose', 'privacy']),
-});
-
-const mapDispatchToProps = dispatch => ({
-
-  onChange (value) {
-    dispatch(changeComposeVisibility(value));
-  },
-
-  isUserTouching,
-  onModalOpen: props => dispatch(openModal('ACTIONS', props)),
-  onModalClose: () => dispatch(closeModal()),
-
-});
-
-export default connect(mapStateToProps, mapDispatchToProps)(PrivacyDropdown);
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
deleted file mode 100644
index a7c82d135..000000000
--- a/app/javascript/flavours/glitch/features/compose/containers/reply_indicator_container.js
+++ /dev/null
@@ -1,24 +0,0 @@
-import { connect } from 'react-redux';
-import { cancelReplyCompose } from 'flavours/glitch/actions/compose';
-import { makeGetStatus } from 'flavours/glitch/selectors';
-import ReplyIndicator from '../components/reply_indicator';
-
-const makeMapStateToProps = () => {
-  const getStatus = makeGetStatus();
-
-  const mapStateToProps = state => ({
-    status: getStatus(state, state.getIn(['compose', 'in_reply_to'])),
-  });
-
-  return mapStateToProps;
-};
-
-const mapDispatchToProps = dispatch => ({
-
-  onCancel () {
-    dispatch(cancelReplyCompose());
-  },
-
-});
-
-export default connect(makeMapStateToProps, mapDispatchToProps)(ReplyIndicator);
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
deleted file mode 100644
index cf6706c0e..000000000
--- a/app/javascript/flavours/glitch/features/compose/containers/sensitive_button_container.js
+++ /dev/null
@@ -1,71 +0,0 @@
-import React from 'react';
-import { connect } from 'react-redux';
-import PropTypes from 'prop-types';
-import classNames from 'classnames';
-import IconButton from 'flavours/glitch/components/icon_button';
-import { changeComposeSensitivity } from 'flavours/glitch/actions/compose';
-import Motion from 'flavours/glitch/util/optional_motion';
-import spring from 'react-motion/lib/spring';
-import { injectIntl, defineMessages } from 'react-intl';
-
-const messages = defineMessages({
-  title: { id: 'compose_form.sensitive', defaultMessage: 'Mark media as sensitive' },
-});
-
-const mapStateToProps = state => ({
-  visible: state.getIn(['compose', 'media_attachments']).size > 0,
-  active: state.getIn(['compose', 'sensitive']),
-  disabled: state.getIn(['compose', 'spoiler']),
-});
-
-const mapDispatchToProps = dispatch => ({
-
-  onClick () {
-    dispatch(changeComposeSensitivity());
-  },
-
-});
-
-class SensitiveButton extends React.PureComponent {
-
-  static propTypes = {
-    visible: PropTypes.bool,
-    active: PropTypes.bool,
-    disabled: PropTypes.bool,
-    onClick: PropTypes.func.isRequired,
-    intl: PropTypes.object.isRequired,
-  };
-
-  render () {
-    const { visible, active, disabled, onClick, intl } = this.props;
-
-    return (
-      <Motion defaultStyle={{ scale: 0.87 }} style={{ scale: spring(visible ? 1 : 0.87, { stiffness: 200, damping: 3 }) }}>
-        {({ scale }) => {
-          const icon = active ? 'eye-slash' : 'eye';
-          const className = classNames('compose-form__sensitive-button', {
-            'compose-form__sensitive-button--visible': visible,
-          });
-          return (
-            <div className={className} style={{ transform: `scale(${scale})` }}>
-              <IconButton
-                className='compose-form__sensitive-button__icon'
-                title={intl.formatMessage(messages.title)}
-                icon={icon}
-                onClick={onClick}
-                size={18}
-                active={active}
-                disabled={disabled}
-                style={{ lineHeight: null, height: null }}
-                inverted
-              />
-            </div>
-          );
-        }}
-      </Motion>
-    );
-  }
-
-}
-
-export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(SensitiveButton));
diff --git a/app/javascript/flavours/glitch/features/compose/containers/spoiler_button_container.js b/app/javascript/flavours/glitch/features/compose/containers/spoiler_button_container.js
deleted file mode 100644
index d7b4246bc..000000000
--- a/app/javascript/flavours/glitch/features/compose/containers/spoiler_button_container.js
+++ /dev/null
@@ -1,25 +0,0 @@
-import { connect } from 'react-redux';
-import TextIconButton from '../components/text_icon_button';
-import { changeComposeSpoilerness } from 'flavours/glitch/actions/compose';
-import { injectIntl, defineMessages } from 'react-intl';
-
-const messages = defineMessages({
-  title: { id: 'compose_form.spoiler', defaultMessage: 'Hide text behind warning' },
-});
-
-const mapStateToProps = (state, { intl }) => ({
-  label: 'CW',
-  title: intl.formatMessage(messages.title),
-  active: state.getIn(['compose', 'spoiler']),
-  ariaControls: 'cw-spoiler-input',
-});
-
-const mapDispatchToProps = dispatch => ({
-
-  onClick () {
-    dispatch(changeComposeSpoilerness());
-  },
-
-});
-
-export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(TextIconButton));
diff --git a/app/javascript/flavours/glitch/features/compose/containers/upload_button_container.js b/app/javascript/flavours/glitch/features/compose/containers/upload_button_container.js
deleted file mode 100644
index 4c1cb49e9..000000000
--- a/app/javascript/flavours/glitch/features/compose/containers/upload_button_container.js
+++ /dev/null
@@ -1,18 +0,0 @@
-import { connect } from 'react-redux';
-import UploadButton from '../components/upload_button';
-import { uploadCompose } from 'flavours/glitch/actions/compose';
-
-const mapStateToProps = state => ({
-  disabled: state.getIn(['compose', 'is_uploading']) || (state.getIn(['compose', 'media_attachments']).size > 3 || state.getIn(['compose', 'media_attachments']).some(m => m.get('type') === 'video')),
-  resetFileKey: state.getIn(['compose', 'resetFileKey']),
-});
-
-const mapDispatchToProps = dispatch => ({
-
-  onSelectFile (files) {
-    dispatch(uploadCompose(files));
-  },
-
-});
-
-export default connect(mapStateToProps, mapDispatchToProps)(UploadButton);
diff --git a/app/javascript/flavours/glitch/features/compose/containers/upload_container.js b/app/javascript/flavours/glitch/features/compose/containers/upload_container.js
deleted file mode 100644
index 368038425..000000000
--- a/app/javascript/flavours/glitch/features/compose/containers/upload_container.js
+++ /dev/null
@@ -1,21 +0,0 @@
-import { connect } from 'react-redux';
-import Upload from '../components/upload';
-import { undoUploadCompose, changeUploadCompose } 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));
-  },
-
-});
-
-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
deleted file mode 100644
index a6798bf51..000000000
--- a/app/javascript/flavours/glitch/features/compose/containers/upload_form_container.js
+++ /dev/null
@@ -1,8 +0,0 @@
-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
deleted file mode 100644
index 0cfee96da..000000000
--- a/app/javascript/flavours/glitch/features/compose/containers/upload_progress_container.js
+++ /dev/null
@@ -1,9 +0,0 @@
-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
deleted file mode 100644
index f20c75b91..000000000
--- a/app/javascript/flavours/glitch/features/compose/containers/warning_container.js
+++ /dev/null
@@ -1,24 +0,0 @@
-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 mapStateToProps = state => ({
-  needsLockWarning: state.getIn(['compose', 'privacy']) === 'private' && !state.getIn(['accounts', me, 'locked']),
-});
-
-const WarningWrapper = ({ needsLockWarning }) => {
-  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> }} />} />;
-  }
-
-  return null;
-};
-
-WarningWrapper.propTypes = {
-  needsLockWarning: 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
deleted file mode 100644
index 63c9500b1..000000000
--- a/app/javascript/flavours/glitch/features/compose/index.js
+++ /dev/null
@@ -1,126 +0,0 @@
-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 { mountCompose, unmountCompose } from 'flavours/glitch/actions/compose';
-import { openModal } from 'flavours/glitch/actions/modal';
-import { changeLocalSetting } from 'flavours/glitch/actions/local_settings';
-import { Link } from 'react-router-dom';
-import { injectIntl, defineMessages } from 'react-intl';
-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 { changeComposing } from 'flavours/glitch/actions/compose';
-
-const messages = defineMessages({
-  start: { id: 'getting_started.heading', defaultMessage: 'Getting started' },
-  home_timeline: { id: 'tabs_bar.home', defaultMessage: 'Home' },
-  notifications: { id: 'tabs_bar.notifications', defaultMessage: 'Notifications' },
-  public: { id: 'navigation_bar.public_timeline', defaultMessage: 'Federated timeline' },
-  community: { id: 'navigation_bar.community_timeline', defaultMessage: 'Local timeline' },
-  settings: { id: 'navigation_bar.app_settings', defaultMessage: 'App settings' },
-  logout: { id: 'navigation_bar.logout', defaultMessage: 'Logout' },
-});
-
-const mapStateToProps = state => ({
-  columns: state.getIn(['settings', 'columns']),
-  showSearch: state.getIn(['search', 'submitted']) && !state.getIn(['search', 'hidden']),
-});
-
-@connect(mapStateToProps)
-@injectIntl
-export default class Compose extends React.PureComponent {
-
-  static propTypes = {
-    dispatch: PropTypes.func.isRequired,
-    columns: ImmutablePropTypes.list.isRequired,
-    multiColumn: PropTypes.bool,
-    showSearch: PropTypes.bool,
-    intl: PropTypes.object.isRequired,
-  };
-
-  componentDidMount () {
-    this.props.dispatch(mountCompose());
-  }
-
-  componentWillUnmount () {
-    this.props.dispatch(unmountCompose());
-  }
-
-  onLayoutClick = (e) => {
-    const layout = e.currentTarget.getAttribute('data-mastodon-layout');
-    this.props.dispatch(changeLocalSetting(['layout'], layout));
-    e.preventDefault();
-  }
-
-  openSettings = () => {
-    this.props.dispatch(openModal('SETTINGS', {}));
-  }
-
-  onFocus = () => {
-    this.props.dispatch(changeComposing(true));
-  }
-
-  onBlur = () => {
-    this.props.dispatch(changeComposing(false));
-  }
-
-  render () {
-    const { multiColumn, showSearch, intl } = this.props;
-
-    let header = '';
-
-    if (multiColumn) {
-      const { columns } = this.props;
-      header = (
-        <nav className='drawer__header'>
-          <Link to='/getting-started' className='drawer__tab' title={intl.formatMessage(messages.start)} aria-label={intl.formatMessage(messages.start)}><i role='img' className='fa fa-fw fa-asterisk' /></Link>
-          {!columns.some(column => column.get('id') === 'HOME') && (
-            <Link to='/timelines/home' className='drawer__tab' title={intl.formatMessage(messages.home_timeline)} aria-label={intl.formatMessage(messages.home_timeline)}><i role='img' className='fa fa-fw fa-home' /></Link>
-          )}
-          {!columns.some(column => column.get('id') === 'NOTIFICATIONS') && (
-            <Link to='/notifications' className='drawer__tab' title={intl.formatMessage(messages.notifications)} aria-label={intl.formatMessage(messages.notifications)}><i role='img' className='fa fa-fw fa-bell' /></Link>
-          )}
-          {!columns.some(column => column.get('id') === 'COMMUNITY') && (
-            <Link to='/timelines/public/local' className='drawer__tab' title={intl.formatMessage(messages.community)} aria-label={intl.formatMessage(messages.community)}><i role='img' className='fa fa-fw fa-users' /></Link>
-          )}
-          {!columns.some(column => column.get('id') === 'PUBLIC') && (
-            <Link to='/timelines/public' className='drawer__tab' title={intl.formatMessage(messages.public)} aria-label={intl.formatMessage(messages.public)}><i role='img' className='fa fa-fw fa-globe' /></Link>
-          )}
-          <a onClick={this.openSettings} role='button' tabIndex='0' className='drawer__tab' title={intl.formatMessage(messages.settings)} aria-label={intl.formatMessage(messages.settings)}><i role='img' className='fa fa-fw fa-cogs' /></a>
-          <a href='/auth/sign_out' className='drawer__tab' data-method='delete' title={intl.formatMessage(messages.logout)} aria-label={intl.formatMessage(messages.logout)}><i role='img' className='fa fa-fw fa-sign-out' /></a>
-        </nav>
-      );
-    }
-
-
-
-    return (
-      <div className='drawer'>
-        {header}
-
-        <SearchContainer />
-
-        <div className='drawer__pager'>
-          <div className='drawer__inner scrollable optionally-scrollable' onFocus={this.onFocus}>
-            <NavigationContainer onClose={this.onBlur} />
-            <ComposeFormContainer />
-          </div>
-
-          <Motion defaultStyle={{ x: -100 }} style={{ x: spring(showSearch ? 0 : -100, { stiffness: 210, damping: 20 }) }}>
-            {({ x }) =>
-              <div className='drawer__inner darker scrollable optionally-scrollable' style={{ transform: `translateX(${x}%)`, visibility: x === -100 ? 'hidden' : 'visible' }}>
-                <SearchResultsContainer />
-              </div>
-            }
-          </Motion>
-        </div>
-
-      </div>
-    );
-  }
-
-}
diff --git a/app/javascript/flavours/glitch/features/composer/index.js b/app/javascript/flavours/glitch/features/composer/index.js
new file mode 100644
index 000000000..25c2622d8
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/composer/index.js
@@ -0,0 +1,440 @@
+//  Package imports.
+import PropTypes from 'prop-types';
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { injectIntl } from 'react-intl';
+import { connect } from 'react-redux';
+import { withRouter } from 'react-router';
+
+//  Actions.
+import {
+  cancelReplyCompose,
+  changeCompose,
+  changeComposeSensitivity,
+  changeComposeSpoilerText,
+  changeComposeSpoilerness,
+  changeComposeVisibility,
+  changeUploadCompose,
+  clearComposeSuggestions,
+  fetchComposeSuggestions,
+  insertEmojiCompose,
+  selectComposeSuggestion,
+  submitCompose,
+  toggleComposeAdvancedOption,
+  undoUploadCompose,
+  uploadCompose,
+} from 'flavours/glitch/actions/compose';
+import {
+  closeModal,
+  openModal,
+} from 'flavours/glitch/actions/modal';
+
+//  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 ComposerWarning from './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 { mergeProps } from 'flavours/glitch/util/redux_helpers';
+
+//  State mapping.
+function mapStateToProps (state) {
+  const inReplyTo = state.getIn(['compose', 'in_reply_to']);
+  return {
+    acceptContentTypes: state.getIn(['media_attachments', 'accept_content_types']).toArray().join(','),
+    amUnlocked: !state.getIn(['accounts', me, 'locked']),
+    doNotFederate: state.getIn(['compose', 'advanced_options', 'do_not_federate']),
+    focusDate: state.getIn(['compose', 'focusDate']),
+    isSubmitting: state.getIn(['compose', 'is_submitting']),
+    isUploading: state.getIn(['compose', 'is_uploading']),
+    media: state.getIn(['compose', 'media_attachments']),
+    preselectDate: state.getIn(['compose', 'preselectDate']),
+    privacy: state.getIn(['compose', 'privacy']),
+    progress: state.getIn(['compose', 'progress']),
+    replyAccount: inReplyTo ? state.getIn(['accounts', state.getIn(['statuses', inReplyTo, 'account'])]) : null,
+    replyContent: inReplyTo ? state.getIn(['statuses', inReplyTo, 'contentHtml']) : null,
+    resetFileKey: state.getIn(['compose', 'resetFileKey']),
+    sideArm: state.getIn(['local_settings', 'side_arm']),
+    sensitive: state.getIn(['compose', 'sensitive']),
+    showSearch: state.getIn(['search', 'submitted']) && !state.getIn(['search', 'hidden']),
+    spoiler: 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']),
+  };
+};
+
+//  Dispatch mapping.
+const mapDispatchToProps = dispatch => ({
+  cancelReply () {
+    dispatch(cancelReplyCompose());
+  },
+  changeDescription (mediaId, description) {
+    dispatch(changeUploadCompose(mediaId, description));
+  },
+  changeSensitivity () {
+    dispatch(changeComposeSensitivity());
+  },
+  changeSpoilerText (checked) {
+    dispatch(changeComposeSpoilerText(checked));
+  },
+  changeSpoilerness () {
+    dispatch(changeComposeSpoilerness());
+  },
+  changeText (text) {
+    dispatch(changeCompose(text));
+  },
+  changeVisibility (value) {
+    dispatch(changeComposeVisibility(value));
+  },
+  clearSuggestions () {
+    dispatch(clearComposeSuggestions());
+  },
+  closeModal () {
+    dispatch(closeModal());
+  },
+  fetchSuggestions (token) {
+    dispatch(fetchComposeSuggestions(token));
+  },
+  insertEmoji (position, data) {
+    dispatch(insertEmojiCompose(position, data));
+  },
+  openActionsModal (data) {
+    dispatch(openModal('ACTIONS', data));
+  },
+  openDoodleModal () {
+    dispatch(openModal('DOODLE', { noEsc: true }));
+  },
+  selectSuggestion (position, token, accountId) {
+    dispatch(selectComposeSuggestion(position, token, accountId));
+  },
+  submit () {
+    dispatch(submitCompose());
+  },
+  toggleAdvancedOption (option) {
+    dispatch(toggleComposeAdvancedOption(option));
+  },
+  undoUpload (mediaId) {
+    dispatch(undoUploadCompose(mediaId));
+  },
+  upload (files) {
+    dispatch(uploadCompose(files));
+  },
+});
+
+//  Handlers.
+const handlers = {
+
+  //  Changes the text value of the spoiler.
+  changeSpoiler ({ target: { value } }) {
+    const { dispatch: { changeSpoilerText } } = this.props;
+    if (changeSpoilerText) {
+      changeSpoilerText(value);
+    }
+  },
+
+  //  Inserts an emoji at the caret.
+  emoji (data) {
+    const { textarea: { selectionStart } } = this;
+    const { dispatch: { insertEmoji } } = this.props;
+    this.caretPos = selectionStart + data.native.length + 1;
+    if (insertEmoji) {
+      insertEmoji(selectionStart, data);
+    }
+  },
+
+  //  Handles the secondary submit button.
+  secondarySubmit () {
+    const { submit } = this.handlers;
+    const {
+      dispatch: { changeVisibility },
+      side_arm,
+    } = this.props;
+    if (changeVisibility) {
+      changeVisibility(side_arm);
+    }
+    submit();
+  },
+
+  //  Selects a suggestion from the autofill.
+  select (tokenStart, token, value) {
+    const { dispatch: { selectSuggestion } } = this.props;
+    this.caretPos = null;
+    if (selectSuggestion) {
+      selectSuggestion(tokenStart, token, value);
+    }
+  },
+
+  //  Submits the status.
+  submit () {
+    const { textarea: { value } } = this;
+    const {
+      dispatch: {
+        changeText,
+        submit,
+      },
+      state: { text },
+    } = this.props;
+
+    //  If something changes inside the textarea, then we update the
+    //  state before submitting.
+    if (changeText && text !== value) {
+      changeText(value);
+    }
+
+    //  Submits the status.
+    if (submit) {
+      submit();
+    }
+  },
+
+  //  Sets a reference to the textarea.
+  refTextarea ({ textarea }) {
+    this.textarea = textarea;
+  },
+};
+
+//  The component.
+@injectIntl
+@connect(mapStateToProps, mapDispatchToProps, mergeProps)
+export default class Composer extends React.Component {
+
+  //  Constructor.
+  constructor (props) {
+    super(props);
+    assignHandlers(this, handlers);
+
+    //  Instance variables.
+    this.caretPos = null;
+    this.textarea = null;
+  }
+
+  //  If this is the update where we've finished uploading,
+  //  save the last caret position so we can restore it below!
+  componentWillReceiveProps (nextProps) {
+    const { textarea: { selectionStart } } = this;
+    const { state: { isUploading } } = this.props;
+    if (isUploading && !nextProps.state.isUploading) {
+      this.caretPos = selectionStart;
+    }
+  }
+
+  //  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.
+  // - If we've just finished uploading an image, and have a saved
+  //   caret position, restores the cursor to that position after the
+  //   text changes.
+  componentDidUpdate (prevProps) {
+    const {
+      caretPos,
+      textarea,
+    } = this;
+    const {
+      state: {
+        focusDate,
+        isUploading,
+        isSubmitting,
+        preselectDate,
+        text,
+      },
+    } = this.props;
+    let selectionEnd, selectionStart;
+
+    //  Caret/selection handling.
+    if (focusDate !== prevProps.state.focusDate || (prevProps.state.isUploading && !isUploading && !isNaN(caretPos) && caretPos !== null)) {
+      switch (true) {
+      case preselectDate !== prevProps.state.preselectDate:
+        selectionStart = text.search(/\s/) + 1;
+        selectionEnd = text.length;
+        break;
+      case !isNaN(caretPos) && caretPos !== null:
+        selectionStart = selectionEnd = caretPos;
+        break;
+      default:
+        selectionStart = selectionEnd = text.length;
+      }
+      textarea.setSelectionRange(selectionStart, selectionEnd);
+      textarea.focus();
+
+    //  Refocuses the textarea after submitting.
+    } else if (prevProps.state.isSubmitting && !isSubmitting) {
+      textarea.focus();
+    }
+  }
+
+  render () {
+    const {
+      changeSpoiler,
+      emoji,
+      secondarySubmit,
+      select,
+      submit,
+      refTextarea,
+    } = this.handlers;
+    const { history } = this.context;
+    const {
+      dispatch: {
+        cancelReply,
+        changeDescription,
+        changeSensitivity,
+        changeText,
+        changeVisibility,
+        clearSuggestions,
+        closeModal,
+        fetchSuggestions,
+        openActionsModal,
+        openDoodleModal,
+        toggleAdvancedOption,
+        undoUpload,
+        upload,
+      },
+      intl,
+      state: {
+        acceptContentTypes,
+        amUnlocked,
+        doNotFederate,
+        isSubmitting,
+        isUploading,
+        media,
+        privacy,
+        progress,
+        replyAccount,
+        replyContent,
+        resetFileKey,
+        sensitive,
+        showSearch,
+        sideArm,
+        spoiler,
+        spoilerText,
+        suggestions,
+        text,
+      },
+    } = this.props;
+
+    return (
+      <div className='compose'>
+        <ComposerSpoiler
+          hidden={!spoiler}
+          intl={intl}
+          onChange={changeSpoiler}
+          onSubmit={submit}
+          text={spoilerText}
+        />
+        {privacy === 'private' && amUnlocked ? <ComposerWarning /> : null}
+        {replyContent ? (
+          <ComposerReply
+            account={replyAccount}
+            content={replyContent}
+            history={history}
+            intl={intl}
+            onCancel={cancelReply}
+          />
+        ) : null}
+        <ComposerTextarea
+          autoFocus={!showSearch && !isMobile(window.innerWidth)}
+          disabled={isSubmitting}
+          intl={intl}
+          onChange={changeText}
+          onPaste={upload}
+          onPickEmoji={emoji}
+          onSubmit={submit}
+          onSuggestionsClearRequested={clearSuggestions}
+          onSuggestionsFetchRequested={fetchSuggestions}
+          onSuggestionSelected={select}
+          ref={refTextarea}
+          suggestions={suggestions}
+          value={text}
+        />
+        {media && media.size ? (
+          <ComposerUploadForm
+            active={isUploading}
+            intl={intl}
+            media={media}
+            onChangeDescription={changeDescription}
+            onRemove={undoUpload}
+            progress={progress}
+          />
+        ) : null}
+        <ComposerOptions
+          acceptContentTypes={acceptContentTypes}
+          disabled={isSubmitting}
+          doNotFederate={doNotFederate}
+          full={media.size >= 4 || media.some(
+            item => item.get('type') === 'video'
+          )}
+          hasMedia={!!media.size}
+          intl={intl}
+          onChangeSensitivity={changeSensitivity}
+          onChangeVisibility={changeVisibility}
+          onDoodleOpen={openDoodleModal}
+          onModalClose={closeModal}
+          onModalOpen={openActionsModal}
+          onToggleAdvancedOption={toggleAdvancedOption}
+          onUpload={upload}
+          privacy={privacy}
+          resetFileKey={resetFileKey}
+          sensitive={sensitive}
+          spoiler={spoiler}
+        />
+        <ComposerPublisher
+          countText={`${spoilerText}${countableText(text)}${doNotFederate ? ' 👁️' : ''}`}
+          disabled={isSubmitting || isUploading || text.length && text.trim().length === 0}
+          intl={intl}
+          onSecondarySubmit={secondarySubmit}
+          onSubmit={submit}
+          privacy={privacy}
+          sideArm={sideArm}
+        />
+      </div>
+    );
+  }
+
+}
+
+//  Context
+Composer.contextTypes = {
+  history: PropTypes.object,
+}
+
+//  Props.
+Composer.propTypes = {
+  dispatch: PropTypes.objectOf(PropTypes.func).isRequired,
+  intl: PropTypes.object.isRequired,
+  state: PropTypes.shape({
+    acceptContentTypes: PropTypes.string,
+    amUnlocked: PropTypes.bool,
+    doNotFederate: PropTypes.bool,
+    focusDate: PropTypes.instanceOf(Date),
+    isSubmitting: PropTypes.bool,
+    isUploading: PropTypes.bool,
+    media: PropTypes.list,
+    preselectDate: PropTypes.instanceOf(Date),
+    privacy: PropTypes.string,
+    progress: PropTypes.number,
+    replyAccount: ImmutablePropTypes.map,
+    replyContent: PropTypes.string,
+    resetFileKey: PropTypes.string,
+    sideArm: PropTypes.string,
+    sensitive: PropTypes.bool,
+    showSearch: PropTypes.bool,
+    spoiler: PropTypes.bool,
+    spoilerText: PropTypes.string,
+    suggestionToken: PropTypes.string,
+    suggestions: ImmutablePropTypes.list,
+    text: PropTypes.string,
+  }).isRequired,
+};
diff --git a/app/javascript/flavours/glitch/features/composer/options/dropdown/index.js b/app/javascript/flavours/glitch/features/composer/options/dropdown/index.js
new file mode 100644
index 000000000..0f304bc88
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/composer/options/dropdown/index.js
@@ -0,0 +1,243 @@
+//  Package imports.
+import classNames from 'classnames';
+import PropTypes from 'prop-types';
+import React from 'react';
+import spring from 'react-motion/lib/spring';
+import Overlay from 'react-overlays/lib/Overlay';
+
+//  Components.
+import IconButton from 'flavours/glitch/components/icon_button';
+import ComposerOptionsDropdownItem from './item';
+
+//  Utils.
+import { withPassive } from 'flavours/glitch/util/dom_helpers';
+import { isUserTouching } from 'flavours/glitch/util/is_mobile';
+import Motion from 'flavours/glitch/util/optional_motion';
+import { assignHandlers } from 'flavours/glitch/util/react_helpers';
+
+//  We'll use this to define our various transitions.
+const springMotion = spring(1, {
+  damping: 35,
+  stiffness: 400,
+});
+
+//  Handlers.
+const handlers = {
+
+  //  Closes the dropdown.
+  close () {
+    this.setState({ open: false });
+  },
+
+  //  When the document is clicked elsewhere, we close the dropdown.
+  documentClick ({ target }) {
+    const { node } = this;
+    const { onClose } = this.props;
+    if (onClose && node && !node.contains(target)) {
+      onClose();
+    }
+  },
+
+  //  The enter key toggles the dropdown's open state, and the escape
+  //  key closes it.
+  keyDown ({ key }) {
+    const {
+      close,
+      toggle,
+    } = this.handlers;
+    switch (key) {
+    case 'Enter':
+      toggle();
+      break;
+    case 'Escape':
+      close();
+      break;
+    }
+  },
+
+  //  Toggles opening and closing the dropdown.
+  toggle () {
+    const {
+      items,
+      onChange,
+      onModalClose,
+      onModalOpen,
+      value,
+    } = this.props;
+    const { open } = this.state;
+
+    //  If this is a touch device, we open a modal instead of the
+    //  dropdown.
+    if (onModalClose && isUserTouching()) {
+      if (open) {
+        onModalClose()
+      } else if (onChange && onModalOpen) {
+        onModalOpen({
+          actions: items.map(
+            ({
+              name,
+              ...rest
+            }) => ({
+              ...rest,
+              active: value && name === value,
+              onClick (e) {
+                e.preventDefault();  //  Prevents focus from changing
+                onModalClose();
+                onChange(name);
+              },
+            })
+          ),
+        });
+      }
+
+    //  Otherwise, we just set our state to open.
+    } else {
+      this.setState({ open: !open });
+    }
+  },
+
+  //  Stores our node in `this.node`.
+  ref (node) {
+    this.node = node;
+  },
+};
+
+//  The component.
+export default class ComposerOptionsDropdown extends React.PureComponent {
+
+  //  Constructor.
+  constructor (props) {
+    super(props);
+    assignHandlers(this, handlers);
+    this.state = { open: false };
+
+    //  Instance variables.
+    this.node = null;
+  }
+
+  //  On mounting, we add our listeners.
+  componentDidMount () {
+    const { documentClick } = this.handlers;
+    document.addEventListener('click', documentClick, false);
+    document.addEventListener('touchend', documentClick, withPassive);
+  }
+
+  //  On unmounting, we remove our listeners.
+  componentWillUnmount () {
+    const { documentClick } = this.handlers;
+    document.removeEventListener('click', documentClick, false);
+    document.removeEventListener('touchend', documentClick, withPassive);
+  }
+
+  //  Rendering.
+  render () {
+    const {
+      close,
+      keyDown,
+      ref,
+      toggle,
+    } = this.handlers;
+    const {
+      active,
+      disabled,
+      title,
+      icon,
+      items,
+      onChange,
+      value,
+    } = this.props;
+    const { open } = this.state;
+    const computedClass = classNames('composer--options--dropdown', {
+      active,
+      open: open || active,
+    });
+
+    //  The result.
+    return (
+      <div
+        className={computedClass}
+        onKeyDown={keyDown}
+        ref={ref}
+      >
+        <IconButton
+          active={open || active}
+          className='value'
+          disabled={disabled}
+          icon={icon}
+          onClick={toggle}
+          size={18}
+          style={{
+            height: null,
+            lineHeight: '27px',
+          }}
+          title={title}
+        />
+        <Overlay
+          placement='bottom'
+          show={open}
+          target={this}
+        >
+          <Motion
+            defaultStyle={{
+              opacity: 0,
+              scaleX: 0.85,
+              scaleY: 0.75,
+            }}
+            style={{
+              opacity: springMotion,
+              scaleX: springMotion,
+              scaleY: springMotion,
+            }}
+          >
+            {({ opacity, scaleX, scaleY }) => (
+              <div
+                className='dropdown'
+                ref={this.setRef}
+                style={{
+                  opacity: opacity,
+                  transform: `scale(${scaleX}, ${scaleY})`,
+                }}
+              >
+                {items.map(
+                  ({
+                    name,
+                    ...rest
+                  }) => (
+                    <ComposerOptionsDropdownItem
+                      active={name === value}
+                      key={name}
+                      name={name}
+                      onChange={onChange}
+                      onClose={close}
+                      options={rest}
+                    />
+                  )
+                )}
+              </div>
+            )}
+          </Motion>
+        </Overlay>
+      </div>
+    );
+  }
+
+}
+
+//  Props.
+ComposerOptionsDropdown.propTypes = {
+  active: PropTypes.bool,
+  disabled: PropTypes.bool,
+  icon: PropTypes.string,
+  items: PropTypes.arrayOf(PropTypes.shape({
+    icon: PropTypes.string,
+    meta: PropTypes.node,
+    name: PropTypes.string.isRequired,
+    on: PropTypes.bool,
+    text: PropTypes.node,
+  })).isRequired,
+  onChange: PropTypes.func,
+  onModalClose: PropTypes.func,
+  onModalOpen: PropTypes.func,
+  title: PropTypes.string,
+  value: PropTypes.string,
+};
diff --git a/app/javascript/flavours/glitch/features/composer/options/dropdown/item/index.js b/app/javascript/flavours/glitch/features/composer/options/dropdown/item/index.js
new file mode 100644
index 000000000..ca4ee393e
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/composer/options/dropdown/item/index.js
@@ -0,0 +1,126 @@
+//  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.
+  activate (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 ComposerOptionsDropdownItem extends React.PureComponent {
+
+  //  Constructor.
+  constructor (props) {
+    super(props);
+    assignHandlers(this, handlers);
+  }
+
+  //  Rendering.
+  render () {
+    const { activate } = this.handlers;
+    const {
+      active,
+      options: {
+        icon,
+        meta,
+        on,
+        text,
+      },
+    } = this.props;
+    const computedClass = classNames('composer--options--dropdown_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={activate}
+        onKeyDown={activate}
+        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={activate}
+              />
+            );
+          case !!icon:
+            return (
+              <Icon
+                fullwidth
+                icon={icon}
+              />
+            );
+          default:
+            return null;
+          }
+        }()}
+        {meta ? (
+          <div>
+            <strong>{text}</strong>
+            {meta}
+          </div>
+        ) : <div>{text}</div>}
+      </div>
+    );
+  }
+
+};
+
+//  Props.
+ComposerOptionsDropdownItem.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/options/index.js b/app/javascript/flavours/glitch/features/composer/options/index.js
new file mode 100644
index 000000000..ee633e865
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/composer/options/index.js
@@ -0,0 +1,321 @@
+//  Package imports.
+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';
+import TextIconButton from 'flavours/glitch/components/text_icon_button';
+import Dropdown from './dropdown';
+
+//  Utils.
+import Motion from 'flavours/glitch/util/optional_motion';
+import {
+  assignHandlers,
+  hiddenComponent,
+} from 'flavours/glitch/util/react_helpers';
+
+//  Messages.
+const messages = defineMessages({
+  advanced_options_icon_title: {
+    defaultMessage: 'Advanced options',
+    id: 'advanced_options.icon_title',
+  },
+  attach: {
+    defaultMessage: 'Attach...',
+    id: 'compose.attach',
+  },
+  change_privacy: {
+    defaultMessage: 'Adjust status privacy',
+    id: 'privacy.change',
+  },
+  direct_long: {
+    defaultMessage: 'Post to mentioned users only',
+    id: 'privacy.direct.long',
+  },
+  direct_short: {
+    defaultMessage: 'Direct',
+    id: 'privacy.direct.short',
+  },
+  doodle: {
+    defaultMessage: 'Draw something',
+    id: 'compose.attach.doodle',
+  },
+  local_only_long: {
+    defaultMessage: 'Do not post to other instances',
+    id: 'advanced-options.local-only.long',
+  },
+  local_only_short: {
+    defaultMessage: 'Local-only',
+    id: 'advanced-options.local-only.short',
+  },
+  private_long: {
+    defaultMessage: 'Post to followers only',
+    id: 'privacy.private.long',
+  },
+  private_short: {
+    defaultMessage: 'Followers-only',
+    id: 'privacy.private.short',
+  },
+  public_long: {
+    defaultMessage: 'Post to public timelines',
+    id: 'privacy.public.long',
+  },
+  public_short: {
+    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',
+  },
+  unlisted_long: {
+    defaultMessage: 'Do not show in public timelines',
+    id: 'privacy.unlisted.long',
+  },
+  unlisted_short: {
+    defaultMessage: 'Unlisted',
+    id: 'privacy.unlisted.short',
+  },
+  upload: {
+    defaultMessage: 'Upload a file',
+    id: 'compose.attach.upload',
+  },
+});
+
+//  Handlers.
+const handlers = {
+
+  //  Handles file selection.
+  changeFiles ({ target: { files } }) {
+    const { onUpload } = this.props;
+    if (files.length && onUpload) {
+      onUpload(files);
+    }
+  },
+
+  //  Handles attachment clicks.
+  clickAttach (name) {
+    const { fileElement } = this;
+    const { onDoodleOpen } = this.props;
+
+    //  We switch over the name of the option.
+    switch (name) {
+    case 'upload':
+      if (fileElement) {
+        fileElement.click();
+      }
+      return;
+    case 'doodle':
+      if (onDoodleOpen) {
+        onDoodleOpen();
+      }
+      return;
+    }
+  },
+
+  //  Handles a ref to the file input.
+  refFileElement (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 {
+      changeFiles,
+      clickAttach,
+      refFileElement,
+    } = this.handlers;
+    const {
+      acceptContentTypes,
+      disabled,
+      doNotFederate,
+      full,
+      hasMedia,
+      intl,
+      onChangeSensitivity,
+      onChangeVisibility,
+      onModalClose,
+      onModalOpen,
+      onToggleAdvancedOption,
+      privacy,
+      resetFileKey,
+      sensitive,
+      spoiler,
+    } = this.props;
+
+    //  We predefine our privacy items so that we can easily pick the
+    //  dropdown icon later.
+    const privacyItems = {
+      direct: {
+        icon: 'envelope',
+        meta: <FormattedMessage {...messages.direct_long} />,
+        name: 'direct',
+        text: <FormattedMessage {...messages.direct_short} />,
+      },
+      private: {
+        icon: 'lock',
+        meta: <FormattedMessage {...messages.private_long} />,
+        name: 'private',
+        text: <FormattedMessage {...messages.private_short} />,
+      },
+      public: {
+        icon: 'globe',
+        meta: <FormattedMessage {...messages.public_long} />,
+        name: 'public',
+        text: <FormattedMessage {...messages.public_short} />,
+      },
+      unlisted: {
+        icon: 'unlock-alt',
+        meta: <FormattedMessage {...messages.unlisted_long} />,
+        name: 'unlisted',
+        text: <FormattedMessage {...messages.unlisted_short} />,
+      },
+    };
+
+    //  The result.
+    return (
+      <div className='composer--options'>
+        <input
+          accept={acceptContentTypes}
+          disabled={disabled || full}
+          key={resetFileKey}
+          onChange={changeFiles}
+          ref={refFileElement}
+          type='file'
+          {...hiddenComponent}
+        />
+        <Dropdown
+          disabled={disabled || full}
+          icon='paperclip'
+          items={[
+            {
+              icon: 'cloud-upload',
+              name: 'upload',
+              text: <FormattedMessage {...messages.upload} />,
+            },
+            {
+              icon: 'paint-brush',
+              name: 'doodle',
+              text: <FormattedMessage {...messages.doodle} />,
+            },
+          ]}
+          onChange={clickAttach}
+          onModalClose={onModalClose}
+          onModalOpen={onModalOpen}
+          title={messages.attach}
+        />
+        <Motion
+          defaultStyle={{ scale: 0.87 }}
+          style={{
+            scale: spring(hasMedia ? 1 : 0.87, {
+              stiffness: 200,
+              damping: 3,
+            }),
+          }}
+        >
+          {({ scale }) => (
+            <div style={{ 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}
+          icon={(privacyItems[privacy] || {}).icon}
+          items={[
+            privacyItems.public,
+            privacyItems.unlisted,
+            privacyItems.private,
+            privacyItems.direct,
+          ]}
+          onChange={onChangeVisibility}
+          onModalClose={onModalClose}
+          onModalOpen={onModalOpen}
+          title={intl.formatMessage(messages.change_privacy)}
+          value={privacy}
+        />
+        <TextIconButton
+          active={spoiler}
+          ariaControls='glitch.composer.spoiler.input'
+          label='CW'
+          title={intl.formatMessage(messages.spoiler)}
+        />
+        <Dropdown
+          active={doNotFederate}
+          disabled={disabled}
+          icon='home'
+          items={[
+            {
+              meta: <FormattedMessage {...messages.local_only_long} />,
+              name: 'do_not_federate',
+              on: doNotFederate,
+              text: <FormattedMessage {...messages.local_only_short} />,
+            },
+          ]}
+          onChange={onToggleAdvancedOption}
+          onModalClose={onModalClose}
+          onModalOpen={onModalOpen}
+          title={intl.formatMessage(messages.advanced_options_icon_title)}
+        />
+      </div>
+    );
+  }
+
+}
+
+//  Props.
+ComposerOptions.propTypes = {
+  acceptContentTypes: PropTypes.string,
+  disabled: PropTypes.bool,
+  doNotFederate: PropTypes.bool,
+  full: PropTypes.bool,
+  hasMedia: PropTypes.bool,
+  intl: PropTypes.object.isRequired,
+  onChangeSensitivity: PropTypes.func,
+  onChangeVisibility: PropTypes.func,
+  onDoodleOpen: PropTypes.func,
+  onModalClose: PropTypes.func,
+  onModalOpen: PropTypes.func,
+  onToggleAdvancedOption: PropTypes.func,
+  onUpload: PropTypes.func,
+  privacy: PropTypes.string,
+  resetFileKey: PropTypes.string,
+  sensitive: PropTypes.bool,
+  spoiler: PropTypes.bool,
+};
diff --git a/app/javascript/flavours/glitch/features/composer/publisher/index.js b/app/javascript/flavours/glitch/features/composer/publisher/index.js
new file mode 100644
index 000000000..85de80a9f
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/composer/publisher/index.js
@@ -0,0 +1,119 @@
+//  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 class='count'>{diff}</span>
+      {sideArm && sideArm !== 'none' ? (
+        <Button
+          text={
+            <span>
+              <Icon
+                icon={{
+                  public: 'globe',
+                  unlisted: 'unlock-alt',
+                  private: 'lock',
+                  direct: 'envelope',
+                }[sideArm]}
+              />
+            </span>
+          }
+          title={`${intl.formatMessage(messages.publish)}: ${intl.formatMessage({ id: `privacy.${sideArm}.short` })}`}
+          onClick={onSecondarySubmit}
+          disabled={disabled || diff < 0}
+        />
+      ) : null}
+      <Button
+        className='compose-form__publish__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-alt',
+                  }[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/reply/index.js b/app/javascript/flavours/glitch/features/composer/reply/index.js
new file mode 100644
index 000000000..2823415d2
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/composer/reply/index.js
@@ -0,0 +1,106 @@
+//  Package imports.
+import PropTypes from 'prop-types';
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { defineMessages } from 'react-intl';
+
+//  Components.
+import Avatar from 'flavours/glitch/components/avatar';
+import DisplayName from 'flavours/glitch/components/display_name';
+import IconButton from 'flavours/glitch/components/icon_button';
+
+//  Utils.
+import { assignHandlers } from 'flavours/glitch/util/react_helpers';
+import { isRtl } from 'flavours/glitch/util/rtl';
+
+//  Messages.
+const messages = defineMessages({
+  cancel: {
+    defaultMessage: 'Cancel',
+    id: 'reply_indicator.cancel',
+  },
+});
+
+//  Handlers.
+const handlers = {
+
+  //  Handles a click on the "close" button.
+  click () {
+    const { onCancel } = this.props;
+    if (onCancel) {
+      onCancel();
+    }
+  },
+
+  //  Handles a click on the status's account.
+  clickAccount () {
+    const {
+      account,
+      history,
+    } = this.props;
+    if (history) {
+      history.push(`/accounts/${account.get('id')}`);
+    }
+  },
+};
+
+//  The component.
+export default class ComposerReply extends React.PureComponent {
+
+  //  Constructor.
+  constructor (props) {
+    super(props);
+    assignHandlers(this, handlers);
+  }
+
+  //  Rendering.
+  render () {
+    const {
+      click,
+      clickAccount,
+    } = this.handlers;
+    const {
+      account,
+      content,
+      intl,
+    } = this.props;
+
+    //  The result.
+    return (
+      <article className='composer--reply'>
+        <header>
+          <IconButton
+            icon='times'
+            onClick={click}
+            title={intl.formatMessage(messages.cancel)}
+          />
+          {account ? (
+            <a
+              href={account.get('url')}
+              onClick={clickAccount}
+            >
+              <Avatar
+                account={account}
+                size={24}
+              />
+              <DisplayName account={account} />
+            </a>
+          ) : null}
+        </header>
+        <div
+          dangerouslySetInnerHTML={{ __html: content || '' }}
+          style={{ direction: isRtl(content) ? 'rtl' : 'ltr' }}
+        />
+      </article>
+    );
+  }
+
+}
+
+ComposerReply.propTypes = {
+  account: ImmutablePropTypes.map,
+  content: PropTypes.string,
+  history: PropTypes.object,
+  intl: PropTypes.object.isRequired,
+  onCancel: PropTypes.func,
+};
diff --git a/app/javascript/flavours/glitch/features/composer/spoiler/index.js b/app/javascript/flavours/glitch/features/composer/spoiler/index.js
new file mode 100644
index 000000000..730ab2205
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/composer/spoiler/index.js
@@ -0,0 +1,92 @@
+//  Package imports.
+import React from 'react';
+import PropTypes from 'prop-types';
+import { defineMessages, FormattedMessage } from 'react-intl';
+
+//  Components.
+import Collapsable from 'flavours/glitch/components/collapsable';
+
+//  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.
+  keyDown ({
+    ctrlKey,
+    keyCode,
+    metaKey,
+  }) {
+    const { onSubmit } = this.props;
+
+    //  We submit the status on control/meta + enter.
+    if (onSubmit && keyCode === 13 && (ctrlKey || metaKey)) {
+      onSubmit();
+    }
+  },
+};
+
+//  The component.
+export default class ComposerSpoiler extends React.PureComponent {
+
+  //  Constructor.
+  constructor (props) {
+    super(props);
+    assignHandlers(this, handlers);
+  }
+
+  //  Rendering.
+  render () {
+    const { keyDown } = this.handlers;
+    const {
+      hidden,
+      intl,
+      onChange,
+      text,
+    } = this.props;
+
+    //  The result.
+    return (
+      <Collapsable
+        isVisible={!hidden}
+        fullHeight={50}
+      >
+        <label className='composer--spoiler'>
+          <span {...hiddenComponent}>
+            <FormattedMessage {...messages.placeholder} />
+          </span>
+          <input
+            id='glitch.composer.spoiler.input'
+            onChange={onChange}
+            onKeyDown={keyDown}
+            placeholder={intl.formatMessage(messages.placeholder)}
+            type='text'
+            value={text}
+          />
+        </label>
+      </Collapsable>
+    );
+  }
+
+}
+
+//  Props.
+ComposerSpoiler.propTypes = {
+  hidden: PropTypes.bool,
+  intl: PropTypes.object.isRequired,
+  onChange: PropTypes.func,
+  onSubmit: PropTypes.func,
+  text: PropTypes.string,
+};
diff --git a/app/javascript/flavours/glitch/features/composer/textarea/index.js b/app/javascript/flavours/glitch/features/composer/textarea/index.js
new file mode 100644
index 000000000..ad0a35d7f
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/composer/textarea/index.js
@@ -0,0 +1,297 @@
+//  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 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.
+  blur () {
+    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.
+  change ({
+    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.
+  clickSuggestion (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.
+  keyDown (e) {
+    const {
+      disabled,
+      onSubmit,
+      onSuggestionSelected,
+      suggestions,
+    } = this.props;
+    const {
+      lastToken,
+      suggestionsHidden,
+      selectedSuggestion,
+      tokenStart,
+    } = this.state;
+
+    //  Keypresses do nothing if the composer is disabled.
+    if (disabled) {
+      e.preventDefault();
+      return;
+    }
+
+    //  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;
+    }
+
+    //  We submit the status on control/meta + enter.
+    if (onSubmit && e.keyCode === 13 && (e.ctrlKey || e.metaKey)) {
+      onSubmit();
+    }
+  },
+
+  //  When the escape key is released, we either close the suggestions
+  //  window or focus the UI.
+  keyUp ({ 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.
+  paste (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.
+  refTextarea (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 {
+      blur,
+      change,
+      clickSuggestion,
+      keyDown,
+      keyUp,
+      paste,
+      refTextarea,
+    } = this.handlers;
+    const {
+      autoFocus,
+      disabled,
+      intl,
+      onPickEmoji,
+      suggestions,
+      value,
+    } = this.props;
+    const {
+      selectedSuggestion,
+      suggestionsHidden,
+    } = this.state;
+
+    //  The result.
+    return (
+      <div className='autosuggest-textarea'>
+        <label>
+          <span {...hiddenComponent}><FormattedMessage {...messages.placeholder} /></span>
+          <Textarea
+            aria-autocomplete='list'
+            autoFocus={autoFocus}
+            disabled={disabled}
+            inputRef={refTextarea}
+            onBlur={blur}
+            onChange={change}
+            onKeyDown={keyDown}
+            onKeyUp={keyUp}
+            onPaste={paste}
+            placeholder={intl.formatMessage(messages.placeholder)}
+            value={value}
+            style={{ direction: isRtl(value) ? 'rtl' : 'ltr' }}
+          />
+        </label>
+        <EmojiPicker onPickEmoji={onPickEmoji} />
+        <ComposerTextareaSuggestions
+          hidden={suggestionsHidden}
+          onSuggestionClick={clickSuggestion}
+          suggestions={suggestions}
+          value={selectedSuggestion}
+        />
+      </div>
+    );
+  }
+
+}
+
+//  Props.
+ComposerTextarea.propTypes = {
+  autoFocus: PropTypes.bool,
+  disabled: PropTypes.bool,
+  intl: PropTypes.object.isRequired,
+  onChange: PropTypes.func,
+  onPaste: PropTypes.func,
+  onPickEmoji: PropTypes.func,
+  onSubmit: 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
new file mode 100644
index 000000000..b90696910
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/composer/textarea/suggestions/index.js
@@ -0,0 +1,41 @@
+//  Package imports.
+import classNames from 'classnames';
+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,
+}) {
+  const computedClass = classNames('comoser--textarea--suggestions', { hidden: hidden || suggestions.isEmpty() });
+
+  return (
+    <div className={computedClass}>
+      {!hidden ? 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.string,
+};
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
new file mode 100644
index 000000000..08c99ed0e
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/composer/textarea/suggestions/item/index.js
@@ -0,0 +1,101 @@
+//  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/emoji_unicode_mapping_light';
+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.
+  click (e) {
+    const {
+      index,
+      onClick,
+    } = this.props;
+    if (onClick) {
+      e.preventDefault();
+      onClick(index);
+    }
+  },
+};
+
+//  The component.
+export default class ComposerTextareaSuggestionsItem extends React.Component {
+
+  //  Constructor.
+  constructor (props) {
+    super(props);
+    assignHandlers(this, handlers);
+  }
+
+  //  Rendering.
+  render () {
+    const { click } = this.handlers;
+    const {
+      selected,
+      suggestion,
+    } = this.props;
+    const computedClass = classNames('composer--textarea--suggestions--item', { selected });
+
+    //  The result.
+    return (
+      <div
+        role='button'
+        tabIndex='0'
+        className={computedClass}
+        onMouseDown={click}
+      >
+        { //  If the suggestion is an object, then we render an emoji.
+          //  Otherwise, we render an account.
+          typeof suggestion === 'object' ? function () {
+            const url = function () {
+              if (suggestion.custom) {
+                return suggestion.imageUrl;
+              } else {
+                const mapping = unicodeMapping[suggestion.native] || unicodeMapping[suggestion.native.replace(/\uFE0F$/, '')];
+                if (!mapping) {
+                  return null;
+                }
+                return `${assetHost}/emoji/${mapping.filename}.svg`;
+              }
+            }();
+            return url ? (
+              <div className='emoji'>
+                <img
+                  alt={suggestion.native || suggestion.colons}
+                  className='emojione'
+                  src={url}
+                />
+                {suggestion.colons}
+              </div>
+            ) : null;
+          }() : (
+            <AccountContainer
+              id={suggestion}
+              small
+            />
+          )
+        }
+      </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
new file mode 100644
index 000000000..ab46a3046
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/composer/upload_form/index.js
@@ -0,0 +1,54 @@
+//  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 ({
+  active,
+  intl,
+  media,
+  onChangeDescription,
+  onRemove,
+  progress,
+}) {
+  const computedClass = classNames('composer--upload_form', { uploading: active });
+
+  //  We need `media` in order to be able to render.
+  if (!media) {
+    return null;
+  }
+
+  //  The result.
+  return (
+    <div className={computedClass}>
+      {active ? <ComposerUploadFormProgress progress={progress} /> : null}
+      {media.map(item => (
+        <ComposerUploadFormItem
+          description={item.get('description')}
+          key={item.get('id')}
+          id={item.get('id')}
+          intl={intl}
+          preview={item.get('preview_url')}
+          onChangeDescription={onChangeDescription}
+          onRemove={onRemove}
+        />
+      ))}
+    </div>
+  );
+}
+
+//  Props.
+ComposerUploadForm.propTypes = {
+  active: PropTypes.bool,
+  intl: PropTypes.object.isRequired,
+  media: ImmutablePropTypes.list,
+  onChangeDescription: PropTypes.func,
+  onRemove: PropTypes.func,
+  progress: PropTypes.number,
+};
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
new file mode 100644
index 000000000..bd67e7227
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/composer/upload_form/item/index.js
@@ -0,0 +1,176 @@
+//  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';
+
+//  Messages.
+const messages = defineMessages({
+  undo: {
+    defaultMessage: 'Undo',
+    id: 'upload_form.undo',
+  },
+  description: {
+    defaultMessage: 'Describe for the visually impaired',
+    id: 'upload_form.description',
+  },
+});
+
+//  Handlers.
+const handlers = {
+
+  //  On blur, we save the description for the media item.
+  blur () {
+    const {
+      id,
+      onChangeDescription,
+    } = this.props;
+    const { dirtyDescription } = this.state;
+    if (id && onChangeDescription && dirtyDescription !== null) {
+      this.setState({
+        dirtyDescription: null,
+        focused: false,
+      });
+      onChangeDescription(id, dirtyDescription);
+    }
+  },
+
+  //  When the value of our description changes, we store it in the
+  //  temp value `dirtyDescription` in our state.
+  change ({ target: { value } }) {
+    this.setState({ dirtyDescription: value });
+  },
+
+  //  Records focus on the media item.
+  focus () {
+    this.setState({ focused: true });
+  },
+
+  //  Records the start of a hover over the media item.
+  mouseEnter () {
+    this.setState({ hovered: true });
+  },
+
+  //  Records the end of a hover over the media item.
+  mouseLeave () {
+    this.setState({ hovered: false });
+  },
+
+  //  Removes the media item.
+  remove () {
+    const {
+      id,
+      onRemove,
+    } = this.props;
+    if (id && onRemove) {
+      onRemove(id);
+    }
+  },
+};
+
+//  The component.
+export default class ComposerUploadFormItem extends React.PureComponent {
+
+  //  Constructor.
+  constructor (props) {
+    super(props);
+    assignHandlers(handlers);
+    this.state = {
+      hovered: false,
+      focused: false,
+      dirtyDescription: null,
+    };
+  }
+
+  //  Rendering.
+  render () {
+    const {
+      blur,
+      change,
+      focus,
+      mouseEnter,
+      mouseLeave,
+      remove,
+    } = this.handlers;
+    const {
+      description,
+      intl,
+      preview,
+    } = this.props;
+    const {
+      focused,
+      hovered,
+      dirtyDescription,
+    } = this.state;
+    const computedClass = classNames('composer--upload_form--item', { active: hovered || focused });
+
+    //  The result.
+    return (
+      <div
+        className={computedClass}
+        onMouseEnter={mouseEnter}
+        onMouseLeave={mouseLeave}
+      >
+        <Motion
+          defaultStyle={{ scale: 0.8 }}
+          style={{
+            scale: spring(1, {
+              stiffness: 180,
+              damping: 12,
+            }),
+          }}
+        >
+          {({ scale }) => (
+            <div
+              style={{
+                transform: `scale(${scale})`,
+                backgroundImage: preview ? `url(${preview})` : null,
+              }}
+            >
+              <IconButton
+                icon='times'
+                onClick={remove}
+                size={36}
+                title={intl.formatMessage(messages.undo)}
+              />
+              <label>
+                <span style={{ display: 'none' }}><FormattedMessage {...messages.description} /></span>
+                <input
+                  maxLength={420}
+                  onBlur={blur}
+                  onChange={change}
+                  onFocus={focus}
+                  placeholder={intl.formatMessage(messages.description)}
+                  type='text'
+                  value={dirtyDescription || description || ''}
+                />
+              </label>
+            </div>
+          )}
+        </Motion>
+      </div>
+    );
+  }
+
+}
+
+//  Props.
+ComposerUploadFormItem.propTypes = {
+  description: PropTypes.string,
+  id: PropTypes.number,
+  intl: PropTypes.object.isRequired,
+  onChangeDescription: PropTypes.func,
+  onRemove: PropTypes.func,
+  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
new file mode 100644
index 000000000..9dac6acf9
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/composer/upload_form/progress/index.js
@@ -0,0 +1,52 @@
+//  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
new file mode 100644
index 000000000..c225b50e8
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/composer/warning/index.js
@@ -0,0 +1,54 @@
+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: '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 () {
+  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: <a href='/settings/profile'><FormattedMessage {...messages.locked} /></a> }}
+          />
+        </div>
+      )}
+    </Motion>
+  );
+}
+
+ComposerWarning.propTypes = {};
diff --git a/app/javascript/flavours/glitch/features/compose/components/navigation_bar.js b/app/javascript/flavours/glitch/features/drawer/components/navigation_bar.js
index 1b6d74123..1b6d74123 100644
--- a/app/javascript/flavours/glitch/features/compose/components/navigation_bar.js
+++ b/app/javascript/flavours/glitch/features/drawer/components/navigation_bar.js
diff --git a/app/javascript/flavours/glitch/features/compose/components/search.js b/app/javascript/flavours/glitch/features/drawer/components/search.js
index 1ce66b19d..1ce66b19d 100644
--- a/app/javascript/flavours/glitch/features/compose/components/search.js
+++ b/app/javascript/flavours/glitch/features/drawer/components/search.js
diff --git a/app/javascript/flavours/glitch/features/compose/components/search_results.js b/app/javascript/flavours/glitch/features/drawer/components/search_results.js
index 2a4818d4e..2a4818d4e 100644
--- a/app/javascript/flavours/glitch/features/compose/components/search_results.js
+++ b/app/javascript/flavours/glitch/features/drawer/components/search_results.js
diff --git a/app/javascript/flavours/glitch/features/compose/containers/navigation_container.js b/app/javascript/flavours/glitch/features/drawer/containers/navigation_container.js
index eb630ffbb..eb630ffbb 100644
--- a/app/javascript/flavours/glitch/features/compose/containers/navigation_container.js
+++ b/app/javascript/flavours/glitch/features/drawer/containers/navigation_container.js
diff --git a/app/javascript/flavours/glitch/features/compose/containers/search_container.js b/app/javascript/flavours/glitch/features/drawer/containers/search_container.js
index 8f4bfcf08..8f4bfcf08 100644
--- a/app/javascript/flavours/glitch/features/compose/containers/search_container.js
+++ b/app/javascript/flavours/glitch/features/drawer/containers/search_container.js
diff --git a/app/javascript/flavours/glitch/features/compose/containers/search_results_container.js b/app/javascript/flavours/glitch/features/drawer/containers/search_results_container.js
index 16d95d417..16d95d417 100644
--- a/app/javascript/flavours/glitch/features/compose/containers/search_results_container.js
+++ b/app/javascript/flavours/glitch/features/drawer/containers/search_results_container.js
diff --git a/app/javascript/flavours/glitch/features/drawer/index.js b/app/javascript/flavours/glitch/features/drawer/index.js
new file mode 100644
index 000000000..8386ae47c
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/drawer/index.js
@@ -0,0 +1,198 @@
+//  Package imports.
+import PropTypes from 'prop-types';
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { injectIntl, defineMessages } from 'react-intl';
+import spring from 'react-motion/lib/spring';
+import { connect } from 'react-redux';
+import { Link } from 'react-router-dom';
+
+//  Actions.
+import { changeComposing } from 'flavours/glitch/actions/compose';
+import { changeLocalSetting } from 'flavours/glitch/actions/local_settings';
+import { openModal } from 'flavours/glitch/actions/modal';
+
+//  Components.
+import Icon from 'flavours/glitch/components/icon';
+import Compose from 'flavours/glitch/features/compose';
+import NavigationContainer from './containers/navigation_container';
+import SearchContainer from './containers/search_container';
+import SearchResultsContainer from './containers/search_results_container';
+
+//  Utils.
+import Motion from 'flavours/glitch/util/optional_motion';
+import {
+  assignHandlers,
+  conditionalRender,
+} from 'flavours/glitch/util/react_helpers';
+
+//  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',
+  },
+});
+
+//  State mapping.
+const mapStateToProps = state => ({
+  columns: state.getIn(['settings', 'columns']),
+  showSearch: state.getIn(['search', 'submitted']) && !state.getIn(['search', 'hidden']),
+});
+
+//  Dispatch mapping.
+const mapDispatchToProps = dispatch => ({
+  onBlur () {
+    dispatch(changeComposing(false));
+  },
+  onFocus () {
+    dispatch(changeComposing(true));
+  },
+  onSettingsOpen () {
+    dispatch(openModal('SETTINGS', {}));
+  },
+});
+
+//  The component.
+@connect(mapStateToProps, mapDispatchToProps)
+@injectIntl
+export default function Drawer ({
+  columns,
+  intl,
+  multiColumn,
+  onBlur,
+  onFocus,
+  onSettingsOpen,
+  showSearch,
+}) {
+
+  //  Only renders the component if the column isn't being shown.
+  const renderForColumn = conditionalRender.bind(
+    columnId => !columns.some(column => column.get('id') === columnId)
+  );
+
+  //  The result.
+  return (
+    <div className='drawer'>
+      {multiColumn ? (
+        <nav className='drawer__header'>
+          <Link
+            aria-label={intl.formatMessage(messages.start)}
+            className='drawer__tab'
+            title={intl.formatMessage(messages.start)}
+            to='/getting-started'
+          ><Icon icon='asterisk' /></Link>
+          {renderForColumn('HOME', (
+            <Link
+              aria-label={intl.formatMessage(messages.home_timeline)}
+              className='drawer__tab'
+              title={intl.formatMessage(messages.home_timeline)}
+              to='/timelines/home'
+            ><Icon icon='home' /></Link>
+          ))}
+          {renderForColumn('NOTIFICATIONS', (
+            <Link
+              aria-label={intl.formatMessage(messages.notifications)}
+              className='drawer__tab'
+              title={intl.formatMessage(messages.notifications)}
+              to='/notifications'
+            ><Icon icon='bell' /></Link>
+          ))}
+          {renderForColumn('COMMUNITY', (
+            <Link
+              aria-label={intl.formatMessage(messages.community)}
+              className='drawer__tab'
+              title={intl.formatMessage(messages.community)}
+              to='/timelines/public/local'
+            ><Icon icon='users' /></Link>
+          ))}
+          {renderForColumn('PUBLIC', (
+            <Link
+              aria-label={intl.formatMessage(messages.public)}
+              className='drawer__tab'
+              title={intl.formatMessage(messages.public)}
+              to='/timelines/public'
+            ><Icon icon='globe' /></Link>
+          ))}
+          <a
+            aria-label={intl.formatMessage(messages.settings)}
+            className='drawer__tab'
+            onClick={settings}
+            role='button'
+            title={intl.formatMessage(messages.settings)}
+            tabIndex='0'
+          ><Icon icon='cogs' /></a>
+          <a
+            aria-label={intl.formatMessage(messages.logout)}
+            className='drawer__tab'
+            data-method='delete'
+            href='/auth/sign_out'
+            title={intl.formatMessage(messages.logout)}
+          ><Icon icon='sign-out' /></a>
+        </nav>
+      ) : null}
+      <SearchContainer />
+      <div className='drawer__pager'>
+        <div
+          className='drawer__inner scrollable optionally-scrollable'
+          onFocus={focus}
+        >
+          <NavigationContainer onClose={blur} />
+          <Compose />
+        </div>
+        <Motion
+          defaultStyle={{ x: -100 }}
+          style={{
+            x: spring(showSearch ? 0 : -100, {
+              stiffness: 210,
+              damping: 20,
+            })
+          }}
+        >
+          {({ x }) => (
+            <div
+              className='drawer__inner darker scrollable optionally-scrollable'
+              style={{
+                transform: `translateX(${x}%)`,
+                visibility: x === -100 ? 'hidden' : 'visible'
+              }}
+            ><SearchResultsContainer /></div>
+          )}
+        </Motion>
+      </div>
+    </div>
+  );
+}
+
+//  Props.
+Drawer.propTypes = {
+  dispatch: PropTypes.func.isRequired,
+  columns: ImmutablePropTypes.list.isRequired,
+  multiColumn: PropTypes.bool,
+  showSearch: PropTypes.bool,
+  intl: PropTypes.object.isRequired,
+};
diff --git a/app/javascript/flavours/glitch/features/compose/components/emoji_picker_dropdown.js b/app/javascript/flavours/glitch/features/emoji_picker/index.js
index cf89f91d3..4b1ef6c97 100644
--- a/app/javascript/flavours/glitch/features/compose/components/emoji_picker_dropdown.js
+++ b/app/javascript/flavours/glitch/features/emoji_picker/index.js
@@ -1,3 +1,8 @@
+import { connect } from 'react-redux';
+import { changeSetting } from 'flavours/glitch/actions/settings';
+import { createSelector } from 'reselect';
+import { Map as ImmutableMap } from 'immutable';
+import { useEmoji } from 'flavours/glitch/actions/emojis';
 import React from 'react';
 import PropTypes from 'prop-types';
 import { defineMessages, injectIntl } from 'react-intl';
@@ -25,6 +30,80 @@ const messages = defineMessages({
   flags: { id: 'emoji_button.flags', defaultMessage: 'Flags' },
 });
 
+const perLine = 8;
+const lines   = 2;
+
+const DEFAULTS = [
+  '+1',
+  'grinning',
+  'kissing_heart',
+  'heart_eyes',
+  'laughing',
+  'stuck_out_tongue_winking_eye',
+  'sweat_smile',
+  'joy',
+  'yum',
+  'disappointed',
+  'thinking_face',
+  'weary',
+  'sob',
+  'sunglasses',
+  'heart',
+  'ok_hand',
+];
+
+const getFrequentlyUsedEmojis = createSelector([
+  state => state.getIn(['settings', 'frequentlyUsedEmojis'], ImmutableMap()),
+], emojiCounters => {
+  let emojis = emojiCounters
+    .keySeq()
+    .sort((a, b) => emojiCounters.get(a) - emojiCounters.get(b))
+    .reverse()
+    .slice(0, perLine * lines)
+    .toArray();
+
+  if (emojis.length < DEFAULTS.length) {
+    emojis = emojis.concat(DEFAULTS.slice(0, DEFAULTS.length - emojis.length));
+  }
+
+  return emojis;
+});
+
+const getCustomEmojis = createSelector([
+  state => state.get('custom_emojis'),
+], emojis => emojis.filter(e => e.get('visible_in_picker')).sort((a, b) => {
+  const aShort = a.get('shortcode').toLowerCase();
+  const bShort = b.get('shortcode').toLowerCase();
+
+  if (aShort < bShort) {
+    return -1;
+  } else if (aShort > bShort ) {
+    return 1;
+  } else {
+    return 0;
+  }
+}));
+
+const mapStateToProps = state => ({
+  custom_emojis: getCustomEmojis(state),
+  skinTone: state.getIn(['settings', 'skinTone']),
+  frequentlyUsedEmojis: getFrequentlyUsedEmojis(state),
+});
+
+const mapDispatchToProps = (dispatch, { onPickEmoji }) => ({
+  onSkinTone: skinTone => {
+    dispatch(changeSetting(['skinTone'], skinTone));
+  },
+
+  onPickEmoji: emoji => {
+    dispatch(useEmoji(emoji));
+
+    if (onPickEmoji) {
+      onPickEmoji(emoji);
+    }
+  },
+});
+
 const assetHost = process.env.CDN_HOST || '';
 let EmojiPicker, Emoji; // load asynchronously
 
@@ -277,6 +356,7 @@ class EmojiPickerMenu extends React.PureComponent {
 
 }
 
+@connect(mapStateToProps, mapDispatchToProps)
 @injectIntl
 export default class EmojiPickerDropdown extends React.PureComponent {
 
diff --git a/app/javascript/flavours/glitch/features/standalone/compose/index.js b/app/javascript/flavours/glitch/features/standalone/compose/index.js
index b33c21cb5..a77b59448 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 ComposeFormContainer from 'flavours/glitch/features/compose/containers/compose_form_container';
+import Composer from 'flavours/glitch/features/composer';
 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>
-        <ComposeFormContainer />
+        <Composer />
         <NotificationsContainer />
         <ModalContainer />
         <LoadingBarContainer className='loading-bar' />
diff --git a/app/javascript/flavours/glitch/reducers/compose.js b/app/javascript/flavours/glitch/reducers/compose.js
index aaa36b696..52a8ab5ec 100644
--- a/app/javascript/flavours/glitch/reducers/compose.js
+++ b/app/javascript/flavours/glitch/reducers/compose.js
@@ -134,7 +134,7 @@ function removeMedia(state, mediaId) {
 
 const insertSuggestion = (state, position, token, completion) => {
   return state.withMutations(map => {
-    map.update('text', oldText => `${oldText.slice(0, position)}${completion}\u200B${oldText.slice(position + token.length)}`);
+    map.update('text', 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());
diff --git a/app/javascript/flavours/glitch/styles/components/compose.scss b/app/javascript/flavours/glitch/styles/components/compose.scss
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/app/javascript/flavours/glitch/styles/components/compose.scss
diff --git a/app/javascript/flavours/glitch/styles/components/drawer.scss b/app/javascript/flavours/glitch/styles/components/drawer.scss
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/app/javascript/flavours/glitch/styles/components/drawer.scss
diff --git a/app/javascript/flavours/glitch/util/dom_helpers.js b/app/javascript/flavours/glitch/util/dom_helpers.js
new file mode 100644
index 000000000..ee95ef8dd
--- /dev/null
+++ b/app/javascript/flavours/glitch/util/dom_helpers.js
@@ -0,0 +1,6 @@
+//  Package imports.
+import detectPassiveEvents from 'detect-passive-events';
+
+//  This will either be a passive lister options object (if passive
+//  events are supported), or `false`.
+export const withPassive = detectPassiveEvents.hasSupport ? { passive: true } : false;
diff --git a/app/javascript/flavours/glitch/util/initial_state.js b/app/javascript/flavours/glitch/util/initial_state.js
index 607d6b9b0..530bca7ef 100644
--- a/app/javascript/flavours/glitch/util/initial_state.js
+++ b/app/javascript/flavours/glitch/util/initial_state.js
@@ -18,5 +18,6 @@ export const boostModal = getMeta('boost_modal');
 export const favouriteModal = getMeta('favourite_modal');
 export const deleteModal = getMeta('delete_modal');
 export const me = getMeta('me');
+export const maxChars = getMeta('max_toot_chars') || 500;
 
 export default initialState;
diff --git a/app/javascript/flavours/glitch/util/react_helpers.js b/app/javascript/flavours/glitch/util/react_helpers.js
new file mode 100644
index 000000000..0826f3584
--- /dev/null
+++ b/app/javascript/flavours/glitch/util/react_helpers.js
@@ -0,0 +1,21 @@
+//  This function binds the given `handlers` to the `target`.
+export function assignHandlers (target, handlers) {
+  if (!target || !handlers) {
+    return;
+  }
+
+  //  We just bind each handler to the `target`.
+  const handle = target.handlers = {};
+  handlers.keys().forEach(
+    key => handle.key = key.bind(target)
+  );
+}
+
+//  This function only returns the component if the result of calling
+//  `test` with `data` is `true`.  Useful with funciton binding.
+export function conditionalRender (test, data, component) {
+  return test ? component : null;
+}
+
+//  This object provides props to make the component not visible.
+export const hiddenComponent = { style: { display: 'none' } };
diff --git a/app/javascript/flavours/glitch/util/redux_helpers.js b/app/javascript/flavours/glitch/util/redux_helpers.js
new file mode 100644
index 000000000..3bc8bc86f
--- /dev/null
+++ b/app/javascript/flavours/glitch/util/redux_helpers.js
@@ -0,0 +1,7 @@
+//  Merges react-redux props.
+export function mergeProps (stateProps, dispatchProps, ownProps) {
+  Object.assign({}, ownProps, {
+    dispatch: Object.assign({}, dispatchProps, ownProps.dispatch || {}),
+    state: Object.assign({}, stateProps, ownProps.state || {}),
+  });
+}