about summary refs log tree commit diff
path: root/app/javascript/themes/glitch/features/compose/components
diff options
context:
space:
mode:
Diffstat (limited to 'app/javascript/themes/glitch/features/compose/components')
-rw-r--r--app/javascript/themes/glitch/features/compose/components/advanced_options.js62
-rw-r--r--app/javascript/themes/glitch/features/compose/components/advanced_options_toggle.js35
-rw-r--r--app/javascript/themes/glitch/features/compose/components/attach_options.js131
-rw-r--r--app/javascript/themes/glitch/features/compose/components/autosuggest_account.js24
-rw-r--r--app/javascript/themes/glitch/features/compose/components/character_counter.js25
-rw-r--r--app/javascript/themes/glitch/features/compose/components/compose_form.js286
-rw-r--r--app/javascript/themes/glitch/features/compose/components/dropdown.js77
-rw-r--r--app/javascript/themes/glitch/features/compose/components/emoji_picker_dropdown.js376
-rw-r--r--app/javascript/themes/glitch/features/compose/components/navigation_bar.js38
-rw-r--r--app/javascript/themes/glitch/features/compose/components/privacy_dropdown.js200
-rw-r--r--app/javascript/themes/glitch/features/compose/components/reply_indicator.js63
-rw-r--r--app/javascript/themes/glitch/features/compose/components/search.js129
-rw-r--r--app/javascript/themes/glitch/features/compose/components/search_results.js65
-rw-r--r--app/javascript/themes/glitch/features/compose/components/text_icon_button.js29
-rw-r--r--app/javascript/themes/glitch/features/compose/components/upload.js96
-rw-r--r--app/javascript/themes/glitch/features/compose/components/upload_button.js77
-rw-r--r--app/javascript/themes/glitch/features/compose/components/upload_form.js29
-rw-r--r--app/javascript/themes/glitch/features/compose/components/upload_progress.js42
-rw-r--r--app/javascript/themes/glitch/features/compose/components/warning.js26
19 files changed, 1810 insertions, 0 deletions
diff --git a/app/javascript/themes/glitch/features/compose/components/advanced_options.js b/app/javascript/themes/glitch/features/compose/components/advanced_options.js
new file mode 100644
index 000000000..045bad2e5
--- /dev/null
+++ b/app/javascript/themes/glitch/features/compose/components/advanced_options.js
@@ -0,0 +1,62 @@
+//  Package imports.
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { injectIntl, defineMessages } from 'react-intl';
+
+//  Our imports.
+import ComposeAdvancedOptionsToggle from './advanced_options_toggle';
+import ComposeDropdown from './dropdown';
+
+const messages = defineMessages({
+  local_only_short            :
+    { id: 'advanced-options.local-only.short', defaultMessage: 'Local-only' },
+  local_only_long             :
+    { id: 'advanced-options.local-only.long', defaultMessage: 'Do not post to other instances' },
+  advanced_options_icon_title :
+    { id: 'advanced_options.icon_title', defaultMessage: 'Advanced options' },
+});
+
+@injectIntl
+export default class ComposeAdvancedOptions extends React.PureComponent {
+
+  static propTypes = {
+    values   : ImmutablePropTypes.contains({
+      do_not_federate : PropTypes.bool.isRequired,
+    }).isRequired,
+    onChange : PropTypes.func.isRequired,
+    intl     : PropTypes.object.isRequired,
+  };
+
+  render () {
+    const { intl, values } = this.props;
+    const options = [
+      { icon: 'wifi', shortText: messages.local_only_short, longText: messages.local_only_long, name: 'do_not_federate' },
+    ];
+    const anyEnabled = values.some((enabled) => enabled);
+
+    const optionElems = options.map((option) => {
+      return (
+        <ComposeAdvancedOptionsToggle
+          onChange={this.props.onChange}
+          active={values.get(option.name)}
+          key={option.name}
+          name={option.name}
+          shortText={intl.formatMessage(option.shortText)}
+          longText={intl.formatMessage(option.longText)}
+        />
+      );
+    });
+
+    return (
+      <ComposeDropdown
+        title={intl.formatMessage(messages.advanced_options_icon_title)}
+        icon='home'
+        highlight={anyEnabled}
+      >
+        {optionElems}
+      </ComposeDropdown>
+    );
+  }
+
+}
diff --git a/app/javascript/themes/glitch/features/compose/components/advanced_options_toggle.js b/app/javascript/themes/glitch/features/compose/components/advanced_options_toggle.js
new file mode 100644
index 000000000..98b3b6a44
--- /dev/null
+++ b/app/javascript/themes/glitch/features/compose/components/advanced_options_toggle.js
@@ -0,0 +1,35 @@
+//  Package imports.
+import React from 'react';
+import PropTypes from 'prop-types';
+import Toggle from 'react-toggle';
+
+export default class ComposeAdvancedOptionsToggle extends React.PureComponent {
+
+  static propTypes = {
+    onChange: PropTypes.func.isRequired,
+    active: PropTypes.bool.isRequired,
+    name: PropTypes.string.isRequired,
+    shortText: PropTypes.string.isRequired,
+    longText: PropTypes.string.isRequired,
+  }
+
+  onToggle = () => {
+    this.props.onChange(this.props.name);
+  }
+
+  render() {
+    const { active, shortText, longText } = this.props;
+    return (
+      <div role='button' tabIndex='0' className='advanced-options-dropdown__option' onClick={this.onToggle}>
+        <div className='advanced-options-dropdown__option__toggle'>
+          <Toggle checked={active} onChange={this.onToggle} />
+        </div>
+        <div className='advanced-options-dropdown__option__content'>
+          <strong>{shortText}</strong>
+          {longText}
+        </div>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/themes/glitch/features/compose/components/attach_options.js b/app/javascript/themes/glitch/features/compose/components/attach_options.js
new file mode 100644
index 000000000..c396714f3
--- /dev/null
+++ b/app/javascript/themes/glitch/features/compose/components/attach_options.js
@@ -0,0 +1,131 @@
+//  Package imports  //
+import React from 'react';
+import PropTypes from 'prop-types';
+import { connect } from 'react-redux';
+import { injectIntl, defineMessages } from 'react-intl';
+
+//  Our imports  //
+import ComposeDropdown from './dropdown';
+import { uploadCompose } from 'themes/glitch/actions/compose';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import { openModal } from 'themes/glitch/actions/modal';
+
+const messages = defineMessages({
+  upload :
+    { id: 'compose.attach.upload', defaultMessage: 'Upload a file' },
+  doodle :
+    { id: 'compose.attach.doodle', defaultMessage: 'Draw something' },
+  attach :
+    { id: 'compose.attach', defaultMessage: 'Attach...' },
+});
+
+const mapStateToProps = state => ({
+  // This horrible expression is copied from vanilla upload_button_container
+  disabled: state.getIn(['compose', 'is_uploading']) || (state.getIn(['compose', 'media_attachments']).size > 3 || state.getIn(['compose', 'media_attachments']).some(m => m.get('type') === 'video')),
+  resetFileKey: state.getIn(['compose', 'resetFileKey']),
+  acceptContentTypes: state.getIn(['media_attachments', 'accept_content_types']),
+});
+
+const mapDispatchToProps = dispatch => ({
+  onSelectFile (files) {
+    dispatch(uploadCompose(files));
+  },
+  onOpenDoodle () {
+    dispatch(openModal('DOODLE', { noEsc: true }));
+  },
+});
+
+@injectIntl
+@connect(mapStateToProps, mapDispatchToProps)
+export default class ComposeAttachOptions extends ImmutablePureComponent {
+
+  static propTypes = {
+    intl     : PropTypes.object.isRequired,
+    resetFileKey: PropTypes.number,
+    acceptContentTypes: ImmutablePropTypes.listOf(PropTypes.string).isRequired,
+    disabled: PropTypes.bool,
+    onSelectFile: PropTypes.func.isRequired,
+    onOpenDoodle: PropTypes.func.isRequired,
+  };
+
+  handleItemClick = bt => {
+    if (bt === 'upload') {
+      this.fileElement.click();
+    }
+
+    if (bt === 'doodle') {
+      this.props.onOpenDoodle();
+    }
+
+    this.dropdown.setState({ open: false });
+  };
+
+  handleFileChange = (e) => {
+    if (e.target.files.length > 0) {
+      this.props.onSelectFile(e.target.files);
+    }
+  }
+
+  setFileRef = (c) => {
+    this.fileElement = c;
+  }
+
+  setDropdownRef = (c) => {
+    this.dropdown = c;
+  }
+
+  render () {
+    const { intl, resetFileKey, disabled, acceptContentTypes } = this.props;
+
+    const options = [
+      { icon: 'cloud-upload', text: messages.upload, name: 'upload' },
+      { icon: 'paint-brush', text: messages.doodle, name: 'doodle' },
+    ];
+
+    const optionElems = options.map((item) => {
+      const hdl = () => this.handleItemClick(item.name);
+      return (
+        <div
+          role='button'
+          tabIndex='0'
+          key={item.name}
+          onClick={hdl}
+          className='privacy-dropdown__option'
+        >
+          <div className='privacy-dropdown__option__icon'>
+            <i className={`fa fa-fw fa-${item.icon}`} />
+          </div>
+
+          <div className='privacy-dropdown__option__content'>
+            <strong>{intl.formatMessage(item.text)}</strong>
+          </div>
+        </div>
+      );
+    });
+
+    return (
+      <div>
+        <ComposeDropdown
+          title={intl.formatMessage(messages.attach)}
+          icon='paperclip'
+          disabled={disabled}
+          ref={this.setDropdownRef}
+        >
+          {optionElems}
+        </ComposeDropdown>
+        <input
+          key={resetFileKey}
+          ref={this.setFileRef}
+          type='file'
+          multiple={false}
+          accept={acceptContentTypes.toArray().join(',')}
+          onChange={this.handleFileChange}
+          disabled={disabled}
+          style={{ display: 'none' }}
+        />
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/themes/glitch/features/compose/components/autosuggest_account.js b/app/javascript/themes/glitch/features/compose/components/autosuggest_account.js
new file mode 100644
index 000000000..4a98d89fe
--- /dev/null
+++ b/app/javascript/themes/glitch/features/compose/components/autosuggest_account.js
@@ -0,0 +1,24 @@
+import React from 'react';
+import Avatar from 'themes/glitch/components/avatar';
+import DisplayName from 'themes/glitch/components/display_name';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+export default class AutosuggestAccount extends ImmutablePureComponent {
+
+  static propTypes = {
+    account: ImmutablePropTypes.map.isRequired,
+  };
+
+  render () {
+    const { account } = this.props;
+
+    return (
+      <div className='autosuggest-account'>
+        <div className='autosuggest-account-icon'><Avatar account={account} size={18} /></div>
+        <DisplayName account={account} />
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/themes/glitch/features/compose/components/character_counter.js b/app/javascript/themes/glitch/features/compose/components/character_counter.js
new file mode 100644
index 000000000..0ecfc9141
--- /dev/null
+++ b/app/javascript/themes/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/themes/glitch/features/compose/components/compose_form.js b/app/javascript/themes/glitch/features/compose/components/compose_form.js
new file mode 100644
index 000000000..54b1944a4
--- /dev/null
+++ b/app/javascript/themes/glitch/features/compose/components/compose_form.js
@@ -0,0 +1,286 @@
+import React from 'react';
+import CharacterCounter from './character_counter';
+import Button from 'themes/glitch/components/button';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import ReplyIndicatorContainer from '../containers/reply_indicator_container';
+import AutosuggestTextarea from 'themes/glitch/components/autosuggest_textarea';
+import { defineMessages, injectIntl } from 'react-intl';
+import Collapsable from 'themes/glitch/components/collapsable';
+import SpoilerButtonContainer from '../containers/spoiler_button_container';
+import PrivacyDropdownContainer from '../containers/privacy_dropdown_container';
+import ComposeAdvancedOptionsContainer from '../containers/advanced_options_container';
+import SensitiveButtonContainer from '../containers/sensitive_button_container';
+import EmojiPickerDropdown from '../containers/emoji_picker_dropdown_container';
+import UploadFormContainer from '../containers/upload_form_container';
+import WarningContainer from '../containers/warning_container';
+import { isMobile } from 'themes/glitch/util/is_mobile';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import { length } from 'stringz';
+import { countableText } from 'themes/glitch/util/counter';
+import ComposeAttachOptions from './attach_options';
+import initialState from 'themes/glitch/util/initial_state';
+
+const maxChars = initialState.max_toot_chars;
+
+const messages = defineMessages({
+  placeholder: { id: 'compose_form.placeholder', defaultMessage: 'What is on your mind?' },
+  spoiler_placeholder: { id: 'compose_form.spoiler_placeholder', defaultMessage: 'Write your warning here' },
+  publish: { id: 'compose_form.publish', defaultMessage: 'Toot' },
+  publishLoud: { id: 'compose_form.publish_loud', defaultMessage: '{publish}!' },
+});
+
+@injectIntl
+export default class ComposeForm extends ImmutablePureComponent {
+
+  static propTypes = {
+    intl: PropTypes.object.isRequired,
+    text: PropTypes.string.isRequired,
+    suggestion_token: PropTypes.string,
+    suggestions: ImmutablePropTypes.list,
+    spoiler: PropTypes.bool,
+    privacy: PropTypes.string,
+    advanced_options: ImmutablePropTypes.contains({
+      do_not_federate: PropTypes.bool,
+    }),
+    spoiler_text: PropTypes.string,
+    focusDate: PropTypes.instanceOf(Date),
+    preselectDate: PropTypes.instanceOf(Date),
+    is_submitting: PropTypes.bool,
+    is_uploading: PropTypes.bool,
+    onChange: PropTypes.func.isRequired,
+    onSubmit: PropTypes.func.isRequired,
+    onClearSuggestions: PropTypes.func.isRequired,
+    onFetchSuggestions: PropTypes.func.isRequired,
+    onPrivacyChange: PropTypes.func.isRequired,
+    onSuggestionSelected: PropTypes.func.isRequired,
+    onChangeSpoilerText: PropTypes.func.isRequired,
+    onPaste: PropTypes.func.isRequired,
+    onPickEmoji: PropTypes.func.isRequired,
+    showSearch: PropTypes.bool,
+    settings : ImmutablePropTypes.map.isRequired,
+  };
+
+  static defaultProps = {
+    showSearch: false,
+  };
+
+  handleChange = (e) => {
+    this.props.onChange(e.target.value);
+  }
+
+  handleKeyDown = (e) => {
+    if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) {
+      this.handleSubmit();
+    }
+  }
+
+  handleSubmit2 = () => {
+    this.props.onPrivacyChange(this.props.settings.get('side_arm'));
+    this.handleSubmit();
+  }
+
+  handleSubmit = () => {
+    if (this.props.text !== this.autosuggestTextarea.textarea.value) {
+      // Something changed the text inside the textarea (e.g. browser extensions like Grammarly)
+      // Update the state to match the current text
+      this.props.onChange(this.autosuggestTextarea.textarea.value);
+    }
+
+    this.props.onSubmit();
+  }
+
+  onSuggestionsClearRequested = () => {
+    this.props.onClearSuggestions();
+  }
+
+  onSuggestionsFetchRequested = (token) => {
+    this.props.onFetchSuggestions(token);
+  }
+
+  onSuggestionSelected = (tokenStart, token, value) => {
+    this._restoreCaret = null;
+    this.props.onSuggestionSelected(tokenStart, token, value);
+  }
+
+  handleChangeSpoilerText = (e) => {
+    this.props.onChangeSpoilerText(e.target.value);
+  }
+
+  componentWillReceiveProps (nextProps) {
+    // If this is the update where we've finished uploading,
+    // save the last caret position so we can restore it below!
+    if (!nextProps.is_uploading && this.props.is_uploading) {
+      this._restoreCaret = this.autosuggestTextarea.textarea.selectionStart;
+    }
+  }
+
+  componentDidUpdate (prevProps) {
+    // This statement does several things:
+    // - If we're beginning a reply, and,
+    //     - Replying to zero or one users, places the cursor at the end of the textbox.
+    //     - Replying to more than one user, selects any usernames past the first;
+    //       this provides a convenient shortcut to drop everyone else from the conversation.
+    // - If we've just finished uploading an image, and have a saved caret position,
+    //   restores the cursor to that position after the text changes!
+    if (this.props.focusDate !== prevProps.focusDate || (prevProps.is_uploading && !this.props.is_uploading && typeof this._restoreCaret === 'number')) {
+      let selectionEnd, selectionStart;
+
+      if (this.props.preselectDate !== prevProps.preselectDate) {
+        selectionEnd   = this.props.text.length;
+        selectionStart = this.props.text.search(/\s/) + 1;
+      } else if (typeof this._restoreCaret === 'number') {
+        selectionStart = this._restoreCaret;
+        selectionEnd   = this._restoreCaret;
+      } else {
+        selectionEnd   = this.props.text.length;
+        selectionStart = selectionEnd;
+      }
+
+      this.autosuggestTextarea.textarea.setSelectionRange(selectionStart, selectionEnd);
+      this.autosuggestTextarea.textarea.focus();
+    } else if(prevProps.is_submitting && !this.props.is_submitting) {
+      this.autosuggestTextarea.textarea.focus();
+    }
+  }
+
+  setAutosuggestTextarea = (c) => {
+    this.autosuggestTextarea = c;
+  }
+
+  handleEmojiPick = (data) => {
+    const position     = this.autosuggestTextarea.textarea.selectionStart;
+    const emojiChar    = data.native;
+    this._restoreCaret = position + emojiChar.length + 1;
+    this.props.onPickEmoji(position, data);
+  }
+
+  render () {
+    const { intl, onPaste, showSearch } = this.props;
+    const disabled = this.props.is_submitting;
+    const maybeEye = (this.props.advanced_options && this.props.advanced_options.do_not_federate) ? ' 👁️' : '';
+    const text     = [this.props.spoiler_text, countableText(this.props.text), maybeEye].join('');
+
+    const secondaryVisibility = this.props.settings.get('side_arm');
+    let showSideArm = secondaryVisibility !== 'none';
+
+    let publishText = '';
+    let publishText2 = '';
+    let title = '';
+    let title2 = '';
+
+    const privacyIcons = {
+      none: '',
+      public: 'globe',
+      unlisted: 'unlock-alt',
+      private: 'lock',
+      direct: 'envelope',
+    };
+
+    title = `${intl.formatMessage(messages.publish)}: ${intl.formatMessage({ id: `privacy.${this.props.privacy}.short` })}`;
+
+    if (showSideArm) {
+      // Enhanced behavior with dual toot buttons
+      publishText = (
+        <span>
+          {
+            <i
+              className={`fa fa-${privacyIcons[this.props.privacy]}`}
+              style={{ paddingRight: '5px' }}
+            />
+          }{intl.formatMessage(messages.publish)}
+        </span>
+      );
+
+      title2 = `${intl.formatMessage(messages.publish)}: ${intl.formatMessage({ id: `privacy.${secondaryVisibility}.short` })}`;
+      publishText2 = (
+        <i
+          className={`fa fa-${privacyIcons[secondaryVisibility]}`}
+          aria-label={title2}
+        />
+      );
+    } else {
+      // Original vanilla behavior - no icon if public or unlisted
+      if (this.props.privacy === 'private' || this.props.privacy === 'direct') {
+        publishText = <span className='compose-form__publish-private'><i className='fa fa-lock' /> {intl.formatMessage(messages.publish)}</span>;
+      } else {
+        publishText = this.props.privacy !== 'unlisted' ? intl.formatMessage(messages.publishLoud, { publish: intl.formatMessage(messages.publish) }) : intl.formatMessage(messages.publish);
+      }
+    }
+
+    const submitDisabled = disabled || this.props.is_uploading || length(text) > maxChars || (text.length !== 0 && text.trim().length === 0);
+
+    return (
+      <div className='compose-form'>
+        <Collapsable isVisible={this.props.spoiler} fullHeight={50}>
+          <div className='spoiler-input'>
+            <label>
+              <span style={{ display: 'none' }}>{intl.formatMessage(messages.spoiler_placeholder)}</span>
+              <input placeholder={intl.formatMessage(messages.spoiler_placeholder)} value={this.props.spoiler_text} onChange={this.handleChangeSpoilerText} onKeyDown={this.handleKeyDown} type='text' className='spoiler-input__input'  id='cw-spoiler-input' />
+            </label>
+          </div>
+        </Collapsable>
+
+        <WarningContainer />
+
+        <ReplyIndicatorContainer />
+
+        <div className='compose-form__autosuggest-wrapper'>
+          <AutosuggestTextarea
+            ref={this.setAutosuggestTextarea}
+            placeholder={intl.formatMessage(messages.placeholder)}
+            disabled={disabled}
+            value={this.props.text}
+            onChange={this.handleChange}
+            suggestions={this.props.suggestions}
+            onKeyDown={this.handleKeyDown}
+            onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
+            onSuggestionsClearRequested={this.onSuggestionsClearRequested}
+            onSuggestionSelected={this.onSuggestionSelected}
+            onPaste={onPaste}
+            autoFocus={!showSearch && !isMobile(window.innerWidth)}
+          />
+
+          <EmojiPickerDropdown onPickEmoji={this.handleEmojiPick} />
+        </div>
+
+        <div className='compose-form__modifiers'>
+          <UploadFormContainer />
+        </div>
+
+        <div className='compose-form__buttons'>
+          <ComposeAttachOptions />
+          <SensitiveButtonContainer />
+          <div className='compose-form__buttons-separator' />
+          <PrivacyDropdownContainer />
+          <SpoilerButtonContainer />
+          <ComposeAdvancedOptionsContainer />
+        </div>
+
+        <div className='compose-form__publish'>
+          <div className='character-counter__wrapper'><CharacterCounter max={maxChars} text={text} /></div>
+          <div className='compose-form__publish-button-wrapper'>
+            {
+              showSideArm ?
+                <Button
+                  className='compose-form__publish__side-arm'
+                  text={publishText2}
+                  title={title2}
+                  onClick={this.handleSubmit2}
+                  disabled={submitDisabled}
+                /> : ''
+            }
+            <Button
+              className='compose-form__publish__primary'
+              text={publishText}
+              title={title}
+              onClick={this.handleSubmit}
+              disabled={submitDisabled}
+            />
+          </div>
+        </div>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/themes/glitch/features/compose/components/dropdown.js b/app/javascript/themes/glitch/features/compose/components/dropdown.js
new file mode 100644
index 000000000..f3d9f094e
--- /dev/null
+++ b/app/javascript/themes/glitch/features/compose/components/dropdown.js
@@ -0,0 +1,77 @@
+//  Package imports.
+import React from 'react';
+import PropTypes from 'prop-types';
+
+//  Our imports.
+import IconButton from 'themes/glitch/components/icon_button';
+
+const iconStyle = {
+  height     : null,
+  lineHeight : '27px',
+};
+
+export default class ComposeDropdown extends React.PureComponent {
+
+  static propTypes = {
+    title: PropTypes.string.isRequired,
+    icon: PropTypes.string,
+    highlight: PropTypes.bool,
+    disabled: PropTypes.bool,
+    children: PropTypes.arrayOf(PropTypes.node).isRequired,
+  };
+
+  state = {
+    open: false,
+  };
+
+  onGlobalClick = (e) => {
+    if (e.target !== this.node && !this.node.contains(e.target) && this.state.open) {
+      this.setState({ open: false });
+    }
+  };
+
+  componentDidMount () {
+    window.addEventListener('click', this.onGlobalClick);
+    window.addEventListener('touchstart', this.onGlobalClick);
+  }
+  componentWillUnmount () {
+    window.removeEventListener('click', this.onGlobalClick);
+    window.removeEventListener('touchstart', this.onGlobalClick);
+  }
+
+  onToggleDropdown = () => {
+    if (this.props.disabled) return;
+    this.setState({ open: !this.state.open });
+  };
+
+  setRef = (c) => {
+    this.node = c;
+  };
+
+  render () {
+    const { open } = this.state;
+    let { highlight, title, icon, disabled } = this.props;
+
+    if (!icon) icon = 'ellipsis-h';
+
+    return (
+      <div ref={this.setRef} className={`advanced-options-dropdown ${open ?  'open' : ''} ${highlight ? 'active' : ''} `}>
+        <div className='advanced-options-dropdown__value'>
+          <IconButton
+            className={'inverted'}
+            title={title}
+            icon={icon} active={open || highlight}
+            size={18}
+            style={iconStyle}
+            disabled={disabled}
+            onClick={this.onToggleDropdown}
+          />
+        </div>
+        <div className='advanced-options-dropdown__dropdown'>
+          {this.props.children}
+        </div>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/themes/glitch/features/compose/components/emoji_picker_dropdown.js b/app/javascript/themes/glitch/features/compose/components/emoji_picker_dropdown.js
new file mode 100644
index 000000000..fd59efb85
--- /dev/null
+++ b/app/javascript/themes/glitch/features/compose/components/emoji_picker_dropdown.js
@@ -0,0 +1,376 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { defineMessages, injectIntl } from 'react-intl';
+import { EmojiPicker as EmojiPickerAsync } from 'themes/glitch/util/async-components';
+import Overlay from 'react-overlays/lib/Overlay';
+import classNames from 'classnames';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import detectPassiveEvents from 'detect-passive-events';
+import { buildCustomEmojis } from 'themes/glitch/util/emoji';
+
+const messages = defineMessages({
+  emoji: { id: 'emoji_button.label', defaultMessage: 'Insert emoji' },
+  emoji_search: { id: 'emoji_button.search', defaultMessage: 'Search...' },
+  emoji_not_found: { id: 'emoji_button.not_found', defaultMessage: 'No emojos!! (╯°□°)╯︵ ┻━┻' },
+  custom: { id: 'emoji_button.custom', defaultMessage: 'Custom' },
+  recent: { id: 'emoji_button.recent', defaultMessage: 'Frequently used' },
+  search_results: { id: 'emoji_button.search_results', defaultMessage: 'Search results' },
+  people: { id: 'emoji_button.people', defaultMessage: 'People' },
+  nature: { id: 'emoji_button.nature', defaultMessage: 'Nature' },
+  food: { id: 'emoji_button.food', defaultMessage: 'Food & Drink' },
+  activity: { id: 'emoji_button.activity', defaultMessage: 'Activity' },
+  travel: { id: 'emoji_button.travel', defaultMessage: 'Travel & Places' },
+  objects: { id: 'emoji_button.objects', defaultMessage: 'Objects' },
+  symbols: { id: 'emoji_button.symbols', defaultMessage: 'Symbols' },
+  flags: { id: 'emoji_button.flags', defaultMessage: 'Flags' },
+});
+
+const assetHost = process.env.CDN_HOST || '';
+let EmojiPicker, Emoji; // load asynchronously
+
+const backgroundImageFn = () => `${assetHost}/emoji/sheet.png`;
+const listenerOptions = detectPassiveEvents.hasSupport ? { passive: true } : false;
+
+const categoriesSort = [
+  'recent',
+  'custom',
+  'people',
+  'nature',
+  'foods',
+  'activity',
+  'places',
+  'objects',
+  'symbols',
+  'flags',
+];
+
+class ModifierPickerMenu extends React.PureComponent {
+
+  static propTypes = {
+    active: PropTypes.bool,
+    onSelect: PropTypes.func.isRequired,
+    onClose: PropTypes.func.isRequired,
+  };
+
+  handleClick = e => {
+    this.props.onSelect(e.currentTarget.getAttribute('data-index') * 1);
+  }
+
+  componentWillReceiveProps (nextProps) {
+    if (nextProps.active) {
+      this.attachListeners();
+    } else {
+      this.removeListeners();
+    }
+  }
+
+  componentWillUnmount () {
+    this.removeListeners();
+  }
+
+  handleDocumentClick = e => {
+    if (this.node && !this.node.contains(e.target)) {
+      this.props.onClose();
+    }
+  }
+
+  attachListeners () {
+    document.addEventListener('click', this.handleDocumentClick, false);
+    document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);
+  }
+
+  removeListeners () {
+    document.removeEventListener('click', this.handleDocumentClick, false);
+    document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions);
+  }
+
+  setRef = c => {
+    this.node = c;
+  }
+
+  render () {
+    const { active } = this.props;
+
+    return (
+      <div className='emoji-picker-dropdown__modifiers__menu' style={{ display: active ? 'block' : 'none' }} ref={this.setRef}>
+        <button onClick={this.handleClick} data-index={1}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={1} backgroundImageFn={backgroundImageFn} /></button>
+        <button onClick={this.handleClick} data-index={2}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={2} backgroundImageFn={backgroundImageFn} /></button>
+        <button onClick={this.handleClick} data-index={3}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={3} backgroundImageFn={backgroundImageFn} /></button>
+        <button onClick={this.handleClick} data-index={4}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={4} backgroundImageFn={backgroundImageFn} /></button>
+        <button onClick={this.handleClick} data-index={5}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={5} backgroundImageFn={backgroundImageFn} /></button>
+        <button onClick={this.handleClick} data-index={6}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={6} backgroundImageFn={backgroundImageFn} /></button>
+      </div>
+    );
+  }
+
+}
+
+class ModifierPicker extends React.PureComponent {
+
+  static propTypes = {
+    active: PropTypes.bool,
+    modifier: PropTypes.number,
+    onChange: PropTypes.func,
+    onClose: PropTypes.func,
+    onOpen: PropTypes.func,
+  };
+
+  handleClick = () => {
+    if (this.props.active) {
+      this.props.onClose();
+    } else {
+      this.props.onOpen();
+    }
+  }
+
+  handleSelect = modifier => {
+    this.props.onChange(modifier);
+    this.props.onClose();
+  }
+
+  render () {
+    const { active, modifier } = this.props;
+
+    return (
+      <div className='emoji-picker-dropdown__modifiers'>
+        <Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={modifier} onClick={this.handleClick} backgroundImageFn={backgroundImageFn} />
+        <ModifierPickerMenu active={active} onSelect={this.handleSelect} onClose={this.props.onClose} />
+      </div>
+    );
+  }
+
+}
+
+@injectIntl
+class EmojiPickerMenu extends React.PureComponent {
+
+  static propTypes = {
+    custom_emojis: ImmutablePropTypes.list,
+    frequentlyUsedEmojis: PropTypes.arrayOf(PropTypes.string),
+    loading: PropTypes.bool,
+    onClose: PropTypes.func.isRequired,
+    onPick: PropTypes.func.isRequired,
+    style: PropTypes.object,
+    placement: PropTypes.string,
+    arrowOffsetLeft: PropTypes.string,
+    arrowOffsetTop: PropTypes.string,
+    intl: PropTypes.object.isRequired,
+    skinTone: PropTypes.number.isRequired,
+    onSkinTone: PropTypes.func.isRequired,
+  };
+
+  static defaultProps = {
+    style: {},
+    loading: true,
+    placement: 'bottom',
+    frequentlyUsedEmojis: [],
+  };
+
+  state = {
+    modifierOpen: false,
+  };
+
+  handleDocumentClick = e => {
+    if (this.node && !this.node.contains(e.target)) {
+      this.props.onClose();
+    }
+  }
+
+  componentDidMount () {
+    document.addEventListener('click', this.handleDocumentClick, false);
+    document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);
+  }
+
+  componentWillUnmount () {
+    document.removeEventListener('click', this.handleDocumentClick, false);
+    document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions);
+  }
+
+  setRef = c => {
+    this.node = c;
+  }
+
+  getI18n = () => {
+    const { intl } = this.props;
+
+    return {
+      search: intl.formatMessage(messages.emoji_search),
+      notfound: intl.formatMessage(messages.emoji_not_found),
+      categories: {
+        search: intl.formatMessage(messages.search_results),
+        recent: intl.formatMessage(messages.recent),
+        people: intl.formatMessage(messages.people),
+        nature: intl.formatMessage(messages.nature),
+        foods: intl.formatMessage(messages.food),
+        activity: intl.formatMessage(messages.activity),
+        places: intl.formatMessage(messages.travel),
+        objects: intl.formatMessage(messages.objects),
+        symbols: intl.formatMessage(messages.symbols),
+        flags: intl.formatMessage(messages.flags),
+        custom: intl.formatMessage(messages.custom),
+      },
+    };
+  }
+
+  handleClick = emoji => {
+    if (!emoji.native) {
+      emoji.native = emoji.colons;
+    }
+
+    this.props.onClose();
+    this.props.onPick(emoji);
+  }
+
+  handleModifierOpen = () => {
+    this.setState({ modifierOpen: true });
+  }
+
+  handleModifierClose = () => {
+    this.setState({ modifierOpen: false });
+  }
+
+  handleModifierChange = modifier => {
+    this.props.onSkinTone(modifier);
+  }
+
+  render () {
+    const { loading, style, intl, custom_emojis, skinTone, frequentlyUsedEmojis } = this.props;
+
+    if (loading) {
+      return <div style={{ width: 299 }} />;
+    }
+
+    const title = intl.formatMessage(messages.emoji);
+    const { modifierOpen } = this.state;
+
+    return (
+      <div className={classNames('emoji-picker-dropdown__menu', { selecting: modifierOpen })} style={style} ref={this.setRef}>
+        <EmojiPicker
+          perLine={8}
+          emojiSize={22}
+          sheetSize={32}
+          custom={buildCustomEmojis(custom_emojis)}
+          color=''
+          emoji=''
+          set='twitter'
+          title={title}
+          i18n={this.getI18n()}
+          onClick={this.handleClick}
+          include={categoriesSort}
+          recent={frequentlyUsedEmojis}
+          skin={skinTone}
+          showPreview={false}
+          backgroundImageFn={backgroundImageFn}
+          emojiTooltip
+        />
+
+        <ModifierPicker
+          active={modifierOpen}
+          modifier={skinTone}
+          onOpen={this.handleModifierOpen}
+          onClose={this.handleModifierClose}
+          onChange={this.handleModifierChange}
+        />
+      </div>
+    );
+  }
+
+}
+
+@injectIntl
+export default class EmojiPickerDropdown extends React.PureComponent {
+
+  static propTypes = {
+    custom_emojis: ImmutablePropTypes.list,
+    frequentlyUsedEmojis: PropTypes.arrayOf(PropTypes.string),
+    intl: PropTypes.object.isRequired,
+    onPickEmoji: PropTypes.func.isRequired,
+    onSkinTone: PropTypes.func.isRequired,
+    skinTone: PropTypes.number.isRequired,
+  };
+
+  state = {
+    active: false,
+    loading: false,
+  };
+
+  setRef = (c) => {
+    this.dropdown = c;
+  }
+
+  onShowDropdown = () => {
+    this.setState({ active: true });
+
+    if (!EmojiPicker) {
+      this.setState({ loading: true });
+
+      EmojiPickerAsync().then(EmojiMart => {
+        EmojiPicker = EmojiMart.Picker;
+        Emoji       = EmojiMart.Emoji;
+
+        this.setState({ loading: false });
+      }).catch(() => {
+        this.setState({ loading: false });
+      });
+    }
+  }
+
+  onHideDropdown = () => {
+    this.setState({ active: false });
+  }
+
+  onToggle = (e) => {
+    if (!this.state.loading && (!e.key || e.key === 'Enter')) {
+      if (this.state.active) {
+        this.onHideDropdown();
+      } else {
+        this.onShowDropdown();
+      }
+    }
+  }
+
+  handleKeyDown = e => {
+    if (e.key === 'Escape') {
+      this.onHideDropdown();
+    }
+  }
+
+  setTargetRef = c => {
+    this.target = c;
+  }
+
+  findTarget = () => {
+    return this.target;
+  }
+
+  render () {
+    const { intl, onPickEmoji, onSkinTone, skinTone, frequentlyUsedEmojis } = this.props;
+    const title = intl.formatMessage(messages.emoji);
+    const { active, loading } = this.state;
+
+    return (
+      <div className='emoji-picker-dropdown' onKeyDown={this.handleKeyDown}>
+        <div ref={this.setTargetRef} className='emoji-button' title={title} aria-label={title} aria-expanded={active} role='button' onClick={this.onToggle} onKeyDown={this.onToggle} tabIndex={0}>
+          <img
+            className={classNames('emojione', { 'pulse-loading': active && loading })}
+            alt='🙂'
+            src={`${assetHost}/emoji/1f602.svg`}
+          />
+        </div>
+
+        <Overlay show={active} placement='bottom' target={this.findTarget}>
+          <EmojiPickerMenu
+            custom_emojis={this.props.custom_emojis}
+            loading={loading}
+            onClose={this.onHideDropdown}
+            onPick={onPickEmoji}
+            onSkinTone={onSkinTone}
+            skinTone={skinTone}
+            frequentlyUsedEmojis={frequentlyUsedEmojis}
+          />
+        </Overlay>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/themes/glitch/features/compose/components/navigation_bar.js b/app/javascript/themes/glitch/features/compose/components/navigation_bar.js
new file mode 100644
index 000000000..24a70949b
--- /dev/null
+++ b/app/javascript/themes/glitch/features/compose/components/navigation_bar.js
@@ -0,0 +1,38 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import Avatar from 'themes/glitch/components/avatar';
+import IconButton from 'themes/glitch/components/icon_button';
+import Permalink from 'themes/glitch/components/permalink';
+import { FormattedMessage } from 'react-intl';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+export default class NavigationBar extends ImmutablePureComponent {
+
+  static propTypes = {
+    account: ImmutablePropTypes.map.isRequired,
+    onClose: PropTypes.func.isRequired,
+  };
+
+  render () {
+    return (
+      <div className='navigation-bar'>
+        <Permalink href={this.props.account.get('url')} to={`/accounts/${this.props.account.get('id')}`}>
+          <span style={{ display: 'none' }}>{this.props.account.get('acct')}</span>
+          <Avatar account={this.props.account} size={40} />
+        </Permalink>
+
+        <div className='navigation-bar__profile'>
+          <Permalink href={this.props.account.get('url')} to={`/accounts/${this.props.account.get('id')}`}>
+            <strong className='navigation-bar__profile-account'>@{this.props.account.get('acct')}</strong>
+          </Permalink>
+
+          <a href='/settings/profile' className='navigation-bar__profile-edit'><FormattedMessage id='navigation_bar.edit_profile' defaultMessage='Edit profile' /></a>
+        </div>
+
+        <IconButton title='' icon='close' onClick={this.props.onClose} />
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/themes/glitch/features/compose/components/privacy_dropdown.js b/app/javascript/themes/glitch/features/compose/components/privacy_dropdown.js
new file mode 100644
index 000000000..0cd92d174
--- /dev/null
+++ b/app/javascript/themes/glitch/features/compose/components/privacy_dropdown.js
@@ -0,0 +1,200 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { injectIntl, defineMessages } from 'react-intl';
+import IconButton from 'themes/glitch/components/icon_button';
+import Overlay from 'react-overlays/lib/Overlay';
+import Motion from 'themes/glitch/util/optional_motion';
+import spring from 'react-motion/lib/spring';
+import detectPassiveEvents from 'detect-passive-events';
+import classNames from 'classnames';
+
+const messages = defineMessages({
+  public_short: { id: 'privacy.public.short', defaultMessage: 'Public' },
+  public_long: { id: 'privacy.public.long', defaultMessage: 'Post to public timelines' },
+  unlisted_short: { id: 'privacy.unlisted.short', defaultMessage: 'Unlisted' },
+  unlisted_long: { id: 'privacy.unlisted.long', defaultMessage: 'Do not show in public timelines' },
+  private_short: { id: 'privacy.private.short', defaultMessage: 'Followers-only' },
+  private_long: { id: 'privacy.private.long', defaultMessage: 'Post to followers only' },
+  direct_short: { id: 'privacy.direct.short', defaultMessage: 'Direct' },
+  direct_long: { id: 'privacy.direct.long', defaultMessage: 'Post to mentioned users only' },
+  change_privacy: { id: 'privacy.change', defaultMessage: 'Adjust status privacy' },
+});
+
+const listenerOptions = detectPassiveEvents.hasSupport ? { passive: true } : false;
+
+class PrivacyDropdownMenu extends React.PureComponent {
+
+  static propTypes = {
+    style: PropTypes.object,
+    items: PropTypes.array.isRequired,
+    value: PropTypes.string.isRequired,
+    onClose: PropTypes.func.isRequired,
+    onChange: PropTypes.func.isRequired,
+  };
+
+  handleDocumentClick = e => {
+    if (this.node && !this.node.contains(e.target)) {
+      this.props.onClose();
+    }
+  }
+
+  handleClick = e => {
+    if (e.key === 'Escape') {
+      this.props.onClose();
+    } else if (!e.key || e.key === 'Enter') {
+      const value = e.currentTarget.getAttribute('data-index');
+
+      e.preventDefault();
+
+      this.props.onClose();
+      this.props.onChange(value);
+    }
+  }
+
+  componentDidMount () {
+    document.addEventListener('click', this.handleDocumentClick, false);
+    document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);
+  }
+
+  componentWillUnmount () {
+    document.removeEventListener('click', this.handleDocumentClick, false);
+    document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions);
+  }
+
+  setRef = c => {
+    this.node = c;
+  }
+
+  render () {
+    const { style, items, value } = this.props;
+
+    return (
+      <Motion defaultStyle={{ opacity: 0, scaleX: 0.85, scaleY: 0.75 }} style={{ opacity: spring(1, { damping: 35, stiffness: 400 }), scaleX: spring(1, { damping: 35, stiffness: 400 }), scaleY: spring(1, { damping: 35, stiffness: 400 }) }}>
+        {({ opacity, scaleX, scaleY }) => (
+          <div className='privacy-dropdown__dropdown' style={{ ...style, opacity: opacity, transform: `scale(${scaleX}, ${scaleY})` }} ref={this.setRef}>
+            {items.map(item =>
+              <div role='button' tabIndex='0' key={item.value} data-index={item.value} onKeyDown={this.handleClick} onClick={this.handleClick} className={classNames('privacy-dropdown__option', { active: item.value === value })}>
+                <div className='privacy-dropdown__option__icon'>
+                  <i className={`fa fa-fw fa-${item.icon}`} />
+                </div>
+
+                <div className='privacy-dropdown__option__content'>
+                  <strong>{item.text}</strong>
+                  {item.meta}
+                </div>
+              </div>
+            )}
+          </div>
+        )}
+      </Motion>
+    );
+  }
+
+}
+
+@injectIntl
+export default class PrivacyDropdown extends React.PureComponent {
+
+  static propTypes = {
+    isUserTouching: PropTypes.func,
+    isModalOpen: PropTypes.bool.isRequired,
+    onModalOpen: PropTypes.func,
+    onModalClose: PropTypes.func,
+    value: PropTypes.string.isRequired,
+    onChange: PropTypes.func.isRequired,
+    intl: PropTypes.object.isRequired,
+  };
+
+  state = {
+    open: false,
+  };
+
+  handleToggle = () => {
+    if (this.props.isUserTouching()) {
+      if (this.state.open) {
+        this.props.onModalClose();
+      } else {
+        this.props.onModalOpen({
+          actions: this.options.map(option => ({ ...option, active: option.value === this.props.value })),
+          onClick: this.handleModalActionClick,
+        });
+      }
+    } else {
+      this.setState({ open: !this.state.open });
+    }
+  }
+
+  handleModalActionClick = (e) => {
+    e.preventDefault();
+
+    const { value } = this.options[e.currentTarget.getAttribute('data-index')];
+
+    this.props.onModalClose();
+    this.props.onChange(value);
+  }
+
+  handleKeyDown = e => {
+    switch(e.key) {
+    case 'Enter':
+      this.handleToggle();
+      break;
+    case 'Escape':
+      this.handleClose();
+      break;
+    }
+  }
+
+  handleClose = () => {
+    this.setState({ open: false });
+  }
+
+  handleChange = value => {
+    this.props.onChange(value);
+  }
+
+  componentWillMount () {
+    const { intl: { formatMessage } } = this.props;
+
+    this.options = [
+      { icon: 'globe', value: 'public', text: formatMessage(messages.public_short), meta: formatMessage(messages.public_long) },
+      { icon: 'unlock-alt', value: 'unlisted', text: formatMessage(messages.unlisted_short), meta: formatMessage(messages.unlisted_long) },
+      { icon: 'lock', value: 'private', text: formatMessage(messages.private_short), meta: formatMessage(messages.private_long) },
+      { icon: 'envelope', value: 'direct', text: formatMessage(messages.direct_short), meta: formatMessage(messages.direct_long) },
+    ];
+  }
+
+  render () {
+    const { value, intl } = this.props;
+    const { open } = this.state;
+
+    const valueOption = this.options.find(item => item.value === value);
+
+    return (
+      <div className={classNames('privacy-dropdown', { active: open })} onKeyDown={this.handleKeyDown}>
+        <div className={classNames('privacy-dropdown__value', { active: this.options.indexOf(valueOption) === 0 })}>
+          <IconButton
+            className='privacy-dropdown__value-icon'
+            icon={valueOption.icon}
+            title={intl.formatMessage(messages.change_privacy)}
+            size={18}
+            expanded={open}
+            active={open}
+            inverted
+            onClick={this.handleToggle}
+            style={{ height: null, lineHeight: '27px' }}
+          />
+        </div>
+
+        <Overlay show={open} placement='bottom' target={this}>
+          <PrivacyDropdownMenu
+            items={this.options}
+            value={value}
+            onClose={this.handleClose}
+            onChange={this.handleChange}
+          />
+        </Overlay>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/themes/glitch/features/compose/components/reply_indicator.js b/app/javascript/themes/glitch/features/compose/components/reply_indicator.js
new file mode 100644
index 000000000..9a8d10ceb
--- /dev/null
+++ b/app/javascript/themes/glitch/features/compose/components/reply_indicator.js
@@ -0,0 +1,63 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import Avatar from 'themes/glitch/components/avatar';
+import IconButton from 'themes/glitch/components/icon_button';
+import DisplayName from 'themes/glitch/components/display_name';
+import { defineMessages, injectIntl } from 'react-intl';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+const messages = defineMessages({
+  cancel: { id: 'reply_indicator.cancel', defaultMessage: 'Cancel' },
+});
+
+@injectIntl
+export default class ReplyIndicator extends ImmutablePureComponent {
+
+  static contextTypes = {
+    router: PropTypes.object,
+  };
+
+  static propTypes = {
+    status: ImmutablePropTypes.map,
+    onCancel: PropTypes.func.isRequired,
+    intl: PropTypes.object.isRequired,
+  };
+
+  handleClick = () => {
+    this.props.onCancel();
+  }
+
+  handleAccountClick = (e) => {
+    if (e.button === 0) {
+      e.preventDefault();
+      this.context.router.history.push(`/accounts/${this.props.status.getIn(['account', 'id'])}`);
+    }
+  }
+
+  render () {
+    const { status, intl } = this.props;
+
+    if (!status) {
+      return null;
+    }
+
+    const content  = { __html: status.get('contentHtml') };
+
+    return (
+      <div className='reply-indicator'>
+        <div className='reply-indicator__header'>
+          <div className='reply-indicator__cancel'><IconButton title={intl.formatMessage(messages.cancel)} icon='times' onClick={this.handleClick} /></div>
+
+          <a href={status.getIn(['account', 'url'])} onClick={this.handleAccountClick} className='reply-indicator__display-name'>
+            <div className='reply-indicator__display-avatar'><Avatar account={status.get('account')} size={24} /></div>
+            <DisplayName account={status.get('account')} />
+          </a>
+        </div>
+
+        <div className='reply-indicator__content' dangerouslySetInnerHTML={content} />
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/themes/glitch/features/compose/components/search.js b/app/javascript/themes/glitch/features/compose/components/search.js
new file mode 100644
index 000000000..c3218137f
--- /dev/null
+++ b/app/javascript/themes/glitch/features/compose/components/search.js
@@ -0,0 +1,129 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import Overlay from 'react-overlays/lib/Overlay';
+import Motion from 'themes/glitch/util/optional_motion';
+import spring from 'react-motion/lib/spring';
+
+const messages = defineMessages({
+  placeholder: { id: 'search.placeholder', defaultMessage: 'Search' },
+});
+
+class SearchPopout extends React.PureComponent {
+
+  static propTypes = {
+    style: PropTypes.object,
+  };
+
+  render () {
+    const { style } = this.props;
+
+    return (
+      <div style={{ ...style, position: 'absolute', width: 285 }}>
+        <Motion defaultStyle={{ opacity: 0, scaleX: 0.85, scaleY: 0.75 }} style={{ opacity: spring(1, { damping: 35, stiffness: 400 }), scaleX: spring(1, { damping: 35, stiffness: 400 }), scaleY: spring(1, { damping: 35, stiffness: 400 }) }}>
+          {({ opacity, scaleX, scaleY }) => (
+            <div className='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>
+
+              <FormattedMessage id='search_popout.tips.text' defaultMessage='Simple text returns matching display names, usernames and hashtags' />
+            </div>
+          )}
+        </Motion>
+      </div>
+    );
+  }
+
+}
+
+@injectIntl
+export default class Search extends React.PureComponent {
+
+  static propTypes = {
+    value: PropTypes.string.isRequired,
+    submitted: PropTypes.bool,
+    onChange: PropTypes.func.isRequired,
+    onSubmit: PropTypes.func.isRequired,
+    onClear: PropTypes.func.isRequired,
+    onShow: PropTypes.func.isRequired,
+    intl: PropTypes.object.isRequired,
+  };
+
+  state = {
+    expanded: false,
+  };
+
+  handleChange = (e) => {
+    this.props.onChange(e.target.value);
+  }
+
+  handleClear = (e) => {
+    e.preventDefault();
+
+    if (this.props.value.length > 0 || this.props.submitted) {
+      this.props.onClear();
+    }
+  }
+
+  handleKeyDown = (e) => {
+    if (e.key === 'Enter') {
+      e.preventDefault();
+      this.props.onSubmit();
+    } else if (e.key === 'Escape') {
+      document.querySelector('.ui').parentElement.focus();
+    }
+  }
+
+  noop () {
+
+  }
+
+  handleFocus = () => {
+    this.setState({ expanded: true });
+    this.props.onShow();
+  }
+
+  handleBlur = () => {
+    this.setState({ expanded: false });
+  }
+
+  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
+            className='search__input'
+            type='text'
+            placeholder={intl.formatMessage(messages.placeholder)}
+            value={value}
+            onChange={this.handleChange}
+            onKeyUp={this.handleKeyDown}
+            onFocus={this.handleFocus}
+            onBlur={this.handleBlur}
+          />
+        </label>
+
+        <div role='button' tabIndex='0' className='search__icon' onClick={this.handleClear}>
+          <i className={`fa fa-search ${hasValue ? '' : 'active'}`} />
+          <i aria-label={intl.formatMessage(messages.placeholder)} className={`fa fa-times-circle ${hasValue ? 'active' : ''}`} />
+        </div>
+
+        <Overlay show={expanded && !hasValue} placement='bottom' target={this}>
+          <SearchPopout />
+        </Overlay>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/themes/glitch/features/compose/components/search_results.js b/app/javascript/themes/glitch/features/compose/components/search_results.js
new file mode 100644
index 000000000..3fdafa5f3
--- /dev/null
+++ b/app/javascript/themes/glitch/features/compose/components/search_results.js
@@ -0,0 +1,65 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { FormattedMessage } from 'react-intl';
+import AccountContainer from 'themes/glitch/containers/account_container';
+import StatusContainer from 'themes/glitch/containers/status_container';
+import { Link } from 'react-router-dom';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+export default class SearchResults extends ImmutablePureComponent {
+
+  static propTypes = {
+    results: ImmutablePropTypes.map.isRequired,
+  };
+
+  render () {
+    const { results } = this.props;
+
+    let accounts, statuses, hashtags;
+    let count = 0;
+
+    if (results.get('accounts') && results.get('accounts').size > 0) {
+      count   += results.get('accounts').size;
+      accounts = (
+        <div className='search-results__section'>
+          {results.get('accounts').map(accountId => <AccountContainer key={accountId} id={accountId} />)}
+        </div>
+      );
+    }
+
+    if (results.get('statuses') && results.get('statuses').size > 0) {
+      count   += results.get('statuses').size;
+      statuses = (
+        <div className='search-results__section'>
+          {results.get('statuses').map(statusId => <StatusContainer key={statusId} id={statusId} />)}
+        </div>
+      );
+    }
+
+    if (results.get('hashtags') && results.get('hashtags').size > 0) {
+      count += results.get('hashtags').size;
+      hashtags = (
+        <div className='search-results__section'>
+          {results.get('hashtags').map(hashtag =>
+            <Link key={hashtag} className='search-results__hashtag' to={`/timelines/tag/${hashtag}`}>
+              #{hashtag}
+            </Link>
+          )}
+        </div>
+      );
+    }
+
+    return (
+      <div className='search-results'>
+        <div className='search-results__header'>
+          <FormattedMessage id='search_results.total' defaultMessage='{count, number} {count, plural, one {result} other {results}}' values={{ count }} />
+        </div>
+
+        {accounts}
+        {statuses}
+        {hashtags}
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/themes/glitch/features/compose/components/text_icon_button.js b/app/javascript/themes/glitch/features/compose/components/text_icon_button.js
new file mode 100644
index 000000000..9c8ffab1f
--- /dev/null
+++ b/app/javascript/themes/glitch/features/compose/components/text_icon_button.js
@@ -0,0 +1,29 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+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}>
+        {label}
+      </button>
+    );
+  }
+
+}
diff --git a/app/javascript/themes/glitch/features/compose/components/upload.js b/app/javascript/themes/glitch/features/compose/components/upload.js
new file mode 100644
index 000000000..ded376ada
--- /dev/null
+++ b/app/javascript/themes/glitch/features/compose/components/upload.js
@@ -0,0 +1,96 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import IconButton from 'themes/glitch/components/icon_button';
+import Motion from 'themes/glitch/util/optional_motion';
+import spring from 'react-motion/lib/spring';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import { defineMessages, injectIntl } from 'react-intl';
+import classNames from 'classnames';
+
+const messages = defineMessages({
+  undo: { id: 'upload_form.undo', defaultMessage: 'Undo' },
+  description: { id: 'upload_form.description', defaultMessage: 'Describe for the visually impaired' },
+});
+
+@injectIntl
+export default class Upload extends ImmutablePureComponent {
+
+  static propTypes = {
+    media: ImmutablePropTypes.map.isRequired,
+    intl: PropTypes.object.isRequired,
+    onUndo: PropTypes.func.isRequired,
+    onDescriptionChange: PropTypes.func.isRequired,
+  };
+
+  state = {
+    hovered: false,
+    focused: false,
+    dirtyDescription: null,
+  };
+
+  handleUndoClick = () => {
+    this.props.onUndo(this.props.media.get('id'));
+  }
+
+  handleInputChange = e => {
+    this.setState({ dirtyDescription: e.target.value });
+  }
+
+  handleMouseEnter = () => {
+    this.setState({ hovered: true });
+  }
+
+  handleMouseLeave = () => {
+    this.setState({ hovered: false });
+  }
+
+  handleInputFocus = () => {
+    this.setState({ focused: true });
+  }
+
+  handleInputBlur = () => {
+    const { dirtyDescription } = this.state;
+
+    this.setState({ focused: false, dirtyDescription: null });
+
+    if (dirtyDescription !== null) {
+      this.props.onDescriptionChange(this.props.media.get('id'), dirtyDescription);
+    }
+  }
+
+  render () {
+    const { intl, media } = this.props;
+    const active          = this.state.hovered || this.state.focused;
+    const description     = this.state.dirtyDescription || media.get('description') || '';
+
+    return (
+      <div className='compose-form__upload' onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}>
+        <Motion defaultStyle={{ scale: 0.8 }} style={{ scale: spring(1, { stiffness: 180, damping: 12 }) }}>
+          {({ scale }) => (
+            <div className='compose-form__upload-thumbnail' style={{ transform: `scale(${scale})`, backgroundImage: `url(${media.get('preview_url')})` }}>
+              <IconButton icon='times' title={intl.formatMessage(messages.undo)} size={36} onClick={this.handleUndoClick} />
+
+              <div className={classNames('compose-form__upload-description', { active })}>
+                <label>
+                  <span style={{ display: 'none' }}>{intl.formatMessage(messages.description)}</span>
+
+                  <input
+                    placeholder={intl.formatMessage(messages.description)}
+                    type='text'
+                    value={description}
+                    maxLength={420}
+                    onFocus={this.handleInputFocus}
+                    onChange={this.handleInputChange}
+                    onBlur={this.handleInputBlur}
+                  />
+                </label>
+              </div>
+            </div>
+          )}
+        </Motion>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/themes/glitch/features/compose/components/upload_button.js b/app/javascript/themes/glitch/features/compose/components/upload_button.js
new file mode 100644
index 000000000..d7742adfe
--- /dev/null
+++ b/app/javascript/themes/glitch/features/compose/components/upload_button.js
@@ -0,0 +1,77 @@
+import React from 'react';
+import IconButton from 'themes/glitch/components/icon_button';
+import PropTypes from 'prop-types';
+import { defineMessages, injectIntl } from 'react-intl';
+import { connect } from 'react-redux';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+
+const messages = defineMessages({
+  upload: { id: 'upload_button.label', defaultMessage: 'Add media' },
+});
+
+const makeMapStateToProps = () => {
+  const mapStateToProps = state => ({
+    acceptContentTypes: state.getIn(['media_attachments', 'accept_content_types']),
+  });
+
+  return mapStateToProps;
+};
+
+const iconStyle = {
+  height: null,
+  lineHeight: '27px',
+};
+
+@connect(makeMapStateToProps)
+@injectIntl
+export default class UploadButton extends ImmutablePureComponent {
+
+  static propTypes = {
+    disabled: PropTypes.bool,
+    onSelectFile: PropTypes.func.isRequired,
+    style: PropTypes.object,
+    resetFileKey: PropTypes.number,
+    acceptContentTypes: ImmutablePropTypes.listOf(PropTypes.string).isRequired,
+    intl: PropTypes.object.isRequired,
+  };
+
+  handleChange = (e) => {
+    if (e.target.files.length > 0) {
+      this.props.onSelectFile(e.target.files);
+    }
+  }
+
+  handleClick = () => {
+    this.fileElement.click();
+  }
+
+  setRef = (c) => {
+    this.fileElement = c;
+  }
+
+  render () {
+
+    const { intl, resetFileKey, disabled, acceptContentTypes } = this.props;
+
+    return (
+      <div className='compose-form__upload-button'>
+        <IconButton icon='camera' title={intl.formatMessage(messages.upload)} disabled={disabled} onClick={this.handleClick} className='compose-form__upload-button-icon' size={18} inverted style={iconStyle} />
+        <label>
+          <span style={{ display: 'none' }}>{intl.formatMessage(messages.upload)}</span>
+          <input
+            key={resetFileKey}
+            ref={this.setRef}
+            type='file'
+            multiple={false}
+            accept={acceptContentTypes.toArray().join(',')}
+            onChange={this.handleChange}
+            disabled={disabled}
+            style={{ display: 'none' }}
+          />
+        </label>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/themes/glitch/features/compose/components/upload_form.js b/app/javascript/themes/glitch/features/compose/components/upload_form.js
new file mode 100644
index 000000000..b7f112205
--- /dev/null
+++ b/app/javascript/themes/glitch/features/compose/components/upload_form.js
@@ -0,0 +1,29 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import UploadProgressContainer from '../containers/upload_progress_container';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import UploadContainer from '../containers/upload_container';
+
+export default class UploadForm extends ImmutablePureComponent {
+
+  static propTypes = {
+    mediaIds: ImmutablePropTypes.list.isRequired,
+  };
+
+  render () {
+    const { mediaIds } = this.props;
+
+    return (
+      <div className='compose-form__upload-wrapper'>
+        <UploadProgressContainer />
+
+        <div className='compose-form__uploads-wrapper'>
+          {mediaIds.map(id => (
+            <UploadContainer id={id} key={id} />
+          ))}
+        </div>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/themes/glitch/features/compose/components/upload_progress.js b/app/javascript/themes/glitch/features/compose/components/upload_progress.js
new file mode 100644
index 000000000..b923d0a22
--- /dev/null
+++ b/app/javascript/themes/glitch/features/compose/components/upload_progress.js
@@ -0,0 +1,42 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import Motion from 'themes/glitch/util/optional_motion';
+import spring from 'react-motion/lib/spring';
+import { FormattedMessage } from 'react-intl';
+
+export default class UploadProgress extends React.PureComponent {
+
+  static propTypes = {
+    active: PropTypes.bool,
+    progress: PropTypes.number,
+  };
+
+  render () {
+    const { active, progress } = this.props;
+
+    if (!active) {
+      return null;
+    }
+
+    return (
+      <div className='upload-progress'>
+        <div className='upload-progress__icon'>
+          <i className='fa fa-upload' />
+        </div>
+
+        <div className='upload-progress__message'>
+          <FormattedMessage id='upload_progress.label' defaultMessage='Uploading...' />
+
+          <div className='upload-progress__backdrop'>
+            <Motion defaultStyle={{ width: 0 }} style={{ width: spring(progress) }}>
+              {({ width }) =>
+                <div className='upload-progress__tracker' style={{ width: `${width}%` }} />
+              }
+            </Motion>
+          </div>
+        </div>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/themes/glitch/features/compose/components/warning.js b/app/javascript/themes/glitch/features/compose/components/warning.js
new file mode 100644
index 000000000..82df55a31
--- /dev/null
+++ b/app/javascript/themes/glitch/features/compose/components/warning.js
@@ -0,0 +1,26 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import Motion from 'themes/glitch/util/optional_motion';
+import spring from 'react-motion/lib/spring';
+
+export default class Warning extends React.PureComponent {
+
+  static propTypes = {
+    message: PropTypes.node.isRequired,
+  };
+
+  render () {
+    const { message } = this.props;
+
+    return (
+      <Motion defaultStyle={{ opacity: 0, scaleX: 0.85, scaleY: 0.75 }} style={{ opacity: spring(1, { damping: 35, stiffness: 400 }), scaleX: spring(1, { damping: 35, stiffness: 400 }), scaleY: spring(1, { damping: 35, stiffness: 400 }) }}>
+        {({ opacity, scaleX, scaleY }) => (
+          <div className='compose-form__warning' style={{ opacity: opacity, transform: `scale(${scaleX}, ${scaleY})` }}>
+            {message}
+          </div>
+        )}
+      </Motion>
+    );
+  }
+
+}