about summary refs log tree commit diff
path: root/app/javascript/flavours/glitch/features/compose
diff options
context:
space:
mode:
Diffstat (limited to 'app/javascript/flavours/glitch/features/compose')
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/action_bar.jsx68
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/autosuggest_account.jsx24
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/character_counter.jsx25
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/compose_form.jsx391
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/dropdown.jsx243
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/dropdown_menu.jsx199
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/emoji_picker_dropdown.jsx413
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/header.jsx136
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/language_dropdown.jsx327
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/navigation_bar.jsx46
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/options.jsx317
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/poll_form.jsx170
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/privacy_dropdown.jsx88
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/publisher.jsx99
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/reply_indicator.jsx82
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/search.jsx168
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/search_results.jsx141
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/text_icon_button.jsx38
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/textarea_icons.jsx60
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/upload.jsx67
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/upload_form.jsx34
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/upload_progress.jsx52
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/warning.jsx26
-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.js144
-rw-r--r--app/javascript/flavours/glitch/features/compose/containers/dropdown_container.js12
-rw-r--r--app/javascript/flavours/glitch/features/compose/containers/emoji_picker_dropdown_container.js83
-rw-r--r--app/javascript/flavours/glitch/features/compose/containers/header_container.js36
-rw-r--r--app/javascript/flavours/glitch/features/compose/containers/language_dropdown_container.js34
-rw-r--r--app/javascript/flavours/glitch/features/compose/containers/navigation_container.js30
-rw-r--r--app/javascript/flavours/glitch/features/compose/containers/options_container.js53
-rw-r--r--app/javascript/flavours/glitch/features/compose/containers/poll_form_container.js52
-rw-r--r--app/javascript/flavours/glitch/features/compose/containers/privacy_dropdown_container.js23
-rw-r--r--app/javascript/flavours/glitch/features/compose/containers/reply_indicator_container.js32
-rw-r--r--app/javascript/flavours/glitch/features/compose/containers/search_container.js35
-rw-r--r--app/javascript/flavours/glitch/features/compose/containers/search_results_container.js18
-rw-r--r--app/javascript/flavours/glitch/features/compose/containers/sensitive_button_container.jsx75
-rw-r--r--app/javascript/flavours/glitch/features/compose/containers/upload_container.js25
-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.js10
-rw-r--r--app/javascript/flavours/glitch/features/compose/containers/warning_container.jsx68
-rw-r--r--app/javascript/flavours/glitch/features/compose/index.jsx116
-rw-r--r--app/javascript/flavours/glitch/features/compose/util/counter.js9
-rw-r--r--app/javascript/flavours/glitch/features/compose/util/url_regex.js30
44 files changed, 4122 insertions, 0 deletions
diff --git a/app/javascript/flavours/glitch/features/compose/components/action_bar.jsx b/app/javascript/flavours/glitch/features/compose/components/action_bar.jsx
new file mode 100644
index 000000000..1843fdacb
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/components/action_bar.jsx
@@ -0,0 +1,68 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import DropdownMenuContainer from '../../../containers/dropdown_menu_container';
+import { defineMessages, injectIntl } from 'react-intl';
+import { preferencesLink, profileLink } from 'flavours/glitch/utils/backend_links';
+
+const messages = defineMessages({
+  edit_profile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' },
+  pins: { id: 'navigation_bar.pins', defaultMessage: 'Pinned posts' },
+  preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' },
+  follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' },
+  favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favourites' },
+  lists: { id: 'navigation_bar.lists', defaultMessage: 'Lists' },
+  followed_tags: { id: 'navigation_bar.followed_tags', defaultMessage: 'Followed hashtags' },
+  blocks: { id: 'navigation_bar.blocks', defaultMessage: 'Blocked users' },
+  domain_blocks: { id: 'navigation_bar.domain_blocks', defaultMessage: 'Hidden domains' },
+  mutes: { id: 'navigation_bar.mutes', defaultMessage: 'Muted users' },
+  filters: { id: 'navigation_bar.filters', defaultMessage: 'Muted words' },
+  logout: { id: 'navigation_bar.logout', defaultMessage: 'Logout' },
+  bookmarks: { id: 'navigation_bar.bookmarks', defaultMessage: 'Bookmarks' },
+});
+
+export default @injectIntl
+class ActionBar extends React.PureComponent {
+
+  static propTypes = {
+    account: ImmutablePropTypes.map.isRequired,
+    onLogout: PropTypes.func.isRequired,
+    intl: PropTypes.object.isRequired,
+  };
+
+  handleLogout = () => {
+    this.props.onLogout();
+  };
+
+  render () {
+    const { intl } = this.props;
+
+    let menu = [];
+
+    menu.push({ text: intl.formatMessage(messages.edit_profile), href: profileLink });
+    menu.push({ text: intl.formatMessage(messages.preferences), href: preferencesLink });
+    menu.push({ text: intl.formatMessage(messages.pins), to: '/pinned' });
+    menu.push(null);
+    menu.push({ text: intl.formatMessage(messages.follow_requests), to: '/follow_requests' });
+    menu.push({ text: intl.formatMessage(messages.favourites), to: '/favourites' });
+    menu.push({ text: intl.formatMessage(messages.bookmarks), to: '/bookmarks' });
+    menu.push({ text: intl.formatMessage(messages.lists), to: '/lists' });
+    menu.push({ text: intl.formatMessage(messages.followed_tags), to: '/followed_tags' });
+    menu.push(null);
+    menu.push({ text: intl.formatMessage(messages.mutes), to: '/mutes' });
+    menu.push({ text: intl.formatMessage(messages.blocks), to: '/blocks' });
+    menu.push({ text: intl.formatMessage(messages.domain_blocks), to: '/domain_blocks' });
+    menu.push({ text: intl.formatMessage(messages.filters), href: '/filters' });
+    menu.push(null);
+    menu.push({ text: intl.formatMessage(messages.logout), action: this.handleLogout });
+
+    return (
+      <div className='compose__action-bar'>
+        <div className='compose__action-bar-dropdown'>
+          <DropdownMenuContainer items={menu} icon='ellipsis-v' size={18} direction='right' />
+        </div>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/compose/components/autosuggest_account.jsx b/app/javascript/flavours/glitch/features/compose/components/autosuggest_account.jsx
new file mode 100644
index 000000000..fb9bb5035
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/components/autosuggest_account.jsx
@@ -0,0 +1,24 @@
+import React from 'react';
+import Avatar from 'flavours/glitch/components/avatar';
+import DisplayName from 'flavours/glitch/components/display_name';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+export default class AutosuggestAccount extends ImmutablePureComponent {
+
+  static propTypes = {
+    account: ImmutablePropTypes.map.isRequired,
+  };
+
+  render () {
+    const { account } = this.props;
+
+    return (
+      <div className='account small' title={account.get('acct')}>
+        <div className='account__avatar-wrapper'><Avatar account={account} size={24} /></div>
+        <DisplayName account={account} inline />
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/compose/components/character_counter.jsx b/app/javascript/flavours/glitch/features/compose/components/character_counter.jsx
new file mode 100644
index 000000000..0ecfc9141
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/components/character_counter.jsx
@@ -0,0 +1,25 @@
+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.jsx b/app/javascript/flavours/glitch/features/compose/components/compose_form.jsx
new file mode 100644
index 000000000..696188f31
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/components/compose_form.jsx
@@ -0,0 +1,391 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import ReplyIndicatorContainer from '../containers/reply_indicator_container';
+import AutosuggestTextarea from '../../../components/autosuggest_textarea';
+import AutosuggestInput from '../../../components/autosuggest_input';
+import { defineMessages, injectIntl } from 'react-intl';
+import EmojiPickerDropdown from '../containers/emoji_picker_dropdown_container';
+import PollFormContainer from '../containers/poll_form_container';
+import UploadFormContainer from '../containers/upload_form_container';
+import WarningContainer from '../containers/warning_container';
+import { isMobile } from 'flavours/glitch/is_mobile';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import { countableText } from '../util/counter';
+import OptionsContainer from '../containers/options_container';
+import Publisher from './publisher';
+import TextareaIcons from './textarea_icons';
+import { maxChars } from 'flavours/glitch/initial_state';
+import CharacterCounter from './character_counter';
+import { length } from 'stringz';
+
+const messages = defineMessages({
+  placeholder: { id: 'compose_form.placeholder', defaultMessage: 'What is on your mind?' },
+  missingDescriptionMessage: {
+    id: 'confirmations.missing_media_description.message',
+    defaultMessage: 'At least one media attachment is lacking a description. Consider describing all media attachments for the visually impaired before sending your toot.',
+  },
+  missingDescriptionConfirm: {
+    id: 'confirmations.missing_media_description.confirm',
+    defaultMessage: 'Send anyway',
+  },
+  spoiler_placeholder: { id: 'compose_form.spoiler_placeholder', defaultMessage: 'Write your warning here' },
+});
+
+export default @injectIntl
+class ComposeForm extends ImmutablePureComponent {
+
+  static contextTypes = {
+    router: PropTypes.object,
+  };
+
+  static propTypes = {
+    intl: PropTypes.object.isRequired,
+    text: PropTypes.string,
+    suggestions: ImmutablePropTypes.list,
+    spoiler: PropTypes.bool,
+    privacy: PropTypes.string,
+    spoilerText: PropTypes.string,
+    focusDate: PropTypes.instanceOf(Date),
+    caretPosition: PropTypes.number,
+    preselectDate: PropTypes.instanceOf(Date),
+    isSubmitting: PropTypes.bool,
+    isChangingUpload: PropTypes.bool,
+    isEditing: PropTypes.bool,
+    isUploading: PropTypes.bool,
+    onChange: PropTypes.func,
+    onSubmit: PropTypes.func,
+    onClearSuggestions: PropTypes.func,
+    onFetchSuggestions: PropTypes.func,
+    onSuggestionSelected: PropTypes.func,
+    onChangeSpoilerText: PropTypes.func,
+    onPaste: PropTypes.func,
+    onPickEmoji: PropTypes.func,
+    showSearch: PropTypes.bool,
+    anyMedia: PropTypes.bool,
+    isInReply: PropTypes.bool,
+    singleColumn: PropTypes.bool,
+    lang: PropTypes.string,
+
+    advancedOptions: ImmutablePropTypes.map,
+    layout: PropTypes.string,
+    media: ImmutablePropTypes.list,
+    sideArm: PropTypes.string,
+    sensitive: PropTypes.bool,
+    spoilersAlwaysOn: PropTypes.bool,
+    mediaDescriptionConfirmation: PropTypes.bool,
+    preselectOnReply: PropTypes.bool,
+    onChangeSpoilerness: PropTypes.func,
+    onChangeVisibility: PropTypes.func,
+    onPaste: PropTypes.func,
+    onMediaDescriptionConfirm: PropTypes.func,
+  };
+
+  static defaultProps = {
+    showSearch: false,
+  };
+
+  handleChange = (e) => {
+    this.props.onChange(e.target.value);
+  };
+
+  getFulltextForCharacterCounting = () => {
+    return [
+      this.props.spoiler? this.props.spoilerText: '',
+      countableText(this.props.text),
+      this.props.advancedOptions && this.props.advancedOptions.get('do_not_federate') ? ' 👁️' : '',
+    ].join('');
+  };
+
+  canSubmit = () => {
+    const { isSubmitting, isChangingUpload, isUploading, anyMedia } = this.props;
+    const fulltext = this.getFulltextForCharacterCounting();
+
+    return !(isSubmitting || isUploading || isChangingUpload || length(fulltext) > maxChars || (!fulltext.trim().length && !anyMedia));
+  };
+
+  handleSubmit = (overriddenVisibility = null) => {
+    const {
+      onSubmit,
+      media,
+      mediaDescriptionConfirmation,
+      onMediaDescriptionConfirm,
+      onChangeVisibility,
+    } = this.props;
+
+    if (this.props.text !== this.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.textarea.value);
+    }
+
+    if (!this.canSubmit()) {
+      return;
+    }
+
+    // Submit unless there are media with missing descriptions
+    if (mediaDescriptionConfirmation && onMediaDescriptionConfirm && media && media.some(item => !item.get('description'))) {
+      const firstWithoutDescription = media.find(item => !item.get('description'));
+      onMediaDescriptionConfirm(this.context.router ? this.context.router.history : null, firstWithoutDescription.get('id'), overriddenVisibility);
+    } else if (onSubmit) {
+      if (onChangeVisibility && overriddenVisibility) {
+        onChangeVisibility(overriddenVisibility);
+      }
+      onSubmit(this.context.router ? this.context.router.history : null);
+    }
+  };
+
+  //  Changes the text value of the spoiler.
+  handleChangeSpoiler = ({ target: { value } }) => {
+    const { onChangeSpoilerText } = this.props;
+    if (onChangeSpoilerText) {
+      onChangeSpoilerText(value);
+    }
+  };
+
+  setRef = c => {
+    this.composeForm = c;
+  };
+
+  //  Inserts an emoji at the caret.
+  handleEmojiPick = (data) => {
+    const { textarea: { selectionStart } } = this;
+    const { onPickEmoji } = this.props;
+    if (onPickEmoji) {
+      onPickEmoji(selectionStart, data);
+    }
+  };
+
+  //  Handles the secondary submit button.
+  handleSecondarySubmit = () => {
+    const {
+      sideArm,
+    } = this.props;
+    this.handleSubmit(sideArm === 'none' ? null : sideArm);
+  };
+
+  //  Selects a suggestion from the autofill.
+  onSuggestionSelected = (tokenStart, token, value) => {
+    this.props.onSuggestionSelected(tokenStart, token, value, ['text']);
+  };
+
+  onSpoilerSuggestionSelected = (tokenStart, token, value) => {
+    this.props.onSuggestionSelected(tokenStart, token, value, ['spoiler_text']);
+  };
+
+  handleKeyDown = (e) => {
+    if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) {
+      this.handleSubmit();
+    }
+
+    if (e.keyCode == 13 && e.altKey) {
+      this.handleSecondarySubmit();
+    }
+  };
+
+  //  Sets a reference to the textarea.
+  setAutosuggestTextarea = (textareaComponent) => {
+    if (textareaComponent) {
+      this.textarea = textareaComponent.textarea;
+    }
+  };
+
+  //  Sets a reference to the CW field.
+  handleRefSpoilerText = (spoilerComponent) => {
+    if (spoilerComponent) {
+      this.spoilerText = spoilerComponent.input;
+    }
+  };
+
+  handleFocus = () => {
+    if (this.composeForm && !this.props.singleColumn) {
+      const { left, right } = this.composeForm.getBoundingClientRect();
+      if (left < 0 || right > (window.innerWidth || document.documentElement.clientWidth)) {
+        this.composeForm.scrollIntoView();
+      }
+    }
+  };
+
+  componentDidMount () {
+    this._updateFocusAndSelection({ });
+  }
+
+  componentDidUpdate (prevProps) {
+    this._updateFocusAndSelection(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.
+  _updateFocusAndSelection = (prevProps) => {
+    const {
+      textarea,
+      spoilerText,
+    } = this;
+    const {
+      focusDate,
+      caretPosition,
+      isSubmitting,
+      preselectDate,
+      text,
+      preselectOnReply,
+      singleColumn,
+    } = this.props;
+    let selectionEnd, selectionStart;
+
+    //  Caret/selection handling.
+    if (focusDate !== prevProps.focusDate) {
+      switch (true) {
+      case preselectDate !== prevProps.preselectDate && this.props.isInReply && preselectOnReply:
+        selectionStart = text.search(/\s/) + 1;
+        selectionEnd = text.length;
+        break;
+      case !isNaN(caretPosition) && caretPosition !== null:
+        selectionStart = selectionEnd = caretPosition;
+        break;
+      default:
+        selectionStart = selectionEnd = text.length;
+      }
+      if (textarea) {
+        // Because of the wicg-inert polyfill, the activeElement may not be
+        // immediately selectable, we have to wait for observers to run, as
+        // described in https://github.com/WICG/inert#performance-and-gotchas
+        Promise.resolve().then(() => {
+          textarea.setSelectionRange(selectionStart, selectionEnd);
+          textarea.focus();
+          if (!singleColumn) textarea.scrollIntoView();
+        }).catch(console.error);
+      }
+
+    //  Refocuses the textarea after submitting.
+    } else if (textarea && prevProps.isSubmitting && !isSubmitting) {
+      textarea.focus();
+    } else if (this.props.spoiler !== prevProps.spoiler) {
+      if (this.props.spoiler) {
+        if (spoilerText) {
+          spoilerText.focus();
+        }
+      } else {
+        if (textarea) {
+          textarea.focus();
+        }
+      }
+    }
+  };
+
+
+  render () {
+    const {
+      handleEmojiPick,
+      handleSecondarySubmit,
+      handleSelect,
+      handleSubmit,
+      handleRefTextarea,
+    } = this;
+    const {
+      advancedOptions,
+      intl,
+      isSubmitting,
+      layout,
+      onChangeSpoilerness,
+      onChangeVisibility,
+      onClearSuggestions,
+      onFetchSuggestions,
+      onPaste,
+      privacy,
+      sensitive,
+      showSearch,
+      sideArm,
+      spoiler,
+      spoilerText,
+      suggestions,
+      spoilersAlwaysOn,
+      isEditing,
+    } = this.props;
+
+    const countText = this.getFulltextForCharacterCounting();
+
+    return (
+      <div className='compose-form'>
+        <WarningContainer />
+
+        <ReplyIndicatorContainer />
+
+        <div className={`spoiler-input ${spoiler ? 'spoiler-input--visible' : ''}`} ref={this.setRef} aria-hidden={!this.props.spoiler}>
+          <AutosuggestInput
+            placeholder={intl.formatMessage(messages.spoiler_placeholder)}
+            value={spoilerText}
+            onChange={this.handleChangeSpoiler}
+            onKeyDown={this.handleKeyDown}
+            disabled={!spoiler}
+            ref={this.handleRefSpoilerText}
+            suggestions={this.props.suggestions}
+            onSuggestionsFetchRequested={onFetchSuggestions}
+            onSuggestionsClearRequested={onClearSuggestions}
+            onSuggestionSelected={this.onSpoilerSuggestionSelected}
+            searchTokens={[':']}
+            id='glitch.composer.spoiler.input'
+            className='spoiler-input__input'
+            lang={this.props.lang}
+            autoFocus={false}
+            spellCheck
+          />
+        </div>
+
+        <AutosuggestTextarea
+          ref={this.setAutosuggestTextarea}
+          placeholder={intl.formatMessage(messages.placeholder)}
+          disabled={isSubmitting}
+          value={this.props.text}
+          onChange={this.handleChange}
+          onKeyDown={this.handleKeyDown}
+          suggestions={this.props.suggestions}
+          onFocus={this.handleFocus}
+          onSuggestionsFetchRequested={onFetchSuggestions}
+          onSuggestionsClearRequested={onClearSuggestions}
+          onSuggestionSelected={this.onSuggestionSelected}
+          onPaste={onPaste}
+          autoFocus={!showSearch && !isMobile(window.innerWidth, layout)}
+          lang={this.props.lang}
+        >
+          <EmojiPickerDropdown onPickEmoji={handleEmojiPick} />
+          <TextareaIcons advancedOptions={advancedOptions} />
+          <div className='compose-form__modifiers'>
+            <UploadFormContainer />
+            <PollFormContainer />
+          </div>
+        </AutosuggestTextarea>
+
+        <div className='compose-form__buttons-wrapper'>
+          <OptionsContainer
+            advancedOptions={advancedOptions}
+            disabled={isSubmitting}
+            onToggleSpoiler={spoilersAlwaysOn ? null : onChangeSpoilerness}
+            onUpload={onPaste}
+            isEditing={isEditing}
+            sensitive={sensitive || (spoilersAlwaysOn && spoilerText && spoilerText.length > 0)}
+            spoiler={spoilersAlwaysOn ? (spoilerText && spoilerText.length > 0) : spoiler}
+          />
+          <div className='character-counter__wrapper'>
+            <CharacterCounter text={countText} max={maxChars} />
+          </div>
+        </div>
+
+        <Publisher
+          countText={countText}
+          disabled={!this.canSubmit()}
+          isEditing={isEditing}
+          onSecondarySubmit={handleSecondarySubmit}
+          onSubmit={handleSubmit}
+          privacy={privacy}
+          sideArm={sideArm}
+        />
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/compose/components/dropdown.jsx b/app/javascript/flavours/glitch/features/compose/components/dropdown.jsx
new file mode 100644
index 000000000..fe4ab36f5
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/components/dropdown.jsx
@@ -0,0 +1,243 @@
+//  Package imports.
+import classNames from 'classnames';
+import PropTypes from 'prop-types';
+import React from 'react';
+import Overlay from 'react-overlays/Overlay';
+
+//  Components.
+import IconButton from 'flavours/glitch/components/icon_button';
+import DropdownMenu from './dropdown_menu';
+
+//  Utils.
+import { assignHandlers } from 'flavours/glitch/utils/react_helpers';
+
+//  The component.
+export default class ComposerOptionsDropdown extends React.PureComponent {
+
+  static propTypes = {
+    isUserTouching: PropTypes.func,
+    disabled: PropTypes.bool,
+    icon: PropTypes.string,
+    items: PropTypes.arrayOf(PropTypes.shape({
+      icon: PropTypes.string,
+      meta: PropTypes.string,
+      name: PropTypes.string.isRequired,
+      text: PropTypes.string,
+    })).isRequired,
+    onModalOpen: PropTypes.func,
+    onModalClose: PropTypes.func,
+    title: PropTypes.string,
+    value: PropTypes.string,
+    onChange: PropTypes.func,
+    container: PropTypes.func,
+    renderItemContents: PropTypes.func,
+    closeOnChange: PropTypes.bool,
+  };
+
+  static defaultProps = {
+    closeOnChange: true,
+  };
+
+  state = {
+    open: false,
+    openedViaKeyboard: undefined,
+    placement: 'bottom',
+  };
+
+  //  Toggles opening and closing the dropdown.
+  handleToggle = ({ type }) => {
+    const { onModalOpen } = this.props;
+    const { open } = this.state;
+
+    if (this.props.isUserTouching && this.props.isUserTouching()) {
+      if (this.state.open) {
+        this.props.onModalClose();
+      } else {
+        const modal = this.handleMakeModal();
+        if (modal && onModalOpen) {
+          onModalOpen(modal);
+        }
+      }
+    } else {
+      if (this.state.open && this.activeElement) {
+        this.activeElement.focus({ preventScroll: true });
+      }
+      this.setState({ open: !this.state.open, openedViaKeyboard: type !== 'click' });
+    }
+  };
+
+  handleKeyDown = (e) => {
+    switch (e.key) {
+    case 'Escape':
+      this.handleClose();
+      break;
+    }
+  };
+
+  handleMouseDown = () => {
+    if (!this.state.open) {
+      this.activeElement = document.activeElement;
+    }
+  };
+
+  handleButtonKeyDown = (e) => {
+    switch(e.key) {
+    case ' ':
+    case 'Enter':
+      this.handleMouseDown();
+      break;
+    }
+  };
+
+  handleKeyPress = (e) => {
+    switch(e.key) {
+    case ' ':
+    case 'Enter':
+      this.handleToggle(e);
+      e.stopPropagation();
+      e.preventDefault();
+      break;
+    }
+  };
+
+  handleClose = () => {
+    if (this.state.open && this.activeElement) {
+      this.activeElement.focus({ preventScroll: true });
+    }
+    this.setState({ open: false });
+  };
+
+  handleItemClick = (e) => {
+    const {
+      items,
+      onChange,
+      onModalClose,
+      closeOnChange,
+    } = this.props;
+
+    const i = Number(e.currentTarget.getAttribute('data-index'));
+
+    const { name } = items[i];
+
+    e.preventDefault();  //  Prevents focus from changing
+    if (closeOnChange) onModalClose();
+    onChange(name);
+  };
+
+  //  Creates an action modal object.
+  handleMakeModal = () => {
+    const {
+      items,
+      onChange,
+      onModalOpen,
+      onModalClose,
+      value,
+    } = this.props;
+
+    //  Required props.
+    if (!(onChange && onModalOpen && onModalClose && items)) {
+      return null;
+    }
+
+    //  The object.
+    return {
+      renderItemContents: this.props.renderItemContents,
+      onClick: this.handleItemClick,
+      actions: items.map(
+        ({
+          name,
+          ...rest
+        }) => ({
+          ...rest,
+          active: value && name === value,
+          name,
+        }),
+      ),
+    };
+  };
+
+  setTargetRef = c => {
+    this.target = c;
+  };
+
+  findTarget = () => {
+    return this.target;
+  };
+
+  handleOverlayEnter = (state) => {
+    this.setState({ placement: state.placement });
+  };
+
+  //  Rendering.
+  render () {
+    const {
+      disabled,
+      title,
+      icon,
+      items,
+      onChange,
+      value,
+      container,
+      renderItemContents,
+      closeOnChange,
+    } = this.props;
+    const { open, placement } = this.state;
+
+    const active = value && items.findIndex(({ name }) => name === value) === (placement === 'bottom' ? 0 : (items.length - 1));
+
+    return (
+      <div
+        className={classNames('privacy-dropdown', placement, { active: open })}
+        onKeyDown={this.handleKeyDown}
+        ref={this.setTargetRef}
+      >
+        <div className={classNames('privacy-dropdown__value', { active })}>
+          <IconButton
+            active={open}
+            className='privacy-dropdown__value-icon'
+            disabled={disabled}
+            icon={icon}
+            inverted
+            onClick={this.handleToggle}
+            onMouseDown={this.handleMouseDown}
+            onKeyDown={this.handleButtonKeyDown}
+            onKeyPress={this.handleKeyPress}
+            size={18}
+            style={{
+              height: null,
+              lineHeight: '27px',
+            }}
+            title={title}
+          />
+        </div>
+
+        <Overlay
+          containerPadding={20}
+          placement={placement}
+          show={open}
+          flip
+          target={this.findTarget}
+          container={container}
+          popperConfig={{ strategy: 'fixed', onFirstUpdate: this.handleOverlayEnter }}
+        >
+          {({ props, placement }) => (
+            <div {...props}>
+              <div className={`dropdown-animation privacy-dropdown__dropdown ${placement}`}>
+                <DropdownMenu
+                  items={items}
+                  renderItemContents={renderItemContents}
+                  onChange={onChange}
+                  onClose={this.handleClose}
+                  value={value}
+                  openedViaKeyboard={this.state.openedViaKeyboard}
+                  closeOnChange={closeOnChange}
+                />
+              </div>
+            </div>
+          )}
+        </Overlay>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/compose/components/dropdown_menu.jsx b/app/javascript/flavours/glitch/features/compose/components/dropdown_menu.jsx
new file mode 100644
index 000000000..1ea0df536
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/components/dropdown_menu.jsx
@@ -0,0 +1,199 @@
+//  Package imports.
+import PropTypes from 'prop-types';
+import React from 'react';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import classNames from 'classnames';
+
+//  Components.
+import Icon from 'flavours/glitch/components/icon';
+
+//  Utils.
+import { withPassive } from 'flavours/glitch/utils/dom_helpers';
+import { assignHandlers } from 'flavours/glitch/utils/react_helpers';
+
+//  The component.
+export default class ComposerOptionsDropdownContent extends React.PureComponent {
+
+  static propTypes = {
+    items: PropTypes.arrayOf(PropTypes.shape({
+      icon: PropTypes.string,
+      meta: PropTypes.node,
+      name: PropTypes.string.isRequired,
+      text: PropTypes.node,
+    })),
+    onChange: PropTypes.func.isRequired,
+    onClose: PropTypes.func.isRequired,
+    style: PropTypes.object,
+    value: PropTypes.string,
+    renderItemContents: PropTypes.func,
+    openedViaKeyboard: PropTypes.bool,
+    closeOnChange: PropTypes.bool,
+  };
+
+  static defaultProps = {
+    style: {},
+    closeOnChange: true,
+  };
+
+  state = {
+    value: this.props.openedViaKeyboard ? this.props.items[0].name : undefined,
+  };
+
+  //  When the document is clicked elsewhere, we close the dropdown.
+  handleDocumentClick = (e) => {
+    if (this.node && !this.node.contains(e.target)) {
+      this.props.onClose();
+    }
+  };
+
+  //  Stores our node in `this.node`.
+  setRef = (node) => {
+    this.node = node;
+  };
+
+  //  On mounting, we add our listeners.
+  componentDidMount () {
+    document.addEventListener('click', this.handleDocumentClick, false);
+    document.addEventListener('touchend', this.handleDocumentClick, withPassive);
+    if (this.focusedItem) {
+      this.focusedItem.focus({ preventScroll: true });
+    } else {
+      this.node.firstChild.focus({ preventScroll: true });
+    }
+  }
+
+  //  On unmounting, we remove our listeners.
+  componentWillUnmount () {
+    document.removeEventListener('click', this.handleDocumentClick, false);
+    document.removeEventListener('touchend', this.handleDocumentClick, withPassive);
+  }
+
+  handleClick = (e) => {
+    const i = Number(e.currentTarget.getAttribute('data-index'));
+
+    const {
+      onChange,
+      onClose,
+      closeOnChange,
+      items,
+    } = this.props;
+
+    const { name } = this.props.items[i];
+    e.preventDefault();  //  Prevents change in focus on click
+    if (closeOnChange) {
+      onClose();
+    }
+    onChange(name);
+  };
+
+  // Handle changes differently whether the dropdown is a list of options or actions
+  handleChange = (name) => {
+    if (this.props.value) {
+      this.props.onChange(name);
+    } else {
+      this.setState({ value: name });
+    }
+  };
+
+  handleKeyDown = (e) => {
+    const index = Number(e.currentTarget.getAttribute('data-index'));
+    const { items } = this.props;
+    let element = null;
+
+    switch(e.key) {
+    case 'Escape':
+      this.props.onClose();
+      break;
+    case 'Enter':
+    case ' ':
+      this.handleClick(e);
+      break;
+    case 'ArrowDown':
+      element = this.node.childNodes[index + 1] || this.node.firstChild;
+      break;
+    case 'ArrowUp':
+      element = this.node.childNodes[index - 1] || this.node.lastChild;
+      break;
+    case 'Tab':
+      if (e.shiftKey) {
+        element = this.node.childNodes[index - 1] || this.node.lastChild;
+      } else {
+        element = this.node.childNodes[index + 1] || this.node.firstChild;
+      }
+      break;
+    case 'Home':
+      element = this.node.firstChild;
+      break;
+    case 'End':
+      element = this.node.lastChild;
+      break;
+    }
+
+    if (element) {
+      element.focus();
+      this.handleChange(this.props.items[Number(element.getAttribute('data-index'))].name);
+      e.preventDefault();
+      e.stopPropagation();
+    }
+  };
+
+  setFocusRef = c => {
+    this.focusedItem = c;
+  };
+
+  renderItem = (item, i) => {
+    const { name, icon, meta, text } = item;
+
+    const active = (name === (this.props.value || this.state.value));
+
+    const computedClass = classNames('privacy-dropdown__option', { active });
+
+    let contents = this.props.renderItemContents && this.props.renderItemContents(item, i);
+
+    if (!contents) {
+      contents = (
+        <React.Fragment>
+          {icon && <Icon className='icon' fixedWidth id={icon} />}
+
+          <div className='privacy-dropdown__option__content'>
+            <strong>{text}</strong>
+            {meta}
+          </div>
+        </React.Fragment>
+      );
+    }
+
+    return (
+      <div
+        className={computedClass}
+        onClick={this.handleClick}
+        onKeyDown={this.handleKeyDown}
+        role='option'
+        tabIndex='0'
+        key={name}
+        data-index={i}
+        ref={active ? this.setFocusRef : null}
+      >
+        {contents}
+      </div>
+    );
+  };
+
+  //  Rendering.
+  render () {
+    const {
+      items,
+      onChange,
+      onClose,
+      style,
+    } = this.props;
+
+    //  The result.
+    return (
+      <div style={{ ...style }} role='listbox' ref={this.setRef}>
+        {!!items && items.map((item, i) => this.renderItem(item, i))}
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/compose/components/emoji_picker_dropdown.jsx b/app/javascript/flavours/glitch/features/compose/components/emoji_picker_dropdown.jsx
new file mode 100644
index 000000000..66355e088
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/components/emoji_picker_dropdown.jsx
@@ -0,0 +1,413 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import { EmojiPicker as EmojiPickerAsync } from '../../ui/util/async-components';
+import Overlay from 'react-overlays/Overlay';
+import classNames from 'classnames';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { supportsPassiveEvents } from 'detect-passive-events';
+import { buildCustomEmojis, categoriesFromEmojis } from '../../emoji/emoji';
+import { useSystemEmojiFont } from 'flavours/glitch/initial_state';
+import { assetHost } from 'flavours/glitch/utils/config';
+
+const messages = defineMessages({
+  emoji: { id: 'emoji_button.label', defaultMessage: 'Insert emoji' },
+  emoji_search: { id: 'emoji_button.search', defaultMessage: 'Search...' },
+  custom: { id: 'emoji_button.custom', defaultMessage: 'Custom' },
+  recent: { id: 'emoji_button.recent', defaultMessage: 'Frequently used' },
+  search_results: { id: 'emoji_button.search_results', defaultMessage: 'Search results' },
+  people: { id: 'emoji_button.people', defaultMessage: 'People' },
+  nature: { id: 'emoji_button.nature', defaultMessage: 'Nature' },
+  food: { id: 'emoji_button.food', defaultMessage: 'Food & Drink' },
+  activity: { id: 'emoji_button.activity', defaultMessage: 'Activity' },
+  travel: { id: 'emoji_button.travel', defaultMessage: 'Travel & Places' },
+  objects: { id: 'emoji_button.objects', defaultMessage: 'Objects' },
+  symbols: { id: 'emoji_button.symbols', defaultMessage: 'Symbols' },
+  flags: { id: 'emoji_button.flags', defaultMessage: 'Flags' },
+});
+
+let EmojiPicker, Emoji; // load asynchronously
+
+const listenerOptions = supportsPassiveEvents ? { passive: true } : false;
+
+const backgroundImageFn = () => `${assetHost}/emoji/sheet_13.png`;
+
+const notFoundFn = () => (
+  <div className='emoji-mart-no-results'>
+    <Emoji
+      emoji='sleuth_or_spy'
+      set='twitter'
+      size={32}
+      sheetSize={32}
+      backgroundImageFn={backgroundImageFn}
+    />
+
+    <div className='emoji-mart-no-results-label'>
+      <FormattedMessage id='emoji_button.not_found' defaultMessage='No matching emojis found' />
+    </div>
+  </div>
+);
+
+class ModifierPickerMenu extends React.PureComponent {
+
+  static propTypes = {
+    active: PropTypes.bool,
+    onSelect: PropTypes.func.isRequired,
+    onClose: PropTypes.func.isRequired,
+  };
+
+  handleClick = e => {
+    this.props.onSelect(e.currentTarget.getAttribute('data-index') * 1);
+  };
+
+  componentWillReceiveProps (nextProps) {
+    if (nextProps.active) {
+      this.attachListeners();
+    } else {
+      this.removeListeners();
+    }
+  }
+
+  componentWillUnmount () {
+    this.removeListeners();
+  }
+
+  handleDocumentClick = e => {
+    if (this.node && !this.node.contains(e.target)) {
+      this.props.onClose();
+    }
+  };
+
+  attachListeners () {
+    document.addEventListener('click', this.handleDocumentClick, false);
+    document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);
+  }
+
+  removeListeners () {
+    document.removeEventListener('click', this.handleDocumentClick, false);
+    document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions);
+  }
+
+  setRef = c => {
+    this.node = c;
+  };
+
+  render () {
+    const { active } = this.props;
+
+    return (
+      <div className='emoji-picker-dropdown__modifiers__menu' style={{ display: active ? 'block' : 'none' }} ref={this.setRef}>
+        <button onClick={this.handleClick} data-index={1}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={1} backgroundImageFn={backgroundImageFn} native={useSystemEmojiFont} /></button>
+        <button onClick={this.handleClick} data-index={2}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={2} backgroundImageFn={backgroundImageFn} native={useSystemEmojiFont} /></button>
+        <button onClick={this.handleClick} data-index={3}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={3} backgroundImageFn={backgroundImageFn} native={useSystemEmojiFont} /></button>
+        <button onClick={this.handleClick} data-index={4}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={4} backgroundImageFn={backgroundImageFn} native={useSystemEmojiFont} /></button>
+        <button onClick={this.handleClick} data-index={5}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={5} backgroundImageFn={backgroundImageFn} native={useSystemEmojiFont} /></button>
+        <button onClick={this.handleClick} data-index={6}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={6} backgroundImageFn={backgroundImageFn} native={useSystemEmojiFont} /></button>
+      </div>
+    );
+  }
+
+}
+
+class ModifierPicker extends React.PureComponent {
+
+  static propTypes = {
+    active: PropTypes.bool,
+    modifier: PropTypes.number,
+    onChange: PropTypes.func,
+    onClose: PropTypes.func,
+    onOpen: PropTypes.func,
+  };
+
+  handleClick = () => {
+    if (this.props.active) {
+      this.props.onClose();
+    } else {
+      this.props.onOpen();
+    }
+  };
+
+  handleSelect = modifier => {
+    this.props.onChange(modifier);
+    this.props.onClose();
+  };
+
+  render () {
+    const { active, modifier } = this.props;
+
+    return (
+      <div className='emoji-picker-dropdown__modifiers'>
+        <Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={modifier} onClick={this.handleClick} backgroundImageFn={backgroundImageFn} native={useSystemEmojiFont} />
+        <ModifierPickerMenu active={active} onSelect={this.handleSelect} onClose={this.props.onClose} />
+      </div>
+    );
+  }
+
+}
+
+@injectIntl
+class EmojiPickerMenu extends React.PureComponent {
+
+  static propTypes = {
+    custom_emojis: ImmutablePropTypes.list,
+    frequentlyUsedEmojis: PropTypes.arrayOf(PropTypes.string),
+    loading: PropTypes.bool,
+    onClose: PropTypes.func.isRequired,
+    onPick: PropTypes.func.isRequired,
+    style: PropTypes.object,
+    intl: PropTypes.object.isRequired,
+    skinTone: PropTypes.number.isRequired,
+    onSkinTone: PropTypes.func.isRequired,
+  };
+
+  static defaultProps = {
+    style: {},
+    loading: true,
+    frequentlyUsedEmojis: [],
+  };
+
+  state = {
+    modifierOpen: false,
+    readyToFocus: false,
+  };
+
+  handleDocumentClick = e => {
+    if (this.node && !this.node.contains(e.target)) {
+      this.props.onClose();
+    }
+  };
+
+  componentDidMount () {
+    document.addEventListener('click', this.handleDocumentClick, false);
+    document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);
+
+    // Because of https://github.com/react-bootstrap/react-bootstrap/issues/2614 we need
+    // to wait for a frame before focusing
+    requestAnimationFrame(() => {
+      this.setState({ readyToFocus: true });
+      if (this.node) {
+        const element = this.node.querySelector('input[type="search"]');
+        if (element) element.focus();
+      }
+    });
+  }
+
+  componentWillUnmount () {
+    document.removeEventListener('click', this.handleDocumentClick, false);
+    document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions);
+  }
+
+  setRef = c => {
+    this.node = c;
+  };
+
+  getI18n = () => {
+    const { intl } = this.props;
+
+    return {
+      search: intl.formatMessage(messages.emoji_search),
+      categories: {
+        search: intl.formatMessage(messages.search_results),
+        recent: intl.formatMessage(messages.recent),
+        people: intl.formatMessage(messages.people),
+        nature: intl.formatMessage(messages.nature),
+        foods: intl.formatMessage(messages.food),
+        activity: intl.formatMessage(messages.activity),
+        places: intl.formatMessage(messages.travel),
+        objects: intl.formatMessage(messages.objects),
+        symbols: intl.formatMessage(messages.symbols),
+        flags: intl.formatMessage(messages.flags),
+        custom: intl.formatMessage(messages.custom),
+      },
+    };
+  };
+
+  handleClick = (emoji, event) => {
+    if (!emoji.native) {
+      emoji.native = emoji.colons;
+    }
+    if (!(event.ctrlKey || event.metaKey)) {
+      this.props.onClose();
+    }
+    this.props.onPick(emoji);
+  };
+
+  handleModifierOpen = () => {
+    this.setState({ modifierOpen: true });
+  };
+
+  handleModifierClose = () => {
+    this.setState({ modifierOpen: false });
+  };
+
+  handleModifierChange = modifier => {
+    this.props.onSkinTone(modifier);
+  };
+
+  render () {
+    const { loading, style, intl, custom_emojis, skinTone, frequentlyUsedEmojis } = this.props;
+
+    if (loading) {
+      return <div style={{ width: 299 }} />;
+    }
+
+    const title = intl.formatMessage(messages.emoji);
+
+    const { modifierOpen } = this.state;
+
+    const categoriesSort = [
+      'recent',
+      'people',
+      'nature',
+      'foods',
+      'activity',
+      'places',
+      'objects',
+      'symbols',
+      'flags',
+    ];
+
+    categoriesSort.splice(1, 0, ...Array.from(categoriesFromEmojis(custom_emojis)).sort());
+
+    return (
+      <div className={classNames('emoji-picker-dropdown__menu', { selecting: modifierOpen })} style={style} ref={this.setRef}>
+        <EmojiPicker
+          perLine={8}
+          emojiSize={22}
+          sheetSize={32}
+          custom={buildCustomEmojis(custom_emojis)}
+          color=''
+          emoji=''
+          set='twitter'
+          title={title}
+          i18n={this.getI18n()}
+          onClick={this.handleClick}
+          include={categoriesSort}
+          recent={frequentlyUsedEmojis}
+          skin={skinTone}
+          showPreview={false}
+          showSkinTones={false}
+          backgroundImageFn={backgroundImageFn}
+          notFound={notFoundFn}
+          autoFocus={this.state.readyToFocus}
+          emojiTooltip
+          native={useSystemEmojiFont}
+        />
+
+        <ModifierPicker
+          active={modifierOpen}
+          modifier={skinTone}
+          onOpen={this.handleModifierOpen}
+          onClose={this.handleModifierClose}
+          onChange={this.handleModifierChange}
+        />
+      </div>
+    );
+  }
+
+}
+
+export default @injectIntl
+class EmojiPickerDropdown extends React.PureComponent {
+
+  static propTypes = {
+    custom_emojis: ImmutablePropTypes.list,
+    frequentlyUsedEmojis: PropTypes.arrayOf(PropTypes.string),
+    intl: PropTypes.object.isRequired,
+    onPickEmoji: PropTypes.func.isRequired,
+    onSkinTone: PropTypes.func.isRequired,
+    skinTone: PropTypes.number.isRequired,
+    button: PropTypes.node,
+  };
+
+  state = {
+    active: false,
+    loading: false,
+  };
+
+  setRef = (c) => {
+    this.dropdown = c;
+  };
+
+  onShowDropdown = () => {
+    this.setState({ active: true });
+
+    if (!EmojiPicker) {
+      this.setState({ loading: true });
+
+      EmojiPickerAsync().then(EmojiMart => {
+        EmojiPicker = EmojiMart.Picker;
+        Emoji       = EmojiMart.Emoji;
+
+        this.setState({ loading: false });
+      }).catch(() => {
+        this.setState({ loading: false, active: false });
+      });
+    }
+  };
+
+  onHideDropdown = () => {
+    this.setState({ active: false });
+  };
+
+  onToggle = (e) => {
+    if (!this.state.loading && (!e.key || e.key === 'Enter')) {
+      if (this.state.active) {
+        this.onHideDropdown();
+      } else {
+        this.onShowDropdown(e);
+      }
+    }
+  };
+
+  handleKeyDown = e => {
+    if (e.key === 'Escape') {
+      this.onHideDropdown();
+    }
+  };
+
+  setTargetRef = c => {
+    this.target = c;
+  };
+
+  findTarget = () => {
+    return this.target;
+  };
+
+  render () {
+    const { intl, onPickEmoji, onSkinTone, skinTone, frequentlyUsedEmojis, button } = this.props;
+    const title = intl.formatMessage(messages.emoji);
+    const { active, loading } = this.state;
+
+    return (
+      <div className='emoji-picker-dropdown' onKeyDown={this.handleKeyDown}>
+        <div ref={this.setTargetRef} className='emoji-button' title={title} aria-label={title} aria-expanded={active} role='button' onClick={this.onToggle} onKeyDown={this.onToggle} tabIndex={0}>
+          {button || <img
+            className={classNames('emojione', { 'pulse-loading': active && loading })}
+            alt='🙂'
+            src={`${assetHost}/emoji/1f602.svg`}
+          />}
+        </div>
+
+        <Overlay show={active} placement={'bottom'} target={this.findTarget} popperConfig={{ strategy: 'fixed' }}>
+          {({ props, placement })=> (
+            <div {...props} style={{ ...props.style, width: 299 }}>
+              <div className={`dropdown-animation ${placement}`}>
+                <EmojiPickerMenu
+                  custom_emojis={this.props.custom_emojis}
+                  loading={loading}
+                  onClose={this.onHideDropdown}
+                  onPick={onPickEmoji}
+                  onSkinTone={onSkinTone}
+                  skinTone={skinTone}
+                  frequentlyUsedEmojis={frequentlyUsedEmojis}
+                />
+              </div>
+            </div>
+          )}
+        </Overlay>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/compose/components/header.jsx b/app/javascript/flavours/glitch/features/compose/components/header.jsx
new file mode 100644
index 000000000..dcbdafa57
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/components/header.jsx
@@ -0,0 +1,136 @@
+//  Package imports.
+import PropTypes from 'prop-types';
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { injectIntl, defineMessages } from 'react-intl';
+import { Link } from 'react-router-dom';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+//  Components.
+import Icon from 'flavours/glitch/components/icon';
+
+//  Utils.
+import { conditionalRender } from 'flavours/glitch/utils/react_helpers';
+import { signOutLink } from 'flavours/glitch/utils/backend_links';
+
+//  Messages.
+const messages = defineMessages({
+  community: {
+    defaultMessage: 'Local timeline',
+    id: 'navigation_bar.community_timeline',
+  },
+  home_timeline: {
+    defaultMessage: 'Home',
+    id: 'tabs_bar.home',
+  },
+  logout: {
+    defaultMessage: 'Logout',
+    id: 'navigation_bar.logout',
+  },
+  notifications: {
+    defaultMessage: 'Notifications',
+    id: 'tabs_bar.notifications',
+  },
+  public: {
+    defaultMessage: 'Federated timeline',
+    id: 'navigation_bar.public_timeline',
+  },
+  settings: {
+    defaultMessage: 'App settings',
+    id: 'navigation_bar.app_settings',
+  },
+  start: {
+    defaultMessage: 'Getting started',
+    id: 'getting_started.heading',
+  },
+});
+
+export default @injectIntl
+class Header extends ImmutablePureComponent {
+
+  static propTypes = {
+    columns: ImmutablePropTypes.list,
+    unreadNotifications: PropTypes.number,
+    showNotificationsBadge: PropTypes.bool,
+    intl: PropTypes.object,
+    onSettingsClick: PropTypes.func,
+    onLogout: PropTypes.func.isRequired,
+  };
+
+  handleLogoutClick = e => {
+    e.preventDefault();
+    e.stopPropagation();
+
+    this.props.onLogout();
+
+    return false;
+  };
+
+  render () {
+    const { intl, columns, unreadNotifications, showNotificationsBadge, onSettingsClick } = this.props;
+
+    //  Only renders the component if the column isn't being shown.
+    const renderForColumn = conditionalRender.bind(null,
+      columnId => !columns || !columns.some(
+        column => column.get('id') === columnId,
+      ),
+    );
+
+    //  The result.
+    return (
+      <nav className='drawer--header'>
+        <Link
+          aria-label={intl.formatMessage(messages.start)}
+          title={intl.formatMessage(messages.start)}
+          to='/getting-started'
+        ><Icon id='asterisk' /></Link>
+        {renderForColumn('HOME', (
+          <Link
+            aria-label={intl.formatMessage(messages.home_timeline)}
+            title={intl.formatMessage(messages.home_timeline)}
+            to='/home'
+          ><Icon id='home' /></Link>
+        ))}
+        {renderForColumn('NOTIFICATIONS', (
+          <Link
+            aria-label={intl.formatMessage(messages.notifications)}
+            title={intl.formatMessage(messages.notifications)}
+            to='/notifications'
+          >
+            <span className='icon-badge-wrapper'>
+              <Icon id='bell' />
+              { showNotificationsBadge && unreadNotifications > 0 && <div className='icon-badge' />}
+            </span>
+          </Link>
+        ))}
+        {renderForColumn('COMMUNITY', (
+          <Link
+            aria-label={intl.formatMessage(messages.community)}
+            title={intl.formatMessage(messages.community)}
+            to='/public/local'
+          ><Icon id='users' /></Link>
+        ))}
+        {renderForColumn('PUBLIC', (
+          <Link
+            aria-label={intl.formatMessage(messages.public)}
+            title={intl.formatMessage(messages.public)}
+            to='/public'
+          ><Icon id='globe' /></Link>
+        ))}
+        <a
+          aria-label={intl.formatMessage(messages.settings)}
+          onClick={onSettingsClick}
+          href='/settings/preferences'
+          title={intl.formatMessage(messages.settings)}
+        ><Icon id='cogs' /></a>
+        <a
+          aria-label={intl.formatMessage(messages.logout)}
+          onClick={this.handleLogoutClick}
+          href={signOutLink}
+          title={intl.formatMessage(messages.logout)}
+        ><Icon id='sign-out' /></a>
+      </nav>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/compose/components/language_dropdown.jsx b/app/javascript/flavours/glitch/features/compose/components/language_dropdown.jsx
new file mode 100644
index 000000000..03fb899f8
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/components/language_dropdown.jsx
@@ -0,0 +1,327 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { injectIntl, defineMessages } from 'react-intl';
+import TextIconButton from './text_icon_button';
+import Overlay from 'react-overlays/Overlay';
+import { supportsPassiveEvents } from 'detect-passive-events';
+import classNames from 'classnames';
+import { languages as preloadedLanguages } from 'flavours/glitch/initial_state';
+import { loupeIcon, deleteIcon } from 'flavours/glitch/utils/icons';
+import fuzzysort from 'fuzzysort';
+
+const messages = defineMessages({
+  changeLanguage: { id: 'compose.language.change', defaultMessage: 'Change language' },
+  search: { id: 'compose.language.search', defaultMessage: 'Search languages...' },
+  clear: { id: 'emoji_button.clear', defaultMessage: 'Clear' },
+});
+
+const listenerOptions = supportsPassiveEvents ? { passive: true } : false;
+
+class LanguageDropdownMenu extends React.PureComponent {
+
+  static propTypes = {
+    value: PropTypes.string.isRequired,
+    frequentlyUsedLanguages: PropTypes.arrayOf(PropTypes.string).isRequired,
+    onClose: PropTypes.func.isRequired,
+    onChange: PropTypes.func.isRequired,
+    languages: PropTypes.arrayOf(PropTypes.arrayOf(PropTypes.string)),
+    intl: PropTypes.object,
+  };
+
+  static defaultProps = {
+    languages: preloadedLanguages,
+  };
+
+  state = {
+    searchValue: '',
+  };
+
+  handleDocumentClick = e => {
+    if (this.node && !this.node.contains(e.target)) {
+      this.props.onClose();
+    }
+  };
+
+  componentDidMount () {
+    document.addEventListener('click', this.handleDocumentClick, false);
+    document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);
+
+    // Because of https://github.com/react-bootstrap/react-bootstrap/issues/2614 we need
+    // to wait for a frame before focusing
+    requestAnimationFrame(() => {
+      if (this.node) {
+        const element = this.node.querySelector('input[type="search"]');
+        if (element) element.focus();
+      }
+    });
+  }
+
+  componentWillUnmount () {
+    document.removeEventListener('click', this.handleDocumentClick, false);
+    document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions);
+  }
+
+  setRef = c => {
+    this.node = c;
+  };
+
+  setListRef = c => {
+    this.listNode = c;
+  };
+
+  handleSearchChange = ({ target }) => {
+    this.setState({ searchValue: target.value });
+  };
+
+  search () {
+    const { languages, value, frequentlyUsedLanguages } = this.props;
+    const { searchValue } = this.state;
+
+    if (searchValue === '') {
+      return [...languages].sort((a, b) => {
+        // Push current selection to the top of the list
+
+        if (a[0] === value) {
+          return -1;
+        } else if (b[0] === value) {
+          return 1;
+        } else {
+          // Sort according to frequently used languages
+
+          const indexOfA = frequentlyUsedLanguages.indexOf(a[0]);
+          const indexOfB = frequentlyUsedLanguages.indexOf(b[0]);
+
+          return ((indexOfA > -1 ? indexOfA : Infinity) - (indexOfB > -1 ? indexOfB : Infinity));
+        }
+      });
+    }
+
+    return fuzzysort.go(searchValue, languages, {
+      keys: ['0', '1', '2'],
+      limit: 5,
+      threshold: -10000,
+    }).map(result => result.obj);
+  }
+
+  frequentlyUsed () {
+    const { languages, value } = this.props;
+    const current = languages.find(lang => lang[0] === value);
+    const results = [];
+
+    if (current) {
+      results.push(current);
+    }
+
+    return results;
+  }
+
+  handleClick = e => {
+    const value = e.currentTarget.getAttribute('data-index');
+
+    e.preventDefault();
+
+    this.props.onClose();
+    this.props.onChange(value);
+  };
+
+  handleKeyDown = e => {
+    const { onClose } = this.props;
+    const index = Array.from(this.listNode.childNodes).findIndex(node => node === e.currentTarget);
+
+    let element = null;
+
+    switch(e.key) {
+    case 'Escape':
+      onClose();
+      break;
+    case 'Enter':
+      this.handleClick(e);
+      break;
+    case 'ArrowDown':
+      element = this.listNode.childNodes[index + 1] || this.listNode.firstChild;
+      break;
+    case 'ArrowUp':
+      element = this.listNode.childNodes[index - 1] || this.listNode.lastChild;
+      break;
+    case 'Tab':
+      if (e.shiftKey) {
+        element = this.listNode.childNodes[index - 1] || this.listNode.lastChild;
+      } else {
+        element = this.listNode.childNodes[index + 1] || this.listNode.firstChild;
+      }
+      break;
+    case 'Home':
+      element = this.listNode.firstChild;
+      break;
+    case 'End':
+      element = this.listNode.lastChild;
+      break;
+    }
+
+    if (element) {
+      element.focus();
+      e.preventDefault();
+      e.stopPropagation();
+    }
+  };
+
+  handleSearchKeyDown = e => {
+    const { onChange, onClose } = this.props;
+    const { searchValue } = this.state;
+
+    let element = null;
+
+    switch(e.key) {
+    case 'Tab':
+    case 'ArrowDown':
+      element = this.listNode.firstChild;
+
+      if (element) {
+        element.focus();
+        e.preventDefault();
+        e.stopPropagation();
+      }
+
+      break;
+    case 'Enter':
+      element = this.listNode.firstChild;
+
+      if (element) {
+        onChange(element.getAttribute('data-index'));
+        onClose();
+      }
+      break;
+    case 'Escape':
+      if (searchValue !== '') {
+        e.preventDefault();
+        this.handleClear();
+      }
+
+      break;
+    }
+  };
+
+  handleClear = () => {
+    this.setState({ searchValue: '' });
+  };
+
+  renderItem = lang => {
+    const { value } = this.props;
+
+    return (
+      <div key={lang[0]} role='option' tabIndex='0' data-index={lang[0]} className={classNames('language-dropdown__dropdown__results__item', { active: lang[0] === value })} aria-selected={lang[0] === value} onClick={this.handleClick} onKeyDown={this.handleKeyDown}>
+        <span className='language-dropdown__dropdown__results__item__native-name' lang={lang[0]}>{lang[2]}</span> <span className='language-dropdown__dropdown__results__item__common-name'>({lang[1]})</span>
+      </div>
+    );
+  };
+
+  render () {
+    const { intl } = this.props;
+    const { searchValue } = this.state;
+    const isSearching = searchValue !== '';
+    const results = this.search();
+
+    return (
+      <div ref={this.setRef}>
+        <div className='emoji-mart-search'>
+          <input type='search' value={searchValue} onChange={this.handleSearchChange} onKeyDown={this.handleSearchKeyDown} placeholder={intl.formatMessage(messages.search)} />
+          <button type='button' className='emoji-mart-search-icon' disabled={!isSearching} aria-label={intl.formatMessage(messages.clear)} onClick={this.handleClear}>{!isSearching ? loupeIcon : deleteIcon}</button>
+        </div>
+
+        <div className='language-dropdown__dropdown__results emoji-mart-scroll' role='listbox' ref={this.setListRef}>
+          {results.map(this.renderItem)}
+        </div>
+      </div>
+    );
+  }
+
+}
+
+export default @injectIntl
+class LanguageDropdown extends React.PureComponent {
+
+  static propTypes = {
+    value: PropTypes.string,
+    frequentlyUsedLanguages: PropTypes.arrayOf(PropTypes.string),
+    intl: PropTypes.object.isRequired,
+    onChange: PropTypes.func,
+    onClose: PropTypes.func,
+  };
+
+  state = {
+    open: false,
+    placement: 'bottom',
+  };
+
+  handleToggle = () => {
+    if (this.state.open && this.activeElement) {
+      this.activeElement.focus({ preventScroll: true });
+    }
+
+    this.setState({ open: !this.state.open });
+  };
+
+  handleClose = () => {
+    const { value, onClose } = this.props;
+
+    if (this.state.open && this.activeElement) {
+      this.activeElement.focus({ preventScroll: true });
+    }
+
+    this.setState({ open: false });
+    onClose(value);
+  };
+
+  handleChange = value => {
+    const { onChange } = this.props;
+    onChange(value);
+  };
+
+  setTargetRef = c => {
+    this.target = c;
+  };
+
+  findTarget = () => {
+    return this.target;
+  };
+
+  handleOverlayEnter = (state) => {
+    this.setState({ placement: state.placement });
+  };
+
+  render () {
+    const { value, intl, frequentlyUsedLanguages } = this.props;
+    const { open, placement } = this.state;
+
+    return (
+      <div className={classNames('privacy-dropdown', placement, { active: open })}>
+        <div className='privacy-dropdown__value' ref={this.setTargetRef} >
+          <TextIconButton
+            className='privacy-dropdown__value-icon'
+            label={value && value.toUpperCase()}
+            title={intl.formatMessage(messages.changeLanguage)}
+            active={open}
+            onClick={this.handleToggle}
+          />
+        </div>
+
+        <Overlay show={open} placement={'bottom'} flip target={this.findTarget} popperConfig={{ strategy: 'fixed', onFirstUpdate: this.handleOverlayEnter }}>
+          {({ props, placement }) => (
+            <div {...props}>
+              <div className={`dropdown-animation language-dropdown__dropdown ${placement}`} >
+                <LanguageDropdownMenu
+                  value={value}
+                  frequentlyUsedLanguages={frequentlyUsedLanguages}
+                  onClose={this.handleClose}
+                  onChange={this.handleChange}
+                  intl={intl}
+                />
+              </div>
+            </div>
+          )}
+        </Overlay>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/compose/components/navigation_bar.jsx b/app/javascript/flavours/glitch/features/compose/components/navigation_bar.jsx
new file mode 100644
index 000000000..1a68f1e12
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/components/navigation_bar.jsx
@@ -0,0 +1,46 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import ActionBar from './action_bar';
+import Avatar from 'flavours/glitch/components/avatar';
+import Permalink from 'flavours/glitch/components/permalink';
+import { FormattedMessage } from 'react-intl';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import { profileLink } from 'flavours/glitch/utils/backend_links';
+
+export default class NavigationBar extends ImmutablePureComponent {
+
+  static propTypes = {
+    account: ImmutablePropTypes.map.isRequired,
+    onLogout: PropTypes.func.isRequired,
+  };
+
+  render () {
+    return (
+      <div className='navigation-bar'>
+        <Permalink className='avatar' href={this.props.account.get('url')} to={`/@${this.props.account.get('acct')}`}>
+          <span style={{ display: 'none' }}>{this.props.account.get('acct')}</span>
+          <Avatar account={this.props.account} size={48} />
+        </Permalink>
+
+        <div className='navigation-bar__profile'>
+          <Permalink className='acct' href={this.props.account.get('url')} to={`/@${this.props.account.get('acct')}`}>
+            <strong>@{this.props.account.get('acct')}</strong>
+          </Permalink>
+
+          { profileLink !== undefined && (
+            <a
+              className='edit'
+              href={profileLink}
+            ><FormattedMessage id='navigation_bar.edit_profile' defaultMessage='Edit profile' /></a>
+          )}
+        </div>
+
+        <div className='navigation-bar__actions'>
+          <ActionBar account={this.props.account} onLogout={this.props.onLogout} />
+        </div>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/compose/components/options.jsx b/app/javascript/flavours/glitch/features/compose/components/options.jsx
new file mode 100644
index 000000000..e09e13bcb
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/components/options.jsx
@@ -0,0 +1,317 @@
+//  Package imports.
+import PropTypes from 'prop-types';
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { defineMessages, injectIntl } from 'react-intl';
+import spring from 'react-motion/lib/spring';
+import Toggle from 'react-toggle';
+import { connect } from 'react-redux';
+
+//  Components.
+import IconButton from 'flavours/glitch/components/icon_button';
+import TextIconButton from './text_icon_button';
+import DropdownContainer from '../containers/dropdown_container';
+import PrivacyDropdownContainer from '../containers/privacy_dropdown_container';
+import LanguageDropdown from '../containers/language_dropdown_container';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+//  Utils.
+import Motion from '../../ui/util/optional_motion';
+import { pollLimits } from 'flavours/glitch/initial_state';
+
+//  Messages.
+const messages = defineMessages({
+  advanced_options_icon_title: {
+    defaultMessage: 'Advanced options',
+    id: 'advanced_options.icon_title',
+  },
+  attach: {
+    defaultMessage: 'Attach...',
+    id: 'compose.attach',
+  },
+  content_type: {
+    defaultMessage: 'Content type',
+    id: 'content-type.change',
+  },
+  doodle: {
+    defaultMessage: 'Draw something',
+    id: 'compose.attach.doodle',
+  },
+  html: {
+    defaultMessage: 'HTML',
+    id: 'compose.content-type.html',
+  },
+  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',
+  },
+  markdown: {
+    defaultMessage: 'Markdown',
+    id: 'compose.content-type.markdown',
+  },
+  plain: {
+    defaultMessage: 'Plain text',
+    id: 'compose.content-type.plain',
+  },
+  spoiler: {
+    defaultMessage: 'Hide text behind warning',
+    id: 'compose_form.spoiler',
+  },
+  threaded_mode_long: {
+    defaultMessage: 'Automatically opens a reply on posting',
+    id: 'advanced_options.threaded_mode.long',
+  },
+  threaded_mode_short: {
+    defaultMessage: 'Threaded mode',
+    id: 'advanced_options.threaded_mode.short',
+  },
+  upload: {
+    defaultMessage: 'Upload a file',
+    id: 'compose.attach.upload',
+  },
+  add_poll: {
+    defaultMessage: 'Add a poll',
+    id: 'poll_button.add_poll',
+  },
+  remove_poll: {
+    defaultMessage: 'Remove poll',
+    id: 'poll_button.remove_poll',
+  },
+});
+
+@connect((state, { name }) => ({ checked: state.getIn(['compose', 'advanced_options', name]) }))
+class ToggleOption extends ImmutablePureComponent {
+
+  static propTypes = {
+    name: PropTypes.string.isRequired,
+    checked: PropTypes.bool,
+    onChangeAdvancedOption: PropTypes.func.isRequired,
+  };
+
+  handleChange = () => {
+    this.props.onChangeAdvancedOption(this.props.name);
+  };
+
+  render() {
+    const { meta, text, checked } = this.props;
+
+    return (
+      <React.Fragment>
+        <Toggle checked={checked} onChange={this.handleChange} />
+
+        <div className='privacy-dropdown__option__content'>
+          <strong>{text}</strong>
+          {meta}
+        </div>
+      </React.Fragment>
+    );
+  }
+
+}
+
+export default @injectIntl
+class ComposerOptions extends ImmutablePureComponent {
+
+  static propTypes = {
+    acceptContentTypes: PropTypes.string,
+    advancedOptions: ImmutablePropTypes.map,
+    disabled: PropTypes.bool,
+    allowMedia: PropTypes.bool,
+    hasMedia: PropTypes.bool,
+    allowPoll: PropTypes.bool,
+    hasPoll: PropTypes.bool,
+    intl: PropTypes.object.isRequired,
+    onChangeAdvancedOption: PropTypes.func,
+    onChangeContentType: PropTypes.func,
+    onTogglePoll: PropTypes.func,
+    onDoodleOpen: PropTypes.func,
+    onToggleSpoiler: PropTypes.func,
+    onUpload: PropTypes.func,
+    contentType: PropTypes.string,
+    resetFileKey: PropTypes.number,
+    spoiler: PropTypes.bool,
+    showContentTypeChoice: PropTypes.bool,
+    isEditing: PropTypes.bool,
+  };
+
+  //  Handles file selection.
+  handleChangeFiles = ({ target: { files } }) => {
+    const { onUpload } = this.props;
+    if (files.length && onUpload) {
+      onUpload(files);
+    }
+  };
+
+  //  Handles attachment clicks.
+  handleClickAttach = (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.
+  handleRefFileElement = (fileElement) => {
+    this.fileElement = fileElement;
+  };
+
+  renderToggleItemContents = (item) => {
+    const { onChangeAdvancedOption } = this.props;
+    const { name, meta, text } = item;
+
+    return <ToggleOption name={name} text={text} meta={meta} onChangeAdvancedOption={onChangeAdvancedOption} />;
+  };
+
+  //  Rendering.
+  render () {
+    const {
+      acceptContentTypes,
+      advancedOptions,
+      contentType,
+      disabled,
+      allowMedia,
+      hasMedia,
+      allowPoll,
+      hasPoll,
+      onChangeAdvancedOption,
+      onChangeContentType,
+      onTogglePoll,
+      onToggleSpoiler,
+      resetFileKey,
+      spoiler,
+      showContentTypeChoice,
+      isEditing,
+      intl: { formatMessage },
+    } = this.props;
+
+    const contentTypeItems = {
+      plain: {
+        icon: 'file-text',
+        name: 'text/plain',
+        text: formatMessage(messages.plain),
+      },
+      html: {
+        icon: 'code',
+        name: 'text/html',
+        text: formatMessage(messages.html),
+      },
+      markdown: {
+        icon: 'arrow-circle-down',
+        name: 'text/markdown',
+        text: formatMessage(messages.markdown),
+      },
+    };
+
+    //  The result.
+    return (
+      <div className='compose-form__buttons'>
+        <input
+          accept={acceptContentTypes}
+          disabled={disabled || !allowMedia}
+          key={resetFileKey}
+          onChange={this.handleChangeFiles}
+          ref={this.handleRefFileElement}
+          type='file'
+          multiple
+          style={{ display: 'none' }}
+        />
+        <DropdownContainer
+          disabled={disabled || !allowMedia}
+          icon='paperclip'
+          items={[
+            {
+              icon: 'cloud-upload',
+              name: 'upload',
+              text: formatMessage(messages.upload),
+            },
+            {
+              icon: 'paint-brush',
+              name: 'doodle',
+              text: formatMessage(messages.doodle),
+            },
+          ]}
+          onChange={this.handleClickAttach}
+          title={formatMessage(messages.attach)}
+        />
+        {!!pollLimits && (
+          <IconButton
+            active={hasPoll}
+            disabled={disabled || !allowPoll}
+            icon='tasks'
+            inverted
+            onClick={onTogglePoll}
+            size={18}
+            style={{
+              height: null,
+              lineHeight: null,
+            }}
+            title={formatMessage(hasPoll ? messages.remove_poll : messages.add_poll)}
+          />
+        )}
+        <hr />
+        <PrivacyDropdownContainer disabled={disabled || isEditing} />
+        {showContentTypeChoice && (
+          <DropdownContainer
+            disabled={disabled}
+            icon={(contentTypeItems[contentType.split('/')[1]] || {}).icon}
+            items={[
+              contentTypeItems.plain,
+              contentTypeItems.html,
+              contentTypeItems.markdown,
+            ]}
+            onChange={onChangeContentType}
+            title={formatMessage(messages.content_type)}
+            value={contentType}
+          />
+        )}
+        {onToggleSpoiler && (
+          <TextIconButton
+            active={spoiler}
+            ariaControls='glitch.composer.spoiler.input'
+            label='CW'
+            onClick={onToggleSpoiler}
+            title={formatMessage(messages.spoiler)}
+          />
+        )}
+        <LanguageDropdown />
+        <DropdownContainer
+          disabled={disabled || isEditing}
+          icon='ellipsis-h'
+          items={advancedOptions ? [
+            {
+              meta: formatMessage(messages.local_only_long),
+              name: 'do_not_federate',
+              text: formatMessage(messages.local_only_short),
+            },
+            {
+              meta: formatMessage(messages.threaded_mode_long),
+              name: 'threaded_mode',
+              text: formatMessage(messages.threaded_mode_short),
+            },
+          ] : null}
+          onChange={onChangeAdvancedOption}
+          renderItemContents={this.renderToggleItemContents}
+          title={formatMessage(messages.advanced_options_icon_title)}
+          closeOnChange={false}
+        />
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/compose/components/poll_form.jsx b/app/javascript/flavours/glitch/features/compose/components/poll_form.jsx
new file mode 100644
index 000000000..cb6b577bf
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/components/poll_form.jsx
@@ -0,0 +1,170 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import IconButton from 'flavours/glitch/components/icon_button';
+import Icon from 'flavours/glitch/components/icon';
+import AutosuggestInput from 'flavours/glitch/components/autosuggest_input';
+import classNames from 'classnames';
+import { pollLimits } from 'flavours/glitch/initial_state';
+
+const messages = defineMessages({
+  option_placeholder: { id: 'compose_form.poll.option_placeholder', defaultMessage: 'Choice {number}' },
+  add_option: { id: 'compose_form.poll.add_option', defaultMessage: 'Add a choice' },
+  remove_option: { id: 'compose_form.poll.remove_option', defaultMessage: 'Remove this choice' },
+  poll_duration: { id: 'compose_form.poll.duration', defaultMessage: 'Poll duration' },
+  single_choice: { id: 'compose_form.poll.single_choice', defaultMessage: 'Allow one choice' },
+  multiple_choices: { id: 'compose_form.poll.multiple_choices', defaultMessage: 'Allow multiple choices' },
+  minutes: { id: 'intervals.full.minutes', defaultMessage: '{number, plural, one {# minute} other {# minutes}}' },
+  hours: { id: 'intervals.full.hours', defaultMessage: '{number, plural, one {# hour} other {# hours}}' },
+  days: { id: 'intervals.full.days', defaultMessage: '{number, plural, one {# day} other {# days}}' },
+});
+
+@injectIntl
+class Option extends React.PureComponent {
+
+  static propTypes = {
+    title: PropTypes.string.isRequired,
+    lang: PropTypes.string,
+    index: PropTypes.number.isRequired,
+    isPollMultiple: PropTypes.bool,
+    autoFocus: PropTypes.bool,
+    onChange: PropTypes.func.isRequired,
+    onRemove: PropTypes.func.isRequired,
+    suggestions: ImmutablePropTypes.list,
+    onClearSuggestions: PropTypes.func.isRequired,
+    onFetchSuggestions: PropTypes.func.isRequired,
+    onSuggestionSelected: PropTypes.func.isRequired,
+    intl: PropTypes.object.isRequired,
+  };
+
+  handleOptionTitleChange = e => {
+    this.props.onChange(this.props.index, e.target.value);
+  };
+
+  handleOptionRemove = () => {
+    this.props.onRemove(this.props.index);
+  };
+
+  onSuggestionsClearRequested = () => {
+    this.props.onClearSuggestions();
+  };
+
+  onSuggestionsFetchRequested = (token) => {
+    this.props.onFetchSuggestions(token);
+  };
+
+  onSuggestionSelected = (tokenStart, token, value) => {
+    this.props.onSuggestionSelected(tokenStart, token, value, ['poll', 'options', this.props.index]);
+  };
+
+  render () {
+    const { isPollMultiple, title, lang, index, autoFocus, intl } = this.props;
+
+    return (
+      <li>
+        <label className='poll__option editable'>
+          <span className={classNames('poll__input', { checkbox: isPollMultiple })} />
+
+          <AutosuggestInput
+            placeholder={intl.formatMessage(messages.option_placeholder, { number: index + 1 })}
+            maxLength={pollLimits.max_option_chars}
+            value={title}
+            lang={lang}
+            spellCheck
+            onChange={this.handleOptionTitleChange}
+            suggestions={this.props.suggestions}
+            onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
+            onSuggestionsClearRequested={this.onSuggestionsClearRequested}
+            onSuggestionSelected={this.onSuggestionSelected}
+            searchTokens={[':']}
+            autoFocus={autoFocus}
+          />
+        </label>
+
+        <div className='poll__cancel'>
+          <IconButton disabled={index <= 1} title={intl.formatMessage(messages.remove_option)} icon='times' onClick={this.handleOptionRemove} />
+        </div>
+      </li>
+    );
+  }
+
+}
+
+export default
+@injectIntl
+class PollForm extends ImmutablePureComponent {
+
+  static propTypes = {
+    options: ImmutablePropTypes.list,
+    lang: PropTypes.string,
+    expiresIn: PropTypes.number,
+    isMultiple: PropTypes.bool,
+    onChangeOption: PropTypes.func.isRequired,
+    onAddOption: PropTypes.func.isRequired,
+    onRemoveOption: PropTypes.func.isRequired,
+    onChangeSettings: PropTypes.func.isRequired,
+    suggestions: ImmutablePropTypes.list,
+    onClearSuggestions: PropTypes.func.isRequired,
+    onFetchSuggestions: PropTypes.func.isRequired,
+    onSuggestionSelected: PropTypes.func.isRequired,
+    intl: PropTypes.object.isRequired,
+  };
+
+  handleAddOption = () => {
+    this.props.onAddOption('');
+  };
+
+  handleSelectDuration = e => {
+    this.props.onChangeSettings(e.target.value, this.props.isMultiple);
+  };
+
+  handleSelectMultiple = e => {
+    this.props.onChangeSettings(this.props.expiresIn, e.target.value === 'true');
+  };
+
+  render () {
+    const { options, lang, expiresIn, isMultiple, onChangeOption, onRemoveOption, intl, ...other } = this.props;
+
+    if (!options) {
+      return null;
+    }
+
+    const autoFocusIndex = options.indexOf('');
+
+    return (
+      <div className='compose-form__poll-wrapper'>
+        <ul>
+          {options.map((title, i) => <Option title={title} lang={lang} key={i} index={i} onChange={onChangeOption} onRemove={onRemoveOption} isPollMultiple={isMultiple} autoFocus={i === autoFocusIndex} {...other} />)}
+          {options.size < pollLimits.max_options && (
+            <label className='poll__text editable'>
+              <span className={classNames('poll__input')} style={{ opacity: 0 }} />
+              <button className='button button-secondary' onClick={this.handleAddOption}><Icon id='plus' /> <FormattedMessage {...messages.add_option} /></button>
+            </label>
+          )}
+        </ul>
+
+        <div className='poll__footer'>
+          <select value={isMultiple ? 'true' : 'false'} onChange={this.handleSelectMultiple}>
+            <option value='false'>{intl.formatMessage(messages.single_choice)}</option>
+            <option value='true'>{intl.formatMessage(messages.multiple_choices)}</option>
+          </select>
+
+          {/* eslint-disable-next-line jsx-a11y/no-onchange */}
+          <select value={expiresIn} onChange={this.handleSelectDuration}>
+            <option value={300}>{intl.formatMessage(messages.minutes, { number: 5 })}</option>
+            <option value={1800}>{intl.formatMessage(messages.minutes, { number: 30 })}</option>
+            <option value={3600}>{intl.formatMessage(messages.hours, { number: 1 })}</option>
+            <option value={21600}>{intl.formatMessage(messages.hours, { number: 6 })}</option>
+            <option value={43200}>{intl.formatMessage(messages.hours, { number: 12 })}</option>
+            <option value={86400}>{intl.formatMessage(messages.days, { number: 1 })}</option>
+            <option value={259200}>{intl.formatMessage(messages.days, { number: 3 })}</option>
+            <option value={604800}>{intl.formatMessage(messages.days, { number: 7 })}</option>
+          </select>
+        </div>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/compose/components/privacy_dropdown.jsx b/app/javascript/flavours/glitch/features/compose/components/privacy_dropdown.jsx
new file mode 100644
index 000000000..02cf72289
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/components/privacy_dropdown.jsx
@@ -0,0 +1,88 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { defineMessages, injectIntl } from 'react-intl';
+import Dropdown from './dropdown';
+
+const messages = defineMessages({
+  public_short: { id: 'privacy.public.short', defaultMessage: 'Public' },
+  public_long: { id: 'privacy.public.long', defaultMessage: 'Visible for all' },
+  unlisted_short: { id: 'privacy.unlisted.short', defaultMessage: 'Unlisted' },
+  unlisted_long: { id: 'privacy.unlisted.long', defaultMessage: 'Visible for all, but opted-out of discovery features' },
+  private_short: { id: 'privacy.private.short', defaultMessage: 'Followers only' },
+  private_long: { id: 'privacy.private.long', defaultMessage: 'Visible for followers only' },
+  direct_short: { id: 'privacy.direct.short', defaultMessage: 'Only people I mention' },
+  direct_long: { id: 'privacy.direct.long', defaultMessage: 'Visible for mentioned users only' },
+  change_privacy: { id: 'privacy.change', defaultMessage: 'Adjust status privacy' },
+});
+
+export default @injectIntl
+class PrivacyDropdown extends React.PureComponent {
+
+  static propTypes = {
+    isUserTouching: PropTypes.func,
+    onModalOpen: PropTypes.func,
+    onModalClose: PropTypes.func,
+    value: PropTypes.string.isRequired,
+    onChange: PropTypes.func.isRequired,
+    noDirect: PropTypes.bool,
+    container: PropTypes.func,
+    disabled: PropTypes.bool,
+    intl: PropTypes.object.isRequired,
+  };
+
+  render () {
+    const { value, onChange, onModalOpen, onModalClose, disabled, noDirect, container, isUserTouching, intl: { formatMessage } } = this.props;
+
+    //  We predefine our privacy items so that we can easily pick the
+    //  dropdown icon later.
+    const privacyItems = {
+      direct: {
+        icon: 'envelope',
+        meta: formatMessage(messages.direct_long),
+        name: 'direct',
+        text: formatMessage(messages.direct_short),
+      },
+      private: {
+        icon: 'lock',
+        meta: formatMessage(messages.private_long),
+        name: 'private',
+        text: formatMessage(messages.private_short),
+      },
+      public: {
+        icon: 'globe',
+        meta: formatMessage(messages.public_long),
+        name: 'public',
+        text: formatMessage(messages.public_short),
+      },
+      unlisted: {
+        icon: 'unlock',
+        meta: formatMessage(messages.unlisted_long),
+        name: 'unlisted',
+        text: formatMessage(messages.unlisted_short),
+      },
+    };
+
+    const items = [privacyItems.public, privacyItems.unlisted, privacyItems.private];
+
+    if (!noDirect) {
+      items.push(privacyItems.direct);
+    }
+
+    return (
+      <Dropdown
+        disabled={disabled}
+        icon={(privacyItems[value] || {}).icon}
+        items={items}
+        onChange={onChange}
+        isUserTouching={isUserTouching}
+        onModalClose={onModalClose}
+        onModalOpen={onModalOpen}
+        title={formatMessage(messages.change_privacy)}
+        container={container}
+        value={value}
+      />
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/compose/components/publisher.jsx b/app/javascript/flavours/glitch/features/compose/components/publisher.jsx
new file mode 100644
index 000000000..59254990b
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/components/publisher.jsx
@@ -0,0 +1,99 @@
+//  Package imports.
+import classNames from 'classnames';
+import PropTypes from 'prop-types';
+import React from 'react';
+import { defineMessages, injectIntl } from 'react-intl';
+import { length } from 'stringz';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+//  Components.
+import Button from 'flavours/glitch/components/button';
+import Icon from 'flavours/glitch/components/icon';
+
+//  Utils.
+import { maxChars } from 'flavours/glitch/initial_state';
+
+//  Messages.
+const messages = defineMessages({
+  publish: {
+    defaultMessage: 'Publish',
+    id: 'compose_form.publish',
+  },
+  publishLoud: {
+    defaultMessage: '{publish}!',
+    id: 'compose_form.publish_loud',
+  },
+  saveChanges: { id: 'compose_form.save_changes', defaultMessage: 'Save changes' },
+});
+
+export default @injectIntl
+class Publisher extends ImmutablePureComponent {
+
+  static propTypes = {
+    countText: PropTypes.string,
+    disabled: PropTypes.bool,
+    intl: PropTypes.object.isRequired,
+    onSecondarySubmit: PropTypes.func,
+    onSubmit: PropTypes.func,
+    privacy: PropTypes.oneOf(['direct', 'private', 'unlisted', 'public']),
+    sideArm: PropTypes.oneOf(['none', 'direct', 'private', 'unlisted', 'public']),
+    isEditing: PropTypes.bool,
+  };
+
+  handleSubmit = () => {
+    this.props.onSubmit();
+  };
+
+  render () {
+    const { countText, disabled, intl, onSecondarySubmit, privacy, sideArm, isEditing } = this.props;
+
+    const diff = maxChars - length(countText || '');
+    const computedClass = classNames('compose-form__publish', {
+      disabled: disabled,
+      over: diff < 0,
+    });
+
+    const privacyIcons = { direct: 'envelope', private: 'lock', public: 'globe', unlisted: 'unlock' };
+
+    let publishText;
+    if (isEditing) {
+      publishText = intl.formatMessage(messages.saveChanges);
+    } else if (privacy === 'private' || privacy === 'direct') {
+      const iconId = privacyIcons[privacy];
+      publishText = (
+        <span>
+          <Icon id={iconId} /> {intl.formatMessage(messages.publish)}
+        </span>
+      );
+    } else {
+      publishText = privacy !== 'unlisted' ? intl.formatMessage(messages.publishLoud, { publish: intl.formatMessage(messages.publish) }) : intl.formatMessage(messages.publish);
+    }
+
+    return (
+      <div className={computedClass}>
+        {sideArm && !isEditing && sideArm !== 'none' ? (
+          <div className='compose-form__publish-button-wrapper'>
+            <Button
+              className='side_arm'
+              disabled={disabled}
+              onClick={onSecondarySubmit}
+              style={{ padding: null }}
+              text={<Icon id={privacyIcons[sideArm]} />}
+              title={`${intl.formatMessage(messages.publish)}: ${intl.formatMessage({ id: `privacy.${sideArm}.short` })}`}
+            />
+          </div>
+        ) : null}
+        <div className='compose-form__publish-button-wrapper'>
+          <Button
+            className='primary'
+            text={publishText}
+            title={`${intl.formatMessage(messages.publish)}: ${intl.formatMessage({ id: `privacy.${privacy}.short` })}`}
+            onClick={this.handleSubmit}
+            disabled={disabled}
+          />
+        </div>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/compose/components/reply_indicator.jsx b/app/javascript/flavours/glitch/features/compose/components/reply_indicator.jsx
new file mode 100644
index 000000000..ca167d114
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/components/reply_indicator.jsx
@@ -0,0 +1,82 @@
+//  Package imports.
+import PropTypes from 'prop-types';
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { defineMessages, injectIntl } from 'react-intl';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+//  Components.
+import AccountContainer from 'flavours/glitch/containers/account_container';
+import IconButton from 'flavours/glitch/components/icon_button';
+import AttachmentList from 'flavours/glitch/components/attachment_list';
+
+//  Messages.
+const messages = defineMessages({
+  cancel: {
+    defaultMessage: 'Cancel',
+    id: 'reply_indicator.cancel',
+  },
+});
+
+
+export default @injectIntl
+class ReplyIndicator extends ImmutablePureComponent {
+
+  static propTypes = {
+    status: ImmutablePropTypes.map,
+    intl: PropTypes.object.isRequired,
+    onCancel: PropTypes.func,
+  };
+
+  handleClick = () => {
+    const { onCancel } = this.props;
+    if (onCancel) {
+      onCancel();
+    }
+  };
+
+  //  Rendering.
+  render () {
+    const { status, intl } = this.props;
+
+    if (!status) {
+      return null;
+    }
+
+    const account     = status.get('account');
+    const content     = status.get('content');
+    const attachments = status.get('media_attachments');
+
+    //  The result.
+    return (
+      <article className='reply-indicator'>
+        <header className='reply-indicator__header'>
+          <IconButton
+            className='reply-indicator__cancel'
+            icon='times'
+            onClick={this.handleClick}
+            title={intl.formatMessage(messages.cancel)}
+            inverted
+          />
+          {account && (
+            <AccountContainer
+              id={account}
+              small
+            />
+          )}
+        </header>
+        <div
+          className='reply-indicator__content translate'
+          dangerouslySetInnerHTML={{ __html: content || '' }}
+        />
+        {attachments.size > 0 && (
+          <AttachmentList
+            compact
+            media={attachments}
+          />
+        )}
+      </article>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/compose/components/search.jsx b/app/javascript/flavours/glitch/features/compose/components/search.jsx
new file mode 100644
index 000000000..6241e2a0a
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/components/search.jsx
@@ -0,0 +1,168 @@
+//  Package imports.
+import classNames from 'classnames';
+import PropTypes from 'prop-types';
+import React from 'react';
+import { connect } from 'react-redux';
+import {
+  injectIntl,
+  FormattedMessage,
+  defineMessages,
+} from 'react-intl';
+import Overlay from 'react-overlays/Overlay';
+
+//  Components.
+import Icon from 'flavours/glitch/components/icon';
+
+//  Utils.
+import { focusRoot } from 'flavours/glitch/utils/dom_helpers';
+import { searchEnabled } from 'flavours/glitch/initial_state';
+
+const messages = defineMessages({
+  placeholder: { id: 'search.placeholder', defaultMessage: 'Search' },
+  placeholderSignedIn: { id: 'search.search_or_paste', defaultMessage: 'Search or paste URL' },
+});
+
+class SearchPopout extends React.PureComponent {
+
+  render () {
+    const extraInformation = searchEnabled ? <FormattedMessage id='search_popout.tips.full_text' defaultMessage='Simple text returns statuses you have written, favourited, boosted, or have been mentioned in, as well as matching usernames, display names, and hashtags.' /> : <FormattedMessage id='search_popout.tips.text' defaultMessage='Simple text returns matching display names, usernames and hashtags' />;
+    return (
+      <div className='search-popout'>
+        <h4><FormattedMessage id='search_popout.search_format' defaultMessage='Advanced search format' /></h4>
+
+        <ul>
+          <li><em>#example</em> <FormattedMessage id='search_popout.tips.hashtag' defaultMessage='hashtag' /></li>
+          <li><em>@username@domain</em> <FormattedMessage id='search_popout.tips.user' defaultMessage='user' /></li>
+          <li><em>URL</em> <FormattedMessage id='search_popout.tips.user' defaultMessage='user' /></li>
+          <li><em>URL</em> <FormattedMessage id='search_popout.tips.status' defaultMessage='status' /></li>
+        </ul>
+
+        {extraInformation}
+      </div>
+    );
+  }
+
+}
+
+//  The component.
+export default @injectIntl
+class Search extends React.PureComponent {
+
+  static contextTypes = {
+    router: PropTypes.object.isRequired,
+    identity: PropTypes.object.isRequired,
+  };
+
+  static propTypes = {
+    value: PropTypes.string.isRequired,
+    submitted: PropTypes.bool,
+    onChange: PropTypes.func.isRequired,
+    onSubmit: PropTypes.func.isRequired,
+    onClear: PropTypes.func.isRequired,
+    onShow: PropTypes.func.isRequired,
+    openInRoute: PropTypes.bool,
+    intl: PropTypes.object.isRequired,
+    singleColumn: PropTypes.bool,
+  };
+
+  state = {
+    expanded: false,
+  };
+
+  setRef = c => {
+    this.searchForm = c;
+  };
+
+  handleChange = (e) => {
+    const { onChange } = this.props;
+    if (onChange) {
+      onChange(e.target.value);
+    }
+  };
+
+  handleClear = (e) => {
+    const {
+      onClear,
+      submitted,
+      value,
+    } = this.props;
+    e.preventDefault();  //  Prevents focus change ??
+    if (onClear && (submitted || value && value.length)) {
+      onClear();
+    }
+  };
+
+  handleBlur = () => {
+    this.setState({ expanded: false });
+  };
+
+  handleFocus = () => {
+    this.setState({ expanded: true });
+    this.props.onShow();
+
+    if (this.searchForm && !this.props.singleColumn) {
+      const { left, right } = this.searchForm.getBoundingClientRect();
+      if (left < 0 || right > (window.innerWidth || document.documentElement.clientWidth)) {
+        this.searchForm.scrollIntoView();
+      }
+    }
+  };
+
+  handleKeyUp = (e) => {
+    const { onSubmit } = this.props;
+    switch (e.key) {
+    case 'Enter':
+      onSubmit();
+
+      if (this.props.openInRoute) {
+        this.context.router.history.push('/search');
+      }
+      break;
+    case 'Escape':
+      focusRoot();
+    }
+  };
+
+  findTarget = () => {
+    return this.searchForm;
+  };
+
+  render () {
+    const { intl, value, submitted } = this.props;
+    const { expanded } = this.state;
+    const { signedIn } = this.context.identity;
+    const hasValue = value.length > 0 || submitted;
+
+    return (
+      <div className='search'>
+        <input
+          ref={this.setRef}
+          className='search__input'
+          type='text'
+          placeholder={intl.formatMessage(signedIn ? messages.placeholderSignedIn : messages.placeholder)}
+          aria-label={intl.formatMessage(signedIn ? messages.placeholderSignedIn : messages.placeholder)}
+          value={value || ''}
+          onChange={this.handleChange}
+          onKeyUp={this.handleKeyUp}
+          onFocus={this.handleFocus}
+          onBlur={this.handleBlur}
+        />
+
+        <div role='button' tabIndex='0' className='search__icon' onClick={this.handleClear}>
+          <Icon id='search' className={hasValue ? '' : 'active'} />
+          <Icon id='times-circle' className={hasValue ? 'active' : ''} />
+        </div>
+        <Overlay show={expanded && !hasValue} placement='bottom' target={this.findTarget} popperConfig={{ strategy: 'fixed' }}>
+          {({ props, placement }) => (
+            <div {...props} style={{ ...props.style, width: 285, zIndex: 2 }}>
+              <div className={`dropdown-animation ${placement}`}>
+                <SearchPopout />
+              </div>
+            </div>
+          )}
+        </Overlay>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/compose/components/search_results.jsx b/app/javascript/flavours/glitch/features/compose/components/search_results.jsx
new file mode 100644
index 000000000..23ff60936
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/components/search_results.jsx
@@ -0,0 +1,141 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
+import AccountContainer from 'flavours/glitch/containers/account_container';
+import StatusContainer from 'flavours/glitch/containers/status_container';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import { ImmutableHashtag as Hashtag } from 'flavours/glitch/components/hashtag';
+import Icon from 'flavours/glitch/components/icon';
+import { searchEnabled } from 'flavours/glitch/initial_state';
+import LoadMore from 'flavours/glitch/components/load_more';
+
+const messages = defineMessages({
+  dismissSuggestion: { id: 'suggestions.dismiss', defaultMessage: 'Dismiss suggestion' },
+});
+
+export default @injectIntl
+class SearchResults extends ImmutablePureComponent {
+
+  static propTypes = {
+    results: ImmutablePropTypes.map.isRequired,
+    suggestions: ImmutablePropTypes.list.isRequired,
+    fetchSuggestions: PropTypes.func.isRequired,
+    expandSearch: PropTypes.func.isRequired,
+    dismissSuggestion: PropTypes.func.isRequired,
+    searchTerm: PropTypes.string,
+    intl: PropTypes.object.isRequired,
+  };
+
+  componentDidMount () {
+    if (this.props.searchTerm === '') {
+      this.props.fetchSuggestions();
+    }
+  }
+
+  componentDidUpdate () {
+    if (this.props.searchTerm === '') {
+      this.props.fetchSuggestions();
+    }
+  }
+
+  handleLoadMoreAccounts = () => this.props.expandSearch('accounts');
+
+  handleLoadMoreStatuses = () => this.props.expandSearch('statuses');
+
+  handleLoadMoreHashtags = () => this.props.expandSearch('hashtags');
+
+  render () {
+    const { intl, results, suggestions, dismissSuggestion, searchTerm } = this.props;
+
+    let accounts, statuses, hashtags;
+    let count = 0;
+
+    if (searchTerm === '' && !suggestions.isEmpty()) {
+      return (
+        <div className='drawer--results'>
+          <div className='trends'>
+            <div className='trends__header'>
+              <Icon fixedWidth id='user-plus' />
+              <FormattedMessage id='suggestions.header' defaultMessage='You might be interested in…' />
+            </div>
+
+            {suggestions && suggestions.map(suggestion => (
+              <AccountContainer
+                key={suggestion.get('account')}
+                id={suggestion.get('account')}
+                actionIcon={suggestion.get('source') === 'past_interaction' ? 'times' : null}
+                actionTitle={suggestion.get('source') === 'past_interaction' ? intl.formatMessage(messages.dismissSuggestion) : null}
+                onActionClick={dismissSuggestion}
+              />
+            ))}
+          </div>
+        </div>
+      );
+    } else if(results.get('statuses') && results.get('statuses').size === 0 && !searchEnabled && !(searchTerm.startsWith('@') || searchTerm.startsWith('#') || searchTerm.includes(' '))) {
+      statuses = (
+        <section className='search-results__section'>
+          <h5><Icon id='quote-right' fixedWidth /><FormattedMessage id='search_results.statuses' defaultMessage='Posts' /></h5>
+
+          <div className='search-results__info'>
+            <FormattedMessage id='search_results.statuses_fts_disabled' defaultMessage='Searching posts by their content is not enabled on this Mastodon server.' />
+          </div>
+        </section>
+      );
+    }
+
+    if (results.get('accounts') && results.get('accounts').size > 0) {
+      count   += results.get('accounts').size;
+      accounts = (
+        <section className='search-results__section'>
+          <h5><Icon id='users' fixedWidth /><FormattedMessage id='search_results.accounts' defaultMessage='People' /></h5>
+
+          {results.get('accounts').map(accountId => <AccountContainer id={accountId} key={accountId} />)}
+
+          {results.get('accounts').size >= 5 && <LoadMore visible onClick={this.handleLoadMoreAccounts} />}
+        </section>
+      );
+    }
+
+    if (results.get('statuses') && results.get('statuses').size > 0) {
+      count   += results.get('statuses').size;
+      statuses = (
+        <section className='search-results__section'>
+          <h5><Icon id='quote-right' fixedWidth /><FormattedMessage id='search_results.statuses' defaultMessage='Posts' /></h5>
+
+          {results.get('statuses').map(statusId => <StatusContainer id={statusId} key={statusId} />)}
+
+          {results.get('statuses').size >= 5 && <LoadMore visible onClick={this.handleLoadMoreStatuses} />}
+        </section>
+      );
+    }
+
+    if (results.get('hashtags') && results.get('hashtags').size > 0) {
+      count += results.get('hashtags').size;
+      hashtags = (
+        <section className='search-results__section'>
+          <h5><Icon id='hashtag' fixedWidth /><FormattedMessage id='search_results.hashtags' defaultMessage='Hashtags' /></h5>
+
+          {results.get('hashtags').map(hashtag => <Hashtag key={hashtag.get('name')} hashtag={hashtag} />)}
+
+          {results.get('hashtags').size >= 5 && <LoadMore visible onClick={this.handleLoadMoreHashtags} />}
+        </section>
+      );
+    }
+
+    //  The result.
+    return (
+      <div className='drawer--results'>
+        <header className='search-results__header'>
+          <Icon id='search' fixedWidth />
+          <FormattedMessage id='search_results.total' defaultMessage='{count, number} {count, plural, one {result} other {results}}' values={{ count }} />
+        </header>
+
+        {accounts}
+        {statuses}
+        {hashtags}
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/compose/components/text_icon_button.jsx b/app/javascript/flavours/glitch/features/compose/components/text_icon_button.jsx
new file mode 100644
index 000000000..a35bd4ff5
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/components/text_icon_button.jsx
@@ -0,0 +1,38 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+const iconStyle = {
+  height: null,
+  lineHeight: '27px',
+  width: `${18 * 1.28571429}px`,
+};
+
+export default class TextIconButton extends React.PureComponent {
+
+  static propTypes = {
+    label: PropTypes.string.isRequired,
+    title: PropTypes.string,
+    active: PropTypes.bool,
+    onClick: PropTypes.func.isRequired,
+    ariaControls: PropTypes.string,
+  };
+
+  render () {
+    const { label, title, active, ariaControls } = this.props;
+
+    return (
+      <button
+        title={title}
+        aria-label={title}
+        className={`text-icon-button ${active ? 'active' : ''}`}
+        aria-expanded={active}
+        onClick={this.props.onClick}
+        aria-controls={ariaControls}
+        style={iconStyle}
+      >
+        {label}
+      </button>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/compose/components/textarea_icons.jsx b/app/javascript/flavours/glitch/features/compose/components/textarea_icons.jsx
new file mode 100644
index 000000000..d8ee5c81b
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/components/textarea_icons.jsx
@@ -0,0 +1,60 @@
+//  Package imports.
+import PropTypes from 'prop-types';
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { defineMessages, injectIntl } from 'react-intl';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+//  Components.
+import Icon from 'flavours/glitch/components/icon';
+
+//  Messages.
+const messages = defineMessages({
+  localOnly: {
+    defaultMessage: 'This post is local-only',
+    id: 'advanced_options.local-only.tooltip',
+  },
+  threadedMode: {
+    defaultMessage: 'Threaded mode enabled',
+    id: 'advanced_options.threaded_mode.tooltip',
+  },
+});
+
+//  We use an array of tuples here instead of an object because it
+//  preserves order.
+const iconMap = [
+  ['do_not_federate', 'home', messages.localOnly],
+  ['threaded_mode', 'comments', messages.threadedMode],
+];
+
+export default @injectIntl
+class TextareaIcons extends ImmutablePureComponent {
+
+  static propTypes = {
+    advancedOptions: ImmutablePropTypes.map,
+    intl: PropTypes.object.isRequired,
+  };
+
+  render () {
+    const { advancedOptions, intl } = this.props;
+    return (
+      <div className='compose-form__textarea-icons'>
+        {advancedOptions ? iconMap.map(
+          ([key, icon, message]) => advancedOptions.get(key) ? (
+            <span
+              className='textarea_icon'
+              key={key}
+              title={intl.formatMessage(message)}
+            >
+              <Icon
+                fixedWidth
+                id={icon}
+              />
+            </span>
+          ) : null,
+        ) : null}
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/compose/components/upload.jsx b/app/javascript/flavours/glitch/features/compose/components/upload.jsx
new file mode 100644
index 000000000..63582c636
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/components/upload.jsx
@@ -0,0 +1,67 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import Motion from '../../ui/util/optional_motion';
+import spring from 'react-motion/lib/spring';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import { FormattedMessage } from 'react-intl';
+import Icon from 'flavours/glitch/components/icon';
+import { isUserTouching } from 'flavours/glitch/is_mobile';
+
+export default class Upload extends ImmutablePureComponent {
+
+  static contextTypes = {
+    router: PropTypes.object,
+  };
+
+  static propTypes = {
+    media: ImmutablePropTypes.map.isRequired,
+    onUndo: PropTypes.func.isRequired,
+    onOpenFocalPoint: PropTypes.func.isRequired,
+  };
+
+  handleUndoClick = e => {
+    e.stopPropagation();
+    this.props.onUndo(this.props.media.get('id'));
+  };
+
+  handleFocalPointClick = e => {
+    e.stopPropagation();
+    this.props.onOpenFocalPoint(this.props.media.get('id'));
+  };
+
+  render () {
+    const { media } = this.props;
+
+    if (!media) {
+      return null;
+    }
+
+    const focusX = media.getIn(['meta', 'focus', 'x']);
+    const focusY = media.getIn(['meta', 'focus', 'y']);
+    const x = ((focusX /  2) + .5) * 100;
+    const y = ((focusY / -2) + .5) * 100;
+
+    return (
+      <div className='compose-form__upload' tabIndex='0' role='button'>
+        <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')})`, backgroundPosition: `${x}% ${y}%` }}>
+              <div className='compose-form__upload__actions'>
+                <button type='button' className='icon-button' onClick={this.handleUndoClick}><Icon id='times' /> <FormattedMessage id='upload_form.undo' defaultMessage='Delete' /></button>
+                <button type='button' className='icon-button' onClick={this.handleFocalPointClick}><Icon id='pencil' /> <FormattedMessage id='upload_form.edit' defaultMessage='Edit' /></button>
+              </div>
+
+              {(media.get('description') || '').length === 0 && (
+                <div className='compose-form__upload__warning'>
+                  <button type='button' className='icon-button' onClick={this.handleFocalPointClick}><Icon id='info-circle' /> <FormattedMessage id='upload_form.description_missing' defaultMessage='No description added' /></button>
+                </div>
+              )}
+            </div>
+          )}
+        </Motion>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/compose/components/upload_form.jsx b/app/javascript/flavours/glitch/features/compose/components/upload_form.jsx
new file mode 100644
index 000000000..f2e7fe7a2
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/components/upload_form.jsx
@@ -0,0 +1,34 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import UploadProgressContainer from '../containers/upload_progress_container';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import UploadContainer from '../containers/upload_container';
+import SensitiveButtonContainer from '../containers/sensitive_button_container';
+
+export default class UploadForm extends ImmutablePureComponent {
+
+  static propTypes = {
+    mediaIds: ImmutablePropTypes.list.isRequired,
+  };
+
+  render () {
+    const { mediaIds } = this.props;
+
+    return (
+      <div className='compose-form__upload-wrapper'>
+        <UploadProgressContainer />
+
+        {mediaIds.size > 0 && (
+          <div className='compose-form__uploads-wrapper'>
+            {mediaIds.map(id => (
+              <UploadContainer id={id} key={id} />
+            ))}
+          </div>
+        )}
+
+        {!mediaIds.isEmpty() && <SensitiveButtonContainer />}
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/compose/components/upload_progress.jsx b/app/javascript/flavours/glitch/features/compose/components/upload_progress.jsx
new file mode 100644
index 000000000..39ac31053
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/components/upload_progress.jsx
@@ -0,0 +1,52 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import Motion from '../../ui/util/optional_motion';
+import spring from 'react-motion/lib/spring';
+import Icon from 'flavours/glitch/components/icon';
+import { FormattedMessage } from 'react-intl';
+
+export default class UploadProgress extends React.PureComponent {
+
+  static propTypes = {
+    active: PropTypes.bool,
+    progress: PropTypes.number,
+    isProcessing: PropTypes.bool,
+  };
+
+  render () {
+    const { active, progress, isProcessing } = this.props;
+
+    if (!active) {
+      return null;
+    }
+
+    let message;
+
+    if (isProcessing) {
+      message = <FormattedMessage id='upload_progress.processing' defaultMessage='Processing…' />;
+    } else {
+      message = <FormattedMessage id='upload_progress.label' defaultMessage='Uploading…' />;
+    }
+
+    return (
+      <div className='upload-progress'>
+        <div className='upload-progress__icon'>
+          <Icon id='upload' />
+        </div>
+
+        <div className='upload-progress__message'>
+          {message}
+
+          <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.jsx b/app/javascript/flavours/glitch/features/compose/components/warning.jsx
new file mode 100644
index 000000000..803b7f86a
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/components/warning.jsx
@@ -0,0 +1,26 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import Motion from '../../ui/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/autosuggest_account_container.js b/app/javascript/flavours/glitch/features/compose/containers/autosuggest_account_container.js
new file mode 100644
index 000000000..0e1c328fe
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/containers/autosuggest_account_container.js
@@ -0,0 +1,15 @@
+import { connect } from 'react-redux';
+import AutosuggestAccount from '../components/autosuggest_account';
+import { makeGetAccount } from 'flavours/glitch/selectors';
+
+const makeMapStateToProps = () => {
+  const getAccount = makeGetAccount();
+
+  const mapStateToProps = (state, { id }) => ({
+    account: getAccount(state, id),
+  });
+
+  return mapStateToProps;
+};
+
+export default connect(makeMapStateToProps)(AutosuggestAccount);
diff --git a/app/javascript/flavours/glitch/features/compose/containers/compose_form_container.js b/app/javascript/flavours/glitch/features/compose/containers/compose_form_container.js
new file mode 100644
index 000000000..ddcdb367a
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/containers/compose_form_container.js
@@ -0,0 +1,144 @@
+import { connect } from 'react-redux';
+import { defineMessages, injectIntl } from 'react-intl';
+import ComposeForm from '../components/compose_form';
+import {
+  changeCompose,
+  changeComposeSpoilerText,
+  changeComposeSpoilerness,
+  changeComposeVisibility,
+  clearComposeSuggestions,
+  fetchComposeSuggestions,
+  insertEmojiCompose,
+  selectComposeSuggestion,
+  submitCompose,
+  uploadCompose,
+} from 'flavours/glitch/actions/compose';
+import {
+  openModal,
+} from 'flavours/glitch/actions/modal';
+import { changeLocalSetting } from 'flavours/glitch/actions/local_settings';
+
+import { privacyPreference } from 'flavours/glitch/utils/privacy_preference';
+
+const messages = defineMessages({
+  missingDescriptionMessage: {
+    id: 'confirmations.missing_media_description.message',
+    defaultMessage: 'At least one media attachment is lacking a description. Consider describing all media attachments for the visually impaired before sending your toot.',
+  },
+  missingDescriptionConfirm: {
+    id: 'confirmations.missing_media_description.confirm',
+    defaultMessage: 'Send anyway',
+  },
+  missingDescriptionEdit: {
+    id: 'confirmations.missing_media_description.edit',
+    defaultMessage: 'Edit media',
+  },
+});
+
+//  State mapping.
+function mapStateToProps (state) {
+  const spoilersAlwaysOn = state.getIn(['local_settings', 'always_show_spoilers_field']);
+  const inReplyTo = state.getIn(['compose', 'in_reply_to']);
+  const replyPrivacy = inReplyTo ? state.getIn(['statuses', inReplyTo, 'visibility']) : null;
+  const sideArmBasePrivacy = state.getIn(['local_settings', 'side_arm']);
+  const sideArmRestrictedPrivacy = replyPrivacy ? privacyPreference(replyPrivacy, sideArmBasePrivacy) : null;
+  let sideArmPrivacy = null;
+  switch (state.getIn(['local_settings', 'side_arm_reply_mode'])) {
+  case 'copy':
+    sideArmPrivacy = replyPrivacy;
+    break;
+  case 'restrict':
+    sideArmPrivacy = sideArmRestrictedPrivacy;
+    break;
+  }
+  sideArmPrivacy = sideArmPrivacy || sideArmBasePrivacy;
+  return {
+    advancedOptions: state.getIn(['compose', 'advanced_options']),
+    focusDate: state.getIn(['compose', 'focusDate']),
+    caretPosition: state.getIn(['compose', 'caretPosition']),
+    isSubmitting: state.getIn(['compose', 'is_submitting']),
+    isEditing: state.getIn(['compose', 'id']) !== null,
+    isChangingUpload: state.getIn(['compose', 'is_changing_upload']),
+    isUploading: state.getIn(['compose', 'is_uploading']),
+    layout: state.getIn(['local_settings', 'layout']),
+    media: state.getIn(['compose', 'media_attachments']),
+    preselectDate: state.getIn(['compose', 'preselectDate']),
+    privacy: state.getIn(['compose', 'privacy']),
+    sideArm: sideArmPrivacy,
+    sensitive: state.getIn(['compose', 'sensitive']),
+    showSearch: state.getIn(['search', 'submitted']) && !state.getIn(['search', 'hidden']),
+    spoiler: spoilersAlwaysOn || state.getIn(['compose', 'spoiler']),
+    spoilerText: state.getIn(['compose', 'spoiler_text']),
+    suggestions: state.getIn(['compose', 'suggestions']),
+    text: state.getIn(['compose', 'text']),
+    anyMedia: state.getIn(['compose', 'media_attachments']).size > 0,
+    spoilersAlwaysOn: spoilersAlwaysOn,
+    mediaDescriptionConfirmation: state.getIn(['local_settings', 'confirm_missing_media_description']),
+    preselectOnReply: state.getIn(['local_settings', 'preselect_on_reply']),
+    isInReply: state.getIn(['compose', 'in_reply_to']) !== null,
+    lang: state.getIn(['compose', 'language']),
+  };
+}
+
+//  Dispatch mapping.
+const mapDispatchToProps = (dispatch, { intl }) => ({
+
+  onChange(text) {
+    dispatch(changeCompose(text));
+  },
+
+  onSubmit(routerHistory) {
+    dispatch(submitCompose(routerHistory));
+  },
+
+  onClearSuggestions() {
+    dispatch(clearComposeSuggestions());
+  },
+
+  onFetchSuggestions(token) {
+    dispatch(fetchComposeSuggestions(token));
+  },
+
+  onSuggestionSelected(position, token, suggestion, path) {
+    dispatch(selectComposeSuggestion(position, token, suggestion, path));
+  },
+
+  onChangeSpoilerText(text) {
+    dispatch(changeComposeSpoilerText(text));
+  },
+
+  onPaste(files) {
+    dispatch(uploadCompose(files));
+  },
+
+  onPickEmoji(position, emoji) {
+    dispatch(insertEmojiCompose(position, emoji));
+  },
+
+  onChangeSpoilerness() {
+    dispatch(changeComposeSpoilerness());
+  },
+
+  onChangeVisibility(value) {
+    dispatch(changeComposeVisibility(value));
+  },
+
+  onMediaDescriptionConfirm(routerHistory, mediaId, overriddenVisibility = null) {
+    dispatch(openModal('CONFIRM', {
+      message: intl.formatMessage(messages.missingDescriptionMessage),
+      confirm: intl.formatMessage(messages.missingDescriptionConfirm),
+      onConfirm: () => {
+        if (overriddenVisibility) {
+          dispatch(changeComposeVisibility(overriddenVisibility));
+        }
+        dispatch(submitCompose(routerHistory));
+      },
+      secondary: intl.formatMessage(messages.missingDescriptionEdit),
+      onSecondary: () => dispatch(openModal('FOCAL_POINT', { id: mediaId })),
+      onDoNotAsk: () => dispatch(changeLocalSetting(['confirm_missing_media_description'], false)),
+    }));
+  },
+
+});
+
+export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(ComposeForm));
diff --git a/app/javascript/flavours/glitch/features/compose/containers/dropdown_container.js b/app/javascript/flavours/glitch/features/compose/containers/dropdown_container.js
new file mode 100644
index 000000000..3ac16505f
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/containers/dropdown_container.js
@@ -0,0 +1,12 @@
+import { connect } from 'react-redux';
+import { isUserTouching } from 'flavours/glitch/is_mobile';
+import { openModal, closeModal } from 'flavours/glitch/actions/modal';
+import Dropdown from '../components/dropdown';
+
+const mapDispatchToProps = dispatch => ({
+  isUserTouching,
+  onModalOpen: props => dispatch(openModal('ACTIONS', props)),
+  onModalClose: () => dispatch(closeModal()),
+});
+
+export default connect(null, mapDispatchToProps)(Dropdown);
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
new file mode 100644
index 000000000..66d51947a
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/containers/emoji_picker_dropdown_container.js
@@ -0,0 +1,83 @@
+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) {
+    let uniqueDefaults = DEFAULTS.filter(emoji => !emojis.includes(emoji));
+    emojis = emojis.concat(uniqueDefaults.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/header_container.js b/app/javascript/flavours/glitch/features/compose/containers/header_container.js
new file mode 100644
index 000000000..e1ce19fb0
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/containers/header_container.js
@@ -0,0 +1,36 @@
+import { openModal } from 'flavours/glitch/actions/modal';
+import { connect }   from 'react-redux';
+import { defineMessages, injectIntl } from 'react-intl';
+import Header from '../components/header';
+import { logOut } from 'flavours/glitch/utils/log_out';
+
+const messages = defineMessages({
+  logoutMessage: { id: 'confirmations.logout.message', defaultMessage: 'Are you sure you want to log out?' },
+  logoutConfirm: { id: 'confirmations.logout.confirm', defaultMessage: 'Log out' },
+});
+
+const mapStateToProps = state => {
+  return {
+    columns: state.getIn(['settings', 'columns']),
+    unreadNotifications: state.getIn(['notifications', 'unread']),
+    showNotificationsBadge: state.getIn(['local_settings', 'notifications', 'tab_badge']),
+  };
+};
+
+const mapDispatchToProps = (dispatch, { intl }) => ({
+  onSettingsClick (e) {
+    e.preventDefault();
+    e.stopPropagation();
+    dispatch(openModal('SETTINGS', {}));
+  },
+  onLogout () {
+    dispatch(openModal('CONFIRM', {
+      message: intl.formatMessage(messages.logoutMessage),
+      confirm: intl.formatMessage(messages.logoutConfirm),
+      closeWhenConfirm: false,
+      onConfirm: () => logOut(),
+    }));
+  },
+});
+
+export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(Header));
diff --git a/app/javascript/flavours/glitch/features/compose/containers/language_dropdown_container.js b/app/javascript/flavours/glitch/features/compose/containers/language_dropdown_container.js
new file mode 100644
index 000000000..828d08cf5
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/containers/language_dropdown_container.js
@@ -0,0 +1,34 @@
+import { connect } from 'react-redux';
+import LanguageDropdown from '../components/language_dropdown';
+import { changeComposeLanguage } from 'flavours/glitch/actions/compose';
+import { useLanguage } from 'flavours/glitch/actions/languages';
+import { createSelector } from 'reselect';
+import { Map as ImmutableMap } from 'immutable';
+
+const getFrequentlyUsedLanguages = createSelector([
+  state => state.getIn(['settings', 'frequentlyUsedLanguages'], ImmutableMap()),
+], languageCounters => (
+  languageCounters.keySeq()
+    .sort((a, b) => languageCounters.get(a) - languageCounters.get(b))
+    .reverse()
+    .toArray()
+));
+
+const mapStateToProps = state => ({
+  frequentlyUsedLanguages: getFrequentlyUsedLanguages(state),
+  value: state.getIn(['compose', 'language']),
+});
+
+const mapDispatchToProps = dispatch => ({
+
+  onChange (value) {
+    dispatch(changeComposeLanguage(value));
+  },
+
+  onClose (value) {
+    dispatch(useLanguage(value));
+  },
+
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(LanguageDropdown);
diff --git a/app/javascript/flavours/glitch/features/compose/containers/navigation_container.js b/app/javascript/flavours/glitch/features/compose/containers/navigation_container.js
new file mode 100644
index 000000000..89036adcd
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/containers/navigation_container.js
@@ -0,0 +1,30 @@
+import { connect }   from 'react-redux';
+import { defineMessages, injectIntl } from 'react-intl';
+import NavigationBar from '../components/navigation_bar';
+import { logOut } from 'flavours/glitch/utils/log_out';
+import { openModal } from 'flavours/glitch/actions/modal';
+import { me } from 'flavours/glitch/initial_state';
+
+const messages = defineMessages({
+  logoutMessage: { id: 'confirmations.logout.message', defaultMessage: 'Are you sure you want to log out?' },
+  logoutConfirm: { id: 'confirmations.logout.confirm', defaultMessage: 'Log out' },
+});
+
+const mapStateToProps = state => {
+  return {
+    account: state.getIn(['accounts', me]),
+  };
+};
+
+const mapDispatchToProps = (dispatch, { intl }) => ({
+  onLogout () {
+    dispatch(openModal('CONFIRM', {
+      message: intl.formatMessage(messages.logoutMessage),
+      confirm: intl.formatMessage(messages.logoutConfirm),
+      closeWhenConfirm: false,
+      onConfirm: () => logOut(),
+    }));
+  },
+});
+
+export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(NavigationBar));
diff --git a/app/javascript/flavours/glitch/features/compose/containers/options_container.js b/app/javascript/flavours/glitch/features/compose/containers/options_container.js
new file mode 100644
index 000000000..19a90ac8b
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/containers/options_container.js
@@ -0,0 +1,53 @@
+import { connect } from 'react-redux';
+import Options from '../components/options';
+import {
+  changeComposeAdvancedOption,
+  changeComposeContentType,
+  addPoll,
+  removePoll,
+} from 'flavours/glitch/actions/compose';
+import { openModal } from 'flavours/glitch/actions/modal';
+
+function mapStateToProps (state) {
+  const spoilersAlwaysOn = state.getIn(['local_settings', 'always_show_spoilers_field']);
+  const poll = state.getIn(['compose', 'poll']);
+  const media = state.getIn(['compose', 'media_attachments']);
+  const pending_media = state.getIn(['compose', 'pending_media_attachments']);
+  return {
+    acceptContentTypes: state.getIn(['media_attachments', 'accept_content_types']).toArray().join(','),
+    resetFileKey: state.getIn(['compose', 'resetFileKey']),
+    hasPoll: !!poll,
+    allowMedia: !poll && (media ? media.size + pending_media < 4 && !media.some(item => ['video', 'audio'].includes(item.get('type'))) : pending_media < 4),
+    hasMedia: media && !!media.size,
+    allowPoll: !(media && !!media.size),
+    showContentTypeChoice: state.getIn(['local_settings', 'show_content_type_choice']),
+    contentType: state.getIn(['compose', 'content_type']),
+  };
+}
+
+const mapDispatchToProps = (dispatch) => ({
+
+  onChangeAdvancedOption(option, value) {
+    dispatch(changeComposeAdvancedOption(option, value));
+  },
+
+  onChangeContentType(value) {
+    dispatch(changeComposeContentType(value));
+  },
+
+  onTogglePoll() {
+    dispatch((_, getState) => {
+      if (getState().getIn(['compose', 'poll'])) {
+        dispatch(removePoll());
+      } else {
+        dispatch(addPoll());
+      }
+    });
+  },
+
+  onDoodleOpen() {
+    dispatch(openModal('DOODLE', { noEsc: true, noClose: true }));
+  },
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(Options);
diff --git a/app/javascript/flavours/glitch/features/compose/containers/poll_form_container.js b/app/javascript/flavours/glitch/features/compose/containers/poll_form_container.js
new file mode 100644
index 000000000..14038b3e8
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/containers/poll_form_container.js
@@ -0,0 +1,52 @@
+import { connect } from 'react-redux';
+import PollForm from '../components/poll_form';
+import {
+  addPollOption,
+  removePollOption,
+  changePollOption,
+  changePollSettings,
+  clearComposeSuggestions,
+  fetchComposeSuggestions,
+  selectComposeSuggestion,
+} from 'flavours/glitch/actions/compose';
+
+const mapStateToProps = state => ({
+  suggestions: state.getIn(['compose', 'suggestions']),
+  options: state.getIn(['compose', 'poll', 'options']),
+  lang: state.getIn(['compose', 'language']),
+  expiresIn: state.getIn(['compose', 'poll', 'expires_in']),
+  isMultiple: state.getIn(['compose', 'poll', 'multiple']),
+});
+
+const mapDispatchToProps = dispatch => ({
+  onAddOption(title) {
+    dispatch(addPollOption(title));
+  },
+
+  onRemoveOption(index) {
+    dispatch(removePollOption(index));
+  },
+
+  onChangeOption(index, title) {
+    dispatch(changePollOption(index, title));
+  },
+
+  onChangeSettings(expiresIn, isMultiple) {
+    dispatch(changePollSettings(expiresIn, isMultiple));
+  },
+
+  onClearSuggestions () {
+    dispatch(clearComposeSuggestions());
+  },
+
+  onFetchSuggestions (token) {
+    dispatch(fetchComposeSuggestions(token));
+  },
+
+  onSuggestionSelected (position, token, accountId, path) {
+    dispatch(selectComposeSuggestion(position, token, accountId, path));
+  },
+
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(PollForm);
diff --git a/app/javascript/flavours/glitch/features/compose/containers/privacy_dropdown_container.js b/app/javascript/flavours/glitch/features/compose/containers/privacy_dropdown_container.js
new file mode 100644
index 000000000..5591d89c4
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/containers/privacy_dropdown_container.js
@@ -0,0 +1,23 @@
+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/is_mobile';
+
+const mapStateToProps = state => ({
+  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
new file mode 100644
index 000000000..dd6899be4
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/containers/reply_indicator_container.js
@@ -0,0 +1,32 @@
+import { connect } from 'react-redux';
+import { cancelReplyCompose } from 'flavours/glitch/actions/compose';
+import ReplyIndicator from '../components/reply_indicator';
+
+const makeMapStateToProps = () => {
+  const mapStateToProps = state => {
+    let statusId = state.getIn(['compose', 'id'], null);
+    let editing  = true;
+
+    if (statusId === null) {
+      statusId = state.getIn(['compose', 'in_reply_to']);
+      editing  = false;
+    }
+
+    return {
+      status: state.getIn(['statuses', statusId]),
+      editing,
+    };
+  };
+
+  return mapStateToProps;
+};
+
+const mapDispatchToProps = dispatch => ({
+
+  onCancel () {
+    dispatch(cancelReplyCompose());
+  },
+
+});
+
+export default connect(makeMapStateToProps, mapDispatchToProps)(ReplyIndicator);
diff --git a/app/javascript/flavours/glitch/features/compose/containers/search_container.js b/app/javascript/flavours/glitch/features/compose/containers/search_container.js
new file mode 100644
index 000000000..8f4bfcf08
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/containers/search_container.js
@@ -0,0 +1,35 @@
+import { connect } from 'react-redux';
+import {
+  changeSearch,
+  clearSearch,
+  submitSearch,
+  showSearch,
+} from 'flavours/glitch/actions/search';
+import Search from '../components/search';
+
+const mapStateToProps = state => ({
+  value: state.getIn(['search', 'value']),
+  submitted: state.getIn(['search', 'submitted']),
+});
+
+const mapDispatchToProps = dispatch => ({
+
+  onChange (value) {
+    dispatch(changeSearch(value));
+  },
+
+  onClear () {
+    dispatch(clearSearch());
+  },
+
+  onSubmit () {
+    dispatch(submitSearch());
+  },
+
+  onShow () {
+    dispatch(showSearch());
+  },
+
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(Search);
diff --git a/app/javascript/flavours/glitch/features/compose/containers/search_results_container.js b/app/javascript/flavours/glitch/features/compose/containers/search_results_container.js
new file mode 100644
index 000000000..5c2c1be23
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/containers/search_results_container.js
@@ -0,0 +1,18 @@
+import { connect } from 'react-redux';
+import SearchResults from '../components/search_results';
+import { fetchSuggestions, dismissSuggestion } from 'flavours/glitch/actions/suggestions';
+import { expandSearch } from 'flavours/glitch/actions/search';
+
+const mapStateToProps = state => ({
+  results: state.getIn(['search', 'results']),
+  suggestions: state.getIn(['suggestions', 'items']),
+  searchTerm: state.getIn(['search', 'searchTerm']),
+});
+
+const mapDispatchToProps = dispatch => ({
+  fetchSuggestions: () => dispatch(fetchSuggestions()),
+  expandSearch: type => dispatch(expandSearch(type)),
+  dismissSuggestion: account => dispatch(dismissSuggestion(account.get('id'))),
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(SearchResults);
diff --git a/app/javascript/flavours/glitch/features/compose/containers/sensitive_button_container.jsx b/app/javascript/flavours/glitch/features/compose/containers/sensitive_button_container.jsx
new file mode 100644
index 000000000..9c23d3f47
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/containers/sensitive_button_container.jsx
@@ -0,0 +1,75 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import classNames from 'classnames';
+import { changeComposeSensitivity } from 'flavours/glitch/actions/compose';
+import { injectIntl, defineMessages, FormattedMessage } from 'react-intl';
+
+const messages = defineMessages({
+  marked: {
+    id: 'compose_form.sensitive.marked',
+    defaultMessage: '{count, plural, one {Media is marked as sensitive} other {Media is marked as sensitive}}',
+  },
+  unmarked: {
+    id: 'compose_form.sensitive.unmarked',
+    defaultMessage: '{count, plural, one {Media is not marked as sensitive} other {Media is not marked as sensitive}}',
+  },
+});
+
+const mapStateToProps = state => {
+  const spoilersAlwaysOn = state.getIn(['local_settings', 'always_show_spoilers_field']);
+  const spoilerText = state.getIn(['compose', 'spoiler_text']);
+  return {
+    active: state.getIn(['compose', 'sensitive']) || (spoilersAlwaysOn && spoilerText && spoilerText.length > 0),
+    disabled: state.getIn(['compose', 'spoiler']),
+    mediaCount: state.getIn(['compose', 'media_attachments']).size,
+  };
+};
+
+const mapDispatchToProps = dispatch => ({
+
+  onClick () {
+    dispatch(changeComposeSensitivity());
+  },
+
+});
+
+class SensitiveButton extends React.PureComponent {
+
+  static propTypes = {
+    active: PropTypes.bool,
+    disabled: PropTypes.bool,
+    mediaCount: PropTypes.number,
+    onClick: PropTypes.func.isRequired,
+    intl: PropTypes.object.isRequired,
+  };
+
+  render () {
+    const { active, disabled, mediaCount, onClick, intl } = this.props;
+
+    return (
+      <div className='compose-form__sensitive-button'>
+        <label className={classNames('icon-button', { active })} title={intl.formatMessage(active ? messages.marked : messages.unmarked, { count: mediaCount })}>
+          <input
+            name='mark-sensitive'
+            type='checkbox'
+            checked={active}
+            onChange={onClick}
+            disabled={disabled}
+          />
+
+          <span className={classNames('checkbox', { active })} />
+
+          <FormattedMessage
+            id='compose_form.sensitive.hide'
+            defaultMessage='{count, plural, one {Mark media as sensitive} other {Mark media as sensitive}}'
+            values={{ count: mediaCount }}
+          />
+        </label>
+      </div>
+    );
+  }
+
+}
+
+export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(SensitiveButton));
diff --git a/app/javascript/flavours/glitch/features/compose/containers/upload_container.js b/app/javascript/flavours/glitch/features/compose/containers/upload_container.js
new file mode 100644
index 000000000..2189c870b
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/containers/upload_container.js
@@ -0,0 +1,25 @@
+import { connect } from 'react-redux';
+import Upload from '../components/upload';
+import { undoUploadCompose, initMediaEditModal, submitCompose } from 'flavours/glitch/actions/compose';
+
+const mapStateToProps = (state, { id }) => ({
+  media: state.getIn(['compose', 'media_attachments']).find(item => item.get('id') === id),
+});
+
+const mapDispatchToProps = dispatch => ({
+
+  onUndo: id => {
+    dispatch(undoUploadCompose(id));
+  },
+
+  onOpenFocalPoint: id => {
+    dispatch(initMediaEditModal(id));
+  },
+
+  onSubmit (router) {
+    dispatch(submitCompose(router));
+  },
+
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(Upload);
diff --git a/app/javascript/flavours/glitch/features/compose/containers/upload_form_container.js b/app/javascript/flavours/glitch/features/compose/containers/upload_form_container.js
new file mode 100644
index 000000000..a6798bf51
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/containers/upload_form_container.js
@@ -0,0 +1,8 @@
+import { connect } from 'react-redux';
+import UploadForm from '../components/upload_form';
+
+const mapStateToProps = state => ({
+  mediaIds: state.getIn(['compose', 'media_attachments']).map(item => item.get('id')),
+});
+
+export default connect(mapStateToProps)(UploadForm);
diff --git a/app/javascript/flavours/glitch/features/compose/containers/upload_progress_container.js b/app/javascript/flavours/glitch/features/compose/containers/upload_progress_container.js
new file mode 100644
index 000000000..b18c76a43
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/containers/upload_progress_container.js
@@ -0,0 +1,10 @@
+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']),
+  isProcessing: state.getIn(['compose', 'is_processing']),
+});
+
+export default connect(mapStateToProps)(UploadProgress);
diff --git a/app/javascript/flavours/glitch/features/compose/containers/warning_container.jsx b/app/javascript/flavours/glitch/features/compose/containers/warning_container.jsx
new file mode 100644
index 000000000..5b48c45e4
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/containers/warning_container.jsx
@@ -0,0 +1,68 @@
+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/initial_state';
+import { profileLink, termsLink } from 'flavours/glitch/utils/backend_links';
+
+const buildHashtagRE = () => {
+  try {
+    const HASHTAG_SEPARATORS = '_\\u00b7\\u200c';
+    const ALPHA = '\\p{L}\\p{M}';
+    const WORD = '\\p{L}\\p{M}\\p{N}\\p{Pc}';
+    return new RegExp(
+      '(?:^|[^\\/\\)\\w])#((' +
+      '[' + WORD + '_]' +
+      '[' + WORD + HASHTAG_SEPARATORS + ']*' +
+      '[' + ALPHA + HASHTAG_SEPARATORS + ']' +
+      '[' + WORD + HASHTAG_SEPARATORS +']*' +
+      '[' + WORD + '_]' +
+      ')|(' +
+      '[' + WORD + '_]*' +
+      '[' + ALPHA + ']' +
+      '[' + WORD + '_]*' +
+      '))', 'iu',
+    );
+  } catch {
+    return /(?:^|[^/)\w])#(\w*[a-zA-Z·]\w*)/i;
+  }
+};
+
+const APPROX_HASHTAG_RE = buildHashtagRE();
+
+const mapStateToProps = state => ({
+  needsLockWarning: state.getIn(['compose', 'privacy']) === 'private' && !state.getIn(['accounts', me, 'locked']),
+  hashtagWarning: state.getIn(['compose', 'privacy']) !== 'public' && APPROX_HASHTAG_RE.test(state.getIn(['compose', 'text'])),
+  directMessageWarning: state.getIn(['compose', 'privacy']) === 'direct',
+});
+
+const WarningWrapper = ({ needsLockWarning, hashtagWarning, directMessageWarning }) => {
+  if (needsLockWarning) {
+    return <Warning message={<FormattedMessage id='compose_form.lock_disclaimer' defaultMessage='Your account is not {locked}. Anyone can follow you to view your follower-only posts.' values={{ locked: <a href={profileLink}><FormattedMessage id='compose_form.lock_disclaimer.lock' defaultMessage='locked' /></a> }} />} />;
+  }
+
+  if (hashtagWarning) {
+    return <Warning message={<FormattedMessage id='compose_form.hashtag_warning' defaultMessage="This post won't be listed under any hashtag as it is unlisted. Only public posts can be searched by hashtag." />} />;
+  }
+
+  if (directMessageWarning) {
+    const message = (
+      <span>
+        <FormattedMessage id='compose_form.encryption_warning' defaultMessage='Posts on Mastodon are not end-to-end encrypted. Do not share any dangerous information over Mastodon.' /> {!!termsLink && <a href={termsLink} target='_blank'><FormattedMessage id='compose_form.direct_message_warning_learn_more' defaultMessage='Learn more' /></a>}
+      </span>
+    );
+
+    return <Warning message={message} />;
+  }
+
+  return null;
+};
+
+WarningWrapper.propTypes = {
+  needsLockWarning: PropTypes.bool,
+  hashtagWarning: PropTypes.bool,
+  directMessageWarning: PropTypes.bool,
+};
+
+export default connect(mapStateToProps)(WarningWrapper);
diff --git a/app/javascript/flavours/glitch/features/compose/index.jsx b/app/javascript/flavours/glitch/features/compose/index.jsx
new file mode 100644
index 000000000..241caa03b
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/index.jsx
@@ -0,0 +1,116 @@
+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, cycleElefriendCompose } from 'flavours/glitch/actions/compose';
+import { injectIntl, defineMessages } from 'react-intl';
+import classNames from 'classnames';
+import SearchContainer from './containers/search_container';
+import Motion from '../ui/util/optional_motion';
+import spring from 'react-motion/lib/spring';
+import SearchResultsContainer from './containers/search_results_container';
+import { me, mascot } from 'flavours/glitch/initial_state';
+import HeaderContainer from './containers/header_container';
+import Column from 'flavours/glitch/components/column';
+import { Helmet } from 'react-helmet';
+
+const messages = defineMessages({
+  compose: { id: 'navigation_bar.compose', defaultMessage: 'Compose new post' },
+});
+
+const mapStateToProps = (state, ownProps) => ({
+  elefriend: state.getIn(['compose', 'elefriend']),
+  showSearch: ownProps.multiColumn ? state.getIn(['search', 'submitted']) && !state.getIn(['search', 'hidden']) : false,
+});
+
+const mapDispatchToProps = (dispatch, { intl }) => ({
+  onClickElefriend () {
+    dispatch(cycleElefriendCompose());
+  },
+
+  onMount () {
+    dispatch(mountCompose());
+  },
+
+  onUnmount () {
+    dispatch(unmountCompose());
+  },
+});
+
+export default @connect(mapStateToProps, mapDispatchToProps)
+@injectIntl
+class Compose extends React.PureComponent {
+
+  static propTypes = {
+    multiColumn: PropTypes.bool,
+    showSearch: PropTypes.bool,
+    elefriend: PropTypes.number,
+    onClickElefriend: PropTypes.func,
+    onMount: PropTypes.func,
+    onUnmount: PropTypes.func,
+    intl: PropTypes.object.isRequired,
+  };
+
+  componentDidMount () {
+    this.props.onMount();
+  }
+
+  componentWillUnmount () {
+    this.props.onUnmount();
+  }
+
+  render () {
+    const {
+      elefriend,
+      intl,
+      multiColumn,
+      onClickElefriend,
+      showSearch,
+    } = this.props;
+    const computedClass = classNames('drawer', `mbstobon-${elefriend}`);
+
+    if (multiColumn) {
+      return (
+        <div className={computedClass} role='region' aria-label={intl.formatMessage(messages.compose)}>
+          <HeaderContainer />
+
+          {multiColumn && <SearchContainer />}
+
+          <div className='drawer__pager'>
+            <div className='drawer__inner'>
+              <NavigationContainer />
+
+              <ComposeFormContainer />
+
+              <div className='drawer__inner__mastodon'>
+                {mascot ? <img alt='' draggable='false' src={mascot} /> : <button className='mastodon' onClick={onClickElefriend} />}
+              </div>
+            </div>
+
+            <Motion defaultStyle={{ x: -100 }} style={{ x: spring(showSearch ? 0 : -100, { stiffness: 210, damping: 20 }) }}>
+              {({ x }) => (
+                <div className='drawer__inner darker' style={{ transform: `translateX(${x}%)`, visibility: x === -100 ? 'hidden' : 'visible' }}>
+                  <SearchResultsContainer />
+                </div>
+              )}
+            </Motion>
+          </div>
+        </div>
+      );
+    }
+
+    return (
+      <Column>
+        <NavigationContainer />
+        <ComposeFormContainer />
+
+        <Helmet>
+          <meta name='robots' content='noindex' />
+        </Helmet>
+      </Column>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/compose/util/counter.js b/app/javascript/flavours/glitch/features/compose/util/counter.js
new file mode 100644
index 000000000..ec2431096
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/util/counter.js
@@ -0,0 +1,9 @@
+import { urlRegex } from './url_regex';
+
+const urlPlaceholder = '$2xxxxxxxxxxxxxxxxxxxxxxx';
+
+export function countableText(inputText) {
+  return inputText
+    .replace(urlRegex, urlPlaceholder)
+    .replace(/(^|[^/\w])@(([a-z0-9_]+)@[a-z0-9.-]+[a-z0-9]+)/ig, '$1@$3');
+}
diff --git a/app/javascript/flavours/glitch/features/compose/util/url_regex.js b/app/javascript/flavours/glitch/features/compose/util/url_regex.js
new file mode 100644
index 000000000..9c2005c53
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/util/url_regex.js
@@ -0,0 +1,30 @@
+import regexSupplant from 'twitter-text/dist/lib/regexSupplant';
+import validUrlPrecedingChars from 'twitter-text/dist/regexp/validUrlPrecedingChars';
+import validDomain from 'twitter-text/dist/regexp/validDomain';
+import validPortNumber from 'twitter-text/dist/regexp/validPortNumber';
+import validUrlPath from 'twitter-text/dist/regexp/validUrlPath';
+import validUrlQueryChars from 'twitter-text/dist/regexp/validUrlQueryChars';
+import validUrlQueryEndingChars from 'twitter-text/dist/regexp/validUrlQueryEndingChars';
+
+// The difference with twitter-text's extractURL is that the protocol isn't
+// optional.
+
+export const urlRegex = regexSupplant(
+  '('                                                          + // $1 URL
+    '(#{validUrlPrecedingChars})'                              + // $2
+    '(https?:\\/\\/)'                                          + // $3 Protocol
+    '(#{validDomain})'                                         + // $4 Domain(s)
+    '(?::(#{validPortNumber}))?'                               + // $5 Port number (optional)
+    '(\\/#{validUrlPath}*)?'                                   + // $6 URL Path
+    '(\\?#{validUrlQueryChars}*#{validUrlQueryEndingChars})?'  + // $7 Query String
+  ')',
+  {
+    validUrlPrecedingChars,
+    validDomain,
+    validPortNumber,
+    validUrlPath,
+    validUrlQueryChars,
+    validUrlQueryEndingChars,
+  },
+  'gi',
+);