about summary refs log tree commit diff
path: root/app/javascript/flavours/glitch/features/compose
diff options
context:
space:
mode:
authorStarfall <us@starfall.systems>2022-11-10 08:50:11 -0600
committerStarfall <us@starfall.systems>2022-11-10 08:50:11 -0600
commit67d1a0476d77e2ed0ca15dd2981c54c2b90b0742 (patch)
tree152f8c13a341d76738e8e2c09b24711936e6af68 /app/javascript/flavours/glitch/features/compose
parentb581e6b6d4a5ba9ed4ae17427b7f2d5d158be4e5 (diff)
parentee7e49d1b1323618e16026bc8db8ab7f9459cc2d (diff)
Merge remote-tracking branch 'glitch/main'
- Remove Helm charts
- Lots of conflicts with our removal of recommended settings and custom
  icons
Diffstat (limited to 'app/javascript/flavours/glitch/features/compose')
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/compose_form.js22
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/dropdown.js53
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/dropdown_menu.js12
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/emoji_picker_dropdown.js414
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/header.js4
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/language_dropdown.js17
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/navigation_bar.js2
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/options.js7
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/poll_form.js2
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/publisher.js34
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/reply_indicator.js8
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/search.js11
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/search_results.js2
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/textarea_icons.js2
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/upload.js14
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/upload_form.js7
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/upload_progress.js29
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/warning.js4
-rw-r--r--app/javascript/flavours/glitch/features/compose/containers/compose_form_container.js2
-rw-r--r--app/javascript/flavours/glitch/features/compose/containers/emoji_picker_dropdown_container.js83
-rw-r--r--app/javascript/flavours/glitch/features/compose/containers/header_container.js2
-rw-r--r--app/javascript/flavours/glitch/features/compose/containers/navigation_container.js2
-rw-r--r--app/javascript/flavours/glitch/features/compose/containers/upload_progress_container.js1
-rw-r--r--app/javascript/flavours/glitch/features/compose/containers/warning_container.js4
-rw-r--r--app/javascript/flavours/glitch/features/compose/index.js73
-rw-r--r--app/javascript/flavours/glitch/features/compose/util/counter.js9
-rw-r--r--app/javascript/flavours/glitch/features/compose/util/url_regex.js30
27 files changed, 706 insertions, 144 deletions
diff --git a/app/javascript/flavours/glitch/features/compose/components/compose_form.js b/app/javascript/flavours/glitch/features/compose/components/compose_form.js
index b03bc34b8..516648f4b 100644
--- a/app/javascript/flavours/glitch/features/compose/components/compose_form.js
+++ b/app/javascript/flavours/glitch/features/compose/components/compose_form.js
@@ -5,17 +5,17 @@ import ReplyIndicatorContainer from '../containers/reply_indicator_container';
 import AutosuggestTextarea from '../../../components/autosuggest_textarea';
 import AutosuggestInput from '../../../components/autosuggest_input';
 import { defineMessages, injectIntl } from 'react-intl';
-import EmojiPicker from 'flavours/glitch/features/emoji_picker';
+import EmojiPickerDropdown from '../containers/emoji_picker_dropdown_container';
 import PollFormContainer from '../containers/poll_form_container';
 import UploadFormContainer from '../containers/upload_form_container';
 import WarningContainer from '../containers/warning_container';
-import { isMobile } from 'flavours/glitch/util/is_mobile';
+import { isMobile } from 'flavours/glitch/is_mobile';
 import ImmutablePureComponent from 'react-immutable-pure-component';
-import { countableText } from 'flavours/glitch/util/counter';
+import { countableText } from '../util/counter';
 import OptionsContainer from '../containers/options_container';
 import Publisher from './publisher';
 import TextareaIcons from './textarea_icons';
-import { maxChars } from 'flavours/glitch/util/initial_state';
+import { maxChars } from 'flavours/glitch/initial_state';
 import CharacterCounter from './character_counter';
 import { length } from 'stringz';
 
@@ -143,7 +143,7 @@ class ComposeForm extends ImmutablePureComponent {
   };
 
   //  Inserts an emoji at the caret.
