about summary refs log tree commit diff
path: root/app/javascript/flavours/glitch/features/compose
diff options
context:
space:
mode:
authorStarfall <root@starfall.blue>2019-12-09 19:07:33 -0600
committerStarfall <root@starfall.blue>2019-12-09 19:09:31 -0600
commit6b34fcfef7566105e8d80ab5fee0a539c06cddbf (patch)
tree8fad2d47bf8be255d3c671c40cbfd04c2f55ed03 /app/javascript/flavours/glitch/features/compose
parent9fbb4af7611aa7836e65ef9f544d341423c15685 (diff)
parent246addd5b33a172600342af3fb6fb5e4c80ad95e (diff)
Merge branch 'glitch'`
Diffstat (limited to 'app/javascript/flavours/glitch/features/compose')
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/character_counter.js25
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/compose_form.js50
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/dropdown.js192
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/dropdown_menu.js251
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/header.js26
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/navigation_bar.js20
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/options.js4
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/poll_form.js2
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/publisher.js5
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/search.js34
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/search_results.js44
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/text_icon_button.js43
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/textarea_icons.js4
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/upload.js86
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/upload_form.js3
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/upload_progress.js9
-rw-r--r--app/javascript/flavours/glitch/features/compose/containers/compose_form_container.js6
-rw-r--r--app/javascript/flavours/glitch/features/compose/containers/header_container.js16
-rw-r--r--app/javascript/flavours/glitch/features/compose/containers/options_container.js3
-rw-r--r--app/javascript/flavours/glitch/features/compose/containers/search_results_container.js5
-rw-r--r--app/javascript/flavours/glitch/features/compose/containers/upload_container.js6
-rw-r--r--app/javascript/flavours/glitch/features/compose/containers/warning_container.js5
22 files changed, 476 insertions, 363 deletions
diff --git a/app/javascript/flavours/glitch/features/compose/components/character_counter.js b/app/javascript/flavours/glitch/features/compose/components/character_counter.js
new file mode 100644
index 000000000..0ecfc9141
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/components/character_counter.js
@@ -0,0 +1,25 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { length } from 'stringz';
+
+export default class CharacterCounter extends React.PureComponent {
+
+  static propTypes = {
+    text: PropTypes.string.isRequired,
+    max: PropTypes.number.isRequired,
+  };
+
+  checkRemainingText (diff) {
+    if (diff < 0) {
+      return <span className='character-counter character-counter--over'>{diff}</span>;
+    }
+
+    return <span className='character-counter'>{diff}</span>;
+  }
+
+  render () {
+    const diff = this.props.max - length(this.props.text);
+    return this.checkRemainingText(diff);
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/compose/components/compose_form.js b/app/javascript/flavours/glitch/features/compose/components/compose_form.js
index cbce675d5..6e07998ec 100644
--- a/app/javascript/flavours/glitch/features/compose/components/compose_form.js
+++ b/app/javascript/flavours/glitch/features/compose/components/compose_form.js
@@ -15,6 +15,8 @@ import { countableText } from 'flavours/glitch/util/counter';
 import OptionsContainer from '../containers/options_container';
 import Publisher from './publisher';
 import TextareaIcons from './textarea_icons';
+import { maxChars } from 'flavours/glitch/util/initial_state';
+import CharacterCounter from './character_counter';
 
 const messages = defineMessages({
   placeholder: { id: 'compose_form.placeholder', defaultMessage: 'What is on your mind?' },
@@ -119,14 +121,8 @@ class ComposeForm extends ImmutablePureComponent {
 
     // Submit unless there are media with missing descriptions
     if (mediaDescriptionConfirmation && onMediaDescriptionConfirm && media && media.some(item => !item.get('description'))) {
-      const firstWithoutDescription = media.findIndex(item => !item.get('description'));
-      if (uploadForm) {
-        const inputs = uploadForm.querySelectorAll('.composer--upload_form--item input');
-        if (inputs.length == media.size && firstWithoutDescription !== -1) {
-          inputs[firstWithoutDescription].focus();
-        }
-      }
-      onMediaDescriptionConfirm(this.context.router ? this.context.router.history : null);
+      const firstWithoutDescription = media.find(item => !item.get('description'));
+      onMediaDescriptionConfirm(this.context.router ? this.context.router.history : null, firstWithoutDescription.get('id'));
     } else if (onSubmit) {
       onSubmit(this.context.router ? this.context.router.history : null);
     }
@@ -197,7 +193,10 @@ class ComposeForm extends ImmutablePureComponent {
 
   handleFocus = () => {
     if (this.composeForm && !this.props.singleColumn) {
-      this.composeForm.scrollIntoView();
+      const { left, right } = this.composeForm.getBoundingClientRect();
+      if (left < 0 || right > (window.innerWidth || document.documentElement.clientWidth)) {
+        this.composeForm.scrollIntoView();
+      }
     }
   }
 
@@ -295,13 +294,15 @@ class ComposeForm extends ImmutablePureComponent {
 
     let disabledButton = isSubmitting || isUploading || isChangingUpload || (!text.trim().length && !anyMedia);
 
+    const countText = `${spoilerText}${countableText(text)}${advancedOptions && advancedOptions.get('do_not_federate') ? ' 👁️' : ''}`;
+
     return (
-      <div className='composer' ref={this.setRef}>
+      <div className='composer'>
         <WarningContainer />
 
         <ReplyIndicatorContainer />
 
-        <div className={`composer--spoiler ${spoiler ? 'composer--spoiler--visible' : ''}`}>
+        <div className={`composer--spoiler ${spoiler ? 'composer--spoiler--visible' : ''}`} ref={this.setRef}>
           <AutosuggestInput
             placeholder={intl.formatMessage(messages.spoiler_placeholder)}
             value={spoilerText}
@@ -344,19 +345,24 @@ class ComposeForm extends ImmutablePureComponent {
           </div>
         </AutosuggestTextarea>
 
-        <OptionsContainer
-          advancedOptions={advancedOptions}
-          disabled={isSubmitting}
-          onChangeVisibility={onChangeVisibility}
-          onToggleSpoiler={spoilersAlwaysOn ? null : onChangeSpoilerness}
-          onUpload={onPaste}
-          privacy={privacy}
-          sensitive={sensitive || (spoilersAlwaysOn && spoilerText && spoilerText.length > 0)}
-          spoiler={spoilersAlwaysOn ? (spoilerText && spoilerText.length > 0) : spoiler}
-        />
+        <div className='composer--options-wrapper'>
+          <OptionsContainer
+            advancedOptions={advancedOptions}
+            disabled={isSubmitting}
+            onChangeVisibility={onChangeVisibility}
+            onToggleSpoiler={spoilersAlwaysOn ? null : onChangeSpoilerness}
+            onUpload={onPaste}
+            privacy={privacy}
+            sensitive={sensitive || (spoilersAlwaysOn && spoilerText && spoilerText.length > 0)}
+            spoiler={spoilersAlwaysOn ? (spoilerText && spoilerText.length > 0) : spoiler}
+          />
+          <div className='compose--counter-wrapper'>
+            <CharacterCounter text={countText} max={maxChars} />
+          </div>
+        </div>
 
         <Publisher
-          countText={`${spoilerText}${countableText(text)}${advancedOptions && advancedOptions.get('do_not_federate') ? ' 👁️' : ''}`}
+          countText={countText}
           disabled={disabledButton}
           onSecondarySubmit={handleSecondarySubmit}
           onSubmit={handleSubmit}
diff --git a/app/javascript/flavours/glitch/features/compose/components/dropdown.js b/app/javascript/flavours/glitch/features/compose/components/dropdown.js
index 8d982208f..60035b705 100644
--- a/app/javascript/flavours/glitch/features/compose/components/dropdown.js
+++ b/app/javascript/flavours/glitch/features/compose/components/dropdown.js
@@ -12,33 +12,101 @@ import DropdownMenu from './dropdown_menu';
 import { isUserTouching } from 'flavours/glitch/util/is_mobile';
 import { assignHandlers } from 'flavours/glitch/util/react_helpers';
 
-//  Handlers.
-const handlers = {
+//  The component.
+export default class ComposerOptionsDropdown extends React.PureComponent {
 
-  //  Closes the dropdown.
-  handleClose () {
-    this.setState({ open: false });
-  },
+  static propTypes = {
+    active: PropTypes.bool,
+    disabled: PropTypes.bool,
+    icon: PropTypes.string,
+    items: PropTypes.arrayOf(PropTypes.shape({
+      icon: PropTypes.string,
+      meta: PropTypes.node,
+      name: PropTypes.string.isRequired,
+      on: PropTypes.bool,
+      text: PropTypes.node,
+    })).isRequired,
+    onModalOpen: PropTypes.func,
+    onModalClose: PropTypes.func,
+    title: PropTypes.string,
+    value: PropTypes.string,
+    onChange: PropTypes.func,
+  };
+
+  state = {
+    needsModalUpdate: false,
+    open: false,
+    openedViaKeyboard: undefined,
+    placement: 'bottom',
+  };
 
-  //  The enter key toggles the dropdown's open state, and the escape
-  //  key closes it.
-  handleKeyDown ({ key }) {
-    const {
-      handleClose,
-      handleToggle,
-    } = this.handlers;
-    switch (key) {
+  //  Toggles opening and closing the dropdown.
+  handleToggle = ({ target, type }) => {
+    const { onModalOpen } = this.props;
+    const { open } = this.state;
+
+    if (isUserTouching()) {
+      if (this.state.open) {
+        this.props.onModalClose();
+      } else {
+        const modal = this.handleMakeModal();
+        if (modal && onModalOpen) {
+          onModalOpen(modal);
+        }
+      }
+    } else {
+      const { top } = target.getBoundingClientRect();
+      if (this.state.open && this.activeElement) {
+        this.activeElement.focus();
+      }
+      this.setState({ placement: top * 2 < innerHeight ? 'bottom' : 'top' });
+      this.setState({ open: !this.state.open, openedViaKeyboard: type !== 'click' });
+    }
+  }
+
+  handleKeyDown = (e) => {
+    switch (e.key) {
+    case 'Escape':
+      this.handleClose();
+      break;
+    }
+  }
+
+  handleMouseDown = () => {
+    if (!this.state.open) {
+      this.activeElement = document.activeElement;
+    }
+  }
+
+  handleButtonKeyDown = (e) => {
+    switch(e.key) {
+    case ' ':
     case 'Enter':
-      handleToggle(key);
+      this.handleMouseDown();
       break;
-    case 'Escape':
-      handleClose();
+    }
+  }
+
+  handleKeyPress = (e) => {
+    switch(e.key) {
+    case ' ':
+    case 'Enter':
+      this.handleToggle(e);
+      e.stopPropagation();
+      e.preventDefault();
       break;
     }
-  },
+  }
+
+  handleClose = () => {
+    if (this.state.open && this.activeElement) {
+      this.activeElement.focus();
+    }
+    this.setState({ open: false });
+  }
 
   //  Creates an action modal object.
-  handleMakeModal () {
+  handleMakeModal = () => {
     const component = this;
     const {
       items,
@@ -76,74 +144,31 @@ const handlers = {
         })
       ),
     };
-  },
-
-  //  Toggles opening and closing the dropdown.
-  handleToggle ({ target }) {
-    const { handleMakeModal } = this.handlers;
-    const { onModalOpen } = this.props;
-    const { open } = this.state;
-
-    //  If this is a touch device, we open a modal instead of the
-    //  dropdown.
-    if (isUserTouching()) {
-
-      //  This gets the modal to open.
-      const modal = handleMakeModal();
-
-      //  If we can, we then open the modal.
-      if (modal && onModalOpen) {
-        onModalOpen(modal);
-        return;
-      }
-    }
-
-    const { top } = target.getBoundingClientRect();
-    this.setState({ placement: top * 2 < innerHeight ? 'bottom' : 'top' });
-    //  Otherwise, we just set our state to open.
-    this.setState({ open: !open });
-  },
+  }
 
   //  If our modal is open and our props update, we need to also update
   //  the modal.
-  handleUpdate () {
-    const { handleMakeModal } = this.handlers;
+  handleUpdate = () => {
     const { onModalOpen } = this.props;
     const { needsModalUpdate } = this.state;
 
     //  Gets our modal object.
-    const modal = handleMakeModal();
+    const modal = this.handleMakeModal();
 
     //  Reopens the modal with the new object.
     if (needsModalUpdate && modal && onModalOpen) {
       onModalOpen(modal);
     }
-  },
-};
-
-//  The component.
-export default class ComposerOptionsDropdown extends React.PureComponent {
-
-  //  Constructor.
-  constructor (props) {
-    super(props);
-    assignHandlers(this, handlers);
-    this.state = {
-      needsModalUpdate: false,
-      open: false,
-      placement: 'bottom',
-    };
   }
 
   //  Updates our modal as necessary.
   componentDidUpdate (prevProps) {
-    const { handleUpdate } = this.handlers;
     const { items } = this.props;
     const { needsModalUpdate } = this.state;
     if (needsModalUpdate && items.find(
       (item, i) => item.on !== prevProps.items[i].on
     )) {
-      handleUpdate();
+      this.handleUpdate();
       this.setState({ needsModalUpdate: false });
     }
   }
@@ -151,11 +176,6 @@ export default class ComposerOptionsDropdown extends React.PureComponent {
   //  Rendering.
   render () {
     const {
-      handleClose,
-      handleKeyDown,
-      handleToggle,
-    } = this.handlers;
-    const {
       active,
       disabled,
       title,
@@ -175,14 +195,18 @@ export default class ComposerOptionsDropdown extends React.PureComponent {
     return (
       <div
         className={computedClass}
-        onKeyDown={handleKeyDown}
+        onKeyDown={this.handleKeyDown}
       >
         <IconButton
           active={open || active}
           className='value'
           disabled={disabled}
           icon={icon}
-          onClick={handleToggle}
+          inverted
+          onClick={this.handleToggle}
+          onMouseDown={this.handleMouseDown}
+          onKeyDown={this.handleButtonKeyDown}
+          onKeyPress={this.handleKeyPress}
           size={18}
           style={{
             height: null,
@@ -199,8 +223,9 @@ export default class ComposerOptionsDropdown extends React.PureComponent {
           <DropdownMenu
             items={items}
             onChange={onChange}
-            onClose={handleClose}
+            onClose={this.handleClose}
             value={value}
+            openedViaKeyboard={this.state.openedViaKeyboard}
           />
         </Overlay>
       </div>
@@ -208,22 +233,3 @@ export default class ComposerOptionsDropdown extends React.PureComponent {
   }
 
 }
-
-//  Props.
-ComposerOptionsDropdown.propTypes = {
-  active: PropTypes.bool,
-  disabled: PropTypes.bool,
-  icon: PropTypes.string,
-  items: PropTypes.arrayOf(PropTypes.shape({
-    icon: PropTypes.string,
-    meta: PropTypes.node,
-    name: PropTypes.string.isRequired,
-    on: PropTypes.bool,
-    text: PropTypes.node,
-  })).isRequired,
-  onChange: PropTypes.func,
-  onModalClose: PropTypes.func,
-  onModalOpen: PropTypes.func,
-  title: PropTypes.string,
-  value: PropTypes.string,
-};
diff --git a/app/javascript/flavours/glitch/features/compose/components/dropdown_menu.js b/app/javascript/flavours/glitch/features/compose/components/dropdown_menu.js
index 19d35a8f4..404504e84 100644
--- a/app/javascript/flavours/glitch/features/compose/components/dropdown_menu.js
+++ b/app/javascript/flavours/glitch/features/compose/components/dropdown_menu.js
@@ -14,91 +14,6 @@ import { withPassive } from 'flavours/glitch/util/dom_helpers';
 import Motion from 'flavours/glitch/util/optional_motion';
 import { assignHandlers } from 'flavours/glitch/util/react_helpers';
 
-class ComposerOptionsDropdownContentItem extends ImmutablePureComponent {
-
-  static propTypes = {
-    active: PropTypes.bool,
-    name: PropTypes.string,
-    onChange: PropTypes.func,
-    onClose: PropTypes.func,
-    options: PropTypes.shape({
-      icon: PropTypes.string,
-      meta: PropTypes.node,
-      on: PropTypes.bool,
-      text: PropTypes.node,
-    }),
-  };
-
-  handleActivate = (e) => {
-    const {
-      name,
-      onChange,
-      onClose,
-      options: { on },
-    } = this.props;
-
-    //  If the escape key was pressed, we close the dropdown.
-    if (e.key === 'Escape' && onClose) {
-      onClose();
-
-    //  Otherwise, we both close the dropdown and change the value.
-    } else if (onChange && (!e.key || e.key === 'Enter')) {
-      e.preventDefault();  //  Prevents change in focus on click
-      if ((on === null || typeof on === 'undefined') && onClose) {
-        onClose();
-      }
-      onChange(name);
-    }
-  }
-
-  //  Rendering.
-  render () {
-    const {
-      active,
-      options: {
-        icon,
-        meta,
-        on,
-        text,
-      },
-    } = this.props;
-    const computedClass = classNames('composer--options--dropdown--content--item', {
-      active,
-      lengthy: meta,
-      'toggled-off': !on && on !== null && typeof on !== 'undefined',
-      'toggled-on': on,
-      'with-icon': icon,
-    });
-
-    let prefix = null;
-
-    if (on !== null && typeof on !== 'undefined') {
-      prefix = <Toggle checked={on} onChange={this.handleActivate} />;
-    } else if (icon) {
-      prefix = <Icon className='icon' fullwidth icon={icon} />
-    }
-
-    //  The result.
-    return (
-      <div
-        className={computedClass}
-        onClick={this.handleActivate}
-        onKeyDown={this.handleActivate}
-        role='button'
-        tabIndex='0'
-      >
-        {prefix}
-
-        <div className='content'>
-          <strong>{text}</strong>
-          {meta}
-        </div>
-      </div>
-    );
-  }
-
-};
-
 //  The spring to use with our motion.
 const springMotion = spring(1, {
   damping: 35,
@@ -116,10 +31,11 @@ export default class ComposerOptionsDropdownContent extends React.PureComponent
       on: PropTypes.bool,
       text: PropTypes.node,
     })),
-    onChange: PropTypes.func,
-    onClose: PropTypes.func,
+    onChange: PropTypes.func.isRequired,
+    onClose: PropTypes.func.isRequired,
     style: PropTypes.object,
     value: PropTypes.string,
+    openedViaKeyboard: PropTypes.bool,
   };
 
   static defaultProps = {
@@ -128,14 +44,13 @@ export default class ComposerOptionsDropdownContent extends React.PureComponent
 
   state = {
     mounted: false,
+    value: this.props.openedViaKeyboard ? this.props.items[0].name : undefined,
   };
 
   //  When the document is clicked elsewhere, we close the dropdown.
-  handleDocumentClick = ({ target }) => {
-    const { node } = this;
-    const { onClose } = this.props;
-    if (onClose && node && !node.contains(target)) {
-      onClose();
+  handleDocumentClick = (e) => {
+    if (this.node && !this.node.contains(e.target)) {
+      this.props.onClose();
     }
   }
 
@@ -148,6 +63,11 @@ export default class ComposerOptionsDropdownContent extends React.PureComponent
   componentDidMount () {
     document.addEventListener('click', this.handleDocumentClick, false);
     document.addEventListener('touchend', this.handleDocumentClick, withPassive);
+    if (this.focusedItem) {
+      this.focusedItem.focus();
+    } else {
+      this.node.firstChild.focus();
+    }
     this.setState({ mounted: true });
   }
 
@@ -157,6 +77,135 @@ export default class ComposerOptionsDropdownContent extends React.PureComponent
     document.removeEventListener('touchend', this.handleDocumentClick, withPassive);
   }
 
+  handleClick = (name, e) => {
+    const {
+      onChange,
+      onClose,
+      items,
+    } = this.props;
+
+    const { on } = this.props.items.find(item => item.name === name);
+    e.preventDefault();  //  Prevents change in focus on click
+    if ((on === null || typeof on === 'undefined')) {
+      onClose();
+    }
+    onChange(name);
+  }
+
+  // Handle changes differently whether the dropdown is a list of options or actions
+  handleChange = (name) => {
+    if (this.props.value) {
+      this.props.onChange(name);
+    } else {
+      this.setState({ value: name });
+    }
+  }
+
+  handleKeyDown = (name, e) => {
+    const { items } = this.props;
+    const index = items.findIndex(item => {
+      return (item.name === name);
+    });
+    let element;
+
+    switch(e.key) {
+    case 'Escape':
+      this.props.onClose();
+      break;
+    case 'Enter':
+    case ' ':
+      this.handleClick(e);
+      break;
+    case 'ArrowDown':
+      element = this.node.childNodes[index + 1];
+      if (element) {
+        element.focus();
+        this.handleChange(element.getAttribute('data-index'));
+      }
+      break;
+    case 'ArrowUp':
+      element = this.node.childNodes[index - 1];
+      if (element) {
+        element.focus();
+        this.handleChange(element.getAttribute('data-index'));
+      }
+      break;
+    case 'Tab':
+      if (e.shiftKey) {
+        element = this.node.childNodes[index - 1] || this.node.lastChild;
+      } else {
+        element = this.node.childNodes[index + 1] || this.node.firstChild;
+      }
+      if (element) {
+        element.focus();
+        this.handleChange(element.getAttribute('data-index'));
+        e.preventDefault();
+        e.stopPropagation();
+      }
+      break;
+    case 'Home':
+      element = this.node.firstChild;
+      if (element) {
+        element.focus();
+        this.handleChange(element.getAttribute('data-index'));
+      }
+      break;
+    case 'End':
+      element = this.node.lastChild;
+      if (element) {
+        element.focus();
+        this.handleChange(element.getAttribute('data-index'));
+      }
+      break;
+    }
+  }
+
+  setFocusRef = c => {
+    this.focusedItem = c;
+  }
+
+  renderItem = (item) => {
+    const { name, icon, meta, on, text } = item;
+
+    const active = (name === (this.props.value || this.state.value));
+
+    const computedClass = classNames('composer--options--dropdown--content--item', {
+      active,
+      lengthy: meta,
+      'toggled-off': !on && on !== null && typeof on !== 'undefined',
+      'toggled-on': on,
+      'with-icon': icon,
+    });
+
+    let prefix = null;
+
+    if (on !== null && typeof on !== 'undefined') {
+      prefix = <Toggle checked={on} onChange={this.handleClick.bind(this, name)} />;
+    } else if (icon) {
+      prefix = <Icon className='icon' fixedWidth id={icon} />
+    }
+
+    return (
+      <div
+        className={computedClass}
+        onClick={this.handleClick.bind(this, name)}
+        onKeyDown={this.handleKeyDown.bind(this, name)}
+        role='option'
+        tabIndex='0'
+        key={name}
+        data-index={name}
+        ref={active ? this.setFocusRef : null}
+      >
+        {prefix}
+
+        <div className='content'>
+          <strong>{text}</strong>
+          {meta}
+        </div>
+      </div>
+    );
+  }
+
   //  Rendering.
   render () {
     const { mounted } = this.state;
@@ -165,7 +214,6 @@ export default class ComposerOptionsDropdownContent extends React.PureComponent
       onChange,
       onClose,
       style,
-      value,
     } = this.props;
 
     //  The result.
@@ -189,27 +237,14 @@ export default class ComposerOptionsDropdownContent extends React.PureComponent
           <div
             className='composer--options--dropdown--content'
             ref={this.handleRef}
+            role='listbox'
             style={{
               ...style,
               opacity: opacity,
               transform: mounted ? `scale(${scaleX}, ${scaleY})` : null,
             }}
           >
-            {items ? items.map(
-              ({
-                name,
-                ...rest
-              }) => (
-                <ComposerOptionsDropdownContentItem
-                  active={name === value}
-                  key={name}
-                  name={name}
-                  onChange={onChange}
-                  onClose={onClose}
-                  options={rest}
-                />
-              )
-            ) : null}
+            {!!items && items.map(item => this.renderItem(item))}
           </div>
         )}
       </Motion>
diff --git a/app/javascript/flavours/glitch/features/compose/components/header.js b/app/javascript/flavours/glitch/features/compose/components/header.js
index 2e29084f2..5b456b717 100644
--- a/app/javascript/flavours/glitch/features/compose/components/header.js
+++ b/app/javascript/flavours/glitch/features/compose/components/header.js
@@ -53,8 +53,18 @@ class Header extends ImmutablePureComponent {
     showNotificationsBadge: PropTypes.bool,
     intl: PropTypes.object,
     onSettingsClick: PropTypes.func,
+    onLogout: PropTypes.func.isRequired,
   };
 
+  handleLogoutClick = e => {
+    e.preventDefault();
+    e.stopPropagation();
+
+    this.props.onLogout();
+
+    return false;
+  }
+
   render () {
     const { intl, columns, unreadNotifications, showNotificationsBadge, onSettingsClick } = this.props;
 
@@ -72,13 +82,13 @@ class Header extends ImmutablePureComponent {
           aria-label={intl.formatMessage(messages.start)}
           title={intl.formatMessage(messages.start)}
           to='/getting-started'
-        ><Icon icon='asterisk' /></Link>
+        ><Icon id='asterisk' /></Link>
         {renderForColumn('HOME', (
           <Link
             aria-label={intl.formatMessage(messages.home_timeline)}
             title={intl.formatMessage(messages.home_timeline)}
             to='/timelines/home'
-          ><Icon icon='home' /></Link>
+          ><Icon id='home' /></Link>
         ))}
         {renderForColumn('NOTIFICATIONS', (
           <Link
@@ -87,7 +97,7 @@ class Header extends ImmutablePureComponent {
             to='/notifications'
           >
             <span className='icon-badge-wrapper'>
-              <Icon icon='bell' />
+              <Icon id='bell' />
               { showNotificationsBadge && unreadNotifications > 0 && <div className='icon-badge' />}
             </span>
           </Link>
@@ -97,27 +107,27 @@ class Header extends ImmutablePureComponent {
             aria-label={intl.formatMessage(messages.community)}
             title={intl.formatMessage(messages.community)}
             to='/timelines/public/local'
-          ><Icon icon='users' /></Link>
+          ><Icon id='users' /></Link>
         ))}
         {renderForColumn('PUBLIC', (
           <Link
             aria-label={intl.formatMessage(messages.public)}
             title={intl.formatMessage(messages.public)}
             to='/timelines/public'
-          ><Icon icon='globe' /></Link>
+          ><Icon id='globe' /></Link>
         ))}
         <a
           aria-label={intl.formatMessage(messages.settings)}
           onClick={onSettingsClick}
           href='#'
           title={intl.formatMessage(messages.settings)}
-        ><Icon icon='cogs' /></a>
+        ><Icon id='cogs' /></a>
         <a
           aria-label={intl.formatMessage(messages.logout)}
-          data-method='delete'
+          onClick={this.handleLogoutClick}
           href={ signOutLink }
           title={intl.formatMessage(messages.logout)}
-        ><Icon icon='sign-out' /></a>
+        ><Icon id='sign-out' /></a>
       </nav>
     );
   };
diff --git a/app/javascript/flavours/glitch/features/compose/components/navigation_bar.js b/app/javascript/flavours/glitch/features/compose/components/navigation_bar.js
index 3148434f1..f6bfbdd1e 100644
--- a/app/javascript/flavours/glitch/features/compose/components/navigation_bar.js
+++ b/app/javascript/flavours/glitch/features/compose/components/navigation_bar.js
@@ -20,16 +20,18 @@ export default class NavigationBar extends ImmutablePureComponent {
           <Avatar account={this.props.account} size={48} />
         </Permalink>
 
-        <Permalink className='acct' href={this.props.account.get('url')} to={`/accounts/${this.props.account.get('id')}`}>
-          <strong>@{this.props.account.get('acct')}</strong>
-        </Permalink>
+        <div className='navigation-bar__profile'>
+          <Permalink className='acct' href={this.props.account.get('url')} to={`/accounts/${this.props.account.get('id')}`}>
+            <strong>@{this.props.account.get('acct')}</strong>
+          </Permalink>
 
-        { profileLink !== undefined && (
-          <a
-            className='edit'
-            href={ profileLink }
-          ><FormattedMessage id='navigation_bar.edit_profile' defaultMessage='Edit profile' /></a>
-        )}
+          { profileLink !== undefined && (
+            <a
+              className='edit'
+              href={ profileLink }
+            ><FormattedMessage id='navigation_bar.edit_profile' defaultMessage='Edit profile' /></a>
+          )}
+        </div>
       </div>
     );
   };
diff --git a/app/javascript/flavours/glitch/features/compose/components/options.js b/app/javascript/flavours/glitch/features/compose/components/options.js
index 0c94f5514..92348b000 100644
--- a/app/javascript/flavours/glitch/features/compose/components/options.js
+++ b/app/javascript/flavours/glitch/features/compose/components/options.js
@@ -7,7 +7,7 @@ import spring from 'react-motion/lib/spring';
 
 //  Components.
 import IconButton from 'flavours/glitch/components/icon_button';
-import TextIconButton from 'flavours/glitch/components/text_icon_button';
+import TextIconButton from './text_icon_button';
 import Dropdown from './dropdown';
 import ImmutablePureComponent from 'react-immutable-pure-component';
 
@@ -232,7 +232,7 @@ class ComposerOptions extends ImmutablePureComponent {
 
     const contentTypeItems = {
       plain: {
-        icon: 'align-left',
+        icon: 'file-text',
         name: 'text/plain',
         text: <FormattedMessage {...messages.plain} />,
       },
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 21b5d3d73..3d818ea20 100644
--- a/app/javascript/flavours/glitch/features/compose/components/poll_form.js
+++ b/app/javascript/flavours/glitch/features/compose/components/poll_form.js
@@ -132,7 +132,7 @@ class PollForm extends ImmutablePureComponent {
           {options.size < pollLimits.max_options && (
             <label className='poll__text editable'>
               <span className={classNames('poll__input')} style={{ opacity: 0 }} />
-              <button className='button button-secondary' onClick={this.handleAddOption}><Icon icon='plus' /> <FormattedMessage {...messages.add_option} /></button>
+              <button className='button button-secondary' onClick={this.handleAddOption}><Icon id='plus' /> <FormattedMessage {...messages.add_option} /></button>
             </label>
           )}
         </ul>
diff --git a/app/javascript/flavours/glitch/features/compose/components/publisher.js b/app/javascript/flavours/glitch/features/compose/components/publisher.js
index e283b32b9..b8d9d98bf 100644
--- a/app/javascript/flavours/glitch/features/compose/components/publisher.js
+++ b/app/javascript/flavours/glitch/features/compose/components/publisher.js
@@ -49,7 +49,6 @@ class Publisher extends ImmutablePureComponent {
 
     return (
       <div className={computedClass}>
-        <span className='count'>{diff}</span>
         {sideArm && sideArm !== 'none' ? (
           <Button
             className='side_arm'
@@ -59,7 +58,7 @@ class Publisher extends ImmutablePureComponent {
             text={
               <span>
                 <Icon
-                  icon={{
+                  id={{
                     public: 'globe',
                     unlisted: 'unlock',
                     private: 'lock',
@@ -81,7 +80,7 @@ class Publisher extends ImmutablePureComponent {
               return (
                 <span>
                   <Icon
-                    icon={{
+                    id={{
                       direct: 'envelope',
                       private: 'lock',
                       public: 'globe',
diff --git a/app/javascript/flavours/glitch/features/compose/components/search.js b/app/javascript/flavours/glitch/features/compose/components/search.js
index 1d96933ea..12d839637 100644
--- a/app/javascript/flavours/glitch/features/compose/components/search.js
+++ b/app/javascript/flavours/glitch/features/compose/components/search.js
@@ -36,7 +36,7 @@ class SearchPopout extends React.PureComponent {
       <div style={{ ...style, position: 'absolute', width: 285, zIndex: 2 }}>
         <Motion defaultStyle={{ opacity: 0, scaleX: 0.85, scaleY: 0.75 }} style={{ opacity: spring(1, { damping: 35, stiffness: 400 }), scaleX: spring(1, { damping: 35, stiffness: 400 }), scaleY: spring(1, { damping: 35, stiffness: 400 }) }}>
           {({ opacity, scaleX, scaleY }) => (
-            <div className='drawer--search--popout' style={{ opacity: opacity, transform: `scale(${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>
@@ -73,12 +73,17 @@ class Search extends React.PureComponent {
     onShow: PropTypes.func.isRequired,
     openInRoute: PropTypes.bool,
     intl: PropTypes.object.isRequired,
+    singleColumn: PropTypes.bool,
   };
 
   state = {
     expanded: false,
   };
 
+  setRef = c => {
+    this.searchForm = c;
+  }
+
   handleChange = (e) => {
     const { onChange } = this.props;
     if (onChange) {
@@ -103,10 +108,14 @@ class Search extends React.PureComponent {
   }
 
   handleFocus = () => {
-    const { onShow } = this.props;
     this.setState({ expanded: true });
-    if (onShow) {
-      onShow();
+    this.props.onShow();
+
+    if (this.searchForm && !this.props.singleColumn) {
+      const { left, right } = this.searchForm.getBoundingClientRect();
+      if (left < 0 || right > (window.innerWidth || document.documentElement.clientWidth)) {
+        this.searchForm.scrollIntoView();
+      }
     }
   }
 
@@ -128,14 +137,15 @@ class Search extends React.PureComponent {
   render () {
     const { intl, value, submitted } = this.props;
     const { expanded } = this.state;
-    const active = value.length > 0 || submitted;
-    const computedClass = classNames('drawer--search', { active });
+    const hasValue = value.length > 0 || submitted;
 
     return (
-      <div className={computedClass}>
+      <div className='search'>
         <label>
           <span style={{ display: 'none' }}>{intl.formatMessage(messages.placeholder)}</span>
           <input
+            ref={this.setRef}
+            className='search__input'
             type='text'
             placeholder={intl.formatMessage(messages.placeholder)}
             value={value || ''}
@@ -145,17 +155,19 @@ class Search extends React.PureComponent {
             onBlur={this.handleBlur}
           />
         </label>
+
         <div
           aria-label={intl.formatMessage(messages.placeholder)}
-          className='icon'
+          className='search__icon'
           onClick={this.handleClear}
           role='button'
           tabIndex='0'
         >
-          <Icon icon='search' />
-          <Icon icon='times-circle' />
+          <Icon id='search' className={hasValue ? '' : 'active'} />
+          <Icon id='times-circle' className={hasValue ? 'active' : ''} />
         </div>
-        <Overlay show={expanded && !active} placement='bottom' target={this}>
+
+        <Overlay show={expanded && !hasValue} placement='bottom' target={this}>
           <SearchPopout />
         </Overlay>
       </div>
diff --git a/app/javascript/flavours/glitch/features/compose/components/search_results.js b/app/javascript/flavours/glitch/features/compose/components/search_results.js
index 69df8cdc9..fa3487328 100644
--- a/app/javascript/flavours/glitch/features/compose/components/search_results.js
+++ b/app/javascript/flavours/glitch/features/compose/components/search_results.js
@@ -7,6 +7,8 @@ import StatusContainer from 'flavours/glitch/containers/status_container';
 import ImmutablePureComponent from 'react-immutable-pure-component';
 import Hashtag from 'flavours/glitch/components/hashtag';
 import Icon from 'flavours/glitch/components/icon';
+import { searchEnabled } from 'flavours/glitch/util/initial_state';
+import LoadMore from 'flavours/glitch/components/load_more';
 
 const messages = defineMessages({
   dismissSuggestion: { id: 'suggestions.dismiss', defaultMessage: 'Dismiss suggestion' },
@@ -19,23 +21,33 @@ class SearchResults extends ImmutablePureComponent {
     results: ImmutablePropTypes.map.isRequired,
     suggestions: ImmutablePropTypes.list.isRequired,
     fetchSuggestions: PropTypes.func.isRequired,
+    expandSearch: PropTypes.func.isRequired,
     dismissSuggestion: PropTypes.func.isRequired,
+    searchTerm: PropTypes.string,
     intl: PropTypes.object.isRequired,
   };
 
   componentDidMount () {
-    this.props.fetchSuggestions();
+    if (this.props.searchTerm === '') {
+      this.props.fetchSuggestions();
+    }
   }
 
-  render() {
-    const { intl, results, suggestions, dismissSuggestion } = this.props;
+  handleLoadMoreAccounts = () => this.props.expandSearch('accounts');
+
+  handleLoadMoreStatuses = () => this.props.expandSearch('statuses');
+
+  handleLoadMoreHashtags = () => this.props.expandSearch('hashtags');
+
+  render () {
+    const { intl, results, suggestions, dismissSuggestion, searchTerm } = this.props;
 
     if (results.isEmpty() && !suggestions.isEmpty()) {
       return (
         <div className='drawer--results'>
           <div className='trends'>
             <div className='trends__header'>
-              <i className='fa fa-user-plus fa-fw' />
+              <Icon fixedWidth id='user-plus' />
               <FormattedMessage id='suggestions.header' defaultMessage='You might be interested in…' />
             </div>
 
@@ -51,6 +63,16 @@ class SearchResults extends ImmutablePureComponent {
           </div>
         </div>
       );
+    } else if(results.get('statuses') && results.get('statuses').size === 0 && !searchEnabled && !(searchTerm.startsWith('@') || searchTerm.startsWith('#') || searchTerm.includes(' '))) {
+      statuses = (
+        <section>
+          <h5><Icon id='quote-right' fixedWidth /><FormattedMessage id='search_results.statuses' defaultMessage='Toots' /></h5>
+
+          <div className='search-results__info'>
+            <FormattedMessage id='search_results.statuses_fts_disabled' defaultMessage='Searching toots by their content is not enabled on this Mastodon server.' />
+          </div>
+        </section>
+      );
     }
 
     let accounts, statuses, hashtags;
@@ -60,9 +82,11 @@ class SearchResults extends ImmutablePureComponent {
       count   += results.get('accounts').size;
       accounts = (
         <section>
-          <h5><Icon icon='users' fixedWidth /><FormattedMessage id='search_results.accounts' defaultMessage='People' /></h5>
+          <h5><Icon id='users' fixedWidth /><FormattedMessage id='search_results.accounts' defaultMessage='People' /></h5>
 
           {results.get('accounts').map(accountId => <AccountContainer id={accountId} key={accountId} />)}
+
+          {results.get('accounts').size >= 5 && <LoadMore visible onClick={this.handleLoadMoreAccounts} />}
         </section>
       );
     }
@@ -71,9 +95,11 @@ class SearchResults extends ImmutablePureComponent {
       count   += results.get('statuses').size;
       statuses = (
         <section>
-          <h5><Icon icon='quote-right' fixedWidth /><FormattedMessage id='search_results.statuses' defaultMessage='Toots' /></h5>
+          <h5><Icon id='quote-right' fixedWidth /><FormattedMessage id='search_results.statuses' defaultMessage='Toots' /></h5>
 
           {results.get('statuses').map(statusId => <StatusContainer id={statusId} key={statusId}/>)}
+
+          {results.get('statuses').size >= 5 && <LoadMore visible onClick={this.handleLoadMoreStatuses} />}
         </section>
       );
     }
@@ -82,9 +108,11 @@ class SearchResults extends ImmutablePureComponent {
       count += results.get('hashtags').size;
       hashtags = (
         <section>
-          <h5><Icon icon='hashtag' fixedWidth /><FormattedMessage id='search_results.hashtags' defaultMessage='Hashtags' /></h5>
+          <h5><Icon id='hashtag' fixedWidth /><FormattedMessage id='search_results.hashtags' defaultMessage='Hashtags' /></h5>
 
           {results.get('hashtags').map(hashtag => <Hashtag key={hashtag.get('name')} hashtag={hashtag} />)}
+
+          {results.get('hashtags').size >= 5 && <LoadMore visible onClick={this.handleLoadMoreHashtags} />}
         </section>
       );
     }
@@ -93,7 +121,7 @@ class SearchResults extends ImmutablePureComponent {
     return (
       <div className='drawer--results'>
         <header className='search-results__header'>
-          <Icon icon='search' fixedWidth />
+          <Icon id='search' fixedWidth />
           <FormattedMessage id='search_results.total' defaultMessage='{count, number} {count, plural, one {result} other {results}}' values={{ count }} />
         </header>
 
diff --git a/app/javascript/flavours/glitch/features/compose/components/text_icon_button.js b/app/javascript/flavours/glitch/features/compose/components/text_icon_button.js
new file mode 100644
index 000000000..7f2005060
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/components/text_icon_button.js
@@ -0,0 +1,43 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+const iconStyle = {
+  height: null,
+  lineHeight: '27px',
+  width: `${18 * 1.28571429}px`,
+};
+
+export default class TextIconButton extends React.PureComponent {
+
+  static propTypes = {
+    label: PropTypes.string.isRequired,
+    title: PropTypes.string,
+    active: PropTypes.bool,
+    onClick: PropTypes.func.isRequired,
+    ariaControls: PropTypes.string,
+  };
+
+  handleClick = (e) => {
+    e.preventDefault();
+    this.props.onClick();
+  }
+
+  render () {
+    const { label, title, active, ariaControls } = this.props;
+
+    return (
+      <button
+        title={title}
+        aria-label={title}
+        className={`text-icon-button ${active ? 'active' : ''}`}
+        aria-expanded={active}
+        onClick={this.handleClick}
+        aria-controls={ariaControls}
+        style={iconStyle}
+      >
+        {label}
+      </button>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/compose/components/textarea_icons.js b/app/javascript/flavours/glitch/features/compose/components/textarea_icons.js
index ec696f9c3..b875fb15e 100644
--- a/app/javascript/flavours/glitch/features/compose/components/textarea_icons.js
+++ b/app/javascript/flavours/glitch/features/compose/components/textarea_icons.js
@@ -47,8 +47,8 @@ class TextareaIcons extends ImmutablePureComponent {
               title={intl.formatMessage(message)}
             >
               <Icon
-                fullwidth
-                icon={icon}
+                fixedWidth
+                id={icon}
               />
             </span>
           ) : null
diff --git a/app/javascript/flavours/glitch/features/compose/components/upload.js b/app/javascript/flavours/glitch/features/compose/components/upload.js
index 84edf664e..425b0fe5e 100644
--- a/app/javascript/flavours/glitch/features/compose/components/upload.js
+++ b/app/javascript/flavours/glitch/features/compose/components/upload.js
@@ -4,18 +4,12 @@ import PropTypes from 'prop-types';
 import Motion from 'flavours/glitch/util/optional_motion';
 import spring from 'react-motion/lib/spring';
 import ImmutablePureComponent from 'react-immutable-pure-component';
-import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import { FormattedMessage } from 'react-intl';
 import classNames from 'classnames';
 import Icon from 'flavours/glitch/components/icon';
 import { isUserTouching } from 'flavours/glitch/util/is_mobile';
 
-const messages = defineMessages({
-  description: { id: 'upload_form.description', defaultMessage: 'Describe for the visually impaired' },
-});
-
-//  The component.
-export default @injectIntl
-class Upload extends ImmutablePureComponent {
+export default class Upload extends ImmutablePureComponent {
 
   static contextTypes = {
     router: PropTypes.object,
@@ -23,30 +17,10 @@ class Upload extends ImmutablePureComponent {
 
   static propTypes = {
     media: ImmutablePropTypes.map.isRequired,
-    intl: PropTypes.object.isRequired,
     onUndo: PropTypes.func.isRequired,
-    onDescriptionChange: PropTypes.func.isRequired,
     onOpenFocalPoint: PropTypes.func.isRequired,
-    onSubmit: PropTypes.func.isRequired,
-  };
-
-  state = {
-    hovered: false,
-    focused: false,
-    dirtyDescription: null,
   };
 
-  handleKeyDown = (e) => {
-    if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) {
-      this.handleSubmit();
-    }
-  }
-
-  handleSubmit = () => {
-    this.handleInputBlur();
-    this.props.onSubmit(this.context.router.history);
-  }
-
   handleUndoClick = e => {
     e.stopPropagation();
     this.props.onUndo(this.props.media.get('id'));
@@ -57,69 +31,21 @@ class Upload extends ImmutablePureComponent {
     this.props.onOpenFocalPoint(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 });
-  }
-
-  handleClick = () => {
-    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 || isUserTouching();
-    const description     = this.state.dirtyDescription || (this.state.dirtyDescription !== '' && media.get('description')) || '';
-    const computedClass   = classNames('composer--upload_form--item', { active });
     const focusX = media.getIn(['meta', 'focus', 'x']);
     const focusY = media.getIn(['meta', 'focus', 'y']);
     const x = ((focusX /  2) + .5) * 100;
     const y = ((focusY / -2) + .5) * 100;
 
     return (
-      <div className={computedClass} tabIndex='0' onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave} onClick={this.handleClick} role='button'>
+      <div className='composer--upload_form--item' tabIndex='0' role='button'>
         <Motion defaultStyle={{ scale: 0.8 }} style={{ scale: spring(1, { stiffness: 180, damping: 12, }) }}>
           {({ scale }) => (
             <div style={{ transform: `scale(${scale})`, backgroundImage: `url(${media.get('preview_url')})`, backgroundPosition: `${x}% ${y}%` }}>
-              <div className={classNames('composer--upload_form--actions', { active })}>
-                <button className='icon-button' onClick={this.handleUndoClick}><Icon icon='times' /> <FormattedMessage id='upload_form.undo' defaultMessage='Delete' /></button>
-                {media.get('type') === 'image' && <button className='icon-button' onClick={this.handleFocalPointClick}><Icon id='crosshairs' /> <FormattedMessage id='upload_form.focus' defaultMessage='Crop' /></button>}
-              </div>
-
-              <div className={classNames('composer--upload_form--description', { active })}>
-                <label>
-                  <span style={{ display: 'none' }}>{intl.formatMessage(messages.description)}</span>
-                  <textarea
-                    placeholder={intl.formatMessage(messages.description)}
-                    value={description}
-                    maxLength={420}
-                    onFocus={this.handleInputFocus}
-                    onChange={this.handleInputChange}
-                    onBlur={this.handleInputBlur}
-                    onKeyDown={this.handleKeyDown}
-                  />
-                </label>
+              <div className={classNames('composer--upload_form--actions', { active: true })}>
+                <button className='icon-button' onClick={this.handleUndoClick}><Icon id='times' /> <FormattedMessage id='upload_form.undo' defaultMessage='Delete' /></button>
+                <button className='icon-button' onClick={this.handleFocalPointClick}><Icon id='pencil' /> <FormattedMessage id='upload_form.edit' defaultMessage='Edit' /></button>
               </div>
             </div>
           )}
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 35880ddcc..43039c674 100644
--- a/app/javascript/flavours/glitch/features/compose/components/upload_form.js
+++ b/app/javascript/flavours/glitch/features/compose/components/upload_form.js
@@ -4,6 +4,7 @@ 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,7 +16,7 @@ export default class UploadForm extends ImmutablePureComponent {
 
     return (
       <div className='composer--upload_form'>
-        <UploadProgressContainer />
+        <UploadProgressContainer icon='upload' message={<FormattedMessage id='upload_progress.label' defaultMessage='Uploading…' />} />
 
         {mediaIds.size > 0 && (
           <div className='content'>
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 264c563f2..493bb9ca5 100644
--- a/app/javascript/flavours/glitch/features/compose/components/upload_progress.js
+++ b/app/javascript/flavours/glitch/features/compose/components/upload_progress.js
@@ -2,7 +2,6 @@ import React from 'react';
 import PropTypes from 'prop-types';
 import Motion from 'flavours/glitch/util/optional_motion';
 import spring from 'react-motion/lib/spring';
-import { FormattedMessage } from 'react-intl';
 import Icon from 'flavours/glitch/components/icon';
 
 export default class UploadProgress extends React.PureComponent {
@@ -10,10 +9,12 @@ export default class UploadProgress extends React.PureComponent {
   static propTypes = {
     active: PropTypes.bool,
     progress: PropTypes.number,
+    icon: PropTypes.string.isRequired,
+    message: PropTypes.node.isRequired,
   };
 
   render () {
-    const { active, progress } = this.props;
+    const { active, progress, icon, message } = this.props;
 
     if (!active) {
       return null;
@@ -21,10 +22,10 @@ export default class UploadProgress extends React.PureComponent {
 
     return (
       <div className='composer--upload_form--progress'>
-        <Icon icon='upload' />
+        <Icon id={icon} />
 
         <div className='message'>
-          <FormattedMessage id='upload_progress.label' defaultMessage='Uploading...' />
+          {message}
 
           <div className='backdrop'>
             <Motion defaultStyle={{ width: 0 }} style={{ width: spring(progress) }}>
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 199d43913..18e2b2f39 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
@@ -25,6 +25,8 @@ const messages = defineMessages({
                                 defaultMessage: 'At least one media attachment is lacking a description. Consider describing all media attachments for the visually impaired before sending your toot.' },
   missingDescriptionConfirm: {  id: 'confirmations.missing_media_description.confirm',
                                 defaultMessage: 'Send anyway' },
+  missingDescriptionEdit:    {  id: 'confirmations.missing_media_description.edit',
+                                defaultMessage: 'Edit media' },
 });
 
 //  State mapping.
@@ -112,11 +114,13 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
     dispatch(changeComposeVisibility(value));
   },
 
-  onMediaDescriptionConfirm(routerHistory) {
+  onMediaDescriptionConfirm(routerHistory, mediaId) {
     dispatch(openModal('CONFIRM', {
       message: intl.formatMessage(messages.missingDescriptionMessage),
       confirm: intl.formatMessage(messages.missingDescriptionConfirm),
       onConfirm: () => dispatch(submitCompose(routerHistory)),
+      secondary: intl.formatMessage(messages.missingDescriptionEdit),
+      onSecondary: () => dispatch(openModal('FOCAL_POINT', { id: mediaId })),
       onDoNotAsk: () => dispatch(changeLocalSetting(['confirm_missing_media_description'], false)),
     }));
   },
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 ce1dea319..b4dcb4d56 100644
--- a/app/javascript/flavours/glitch/features/compose/containers/header_container.js
+++ b/app/javascript/flavours/glitch/features/compose/containers/header_container.js
@@ -1,6 +1,13 @@
 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';
+
+const messages = defineMessages({
+  logoutMessage: { id: 'confirmations.logout.message', defaultMessage: 'Are you sure you want to log out?' },
+  logoutConfirm: { id: 'confirmations.logout.confirm', defaultMessage: 'Log out' },
+});
 
 const mapStateToProps = state => {
   return {
@@ -16,6 +23,13 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
     e.stopPropagation();
     dispatch(openModal('SETTINGS', {}));
   },
+  onLogout () {
+    dispatch(openModal('CONFIRM', {
+      message: intl.formatMessage(messages.logoutMessage),
+      confirm: intl.formatMessage(messages.logoutConfirm),
+      onConfirm: () => logOut(),
+    }));
+  },
 });
 
-export default connect(mapStateToProps, mapDispatchToProps)(Header);
+export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(Header));
diff --git a/app/javascript/flavours/glitch/features/compose/containers/options_container.js b/app/javascript/flavours/glitch/features/compose/containers/options_container.js
index c8c7ecd43..c792aa582 100644
--- a/app/javascript/flavours/glitch/features/compose/containers/options_container.js
+++ b/app/javascript/flavours/glitch/features/compose/containers/options_container.js
@@ -12,11 +12,12 @@ function mapStateToProps (state) {
   const spoilersAlwaysOn = state.getIn(['local_settings', 'always_show_spoilers_field']);
   const poll = state.getIn(['compose', 'poll']);
   const media = state.getIn(['compose', 'media_attachments']);
+  const pending_media = state.getIn(['compose', 'pending_media_attachments']);
   return {
     acceptContentTypes: state.getIn(['media_attachments', 'accept_content_types']).toArray().join(','),
     resetFileKey: state.getIn(['compose', 'resetFileKey']),
     hasPoll: !!poll,
-    allowMedia: !poll && (media ? media.size < 4 && !media.some(item => item.get('type') === 'video') : true),
+    allowMedia: !poll && (media ? media.size + pending_media < 4 && !media.some(item => ['video', 'audio'].includes(item.get('type'))) : pending_media < 4),
     hasMedia: media && !!media.size,
     allowPoll: !(media && !!media.size),
     showContentTypeChoice: state.getIn(['local_settings', 'show_content_type_choice']),
diff --git a/app/javascript/flavours/glitch/features/compose/containers/search_results_container.js b/app/javascript/flavours/glitch/features/compose/containers/search_results_container.js
index f9637861a..5c2c1be23 100644
--- a/app/javascript/flavours/glitch/features/compose/containers/search_results_container.js
+++ b/app/javascript/flavours/glitch/features/compose/containers/search_results_container.js
@@ -1,14 +1,17 @@
 import { connect } from 'react-redux';
 import SearchResults from '../components/search_results';
-import { fetchSuggestions, dismissSuggestion } from '../../../actions/suggestions';
+import { fetchSuggestions, dismissSuggestion } from 'flavours/glitch/actions/suggestions';
+import { expandSearch } from 'flavours/glitch/actions/search';
 
 const mapStateToProps = state => ({
   results: state.getIn(['search', 'results']),
   suggestions: state.getIn(['suggestions', 'items']),
+  searchTerm: state.getIn(['search', 'searchTerm']),
 });
 
 const mapDispatchToProps = dispatch => ({
   fetchSuggestions: () => dispatch(fetchSuggestions()),
+  expandSearch: type => dispatch(expandSearch(type)),
   dismissSuggestion: account => dispatch(dismissSuggestion(account.get('id'))),
 });
 
diff --git a/app/javascript/flavours/glitch/features/compose/containers/upload_container.js b/app/javascript/flavours/glitch/features/compose/containers/upload_container.js
index d6bff63ac..f687fae99 100644
--- a/app/javascript/flavours/glitch/features/compose/containers/upload_container.js
+++ b/app/javascript/flavours/glitch/features/compose/containers/upload_container.js
@@ -1,6 +1,6 @@
 import { connect } from 'react-redux';
 import Upload from '../components/upload';
-import { undoUploadCompose, changeUploadCompose } from 'flavours/glitch/actions/compose';
+import { undoUploadCompose } from 'flavours/glitch/actions/compose';
 import { openModal } from 'flavours/glitch/actions/modal';
 import { submitCompose } from 'flavours/glitch/actions/compose';
 
@@ -14,10 +14,6 @@ const mapDispatchToProps = dispatch => ({
     dispatch(undoUploadCompose(id));
   },
 
-  onDescriptionChange: (id, description) => {
-    dispatch(changeUploadCompose(id, { description }));
-  },
-
   onOpenFocalPoint: id => {
     dispatch(openModal('FOCAL_POINT', { id }));
   },
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 fdd21f114..b9b0a2644 100644
--- a/app/javascript/flavours/glitch/features/compose/containers/warning_container.js
+++ b/app/javascript/flavours/glitch/features/compose/containers/warning_container.js
@@ -4,6 +4,7 @@ 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';
 
 const APPROX_HASHTAG_RE = /(?:^|[^\/\)\w])#(\w*[a-zA-Z·]\w*)/i;
 
@@ -15,7 +16,7 @@ const mapStateToProps = state => ({
 
 const WarningWrapper = ({ needsLockWarning, hashtagWarning, directMessageWarning }) => {
   if (needsLockWarning) {
-    return <Warning message={<FormattedMessage id='compose_form.lock_disclaimer' defaultMessage='Your account is not {locked}. Anyone can follow you to view your follower-only posts.' values={{ locked: <a href='/settings/profile'><FormattedMessage id='compose_form.lock_disclaimer.lock' defaultMessage='locked' /></a> }} />} />;
+    return <Warning message={<FormattedMessage id='compose_form.lock_disclaimer' defaultMessage='Your account is not {locked}. Anyone can follow you to view your follower-only posts.' values={{ locked: <a href={profileLink}><FormattedMessage id='compose_form.lock_disclaimer.lock' defaultMessage='locked' /></a> }} />} />;
   }
 
   if (hashtagWarning) {
@@ -25,7 +26,7 @@ const WarningWrapper = ({ needsLockWarning, hashtagWarning, directMessageWarning
   if (directMessageWarning) {
     const message = (
       <span>
-        <FormattedMessage id='compose_form.direct_message_warning' defaultMessage='This toot will only be sent to all the mentioned users.' /> <a href='/terms' target='_blank'><FormattedMessage id='compose_form.direct_message_warning_learn_more' defaultMessage='Learn more' /></a>
+        <FormattedMessage id='compose_form.direct_message_warning' defaultMessage='This toot will only be sent to all the mentioned users.' /> {!!termsLink && <a href='/terms' target='_blank'><FormattedMessage id='compose_form.direct_message_warning_learn_more' defaultMessage='Learn more' /></a>}
       </span>
     );