about summary refs log tree commit diff
path: root/app/javascript/flavours/glitch/features/compose/components
diff options
context:
space:
mode:
Diffstat (limited to 'app/javascript/flavours/glitch/features/compose/components')
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/autosuggest_account.js24
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/character_counter.js25
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/compose_form.js376
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/dropdown.js235
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/dropdown_menu.js254
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/header.js134
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/navigation_bar.js38
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/options.js367
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/poll_form.js160
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/publisher.js114
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/reply_indicator.js86
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/search.js177
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/search_results.js134
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/text_icon_button.js43
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/textarea_icons.js59
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/upload.js57
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/upload_form.js34
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/upload_progress.js43
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/warning.js26
19 files changed, 2386 insertions, 0 deletions
diff --git a/app/javascript/flavours/glitch/features/compose/components/autosuggest_account.js b/app/javascript/flavours/glitch/features/compose/components/autosuggest_account.js
new file mode 100644
index 000000000..fb9bb5035
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/components/autosuggest_account.js
@@ -0,0 +1,24 @@
+import React from 'react';
+import Avatar from 'flavours/glitch/components/avatar';
+import DisplayName from 'flavours/glitch/components/display_name';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+export default class AutosuggestAccount extends ImmutablePureComponent {
+
+  static propTypes = {
+    account: ImmutablePropTypes.map.isRequired,
+  };
+
+  render () {
+    const { account } = this.props;
+
+    return (
+      <div className='account small' title={account.get('acct')}>
+        <div className='account__avatar-wrapper'><Avatar account={account} size={24} /></div>
+        <DisplayName account={account} inline />
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/compose/components/character_counter.js b/app/javascript/flavours/glitch/features/compose/components/character_counter.js
new file mode 100644
index 000000000..0ecfc9141
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/components/character_counter.js
@@ -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.js b/app/javascript/flavours/glitch/features/compose/components/compose_form.js
new file mode 100644
index 000000000..6e07998ec
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/components/compose_form.js
@@ -0,0 +1,376 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import ReplyIndicatorContainer from '../containers/reply_indicator_container';
+import AutosuggestTextarea from '../../../components/autosuggest_textarea';
+import AutosuggestInput from '../../../components/autosuggest_input';
+import { defineMessages, injectIntl } from 'react-intl';
+import EmojiPicker from 'flavours/glitch/features/emoji_picker';
+import PollFormContainer from '../containers/poll_form_container';
+import UploadFormContainer from '../containers/upload_form_container';
+import WarningContainer from '../containers/warning_container';
+import { isMobile } from 'flavours/glitch/util/is_mobile';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import { countableText } from 'flavours/glitch/util/counter';
+import OptionsContainer from '../containers/options_container';
+import Publisher from './publisher';
+import TextareaIcons from './textarea_icons';
+import { maxChars } from 'flavours/glitch/util/initial_state';
+import CharacterCounter from './character_counter';
+
+const messages = defineMessages({
+  placeholder: { id: 'compose_form.placeholder', defaultMessage: 'What is on your mind?' },
+  missingDescriptionMessage: {  id: 'confirmations.missing_media_description.message',
+                                defaultMessage: 'At least one media attachment is lacking a description. Consider describing all media attachments for the visually impaired before sending your toot.' },
+  missingDescriptionConfirm: {  id: 'confirmations.missing_media_description.confirm',
+                                defaultMessage: 'Send anyway' },
+  spoiler_placeholder: { id: 'compose_form.spoiler_placeholder', defaultMessage: 'Write your warning here' },
+});
+
+export default @injectIntl
+class ComposeForm extends ImmutablePureComponent {
+
+  static contextTypes = {
+    router: PropTypes.object,
+  };
+
+  static propTypes = {
+    intl: PropTypes.object.isRequired,
+    text: PropTypes.string,
+    suggestions: ImmutablePropTypes.list,
+    spoiler: PropTypes.bool,
+    privacy: PropTypes.string,
+    spoilerText: PropTypes.string,
+    focusDate: PropTypes.instanceOf(Date),
+    caretPosition: PropTypes.number,
+    preselectDate: PropTypes.instanceOf(Date),
+    isSubmitting: PropTypes.bool,
+    isChangingUpload: PropTypes.bool,
+    isUploading: PropTypes.bool,
+    onChange: PropTypes.func,
+    onSubmit: PropTypes.func,
+    onClearSuggestions: PropTypes.func,
+    onFetchSuggestions: PropTypes.func,
+    onSuggestionSelected: PropTypes.func,
+    onChangeSpoilerText: PropTypes.func,
+    onPaste: PropTypes.func,
+    onPickEmoji: PropTypes.func,
+    showSearch: PropTypes.bool,
+    anyMedia: PropTypes.bool,
+    singleColumn: PropTypes.bool,
+
+    advancedOptions: ImmutablePropTypes.map,
+    layout: PropTypes.string,
+    media: ImmutablePropTypes.list,
+    sideArm: PropTypes.string,
+    sensitive: PropTypes.bool,
+    spoilersAlwaysOn: PropTypes.bool,
+    mediaDescriptionConfirmation: PropTypes.bool,
+    preselectOnReply: PropTypes.bool,
+    onChangeSpoilerness: PropTypes.func,
+    onChangeVisibility: PropTypes.func,
+    onPaste: PropTypes.func,
+    onMediaDescriptionConfirm: PropTypes.func,
+  };
+
+  static defaultProps = {
+    showSearch: false,
+  };
+
+  handleChange = (e) => {
+    this.props.onChange(e.target.value);
+  }
+
+  handleKeyDown = ({ ctrlKey, keyCode, metaKey, altKey }) => {
+    //  We submit the status on control/meta + enter.
+    if (keyCode === 13 && (ctrlKey || metaKey)) {
+      this.handleSubmit();
+    }
+
+    // Submit the status with secondary visibility on alt + enter.
+    if (keyCode === 13 && altKey) {
+      this.handleSecondarySubmit();
+    }
+  }
+
+  handleSubmit = () => {
+    const { textarea: { value }, uploadForm } = this;
+    const {
+      onChange,
+      onSubmit,
+      isSubmitting,
+      isChangingUpload,
+      isUploading,
+      media,
+      anyMedia,
+      text,
+      mediaDescriptionConfirmation,
+      onMediaDescriptionConfirm,
+    } = this.props;
+
+    //  If something changes inside the textarea, then we update the
+    //  state before submitting.
+    if (onChange && text !== value) {
+      onChange(value);
+    }
+
+    // Submit disabled:
+    if (isSubmitting || isUploading || isChangingUpload || (!text.trim().length && !anyMedia)) {
+      return;
+    }
+
+    // Submit unless there are media with missing descriptions
+    if (mediaDescriptionConfirmation && onMediaDescriptionConfirm && media && media.some(item => !item.get('description'))) {
+      const firstWithoutDescription = media.find(item => !item.get('description'));
+      onMediaDescriptionConfirm(this.context.router ? this.context.router.history : null, firstWithoutDescription.get('id'));
+    } else if (onSubmit) {
+      onSubmit(this.context.router ? this.context.router.history : null);
+    }
+  }
+
+  //  Changes the text value of the spoiler.
+  handleChangeSpoiler = ({ target: { value } }) => {
+    const { onChangeSpoilerText } = this.props;
+    if (onChangeSpoilerText) {
+      onChangeSpoilerText(value);
+    }
+  }
+
+  setRef = c => {
+    this.composeForm = c;
+  };
+
+  //  Inserts an emoji at the caret.
+  handleEmoji = (data) => {
+    const { textarea: { selectionStart } } = this;
+    const { onPickEmoji } = this.props;
+    if (onPickEmoji) {
+      onPickEmoji(selectionStart, data);
+    }
+  }
+
+  //  Handles the secondary submit button.
+  handleSecondarySubmit = () => {
+    const {
+      onChangeVisibility,
+      sideArm,
+    } = this.props;
+    if (sideArm !== 'none' && onChangeVisibility) {
+      onChangeVisibility(sideArm);
+    }
+    this.handleSubmit();
+  }
+
+  //  Selects a suggestion from the autofill.
+  onSuggestionSelected = (tokenStart, token, value) => {
+    this.props.onSuggestionSelected(tokenStart, token, value, ['text']);
+  }
+
+  onSpoilerSuggestionSelected = (tokenStart, token, value) => {
+    this.props.onSuggestionSelected(tokenStart, token, value, ['spoiler_text']);
+  }
+
+  //  When the escape key is released, we focus the UI.
+  handleKeyUp = ({ key }) => {
+    if (key === 'Escape') {
+      document.querySelector('.ui').parentElement.focus();
+    }
+  }
+
+  //  Sets a reference to the textarea.
+  setAutosuggestTextarea = (textareaComponent) => {
+    if (textareaComponent) {
+      this.textarea = textareaComponent.textarea;
+    }
+  }
+
+  //  Sets a reference to the CW field.
+  handleRefSpoilerText = (spoilerComponent) => {
+    if (spoilerComponent) {
+      this.spoilerText = spoilerComponent.input;
+    }
+  }
+
+  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();
+      }
+    }
+  }
+
+  //  This statement does several things:
+  //  - If we're beginning a reply, and,
+  //      - Replying to zero or one users, places the cursor at the end
+  //        of the textbox.
+  //      - Replying to more than one user, selects any usernames past
+  //        the first; this provides a convenient shortcut to drop
+  //        everyone else from the conversation.
+  componentDidUpdate (prevProps) {
+    const {
+      textarea,
+      spoilerText,
+    } = this;
+    const {
+      focusDate,
+      caretPosition,
+      isSubmitting,
+      preselectDate,
+      text,
+      preselectOnReply,
+      singleColumn,
+    } = this.props;
+    let selectionEnd, selectionStart;
+
+    //  Caret/selection handling.
+    if (focusDate !== prevProps.focusDate) {
+      switch (true) {
+      case preselectDate !== prevProps.preselectDate && preselectOnReply:
+        selectionStart = text.search(/\s/) + 1;
+        selectionEnd = text.length;
+        break;
+      case !isNaN(caretPosition) && caretPosition !== null:
+        selectionStart = selectionEnd = caretPosition;
+        break;
+      default:
+        selectionStart = selectionEnd = text.length;
+      }
+      if (textarea) {
+        textarea.setSelectionRange(selectionStart, selectionEnd);
+        textarea.focus();
+        if (!singleColumn) textarea.scrollIntoView();
+      }
+
+    //  Refocuses the textarea after submitting.
+    } else if (textarea && prevProps.isSubmitting && !isSubmitting) {
+      textarea.focus();
+    } else if (this.props.spoiler !== prevProps.spoiler) {
+      if (this.props.spoiler) {
+        if (spoilerText) {
+          spoilerText.focus();
+        }
+      } else {
+        if (textarea) {
+          textarea.focus();
+        }
+      }
+    }
+  }
+
+
+  render () {
+    const {
+      handleEmoji,
+      handleSecondarySubmit,
+      handleSelect,
+      handleSubmit,
+      handleRefTextarea,
+    } = this;
+    const {
+      advancedOptions,
+      anyMedia,
+      intl,
+      isSubmitting,
+      isChangingUpload,
+      isUploading,
+      layout,
+      media,
+      onChangeSpoilerness,
+      onChangeVisibility,
+      onClearSuggestions,
+      onFetchSuggestions,
+      onPaste,
+      privacy,
+      sensitive,
+      showSearch,
+      sideArm,
+      spoiler,
+      spoilerText,
+      suggestions,
+      text,
+      spoilersAlwaysOn,
+    } = this.props;
+
+    let disabledButton = isSubmitting || isUploading || isChangingUpload || (!text.trim().length && !anyMedia);
+
+    const countText = `${spoilerText}${countableText(text)}${advancedOptions && advancedOptions.get('do_not_federate') ? ' 👁️' : ''}`;
+
+    return (
+      <div className='composer'>
+        <WarningContainer />
+
+        <ReplyIndicatorContainer />
+
+        <div className={`composer--spoiler ${spoiler ? 'composer--spoiler--visible' : ''}`} ref={this.setRef}>
+          <AutosuggestInput
+            placeholder={intl.formatMessage(messages.spoiler_placeholder)}
+            value={spoilerText}
+            onChange={this.handleChangeSpoiler}
+            onKeyDown={this.handleKeyDown}
+            onKeyUp={this.handleKeyUp}
+            disabled={!spoiler}
+            ref={this.handleRefSpoilerText}
+            suggestions={this.props.suggestions}
+            onSuggestionsFetchRequested={onFetchSuggestions}
+            onSuggestionsClearRequested={onClearSuggestions}
+            onSuggestionSelected={this.onSpoilerSuggestionSelected}
+            searchTokens={[':']}
+            id='glitch.composer.spoiler.input'
+            className='spoiler-input__input'
+            autoFocus={false}
+          />
+        </div>
+
+        <AutosuggestTextarea
+          ref={this.setAutosuggestTextarea}
+          placeholder={intl.formatMessage(messages.placeholder)}
+          disabled={isSubmitting}
+          value={this.props.text}
+          onChange={this.handleChange}
+          suggestions={this.props.suggestions}
+          onFocus={this.handleFocus}
+          onKeyDown={this.handleKeyDown}
+          onSuggestionsFetchRequested={onFetchSuggestions}
+          onSuggestionsClearRequested={onClearSuggestions}
+          onSuggestionSelected={this.onSuggestionSelected}
+          onPaste={onPaste}
+          autoFocus={!showSearch && !isMobile(window.innerWidth, layout)}
+        >
+          <EmojiPicker onPickEmoji={handleEmoji} />
+          <TextareaIcons advancedOptions={advancedOptions} />
+          <div className='compose-form__modifiers'>
+            <UploadFormContainer />
+            <PollFormContainer />
+          </div>
+        </AutosuggestTextarea>
+
+        <div className='composer--options-wrapper'>
+          <OptionsContainer
+            advancedOptions={advancedOptions}
+            disabled={isSubmitting}
+            onChangeVisibility={onChangeVisibility}
+            onToggleSpoiler={spoilersAlwaysOn ? null : onChangeSpoilerness}
+            onUpload={onPaste}
+            privacy={privacy}
+            sensitive={sensitive || (spoilersAlwaysOn && spoilerText && spoilerText.length > 0)}
+            spoiler={spoilersAlwaysOn ? (spoilerText && spoilerText.length > 0) : spoiler}
+          />
+          <div className='compose--counter-wrapper'>
+            <CharacterCounter text={countText} max={maxChars} />
+          </div>
+        </div>
+
+        <Publisher
+          countText={countText}
+          disabled={disabledButton}
+          onSecondarySubmit={handleSecondarySubmit}
+          onSubmit={handleSubmit}
+          privacy={privacy}
+          sideArm={sideArm}
+        />
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/compose/components/dropdown.js b/app/javascript/flavours/glitch/features/compose/components/dropdown.js
new file mode 100644
index 000000000..60035b705
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/components/dropdown.js
@@ -0,0 +1,235 @@
+//  Package imports.
+import classNames from 'classnames';
+import PropTypes from 'prop-types';
+import React from 'react';
+import Overlay from 'react-overlays/lib/Overlay';
+
+//  Components.
+import IconButton from 'flavours/glitch/components/icon_button';
+import DropdownMenu from './dropdown_menu';
+
+//  Utils.
+import { isUserTouching } from 'flavours/glitch/util/is_mobile';
+import { assignHandlers } from 'flavours/glitch/util/react_helpers';
+
+//  The component.
+export default class ComposerOptionsDropdown extends React.PureComponent {
+
+  static propTypes = {
+    active: PropTypes.bool,
+    disabled: PropTypes.bool,
+    icon: PropTypes.string,
+    items: PropTypes.arrayOf(PropTypes.shape({
+      icon: PropTypes.string,
+      meta: PropTypes.node,
+      name: PropTypes.string.isRequired,
+      on: PropTypes.bool,
+      text: PropTypes.node,
+    })).isRequired,
+    onModalOpen: PropTypes.func,
+    onModalClose: PropTypes.func,
+    title: PropTypes.string,
+    value: PropTypes.string,
+    onChange: PropTypes.func,
+  };
+
+  state = {
+    needsModalUpdate: false,
+    open: false,
+    openedViaKeyboard: undefined,
+    placement: 'bottom',
+  };
+
+  //  Toggles opening and closing the dropdown.
+  handleToggle = ({ target, type }) => {
+    const { onModalOpen } = this.props;
+    const { open } = this.state;
+
+    if (isUserTouching()) {
+      if (this.state.open) {
+        this.props.onModalClose();
+      } else {
+        const modal = this.handleMakeModal();
+        if (modal && onModalOpen) {
+          onModalOpen(modal);
+        }
+      }
+    } else {
+      const { top } = target.getBoundingClientRect();
+      if (this.state.open && this.activeElement) {
+        this.activeElement.focus();
+      }
+      this.setState({ placement: top * 2 < innerHeight ? 'bottom' : 'top' });
+      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();
+    }
+    this.setState({ open: false });
+  }
+
+  //  Creates an action modal object.
+  handleMakeModal = () => {
+    const component = this;
+    const {
+      items,
+      onChange,
+      onModalOpen,
+      onModalClose,
+      value,
+    } = this.props;
+
+    //  Required props.
+    if (!(onChange && onModalOpen && onModalClose && items)) {
+      return null;
+    }
+
+    //  The object.
+    return {
+      actions: items.map(
+        ({
+          name,
+          ...rest
+        }) => ({
+          ...rest,
+          active: value && name === value,
+          name,
+          onClick (e) {
+            e.preventDefault();  //  Prevents focus from changing
+            onModalClose();
+            onChange(name);
+          },
+          onPassiveClick (e) {
+            e.preventDefault();  //  Prevents focus from changing
+            onChange(name);
+            component.setState({ needsModalUpdate: true });
+          },
+        })
+      ),
+    };
+  }
+
+  //  If our modal is open and our props update, we need to also update
+  //  the modal.
+  handleUpdate = () => {
+    const { onModalOpen } = this.props;
+    const { needsModalUpdate } = this.state;
+
+    //  Gets our modal object.
+    const modal = this.handleMakeModal();
+
+    //  Reopens the modal with the new object.
+    if (needsModalUpdate && modal && onModalOpen) {
+      onModalOpen(modal);
+    }
+  }
+
+  //  Updates our modal as necessary.
+  componentDidUpdate (prevProps) {
+    const { items } = this.props;
+    const { needsModalUpdate } = this.state;
+    if (needsModalUpdate && items.find(
+      (item, i) => item.on !== prevProps.items[i].on
+    )) {
+      this.handleUpdate();
+      this.setState({ needsModalUpdate: false });
+    }
+  }
+
+  //  Rendering.
+  render () {
+    const {
+      active,
+      disabled,
+      title,
+      icon,
+      items,
+      onChange,
+      value,
+    } = this.props;
+    const { open, placement } = this.state;
+    const computedClass = classNames('composer--options--dropdown', {
+      active,
+      open,
+      top: placement === 'top',
+    });
+
+    //  The result.
+    return (
+      <div
+        className={computedClass}
+        onKeyDown={this.handleKeyDown}
+      >
+        <IconButton
+          active={open || active}
+          className='value'
+          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}
+        />
+        <Overlay
+          containerPadding={20}
+          placement={placement}
+          show={open}
+          target={this}
+        >
+          <DropdownMenu
+            items={items}
+            onChange={onChange}
+            onClose={this.handleClose}
+            value={value}
+            openedViaKeyboard={this.state.openedViaKeyboard}
+          />
+        </Overlay>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/compose/components/dropdown_menu.js b/app/javascript/flavours/glitch/features/compose/components/dropdown_menu.js
new file mode 100644
index 000000000..404504e84
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/components/dropdown_menu.js
@@ -0,0 +1,254 @@
+//  Package imports.
+import PropTypes from 'prop-types';
+import React from 'react';
+import spring from 'react-motion/lib/spring';
+import Toggle from 'react-toggle';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import classNames from 'classnames';
+
+//  Components.
+import Icon from 'flavours/glitch/components/icon';
+
+//  Utils.
+import { withPassive } from 'flavours/glitch/util/dom_helpers';
+import Motion from 'flavours/glitch/util/optional_motion';
+import { assignHandlers } from 'flavours/glitch/util/react_helpers';
+
+//  The spring to use with our motion.
+const springMotion = spring(1, {
+  damping: 35,
+  stiffness: 400,
+});
+
+//  The component.
+export default class ComposerOptionsDropdownContent extends React.PureComponent {
+
+  static propTypes = {
+    items: PropTypes.arrayOf(PropTypes.shape({
+      icon: PropTypes.string,
+      meta: PropTypes.node,
+      name: PropTypes.string.isRequired,
+      on: PropTypes.bool,
+      text: PropTypes.node,
+    })),
+    onChange: PropTypes.func.isRequired,
+    onClose: PropTypes.func.isRequired,
+    style: PropTypes.object,
+    value: PropTypes.string,
+    openedViaKeyboard: PropTypes.bool,
+  };
+
+  static defaultProps = {
+    style: {},
+  };
+
+  state = {
+    mounted: false,
+    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`.
+  handleRef = (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();
+    } else {
+      this.node.firstChild.focus();
+    }
+    this.setState({ mounted: true });
+  }
+
+  //  On unmounting, we remove our listeners.
+  componentWillUnmount () {
+    document.removeEventListener('click', this.handleDocumentClick, false);
+    document.removeEventListener('touchend', this.handleDocumentClick, withPassive);
+  }
+
+  handleClick = (name, e) => {
+    const {
+      onChange,
+      onClose,
+      items,
+    } = this.props;
+
+    const { on } = this.props.items.find(item => item.name === name);
+    e.preventDefault();  //  Prevents change in focus on click
+    if ((on === null || typeof on === 'undefined')) {
+      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 = (name, e) => {
+    const { items } = this.props;
+    const index = items.findIndex(item => {
+      return (item.name === name);
+    });
+    let element;
+
+    switch(e.key) {
+    case 'Escape':
+      this.props.onClose();
+      break;
+    case 'Enter':
+    case ' ':
+      this.handleClick(e);
+      break;
+    case 'ArrowDown':
+      element = this.node.childNodes[index + 1];
+      if (element) {
+        element.focus();
+        this.handleChange(element.getAttribute('data-index'));
+      }
+      break;
+    case 'ArrowUp':
+      element = this.node.childNodes[index - 1];
+      if (element) {
+        element.focus();
+        this.handleChange(element.getAttribute('data-index'));
+      }
+      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;
+      }
+      if (element) {
+        element.focus();
+        this.handleChange(element.getAttribute('data-index'));
+        e.preventDefault();
+        e.stopPropagation();
+      }
+      break;
+    case 'Home':
+      element = this.node.firstChild;
+      if (element) {
+        element.focus();
+        this.handleChange(element.getAttribute('data-index'));
+      }
+      break;
+    case 'End':
+      element = this.node.lastChild;
+      if (element) {
+        element.focus();
+        this.handleChange(element.getAttribute('data-index'));
+      }
+      break;
+    }
+  }
+
+  setFocusRef = c => {
+    this.focusedItem = c;
+  }
+
+  renderItem = (item) => {
+    const { name, icon, meta, on, text } = item;
+
+    const active = (name === (this.props.value || this.state.value));
+
+    const computedClass = classNames('composer--options--dropdown--content--item', {
+      active,
+      lengthy: meta,
+      'toggled-off': !on && on !== null && typeof on !== 'undefined',
+      'toggled-on': on,
+      'with-icon': icon,
+    });
+
+    let prefix = null;
+
+    if (on !== null && typeof on !== 'undefined') {
+      prefix = <Toggle checked={on} onChange={this.handleClick.bind(this, name)} />;
+    } else if (icon) {
+      prefix = <Icon className='icon' fixedWidth id={icon} />
+    }
+
+    return (
+      <div
+        className={computedClass}
+        onClick={this.handleClick.bind(this, name)}
+        onKeyDown={this.handleKeyDown.bind(this, name)}
+        role='option'
+        tabIndex='0'
+        key={name}
+        data-index={name}
+        ref={active ? this.setFocusRef : null}
+      >
+        {prefix}
+
+        <div className='content'>
+          <strong>{text}</strong>
+          {meta}
+        </div>
+      </div>
+    );
+  }
+
+  //  Rendering.
+  render () {
+    const { mounted } = this.state;
+    const {
+      items,
+      onChange,
+      onClose,
+      style,
+    } = this.props;
+
+    //  The result.
+    return (
+      <Motion
+        defaultStyle={{
+          opacity: 0,
+          scaleX: 0.85,
+          scaleY: 0.75,
+        }}
+        style={{
+          opacity: springMotion,
+          scaleX: springMotion,
+          scaleY: springMotion,
+        }}
+      >
+        {({ opacity, scaleX, scaleY }) => (
+          // It should not be transformed when mounting because the resulting
+          // size will be used to determine the coordinate of the menu by
+          // react-overlays
+          <div
+            className='composer--options--dropdown--content'
+            ref={this.handleRef}
+            role='listbox'
+            style={{
+              ...style,
+              opacity: opacity,
+              transform: mounted ? `scale(${scaleX}, ${scaleY})` : null,
+            }}
+          >
+            {!!items && items.map(item => this.renderItem(item))}
+          </div>
+        )}
+      </Motion>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/compose/components/header.js b/app/javascript/flavours/glitch/features/compose/components/header.js
new file mode 100644
index 000000000..5b456b717
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/components/header.js
@@ -0,0 +1,134 @@
+//  Package imports.
+import PropTypes from 'prop-types';
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { injectIntl, defineMessages } from 'react-intl';
+import { Link } from 'react-router-dom';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+//  Components.
+import Icon from 'flavours/glitch/components/icon';
+
+//  Utils.
+import { conditionalRender } from 'flavours/glitch/util/react_helpers';
+import { signOutLink } from 'flavours/glitch/util/backend_links';
+
+//  Messages.
+const messages = defineMessages({
+  community: {
+    defaultMessage: 'Local timeline',
+    id: 'navigation_bar.community_timeline',
+  },
+  home_timeline: {
+    defaultMessage: 'Home',
+    id: 'tabs_bar.home',
+  },
+  logout: {
+    defaultMessage: 'Logout',
+    id: 'navigation_bar.logout',
+  },
+  notifications: {
+    defaultMessage: 'Notifications',
+    id: 'tabs_bar.notifications',
+  },
+  public: {
+    defaultMessage: 'Federated timeline',
+    id: 'navigation_bar.public_timeline',
+  },
+  settings: {
+    defaultMessage: 'App settings',
+    id: 'navigation_bar.app_settings',
+  },
+  start: {
+    defaultMessage: 'Getting started',
+    id: 'getting_started.heading',
+  },
+});
+
+export default @injectIntl
+class Header extends ImmutablePureComponent {
+  static propTypes = {
+    columns: ImmutablePropTypes.list,
+    unreadNotifications: PropTypes.number,
+    showNotificationsBadge: PropTypes.bool,
+    intl: PropTypes.object,
+    onSettingsClick: PropTypes.func,
+    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='/timelines/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='/timelines/public/local'
+          ><Icon id='users' /></Link>
+        ))}
+        {renderForColumn('PUBLIC', (
+          <Link
+            aria-label={intl.formatMessage(messages.public)}
+            title={intl.formatMessage(messages.public)}
+            to='/timelines/public'
+          ><Icon id='globe' /></Link>
+        ))}
+        <a
+          aria-label={intl.formatMessage(messages.settings)}
+          onClick={onSettingsClick}
+          href='#'
+          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/navigation_bar.js b/app/javascript/flavours/glitch/features/compose/components/navigation_bar.js
new file mode 100644
index 000000000..f6bfbdd1e
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/components/navigation_bar.js
@@ -0,0 +1,38 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import Avatar from 'flavours/glitch/components/avatar';
+import Permalink from 'flavours/glitch/components/permalink';
+import { FormattedMessage } from 'react-intl';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import { profileLink } from 'flavours/glitch/util/backend_links';
+
+export default class NavigationBar extends ImmutablePureComponent {
+
+  static propTypes = {
+    account: ImmutablePropTypes.map.isRequired,
+  };
+
+  render () {
+    return (
+      <div className='drawer--account'>
+        <Permalink className='avatar' href={this.props.account.get('url')} to={`/accounts/${this.props.account.get('id')}`}>
+          <span style={{ display: 'none' }}>{this.props.account.get('acct')}</span>
+          <Avatar account={this.props.account} size={48} />
+        </Permalink>
+
+        <div className='navigation-bar__profile'>
+          <Permalink className='acct' href={this.props.account.get('url')} to={`/accounts/${this.props.account.get('id')}`}>
+            <strong>@{this.props.account.get('acct')}</strong>
+          </Permalink>
+
+          { profileLink !== undefined && (
+            <a
+              className='edit'
+              href={ profileLink }
+            ><FormattedMessage id='navigation_bar.edit_profile' defaultMessage='Edit profile' /></a>
+          )}
+        </div>
+      </div>
+    );
+  };
+}
diff --git a/app/javascript/flavours/glitch/features/compose/components/options.js b/app/javascript/flavours/glitch/features/compose/components/options.js
new file mode 100644
index 000000000..92348b000
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/components/options.js
@@ -0,0 +1,367 @@
+//  Package imports.
+import PropTypes from 'prop-types';
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
+import spring from 'react-motion/lib/spring';
+
+//  Components.
+import IconButton from 'flavours/glitch/components/icon_button';
+import TextIconButton from './text_icon_button';
+import Dropdown from './dropdown';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+//  Utils.
+import Motion from 'flavours/glitch/util/optional_motion';
+import { pollLimits } from 'flavours/glitch/util/initial_state';
+
+//  Messages.
+const messages = defineMessages({
+  advanced_options_icon_title: {
+    defaultMessage: 'Advanced options',
+    id: 'advanced_options.icon_title',
+  },
+  attach: {
+    defaultMessage: 'Attach...',
+    id: 'compose.attach',
+  },
+  change_privacy: {
+    defaultMessage: 'Adjust status privacy',
+    id: 'privacy.change',
+  },
+  content_type: {
+    defaultMessage: 'Content type',
+    id: 'content-type.change',
+  },
+  direct_long: {
+    defaultMessage: 'Post to mentioned users only',
+    id: 'privacy.direct.long',
+  },
+  direct_short: {
+    defaultMessage: 'Direct',
+    id: 'privacy.direct.short',
+  },
+  doodle: {
+    defaultMessage: 'Draw something',
+    id: 'compose.attach.doodle',
+  },
+  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',
+  },
+  private_long: {
+    defaultMessage: 'Post to followers only',
+    id: 'privacy.private.long',
+  },
+  private_short: {
+    defaultMessage: 'Followers-only',
+    id: 'privacy.private.short',
+  },
+  public_long: {
+    defaultMessage: 'Post to public timelines',
+    id: 'privacy.public.long',
+  },
+  public_short: {
+    defaultMessage: 'Public',
+    id: 'privacy.public.short',
+  },
+  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',
+  },
+  unlisted_long: {
+    defaultMessage: 'Do not show in public timelines',
+    id: 'privacy.unlisted.long',
+  },
+  unlisted_short: {
+    defaultMessage: 'Unlisted',
+    id: 'privacy.unlisted.short',
+  },
+  upload: {
+    defaultMessage: 'Upload a file',
+    id: 'compose.attach.upload',
+  },
+  add_poll: {
+    defaultMessage: 'Add a poll',
+    id: 'poll_button.add_poll',
+  },
+  remove_poll: {
+    defaultMessage: 'Remove poll',
+    id: 'poll_button.remove_poll',
+  },
+});
+
+export default @injectIntl
+class ComposerOptions extends ImmutablePureComponent {
+
+  static propTypes = {
+    acceptContentTypes: PropTypes.string,
+    advancedOptions: ImmutablePropTypes.map,
+    disabled: PropTypes.bool,
+    allowMedia: PropTypes.bool,
+    hasMedia: PropTypes.bool,
+    allowPoll: PropTypes.bool,
+    hasPoll: PropTypes.bool,
+    intl: PropTypes.object.isRequired,
+    onChangeAdvancedOption: PropTypes.func,
+    onChangeVisibility: PropTypes.func,
+    onChangeContentType: PropTypes.func,
+    onTogglePoll: PropTypes.func,
+    onDoodleOpen: PropTypes.func,
+    onModalClose: PropTypes.func,
+    onModalOpen: PropTypes.func,
+    onToggleSpoiler: PropTypes.func,
+    onUpload: PropTypes.func,
+    privacy: PropTypes.string,
+    contentType: PropTypes.string,
+    resetFileKey: PropTypes.number,
+    spoiler: PropTypes.bool,
+    showContentTypeChoice: 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;
+  }
+
+  //  Rendering.
+  render () {
+    const {
+      acceptContentTypes,
+      advancedOptions,
+      contentType,
+      disabled,
+      allowMedia,
+      hasMedia,
+      allowPoll,
+      hasPoll,
+      intl,
+      onChangeAdvancedOption,
+      onChangeContentType,
+      onChangeVisibility,
+      onTogglePoll,
+      onModalClose,
+      onModalOpen,
+      onToggleSpoiler,
+      privacy,
+      resetFileKey,
+      spoiler,
+      showContentTypeChoice,
+    } = this.props;
+
+    //  We predefine our privacy items so that we can easily pick the
+    //  dropdown icon later.
+    const privacyItems = {
+      direct: {
+        icon: 'envelope',
+        meta: <FormattedMessage {...messages.direct_long} />,
+        name: 'direct',
+        text: <FormattedMessage {...messages.direct_short} />,
+      },
+      private: {
+        icon: 'lock',
+        meta: <FormattedMessage {...messages.private_long} />,
+        name: 'private',
+        text: <FormattedMessage {...messages.private_short} />,
+      },
+      public: {
+        icon: 'globe',
+        meta: <FormattedMessage {...messages.public_long} />,
+        name: 'public',
+        text: <FormattedMessage {...messages.public_short} />,
+      },
+      unlisted: {
+        icon: 'unlock',
+        meta: <FormattedMessage {...messages.unlisted_long} />,
+        name: 'unlisted',
+        text: <FormattedMessage {...messages.unlisted_short} />,
+      },
+    };
+
+    const contentTypeItems = {
+      plain: {
+        icon: 'file-text',
+        name: 'text/plain',
+        text: <FormattedMessage {...messages.plain} />,
+      },
+      html: {
+        icon: 'code',
+        name: 'text/html',
+        text: <FormattedMessage {...messages.html} />,
+      },
+      markdown: {
+        icon: 'arrow-circle-down',
+        name: 'text/markdown',
+        text: <FormattedMessage {...messages.markdown} />,
+      },
+    };
+
+    //  The result.
+    return (
+      <div className='composer--options'>
+        <input
+          accept={acceptContentTypes}
+          disabled={disabled || !allowMedia}
+          key={resetFileKey}
+          onChange={this.handleChangeFiles}
+          ref={this.handleRefFileElement}
+          type='file'
+          multiple
+          style={{ display: 'none' }}
+        />
+        <Dropdown
+          disabled={disabled || !allowMedia}
+          icon='paperclip'
+          items={[
+            {
+              icon: 'cloud-upload',
+              name: 'upload',
+              text: <FormattedMessage {...messages.upload} />,
+            },
+            {
+              icon: 'paint-brush',
+              name: 'doodle',
+              text: <FormattedMessage {...messages.doodle} />,
+            },
+          ]}
+          onChange={this.handleClickAttach}
+          onModalClose={onModalClose}
+          onModalOpen={onModalOpen}
+          title={intl.formatMessage(messages.attach)}
+        />
+        {!!pollLimits && (
+          <IconButton
+            active={hasPoll}
+            disabled={disabled || !allowPoll}
+            icon='tasks'
+            inverted
+            onClick={onTogglePoll}
+            size={18}
+            style={{
+              height: null,
+              lineHeight: null,
+            }}
+            title={intl.formatMessage(hasPoll ? messages.remove_poll : messages.add_poll)}
+          />
+        )}
+        <hr />
+        <Dropdown
+          disabled={disabled}
+          icon={(privacyItems[privacy] || {}).icon}
+          items={[
+            privacyItems.public,
+            privacyItems.unlisted,
+            privacyItems.private,
+            privacyItems.direct,
+          ]}
+          onChange={onChangeVisibility}
+          onModalClose={onModalClose}
+          onModalOpen={onModalOpen}
+          title={intl.formatMessage(messages.change_privacy)}
+          value={privacy}
+        />
+        {showContentTypeChoice && (
+          <Dropdown
+            disabled={disabled}
+            icon={(contentTypeItems[contentType.split('/')[1]] || {}).icon}
+            items={[
+              contentTypeItems.plain,
+              contentTypeItems.html,
+              contentTypeItems.markdown,
+            ]}
+            onChange={onChangeContentType}
+            onModalClose={onModalClose}
+            onModalOpen={onModalOpen}
+            title={intl.formatMessage(messages.content_type)}
+            value={contentType}
+          />
+        )}
+        {onToggleSpoiler && (
+          <TextIconButton
+            active={spoiler}
+            ariaControls='glitch.composer.spoiler.input'
+            label='CW'
+            onClick={onToggleSpoiler}
+            title={intl.formatMessage(messages.spoiler)}
+          />
+        )}
+        <Dropdown
+          active={advancedOptions && advancedOptions.some(value => !!value)}
+          disabled={disabled}
+          icon='ellipsis-h'
+          items={advancedOptions ? [
+            {
+              meta: <FormattedMessage {...messages.local_only_long} />,
+              name: 'do_not_federate',
+              on: advancedOptions.get('do_not_federate'),
+              text: <FormattedMessage {...messages.local_only_short} />,
+            },
+            {
+              meta: <FormattedMessage {...messages.threaded_mode_long} />,
+              name: 'threaded_mode',
+              on: advancedOptions.get('threaded_mode'),
+              text: <FormattedMessage {...messages.threaded_mode_short} />,
+            },
+          ] : null}
+          onChange={onChangeAdvancedOption}
+          onModalClose={onModalClose}
+          onModalOpen={onModalOpen}
+          title={intl.formatMessage(messages.advanced_options_icon_title)}
+        />
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/compose/components/poll_form.js b/app/javascript/flavours/glitch/features/compose/components/poll_form.js
new file mode 100644
index 000000000..3d818ea20
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/components/poll_form.js
@@ -0,0 +1,160 @@
+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/util/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,
+    index: PropTypes.number.isRequired,
+    isPollMultiple: PropTypes.bool,
+    onChange: PropTypes.func.isRequired,
+    onRemove: PropTypes.func.isRequired,
+    suggestions: ImmutablePropTypes.list,
+    onClearSuggestions: PropTypes.func.isRequired,
+    onFetchSuggestions: PropTypes.func.isRequired,
+    onSuggestionSelected: PropTypes.func.isRequired,
+    intl: PropTypes.object.isRequired,
+  };
+
+  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, index, intl } = this.props;
+
+    return (
+      <li>
+        <label className='poll__text 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}
+            onChange={this.handleOptionTitleChange}
+            suggestions={this.props.suggestions}
+            onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
+            onSuggestionsClearRequested={this.onSuggestionsClearRequested}
+            onSuggestionSelected={this.onSuggestionSelected}
+            searchTokens={[':']}
+          />
+        </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,
+    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, expiresIn, isMultiple, onChangeOption, onRemoveOption, intl, ...other } = this.props;
+
+    if (!options) {
+      return null;
+    }
+
+    return (
+      <div className='compose-form__poll-wrapper'>
+        <ul>
+          {options.map((title, i) => <Option title={title} key={i} index={i} onChange={onChangeOption} onRemove={onRemoveOption} isPollMultiple={isMultiple} {...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>
+
+          <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={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/publisher.js b/app/javascript/flavours/glitch/features/compose/components/publisher.js
new file mode 100644
index 000000000..b8d9d98bf
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/components/publisher.js
@@ -0,0 +1,114 @@
+//  Package imports.
+import classNames from 'classnames';
+import PropTypes from 'prop-types';
+import React from 'react';
+import { defineMessages, FormattedMessage, injectIntl } from 'react-intl';
+import { length } from 'stringz';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+//  Components.
+import Button from 'flavours/glitch/components/button';
+import Icon from 'flavours/glitch/components/icon';
+
+//  Utils.
+import { maxChars } from 'flavours/glitch/util/initial_state';
+
+//  Messages.
+const messages = defineMessages({
+  publish: {
+    defaultMessage: 'Toot',
+    id: 'compose_form.publish',
+  },
+  publishLoud: {
+    defaultMessage: '{publish}!',
+    id: 'compose_form.publish_loud',
+  },
+});
+
+export default @injectIntl
+class Publisher extends ImmutablePureComponent {
+
+  static propTypes = {
+    countText: PropTypes.string,
+    disabled: PropTypes.bool,
+    intl: PropTypes.object.isRequired,
+    onSecondarySubmit: PropTypes.func,
+    onSubmit: PropTypes.func,
+    privacy: PropTypes.oneOf(['direct', 'private', 'unlisted', 'public']),
+    sideArm: PropTypes.oneOf(['none', 'direct', 'private', 'unlisted', 'public']),
+  };
+
+  render () {
+    const { countText, disabled, intl, onSecondarySubmit, onSubmit, privacy, sideArm } = this.props;
+
+    const diff = maxChars - length(countText || '');
+    const computedClass = classNames('composer--publisher', {
+      disabled: disabled || diff < 0,
+      over: diff < 0,
+    });
+
+    return (
+      <div className={computedClass}>
+        {sideArm && sideArm !== 'none' ? (
+          <Button
+            className='side_arm'
+            disabled={disabled || diff < 0}
+            onClick={onSecondarySubmit}
+            style={{ padding: null }}
+            text={
+              <span>
+                <Icon
+                  id={{
+                    public: 'globe',
+                    unlisted: 'unlock',
+                    private: 'lock',
+                    direct: 'envelope',
+                  }[sideArm]}
+                />
+              </span>
+            }
+            title={`${intl.formatMessage(messages.publish)}: ${intl.formatMessage({ id: `privacy.${sideArm}.short` })}`}
+          />
+        ) : null}
+        <Button
+          className='primary'
+          text={function () {
+            switch (true) {
+            case !!sideArm && sideArm !== 'none':
+            case privacy === 'direct':
+            case privacy === 'private':
+              return (
+                <span>
+                  <Icon
+                    id={{
+                      direct: 'envelope',
+                      private: 'lock',
+                      public: 'globe',
+                      unlisted: 'unlock',
+                    }[privacy]}
+                  />
+                  {' '}
+                  <FormattedMessage {...messages.publish} />
+                </span>
+              );
+            case privacy === 'public':
+              return (
+                <span>
+                  <FormattedMessage
+                    {...messages.publishLoud}
+                    values={{ publish: <FormattedMessage {...messages.publish} /> }}
+                  />
+                </span>
+              );
+            default:
+              return <span><FormattedMessage {...messages.publish} /></span>;
+            }
+          }()}
+          title={`${intl.formatMessage(messages.publish)}: ${intl.formatMessage({ id: `privacy.${privacy}.short` })}`}
+          onClick={onSubmit}
+          disabled={disabled || diff < 0}
+        />
+      </div>
+    );
+  };
+}
diff --git a/app/javascript/flavours/glitch/features/compose/components/reply_indicator.js b/app/javascript/flavours/glitch/features/compose/components/reply_indicator.js
new file mode 100644
index 000000000..9d5b65a40
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/components/reply_indicator.js
@@ -0,0 +1,86 @@
+//  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';
+
+//  Utils.
+import { isRtl } from 'flavours/glitch/util/rtl';
+
+//  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='composer--reply'>
+        <header>
+          <IconButton
+            className='cancel'
+            icon='times'
+            onClick={this.handleClick}
+            title={intl.formatMessage(messages.cancel)}
+            inverted
+          />
+          {account && (
+            <AccountContainer
+              id={account}
+              small
+            />
+          )}
+        </header>
+        <div
+          className='content'
+          dangerouslySetInnerHTML={{ __html: content || '' }}
+          style={{ direction: isRtl(content) ? 'rtl' : 'ltr' }}
+        />
+        {attachments.size > 0 && (
+          <AttachmentList
+            compact
+            media={attachments}
+          />
+        )}
+      </article>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/compose/components/search.js b/app/javascript/flavours/glitch/features/compose/components/search.js
new file mode 100644
index 000000000..12d839637
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/components/search.js
@@ -0,0 +1,177 @@
+//  Package imports.
+import classNames from 'classnames';
+import PropTypes from 'prop-types';
+import React from 'react';
+import { connect } from 'react-redux';
+import spring from 'react-motion/lib/spring';
+import {
+  injectIntl,
+  FormattedMessage,
+  defineMessages,
+} from 'react-intl';
+import Overlay from 'react-overlays/lib/Overlay';
+
+//  Components.
+import Icon from 'flavours/glitch/components/icon';
+
+//  Utils.
+import { focusRoot } from 'flavours/glitch/util/dom_helpers';
+import { searchEnabled } from 'flavours/glitch/util/initial_state';
+import Motion from 'flavours/glitch/util/optional_motion';
+
+const messages = defineMessages({
+  placeholder: { id: 'search.placeholder', defaultMessage: 'Search' },
+});
+
+class SearchPopout extends React.PureComponent {
+
+  static propTypes = {
+    style: PropTypes.object,
+  };
+
+  render () {
+    const { style } = this.props;
+    const extraInformation = searchEnabled ? <FormattedMessage id='search_popout.tips.full_text' defaultMessage='Simple text returns statuses you have written, favourited, boosted, or have been mentioned in, as well as matching usernames, display names, and hashtags.' /> : <FormattedMessage id='search_popout.tips.text' defaultMessage='Simple text returns matching display names, usernames and hashtags' />;
+    return (
+      <div style={{ ...style, position: 'absolute', width: 285, zIndex: 2 }}>
+        <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='search-popout' style={{ opacity: opacity, transform: `scale(${scaleX}, ${scaleY})` }}>
+              <h4><FormattedMessage id='search_popout.search_format' defaultMessage='Advanced search format' /></h4>
+
+              <ul>
+                <li><em>#example</em> <FormattedMessage id='search_popout.tips.hashtag' defaultMessage='hashtag' /></li>
+                <li><em>@username@domain</em> <FormattedMessage id='search_popout.tips.user' defaultMessage='user' /></li>
+                <li><em>URL</em> <FormattedMessage id='search_popout.tips.user' defaultMessage='user' /></li>
+                <li><em>URL</em> <FormattedMessage id='search_popout.tips.status' defaultMessage='status' /></li>
+              </ul>
+
+              {extraInformation}
+            </div>
+          )}
+        </Motion>
+      </div>
+    );
+  }
+
+}
+
+//  The component.
+export default @injectIntl
+class Search extends React.PureComponent {
+
+  static contextTypes = {
+    router: 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();
+    }
+  }
+
+  render () {
+    const { intl, value, submitted } = this.props;
+    const { expanded } = this.state;
+    const hasValue = value.length > 0 || submitted;
+
+    return (
+      <div className='search'>
+        <label>
+          <span style={{ display: 'none' }}>{intl.formatMessage(messages.placeholder)}</span>
+          <input
+            ref={this.setRef}
+            className='search__input'
+            type='text'
+            placeholder={intl.formatMessage(messages.placeholder)}
+            value={value || ''}
+            onChange={this.handleChange}
+            onKeyUp={this.handleKeyUp}
+            onFocus={this.handleFocus}
+            onBlur={this.handleBlur}
+          />
+        </label>
+
+        <div
+          aria-label={intl.formatMessage(messages.placeholder)}
+          className='search__icon'
+          onClick={this.handleClear}
+          role='button'
+          tabIndex='0'
+        >
+          <Icon id='search' className={hasValue ? '' : 'active'} />
+          <Icon id='times-circle' className={hasValue ? 'active' : ''} />
+        </div>
+
+        <Overlay show={expanded && !hasValue} placement='bottom' target={this}>
+          <SearchPopout />
+        </Overlay>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/compose/components/search_results.js b/app/javascript/flavours/glitch/features/compose/components/search_results.js
new file mode 100644
index 000000000..fa3487328
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/components/search_results.js
@@ -0,0 +1,134 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
+import AccountContainer from 'flavours/glitch/containers/account_container';
+import StatusContainer from 'flavours/glitch/containers/status_container';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import Hashtag from 'flavours/glitch/components/hashtag';
+import Icon from 'flavours/glitch/components/icon';
+import { searchEnabled } from 'flavours/glitch/util/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();
+    }
+  }
+
+  handleLoadMoreAccounts = () => this.props.expandSearch('accounts');
+
+  handleLoadMoreStatuses = () => this.props.expandSearch('statuses');
+
+  handleLoadMoreHashtags = () => this.props.expandSearch('hashtags');
+
+  render () {
+    const { intl, results, suggestions, dismissSuggestion, searchTerm } = this.props;
+
+    if (results.isEmpty() && !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(accountId => (
+              <AccountContainer
+                key={accountId}
+                id={accountId}
+                actionIcon='times'
+                actionTitle={intl.formatMessage(messages.dismissSuggestion)}
+                onActionClick={dismissSuggestion}
+              />
+            ))}
+          </div>
+        </div>
+      );
+    } else if(results.get('statuses') && results.get('statuses').size === 0 && !searchEnabled && !(searchTerm.startsWith('@') || searchTerm.startsWith('#') || searchTerm.includes(' '))) {
+      statuses = (
+        <section>
+          <h5><Icon id='quote-right' fixedWidth /><FormattedMessage id='search_results.statuses' defaultMessage='Toots' /></h5>
+
+          <div className='search-results__info'>
+            <FormattedMessage id='search_results.statuses_fts_disabled' defaultMessage='Searching toots by their content is not enabled on this Mastodon server.' />
+          </div>
+        </section>
+      );
+    }
+
+    let accounts, statuses, hashtags;
+    let count = 0;
+
+    if (results.get('accounts') && results.get('accounts').size > 0) {
+      count   += results.get('accounts').size;
+      accounts = (
+        <section>
+          <h5><Icon 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>
+          <h5><Icon id='quote-right' fixedWidth /><FormattedMessage id='search_results.statuses' defaultMessage='Toots' /></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>
+          <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.js b/app/javascript/flavours/glitch/features/compose/components/text_icon_button.js
new file mode 100644
index 000000000..7f2005060
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/components/text_icon_button.js
@@ -0,0 +1,43 @@
+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,
+  };
+
+  handleClick = (e) => {
+    e.preventDefault();
+    this.props.onClick();
+  }
+
+  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.handleClick}
+        aria-controls={ariaControls}
+        style={iconStyle}
+      >
+        {label}
+      </button>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/compose/components/textarea_icons.js b/app/javascript/flavours/glitch/features/compose/components/textarea_icons.js
new file mode 100644
index 000000000..b875fb15e
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/components/textarea_icons.js
@@ -0,0 +1,59 @@
+//  Package imports.
+import PropTypes from 'prop-types';
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { defineMessages, injectIntl } from 'react-intl';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+//  Components.
+import Icon from 'flavours/glitch/components/icon';
+
+//  Messages.
+const messages = defineMessages({
+  localOnly: {
+    defaultMessage: 'This post is local-only',
+    id: 'advanced_options.local-only.tooltip',
+  },
+  threadedMode: {
+    defaultMessage: 'Threaded mode enabled',
+    id: 'advanced_options.threaded_mode.tooltip',
+  },
+});
+
+//  We use an array of tuples here instead of an object because it
+//  preserves order.
+const iconMap = [
+  ['do_not_federate', 'home', messages.localOnly],
+  ['threaded_mode', 'comments', messages.threadedMode],
+];
+
+export default @injectIntl
+class TextareaIcons extends ImmutablePureComponent {
+
+  static propTypes = {
+    advancedOptions: ImmutablePropTypes.map,
+    intl: PropTypes.object.isRequired,
+  };
+
+  render () {
+    const { advancedOptions, intl } = this.props;
+    return (
+      <div className='composer--textarea--icons'>
+        {advancedOptions ? iconMap.map(
+          ([key, icon, message]) => advancedOptions.get(key) ? (
+            <span
+              className='textarea_icon'
+              key={key}
+              title={intl.formatMessage(message)}
+            >
+              <Icon
+                fixedWidth
+                id={icon}
+              />
+            </span>
+          ) : null
+        ) : null}
+      </div>
+    );
+  }
+}
diff --git a/app/javascript/flavours/glitch/features/compose/components/upload.js b/app/javascript/flavours/glitch/features/compose/components/upload.js
new file mode 100644
index 000000000..425b0fe5e
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/components/upload.js
@@ -0,0 +1,57 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import Motion from 'flavours/glitch/util/optional_motion';
+import spring from 'react-motion/lib/spring';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import { FormattedMessage } from 'react-intl';
+import classNames from 'classnames';
+import Icon from 'flavours/glitch/components/icon';
+import { isUserTouching } from 'flavours/glitch/util/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 { intl, media } = this.props;
+    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='composer--upload_form--item' tabIndex='0' role='button'>
+        <Motion defaultStyle={{ scale: 0.8 }} style={{ scale: spring(1, { stiffness: 180, damping: 12, }) }}>
+          {({ scale }) => (
+            <div style={{ transform: `scale(${scale})`, backgroundImage: `url(${media.get('preview_url')})`, backgroundPosition: `${x}% ${y}%` }}>
+              <div className={classNames('composer--upload_form--actions', { active: true })}>
+                <button className='icon-button' onClick={this.handleUndoClick}><Icon id='times' /> <FormattedMessage id='upload_form.undo' defaultMessage='Delete' /></button>
+                <button className='icon-button' onClick={this.handleFocalPointClick}><Icon id='pencil' /> <FormattedMessage id='upload_form.edit' defaultMessage='Edit' /></button>
+              </div>
+            </div>
+          )}
+        </Motion>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/compose/components/upload_form.js b/app/javascript/flavours/glitch/features/compose/components/upload_form.js
new file mode 100644
index 000000000..43039c674
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/components/upload_form.js
@@ -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';
+import { FormattedMessage } from 'react-intl';
+
+export default class UploadForm extends ImmutablePureComponent {
+  static propTypes = {
+    mediaIds: ImmutablePropTypes.list.isRequired,
+  };
+
+  render () {
+    const { mediaIds } = this.props;
+
+    return (
+      <div className='composer--upload_form'>
+        <UploadProgressContainer icon='upload' message={<FormattedMessage id='upload_progress.label' defaultMessage='Uploading…' />} />
+
+        {mediaIds.size > 0 && (
+          <div className='content'>
+            {mediaIds.map(id => (
+              <UploadContainer id={id} key={id} />
+            ))}
+          </div>
+        )}
+
+        {!mediaIds.isEmpty() && <SensitiveButtonContainer />}
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/compose/components/upload_progress.js b/app/javascript/flavours/glitch/features/compose/components/upload_progress.js
new file mode 100644
index 000000000..493bb9ca5
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/components/upload_progress.js
@@ -0,0 +1,43 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import Motion from 'flavours/glitch/util/optional_motion';
+import spring from 'react-motion/lib/spring';
+import Icon from 'flavours/glitch/components/icon';
+
+export default class UploadProgress extends React.PureComponent {
+
+  static propTypes = {
+    active: PropTypes.bool,
+    progress: PropTypes.number,
+    icon: PropTypes.string.isRequired,
+    message: PropTypes.node.isRequired,
+  };
+
+  render () {
+    const { active, progress, icon, message } = this.props;
+
+    if (!active) {
+      return null;
+    }
+
+    return (
+      <div className='composer--upload_form--progress'>
+        <Icon id={icon} />
+
+        <div className='message'>
+          {message}
+
+          <div className='backdrop'>
+            <Motion defaultStyle={{ width: 0 }} style={{ width: spring(progress) }}>
+              {({ width }) =>
+                (<div className='tracker' style={{ width: `${width}%` }}
+                />)
+              }
+            </Motion>
+          </div>
+        </div>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/compose/components/warning.js b/app/javascript/flavours/glitch/features/compose/components/warning.js
new file mode 100644
index 000000000..6ee3640bc
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/components/warning.js
@@ -0,0 +1,26 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import Motion from 'flavours/glitch/util/optional_motion';
+import spring from 'react-motion/lib/spring';
+
+export default class Warning extends React.PureComponent {
+
+  static propTypes = {
+    message: PropTypes.node.isRequired,
+  };
+
+  render () {
+    const { message } = this.props;
+
+    return (
+      <Motion defaultStyle={{ opacity: 0, scaleX: 0.85, scaleY: 0.75 }} style={{ opacity: spring(1, { damping: 35, stiffness: 400 }), scaleX: spring(1, { damping: 35, stiffness: 400 }), scaleY: spring(1, { damping: 35, stiffness: 400 }) }}>
+        {({ opacity, scaleX, scaleY }) => (
+          <div className='composer--warning' style={{ opacity: opacity, transform: `scale(${scaleX}, ${scaleY})` }}>
+            {message}
+          </div>
+        )}
+      </Motion>
+    );
+  }
+
+}