-  handleEmoji = (data) => {
+  handleEmojiPick = (data) => {
     const { textarea: { selectionStart } } = this;
     const { onPickEmoji } = this.props;
     if (onPickEmoji) {
@@ -275,7 +275,7 @@ class ComposeForm extends ImmutablePureComponent {
 
   render () {
     const {
-      handleEmoji,
+      handleEmojiPick,
       handleSecondarySubmit,
       handleSelect,
       handleSubmit,
@@ -305,12 +305,12 @@ class ComposeForm extends ImmutablePureComponent {
     const countText = this.getFulltextForCharacterCounting();
 
     return (
-      <div className='composer'>
+      <div className='compose-form'>
         <WarningContainer />
 
         <ReplyIndicatorContainer />
 
-        <div className={`composer--spoiler ${spoiler ? 'composer--spoiler--visible' : ''}`} ref={this.setRef}>
+        <div className={`spoiler-input ${spoiler ? 'spoiler-input--visible' : ''}`} ref={this.setRef}>
           <AutosuggestInput
             placeholder={intl.formatMessage(messages.spoiler_placeholder)}
             value={spoilerText}
@@ -344,7 +344,7 @@ class ComposeForm extends ImmutablePureComponent {
           onPaste={onPaste}
           autoFocus={!showSearch && !isMobile(window.innerWidth, layout)}
         >
-          <EmojiPicker onPickEmoji={handleEmoji} />
+          <EmojiPickerDropdown onPickEmoji={handleEmojiPick} />
           <TextareaIcons advancedOptions={advancedOptions} />
           <div className='compose-form__modifiers'>
             <UploadFormContainer />
@@ -352,7 +352,7 @@ class ComposeForm extends ImmutablePureComponent {
           </div>
         </AutosuggestTextarea>
 
-        <div className='composer--options-wrapper'>
+        <div className='compose-form__buttons-wrapper'>
           <OptionsContainer
             advancedOptions={advancedOptions}
             disabled={isSubmitting}
@@ -364,7 +364,7 @@ class ComposeForm extends ImmutablePureComponent {
             sensitive={sensitive || (spoilersAlwaysOn && spoilerText && spoilerText.length > 0)}
             spoiler={spoilersAlwaysOn ? (spoilerText && spoilerText.length > 0) : spoiler}
           />
-          <div className='compose--counter-wrapper'>
+          <div className='character-counter__wrapper'>
             <CharacterCounter text={countText} max={maxChars} />
           </div>
         </div>
diff --git a/app/javascript/flavours/glitch/features/compose/components/dropdown.js b/app/javascript/flavours/glitch/features/compose/components/dropdown.js
index 9f70d6b79..6b6d3de94 100644
--- a/app/javascript/flavours/glitch/features/compose/components/dropdown.js
+++ b/app/javascript/flavours/glitch/features/compose/components/dropdown.js
@@ -9,14 +9,13 @@ import IconButton from 'flavours/glitch/components/icon_button';
 import DropdownMenu from './dropdown_menu';
 
 //  Utils.
-import { isUserTouching } from 'flavours/glitch/util/is_mobile';
-import { assignHandlers } from 'flavours/glitch/util/react_helpers';
+import { isUserTouching } from 'flavours/glitch/is_mobile';
+import { assignHandlers } from 'flavours/glitch/utils/react_helpers';
 
 //  The component.
 export default class ComposerOptionsDropdown extends React.PureComponent {
 
   static propTypes = {
-    active: PropTypes.bool,
     disabled: PropTypes.bool,
     icon: PropTypes.string,
     items: PropTypes.arrayOf(PropTypes.shape({
@@ -162,7 +161,6 @@ export default class ComposerOptionsDropdown extends React.PureComponent {
   //  Rendering.
   render () {
     const {
-      active,
       disabled,
       title,
       icon,
@@ -174,35 +172,34 @@ export default class ComposerOptionsDropdown extends React.PureComponent {
       closeOnChange,
     } = this.props;
     const { open, placement } = this.state;
-    const computedClass = classNames('composer--options--dropdown', {
-      active,
-      open,
-      top: placement === 'top',
-    });
 
-    //  The result.
+    const active = value && items.findIndex(({ name }) => name === value) === (placement === 'bottom' ? 0 : (items.length - 1));
+
     return (
       <div
-        className={computedClass}
+        className={classNames('privacy-dropdown', placement, { active: open })}
         onKeyDown={this.handleKeyDown}
       >
-        <IconButton
-          active={open || active}
-          className='value'
-          disabled={disabled}
-          icon={icon}
-          inverted
-          onClick={this.handleToggle}
-          onMouseDown={this.handleMouseDown}
-          onKeyDown={this.handleButtonKeyDown}
-          onKeyPress={this.handleKeyPress}
-          size={18}
-          style={{
-            height: null,
-            lineHeight: '27px',
-          }}
-          title={title}
-        />
+        <div className={classNames('privacy-dropdown__value', { active })}>
+          <IconButton
+            active={open}
+            className='privacy-dropdown__value-icon'
+            disabled={disabled}
+            icon={icon}
+            inverted
+            onClick={this.handleToggle}
+            onMouseDown={this.handleMouseDown}
+            onKeyDown={this.handleButtonKeyDown}
+            onKeyPress={this.handleKeyPress}
+            size={18}
+            style={{
+              height: null,
+              lineHeight: '27px',
+            }}
+            title={title}
+          />
+        </div>
+
         <Overlay
           containerPadding={20}
           placement={placement}
diff --git a/app/javascript/flavours/glitch/features/compose/components/dropdown_menu.js b/app/javascript/flavours/glitch/features/compose/components/dropdown_menu.js
index 0649fe1ca..09e8fc35a 100644
--- a/app/javascript/flavours/glitch/features/compose/components/dropdown_menu.js
+++ b/app/javascript/flavours/glitch/features/compose/components/dropdown_menu.js
@@ -9,9 +9,9 @@ import classNames from 'classnames';
 import Icon from 'flavours/glitch/components/icon';
 
 //  Utils.
-import { withPassive } from 'flavours/glitch/util/dom_helpers';
-import Motion from 'flavours/glitch/util/optional_motion';
-import { assignHandlers } from 'flavours/glitch/util/react_helpers';
+import { withPassive } from 'flavours/glitch/utils/dom_helpers';
+import Motion from '../../ui/util/optional_motion';
+import { assignHandlers } from 'flavours/glitch/utils/react_helpers';
 
 //  The spring to use with our motion.
 const springMotion = spring(1, {
@@ -156,7 +156,7 @@ export default class ComposerOptionsDropdownContent extends React.PureComponent
 
     const active = (name === (this.props.value || this.state.value));
 
-    const computedClass = classNames('composer--options--dropdown--content--item', { active });
+    const computedClass = classNames('privacy-dropdown__option', { active });
 
     let contents = this.props.renderItemContents && this.props.renderItemContents(item, i);
 
@@ -165,7 +165,7 @@ export default class ComposerOptionsDropdownContent extends React.PureComponent
         <React.Fragment>
           {icon && <Icon className='icon' fixedWidth id={icon} />}
 
-          <div className='content'>
+          <div className='privacy-dropdown__option__content'>
             <strong>{text}</strong>
             {meta}
           </div>
@@ -218,7 +218,7 @@ export default class ComposerOptionsDropdownContent extends React.PureComponent
           // size will be used to determine the coordinate of the menu by
           // react-overlays
           <div
-            className='composer--options--dropdown--content'
+            className='privacy-dropdown__dropdown'
             ref={this.handleRef}
             role='listbox'
             style={{
diff --git a/app/javascript/flavours/glitch/features/compose/components/emoji_picker_dropdown.js b/app/javascript/flavours/glitch/features/compose/components/emoji_picker_dropdown.js
new file mode 100644
index 000000000..546d398a0
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/components/emoji_picker_dropdown.js
@@ -0,0 +1,414 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import { EmojiPicker as EmojiPickerAsync } from '../../ui/util/async-components';
+import Overlay from 'react-overlays/lib/Overlay';
+import classNames from 'classnames';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { supportsPassiveEvents } from 'detect-passive-events';
+import { buildCustomEmojis, categoriesFromEmojis } from '../../emoji/emoji';
+import { useSystemEmojiFont } from 'flavours/glitch/initial_state';
+import { assetHost } from 'flavours/glitch/utils/config';
+
+const messages = defineMessages({
+  emoji: { id: 'emoji_button.label', defaultMessage: 'Insert emoji' },
+  emoji_search: { id: 'emoji_button.search', defaultMessage: 'Search...' },
+  custom: { id: 'emoji_button.custom', defaultMessage: 'Custom' },
+  recent: { id: 'emoji_button.recent', defaultMessage: 'Frequently used' },
+  search_results: { id: 'emoji_button.search_results', defaultMessage: 'Search results' },
+  people: { id: 'emoji_button.people', defaultMessage: 'People' },
+  nature: { id: 'emoji_button.nature', defaultMessage: 'Nature' },
+  food: { id: 'emoji_button.food', defaultMessage: 'Food & Drink' },
+  activity: { id: 'emoji_button.activity', defaultMessage: 'Activity' },
+  travel: { id: 'emoji_button.travel', defaultMessage: 'Travel & Places' },
+  objects: { id: 'emoji_button.objects', defaultMessage: 'Objects' },
+  symbols: { id: 'emoji_button.symbols', defaultMessage: 'Symbols' },
+  flags: { id: 'emoji_button.flags', defaultMessage: 'Flags' },
+});
+
+let EmojiPicker, Emoji; // load asynchronously
+
+const listenerOptions = supportsPassiveEvents ? { passive: true } : false;
+
+const backgroundImageFn = () => `${assetHost}/emoji/sheet_13.png`;
+
+const notFoundFn = () => (
+  <div className='emoji-mart-no-results'>
+    <Emoji
+      emoji='sleuth_or_spy'
+      set='twitter'
+      size={32}
+      sheetSize={32}
+      backgroundImageFn={backgroundImageFn}
+    />
+
+    <div className='emoji-mart-no-results-label'>
+      <FormattedMessage id='emoji_button.not_found' defaultMessage='No matching emojis found' />
+    </div>
+  </div>
+);
+
+class ModifierPickerMenu extends React.PureComponent {
+
+  static propTypes = {
+    active: PropTypes.bool,
+    onSelect: PropTypes.func.isRequired,
+    onClose: PropTypes.func.isRequired,
+  };
+
+  handleClick = e => {
+    this.props.onSelect(e.currentTarget.getAttribute('data-index') * 1);
+  }
+
+  componentWillReceiveProps (nextProps) {
+    if (nextProps.active) {
+      this.attachListeners();
+    } else {
+      this.removeListeners();
+    }
+  }
+
+  componentWillUnmount () {
+    this.removeListeners();
+  }
+
+  handleDocumentClick = e => {
+    if (this.node && !this.node.contains(e.target)) {
+      this.props.onClose();
+    }
+  }
+
+  attachListeners () {
+    document.addEventListener('click', this.handleDocumentClick, false);
+    document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);
+  }
+
+  removeListeners () {
+    document.removeEventListener('click', this.handleDocumentClick, false);
+    document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions);
+  }
+
+  setRef = c => {
+    this.node = c;
+  }
+
+  render () {
+    const { active } = this.props;
+
+    return (
+      <div className='emoji-picker-dropdown__modifiers__menu' style={{ display: active ? 'block' : 'none' }} ref={this.setRef}>
+        <button onClick={this.handleClick} data-index={1}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={1} backgroundImageFn={backgroundImageFn} native={useSystemEmojiFont} /></button>
+        <button onClick={this.handleClick} data-index={2}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={2} backgroundImageFn={backgroundImageFn} native={useSystemEmojiFont} /></button>
+        <button onClick={this.handleClick} data-index={3}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={3} backgroundImageFn={backgroundImageFn} native={useSystemEmojiFont} /></button>
+        <button onClick={this.handleClick} data-index={4}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={4} backgroundImageFn={backgroundImageFn} native={useSystemEmojiFont} /></button>
+        <button onClick={this.handleClick} data-index={5}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={5} backgroundImageFn={backgroundImageFn} native={useSystemEmojiFont} /></button>
+        <button onClick={this.handleClick} data-index={6}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={6} backgroundImageFn={backgroundImageFn} native={useSystemEmojiFont} /></button>
+      </div>
+    );
+  }
+
+}
+
+class ModifierPicker extends React.PureComponent {
+
+  static propTypes = {
+    active: PropTypes.bool,
+    modifier: PropTypes.number,
+    onChange: PropTypes.func,
+    onClose: PropTypes.func,
+    onOpen: PropTypes.func,
+  };
+
+  handleClick = () => {
+    if (this.props.active) {
+      this.props.onClose();
+    } else {
+      this.props.onOpen();
+    }
+  }
+
+  handleSelect = modifier => {
+    this.props.onChange(modifier);
+    this.props.onClose();
+  }
+
+  render () {
+    const { active, modifier } = this.props;
+
+    return (
+      <div className='emoji-picker-dropdown__modifiers'>
+        <Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={modifier} onClick={this.handleClick} backgroundImageFn={backgroundImageFn} native={useSystemEmojiFont} />
+        <ModifierPickerMenu active={active} onSelect={this.handleSelect} onClose={this.props.onClose} />
+      </div>
+    );
+  }
+
+}
+
+@injectIntl
+class EmojiPickerMenu extends React.PureComponent {
+
+  static propTypes = {
+    custom_emojis: ImmutablePropTypes.list,
+    frequentlyUsedEmojis: PropTypes.arrayOf(PropTypes.string),
+    loading: PropTypes.bool,
+    onClose: PropTypes.func.isRequired,
+    onPick: PropTypes.func.isRequired,
+    style: PropTypes.object,
+    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,
+    frequentlyUsedEmojis: [],
+  };
+
+  state = {
+    modifierOpen: false,
+    readyToFocus: false,
+  };
+
+  handleDocumentClick = e => {
+    if (this.node && !this.node.contains(e.target)) {
+      this.props.onClose();
+    }
+  }
+
+  componentDidMount () {
+    document.addEventListener('click', this.handleDocumentClick, false);
+    document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);
+
+    // Because of https://github.com/react-bootstrap/react-bootstrap/issues/2614 we need
+    // to wait for a frame before focusing
+    requestAnimationFrame(() => {
+      this.setState({ readyToFocus: true });
+      if (this.node) {
+        const element = this.node.querySelector('input[type="search"]');
+        if (element) element.focus();
+      }
+    });
+  }
+
+  componentWillUnmount () {
+    document.removeEventListener('click', this.handleDocumentClick, false);
+    document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions);
+  }
+
+  setRef = c => {
+    this.node = c;
+  }
+
+  getI18n = () => {
+    const { intl } = this.props;
+
+    return {
+      search: intl.formatMessage(messages.emoji_search),
+      categories: {
+        search: intl.formatMessage(messages.search_results),
+        recent: intl.formatMessage(messages.recent),
+        people: intl.formatMessage(messages.people),
+        nature: intl.formatMessage(messages.nature),
+        foods: intl.formatMessage(messages.food),
+        activity: intl.formatMessage(messages.activity),
+        places: intl.formatMessage(messages.travel),
+        objects: intl.formatMessage(messages.objects),
+        symbols: intl.formatMessage(messages.symbols),
+        flags: intl.formatMessage(messages.flags),
+        custom: intl.formatMessage(messages.custom),
+      },
+    };
+  }
+
+  handleClick = (emoji, event) => {
+    if (!emoji.native) {
+      emoji.native = emoji.colons;
+    }
+    if (!(event.ctrlKey || event.metaKey)) {
+      this.props.onClose();
+    }
+    this.props.onPick(emoji);
+  }
+
+  handleModifierOpen = () => {
+    this.setState({ modifierOpen: true });
+  }
+
+  handleModifierClose = () => {
+    this.setState({ modifierOpen: false });
+  }
+
+  handleModifierChange = modifier => {
+    this.props.onSkinTone(modifier);
+  }
+
+  render () {
+    const { loading, style, intl, custom_emojis, skinTone, frequentlyUsedEmojis } = this.props;
+
+    if (loading) {
+      return <div style={{ width: 299 }} />;
+    }
+
+    const title = intl.formatMessage(messages.emoji);
+
+    const { modifierOpen } = this.state;
+
+    const categoriesSort = [
+      'recent',
+      'people',
+      'nature',
+      'foods',
+      'activity',
+      'places',
+      'objects',
+      'symbols',
+      'flags',
+    ];
+
+    categoriesSort.splice(1, 0, ...Array.from(categoriesFromEmojis(custom_emojis)).sort());
+
+    return (
+      <div className={classNames('emoji-picker-dropdown__menu', { selecting: modifierOpen })} style={style} ref={this.setRef}>
+        <EmojiPicker
+          perLine={8}
+          emojiSize={22}
+          sheetSize={32}
+          custom={buildCustomEmojis(custom_emojis)}
+          color=''
+          emoji=''
+          set='twitter'
+          title={title}
+          i18n={this.getI18n()}
+          onClick={this.handleClick}
+          include={categoriesSort}
+          recent={frequentlyUsedEmojis}
+          skin={skinTone}
+          showPreview={false}
+          showSkinTones={false}
+          backgroundImageFn={backgroundImageFn}
+          notFound={notFoundFn}
+          autoFocus={this.state.readyToFocus}
+          emojiTooltip
+          native={useSystemEmojiFont}
+        />
+
+        <ModifierPicker
+          active={modifierOpen}
+          modifier={skinTone}
+          onOpen={this.handleModifierOpen}
+          onClose={this.handleModifierClose}
+          onChange={this.handleModifierChange}
+        />
+      </div>
+    );
+  }
+
+}
+
+export default @injectIntl
+class EmojiPickerDropdown extends React.PureComponent {
+
+  static propTypes = {
+    custom_emojis: ImmutablePropTypes.list,
+    frequentlyUsedEmojis: PropTypes.arrayOf(PropTypes.string),
+    intl: PropTypes.object.isRequired,
+    onPickEmoji: PropTypes.func.isRequired,
+    onSkinTone: PropTypes.func.isRequired,
+    skinTone: PropTypes.number.isRequired,
+    button: PropTypes.node,
+  };
+
+  state = {
+    active: false,
+    loading: false,
+    placement: null,
+  };
+
+  setRef = (c) => {
+    this.dropdown = c;
+  }
+
+  onShowDropdown = ({ target }) => {
+    this.setState({ active: true });
+
+    if (!EmojiPicker) {
+      this.setState({ loading: true });
+
+      EmojiPickerAsync().then(EmojiMart => {
+        EmojiPicker = EmojiMart.Picker;
+        Emoji       = EmojiMart.Emoji;
+
+        this.setState({ loading: false });
+      }).catch(() => {
+        this.setState({ loading: false, active: false });
+      });
+    }
+
+    const { top } = target.getBoundingClientRect();
+    this.setState({ placement: top * 2 < innerHeight ? 'bottom' : 'top' });
+  }
+
+  onHideDropdown = () => {
+    this.setState({ active: false });
+  }
+
+  onToggle = (e) => {
+    if (!this.state.loading && (!e.key || e.key === 'Enter')) {
+      if (this.state.active) {
+        this.onHideDropdown();
+      } else {
+        this.onShowDropdown(e);
+      }
+    }
+  }
+
+  handleKeyDown = e => {
+    if (e.key === 'Escape') {
+      this.onHideDropdown();
+    }
+  }
+
+  setTargetRef = c => {
+    this.target = c;
+  }
+
+  findTarget = () => {
+    return this.target;
+  }
+
+  render () {
+    const { intl, onPickEmoji, onSkinTone, skinTone, frequentlyUsedEmojis, button } = this.props;
+    const title = intl.formatMessage(messages.emoji);
+    const { active, loading, placement } = this.state;
+
+    return (
+      <div className='emoji-picker-dropdown' onKeyDown={this.handleKeyDown}>
+        <div ref={this.setTargetRef} className='emoji-button' title={title} aria-label={title} aria-expanded={active} role='button' onClick={this.onToggle} onKeyDown={this.onToggle} tabIndex={0}>
+          {button || <img
+            className={classNames('emojione', { 'pulse-loading': active && loading })}
+            alt='🙂'
+            src={`${assetHost}/emoji/1f602.svg`}
+          />}
+        </div>
+
+        <Overlay show={active} placement={placement} 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/flavours/glitch/features/compose/components/header.js b/app/javascript/flavours/glitch/features/compose/components/header.js
index 95add2027..7ecb573ab 100644
--- a/app/javascript/flavours/glitch/features/compose/components/header.js
+++ b/app/javascript/flavours/glitch/features/compose/components/header.js
@@ -10,8 +10,8 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
 import Icon from 'flavours/glitch/components/icon';
 
 //  Utils.
-import { conditionalRender } from 'flavours/glitch/util/react_helpers';
-import { signOutLink } from 'flavours/glitch/util/backend_links';
+import { conditionalRender } from 'flavours/glitch/utils/react_helpers';
+import { signOutLink } from 'flavours/glitch/utils/backend_links';
 
 //  Messages.
 const messages = defineMessages({
diff --git a/app/javascript/flavours/glitch/features/compose/components/language_dropdown.js b/app/javascript/flavours/glitch/features/compose/components/language_dropdown.js
index 035b0c0c3..a3256aa9b 100644
--- a/app/javascript/flavours/glitch/features/compose/components/language_dropdown.js
+++ b/app/javascript/flavours/glitch/features/compose/components/language_dropdown.js
@@ -3,12 +3,12 @@ import PropTypes from 'prop-types';
 import { injectIntl, defineMessages } from 'react-intl';
 import TextIconButton from './text_icon_button';
 import Overlay from 'react-overlays/lib/Overlay';
-import Motion from 'flavours/glitch/util/optional_motion';
+import Motion from 'flavours/glitch/features/ui/util/optional_motion';
 import spring from 'react-motion/lib/spring';
 import { supportsPassiveEvents } from 'detect-passive-events';
 import classNames from 'classnames';
-import { languages as preloadedLanguages } from 'flavours/glitch/util/initial_state';
-import { loupeIcon, deleteIcon } from 'flavours/glitch/util/icons';
+import { languages as preloadedLanguages } from 'flavours/glitch/initial_state';
+import { loupeIcon, deleteIcon } from 'flavours/glitch/utils/icons';
 import fuzzysort from 'fuzzysort';
 
 const messages = defineMessages({
@@ -51,6 +51,15 @@ class LanguageDropdownMenu extends React.PureComponent {
     document.addEventListener('click', this.handleDocumentClick, false);
     document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);
     this.setState({ mounted: true });
+
+    // Because of https://github.com/react-bootstrap/react-bootstrap/issues/2614 we need
+    // to wait for a frame before focusing
+    requestAnimationFrame(() => {
+      if (this.node) {
+        const element = this.node.querySelector('input[type="search"]');
+        if (element) element.focus();
+      }
+    });
   }
 
   componentWillUnmount () {
@@ -226,7 +235,7 @@ class LanguageDropdownMenu extends React.PureComponent {
           // react-overlays
           <div className={`language-dropdown__dropdown ${placement}`} style={{ ...style, opacity: opacity, transform: mounted ? `scale(${scaleX}, ${scaleY})` : null }} ref={this.setRef}>
             <div className='emoji-mart-search'>
-              <input type='search' value={searchValue} onChange={this.handleSearchChange} onKeyDown={this.handleSearchKeyDown} placeholder={intl.formatMessage(messages.search)} autoFocus />
+              <input type='search' value={searchValue} onChange={this.handleSearchChange} onKeyDown={this.handleSearchKeyDown} placeholder={intl.formatMessage(messages.search)} />
               <button className='emoji-mart-search-icon' disabled={!isSearching} aria-label={intl.formatMessage(messages.clear)} onClick={this.handleClear}>{!isSearching ? loupeIcon : deleteIcon}</button>
             </div>
 
diff --git a/app/javascript/flavours/glitch/features/compose/components/navigation_bar.js b/app/javascript/flavours/glitch/features/compose/components/navigation_bar.js
index 595ca5512..ba73ed553 100644
--- a/app/javascript/flavours/glitch/features/compose/components/navigation_bar.js
+++ b/app/javascript/flavours/glitch/features/compose/components/navigation_bar.js
@@ -4,7 +4,7 @@ import Avatar from 'flavours/glitch/components/avatar';
 import Permalink from 'flavours/glitch/components/permalink';
 import { FormattedMessage } from 'react-intl';
 import ImmutablePureComponent from 'react-immutable-pure-component';
-import { profileLink } from 'flavours/glitch/util/backend_links';
+import { profileLink } from 'flavours/glitch/utils/backend_links';
 
 export default class NavigationBar extends ImmutablePureComponent {
 
diff --git a/app/javascript/flavours/glitch/features/compose/components/options.js b/app/javascript/flavours/glitch/features/compose/components/options.js
index f005dbdd1..47bd9b056 100644
--- a/app/javascript/flavours/glitch/features/compose/components/options.js
+++ b/app/javascript/flavours/glitch/features/compose/components/options.js
@@ -16,8 +16,8 @@ import LanguageDropdown from '../containers/language_dropdown_container';
 import ImmutablePureComponent from 'react-immutable-pure-component';
 
 //  Utils.
-import Motion from 'flavours/glitch/util/optional_motion';
-import { pollLimits } from 'flavours/glitch/util/initial_state';
+import Motion from '../../ui/util/optional_motion';
+import { pollLimits } from 'flavours/glitch/initial_state';
 
 //  Messages.
 const messages = defineMessages({
@@ -228,7 +228,7 @@ class ComposerOptions extends ImmutablePureComponent {
 
     //  The result.
     return (
-      <div className='composer--options'>
+      <div className='compose-form__buttons'>
         <input
           accept={acceptContentTypes}
           disabled={disabled || !allowMedia}
@@ -309,7 +309,6 @@ class ComposerOptions extends ImmutablePureComponent {
         )}
         <LanguageDropdown />
         <Dropdown
-          active={advancedOptions && advancedOptions.some(value => !!value)}
           disabled={disabled || isEditing}
           icon='ellipsis-h'
           items={advancedOptions ? [
diff --git a/app/javascript/flavours/glitch/features/compose/components/poll_form.js b/app/javascript/flavours/glitch/features/compose/components/poll_form.js
index e4b5104f3..d5edccff3 100644
--- a/app/javascript/flavours/glitch/features/compose/components/poll_form.js
+++ b/app/javascript/flavours/glitch/features/compose/components/poll_form.js
@@ -7,7 +7,7 @@ import IconButton from 'flavours/glitch/components/icon_button';
 import Icon from 'flavours/glitch/components/icon';
 import AutosuggestInput from 'flavours/glitch/components/autosuggest_input';
 import classNames from 'classnames';
-import { pollLimits } from 'flavours/glitch/util/initial_state';
+import { pollLimits } from 'flavours/glitch/initial_state';
 
 const messages = defineMessages({
   option_placeholder: { id: 'compose_form.poll.option_placeholder', defaultMessage: 'Choice {number}' },
diff --git a/app/javascript/flavours/glitch/features/compose/components/publisher.js b/app/javascript/flavours/glitch/features/compose/components/publisher.js
index e2498bcad..9d53b7ee3 100644
--- a/app/javascript/flavours/glitch/features/compose/components/publisher.js
+++ b/app/javascript/flavours/glitch/features/compose/components/publisher.js
@@ -11,7 +11,7 @@ import Button from 'flavours/glitch/components/button';
 import Icon from 'flavours/glitch/components/icon';
 
 //  Utils.
-import { maxChars } from 'flavours/glitch/util/initial_state';
+import { maxChars } from 'flavours/glitch/initial_state';
 
 //  Messages.
 const messages = defineMessages({
@@ -48,7 +48,7 @@ class Publisher extends ImmutablePureComponent {
     const { countText, disabled, intl, onSecondarySubmit, privacy, sideArm, isEditing } = this.props;
 
     const diff = maxChars - length(countText || '');
-    const computedClass = classNames('composer--publisher', {
+    const computedClass = classNames('compose-form__publish', {
       disabled: disabled,
       over: diff < 0,
     });
@@ -72,22 +72,26 @@ class Publisher extends ImmutablePureComponent {
     return (
       <div className={computedClass}>
         {sideArm && !isEditing && sideArm !== 'none' ? (
+          <div className='compose-form__publish-button-wrapper'>
+            <Button
+              className='side_arm'
+              disabled={disabled}
+              onClick={onSecondarySubmit}
+              style={{ padding: null }}
+              text={<Icon id={privacyIcons[sideArm]} />}
+              title={`${intl.formatMessage(messages.publish)}: ${intl.formatMessage({ id: `privacy.${sideArm}.short` })}`}
+            />
+          </div>
+        ) : null}
+        <div className='compose-form__publish-button-wrapper'>
           <Button
-            className='side_arm'
+            className='primary'
+            text={publishText}
+            title={`${intl.formatMessage(messages.publish)}: ${intl.formatMessage({ id: `privacy.${privacy}.short` })}`}
+            onClick={this.handleSubmit}
             disabled={disabled}
-            onClick={onSecondarySubmit}
-            style={{ padding: null }}
-            text={<Icon id={privacyIcons[sideArm]} />}
-            title={`${intl.formatMessage(messages.publish)}: ${intl.formatMessage({ id: `privacy.${sideArm}.short` })}`}
           />
-        ) : null}
-        <Button
-          className='primary'
-          text={publishText}
-          title={`${intl.formatMessage(messages.publish)}: ${intl.formatMessage({ id: `privacy.${privacy}.short` })}`}
-          onClick={this.handleSubmit}
-          disabled={disabled}
-        />
+        </div>
       </div>
     );
   };
diff --git a/app/javascript/flavours/glitch/features/compose/components/reply_indicator.js b/app/javascript/flavours/glitch/features/compose/components/reply_indicator.js
index 37ae9cab9..7ad9e2b64 100644
--- a/app/javascript/flavours/glitch/features/compose/components/reply_indicator.js
+++ b/app/javascript/flavours/glitch/features/compose/components/reply_indicator.js
@@ -49,10 +49,10 @@ class ReplyIndicator extends ImmutablePureComponent {
 
     //  The result.
     return (
-      <article className='composer--reply'>
-        <header>
+      <article className='reply-indicator'>
+        <header className='reply-indicator__header'>
           <IconButton
-            className='cancel'
+            className='reply-indicator__cancel'
             icon='times'
             onClick={this.handleClick}
             title={intl.formatMessage(messages.cancel)}
@@ -66,7 +66,7 @@ class ReplyIndicator extends ImmutablePureComponent {
           )}
         </header>
         <div
-          className='content translate'
+          className='reply-indicator__content translate'
           dangerouslySetInnerHTML={{ __html: content || '' }}
         />
         {attachments.size > 0 && (
diff --git a/app/javascript/flavours/glitch/features/compose/components/search.js b/app/javascript/flavours/glitch/features/compose/components/search.js
index 12d839637..326fe5b70 100644
--- a/app/javascript/flavours/glitch/features/compose/components/search.js
+++ b/app/javascript/flavours/glitch/features/compose/components/search.js
@@ -15,12 +15,13 @@ import Overlay from 'react-overlays/lib/Overlay';
 import Icon from 'flavours/glitch/components/icon';
 
 //  Utils.
-import { focusRoot } from 'flavours/glitch/util/dom_helpers';
-import { searchEnabled } from 'flavours/glitch/util/initial_state';
-import Motion from 'flavours/glitch/util/optional_motion';
+import { focusRoot } from 'flavours/glitch/utils/dom_helpers';
+import { searchEnabled } from 'flavours/glitch/initial_state';
+import Motion from '../../ui/util/optional_motion';
 
 const messages = defineMessages({
   placeholder: { id: 'search.placeholder', defaultMessage: 'Search' },
+  placeholderSignedIn: { id: 'search.search_or_paste', defaultMessage: 'Search or paste URL' },
 });
 
 class SearchPopout extends React.PureComponent {
@@ -62,6 +63,7 @@ class Search extends React.PureComponent {
 
   static contextTypes = {
     router: PropTypes.object.isRequired,
+    identity: PropTypes.object.isRequired,
   };
 
   static propTypes = {
@@ -137,6 +139,7 @@ class Search extends React.PureComponent {
   render () {
     const { intl, value, submitted } = this.props;
     const { expanded } = this.state;
+    const { signedIn } = this.context.identity;
     const hasValue = value.length > 0 || submitted;
 
     return (
@@ -147,7 +150,7 @@ class Search extends React.PureComponent {
             ref={this.setRef}
             className='search__input'
             type='text'
-            placeholder={intl.formatMessage(messages.placeholder)}
+            placeholder={intl.formatMessage(signedIn ? messages.placeholderSignedIn : messages.placeholder)}
             value={value || ''}
             onChange={this.handleChange}
             onKeyUp={this.handleKeyUp}
diff --git a/app/javascript/flavours/glitch/features/compose/components/search_results.js b/app/javascript/flavours/glitch/features/compose/components/search_results.js
index e82ee2ca2..c2178702c 100644
--- a/app/javascript/flavours/glitch/features/compose/components/search_results.js
+++ b/app/javascript/flavours/glitch/features/compose/components/search_results.js
@@ -7,7 +7,7 @@ import StatusContainer from 'flavours/glitch/containers/status_container';
 import ImmutablePureComponent from 'react-immutable-pure-component';
 import { ImmutableHashtag as Hashtag } from 'flavours/glitch/components/hashtag';
 import Icon from 'flavours/glitch/components/icon';
-import { searchEnabled } from 'flavours/glitch/util/initial_state';
+import { searchEnabled } from 'flavours/glitch/initial_state';
 import LoadMore from 'flavours/glitch/components/load_more';
 
 const messages = defineMessages({
diff --git a/app/javascript/flavours/glitch/features/compose/components/textarea_icons.js b/app/javascript/flavours/glitch/features/compose/components/textarea_icons.js
index b875fb15e..25c2443b1 100644
--- a/app/javascript/flavours/glitch/features/compose/components/textarea_icons.js
+++ b/app/javascript/flavours/glitch/features/compose/components/textarea_icons.js
@@ -38,7 +38,7 @@ class TextareaIcons extends ImmutablePureComponent {
   render () {
     const { advancedOptions, intl } = this.props;
     return (
-      <div className='composer--textarea--icons'>
+      <div className='compose-form__textarea-icons'>
         {advancedOptions ? iconMap.map(
           ([key, icon, message]) => advancedOptions.get(key) ? (
             <span
diff --git a/app/javascript/flavours/glitch/features/compose/components/upload.js b/app/javascript/flavours/glitch/features/compose/components/upload.js
index 963b95c87..94ac6c499 100644
--- a/app/javascript/flavours/glitch/features/compose/components/upload.js
+++ b/app/javascript/flavours/glitch/features/compose/components/upload.js
@@ -1,12 +1,12 @@
 import React from 'react';
 import ImmutablePropTypes from 'react-immutable-proptypes';
 import PropTypes from 'prop-types';
-import Motion from 'flavours/glitch/util/optional_motion';
+import Motion from '../../ui/util/optional_motion';
 import spring from 'react-motion/lib/spring';
 import ImmutablePureComponent from 'react-immutable-pure-component';
 import { FormattedMessage } from 'react-intl';
 import Icon from 'flavours/glitch/components/icon';
-import { isUserTouching } from 'flavours/glitch/util/is_mobile';
+import { isUserTouching } from 'flavours/glitch/is_mobile';
 
 export default class Upload extends ImmutablePureComponent {
 
@@ -18,7 +18,7 @@ export default class Upload extends ImmutablePureComponent {
     media: ImmutablePropTypes.map.isRequired,
     onUndo: PropTypes.func.isRequired,
     onOpenFocalPoint: PropTypes.func.isRequired,
-    isEditingStatus: PropTypes.func.isRequired,
+    isEditingStatus: PropTypes.bool.isRequired,
   };
 
   handleUndoClick = e => {
@@ -39,17 +39,17 @@ export default class Upload extends ImmutablePureComponent {
     const y = ((focusY / -2) + .5) * 100;
 
     return (
-      <div className='composer--upload_form--item' tabIndex='0' role='button'>
+      <div className='compose-form__upload' tabIndex='0' role='button'>
         <Motion defaultStyle={{ scale: 0.8 }} style={{ scale: spring(1, { stiffness: 180, damping: 12, }) }}>
           {({ scale }) => (
-            <div style={{ transform: `scale(${scale})`, backgroundImage: `url(${media.get('preview_url')})`, backgroundPosition: `${x}% ${y}%` }}>
-              <div className='composer--upload_form--actions'>
+            <div className='compose-form__upload-thumbnail' style={{ transform: `scale(${scale})`, backgroundImage: `url(${media.get('preview_url')})`, backgroundPosition: `${x}% ${y}%` }}>
+              <div className='compose-form__upload__actions'>
                 <button className='icon-button' onClick={this.handleUndoClick}><Icon id='times' /> <FormattedMessage id='upload_form.undo' defaultMessage='Delete' /></button>
                 {!isEditingStatus && (<button className='icon-button' onClick={this.handleFocalPointClick}><Icon id='pencil' /> <FormattedMessage id='upload_form.edit' defaultMessage='Edit' /></button>)}
               </div>
 
               {(media.get('description') || '').length === 0 && (
-                <div className='composer--upload_form--item__warning'>
+                <div className='compose-form__upload__warning'>
                   <button className='icon-button' onClick={this.handleFocalPointClick}><Icon id='info-circle' /> <FormattedMessage id='upload_form.description_missing' defaultMessage='No description added' /></button>
                 </div>
               )}
diff --git a/app/javascript/flavours/glitch/features/compose/components/upload_form.js b/app/javascript/flavours/glitch/features/compose/components/upload_form.js
index 43039c674..7ebbac963 100644
--- a/app/javascript/flavours/glitch/features/compose/components/upload_form.js
+++ b/app/javascript/flavours/glitch/features/compose/components/upload_form.js
@@ -4,7 +4,6 @@ import UploadProgressContainer from '../containers/upload_progress_container';
 import ImmutablePureComponent from 'react-immutable-pure-component';
 import UploadContainer from '../containers/upload_container';
 import SensitiveButtonContainer from '../containers/sensitive_button_container';
-import { FormattedMessage } from 'react-intl';
 
 export default class UploadForm extends ImmutablePureComponent {
   static propTypes = {
@@ -15,11 +14,11 @@ export default class UploadForm extends ImmutablePureComponent {
     const { mediaIds } = this.props;
 
     return (
-      <div className='composer--upload_form'>
-        <UploadProgressContainer icon='upload' message={<FormattedMessage id='upload_progress.label' defaultMessage='Uploading…' />} />
+      <div className='compose-form__upload-wrapper'>
+        <UploadProgressContainer />
 
         {mediaIds.size > 0 && (
-          <div className='content'>
+          <div className='compose-form__uploads-wrapper'>
             {mediaIds.map(id => (
               <UploadContainer id={id} key={id} />
             ))}
diff --git a/app/javascript/flavours/glitch/features/compose/components/upload_progress.js b/app/javascript/flavours/glitch/features/compose/components/upload_progress.js
index 493bb9ca5..39ac31053 100644
--- a/app/javascript/flavours/glitch/features/compose/components/upload_progress.js
+++ b/app/javascript/flavours/glitch/features/compose/components/upload_progress.js
@@ -1,37 +1,46 @@
 import React from 'react';
 import PropTypes from 'prop-types';
-import Motion from 'flavours/glitch/util/optional_motion';
+import Motion from '../../ui/util/optional_motion';
 import spring from 'react-motion/lib/spring';
 import Icon from 'flavours/glitch/components/icon';
+import { FormattedMessage } from 'react-intl';
 
 export default class UploadProgress extends React.PureComponent {
 
   static propTypes = {
     active: PropTypes.bool,
     progress: PropTypes.number,
-    icon: PropTypes.string.isRequired,
-    message: PropTypes.node.isRequired,
+    isProcessing: PropTypes.bool,
   };
 
   render () {
-    const { active, progress, icon, message } = this.props;
+    const { active, progress, isProcessing } = this.props;
 
     if (!active) {
       return null;
     }
 
+    let message;
+
+    if (isProcessing) {
+      message = <FormattedMessage id='upload_progress.processing' defaultMessage='Processing…' />;
+    } else {
+      message = <FormattedMessage id='upload_progress.label' defaultMessage='Uploading…' />;
+    }
+
     return (
-      <div className='composer--upload_form--progress'>
-        <Icon id={icon} />
+      <div className='upload-progress'>
+        <div className='upload-progress__icon'>
+          <Icon id='upload' />
+        </div>
 
-        <div className='message'>
+        <div className='upload-progress__message'>
           {message}
 
-          <div className='backdrop'>
+          <div className='upload-progress__backdrop'>
             <Motion defaultStyle={{ width: 0 }} style={{ width: spring(progress) }}>
               {({ width }) =>
-                (<div className='tracker' style={{ width: `${width}%` }}
-                />)
+                <div className='upload-progress__tracker' style={{ width: `${width}%` }} />
               }
             </Motion>
           </div>
diff --git a/app/javascript/flavours/glitch/features/compose/components/warning.js b/app/javascript/flavours/glitch/features/compose/components/warning.js
index 6ee3640bc..803b7f86a 100644
--- a/app/javascript/flavours/glitch/features/compose/components/warning.js
+++ b/app/javascript/flavours/glitch/features/compose/components/warning.js
@@ -1,6 +1,6 @@
 import React from 'react';
 import PropTypes from 'prop-types';
-import Motion from 'flavours/glitch/util/optional_motion';
+import Motion from '../../ui/util/optional_motion';
 import spring from 'react-motion/lib/spring';
 
 export default class Warning extends React.PureComponent {
@@ -15,7 +15,7 @@ export default class Warning extends React.PureComponent {
     return (
       <Motion defaultStyle={{ opacity: 0, scaleX: 0.85, scaleY: 0.75 }} style={{ opacity: spring(1, { damping: 35, stiffness: 400 }), scaleX: spring(1, { damping: 35, stiffness: 400 }), scaleY: spring(1, { damping: 35, stiffness: 400 }) }}>
         {({ opacity, scaleX, scaleY }) => (
-          <div className='composer--warning' style={{ opacity: opacity, transform: `scale(${scaleX}, ${scaleY})` }}>
+          <div className='compose-form__warning' style={{ opacity: opacity, transform: `scale(${scaleX}, ${scaleY})` }}>
             {message}
           </div>
         )}
diff --git a/app/javascript/flavours/glitch/features/compose/containers/compose_form_container.js b/app/javascript/flavours/glitch/features/compose/containers/compose_form_container.js
index a037bbbcc..d12c98c01 100644
--- a/app/javascript/flavours/glitch/features/compose/containers/compose_form_container.js
+++ b/app/javascript/flavours/glitch/features/compose/containers/compose_form_container.js
@@ -18,7 +18,7 @@ import {
 } from 'flavours/glitch/actions/modal';
 import { changeLocalSetting } from 'flavours/glitch/actions/local_settings';
 
-import { privacyPreference } from 'flavours/glitch/util/privacy_preference';
+import { privacyPreference } from 'flavours/glitch/utils/privacy_preference';
 
 const messages = defineMessages({
   missingDescriptionMessage: {  id: 'confirmations.missing_media_description.message',
diff --git a/app/javascript/flavours/glitch/features/compose/containers/emoji_picker_dropdown_container.js b/app/javascript/flavours/glitch/features/compose/containers/emoji_picker_dropdown_container.js
new file mode 100644
index 000000000..66d51947a
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/containers/emoji_picker_dropdown_container.js
@@ -0,0 +1,83 @@
+import { connect } from 'react-redux';
+import EmojiPickerDropdown from '../components/emoji_picker_dropdown';
+import { changeSetting } from 'flavours/glitch/actions/settings';
+import { createSelector } from 'reselect';
+import { Map as ImmutableMap } from 'immutable';
+import { useEmoji } from 'flavours/glitch/actions/emojis';
+
+const perLine = 8;
+const lines   = 2;
+
+const DEFAULTS = [
+  '+1',
+  'grinning',
+  'kissing_heart',
+  'heart_eyes',
+  'laughing',
+  'stuck_out_tongue_winking_eye',
+  'sweat_smile',
+  'joy',
+  'yum',
+  'disappointed',
+  'thinking_face',
+  'weary',
+  'sob',
+  'sunglasses',
+  'heart',
+  'ok_hand',
+];
+
+const getFrequentlyUsedEmojis = createSelector([
+  state => state.getIn(['settings', 'frequentlyUsedEmojis'], ImmutableMap()),
+], emojiCounters => {
+  let emojis = emojiCounters
+    .keySeq()
+    .sort((a, b) => emojiCounters.get(a) - emojiCounters.get(b))
+    .reverse()
+    .slice(0, perLine * lines)
+    .toArray();
+
+  if (emojis.length < DEFAULTS.length) {
+    let uniqueDefaults = DEFAULTS.filter(emoji => !emojis.includes(emoji));
+    emojis = emojis.concat(uniqueDefaults.slice(0, DEFAULTS.length - emojis.length));
+  }
+
+  return emojis;
+});
+
+const getCustomEmojis = createSelector([
+  state => state.get('custom_emojis'),
+], emojis => emojis.filter(e => e.get('visible_in_picker')).sort((a, b) => {
+  const aShort = a.get('shortcode').toLowerCase();
+  const bShort = b.get('shortcode').toLowerCase();
+
+  if (aShort < bShort) {
+    return -1;
+  } else if (aShort > bShort ) {
+    return 1;
+  } else {
+    return 0;
+  }
+}));
+
+const mapStateToProps = state => ({
+  custom_emojis: getCustomEmojis(state),
+  skinTone: state.getIn(['settings', 'skinTone']),
+  frequentlyUsedEmojis: getFrequentlyUsedEmojis(state),
+});
+
+const mapDispatchToProps = (dispatch, { onPickEmoji }) => ({
+  onSkinTone: skinTone => {
+    dispatch(changeSetting(['skinTone'], skinTone));
+  },
+
+  onPickEmoji: emoji => {
+    dispatch(useEmoji(emoji));
+
+    if (onPickEmoji) {
+      onPickEmoji(emoji);
+    }
+  },
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(EmojiPickerDropdown);
diff --git a/app/javascript/flavours/glitch/features/compose/containers/header_container.js b/app/javascript/flavours/glitch/features/compose/containers/header_container.js
index 2f0da48c8..e1ce19fb0 100644
--- a/app/javascript/flavours/glitch/features/compose/containers/header_container.js
+++ b/app/javascript/flavours/glitch/features/compose/containers/header_container.js
@@ -2,7 +2,7 @@ import { openModal } from 'flavours/glitch/actions/modal';
 import { connect }   from 'react-redux';
 import { defineMessages, injectIntl } from 'react-intl';
 import Header from '../components/header';
-import { logOut } from 'flavours/glitch/util/log_out';
+import { logOut } from 'flavours/glitch/utils/log_out';
 
 const messages = defineMessages({
   logoutMessage: { id: 'confirmations.logout.message', defaultMessage: 'Are you sure you want to log out?' },
diff --git a/app/javascript/flavours/glitch/features/compose/containers/navigation_container.js b/app/javascript/flavours/glitch/features/compose/containers/navigation_container.js
index eb630ffbb..0e1400261 100644
--- a/app/javascript/flavours/glitch/features/compose/containers/navigation_container.js
+++ b/app/javascript/flavours/glitch/features/compose/containers/navigation_container.js
@@ -1,6 +1,6 @@
 import { connect }   from 'react-redux';
 import NavigationBar from '../components/navigation_bar';
-import { me } from 'flavours/glitch/util/initial_state';
+import { me } from 'flavours/glitch/initial_state';
 
 const mapStateToProps = state => {
   return {
diff --git a/app/javascript/flavours/glitch/features/compose/containers/upload_progress_container.js b/app/javascript/flavours/glitch/features/compose/containers/upload_progress_container.js
index 0cfee96da..b18c76a43 100644
--- a/app/javascript/flavours/glitch/features/compose/containers/upload_progress_container.js
+++ b/app/javascript/flavours/glitch/features/compose/containers/upload_progress_container.js
@@ -4,6 +4,7 @@ import UploadProgress from '../components/upload_progress';
 const mapStateToProps = state => ({
   active: state.getIn(['compose', 'is_uploading']),
   progress: state.getIn(['compose', 'progress']),
+  isProcessing: state.getIn(['compose', 'is_processing']),
 });
 
 export default connect(mapStateToProps)(UploadProgress);
diff --git a/app/javascript/flavours/glitch/features/compose/containers/warning_container.js b/app/javascript/flavours/glitch/features/compose/containers/warning_container.js
index 5fccaa442..b2ed40b82 100644
--- a/app/javascript/flavours/glitch/features/compose/containers/warning_container.js
+++ b/app/javascript/flavours/glitch/features/compose/containers/warning_container.js
@@ -3,8 +3,8 @@ import { connect } from 'react-redux';
 import Warning from '../components/warning';
 import PropTypes from 'prop-types';
 import { FormattedMessage } from 'react-intl';
-import { me } from 'flavours/glitch/util/initial_state';
-import { profileLink, termsLink } from 'flavours/glitch/util/backend_links';
+import { me } from 'flavours/glitch/initial_state';
+import { profileLink, termsLink } from 'flavours/glitch/utils/backend_links';
 
 const buildHashtagRE = () => {
   try {
diff --git a/app/javascript/flavours/glitch/features/compose/index.js b/app/javascript/flavours/glitch/features/compose/index.js
index b9a8e0245..8ca378672 100644
--- a/app/javascript/flavours/glitch/features/compose/index.js
+++ b/app/javascript/flavours/glitch/features/compose/index.js
@@ -8,12 +8,14 @@ import { mountCompose, unmountCompose } from 'flavours/glitch/actions/compose';
 import { injectIntl, defineMessages } from 'react-intl';
 import classNames from 'classnames';
 import SearchContainer from './containers/search_container';
-import Motion from 'flavours/glitch/util/optional_motion';
+import Motion from '../ui/util/optional_motion';
 import spring from 'react-motion/lib/spring';
 import SearchResultsContainer from './containers/search_results_container';
-import { me, mascot } from 'flavours/glitch/util/initial_state';
+import { me, mascot } from 'flavours/glitch/initial_state';
 import { cycleElefriendCompose } from 'flavours/glitch/actions/compose';
 import HeaderContainer from './containers/header_container';
+import Column from 'flavours/glitch/components/column';
+import { Helmet } from 'react-helmet';
 
 const messages = defineMessages({
   compose: { id: 'navigation_bar.compose', defaultMessage: 'Compose new post' },
@@ -21,7 +23,7 @@ const messages = defineMessages({
 
 const mapStateToProps = (state, ownProps) => ({
   elefriend: state.getIn(['compose', 'elefriend']),
-  showSearch: ownProps.multiColumn ? state.getIn(['search', 'submitted']) && !state.getIn(['search', 'hidden']) : ownProps.isSearchPage,
+  showSearch: ownProps.multiColumn ? state.getIn(['search', 'submitted']) && !state.getIn(['search', 'hidden']) : false,
 });
 
 const mapDispatchToProps = (dispatch, { intl }) => ({
@@ -44,7 +46,6 @@ class Compose extends React.PureComponent {
   static propTypes = {
     multiColumn: PropTypes.bool,
     showSearch: PropTypes.bool,
-    isSearchPage: PropTypes.bool,
     elefriend: PropTypes.number,
     onClickElefriend: PropTypes.func,
     onMount: PropTypes.func,
@@ -53,19 +54,11 @@ class Compose extends React.PureComponent {
   };
 
   componentDidMount () {
-    const { isSearchPage } = this.props;
-
-    if (!isSearchPage) {
-      this.props.onMount();
-    }
+    this.props.onMount();
   }
 
   componentWillUnmount () {
-    const { isSearchPage } = this.props;
-
-    if (!isSearchPage) {
-      this.props.onUnmount();
-    }
+    this.props.onUnmount();
   }
 
   render () {
@@ -74,37 +67,49 @@ class Compose extends React.PureComponent {
       intl,
       multiColumn,
       onClickElefriend,
-      isSearchPage,
       showSearch,
     } = this.props;
     const computedClass = classNames('drawer', `mbstobon-${elefriend}`);
 
-    return (
-      <div className={computedClass} role='region' aria-label={intl.formatMessage(messages.compose)}>
-        {multiColumn && <HeaderContainer />}
+    if (multiColumn) {
+      return (
+        <div className={computedClass} role='region' aria-label={intl.formatMessage(messages.compose)}>
+          <HeaderContainer />
 
-        {(multiColumn || isSearchPage) && <SearchContainer />}
+          {multiColumn && <SearchContainer />}
 
-        <div className='drawer__pager'>
-          {!isSearchPage && <div className='drawer__inner'>
-            <NavigationContainer />
+          <div className='drawer__pager'>
+            <div className='drawer__inner'>
+              <NavigationContainer />
 
-            <ComposeFormContainer />
+              <ComposeFormContainer />
 
-            <div className='drawer__inner__mastodon'>
-              {mascot ? <img alt='' draggable='false' src={mascot} /> : <button className='mastodon' onClick={onClickElefriend} />}
+              <div className='drawer__inner__mastodon'>
+                {mascot ? <img alt='' draggable='false' src={mascot} /> : <button className='mastodon' onClick={onClickElefriend} />}
+              </div>
             </div>
-          </div>}
 
-          <Motion defaultStyle={{ x: isSearchPage ? 0 : -100 }} style={{ x: spring(showSearch || isSearchPage ? 0 : -100, { stiffness: 210, damping: 20 }) }}>
-            {({ x }) => (
-              <div className='drawer__inner darker' style={{ transform: `translateX(${x}%)`, visibility: x === -100 ? 'hidden' : 'visible' }}>
-                <SearchResultsContainer />
-              </div>
-            )}
-          </Motion>
+            <Motion defaultStyle={{ x: -100 }} style={{ x: spring(showSearch ? 0 : -100, { stiffness: 210, damping: 20 }) }}>
+              {({ x }) => (
+                <div className='drawer__inner darker' style={{ transform: `translateX(${x}%)`, visibility: x === -100 ? 'hidden' : 'visible' }}>
+                  <SearchResultsContainer />
+                </div>
+              )}
+            </Motion>
+          </div>
         </div>
-      </div>
+      );
+    }
+
+    return (
+      <Column>
+        <NavigationContainer />
+        <ComposeFormContainer />
+
+        <Helmet>
+          <meta name='robots' content='noindex' />
+        </Helmet>
+      </Column>
     );
   }
 
diff --git a/app/javascript/flavours/glitch/features/compose/util/counter.js b/app/javascript/flavours/glitch/features/compose/util/counter.js
new file mode 100644
index 000000000..7aa9e87b1
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/util/counter.js
@@ -0,0 +1,9 @@
+import { urlRegex } from './url_regex';
+
+const urlPlaceholder = '$2xxxxxxxxxxxxxxxxxxxxxxx';
+
+export function countableText(inputText) {
+  return inputText
+    .replace(urlRegex, urlPlaceholder)
+    .replace(/(^|[^\/\w])@(([a-z0-9_]+)@[a-z0-9\.\-]+[a-z0-9]+)/ig, '$1@$3');
+};
diff --git a/app/javascript/flavours/glitch/features/compose/util/url_regex.js b/app/javascript/flavours/glitch/features/compose/util/url_regex.js
new file mode 100644
index 000000000..9c2005c53
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/util/url_regex.js
@@ -0,0 +1,30 @@
+import regexSupplant from 'twitter-text/dist/lib/regexSupplant';
+import validUrlPrecedingChars from 'twitter-text/dist/regexp/validUrlPrecedingChars';
+import validDomain from 'twitter-text/dist/regexp/validDomain';
+import validPortNumber from 'twitter-text/dist/regexp/validPortNumber';
+import validUrlPath from 'twitter-text/dist/regexp/validUrlPath';
+import validUrlQueryChars from 'twitter-text/dist/regexp/validUrlQueryChars';
+import validUrlQueryEndingChars from 'twitter-text/dist/regexp/validUrlQueryEndingChars';
+
+// The difference with twitter-text's extractURL is that the protocol isn't
+// optional.
+
+export const urlRegex = regexSupplant(
+  '('                                                          + // $1 URL
+    '(#{validUrlPrecedingChars})'                              + // $2
+    '(https?:\\/\\/)'                                          + // $3 Protocol
+    '(#{validDomain})'                                         + // $4 Domain(s)
+    '(?::(#{validPortNumber}))?'                               + // $5 Port number (optional)
+    '(\\/#{validUrlPath}*)?'                                   + // $6 URL Path
+    '(\\?#{validUrlQueryChars}*#{validUrlQueryEndingChars})?'  + // $7 Query String
+  ')',
+  {
+    validUrlPrecedingChars,
+    validDomain,
+    validPortNumber,
+    validUrlPath,
+    validUrlQueryChars,
+    validUrlQueryEndingChars,
+  },
+  'gi',
+);