about summary refs log tree commit diff
path: root/app/javascript/flavours/glitch/features/composer
diff options
context:
space:
mode:
authorkibigo! <marrus-sh@users.noreply.github.com>2017-12-23 22:16:45 -0800
committerkibigo! <marrus-sh@users.noreply.github.com>2018-01-04 18:21:59 -0800
commit924ffe81d477a8cf890c8117efb94b908760bccc (patch)
treeacefef7362929f4495424fbb037c3be59cca318f /app/javascript/flavours/glitch/features/composer
parentfc884d015a1a2d6c31976af3d63039390fa15939 (diff)
WIPgit status <Compose> Refactor; <Composer> ed.
Diffstat (limited to 'app/javascript/flavours/glitch/features/composer')
-rw-r--r--app/javascript/flavours/glitch/features/composer/index.js440
-rw-r--r--app/javascript/flavours/glitch/features/composer/options/dropdown/index.js243
-rw-r--r--app/javascript/flavours/glitch/features/composer/options/dropdown/item/index.js126
-rw-r--r--app/javascript/flavours/glitch/features/composer/options/index.js321
-rw-r--r--app/javascript/flavours/glitch/features/composer/publisher/index.js119
-rw-r--r--app/javascript/flavours/glitch/features/composer/reply/index.js106
-rw-r--r--app/javascript/flavours/glitch/features/composer/spoiler/index.js92
-rw-r--r--app/javascript/flavours/glitch/features/composer/textarea/index.js297
-rw-r--r--app/javascript/flavours/glitch/features/composer/textarea/suggestions/index.js41
-rw-r--r--app/javascript/flavours/glitch/features/composer/textarea/suggestions/item/index.js101
-rw-r--r--app/javascript/flavours/glitch/features/composer/upload_form/index.js54
-rw-r--r--app/javascript/flavours/glitch/features/composer/upload_form/item/index.js176
-rw-r--r--app/javascript/flavours/glitch/features/composer/upload_form/progress/index.js52
-rw-r--r--app/javascript/flavours/glitch/features/composer/warning/index.js54
14 files changed, 2222 insertions, 0 deletions
diff --git a/app/javascript/flavours/glitch/features/composer/index.js b/app/javascript/flavours/glitch/features/composer/index.js
new file mode 100644
index 000000000..25c2622d8
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/composer/index.js
@@ -0,0 +1,440 @@
+//  Package imports.
+import PropTypes from 'prop-types';
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { injectIntl } from 'react-intl';
+import { connect } from 'react-redux';
+import { withRouter } from 'react-router';
+
+//  Actions.
+import {
+  cancelReplyCompose,
+  changeCompose,
+  changeComposeSensitivity,
+  changeComposeSpoilerText,
+  changeComposeSpoilerness,
+  changeComposeVisibility,
+  changeUploadCompose,
+  clearComposeSuggestions,
+  fetchComposeSuggestions,
+  insertEmojiCompose,
+  selectComposeSuggestion,
+  submitCompose,
+  toggleComposeAdvancedOption,
+  undoUploadCompose,
+  uploadCompose,
+} from 'flavours/glitch/actions/compose';
+import {
+  closeModal,
+  openModal,
+} from 'flavours/glitch/actions/modal';
+
+//  Components.
+import ComposerOptions from './options';
+import ComposerPublisher from './publisher';
+import ComposerReply from './reply';
+import ComposerSpoiler from './spoiler';
+import ComposerTextarea from './textarea';
+import ComposerUploadForm from './upload_form';
+import ComposerWarning from './warning';
+
+//  Utils.
+import { countableText } from 'flavours/glitch/util/counter';
+import { me } from 'flavours/glitch/util/initial_state';
+import { isMobile } from 'flavours/glitch/util/is_mobile';
+import { assignHandlers } from 'flavours/glitch/util/react_helpers';
+import { mergeProps } from 'flavours/glitch/util/redux_helpers';
+
+//  State mapping.
+function mapStateToProps (state) {
+  const inReplyTo = state.getIn(['compose', 'in_reply_to']);
+  return {
+    acceptContentTypes: state.getIn(['media_attachments', 'accept_content_types']).toArray().join(','),
+    amUnlocked: !state.getIn(['accounts', me, 'locked']),
+    doNotFederate: state.getIn(['compose', 'advanced_options', 'do_not_federate']),
+    focusDate: state.getIn(['compose', 'focusDate']),
+    isSubmitting: state.getIn(['compose', 'is_submitting']),
+    isUploading: state.getIn(['compose', 'is_uploading']),
+    media: state.getIn(['compose', 'media_attachments']),
+    preselectDate: state.getIn(['compose', 'preselectDate']),
+    privacy: state.getIn(['compose', 'privacy']),
+    progress: state.getIn(['compose', 'progress']),
+    replyAccount: inReplyTo ? state.getIn(['accounts', state.getIn(['statuses', inReplyTo, 'account'])]) : null,
+    replyContent: inReplyTo ? state.getIn(['statuses', inReplyTo, 'contentHtml']) : null,
+    resetFileKey: state.getIn(['compose', 'resetFileKey']),
+    sideArm: state.getIn(['local_settings', 'side_arm']),
+    sensitive: state.getIn(['compose', 'sensitive']),
+    showSearch: state.getIn(['search', 'submitted']) && !state.getIn(['search', 'hidden']),
+    spoiler: state.getIn(['compose', 'spoiler']),
+    spoilerText: state.getIn(['compose', 'spoiler_text']),
+    suggestionToken: state.getIn(['compose', 'suggestion_token']),
+    suggestions: state.getIn(['compose', 'suggestions']),
+    text: state.getIn(['compose', 'text']),
+  };
+};
+
+//  Dispatch mapping.
+const mapDispatchToProps = dispatch => ({
+  cancelReply () {
+    dispatch(cancelReplyCompose());
+  },
+  changeDescription (mediaId, description) {
+    dispatch(changeUploadCompose(mediaId, description));
+  },
+  changeSensitivity () {
+    dispatch(changeComposeSensitivity());
+  },
+  changeSpoilerText (checked) {
+    dispatch(changeComposeSpoilerText(checked));
+  },
+  changeSpoilerness () {
+    dispatch(changeComposeSpoilerness());
+  },
+  changeText (text) {
+    dispatch(changeCompose(text));
+  },
+  changeVisibility (value) {
+    dispatch(changeComposeVisibility(value));
+  },
+  clearSuggestions () {
+    dispatch(clearComposeSuggestions());
+  },
+  closeModal () {
+    dispatch(closeModal());
+  },
+  fetchSuggestions (token) {
+    dispatch(fetchComposeSuggestions(token));
+  },
+  insertEmoji (position, data) {
+    dispatch(insertEmojiCompose(position, data));
+  },
+  openActionsModal (data) {
+    dispatch(openModal('ACTIONS', data));
+  },
+  openDoodleModal () {
+    dispatch(openModal('DOODLE', { noEsc: true }));
+  },
+  selectSuggestion (position, token, accountId) {
+    dispatch(selectComposeSuggestion(position, token, accountId));
+  },
+  submit () {
+    dispatch(submitCompose());
+  },
+  toggleAdvancedOption (option) {
+    dispatch(toggleComposeAdvancedOption(option));
+  },
+  undoUpload (mediaId) {
+    dispatch(undoUploadCompose(mediaId));
+  },
+  upload (files) {
+    dispatch(uploadCompose(files));
+  },
+});
+
+//  Handlers.
+const handlers = {
+
+  //  Changes the text value of the spoiler.
+  changeSpoiler ({ target: { value } }) {
+    const { dispatch: { changeSpoilerText } } = this.props;
+    if (changeSpoilerText) {
+      changeSpoilerText(value);
+    }
+  },
+
+  //  Inserts an emoji at the caret.
+  emoji (data) {
+    const { textarea: { selectionStart } } = this;
+    const { dispatch: { insertEmoji } } = this.props;
+    this.caretPos = selectionStart + data.native.length + 1;
+    if (insertEmoji) {
+      insertEmoji(selectionStart, data);
+    }
+  },
+
+  //  Handles the secondary submit button.
+  secondarySubmit () {
+    const { submit } = this.handlers;
+    const {
+      dispatch: { changeVisibility },
+      side_arm,
+    } = this.props;
+    if (changeVisibility) {
+      changeVisibility(side_arm);
+    }
+    submit();
+  },
+
+  //  Selects a suggestion from the autofill.
+  select (tokenStart, token, value) {
+    const { dispatch: { selectSuggestion } } = this.props;
+    this.caretPos = null;
+    if (selectSuggestion) {
+      selectSuggestion(tokenStart, token, value);
+    }
+  },
+
+  //  Submits the status.
+  submit () {
+    const { textarea: { value } } = this;
+    const {
+      dispatch: {
+        changeText,
+        submit,
+      },
+      state: { text },
+    } = this.props;
+
+    //  If something changes inside the textarea, then we update the
+    //  state before submitting.
+    if (changeText && text !== value) {
+      changeText(value);
+    }
+
+    //  Submits the status.
+    if (submit) {
+      submit();
+    }
+  },
+
+  //  Sets a reference to the textarea.
+  refTextarea ({ textarea }) {
+    this.textarea = textarea;
+  },
+};
+
+//  The component.
+@injectIntl
+@connect(mapStateToProps, mapDispatchToProps, mergeProps)
+export default class Composer extends React.Component {
+
+  //  Constructor.
+  constructor (props) {
+    super(props);
+    assignHandlers(this, handlers);
+
+    //  Instance variables.
+    this.caretPos = null;
+    this.textarea = null;
+  }
+
+  //  If this is the update where we've finished uploading,
+  //  save the last caret position so we can restore it below!
+  componentWillReceiveProps (nextProps) {
+    const { textarea: { selectionStart } } = this;
+    const { state: { isUploading } } = this.props;
+    if (isUploading && !nextProps.state.isUploading) {
+      this.caretPos = selectionStart;
+    }
+  }
+
+  //  This statement does several things:
+  //  - If we're beginning a reply, and,
+  //      - Replying to zero or one users, places the cursor at the end
+  //        of the textbox.
+  //      - Replying to more than one user, selects any usernames past
+  //        the first; this provides a convenient shortcut to drop
+  //        everyone else from the conversation.
+  // - If we've just finished uploading an image, and have a saved
+  //   caret position, restores the cursor to that position after the
+  //   text changes.
+  componentDidUpdate (prevProps) {
+    const {
+      caretPos,
+      textarea,
+    } = this;
+    const {
+      state: {
+        focusDate,
+        isUploading,
+        isSubmitting,
+        preselectDate,
+        text,
+      },
+    } = this.props;
+    let selectionEnd, selectionStart;
+
+    //  Caret/selection handling.
+    if (focusDate !== prevProps.state.focusDate || (prevProps.state.isUploading && !isUploading && !isNaN(caretPos) && caretPos !== null)) {
+      switch (true) {
+      case preselectDate !== prevProps.state.preselectDate:
+        selectionStart = text.search(/\s/) + 1;
+        selectionEnd = text.length;
+        break;
+      case !isNaN(caretPos) && caretPos !== null:
+        selectionStart = selectionEnd = caretPos;
+        break;
+      default:
+        selectionStart = selectionEnd = text.length;
+      }
+      textarea.setSelectionRange(selectionStart, selectionEnd);
+      textarea.focus();
+
+    //  Refocuses the textarea after submitting.
+    } else if (prevProps.state.isSubmitting && !isSubmitting) {
+      textarea.focus();
+    }
+  }
+
+  render () {
+    const {
+      changeSpoiler,
+      emoji,
+      secondarySubmit,
+      select,
+      submit,
+      refTextarea,
+    } = this.handlers;
+    const { history } = this.context;
+    const {
+      dispatch: {
+        cancelReply,
+        changeDescription,
+        changeSensitivity,
+        changeText,
+        changeVisibility,
+        clearSuggestions,
+        closeModal,
+        fetchSuggestions,
+        openActionsModal,
+        openDoodleModal,
+        toggleAdvancedOption,
+        undoUpload,
+        upload,
+      },
+      intl,
+      state: {
+        acceptContentTypes,
+        amUnlocked,
+        doNotFederate,
+        isSubmitting,
+        isUploading,
+        media,
+        privacy,
+        progress,
+        replyAccount,
+        replyContent,
+        resetFileKey,
+        sensitive,
+        showSearch,
+        sideArm,
+        spoiler,
+        spoilerText,
+        suggestions,
+        text,
+      },
+    } = this.props;
+
+    return (
+      <div className='compose'>
+        <ComposerSpoiler
+          hidden={!spoiler}
+          intl={intl}
+          onChange={changeSpoiler}
+          onSubmit={submit}
+          text={spoilerText}
+        />
+        {privacy === 'private' && amUnlocked ? <ComposerWarning /> : null}
+        {replyContent ? (
+          <ComposerReply
+            account={replyAccount}
+            content={replyContent}
+            history={history}
+            intl={intl}
+            onCancel={cancelReply}
+          />
+        ) : null}
+        <ComposerTextarea
+          autoFocus={!showSearch && !isMobile(window.innerWidth)}
+          disabled={isSubmitting}
+          intl={intl}
+          onChange={changeText}
+          onPaste={upload}
+          onPickEmoji={emoji}
+          onSubmit={submit}
+          onSuggestionsClearRequested={clearSuggestions}
+          onSuggestionsFetchRequested={fetchSuggestions}
+          onSuggestionSelected={select}
+          ref={refTextarea}
+          suggestions={suggestions}
+          value={text}
+        />
+        {media && media.size ? (
+          <ComposerUploadForm
+            active={isUploading}
+            intl={intl}
+            media={media}
+            onChangeDescription={changeDescription}
+            onRemove={undoUpload}
+            progress={progress}
+          />
+        ) : null}
+        <ComposerOptions
+          acceptContentTypes={acceptContentTypes}
+          disabled={isSubmitting}
+          doNotFederate={doNotFederate}
+          full={media.size >= 4 || media.some(
+            item => item.get('type') === 'video'
+          )}
+          hasMedia={!!media.size}
+          intl={intl}
+          onChangeSensitivity={changeSensitivity}
+          onChangeVisibility={changeVisibility}
+          onDoodleOpen={openDoodleModal}
+          onModalClose={closeModal}
+          onModalOpen={openActionsModal}
+          onToggleAdvancedOption={toggleAdvancedOption}
+          onUpload={upload}
+          privacy={privacy}
+          resetFileKey={resetFileKey}
+          sensitive={sensitive}
+          spoiler={spoiler}
+        />
+        <ComposerPublisher
+          countText={`${spoilerText}${countableText(text)}${doNotFederate ? ' 👁️' : ''}`}
+          disabled={isSubmitting || isUploading || text.length && text.trim().length === 0}
+          intl={intl}
+          onSecondarySubmit={secondarySubmit}
+          onSubmit={submit}
+          privacy={privacy}
+          sideArm={sideArm}
+        />
+      </div>
+    );
+  }
+
+}
+
+//  Context
+Composer.contextTypes = {
+  history: PropTypes.object,
+}
+
+//  Props.
+Composer.propTypes = {
+  dispatch: PropTypes.objectOf(PropTypes.func).isRequired,
+  intl: PropTypes.object.isRequired,
+  state: PropTypes.shape({
+    acceptContentTypes: PropTypes.string,
+    amUnlocked: PropTypes.bool,
+    doNotFederate: PropTypes.bool,
+    focusDate: PropTypes.instanceOf(Date),
+    isSubmitting: PropTypes.bool,
+    isUploading: PropTypes.bool,
+    media: PropTypes.list,
+    preselectDate: PropTypes.instanceOf(Date),
+    privacy: PropTypes.string,
+    progress: PropTypes.number,
+    replyAccount: ImmutablePropTypes.map,
+    replyContent: PropTypes.string,
+    resetFileKey: PropTypes.string,
+    sideArm: PropTypes.string,
+    sensitive: PropTypes.bool,
+    showSearch: PropTypes.bool,
+    spoiler: PropTypes.bool,
+    spoilerText: PropTypes.string,
+    suggestionToken: PropTypes.string,
+    suggestions: ImmutablePropTypes.list,
+    text: PropTypes.string,
+  }).isRequired,
+};
diff --git a/app/javascript/flavours/glitch/features/composer/options/dropdown/index.js b/app/javascript/flavours/glitch/features/composer/options/dropdown/index.js
new file mode 100644
index 000000000..0f304bc88
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/composer/options/dropdown/index.js
@@ -0,0 +1,243 @@
+//  Package imports.
+import classNames from 'classnames';
+import PropTypes from 'prop-types';
+import React from 'react';
+import spring from 'react-motion/lib/spring';
+import Overlay from 'react-overlays/lib/Overlay';
+
+//  Components.
+import IconButton from 'flavours/glitch/components/icon_button';
+import ComposerOptionsDropdownItem from './item';
+
+//  Utils.
+import { withPassive } from 'flavours/glitch/util/dom_helpers';
+import { isUserTouching } from 'flavours/glitch/util/is_mobile';
+import Motion from 'flavours/glitch/util/optional_motion';
+import { assignHandlers } from 'flavours/glitch/util/react_helpers';
+
+//  We'll use this to define our various transitions.
+const springMotion = spring(1, {
+  damping: 35,
+  stiffness: 400,
+});
+
+//  Handlers.
+const handlers = {
+
+  //  Closes the dropdown.
+  close () {
+    this.setState({ open: false });
+  },
+
+  //  When the document is clicked elsewhere, we close the dropdown.
+  documentClick ({ target }) {
+    const { node } = this;
+    const { onClose } = this.props;
+    if (onClose && node && !node.contains(target)) {
+      onClose();
+    }
+  },
+
+  //  The enter key toggles the dropdown's open state, and the escape
+  //  key closes it.
+  keyDown ({ key }) {
+    const {
+      close,
+      toggle,
+    } = this.handlers;
+    switch (key) {
+    case 'Enter':
+      toggle();
+      break;
+    case 'Escape':
+      close();
+      break;
+    }
+  },
+
+  //  Toggles opening and closing the dropdown.
+  toggle () {
+    const {
+      items,
+      onChange,
+      onModalClose,
+      onModalOpen,
+      value,
+    } = this.props;
+    const { open } = this.state;
+
+    //  If this is a touch device, we open a modal instead of the
+    //  dropdown.
+    if (onModalClose && isUserTouching()) {
+      if (open) {
+        onModalClose()
+      } else if (onChange && onModalOpen) {
+        onModalOpen({
+          actions: items.map(
+            ({
+              name,
+              ...rest
+            }) => ({
+              ...rest,
+              active: value && name === value,
+              onClick (e) {
+                e.preventDefault();  //  Prevents focus from changing
+                onModalClose();
+                onChange(name);
+              },
+            })
+          ),
+        });
+      }
+
+    //  Otherwise, we just set our state to open.
+    } else {
+      this.setState({ open: !open });
+    }
+  },
+
+  //  Stores our node in `this.node`.
+  ref (node) {
+    this.node = node;
+  },
+};
+
+//  The component.
+export default class ComposerOptionsDropdown extends React.PureComponent {
+
+  //  Constructor.
+  constructor (props) {
+    super(props);
+    assignHandlers(this, handlers);
+    this.state = { open: false };
+
+    //  Instance variables.
+    this.node = null;
+  }
+
+  //  On mounting, we add our listeners.
+  componentDidMount () {
+    const { documentClick } = this.handlers;
+    document.addEventListener('click', documentClick, false);
+    document.addEventListener('touchend', documentClick, withPassive);
+  }
+
+  //  On unmounting, we remove our listeners.
+  componentWillUnmount () {
+    const { documentClick } = this.handlers;
+    document.removeEventListener('click', documentClick, false);
+    document.removeEventListener('touchend', documentClick, withPassive);
+  }
+
+  //  Rendering.
+  render () {
+    const {
+      close,
+      keyDown,
+      ref,
+      toggle,
+    } = this.handlers;
+    const {
+      active,
+      disabled,
+      title,
+      icon,
+      items,
+      onChange,
+      value,
+    } = this.props;
+    const { open } = this.state;
+    const computedClass = classNames('composer--options--dropdown', {
+      active,
+      open: open || active,
+    });
+
+    //  The result.
+    return (
+      <div
+        className={computedClass}
+        onKeyDown={keyDown}
+        ref={ref}
+      >
+        <IconButton
+          active={open || active}
+          className='value'
+          disabled={disabled}
+          icon={icon}
+          onClick={toggle}
+          size={18}
+          style={{
+            height: null,
+            lineHeight: '27px',
+          }}
+          title={title}
+        />
+        <Overlay
+          placement='bottom'
+          show={open}
+          target={this}
+        >
+          <Motion
+            defaultStyle={{
+              opacity: 0,
+              scaleX: 0.85,
+              scaleY: 0.75,
+            }}
+            style={{
+              opacity: springMotion,
+              scaleX: springMotion,
+              scaleY: springMotion,
+            }}
+          >
+            {({ opacity, scaleX, scaleY }) => (
+              <div
+                className='dropdown'
+                ref={this.setRef}
+                style={{
+                  opacity: opacity,
+                  transform: `scale(${scaleX}, ${scaleY})`,
+                }}
+              >
+                {items.map(
+                  ({
+                    name,
+                    ...rest
+                  }) => (
+                    <ComposerOptionsDropdownItem
+                      active={name === value}
+                      key={name}
+                      name={name}
+                      onChange={onChange}
+                      onClose={close}
+                      options={rest}
+                    />
+                  )
+                )}
+              </div>
+            )}
+          </Motion>
+        </Overlay>
+      </div>
+    );
+  }
+
+}
+
+//  Props.
+ComposerOptionsDropdown.propTypes = {
+  active: PropTypes.bool,
+  disabled: PropTypes.bool,
+  icon: PropTypes.string,
+  items: PropTypes.arrayOf(PropTypes.shape({
+    icon: PropTypes.string,
+    meta: PropTypes.node,
+    name: PropTypes.string.isRequired,
+    on: PropTypes.bool,
+    text: PropTypes.node,
+  })).isRequired,
+  onChange: PropTypes.func,
+  onModalClose: PropTypes.func,
+  onModalOpen: PropTypes.func,
+  title: PropTypes.string,
+  value: PropTypes.string,
+};
diff --git a/app/javascript/flavours/glitch/features/composer/options/dropdown/item/index.js b/app/javascript/flavours/glitch/features/composer/options/dropdown/item/index.js
new file mode 100644
index 000000000..ca4ee393e
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/composer/options/dropdown/item/index.js
@@ -0,0 +1,126 @@
+//  Package imports.
+import classNames from 'classnames';
+import PropTypes from 'prop-types';
+import React from 'react';
+import Toggle from 'react-toggle';
+
+//  Components.
+import Icon from 'flavours/glitch/components/icon';
+
+//  Utils.
+import { assignHandlers } from 'flavours/glitch/util/react_helpers';
+
+//  Handlers.
+const handlers = {
+
+  //  This function activates the dropdown item.
+  activate (e) {
+    const {
+      name,
+      onChange,
+      onClose,
+      options: { on },
+    } = this.props;
+
+    //  If the escape key was pressed, we close the dropdown.
+    if (e.key === 'Escape' && onClose) {
+      onClose();
+
+    //  Otherwise, we both close the dropdown and change the value.
+    } else if (onChange && (!e.key || e.key === 'Enter')) {
+      e.preventDefault();  //  Prevents change in focus on click
+      if ((on === null || typeof on === 'undefined') && onClose) {
+        onClose();
+      }
+      onChange(name);
+    }
+  },
+
+};
+
+//  The component.
+export default class ComposerOptionsDropdownItem extends React.PureComponent {
+
+  //  Constructor.
+  constructor (props) {
+    super(props);
+    assignHandlers(this, handlers);
+  }
+
+  //  Rendering.
+  render () {
+    const { activate } = this.handlers;
+    const {
+      active,
+      options: {
+        icon,
+        meta,
+        on,
+        text,
+      },
+    } = this.props;
+    const computedClass = classNames('composer--options--dropdown_item', {
+      active,
+      lengthy: meta,
+      'toggled-off': !on && on !== null && typeof on !== 'undefined',
+      'toggled-on': on,
+      'with-icon': icon,
+    });
+
+    //  The result.
+    return (
+      <div
+        className={computedClass}
+        onClick={activate}
+        onKeyDown={activate}
+        role='button'
+        tabIndex='0'
+      >
+        {function () {
+
+          //  We render a `<Toggle>` if we were provided an `on`
+          //  property, and otherwise show an `<Icon>` if available.
+          switch (true) {
+          case on !== null && typeof on !== 'undefined':
+            return (
+              <Toggle
+                checked={on}
+                onChange={activate}
+              />
+            );
+          case !!icon:
+            return (
+              <Icon
+                fullwidth
+                icon={icon}
+              />
+            );
+          default:
+            return null;
+          }
+        }()}
+        {meta ? (
+          <div>
+            <strong>{text}</strong>
+            {meta}
+          </div>
+        ) : <div>{text}</div>}
+      </div>
+    );
+  }
+
+};
+
+//  Props.
+ComposerOptionsDropdownItem.propTypes = {
+  active: PropTypes.bool,
+  name: PropTypes.string,
+  onChange: PropTypes.func,
+  onClose: PropTypes.func,
+  options: PropTypes.shape({
+    icon: PropTypes.string,
+    meta: PropTypes.node,
+    on: PropTypes.bool,
+    text: PropTypes.node,
+  }),
+};
diff --git a/app/javascript/flavours/glitch/features/composer/options/index.js b/app/javascript/flavours/glitch/features/composer/options/index.js
new file mode 100644
index 000000000..ee633e865
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/composer/options/index.js
@@ -0,0 +1,321 @@
+//  Package imports.
+import PropTypes from 'prop-types';
+import React from 'react';
+import {
+  FormattedMessage,
+  defineMessages,
+} from 'react-intl';
+import spring from 'react-motion/lib/spring';
+
+//  Components.
+import IconButton from 'flavours/glitch/components/icon_button';
+import TextIconButton from 'flavours/glitch/components/text_icon_button';
+import Dropdown from './dropdown';
+
+//  Utils.
+import Motion from 'flavours/glitch/util/optional_motion';
+import {
+  assignHandlers,
+  hiddenComponent,
+} from 'flavours/glitch/util/react_helpers';
+
+//  Messages.
+const messages = defineMessages({
+  advanced_options_icon_title: {
+    defaultMessage: 'Advanced options',
+    id: 'advanced_options.icon_title',
+  },
+  attach: {
+    defaultMessage: 'Attach...',
+    id: 'compose.attach',
+  },
+  change_privacy: {
+    defaultMessage: 'Adjust status privacy',
+    id: 'privacy.change',
+  },
+  direct_long: {
+    defaultMessage: 'Post to mentioned users only',
+    id: 'privacy.direct.long',
+  },
+  direct_short: {
+    defaultMessage: 'Direct',
+    id: 'privacy.direct.short',
+  },
+  doodle: {
+    defaultMessage: 'Draw something',
+    id: 'compose.attach.doodle',
+  },
+  local_only_long: {
+    defaultMessage: 'Do not post to other instances',
+    id: 'advanced-options.local-only.long',
+  },
+  local_only_short: {
+    defaultMessage: 'Local-only',
+    id: 'advanced-options.local-only.short',
+  },
+  private_long: {
+    defaultMessage: 'Post to followers only',
+    id: 'privacy.private.long',
+  },
+  private_short: {
+    defaultMessage: 'Followers-only',
+    id: 'privacy.private.short',
+  },
+  public_long: {
+    defaultMessage: 'Post to public timelines',
+    id: 'privacy.public.long',
+  },
+  public_short: {
+    defaultMessage: 'Public',
+    id: 'privacy.public.short',
+  },
+  sensitive: {
+    defaultMessage: 'Mark media as sensitive',
+    id: 'compose_form.sensitive',
+  },
+  spoiler: {
+    defaultMessage: 'Hide text behind warning',
+    id: 'compose_form.spoiler',
+  },
+  unlisted_long: {
+    defaultMessage: 'Do not show in public timelines',
+    id: 'privacy.unlisted.long',
+  },
+  unlisted_short: {
+    defaultMessage: 'Unlisted',
+    id: 'privacy.unlisted.short',
+  },
+  upload: {
+    defaultMessage: 'Upload a file',
+    id: 'compose.attach.upload',
+  },
+});
+
+//  Handlers.
+const handlers = {
+
+  //  Handles file selection.
+  changeFiles ({ target: { files } }) {
+    const { onUpload } = this.props;
+    if (files.length && onUpload) {
+      onUpload(files);
+    }
+  },
+
+  //  Handles attachment clicks.
+  clickAttach (name) {
+    const { fileElement } = this;
+    const { onDoodleOpen } = this.props;
+
+    //  We switch over the name of the option.
+    switch (name) {
+    case 'upload':
+      if (fileElement) {
+        fileElement.click();
+      }
+      return;
+    case 'doodle':
+      if (onDoodleOpen) {
+        onDoodleOpen();
+      }
+      return;
+    }
+  },
+
+  //  Handles a ref to the file input.
+  refFileElement (fileElement) {
+    this.fileElement = fileElement;
+  },
+};
+
+//  The component.
+export default class ComposerOptions extends React.PureComponent {
+
+  //  Constructor.
+  constructor (props) {
+    super(props);
+    assignHandlers(this, handlers);
+
+    //  Instance variables.
+    this.fileElement = null;
+  }
+
+  //  Rendering.
+  render () {
+    const {
+      changeFiles,
+      clickAttach,
+      refFileElement,
+    } = this.handlers;
+    const {
+      acceptContentTypes,
+      disabled,
+      doNotFederate,
+      full,
+      hasMedia,
+      intl,
+      onChangeSensitivity,
+      onChangeVisibility,
+      onModalClose,
+      onModalOpen,
+      onToggleAdvancedOption,
+      privacy,
+      resetFileKey,
+      sensitive,
+      spoiler,
+    } = this.props;
+
+    //  We predefine our privacy items so that we can easily pick the
+    //  dropdown icon later.
+    const privacyItems = {
+      direct: {
+        icon: 'envelope',
+        meta: <FormattedMessage {...messages.direct_long} />,
+        name: 'direct',
+        text: <FormattedMessage {...messages.direct_short} />,
+      },
+      private: {
+        icon: 'lock',
+        meta: <FormattedMessage {...messages.private_long} />,
+        name: 'private',
+        text: <FormattedMessage {...messages.private_short} />,
+      },
+      public: {
+        icon: 'globe',
+        meta: <FormattedMessage {...messages.public_long} />,
+        name: 'public',
+        text: <FormattedMessage {...messages.public_short} />,
+      },
+      unlisted: {
+        icon: 'unlock-alt',
+        meta: <FormattedMessage {...messages.unlisted_long} />,
+        name: 'unlisted',
+        text: <FormattedMessage {...messages.unlisted_short} />,
+      },
+    };
+
+    //  The result.
+    return (
+      <div className='composer--options'>
+        <input
+          accept={acceptContentTypes}
+          disabled={disabled || full}
+          key={resetFileKey}
+          onChange={changeFiles}
+          ref={refFileElement}
+          type='file'
+          {...hiddenComponent}
+        />
+        <Dropdown
+          disabled={disabled || full}
+          icon='paperclip'
+          items={[
+            {
+              icon: 'cloud-upload',
+              name: 'upload',
+              text: <FormattedMessage {...messages.upload} />,
+            },
+            {
+              icon: 'paint-brush',
+              name: 'doodle',
+              text: <FormattedMessage {...messages.doodle} />,
+            },
+          ]}
+          onChange={clickAttach}
+          onModalClose={onModalClose}
+          onModalOpen={onModalOpen}
+          title={messages.attach}
+        />
+        <Motion
+          defaultStyle={{ scale: 0.87 }}
+          style={{
+            scale: spring(hasMedia ? 1 : 0.87, {
+              stiffness: 200,
+              damping: 3,
+            }),
+          }}
+        >
+          {({ scale }) => (
+            <div style={{ transform: `scale(${scale})` }}>
+              <IconButton
+                active={sensitive}
+                className='sensitive'
+                disabled={spoiler}
+                icon={sensitive ? 'eye-slash' : 'eye'}
+                inverted
+                onClick={onChangeSensitivity}
+                size={18}
+                style={{
+                  height: null,
+                  lineHeight: null,
+                }}
+                title={intl.formatMessage(messages.sensitive)}
+              />
+            </div>
+          )}
+        </Motion>
+        <hr />
+        <Dropdown
+          disabled={disabled}
+          icon={(privacyItems[privacy] || {}).icon}
+          items={[
+            privacyItems.public,
+            privacyItems.unlisted,
+            privacyItems.private,
+            privacyItems.direct,
+          ]}
+          onChange={onChangeVisibility}
+          onModalClose={onModalClose}
+          onModalOpen={onModalOpen}
+          title={intl.formatMessage(messages.change_privacy)}
+          value={privacy}
+        />
+        <TextIconButton
+          active={spoiler}
+          ariaControls='glitch.composer.spoiler.input'
+          label='CW'
+          title={intl.formatMessage(messages.spoiler)}
+        />
+        <Dropdown
+          active={doNotFederate}
+          disabled={disabled}
+          icon='home'
+          items={[
+            {
+              meta: <FormattedMessage {...messages.local_only_long} />,
+              name: 'do_not_federate',
+              on: doNotFederate,
+              text: <FormattedMessage {...messages.local_only_short} />,
+            },
+          ]}
+          onChange={onToggleAdvancedOption}
+          onModalClose={onModalClose}
+          onModalOpen={onModalOpen}
+          title={intl.formatMessage(messages.advanced_options_icon_title)}
+        />
+      </div>
+    );
+  }
+
+}
+
+//  Props.
+ComposerOptions.propTypes = {
+  acceptContentTypes: PropTypes.string,
+  disabled: PropTypes.bool,
+  doNotFederate: PropTypes.bool,
+  full: PropTypes.bool,
+  hasMedia: PropTypes.bool,
+  intl: PropTypes.object.isRequired,
+  onChangeSensitivity: PropTypes.func,
+  onChangeVisibility: PropTypes.func,
+  onDoodleOpen: PropTypes.func,
+  onModalClose: PropTypes.func,
+  onModalOpen: PropTypes.func,
+  onToggleAdvancedOption: PropTypes.func,
+  onUpload: PropTypes.func,
+  privacy: PropTypes.string,
+  resetFileKey: PropTypes.string,
+  sensitive: PropTypes.bool,
+  spoiler: PropTypes.bool,
+};
diff --git a/app/javascript/flavours/glitch/features/composer/publisher/index.js b/app/javascript/flavours/glitch/features/composer/publisher/index.js
new file mode 100644
index 000000000..85de80a9f
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/composer/publisher/index.js
@@ -0,0 +1,119 @@
+//  Package imports.
+import classNames from 'classnames';
+import PropTypes from 'prop-types';
+import React from 'react';
+import {
+  defineMessages,
+  FormattedMessage,
+} from 'react-intl';
+import { length } from 'stringz';
+
+//  Components.
+import Button from 'flavours/glitch/components/button';
+import Icon from 'flavours/glitch/components/icon';
+
+//  Utils.
+import { maxChars } from 'flavours/glitch/util/initial_state';
+
+//  Messages.
+const messages = defineMessages({
+  publish: {
+    defaultMessage: 'Toot',
+    id: 'compose_form.publish',
+  },
+  publishLoud: {
+    defaultMessage: '{publish}!',
+    id: 'compose_form.publish_loud',
+  },
+});
+
+//  The component.
+export default function ComposerPublisher ({
+  countText,
+  disabled,
+  intl,
+  onSecondarySubmit,
+  onSubmit,
+  privacy,
+  sideArm,
+}) {
+  const diff = maxChars - length(countText || '');
+  const computedClass = classNames('composer--publisher', {
+    disabled: disabled || diff < 0,
+    over: diff < 0,
+  });
+
+  //  The result.
+  return (
+    <div className={computedClass}>
+      <span class='count'>{diff}</span>
+      {sideArm && sideArm !== 'none' ? (
+        <Button
+          text={
+            <span>
+              <Icon
+                icon={{
+                  public: 'globe',
+                  unlisted: 'unlock-alt',
+                  private: 'lock',
+                  direct: 'envelope',
+                }[sideArm]}
+              />
+            </span>
+          }
+          title={`${intl.formatMessage(messages.publish)}: ${intl.formatMessage({ id: `privacy.${sideArm}.short` })}`}
+          onClick={onSecondarySubmit}
+          disabled={disabled || diff < 0}
+        />
+      ) : null}
+      <Button
+        className='compose-form__publish__primary'
+        text={function () {
+          switch (true) {
+          case !!sideArm && sideArm !== 'none':
+          case privacy === 'direct':
+          case privacy === 'private':
+            return (
+              <span>
+                <Icon
+                  icon={{
+                    direct: 'envelope',
+                    private: 'lock',
+                    public: 'globe',
+                    unlisted: 'unlock-alt',
+                  }[privacy]}
+                />
+                <FormattedMessage {...messages.publish} />
+              </span>
+            );
+          case privacy === 'public':
+            return (
+              <span>
+                <FormattedMessage
+                  {...messages.publishLoud}
+                  values={{ publish: <FormattedMessage {...messages.publish} /> }}
+                />
+              </span>
+            );
+          default:
+            return <span><FormattedMessage {...messages.publish} /></span>;
+          }
+        }()}
+        title={`${intl.formatMessage(messages.publish)}: ${intl.formatMessage({ id: `privacy.${privacy}.short` })}`}
+        onClick={onSubmit}
+        disabled={disabled || diff < 0}
+      />
+    </div>
+  );
+}
+
+//  Props.
+ComposerPublisher.propTypes = {
+  countText: PropTypes.string,
+  disabled: PropTypes.bool,
+  intl: PropTypes.object.isRequired,
+  onSecondarySubmit: PropTypes.func,
+  onSubmit: PropTypes.func,
+  privacy: PropTypes.oneOf(['direct', 'private', 'unlisted', 'public']),
+  sideArm: PropTypes.oneOf(['none', 'direct', 'private', 'unlisted', 'public']),
+};
diff --git a/app/javascript/flavours/glitch/features/composer/reply/index.js b/app/javascript/flavours/glitch/features/composer/reply/index.js
new file mode 100644
index 000000000..2823415d2
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/composer/reply/index.js
@@ -0,0 +1,106 @@
+//  Package imports.
+import PropTypes from 'prop-types';
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { defineMessages } from 'react-intl';
+
+//  Components.
+import Avatar from 'flavours/glitch/components/avatar';
+import DisplayName from 'flavours/glitch/components/display_name';
+import IconButton from 'flavours/glitch/components/icon_button';
+
+//  Utils.
+import { assignHandlers } from 'flavours/glitch/util/react_helpers';
+import { isRtl } from 'flavours/glitch/util/rtl';
+
+//  Messages.
+const messages = defineMessages({
+  cancel: {
+    defaultMessage: 'Cancel',
+    id: 'reply_indicator.cancel',
+  },
+});
+
+//  Handlers.
+const handlers = {
+
+  //  Handles a click on the "close" button.
+  click () {
+    const { onCancel } = this.props;
+    if (onCancel) {
+      onCancel();
+    }
+  },
+
+  //  Handles a click on the status's account.
+  clickAccount () {
+    const {
+      account,
+      history,
+    } = this.props;
+    if (history) {
+      history.push(`/accounts/${account.get('id')}`);
+    }
+  },
+};
+
+//  The component.
+export default class ComposerReply extends React.PureComponent {
+
+  //  Constructor.
+  constructor (props) {
+    super(props);
+    assignHandlers(this, handlers);
+  }
+
+  //  Rendering.
+  render () {
+    const {
+      click,
+      clickAccount,
+    } = this.handlers;
+    const {
+      account,
+      content,
+      intl,
+    } = this.props;
+
+    //  The result.
+    return (
+      <article className='composer--reply'>
+        <header>
+          <IconButton
+            icon='times'
+            onClick={click}
+            title={intl.formatMessage(messages.cancel)}
+          />
+          {account ? (
+            <a
+              href={account.get('url')}
+              onClick={clickAccount}
+            >
+              <Avatar
+                account={account}
+                size={24}
+              />
+              <DisplayName account={account} />
+            </a>
+          ) : null}
+        </header>
+        <div
+          dangerouslySetInnerHTML={{ __html: content || '' }}
+          style={{ direction: isRtl(content) ? 'rtl' : 'ltr' }}
+        />
+      </article>
+    );
+  }
+
+}
+
+ComposerReply.propTypes = {
+  account: ImmutablePropTypes.map,
+  content: PropTypes.string,
+  history: PropTypes.object,
+  intl: PropTypes.object.isRequired,
+  onCancel: PropTypes.func,
+};
diff --git a/app/javascript/flavours/glitch/features/composer/spoiler/index.js b/app/javascript/flavours/glitch/features/composer/spoiler/index.js
new file mode 100644
index 000000000..730ab2205
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/composer/spoiler/index.js
@@ -0,0 +1,92 @@
+//  Package imports.
+import React from 'react';
+import PropTypes from 'prop-types';
+import { defineMessages, FormattedMessage } from 'react-intl';
+
+//  Components.
+import Collapsable from 'flavours/glitch/components/collapsable';
+
+//  Utils.
+import {
+  assignHandlers,
+  hiddenComponent,
+} from 'flavours/glitch/util/react_helpers';
+
+//  Messages.
+const messages = defineMessages({
+  placeholder: {
+    defaultMessage: 'Write your warning here',
+    id: 'compose_form.spoiler_placeholder',
+  },
+});
+
+//  Handlers.
+const handlers = {
+
+  //  Handles a keypress.
+  keyDown ({
+    ctrlKey,
+    keyCode,
+    metaKey,
+  }) {
+    const { onSubmit } = this.props;
+
+    //  We submit the status on control/meta + enter.
+    if (onSubmit && keyCode === 13 && (ctrlKey || metaKey)) {
+      onSubmit();
+    }
+  },
+};
+
+//  The component.
+export default class ComposerSpoiler extends React.PureComponent {
+
+  //  Constructor.
+  constructor (props) {
+    super(props);
+    assignHandlers(this, handlers);
+  }
+
+  //  Rendering.
+  render () {
+    const { keyDown } = this.handlers;
+    const {
+      hidden,
+      intl,
+      onChange,
+      text,
+    } = this.props;
+
+    //  The result.
+    return (
+      <Collapsable
+        isVisible={!hidden}
+        fullHeight={50}
+      >
+        <label className='composer--spoiler'>
+          <span {...hiddenComponent}>
+            <FormattedMessage {...messages.placeholder} />
+          </span>
+          <input
+            id='glitch.composer.spoiler.input'
+            onChange={onChange}
+            onKeyDown={keyDown}
+            placeholder={intl.formatMessage(messages.placeholder)}
+            type='text'
+            value={text}
+          />
+        </label>
+      </Collapsable>
+    );
+  }
+
+}
+
+//  Props.
+ComposerSpoiler.propTypes = {
+  hidden: PropTypes.bool,
+  intl: PropTypes.object.isRequired,
+  onChange: PropTypes.func,
+  onSubmit: PropTypes.func,
+  text: PropTypes.string,
+};
diff --git a/app/javascript/flavours/glitch/features/composer/textarea/index.js b/app/javascript/flavours/glitch/features/composer/textarea/index.js
new file mode 100644
index 000000000..ad0a35d7f
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/composer/textarea/index.js
@@ -0,0 +1,297 @@
+//  Package imports.
+import PropTypes from 'prop-types';
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import {
+  defineMessages,
+  FormattedMessage,
+} from 'react-intl';
+import Textarea from 'react-textarea-autosize';
+
+//  Components.
+import EmojiPicker from 'flavours/glitch/features/emoji_picker';
+import ComposerTextareaSuggestions from './suggestions';
+
+//  Utils.
+import { isRtl } from 'flavours/glitch/util/rtl';
+import {
+  assignHandlers,
+  hiddenComponent,
+} from 'flavours/glitch/util/react_helpers';
+
+//  Messages.
+const messages = defineMessages({
+  placeholder: {
+    defaultMessage: 'What is on your mind?',
+    id: 'compose_form.placeholder',
+  },
+});
+
+//  Handlers.
+const handlers = {
+
+  //  When blurring the textarea, suggestions are hidden.
+  blur () {
+    this.setState({ suggestionsHidden: true });
+  },
+
+  //  When the contents of the textarea change, we have to pull up new
+  //  autosuggest suggestions if applicable, and also change the value
+  //  of the textarea in our store.
+  change ({
+    target: {
+      selectionStart,
+      value,
+    },
+  }) {
+    const {
+      onChange,
+      onSuggestionsFetchRequested,
+      onSuggestionsClearRequested,
+    } = this.props;
+    const { lastToken } = this.state;
+
+    //  This gets the token at the caret location, if it begins with an
+    //  `@` (mentions) or `:` (shortcodes).
+    const left = value.slice(0, selectionStart).search(/[^\s\u200B]+$/);
+    const right = value.slice(selectionStart).search(/[\s\u200B]/);
+    const token = function () {
+      switch (true) {
+      case left < 0 || /[@:]/.test(!value[left]):
+        return null;
+      case right < 0:
+        return value.slice(left);
+      default:
+        return value.slice(left, right + selectionStart).trim().toLowerCase();
+      }
+    }();
+
+    //  We only request suggestions for tokens which are at least 3
+    //  characters long.
+    if (onSuggestionsFetchRequested && token && token.length >= 3) {
+      if (lastToken !== token) {
+        this.setState({
+          lastToken: token,
+          selectedSuggestion: 0,
+          tokenStart: left,
+        });
+        onSuggestionsFetchRequested(token);
+      }
+    } else {
+      this.setState({ lastToken: null });
+      if (onSuggestionsClearRequested) {
+        onSuggestionsClearRequested();
+      }
+    }
+
+    //  Updates the value of the textarea.
+    if (onChange) {
+      onChange(value);
+    }
+  },
+
+  //  Handles a click on an autosuggestion.
+  clickSuggestion (index) {
+    const { textarea } = this;
+    const {
+      onSuggestionSelected,
+      suggestions,
+    } = this.props;
+    const {
+      lastToken,
+      tokenStart,
+    } = this.state;
+    onSuggestionSelected(tokenStart, lastToken, suggestions.get(index));
+    textarea.focus();
+  },
+
+  //  Handles a keypress.  If the autosuggestions are visible, we need
+  //  to allow keypresses to navigate and sleect them.
+  keyDown (e) {
+    const {
+      disabled,
+      onSubmit,
+      onSuggestionSelected,
+      suggestions,
+    } = this.props;
+    const {
+      lastToken,
+      suggestionsHidden,
+      selectedSuggestion,
+      tokenStart,
+    } = this.state;
+
+    //  Keypresses do nothing if the composer is disabled.
+    if (disabled) {
+      e.preventDefault();
+      return;
+    }
+
+    //  Switches over the pressed key.
+    switch(e.key) {
+
+    //  On arrow down, we pick the next suggestion.
+    case 'ArrowDown':
+      if (suggestions && suggestions.size > 0 && !suggestionsHidden) {
+        e.preventDefault();
+        this.setState({ selectedSuggestion: Math.min(selectedSuggestion + 1, suggestions.size - 1) });
+      }
+      return;
+
+    //  On arrow up, we pick the previous suggestion.
+    case 'ArrowUp':
+      if (suggestions && suggestions.size > 0 && !suggestionsHidden) {
+        e.preventDefault();
+        this.setState({ selectedSuggestion: Math.max(selectedSuggestion - 1, 0) });
+      }
+      return;
+
+    //  On enter or tab, we select the suggestion.
+    case 'Enter':
+    case 'Tab':
+      if (onSuggestionSelected && lastToken !== null && suggestions && suggestions.size > 0 && !suggestionsHidden) {
+        e.preventDefault();
+        e.stopPropagation();
+        onSuggestionSelected(tokenStart, lastToken, suggestions.get(selectedSuggestion));
+      }
+      return;
+    }
+
+    //  We submit the status on control/meta + enter.
+    if (onSubmit && e.keyCode === 13 && (e.ctrlKey || e.metaKey)) {
+      onSubmit();
+    }
+  },
+
+  //  When the escape key is released, we either close the suggestions
+  //  window or focus the UI.
+  keyUp ({ key }) {
+    const { suggestionsHidden } = this.state;
+    if (key === 'Escape') {
+      if (!suggestionsHidden) {
+        this.setState({ suggestionsHidden: true });
+      } else {
+        document.querySelector('.ui').parentElement.focus();
+      }
+    }
+  },
+
+  //  Handles the pasting of images into the composer.
+  paste (e) {
+    const { onPaste } = this.props;
+    let d;
+    if (onPaste && (d = e.clipboardData) && (d = d.files).length === 1) {
+      onPaste(d);
+      e.preventDefault();
+    }
+  },
+
+  //  Saves a reference to the textarea.
+  refTextarea (textarea) {
+    this.textarea = textarea;
+  },
+};
+
+//  The component.
+export default class ComposerTextarea extends React.Component {
+
+  //  Constructor.
+  constructor (props) {
+    super(props);
+    assignHandlers(this, handlers);
+    this.state = {
+      suggestionsHidden: false,
+      selectedSuggestion: 0,
+      lastToken: null,
+      tokenStart: 0,
+    };
+
+    //  Instance variables.
+    this.textarea = null;
+  }
+
+  //  When we receive new suggestions, we unhide the suggestions window
+  //  if we didn't have any suggestions before.
+  componentWillReceiveProps (nextProps) {
+    const { suggestions } = this.props;
+    const { suggestionsHidden } = this.state;
+    if (nextProps.suggestions && nextProps.suggestions !== suggestions && nextProps.suggestions.size > 0 && suggestionsHidden) {
+      this.setState({ suggestionsHidden: false });
+    }
+  }
+
+  //  Rendering.
+  render () {
+    const {
+      blur,
+      change,
+      clickSuggestion,
+      keyDown,
+      keyUp,
+      paste,
+      refTextarea,
+    } = this.handlers;
+    const {
+      autoFocus,
+      disabled,
+      intl,
+      onPickEmoji,
+      suggestions,
+      value,
+    } = this.props;
+    const {
+      selectedSuggestion,
+      suggestionsHidden,
+    } = this.state;
+
+    //  The result.
+    return (
+      <div className='autosuggest-textarea'>
+        <label>
+          <span {...hiddenComponent}><FormattedMessage {...messages.placeholder} /></span>
+          <Textarea
+            aria-autocomplete='list'
+            autoFocus={autoFocus}
+            disabled={disabled}
+            inputRef={refTextarea}
+            onBlur={blur}
+            onChange={change}
+            onKeyDown={keyDown}
+            onKeyUp={keyUp}
+            onPaste={paste}
+            placeholder={intl.formatMessage(messages.placeholder)}
+            value={value}
+            style={{ direction: isRtl(value) ? 'rtl' : 'ltr' }}
+          />
+        </label>
+        <EmojiPicker onPickEmoji={onPickEmoji} />
+        <ComposerTextareaSuggestions
+          hidden={suggestionsHidden}
+          onSuggestionClick={clickSuggestion}
+          suggestions={suggestions}
+          value={selectedSuggestion}
+        />
+      </div>
+    );
+  }
+
+}
+
+//  Props.
+ComposerTextarea.propTypes = {
+  autoFocus: PropTypes.bool,
+  disabled: PropTypes.bool,
+  intl: PropTypes.object.isRequired,
+  onChange: PropTypes.func,
+  onPaste: PropTypes.func,
+  onPickEmoji: PropTypes.func,
+  onSubmit: PropTypes.func,
+  onSuggestionsClearRequested: PropTypes.func,
+  onSuggestionsFetchRequested: PropTypes.func,
+  onSuggestionSelected: PropTypes.func,
+  suggestions: ImmutablePropTypes.list,
+  value: PropTypes.string,
+};
+
+//  Default props.
+ComposerTextarea.defaultProps = { autoFocus: true };
diff --git a/app/javascript/flavours/glitch/features/composer/textarea/suggestions/index.js b/app/javascript/flavours/glitch/features/composer/textarea/suggestions/index.js
new file mode 100644
index 000000000..b90696910
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/composer/textarea/suggestions/index.js
@@ -0,0 +1,41 @@
+//  Package imports.
+import classNames from 'classnames';
+import PropTypes from 'prop-types';
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+
+//  Components.
+import ComposerTextareaSuggestionsItem from './item';
+
+//  The component.
+export default function ComposerTextareaSuggestions ({
+  hidden,
+  onSuggestionClick,
+  suggestions,
+  value,
+}) {
+  const computedClass = classNames('comoser--textarea--suggestions', { hidden: hidden || suggestions.isEmpty() });
+
+  return (
+    <div className={computedClass}>
+      {!hidden ? suggestions.map(
+        (suggestion, index) => (
+          <ComposerTextareaSuggestionsItem
+            index={index}
+            key={typeof suggestion === 'object' ? suggestion.id : suggestion}
+            onClick={onSuggestionClick}
+            selected={index === value}
+            suggestion={suggestion}
+          />
+        )
+      ) : null}
+    </div>
+  );
+}
+
+ComposerTextareaSuggestions.propTypes = {
+  hidden: PropTypes.bool,
+  onSuggestionClick: PropTypes.func,
+  suggestions: ImmutablePropTypes.list,
+  value: PropTypes.string,
+};
diff --git a/app/javascript/flavours/glitch/features/composer/textarea/suggestions/item/index.js b/app/javascript/flavours/glitch/features/composer/textarea/suggestions/item/index.js
new file mode 100644
index 000000000..08c99ed0e
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/composer/textarea/suggestions/item/index.js
@@ -0,0 +1,101 @@
+//  Package imports.
+import classNames from 'classnames';
+import PropTypes from 'prop-types';
+import React from 'react';
+
+//  Components.
+import AccountContainer from 'flavours/glitch/containers/account_container';
+
+//  Utils.
+import { unicodeMapping } from 'flavours/glitch/util/emoji/emoji_unicode_mapping_light';
+import { assignHandlers } from 'flavours/glitch/util/react_helpers';
+
+//  Gets our asset host from the environment, if available.
+const assetHost = ((process || {}).env || {}).CDN_HOST || '';
+
+//  Handlers.
+const handlers = {
+
+  //  Handles a click on a suggestion.
+  click (e) {
+    const {
+      index,
+      onClick,
+    } = this.props;
+    if (onClick) {
+      e.preventDefault();
+      onClick(index);
+    }
+  },
+};
+
+//  The component.
+export default class ComposerTextareaSuggestionsItem extends React.Component {
+
+  //  Constructor.
+  constructor (props) {
+    super(props);
+    assignHandlers(this, handlers);
+  }
+
+  //  Rendering.
+  render () {
+    const { click } = this.handlers;
+    const {
+      selected,
+      suggestion,
+    } = this.props;
+    const computedClass = classNames('composer--textarea--suggestions--item', { selected });
+
+    //  The result.
+    return (
+      <div
+        role='button'
+        tabIndex='0'
+        className={computedClass}
+        onMouseDown={click}
+      >
+        { //  If the suggestion is an object, then we render an emoji.
+          //  Otherwise, we render an account.
+          typeof suggestion === 'object' ? function () {
+            const url = function () {
+              if (suggestion.custom) {
+                return suggestion.imageUrl;
+              } else {
+                const mapping = unicodeMapping[suggestion.native] || unicodeMapping[suggestion.native.replace(/\uFE0F$/, '')];
+                if (!mapping) {
+                  return null;
+                }
+                return `${assetHost}/emoji/${mapping.filename}.svg`;
+              }
+            }();
+            return url ? (
+              <div className='emoji'>
+                <img
+                  alt={suggestion.native || suggestion.colons}
+                  className='emojione'
+                  src={url}
+                />
+                {suggestion.colons}
+              </div>
+            ) : null;
+          }() : (
+            <AccountContainer
+              id={suggestion}
+              small
+            />
+          )
+        }
+      </div>
+    );
+  }
+
+}
+
+//  Props.
+ComposerTextareaSuggestionsItem.propTypes = {
+  index: PropTypes.number,
+  onClick: PropTypes.func,
+  selected: PropTypes.bool,
+  suggestion: PropTypes.oneOfType([PropTypes.object, PropTypes.string]),
+};
diff --git a/app/javascript/flavours/glitch/features/composer/upload_form/index.js b/app/javascript/flavours/glitch/features/composer/upload_form/index.js
new file mode 100644
index 000000000..ab46a3046
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/composer/upload_form/index.js
@@ -0,0 +1,54 @@
+//  Package imports.
+import classNames from 'classnames';
+import PropTypes from 'prop-types';
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+
+//  Components.
+import ComposerUploadFormItem from './item';
+import ComposerUploadFormProgress from './progress';
+
+//  The component.
+export default function ComposerUploadForm ({
+  active,
+  intl,
+  media,
+  onChangeDescription,
+  onRemove,
+  progress,
+}) {
+  const computedClass = classNames('composer--upload_form', { uploading: active });
+
+  //  We need `media` in order to be able to render.
+  if (!media) {
+    return null;
+  }
+
+  //  The result.
+  return (
+    <div className={computedClass}>
+      {active ? <ComposerUploadFormProgress progress={progress} /> : null}
+      {media.map(item => (
+        <ComposerUploadFormItem
+          description={item.get('description')}
+          key={item.get('id')}
+          id={item.get('id')}
+          intl={intl}
+          preview={item.get('preview_url')}
+          onChangeDescription={onChangeDescription}
+          onRemove={onRemove}
+        />
+      ))}
+    </div>
+  );
+}
+
+//  Props.
+ComposerUploadForm.propTypes = {
+  active: PropTypes.bool,
+  intl: PropTypes.object.isRequired,
+  media: ImmutablePropTypes.list,
+  onChangeDescription: PropTypes.func,
+  onRemove: PropTypes.func,
+  progress: PropTypes.number,
+};
diff --git a/app/javascript/flavours/glitch/features/composer/upload_form/item/index.js b/app/javascript/flavours/glitch/features/composer/upload_form/item/index.js
new file mode 100644
index 000000000..bd67e7227
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/composer/upload_form/item/index.js
@@ -0,0 +1,176 @@
+//  Package imports.
+import classNames from 'classnames';
+import PropTypes from 'prop-types';
+import React from 'react';
+import {
+  FormattedMessage,
+  defineMessages,
+} from 'react-intl';
+import spring from 'react-motion/lib/spring';
+
+//  Components.
+import IconButton from 'flavours/glitch/components/icon_button';
+
+//  Utils.
+import Motion from 'flavours/glitch/util/optional_motion';
+import { assignHandlers } from 'flavours/glitch/util/react_helpers';
+
+//  Messages.
+const messages = defineMessages({
+  undo: {
+    defaultMessage: 'Undo',
+    id: 'upload_form.undo',
+  },
+  description: {
+    defaultMessage: 'Describe for the visually impaired',
+    id: 'upload_form.description',
+  },
+});
+
+//  Handlers.
+const handlers = {
+
+  //  On blur, we save the description for the media item.
+  blur () {
+    const {
+      id,
+      onChangeDescription,
+    } = this.props;
+    const { dirtyDescription } = this.state;
+    if (id && onChangeDescription && dirtyDescription !== null) {
+      this.setState({
+        dirtyDescription: null,
+        focused: false,
+      });
+      onChangeDescription(id, dirtyDescription);
+    }
+  },
+
+  //  When the value of our description changes, we store it in the
+  //  temp value `dirtyDescription` in our state.
+  change ({ target: { value } }) {
+    this.setState({ dirtyDescription: value });
+  },
+
+  //  Records focus on the media item.
+  focus () {
+    this.setState({ focused: true });
+  },
+
+  //  Records the start of a hover over the media item.
+  mouseEnter () {
+    this.setState({ hovered: true });
+  },
+
+  //  Records the end of a hover over the media item.
+  mouseLeave () {
+    this.setState({ hovered: false });
+  },
+
+  //  Removes the media item.
+  remove () {
+    const {
+      id,
+      onRemove,
+    } = this.props;
+    if (id && onRemove) {
+      onRemove(id);
+    }
+  },
+};
+
+//  The component.
+export default class ComposerUploadFormItem extends React.PureComponent {
+
+  //  Constructor.
+  constructor (props) {
+    super(props);
+    assignHandlers(handlers);
+    this.state = {
+      hovered: false,
+      focused: false,
+      dirtyDescription: null,
+    };
+  }
+
+  //  Rendering.
+  render () {
+    const {
+      blur,
+      change,
+      focus,
+      mouseEnter,
+      mouseLeave,
+      remove,
+    } = this.handlers;
+    const {
+      description,
+      intl,
+      preview,
+    } = this.props;
+    const {
+      focused,
+      hovered,
+      dirtyDescription,
+    } = this.state;
+    const computedClass = classNames('composer--upload_form--item', { active: hovered || focused });
+
+    //  The result.
+    return (
+      <div
+        className={computedClass}
+        onMouseEnter={mouseEnter}
+        onMouseLeave={mouseLeave}
+      >
+        <Motion
+          defaultStyle={{ scale: 0.8 }}
+          style={{
+            scale: spring(1, {
+              stiffness: 180,
+              damping: 12,
+            }),
+          }}
+        >
+          {({ scale }) => (
+            <div
+              style={{
+                transform: `scale(${scale})`,
+                backgroundImage: preview ? `url(${preview})` : null,
+              }}
+            >
+              <IconButton
+                icon='times'
+                onClick={remove}
+                size={36}
+                title={intl.formatMessage(messages.undo)}
+              />
+              <label>
+                <span style={{ display: 'none' }}><FormattedMessage {...messages.description} /></span>
+                <input
+                  maxLength={420}
+                  onBlur={blur}
+                  onChange={change}
+                  onFocus={focus}
+                  placeholder={intl.formatMessage(messages.description)}
+                  type='text'
+                  value={dirtyDescription || description || ''}
+                />
+              </label>
+            </div>
+          )}
+        </Motion>
+      </div>
+    );
+  }
+
+}
+
+//  Props.
+ComposerUploadFormItem.propTypes = {
+  description: PropTypes.string,
+  id: PropTypes.number,
+  intl: PropTypes.object.isRequired,
+  onChangeDescription: PropTypes.func,
+  onRemove: PropTypes.func,
+  preview: PropTypes.string,
+};
diff --git a/app/javascript/flavours/glitch/features/composer/upload_form/progress/index.js b/app/javascript/flavours/glitch/features/composer/upload_form/progress/index.js
new file mode 100644
index 000000000..9dac6acf9
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/composer/upload_form/progress/index.js
@@ -0,0 +1,52 @@
+//  Package imports.
+import PropTypes from 'prop-types';
+import React from 'react';
+import {
+  defineMessages,
+  FormattedMessage,
+} from 'react-intl';
+import spring from 'react-motion/lib/spring';
+
+//  Components.
+import Icon from 'flavours/glitch/components/icon';
+
+//  Utils.
+import Motion from 'flavours/glitch/util/optional_motion';
+
+//  Messages.
+const messages = defineMessages({
+  upload: {
+    defaultMessage: 'Uploading...',
+    id: 'upload_progress.label',
+  },
+});
+
+//  The component.
+export default function ComposerUploadFormProgress ({ progress }) {
+
+  //  The result.
+  return (
+    <div className='composer--upload_form--progress'>
+      <Icon icon='upload' />
+      <div className='message'>
+        <FormattedMessage {...messages.upload} />
+        <div className='backdrop'>
+          <Motion
+            defaultStyle={{ width: 0 }}
+            style={{ width: spring(progress) }}
+          >
+            {({ width }) =>
+              <div
+                className='tracker'
+                style={{ width: `${width}%` }}
+              />
+            }
+          </Motion>
+        </div>
+      </div>
+    </div>
+  );
+}
+
+//  Props.
+ComposerUploadFormProgress.propTypes = { progress: PropTypes.number };
diff --git a/app/javascript/flavours/glitch/features/composer/warning/index.js b/app/javascript/flavours/glitch/features/composer/warning/index.js
new file mode 100644
index 000000000..c225b50e8
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/composer/warning/index.js
@@ -0,0 +1,54 @@
+import React from 'react';
+import Motion from 'flavours/glitch/util/optional_motion';
+import spring from 'react-motion/lib/spring';
+import { defineMessages, FormattedMessage } from 'react-intl';
+
+//  This is the spring used with our motion.
+const motionSpring = spring(1, { damping: 35, stiffness: 400 });
+
+//  Messages.
+const messages = defineMessages({
+  disclaimer: {
+    defaultMessage: 'Your account is not {locked}. Anyone can follow you to view your follower-only posts.',
+    id: 'compose_form.lock_disclaimer',
+  },
+  locked: {
+    defaultMessage: 'locked',
+    id: 'compose_form.lock_disclaimer.lock',
+  },
+});
+
+//  The component.
+export default function ComposerWarning () {
+  return (
+    <Motion
+      defaultStyle={{
+        opacity: 0,
+        scaleX: 0.85,
+        scaleY: 0.75,
+      }}
+      style={{
+        opacity: motionSpring,
+        scaleX: motionSpring,
+        scaleY: motionSpring,
+      }}
+    >
+      {({ opacity, scaleX, scaleY }) => (
+        <div
+          className='composer--warning'
+          style={{
+            opacity: opacity,
+            transform: `scale(${scaleX}, ${scaleY})`,
+          }}
+        >
+          <FormattedMessage
+            {...messages.disclaimer}
+            values={{ locked: <a href='/settings/profile'><FormattedMessage {...messages.locked} /></a> }}
+          />
+        </div>
+      )}
+    </Motion>
+  );
+}
+
+ComposerWarning.propTypes = {};