about summary refs log tree commit diff
diff options
context:
space:
mode:
authorEugen Rochko <eugen@zeonfederated.com>2017-03-25 00:01:43 +0100
committerEugen Rochko <eugen@zeonfederated.com>2017-03-25 19:14:12 +0100
commitd8c5a83827c24a8931da7f2b1fd78da66162bd7e (patch)
tree0c178387da08b6978a87d5191ff9651d6735e4b2
parent9bf4c34919c0ad09417394eaa246c8af9effe131 (diff)
Redesigned compose form
-rw-r--r--app/assets/javascripts/components/actions/compose.jsx23
-rw-r--r--app/assets/javascripts/components/components/icon_button.jsx7
-rw-r--r--app/assets/javascripts/components/features/compose/components/compose_form.jsx65
-rw-r--r--app/assets/javascripts/components/features/compose/components/emoji_picker_dropdown.jsx14
-rw-r--r--app/assets/javascripts/components/features/compose/components/privacy_dropdown.jsx83
-rw-r--r--app/assets/javascripts/components/features/compose/components/private_toggle.jsx27
-rw-r--r--app/assets/javascripts/components/features/compose/components/text_icon_button.jsx31
-rw-r--r--app/assets/javascripts/components/features/compose/components/unlisted_toggle.jsx32
-rw-r--r--app/assets/javascripts/components/features/compose/components/upload_button.jsx2
-rw-r--r--app/assets/javascripts/components/features/compose/components/upload_form.jsx6
-rw-r--r--app/assets/javascripts/components/features/compose/containers/privacy_dropdown_container.jsx (renamed from app/assets/javascripts/components/features/compose/containers/private_toggle_container.jsx)10
-rw-r--r--app/assets/javascripts/components/features/compose/containers/sensitive_button_container.jsx49
-rw-r--r--app/assets/javascripts/components/features/compose/containers/sensitive_toggle_container.jsx18
-rw-r--r--app/assets/javascripts/components/features/compose/containers/spoiler_button_container.jsx24
-rw-r--r--app/assets/javascripts/components/features/compose/containers/spoiler_toggle_container.jsx17
-rw-r--r--app/assets/javascripts/components/features/compose/containers/unlisted_toggle_container.jsx31
-rw-r--r--app/assets/javascripts/components/features/compose/index.jsx1
-rw-r--r--app/assets/javascripts/components/reducers/compose.jsx32
-rw-r--r--app/assets/stylesheets/components.scss205
-rw-r--r--app/assets/stylesheets/fonts/roboto-mono.scss7
-rw-r--r--app/assets/stylesheets/fonts/roboto.scss11
21 files changed, 477 insertions, 218 deletions
diff --git a/app/assets/javascripts/components/actions/compose.jsx b/app/assets/javascripts/components/actions/compose.jsx
index 51c11e5ad..13cab8b5b 100644
--- a/app/assets/javascripts/components/actions/compose.jsx
+++ b/app/assets/javascripts/components/actions/compose.jsx
@@ -123,6 +123,10 @@ export function submitComposeFail(error) {
 
 export function uploadCompose(files) {
   return function (dispatch, getState) {
+    if (getState().getIn(['compose', 'media_attachments']).size > 3) {
+      return;
+    }
+
     dispatch(uploadComposeRequest());
 
     let data = new FormData();
@@ -231,17 +235,15 @@ export function unmountCompose() {
   };
 };
 
-export function changeComposeSensitivity(checked) {
+export function changeComposeSensitivity() {
   return {
     type: COMPOSE_SENSITIVITY_CHANGE,
-    checked
   };
 };
 
-export function changeComposeSpoilerness(checked) {
+export function changeComposeSpoilerness() {
   return {
-    type: COMPOSE_SPOILERNESS_CHANGE,
-    checked
+    type: COMPOSE_SPOILERNESS_CHANGE
   };
 };
 
@@ -252,17 +254,10 @@ export function changeComposeSpoilerText(text) {
   };
 };
 
-export function changeComposeVisibility(checked) {
+export function changeComposeVisibility(value) {
   return {
     type: COMPOSE_VISIBILITY_CHANGE,
-    checked
-  };
-};
-
-export function changeComposeListability(checked) {
-  return {
-    type: COMPOSE_LISTABILITY_CHANGE,
-    checked
+    value
   };
 };
 
diff --git a/app/assets/javascripts/components/components/icon_button.jsx b/app/assets/javascripts/components/components/icon_button.jsx
index f9b6192c0..a08b1159b 100644
--- a/app/assets/javascripts/components/components/icon_button.jsx
+++ b/app/assets/javascripts/components/components/icon_button.jsx
@@ -12,6 +12,7 @@ const IconButton = React.createClass({
     style: React.PropTypes.object,
     activeStyle: React.PropTypes.object,
     disabled: React.PropTypes.bool,
+    inverted: React.PropTypes.bool,
     animate: React.PropTypes.bool
   },
 
@@ -36,10 +37,6 @@ const IconButton = React.createClass({
 
   render () {
     let style = {
-      display: 'inline-block',
-      border: 'none',
-      padding: '0',
-      background: 'transparent',
       fontSize: `${this.props.size}px`,
       width: `${this.props.size * 1.28571429}px`,
       height: `${this.props.size}px`,
@@ -57,7 +54,7 @@ const IconButton = React.createClass({
           <button
             aria-label={this.props.title}
             title={this.props.title}
-            className={`icon-button ${this.props.active ? 'active' : ''} ${this.props.disabled ? 'disabled' : ''}`}
+            className={`icon-button ${this.props.active ? 'active' : ''} ${this.props.disabled ? 'disabled' : ''} ${this.props.inverted ? 'inverted' : ''}`}
             onClick={this.handleClick}
             style={style}>
             <i style={{ transform: `rotate(${rotate}deg)` }} className={`fa fa-fw fa-${this.props.icon}`} aria-hidden='true' />
diff --git a/app/assets/javascripts/components/features/compose/components/compose_form.jsx b/app/assets/javascripts/components/features/compose/components/compose_form.jsx
index 2a252af4b..330be5662 100644
--- a/app/assets/javascripts/components/features/compose/components/compose_form.jsx
+++ b/app/assets/javascripts/components/features/compose/components/compose_form.jsx
@@ -3,19 +3,18 @@ import Button from '../../../components/button';
 import PureRenderMixin from 'react-addons-pure-render-mixin';
 import ImmutablePropTypes from 'react-immutable-proptypes';
 import ReplyIndicatorContainer from '../containers/reply_indicator_container';
-import UploadButton from './upload_button';
 import AutosuggestTextarea from '../../../components/autosuggest_textarea';
-import AutosuggestAccountContainer from '../../compose/containers/autosuggest_account_container';
 import { debounce } from 'react-decoration';
 import UploadButtonContainer from '../containers/upload_button_container';
 import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
 import Toggle from 'react-toggle';
 import Collapsable from '../../../components/collapsable';
-import UnlistedToggleContainer from '../containers/unlisted_toggle_container';
-import SpoilerToggleContainer from '../containers/spoiler_toggle_container';
-import PrivateToggleContainer from '../containers/private_toggle_container';
-import SensitiveToggleContainer from '../containers/sensitive_toggle_container';
+import SpoilerButtonContainer from '../containers/spoiler_button_container';
+import PrivacyDropdownContainer from '../containers/privacy_dropdown_container';
+import SensitiveButtonContainer from '../containers/sensitive_button_container';
 import EmojiPickerDropdown from './emoji_picker_dropdown';
+import UploadFormContainer from '../containers/upload_form_container';
+import TextIconButton from './text_icon_button';
 
 const messages = defineMessages({
   placeholder: { id: 'compose_form.placeholder', defaultMessage: 'What is on your mind?' },
@@ -156,33 +155,39 @@ const ComposeForm = React.createClass({
 
         <ReplyIndicatorContainer />
 
-        <AutosuggestTextarea
-          ref={this.setAutosuggestTextarea}
-          placeholder={intl.formatMessage(messages.placeholder)}
-          disabled={disabled}
-          value={this.props.text}
-          onChange={this.handleChange}
-          suggestions={this.props.suggestions}
-          onKeyDown={this.handleKeyDown}
-          onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
-          onSuggestionsClearRequested={this.onSuggestionsClearRequested}
-          onSuggestionSelected={this.onSuggestionSelected}
-          onPaste={onPaste}
-        />
-
-        <div style={{ marginTop: '10px', overflow: 'hidden' }}>
-          <div style={{ float: 'right' }}><Button text={publishText} onClick={this.handleSubmit} disabled={disabled} /></div>
-          <div style={{ float: 'right', marginRight: '16px', lineHeight: '36px' }}><CharacterCounter max={500} text={[this.props.spoiler_text, this.props.text].join('')} /></div>
-          <div style={{ display: 'flex', paddingTop: '4px' }}>
+        <div style={{ position: 'relative' }}>
+          <AutosuggestTextarea
+            ref={this.setAutosuggestTextarea}
+            placeholder={intl.formatMessage(messages.placeholder)}
+            disabled={disabled}
+            value={this.props.text}
+            onChange={this.handleChange}
+            suggestions={this.props.suggestions}
+            onKeyDown={this.handleKeyDown}
+            onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
+            onSuggestionsClearRequested={this.onSuggestionsClearRequested}
+            onSuggestionSelected={this.onSuggestionSelected}
+            onPaste={onPaste}
+          />
+
+          <EmojiPickerDropdown onPickEmoji={this.handleEmojiPick} />
+        </div>
+
+        <div className='compose-form__modifiers'>
+          <UploadFormContainer />
+        </div>
+
+        <div style={{ display: 'flex' }}>
+          <div className='compose-form__buttons'>
             <UploadButtonContainer />
-            <EmojiPickerDropdown onPickEmoji={this.handleEmojiPick} />
+            <PrivacyDropdownContainer />
+            <SensitiveButtonContainer />
+            <SpoilerButtonContainer />
           </div>
-        </div>
 
-        <SpoilerToggleContainer />
-        <PrivateToggleContainer />
-        <UnlistedToggleContainer />
-        <SensitiveToggleContainer />
+          <div style={{ paddingTop: '10px', marginRight: '16px', lineHeight: '36px' }}><CharacterCounter max={500} text={[this.props.spoiler_text, this.props.text].join('')} /></div>
+          <div style={{ paddingTop: '10px' }}><Button text={publishText} onClick={this.handleSubmit} disabled={disabled} /></div>
+        </div>
       </div>
     );
   }
diff --git a/app/assets/javascripts/components/features/compose/components/emoji_picker_dropdown.jsx b/app/assets/javascripts/components/features/compose/components/emoji_picker_dropdown.jsx
index 37e366203..471f0e1bb 100644
--- a/app/assets/javascripts/components/features/compose/components/emoji_picker_dropdown.jsx
+++ b/app/assets/javascripts/components/features/compose/components/emoji_picker_dropdown.jsx
@@ -13,6 +13,12 @@ const settings = {
   imagePathPNG: '/emoji/'
 };
 
+const style = {
+  position: 'absolute',
+  right: '5px',
+  top: '5px'
+};
+
 const EmojiPickerDropdown = React.createClass({
 
   propTypes: {
@@ -35,12 +41,12 @@ const EmojiPickerDropdown = React.createClass({
     const { intl } = this.props;
 
     return (
-      <Dropdown ref={this.setRef} style={{ marginLeft: '5px' }}>
-        <DropdownTrigger className='icon-button emoji-button' title={intl.formatMessage(messages.emoji)} style={{ fontSize: `24px`, width: `24px`, lineHeight: `24px`, display: 'block', marginLeft: '2px' }}>
-          <img className="emojione" alt="🙂" src="/emoji/1f642.png" />
+      <Dropdown ref={this.setRef} style={style}>
+        <DropdownTrigger className='emoji-button' title={intl.formatMessage(messages.emoji)} style={{ fontSize: `24px`, width: `24px`, lineHeight: `24px`, display: 'block', marginLeft: '2px' }}>
+          <img className="emojione" alt="🙂" src="/emoji/1f602.png" />
         </DropdownTrigger>
 
-        <DropdownContent>
+        <DropdownContent className='dropdown__left'>
           <EmojiPicker emojione={settings} onChange={this.handleChange} />
         </DropdownContent>
       </Dropdown>
diff --git a/app/assets/javascripts/components/features/compose/components/privacy_dropdown.jsx b/app/assets/javascripts/components/features/compose/components/privacy_dropdown.jsx
new file mode 100644
index 000000000..f582267f0
--- /dev/null
+++ b/app/assets/javascripts/components/features/compose/components/privacy_dropdown.jsx
@@ -0,0 +1,83 @@
+import PureRenderMixin from 'react-addons-pure-render-mixin';
+import { FormattedMessage } from 'react-intl';
+import IconButton from '../../../components/icon_button';
+
+const PrivacyDropdown = React.createClass({
+
+  propTypes: {
+    value: React.PropTypes.string.isRequired,
+    onChange: React.PropTypes.func.isRequired
+  },
+
+  getInitialState () {
+    return {
+      open: false
+    };
+  },
+
+  mixins: [PureRenderMixin],
+
+  handleToggle () {
+    this.setState({ open: !this.state.open });
+  },
+
+  handleClick (value, e) {
+    e.preventDefault();
+    this.setState({ open: false });
+    this.props.onChange(value);
+  },
+
+  onGlobalClick (e) {
+    if (e.target !== this.node && !this.node.contains(e.target) && this.state.open) {
+      this.setState({ open: false });
+    }
+  },
+
+  componentDidMount () {
+    window.addEventListener('click', this.onGlobalClick);
+    window.addEventListener('touchstart', this.onGlobalClick);
+  },
+
+  componentWillUnmount () {
+    window.removeEventListener('click', this.onGlobalClick);
+    window.removeEventListener('touchstart', this.onGlobalClick);
+  },
+
+  setRef (c) {
+    this.node = c;
+  },
+
+  render () {
+    const { value, onChange } = this.props;
+    const { open } = this.state;
+
+    const options = [
+      { icon: 'globe', value: 'public', shortText: 'Public', longText: 'Anyone can see' },
+      { icon: 'globe', value: 'unlisted', shortText: 'Unlisted', longText: 'Anyone can see' },
+      { icon: 'lock', value: 'private', shortText: 'Private', longText: 'Followers can see' },
+      { icon: 'send', value: 'direct', shortText: 'Direct', longText: 'Mentions can see' }
+    ];
+
+    const valueOption = options.find(item => item.value === value);
+
+    return (
+      <div ref={this.setRef} className={`privacy-dropdown ${open ? 'active' : ''}`}>
+        <div className='privacy-dropdown__value'><IconButton icon={valueOption.icon} size={22} active={open} inverted onClick={this.handleToggle} /></div>
+        <div className='privacy-dropdown__dropdown'>
+          {options.map(item =>
+            <div key={item.value} onClick={this.handleClick.bind(this, item.value)} className={`privacy-dropdown__option ${item.value === value ? 'active' : ''}`}>
+              <div className='privacy-dropdown__option__icon'><i className={`fa fa-fw fa-${item.icon}`} /></div>
+              <div className='privacy-dropdown__option__content'>
+                <strong>{item.shortText}</strong>
+                {item.longText}
+              </div>
+            </div>
+          )}
+        </div>
+      </div>
+    );
+  }
+
+});
+
+export default PrivacyDropdown;
diff --git a/app/assets/javascripts/components/features/compose/components/private_toggle.jsx b/app/assets/javascripts/components/features/compose/components/private_toggle.jsx
deleted file mode 100644
index 902ee70ca..000000000
--- a/app/assets/javascripts/components/features/compose/components/private_toggle.jsx
+++ /dev/null
@@ -1,27 +0,0 @@
-import PureRenderMixin from 'react-addons-pure-render-mixin';
-import { FormattedMessage } from 'react-intl';
-import Toggle from 'react-toggle';
-
-const PrivateToggle = React.createClass({
-
-  propTypes: {
-    isPrivate: React.PropTypes.bool,
-    onChange: React.PropTypes.func.isRequired
-  },
-
-  mixins: [PureRenderMixin],
-
-  render () {
-    const { isPrivate, onChange } = this.props;
-
-    return (
-      <label className='compose-form__label with-border'>
-        <Toggle checked={isPrivate} onChange={onChange} />
-        <span className='compose-form__label__text'><FormattedMessage id='compose_form.private' defaultMessage='Mark as private' /></span>
-      </label>
-    );
-  }
-
-});
-
-export default PrivateToggle;
diff --git a/app/assets/javascripts/components/features/compose/components/text_icon_button.jsx b/app/assets/javascripts/components/features/compose/components/text_icon_button.jsx
new file mode 100644
index 000000000..e3ac63d87
--- /dev/null
+++ b/app/assets/javascripts/components/features/compose/components/text_icon_button.jsx
@@ -0,0 +1,31 @@
+import PureRenderMixin from 'react-addons-pure-render-mixin';
+
+const TextIconButton = React.createClass({
+
+  propTypes: {
+    label: React.PropTypes.string.isRequired,
+    title: React.PropTypes.string,
+    active: React.PropTypes.bool,
+    onClick: React.PropTypes.func.isRequired
+  },
+
+  mixins: [PureRenderMixin],
+
+  handleClick (e) {
+    e.preventDefault();
+    this.props.onClick();
+  },
+
+  render () {
+    const { label, title, active } = this.props;
+
+    return (
+      <button title={title} aria-label={title} className={`text-icon-button ${active ? 'active' : ''}`} onClick={this.handleClick}>
+        {label}
+      </button>
+    );
+  }
+
+});
+
+export default TextIconButton;
diff --git a/app/assets/javascripts/components/features/compose/components/unlisted_toggle.jsx b/app/assets/javascripts/components/features/compose/components/unlisted_toggle.jsx
deleted file mode 100644
index 0745051eb..000000000
--- a/app/assets/javascripts/components/features/compose/components/unlisted_toggle.jsx
+++ /dev/null
@@ -1,32 +0,0 @@
-import PureRenderMixin from 'react-addons-pure-render-mixin';
-import { FormattedMessage } from 'react-intl';
-import Toggle from 'react-toggle';
-import Collapsable from '../../../components/collapsable';
-
-const UnlistedToggle = React.createClass({
-
-  propTypes: {
-    isPrivate: React.PropTypes.bool,
-    isUnlisted: React.PropTypes.bool,
-    isReplyToOther: React.PropTypes.bool,
-    onChangeListability: React.PropTypes.func.isRequired
-  },
-
-  mixins: [PureRenderMixin],
-
-  render () {
-    const { isPrivate, isUnlisted, isReplyToOther, onChangeListability } = this.props;
-
-    return (
-      <Collapsable isVisible={!(isPrivate || isReplyToOther)} fullHeight={39.5}>
-        <label className='compose-form__label'>
-          <Toggle checked={isUnlisted} onChange={onChangeListability} />
-          <span className='compose-form__label__text'><FormattedMessage id='compose_form.unlisted' defaultMessage='Do not display on public timelines' /></span>
-        </label>
-      </Collapsable>
-    );
-  }
-
-});
-
-export default UnlistedToggle;
diff --git a/app/assets/javascripts/components/features/compose/components/upload_button.jsx b/app/assets/javascripts/components/features/compose/components/upload_button.jsx
index 4c8181aa1..3089bde50 100644
--- a/app/assets/javascripts/components/features/compose/components/upload_button.jsx
+++ b/app/assets/javascripts/components/features/compose/components/upload_button.jsx
@@ -37,7 +37,7 @@ const UploadButton = React.createClass({
 
     return (
       <div style={this.props.style}>
-        <IconButton icon='photo' title={intl.formatMessage(messages.upload)} disabled={disabled} onClick={this.handleClick} size={24} />
+        <IconButton icon='camera' title={intl.formatMessage(messages.upload)} disabled={disabled} onClick={this.handleClick} size={22} inverted />
         <input key={resetFileKey} ref={this.setRef} type='file' multiple={false} onChange={this.handleChange} disabled={disabled} style={{ display: 'none' }} />
       </div>
     );
diff --git a/app/assets/javascripts/components/features/compose/components/upload_form.jsx b/app/assets/javascripts/components/features/compose/components/upload_form.jsx
index 1a01c2380..77590d90d 100644
--- a/app/assets/javascripts/components/features/compose/components/upload_form.jsx
+++ b/app/assets/javascripts/components/features/compose/components/upload_form.jsx
@@ -23,7 +23,7 @@ const UploadForm = React.createClass({
     const { intl, media } = this.props;
 
     const uploads = media.map(attachment =>
-      <div key={attachment.get('id')} style={{ marginBottom: '10px' }}>
+      <div key={attachment.get('id')} style={{ margin: '5px', flex: '1 1 0' }}>
         <Motion defaultStyle={{ scale: 0.8 }} style={{ scale: spring(1, { stiffness: 180, damping: 12 }) }}>
           {({ scale }) =>
             <div style={{ transform: `translateZ(0) scale(${scale})`, width: '100%', height: '100px', borderRadius: '4px', background: `url(${attachment.get('preview_url')}) no-repeat center`, backgroundSize: 'cover' }}>
@@ -35,9 +35,9 @@ const UploadForm = React.createClass({
     );
 
     return (
-      <div style={{ marginBottom: '20px', padding: '10px', overflow: 'hidden', flexShrink: '0' }}>
+      <div style={{ overflow: 'hidden' }}>
         <UploadProgressContainer />
-        {uploads}
+        <div style={{ display: 'flex', padding: '5px' }}>{uploads}</div>
       </div>
     );
   }
diff --git a/app/assets/javascripts/components/features/compose/containers/private_toggle_container.jsx b/app/assets/javascripts/components/features/compose/containers/privacy_dropdown_container.jsx
index ee3596902..1eee8f84c 100644
--- a/app/assets/javascripts/components/features/compose/containers/private_toggle_container.jsx
+++ b/app/assets/javascripts/components/features/compose/containers/privacy_dropdown_container.jsx
@@ -1,17 +1,17 @@
 import { connect } from 'react-redux';
-import PrivateToggle from '../components/private_toggle';
+import PrivacyDropdown from '../components/privacy_dropdown';
 import { changeComposeVisibility } from '../../../actions/compose';
 
 const mapStateToProps = state => ({
-  isPrivate: state.getIn(['compose', 'private'])
+  value: state.getIn(['compose', 'privacy'])
 });
 
 const mapDispatchToProps = dispatch => ({
 
-  onChange (e) {
-    dispatch(changeComposeVisibility(e.target.checked));
+  onChange (value) {
+    dispatch(changeComposeVisibility(value));
   }
 
 });
 
-export default connect(mapStateToProps, mapDispatchToProps)(PrivateToggle);
+export default connect(mapStateToProps, mapDispatchToProps)(PrivacyDropdown);
diff --git a/app/assets/javascripts/components/features/compose/containers/sensitive_button_container.jsx b/app/assets/javascripts/components/features/compose/containers/sensitive_button_container.jsx
new file mode 100644
index 000000000..074b568f4
--- /dev/null
+++ b/app/assets/javascripts/components/features/compose/containers/sensitive_button_container.jsx
@@ -0,0 +1,49 @@
+import { connect } from 'react-redux';
+import TextIconButton from '../components/text_icon_button';
+import { changeComposeSensitivity } from '../../../actions/compose';
+import { Motion, spring } from 'react-motion';
+import { injectIntl, defineMessages } from 'react-intl';
+
+const messages = defineMessages({
+  title: { id: 'compose_form.sensitive', defaultMessage: 'Mark media as sensitive' }
+});
+
+const mapStateToProps = state => ({
+  visible: state.getIn(['compose', 'media_attachments']).size > 0,
+  active: state.getIn(['compose', 'sensitive'])
+});
+
+const mapDispatchToProps = dispatch => ({
+
+  onClick () {
+    dispatch(changeComposeSensitivity());
+  }
+
+});
+
+const SensitiveButton = React.createClass({
+
+  propTypes: {
+    visible: React.PropTypes.bool,
+    active: React.PropTypes.bool,
+    onClick: React.PropTypes.func.isRequired,
+    intl: React.PropTypes.object.isRequired
+  },
+
+  render () {
+    const { visible, active, onClick, intl } = this.props;
+
+    return (
+      <Motion defaultStyle={{ scale: 0.87 }} style={{ scale: spring(visible ? 1 : 0.87, { stiffness: 200, damping: 3 }) }}>
+        {({ scale }) =>
+          <div style={{ display: visible ? 'block' : 'none', transform: `translateZ(0) scale(${scale})` }}>
+            <TextIconButton onClick={onClick} label='NSFW' title={intl.formatMessage(messages.title)} active={active} />
+          </div>
+        }
+      </Motion>
+    );
+  }
+
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(SensitiveButton));
diff --git a/app/assets/javascripts/components/features/compose/containers/sensitive_toggle_container.jsx b/app/assets/javascripts/components/features/compose/containers/sensitive_toggle_container.jsx
deleted file mode 100644
index 97b3361ba..000000000
--- a/app/assets/javascripts/components/features/compose/containers/sensitive_toggle_container.jsx
+++ /dev/null
@@ -1,18 +0,0 @@
-import { connect } from 'react-redux';
-import SensitiveToggle from '../components/sensitive_toggle';
-import { changeComposeSensitivity } from '../../../actions/compose';
-
-const mapStateToProps = state => ({
-  hasMedia: state.getIn(['compose', 'media_attachments']).size > 0,
-  isSensitive: state.getIn(['compose', 'sensitive'])
-});
-
-const mapDispatchToProps = dispatch => ({
-
-  onChange (e) {
-    dispatch(changeComposeSensitivity(e.target.checked));
-  }
-
-});
-
-export default connect(mapStateToProps, mapDispatchToProps)(SensitiveToggle);
diff --git a/app/assets/javascripts/components/features/compose/containers/spoiler_button_container.jsx b/app/assets/javascripts/components/features/compose/containers/spoiler_button_container.jsx
new file mode 100644
index 000000000..61ac32b85
--- /dev/null
+++ b/app/assets/javascripts/components/features/compose/containers/spoiler_button_container.jsx
@@ -0,0 +1,24 @@
+import { connect } from 'react-redux';
+import TextIconButton from '../components/text_icon_button';
+import { changeComposeSpoilerness } from '../../../actions/compose';
+import { injectIntl, defineMessages } from 'react-intl';
+
+const messages = defineMessages({
+  title: { id: 'compose_form.spoiler', defaultMessage: 'Hide text behind content warning' }
+});
+
+const mapStateToProps = (state, { intl }) => ({
+  label: 'CW',
+  title: intl.formatMessage(messages.title),
+  active: state.getIn(['compose', 'spoiler'])
+});
+
+const mapDispatchToProps = dispatch => ({
+
+  onClick () {
+    dispatch(changeComposeSpoilerness());
+  }
+
+});
+
+export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(TextIconButton));
diff --git a/app/assets/javascripts/components/features/compose/containers/spoiler_toggle_container.jsx b/app/assets/javascripts/components/features/compose/containers/spoiler_toggle_container.jsx
deleted file mode 100644
index 0bd4df759..000000000
--- a/app/assets/javascripts/components/features/compose/containers/spoiler_toggle_container.jsx
+++ /dev/null
@@ -1,17 +0,0 @@
-import { connect } from 'react-redux';
-import SpoilerToggle from '../components/spoiler_toggle';
-import { changeComposeSpoilerness } from '../../../actions/compose';
-
-const mapStateToProps = state => ({
-  isSpoiler: state.getIn(['compose', 'spoiler'])
-});
-
-const mapDispatchToProps = dispatch => ({
-
-  onChange (e) {
-    dispatch(changeComposeSpoilerness(e.target.checked));
-  }
-
-});
-
-export default connect(mapStateToProps, mapDispatchToProps)(SpoilerToggle);
diff --git a/app/assets/javascripts/components/features/compose/containers/unlisted_toggle_container.jsx b/app/assets/javascripts/components/features/compose/containers/unlisted_toggle_container.jsx
deleted file mode 100644
index ceac903d9..000000000
--- a/app/assets/javascripts/components/features/compose/containers/unlisted_toggle_container.jsx
+++ /dev/null
@@ -1,31 +0,0 @@
-import { connect } from 'react-redux';
-import UnlistedToggle from '../components/unlisted_toggle';
-import { makeGetStatus } from '../../../selectors';
-import { changeComposeListability } from '../../../actions/compose';
-
-const makeMapStateToProps = () => {
-  const getStatus = makeGetStatus();
-
-  const mapStateToProps = state => {
-    const status = getStatus(state, state.getIn(['compose', 'in_reply_to']));
-    const me     = state.getIn(['compose', 'me']);
-
-    return {
-      isPrivate: state.getIn(['compose', 'private']),
-      isUnlisted: state.getIn(['compose', 'unlisted']),
-      isReplyToOther: status ? status.getIn(['account', 'id']) !== me : false
-    };
-  };
-
-  return mapStateToProps;
-};
-
-const mapDispatchToProps = dispatch => ({
-
-  onChangeListability (e) {
-    dispatch(changeComposeListability(e.target.checked));
-  }
-
-});
-
-export default connect(makeMapStateToProps, mapDispatchToProps)(UnlistedToggle);
diff --git a/app/assets/javascripts/components/features/compose/index.jsx b/app/assets/javascripts/components/features/compose/index.jsx
index f6095c0c6..15e2c5809 100644
--- a/app/assets/javascripts/components/features/compose/index.jsx
+++ b/app/assets/javascripts/components/features/compose/index.jsx
@@ -30,7 +30,6 @@ const Compose = React.createClass({
         <SearchContainer />
         <NavigationContainer />
         <ComposeFormContainer />
-        <UploadFormContainer />
       </Drawer>
     );
   }
diff --git a/app/assets/javascripts/components/reducers/compose.jsx b/app/assets/javascripts/components/reducers/compose.jsx
index a4bdd20cd..a5deae930 100644
--- a/app/assets/javascripts/components/reducers/compose.jsx
+++ b/app/assets/javascripts/components/reducers/compose.jsx
@@ -32,8 +32,7 @@ const initialState = Immutable.Map({
   sensitive: false,
   spoiler: false,
   spoiler_text: '',
-  unlisted: false,
-  private: false,
+  privacy: null,
   text: '',
   focusDate: null,
   preselectDate: null,
@@ -67,8 +66,7 @@ function clearAll(state) {
     map.set('spoiler_text', '');
     map.set('is_submitting', false);
     map.set('in_reply_to', null);
-    map.set('unlisted', state.get('default_privacy') === 'unlisted');
-    map.set('private', state.get('default_privacy') === 'private');
+    map.set('privacy', state.get('default_privacy'));
     map.update('media_attachments', list => list.clear());
   });
 };
@@ -114,6 +112,18 @@ const insertEmoji = (state, position, emojiData) => {
   });
 };
 
+const privacyPreference = (a, b) => {
+  if (a === 'direct' || b === 'direct') {
+    return 'direct';
+  } else if (a === 'private' || b === 'private') {
+    return 'private';
+  } else if (a === 'unlisted' || b === 'unlisted') {
+    return 'unlisted';
+  } else {
+    return 'public';
+  }
+};
+
 export default function compose(state = initialState, action) {
   switch(action.type) {
   case STORE_HYDRATE:
@@ -123,26 +133,23 @@ export default function compose(state = initialState, action) {
   case COMPOSE_UNMOUNT:
     return state.set('mounted', false);
   case COMPOSE_SENSITIVITY_CHANGE:
-    return state.set('sensitive', action.checked);
+    return state.set('sensitive', !state.get('sensitive'));
   case COMPOSE_SPOILERNESS_CHANGE:
     return state.withMutations(map => {
       map.set('spoiler_text', '');
-      map.set('spoiler', action.checked);
+      map.set('spoiler', !state.get('spoiler'));
     });
   case COMPOSE_SPOILER_TEXT_CHANGE:
     return state.set('spoiler_text', action.text);
   case COMPOSE_VISIBILITY_CHANGE:
-    return state.set('private', action.checked);
-  case COMPOSE_LISTABILITY_CHANGE:
-    return state.set('unlisted', action.checked);
+    return state.set('privacy', action.value);
   case COMPOSE_CHANGE:
     return state.set('text', action.text);
   case COMPOSE_REPLY:
     return state.withMutations(map => {
       map.set('in_reply_to', action.status.get('id'));
       map.set('text', statusToTextMentions(state, action.status));
-      map.set('unlisted', action.status.get('visibility') === 'unlisted' || state.get('default_privacy') === 'unlisted');
-      map.set('private', action.status.get('visibility') === 'private' || state.get('default_privacy') === 'private');
+      map.set('privacy', privacyPreference(action.status.get('visibility'), state.get('default_privacy')));
       map.set('focusDate', new Date());
       map.set('preselectDate', new Date());
     });
@@ -150,8 +157,7 @@ export default function compose(state = initialState, action) {
     return state.withMutations(map => {
       map.set('in_reply_to', null);
       map.set('text', '');
-      map.set('unlisted', state.get('default_privacy') === 'unlisted');
-      map.set('private', state.get('default_privacy') === 'private');
+      map.set('privacy', state.get('default_privacy'));
     });
   case COMPOSE_SUBMIT_REQUEST:
     return state.set('is_submitting', true);
diff --git a/app/assets/stylesheets/components.scss b/app/assets/stylesheets/components.scss
index bda35425f..506410c27 100644
--- a/app/assets/stylesheets/components.scss
+++ b/app/assets/stylesheets/components.scss
@@ -19,9 +19,11 @@
   line-height: 36px;
   border-radius: 4px;
   text-decoration: none;
+  transition: all 100ms ease-in;
 
   &:hover {
     background-color: lighten($color4, 7%);
+    transition: all 200ms ease-out;
   }
 
   &:disabled {
@@ -44,13 +46,17 @@
 }
 
 .icon-button {
+  display: inline-block;
+  padding: 0;
   color: lighten($color1, 26%);
   border: none;
   background: transparent;
   cursor: pointer;
+  transition: all 100ms ease-in;
 
   &:hover {
     color: lighten($color1, 33%);
+    transition: all 200ms ease-out;
   }
 
   &.disabled {
@@ -62,8 +68,63 @@
     color: $color4;
   }
 
-  &:focus {
-    outline: none;
+  &::-moz-focus-inner {
+    border: 0;
+  }
+
+  &::-moz-focus-inner, &:focus, &:active {
+    outline: 0 !important;
+  }
+
+  &.inverted {
+    color: lighten($color1, 33%);
+
+    &:hover {
+      color: lighten($color1, 26%);
+    }
+
+    &.active {
+      color: $color4;
+    }
+
+    &.disabled {
+      color: $color3;
+    }
+  }
+}
+
+.text-icon-button {
+  color: lighten($color1, 26%);
+  border: none;
+  background: transparent;
+  cursor: pointer;
+  font-weight: 600;
+  font-size: 12px;
+  padding: 0 3px;
+  line-height: 27px;
+  outline: 0;
+  transition: all 100ms ease-in;
+
+  &:hover {
+    color: lighten($color1, 33%);
+    transition: all 200ms ease-out;
+  }
+
+  &.disabled {
+    color: lighten($color1, 13%);
+    cursor: default;
+  }
+
+  &.active {
+    color: $color4;
+  }
+
+  &::-moz-focus-inner {
+    border: 0;
+  }
+
+  &::-moz-focus-inner, &:focus, &:active {
+    outline: 0 !important;
   }
 }
 
@@ -103,6 +164,29 @@
   }
 }
 
+.compose-form__modifiers {
+  color: $color1;
+  font-family: inherit;
+  font-size: 14px;
+  background: $color5;
+  border-radius: 0 0 4px 0;
+}
+
+.compose-form__buttons {
+  padding: 10px;
+  background: darken($color5, 8%);
+  box-shadow: inset 0 5px 5px rgba($color8, 0.05);
+  border-radius: 0 0 4px 4px;
+  flex: 1 1 auto;
+  margin-right: 16px;
+  display: flex;
+
+  .icon-button {
+    box-sizing: content-box;
+    padding: 0 3px;
+  }
+}
+
 .compose-form__label {
   display: block;
   line-height: 24px;
@@ -144,6 +228,9 @@
 }
 
 .reply-indicator {
+  border-radius: 4px 4px 0 0;
+  position: relative;
+  bottom: -2px;
   background: $color3;
   padding: 10px;
 
@@ -515,6 +602,7 @@ a.status__content__spoiler-link {
   flex-shrink: 0;
   cursor: default;
   color: $color3;
+  padding-bottom: 5px;
 
   strong {
     color: $color5;
@@ -568,6 +656,10 @@ a.status__content__spoiler-link {
     & > ul {
       left: -98px;
     }
+
+    & > .emoji-dialog {
+      left: -249px;
+    }
   }
 
   & > ul > li > a {
@@ -585,7 +677,7 @@ a.status__content__spoiler-link {
     white-space: nowrap;
 
     &:focus {
-      outline: none;
+      outline: 0;
     }
 
     &:hover {
@@ -643,7 +735,7 @@ a.status__content__spoiler-link {
 }
 
 .drawer {
-  width: 280px;
+  width: 330px;
   box-sizing: border-box;
   display: flex;
   flex-direction: column;
@@ -676,7 +768,8 @@ a.status__content__spoiler-link {
 }
 
 .drawer__inner {
-  background: linear-gradient(rgba(lighten($color1, 13%), 1), rgba(lighten($color1, 13%), 0.65));
+  //background: linear-gradient(rgba(lighten($color1, 13%), 1), rgba(lighten($color1, 13%), 0.65));
+  background: lighten($color1, 13%);
   box-sizing: border-box;
   padding: 0;
   display: flex;
@@ -965,11 +1058,23 @@ a.status__content__spoiler-link {
   font-size: 14px;
   resize: vertical;
   border: 0;
+  outline: 0;
+
+  &:focus {
+    outline: 0;
+  }
+}
+
+.spoiler-input__input {
+  border-radius: 4px;
 }
 
 .autosuggest-textarea__textarea {
   height: 100px;
   background: $color5;
+  border-radius: 4px 4px 0 0;
+  padding-bottom: 0;
+  padding-right: 10px + 22px;
 }
 
 .autosuggest-textarea__suggestions {
@@ -1153,6 +1258,11 @@ button.active i.fa-retweet {
 .collapsable {
   color: $color5;
   background: lighten($color1, 8%);
+
+  &:hover {
+    color: $color5;
+    background: lighten($color1, 8%);
+  }
 }
 
 .media-spoiler {
@@ -1411,7 +1521,7 @@ button.active i.fa-retweet {
     cursor: pointer;
 
     &:focus {
-      outline: none;
+      outline: 0;
     }
   }
 }
@@ -1479,7 +1589,7 @@ button.active i.fa-retweet {
 }
 
 .upload-progress {
-  padding-bottom: 20px;
+  padding: 10px;
   color: lighten($color1, 26%);
   overflow: hidden;
   display: flex;
@@ -1511,14 +1621,20 @@ button.active i.fa-retweet {
   left: 0;
   top: 0;
   height: 6px;
-  background: $color2;
+  background: $color4;
   border-radius: 6px;
 }
 
 .emoji-button {
+  outline: 0;
+
+  &:active, &:focus {
+    outline: 0 !important;
+  }
+
   img {
     filter: grayscale(100%);
-    opacity: 0.4;
+    opacity: 0.8;
     display: block;
     margin: 0;
     width: 22px;
@@ -1538,3 +1654,74 @@ button.active i.fa-retweet {
   opacity: 1;
   filter: none;
 }
+
+.privacy-dropdown {
+  position: relative;
+}
+
+.privacy-dropdown__dropdown {
+  display: none;
+  position: absolute;
+  left: 0;
+  top: 24px;
+  width: 180px;
+  background: $color5;
+  border-radius: 0 4px 4px 4px;
+  z-index: 2;
+  overflow: hidden;
+}
+
+.privacy-dropdown__option {
+  color: $color1;
+  padding: 10px;
+  cursor: pointer;
+  display: flex;
+
+  &:hover, &.active {
+    background: $color4;
+    color: $color5;
+
+    .privacy-dropdown__option__content {
+      color: $color5;
+
+      strong {
+        color: $color5;
+      }
+    }
+  }
+
+  &.active:hover {
+    background: lighten($color4, 4%);
+  }
+}
+
+.privacy-dropdown__option__icon {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  margin-right: 10px;
+}
+
+.privacy-dropdown__option__content {
+  flex: 1 1 auto;
+  color: $color3;
+
+  strong {
+    font-weight: 500;
+    display: block;
+    color: $color1;
+  }
+}
+
+.privacy-dropdown.active {
+  .privacy-dropdown__value {
+    background: $color5;
+    border-radius: 4px 4px 0 0;
+    box-shadow: 0 -4px 4px rgba($color8, 0.1);
+  }
+
+  .privacy-dropdown__dropdown {
+    display: block;
+    box-shadow: 2px 4px 6px rgba($color8, 0.1);
+  }
+}
diff --git a/app/assets/stylesheets/fonts/roboto-mono.scss b/app/assets/stylesheets/fonts/roboto-mono.scss
index ca64649de..319ecb08e 100644
--- a/app/assets/stylesheets/fonts/roboto-mono.scss
+++ b/app/assets/stylesheets/fonts/roboto-mono.scss
@@ -1,3 +1,4 @@
+/*
 @font-face {
   font-family: 'Roboto Mono';
   src: font-url('roboto-mono/robotomono-bold-webfont.eot');
@@ -105,7 +106,7 @@
 }
 
 
-
+*/
 
 @font-face {
   font-family: 'Roboto Mono';
@@ -121,7 +122,7 @@
 }
 
 
-
+/*
 
 @font-face {
   font-family: 'Roboto Mono';
@@ -150,4 +151,4 @@
   font-weight: 200;
   font-style: italic;
 
-}
\ No newline at end of file
+}*/
diff --git a/app/assets/stylesheets/fonts/roboto.scss b/app/assets/stylesheets/fonts/roboto.scss
index aa91efe6d..5c0d14043 100644
--- a/app/assets/stylesheets/fonts/roboto.scss
+++ b/app/assets/stylesheets/fonts/roboto.scss
@@ -1,3 +1,4 @@
+/*
 @font-face {
 	font-family: 'Roboto';
 	src: font-url('roboto/roboto-lightitalic-webfont.eot');
@@ -8,7 +9,7 @@
 		font-url('roboto/roboto-lightitalic-webfont.svg#roboto-lightitalic-webfont') format('svg');
 	font-weight: 300;
 	font-style: italic;
-}
+}*/
 
 @font-face {
 	font-family: 'Roboto';
@@ -46,7 +47,7 @@
 	font-weight: 500;
 	font-style: normal;
 }
-
+/*
 @font-face {
 	font-family: 'Roboto';
 	src: font-url('roboto/roboto-thin-webfont.eot');
@@ -57,7 +58,7 @@
 		font-url('roboto/roboto-thin-webfont.svg#roboto-thin-webfont') format('svg');
 	font-weight: 100;
 	font-style: normal;
-}
+}*/
 
 @font-face {
 	font-family: 'Roboto';
@@ -70,7 +71,7 @@
 	font-weight: normal;
 	font-style: normal;
 }
-
+/*
 @font-face {
 	font-family: 'Roboto';
 	src: font-url('roboto/roboto-mediumitalic-webfont.eot');
@@ -141,4 +142,4 @@
 		font-url('roboto/roboto-black-webfont.svg#roboto-black-webfont') format('svg');
 	font-weight: 900;
 	font-style: normal;
-}
+}*/