about summary refs log tree commit diff
path: root/app/javascript/mastodon/features
diff options
context:
space:
mode:
Diffstat (limited to 'app/javascript/mastodon/features')
-rw-r--r--app/javascript/mastodon/features/account/components/action_bar.js2
-rw-r--r--app/javascript/mastodon/features/account/components/header.js2
-rw-r--r--app/javascript/mastodon/features/account_gallery/index.js16
-rw-r--r--app/javascript/mastodon/features/account_timeline/components/header.js2
-rw-r--r--app/javascript/mastodon/features/account_timeline/containers/header_container.js2
-rw-r--r--app/javascript/mastodon/features/account_timeline/index.js18
-rw-r--r--app/javascript/mastodon/features/compose/components/autosuggest_shortcode.js38
-rw-r--r--app/javascript/mastodon/features/compose/components/compose_form.js11
-rw-r--r--app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js334
-rw-r--r--app/javascript/mastodon/features/compose/components/privacy_dropdown.js5
-rw-r--r--app/javascript/mastodon/features/compose/components/upload_form.js2
-rw-r--r--app/javascript/mastodon/features/compose/containers/emoji_picker_dropdown_container.js8
-rw-r--r--app/javascript/mastodon/features/compose/containers/warning_container.js34
-rw-r--r--app/javascript/mastodon/features/compose/util/counter.js4
-rw-r--r--app/javascript/mastodon/features/compose/util/url_regex.js196
-rw-r--r--app/javascript/mastodon/features/favourites/index.js6
-rw-r--r--app/javascript/mastodon/features/followers/index.js16
-rw-r--r--app/javascript/mastodon/features/following/index.js16
-rw-r--r--app/javascript/mastodon/features/reblogs/index.js6
-rw-r--r--app/javascript/mastodon/features/standalone/compose/index.js2
-rw-r--r--app/javascript/mastodon/features/status/components/action_bar.js2
-rw-r--r--app/javascript/mastodon/features/status/components/card.js10
-rw-r--r--app/javascript/mastodon/features/status/components/detailed_status.js5
-rw-r--r--app/javascript/mastodon/features/status/index.js12
-rw-r--r--app/javascript/mastodon/features/ui/components/actions_modal.js9
-rw-r--r--app/javascript/mastodon/features/ui/components/column_loading.js33
-rw-r--r--app/javascript/mastodon/features/ui/components/columns_area.js2
-rw-r--r--app/javascript/mastodon/features/ui/components/onboarding_modal.js7
-rw-r--r--app/javascript/mastodon/features/ui/components/video_modal.js22
-rw-r--r--app/javascript/mastodon/features/ui/index.js14
-rw-r--r--app/javascript/mastodon/features/ui/util/async-components.js8
-rw-r--r--app/javascript/mastodon/features/video/index.js304
32 files changed, 900 insertions, 248 deletions
diff --git a/app/javascript/mastodon/features/account/components/action_bar.js b/app/javascript/mastodon/features/account/components/action_bar.js
index c12c0889e..9e8fea69d 100644
--- a/app/javascript/mastodon/features/account/components/action_bar.js
+++ b/app/javascript/mastodon/features/account/components/action_bar.js
@@ -26,7 +26,7 @@ export default class ActionBar extends React.PureComponent {
 
   static propTypes = {
     account: ImmutablePropTypes.map.isRequired,
-    me: PropTypes.number.isRequired,
+    me: PropTypes.string.isRequired,
     onFollow: PropTypes.func,
     onBlock: PropTypes.func.isRequired,
     onMention: PropTypes.func.isRequired,
diff --git a/app/javascript/mastodon/features/account/components/header.js b/app/javascript/mastodon/features/account/components/header.js
index 7ab492225..5402d6753 100644
--- a/app/javascript/mastodon/features/account/components/header.js
+++ b/app/javascript/mastodon/features/account/components/header.js
@@ -80,7 +80,7 @@ export default class Header extends ImmutablePureComponent {
 
   static propTypes = {
     account: ImmutablePropTypes.map,
-    me: PropTypes.number.isRequired,
+    me: PropTypes.string.isRequired,
     onFollow: PropTypes.func.isRequired,
     intl: PropTypes.object.isRequired,
     autoPlayGif: PropTypes.bool.isRequired,
diff --git a/app/javascript/mastodon/features/account_gallery/index.js b/app/javascript/mastodon/features/account_gallery/index.js
index 0cfd98f23..2a88addc4 100644
--- a/app/javascript/mastodon/features/account_gallery/index.js
+++ b/app/javascript/mastodon/features/account_gallery/index.js
@@ -16,9 +16,9 @@ import { ScrollContainer } from 'react-router-scroll';
 import LoadMore from '../../components/load_more';
 
 const mapStateToProps = (state, props) => ({
-  medias: getAccountGallery(state, Number(props.params.accountId)),
-  isLoading: state.getIn(['timelines', `account:${Number(props.params.accountId)}:media`, 'isLoading']),
-  hasMore: !!state.getIn(['timelines', `account:${Number(props.params.accountId)}:media`, 'next']),
+  medias: getAccountGallery(state, props.params.accountId),
+  isLoading: state.getIn(['timelines', `account:${props.params.accountId}:media`, 'isLoading']),
+  hasMore: !!state.getIn(['timelines', `account:${props.params.accountId}:media`, 'next']),
   autoPlayGif: state.getIn(['meta', 'auto_play_gif']),
 });
 
@@ -35,20 +35,20 @@ export default class AccountGallery extends ImmutablePureComponent {
   };
 
   componentDidMount () {
-    this.props.dispatch(fetchAccount(Number(this.props.params.accountId)));
-    this.props.dispatch(refreshAccountMediaTimeline(Number(this.props.params.accountId)));
+    this.props.dispatch(fetchAccount(this.props.params.accountId));
+    this.props.dispatch(refreshAccountMediaTimeline(this.props.params.accountId));
   }
 
   componentWillReceiveProps (nextProps) {
     if (nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) {
-      this.props.dispatch(fetchAccount(Number(nextProps.params.accountId)));
-      this.props.dispatch(refreshAccountMediaTimeline(Number(this.props.params.accountId)));
+      this.props.dispatch(fetchAccount(nextProps.params.accountId));
+      this.props.dispatch(refreshAccountMediaTimeline(this.props.params.accountId));
     }
   }
 
   handleScrollToBottom = () => {
     if (this.props.hasMore) {
-      this.props.dispatch(expandAccountMediaTimeline(Number(this.props.params.accountId)));
+      this.props.dispatch(expandAccountMediaTimeline(this.props.params.accountId));
     }
   }
 
diff --git a/app/javascript/mastodon/features/account_timeline/components/header.js b/app/javascript/mastodon/features/account_timeline/components/header.js
index 09883d7d6..c3cd4e55d 100644
--- a/app/javascript/mastodon/features/account_timeline/components/header.js
+++ b/app/javascript/mastodon/features/account_timeline/components/header.js
@@ -10,7 +10,7 @@ export default class Header extends ImmutablePureComponent {
 
   static propTypes = {
     account: ImmutablePropTypes.map,
-    me: PropTypes.number.isRequired,
+    me: PropTypes.string.isRequired,
     onFollow: PropTypes.func.isRequired,
     onBlock: PropTypes.func.isRequired,
     onMention: PropTypes.func.isRequired,
diff --git a/app/javascript/mastodon/features/account_timeline/containers/header_container.js b/app/javascript/mastodon/features/account_timeline/containers/header_container.js
index ac1364441..9ad13a231 100644
--- a/app/javascript/mastodon/features/account_timeline/containers/header_container.js
+++ b/app/javascript/mastodon/features/account_timeline/containers/header_container.js
@@ -26,7 +26,7 @@ const makeMapStateToProps = () => {
   const getAccount = makeGetAccount();
 
   const mapStateToProps = (state, { accountId }) => ({
-    account: getAccount(state, Number(accountId)),
+    account: getAccount(state, accountId),
     me: state.getIn(['meta', 'me']),
     unfollowModal: state.getIn(['meta', 'unfollow_modal']),
   });
diff --git a/app/javascript/mastodon/features/account_timeline/index.js b/app/javascript/mastodon/features/account_timeline/index.js
index 3c8b63114..fe92216d5 100644
--- a/app/javascript/mastodon/features/account_timeline/index.js
+++ b/app/javascript/mastodon/features/account_timeline/index.js
@@ -13,9 +13,9 @@ import { List as ImmutableList } from 'immutable';
 import ImmutablePureComponent from 'react-immutable-pure-component';
 
 const mapStateToProps = (state, props) => ({
-  statusIds: state.getIn(['timelines', `account:${Number(props.params.accountId)}`, 'items'], ImmutableList()),
-  isLoading: state.getIn(['timelines', `account:${Number(props.params.accountId)}`, 'isLoading']),
-  hasMore: !!state.getIn(['timelines', `account:${Number(props.params.accountId)}`, 'next']),
+  statusIds: state.getIn(['timelines', `account:${props.params.accountId}`, 'items'], ImmutableList()),
+  isLoading: state.getIn(['timelines', `account:${props.params.accountId}`, 'isLoading']),
+  hasMore: !!state.getIn(['timelines', `account:${props.params.accountId}`, 'next']),
   me: state.getIn(['meta', 'me']),
 });
 
@@ -28,24 +28,24 @@ export default class AccountTimeline extends ImmutablePureComponent {
     statusIds: ImmutablePropTypes.list,
     isLoading: PropTypes.bool,
     hasMore: PropTypes.bool,
-    me: PropTypes.number.isRequired,
+    me: PropTypes.string.isRequired,
   };
 
   componentWillMount () {
-    this.props.dispatch(fetchAccount(Number(this.props.params.accountId)));
-    this.props.dispatch(refreshAccountTimeline(Number(this.props.params.accountId)));
+    this.props.dispatch(fetchAccount(this.props.params.accountId));
+    this.props.dispatch(refreshAccountTimeline(this.props.params.accountId));
   }
 
   componentWillReceiveProps (nextProps) {
     if (nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) {
-      this.props.dispatch(fetchAccount(Number(nextProps.params.accountId)));
-      this.props.dispatch(refreshAccountTimeline(Number(nextProps.params.accountId)));
+      this.props.dispatch(fetchAccount(nextProps.params.accountId));
+      this.props.dispatch(refreshAccountTimeline(nextProps.params.accountId));
     }
   }
 
   handleScrollToBottom = () => {
     if (!this.props.isLoading && this.props.hasMore) {
-      this.props.dispatch(expandAccountTimeline(Number(this.props.params.accountId)));
+      this.props.dispatch(expandAccountTimeline(this.props.params.accountId));
     }
   }
 
diff --git a/app/javascript/mastodon/features/compose/components/autosuggest_shortcode.js b/app/javascript/mastodon/features/compose/components/autosuggest_shortcode.js
deleted file mode 100644
index 4a0ef96b3..000000000
--- a/app/javascript/mastodon/features/compose/components/autosuggest_shortcode.js
+++ /dev/null
@@ -1,38 +0,0 @@
-import React from 'react';
-import ImmutablePureComponent from 'react-immutable-pure-component';
-import PropTypes from 'prop-types';
-import emojione from 'emojione';
-
-// This is bad, but I don't know how to make it work without importing the entirety of emojione.
-// taken from some old version of mastodon before they gutted emojione to "emojione_light"
-const shortnameToImage = str => str.replace(emojione.regShortNames, shortname => {
-  if (typeof shortname === 'undefined' || shortname === '' || !(shortname in emojione.emojioneList)) {
-    return shortname;
-  }
-
-  const unicode = emojione.emojioneList[shortname].unicode[emojione.emojioneList[shortname].unicode.length - 1];
-  const alt     = emojione.convert(unicode.toUpperCase());
-
-  return `<img draggable="false" class="emojione" alt="${alt}" src="/emoji/${unicode}.svg" />`;
-});
-
-export default class AutosuggestShortcode extends ImmutablePureComponent {
-
-  static propTypes = {
-    shortcode: PropTypes.string.isRequired,
-  };
-
-  render () {
-    const { shortcode } = this.props;
-
-    let emoji = shortnameToImage(shortcode);
-
-    return (
-      <div className='autosuggest-account'>
-        <div className='autosuggest-account-icon' dangerouslySetInnerHTML={{ __html: emoji }} />
-        {shortcode}
-      </div>
-    );
-  }
-
-}
diff --git a/app/javascript/mastodon/features/compose/components/compose_form.js b/app/javascript/mastodon/features/compose/components/compose_form.js
index 5befd0337..b85105c53 100644
--- a/app/javascript/mastodon/features/compose/components/compose_form.js
+++ b/app/javascript/mastodon/features/compose/components/compose_form.js
@@ -13,7 +13,7 @@ import SpoilerButtonContainer from '../containers/spoiler_button_container';
 import PrivacyDropdownContainer from '../containers/privacy_dropdown_container';
 import ComposeAdvancedOptionsContainer from '../../../../glitch/components/compose/advanced_options/container';
 import SensitiveButtonContainer from '../containers/sensitive_button_container';
-import EmojiPickerDropdown from './emoji_picker_dropdown';
+import EmojiPickerDropdown from '../containers/emoji_picker_dropdown_container';
 import UploadFormContainer from '../containers/upload_form_container';
 import WarningContainer from '../containers/warning_container';
 import { isMobile } from '../../../is_mobile';
@@ -46,7 +46,7 @@ export default class ComposeForm extends ImmutablePureComponent {
     preselectDate: PropTypes.instanceOf(Date),
     is_submitting: PropTypes.bool,
     is_uploading: PropTypes.bool,
-    me: PropTypes.number,
+    me: PropTypes.string,
     onChange: PropTypes.func.isRequired,
     onSubmit: PropTypes.func.isRequired,
     onClearSuggestions: PropTypes.func.isRequired,
@@ -98,10 +98,6 @@ export default class ComposeForm extends ImmutablePureComponent {
     this.props.onFetchSuggestions(token);
   }, 500, { trailing: true })
 
-  onLocalSuggestionsFetchRequested = debounce((token) => {
-    this.props.onFetchSuggestions(token);
-  }, 100, { trailing: true })
-
   onSuggestionSelected = (tokenStart, token, value) => {
     this._restoreCaret = null;
     this.props.onSuggestionSelected(tokenStart, token, value);
@@ -154,7 +150,7 @@ export default class ComposeForm extends ImmutablePureComponent {
 
   handleEmojiPick = (data) => {
     const position     = this.autosuggestTextarea.textarea.selectionStart;
-    const emojiChar    = data.unicode.split('-').map(code => String.fromCodePoint(parseInt(code, 16))).join('');
+    const emojiChar    = data.native;
     this._restoreCaret = position + emojiChar.length + 1;
     this.props.onPickEmoji(position, data);
   }
@@ -238,7 +234,6 @@ export default class ComposeForm extends ImmutablePureComponent {
             suggestions={this.props.suggestions}
             onKeyDown={this.handleKeyDown}
             onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
-            onLocalSuggestionsFetchRequested={this.onLocalSuggestionsFetchRequested}
             onSuggestionsClearRequested={this.onSuggestionsClearRequested}
             onSuggestionSelected={this.onSuggestionSelected}
             onPaste={onPaste}
diff --git a/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js b/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js
index 9d05b7a34..621cc21ce 100644
--- a/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js
+++ b/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js
@@ -1,12 +1,19 @@
 import React from 'react';
-import Dropdown, { DropdownTrigger, DropdownContent } from 'react-simple-dropdown';
 import PropTypes from 'prop-types';
 import { defineMessages, injectIntl } from 'react-intl';
-import { EmojiPicker as EmojiPickerAsync } from '../../ui/util/async-components';
+import { Picker, Emoji } from 'emoji-mart';
+import { Overlay } from 'react-overlays';
+import classNames from 'classnames';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import detectPassiveEvents from 'detect-passive-events';
 
 const messages = defineMessages({
   emoji: { id: 'emoji_button.label', defaultMessage: 'Insert emoji' },
   emoji_search: { id: 'emoji_button.search', defaultMessage: 'Search...' },
+  emoji_not_found: { id: 'emoji_button.not_found', defaultMessage: 'No emojos!! (╯°□°)╯︵ ┻━┻' },
+  custom: { id: 'emoji_button.custom', defaultMessage: 'Custom' },
+  recent: { id: 'emoji_button.recent', defaultMessage: 'Frequently used' },
+  search_results: { id: 'emoji_button.search_results', defaultMessage: 'Search results' },
   people: { id: 'emoji_button.people', defaultMessage: 'People' },
   nature: { id: 'emoji_button.nature', defaultMessage: 'Nature' },
   food: { id: 'emoji_button.food', defaultMessage: 'Food & Drink' },
@@ -17,48 +24,250 @@ const messages = defineMessages({
   flags: { id: 'emoji_button.flags', defaultMessage: 'Flags' },
 });
 
-const settings = {
-  imageType: 'png',
-  sprites: false,
-  imagePathPNG: '/emoji/',
-};
+const assetHost = process.env.CDN_HOST || '';
+const backgroundImageFn = () => `${assetHost}/emoji/sheet.png`;
+const listenerOptions = detectPassiveEvents.hasSupport ? { passive: true } : false;
 
-let EmojiPicker; // load asynchronously
+class ModifierPickerMenu extends React.PureComponent {
+
+  static propTypes = {
+    active: PropTypes.bool,
+    onSelect: PropTypes.func.isRequired,
+    onClose: PropTypes.func.isRequired,
+  };
+
+  handleClick = (e) => {
+    const modifier = [].slice.call(e.currentTarget.parentNode.children).indexOf(e.target) + 1;
+    this.props.onSelect(modifier);
+  }
+
+  componentWillReceiveProps (nextProps) {
+    if (nextProps.active) {
+      this.attachListeners();
+    } else {
+      this.removeListeners();
+    }
+  }
+
+  componentWillUnmount () {
+    this.removeListeners();
+  }
+
+  handleDocumentClick = e => {
+    if (this.node && !this.node.contains(e.target)) {
+      this.props.onClose();
+    }
+  }
+
+  attachListeners () {
+    document.addEventListener('click', this.handleDocumentClick, false);
+    document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);
+  }
+
+  removeListeners () {
+    document.removeEventListener('click', this.handleDocumentClick, false);
+    document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions);
+  }
+
+  setRef = c => {
+    this.node = c;
+  }
+
+  render () {
+    const { active } = this.props;
+
+    return (
+      <div className='emoji-picker-dropdown__modifiers__menu' style={{ display: active ? 'block' : 'none' }} ref={this.setRef}>
+        <button onClick={this.handleClick}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={1} backgroundImageFn={backgroundImageFn} /></button>
+        <button onClick={this.handleClick}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={2} backgroundImageFn={backgroundImageFn} /></button>
+        <button onClick={this.handleClick}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={3} backgroundImageFn={backgroundImageFn} /></button>
+        <button onClick={this.handleClick}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={4} backgroundImageFn={backgroundImageFn} /></button>
+        <button onClick={this.handleClick}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={5} backgroundImageFn={backgroundImageFn} /></button>
+        <button onClick={this.handleClick}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={6} backgroundImageFn={backgroundImageFn} /></button>
+      </div>
+    );
+  }
+
+}
+
+class ModifierPicker extends React.PureComponent {
+
+  static propTypes = {
+    active: PropTypes.bool,
+    modifier: PropTypes.number,
+    onChange: PropTypes.func,
+    onClose: PropTypes.func,
+    onOpen: PropTypes.func,
+  };
+
+  handleClick = () => {
+    if (this.props.active) {
+      this.props.onClose();
+    } else {
+      this.props.onOpen();
+    }
+  }
+
+  handleSelect = modifier => {
+    this.props.onChange(modifier);
+    this.props.onClose();
+  }
+
+  render () {
+    const { active, modifier } = this.props;
+
+    return (
+      <div className='emoji-picker-dropdown__modifiers'>
+        <Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={modifier} onClick={this.handleClick} backgroundImageFn={backgroundImageFn} />
+        <ModifierPickerMenu active={active} onSelect={this.handleSelect} onClose={this.props.onClose} />
+      </div>
+    );
+  }
+
+}
+
+@injectIntl
+class EmojiPickerMenu extends React.PureComponent {
+
+  static propTypes = {
+    custom_emojis: ImmutablePropTypes.list,
+    onClose: PropTypes.func.isRequired,
+    onPick: PropTypes.func.isRequired,
+    style: PropTypes.object,
+    placement: PropTypes.string,
+    arrowOffsetLeft: PropTypes.string,
+    arrowOffsetTop: PropTypes.string,
+    intl: PropTypes.object.isRequired,
+  };
+
+  static defaultProps = {
+    style: {},
+    placement: 'bottom',
+  };
+
+  state = {
+    modifierOpen: false,
+    modifier: 1,
+  };
+
+  handleDocumentClick = e => {
+    if (this.node && !this.node.contains(e.target)) {
+      this.props.onClose();
+    }
+  }
+
+  componentDidMount () {
+    document.addEventListener('click', this.handleDocumentClick, false);
+    document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);
+  }
+
+  componentWillUnmount () {
+    document.removeEventListener('click', this.handleDocumentClick, false);
+    document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions);
+  }
+
+  setRef = c => {
+    this.node = c;
+  }
+
+  getI18n = () => {
+    const { intl } = this.props;
+
+    return {
+      search: intl.formatMessage(messages.emoji_search),
+      notfound: intl.formatMessage(messages.emoji_not_found),
+      categories: {
+        search: intl.formatMessage(messages.search_results),
+        recent: intl.formatMessage(messages.recent),
+        people: intl.formatMessage(messages.people),
+        nature: intl.formatMessage(messages.nature),
+        foods: intl.formatMessage(messages.food),
+        activity: intl.formatMessage(messages.activity),
+        places: intl.formatMessage(messages.travel),
+        objects: intl.formatMessage(messages.objects),
+        symbols: intl.formatMessage(messages.symbols),
+        flags: intl.formatMessage(messages.flags),
+        custom: intl.formatMessage(messages.custom),
+      },
+    };
+  }
+
+  handleClick = emoji => {
+    if (!emoji.native) {
+      emoji.native = emoji.colons;
+    }
+
+    this.props.onClose();
+    this.props.onPick(emoji);
+  }
+
+  handleModifierOpen = () => {
+    this.setState({ modifierOpen: true });
+  }
+
+  handleModifierClose = () => {
+    this.setState({ modifierOpen: false });
+  }
+
+  handleModifierChange = modifier => {
+    if (modifier !== this.state.modifier) {
+      this.setState({ modifier });
+    }
+  }
+
+  render () {
+    const { style, intl } = this.props;
+    const title = intl.formatMessage(messages.emoji);
+    const { modifierOpen, modifier } = this.state;
+
+    return (
+      <div className={classNames('emoji-picker-dropdown__menu', { selecting: modifierOpen })} style={style} ref={this.setRef}>
+        <Picker
+          perLine={8}
+          emojiSize={22}
+          sheetSize={32}
+          color=''
+          emoji=''
+          set='twitter'
+          title={title}
+          i18n={this.getI18n()}
+          onClick={this.handleClick}
+          skin={modifier}
+          backgroundImageFn={backgroundImageFn}
+        />
+
+        <ModifierPicker
+          active={modifierOpen}
+          modifier={modifier}
+          onOpen={this.handleModifierOpen}
+          onClose={this.handleModifierClose}
+          onChange={this.handleModifierChange}
+        />
+      </div>
+    );
+  }
+
+}
 
 @injectIntl
 export default class EmojiPickerDropdown extends React.PureComponent {
 
   static propTypes = {
+    custom_emojis: ImmutablePropTypes.list,
     intl: PropTypes.object.isRequired,
     onPickEmoji: PropTypes.func.isRequired,
   };
 
   state = {
     active: false,
-    loading: false,
   };
 
   setRef = (c) => {
     this.dropdown = c;
   }
 
-  handleChange = (data) => {
-    this.dropdown.hide();
-    this.props.onPickEmoji(data);
-  }
-
   onShowDropdown = () => {
     this.setState({ active: true });
-    if (!EmojiPicker) {
-      this.setState({ loading: true });
-      EmojiPickerAsync().then(TheEmojiPicker => {
-        EmojiPicker = TheEmojiPicker.default;
-        this.setState({ loading: false });
-      }).catch(() => {
-        // TODO: show the user an error?
-        this.setState({ loading: false });
-      });
-    }
   }
 
   onHideDropdown = () => {
@@ -66,7 +275,7 @@ export default class EmojiPickerDropdown extends React.PureComponent {
   }
 
   onToggle = (e) => {
-    if (!this.state.loading && (!e.key || e.key === 'Enter')) {
+    if (!e.key || e.key === 'Enter') {
       if (this.state.active) {
         this.onHideDropdown();
       } else {
@@ -75,70 +284,43 @@ export default class EmojiPickerDropdown extends React.PureComponent {
     }
   }
 
-  onEmojiPickerKeyDown = (e) => {
+  handleKeyDown = e => {
     if (e.key === 'Escape') {
       this.onHideDropdown();
     }
   }
 
-  render () {
-    const { intl } = this.props;
+  setTargetRef = c => {
+    this.target = c;
+  }
 
-    const categories = {
-      people: {
-        title: intl.formatMessage(messages.people),
-        emoji: 'smile',
-      },
-      nature: {
-        title: intl.formatMessage(messages.nature),
-        emoji: 'hamster',
-      },
-      food: {
-        title: intl.formatMessage(messages.food),
-        emoji: 'pizza',
-      },
-      activity: {
-        title: intl.formatMessage(messages.activity),
-        emoji: 'soccer',
-      },
-      travel: {
-        title: intl.formatMessage(messages.travel),
-        emoji: 'earth_americas',
-      },
-      objects: {
-        title: intl.formatMessage(messages.objects),
-        emoji: 'bulb',
-      },
-      symbols: {
-        title: intl.formatMessage(messages.symbols),
-        emoji: 'clock9',
-      },
-      flags: {
-        title: intl.formatMessage(messages.flags),
-        emoji: 'flag_gb',
-      },
-    };
+  findTarget = () => {
+    return this.target;
+  }
 
-    const { active, loading } = this.state;
+  render () {
+    const { intl, onPickEmoji } = this.props;
     const title = intl.formatMessage(messages.emoji);
+    const { active } = this.state;
 
     return (
-      <Dropdown ref={this.setRef} className='emoji-picker__dropdown' active={active && !loading} onShow={this.onShowDropdown} onHide={this.onHideDropdown}>
-        <DropdownTrigger className='emoji-button' title={title} aria-label={title} aria-expanded={active} role='button' onKeyDown={this.onToggle} tabIndex={0} >
+      <div className='emoji-picker-dropdown' onKeyDown={this.handleKeyDown}>
+        <div ref={this.setTargetRef} className='emoji-button' title={title} aria-label={title} aria-expanded={active} role='button' onClick={this.onToggle} onKeyDown={this.onToggle} tabIndex={0}>
           <img
-            className={`emojione ${active && loading ? 'pulse-loading' : ''}`}
+            className='emojione'
             alt='🙂'
-            src='/emoji/1f602.svg'
+            src={`${assetHost}/emoji/1f602.svg`}
+          />
+        </div>
+
+        <Overlay show={active} placement='bottom' target={this.findTarget}>
+          <EmojiPickerMenu
+            custom_emojis={this.props.custom_emojis}
+            onClose={this.onHideDropdown}
+            onPick={onPickEmoji}
           />
-        </DropdownTrigger>
-
-        <DropdownContent className='dropdown__left'>
-          {
-            this.state.active && !this.state.loading &&
-            (<EmojiPicker emojione={settings} onChange={this.handleChange} searchPlaceholder={intl.formatMessage(messages.emoji_search)} onKeyDown={this.onEmojiPickerKeyDown} categories={categories} search />)
-          }
-        </DropdownContent>
-      </Dropdown>
+        </Overlay>
+      </div>
     );
   }
 
diff --git a/app/javascript/mastodon/features/compose/components/privacy_dropdown.js b/app/javascript/mastodon/features/compose/components/privacy_dropdown.js
index da3c0a0ab..0474dfb4e 100644
--- a/app/javascript/mastodon/features/compose/components/privacy_dropdown.js
+++ b/app/javascript/mastodon/features/compose/components/privacy_dropdown.js
@@ -2,6 +2,7 @@ import React from 'react';
 import PropTypes from 'prop-types';
 import { injectIntl, defineMessages } from 'react-intl';
 import IconButton from '../../../components/icon_button';
+import detectPassiveEvents from 'detect-passive-events';
 
 const messages = defineMessages({
   public_short: { id: 'privacy.public.short', defaultMessage: 'Public' },
@@ -89,12 +90,12 @@ export default class PrivacyDropdown extends React.PureComponent {
 
   componentDidMount () {
     window.addEventListener('click', this.onGlobalClick);
-    window.addEventListener('touchstart', this.onGlobalClick);
+    window.addEventListener('touchstart', this.onGlobalClick, detectPassiveEvents.hasSupport ? { passive: true } : false);
   }
 
   componentWillUnmount () {
     window.removeEventListener('click', this.onGlobalClick);
-    window.removeEventListener('touchstart', this.onGlobalClick);
+    window.removeEventListener('touchstart', this.onGlobalClick, detectPassiveEvents.hasSupport ? { passive: true } : false);
   }
 
   setRef = (c) => {
diff --git a/app/javascript/mastodon/features/compose/components/upload_form.js b/app/javascript/mastodon/features/compose/components/upload_form.js
index 78473dab4..cf2d2658a 100644
--- a/app/javascript/mastodon/features/compose/components/upload_form.js
+++ b/app/javascript/mastodon/features/compose/components/upload_form.js
@@ -21,7 +21,7 @@ export default class UploadForm extends React.PureComponent {
   };
 
   onRemoveFile = (e) => {
-    const id = Number(e.currentTarget.parentElement.getAttribute('data-id'));
+    const id = e.currentTarget.parentElement.getAttribute('data-id');
     this.props.onRemoveFile(id);
   }
 
diff --git a/app/javascript/mastodon/features/compose/containers/emoji_picker_dropdown_container.js b/app/javascript/mastodon/features/compose/containers/emoji_picker_dropdown_container.js
new file mode 100644
index 000000000..7a8026bbc
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/containers/emoji_picker_dropdown_container.js
@@ -0,0 +1,8 @@
+import { connect } from 'react-redux';
+import EmojiPickerDropdown from '../components/emoji_picker_dropdown';
+
+const mapStateToProps = state => ({
+  custom_emojis: state.get('custom_emojis'),
+});
+
+export default connect(mapStateToProps)(EmojiPickerDropdown);
diff --git a/app/javascript/mastodon/features/compose/containers/warning_container.js b/app/javascript/mastodon/features/compose/containers/warning_container.js
index 6e7d11c63..35eab5976 100644
--- a/app/javascript/mastodon/features/compose/containers/warning_container.js
+++ b/app/javascript/mastodon/features/compose/containers/warning_container.js
@@ -1,51 +1,23 @@
 import React from 'react';
-import ImmutablePropTypes from 'react-immutable-proptypes';
 import { connect } from 'react-redux';
 import Warning from '../components/warning';
-import { createSelector } from 'reselect';
 import PropTypes from 'prop-types';
 import { FormattedMessage } from 'react-intl';
-import { OrderedSet } from 'immutable';
 
-const getMentionedUsernames = createSelector(state => state.getIn(['compose', 'text']), text => text.match(/(?:^|[^\/\w])@([a-z0-9_]+@[a-z0-9\.\-]+)/ig));
-
-const getMentionedDomains = createSelector(getMentionedUsernames, mentionedUsernamesWithDomains => {
-  return OrderedSet(mentionedUsernamesWithDomains !== null ? mentionedUsernamesWithDomains.map(item => item.split('@')[2]) : []);
+const mapStateToProps = state => ({
+  needsLockWarning: state.getIn(['compose', 'privacy']) === 'private' && !state.getIn(['accounts', state.getIn(['meta', 'me']), 'locked']),
 });
 
-const mapStateToProps = state => {
-  const mentionedUsernames = getMentionedUsernames(state);
-  const mentionedUsernamesWithDomains = getMentionedDomains(state);
-
-  return {
-    needsLeakWarning: (state.getIn(['compose', 'privacy']) === 'private' || state.getIn(['compose', 'privacy']) === 'direct') && mentionedUsernames !== null,
-    mentionedDomains: mentionedUsernamesWithDomains,
-    needsLockWarning: state.getIn(['compose', 'privacy']) === 'private' && !state.getIn(['accounts', state.getIn(['meta', 'me']), 'locked']),
-  };
-};
-
-const WarningWrapper = ({ needsLeakWarning, needsLockWarning, mentionedDomains }) => {
+const WarningWrapper = ({ needsLockWarning }) => {
   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> }} />} />;
-  } else if (needsLeakWarning) {
-    return (
-      <Warning
-        message={<FormattedMessage
-          id='compose_form.privacy_disclaimer'
-          defaultMessage='Your private status will be delivered to mentioned users on {domains}. Do you trust {domainsCount, plural, one {that server} other {those servers}}? Post privacy only works on Mastodon instances. If {domains} {domainsCount, plural, one {is not a Mastodon instance} other {are not Mastodon instances}}, there will be no indication that your post is private, and it may be boosted or otherwise made visible to unintended recipients.'
-          values={{ domains: <strong>{mentionedDomains.join(', ')}</strong>, domainsCount: mentionedDomains.size }}
-        />}
-      />
-    );
   }
 
   return null;
 };
 
 WarningWrapper.propTypes = {
-  needsLeakWarning: PropTypes.bool,
   needsLockWarning: PropTypes.bool,
-  mentionedDomains: ImmutablePropTypes.orderedSet.isRequired,
 };
 
 export default connect(mapStateToProps)(WarningWrapper);
diff --git a/app/javascript/mastodon/features/compose/util/counter.js b/app/javascript/mastodon/features/compose/util/counter.js
index f0fea1a0e..588a372c6 100644
--- a/app/javascript/mastodon/features/compose/util/counter.js
+++ b/app/javascript/mastodon/features/compose/util/counter.js
@@ -1,7 +1,9 @@
+import { urlRegex } from './url_regex';
+
 const urlPlaceholder = 'xxxxxxxxxxxxxxxxxxxxxxx';
 
 export function countableText(inputText) {
   return inputText
-    .replace(/https?:\/\/\S+/g, urlPlaceholder)
+    .replace(urlRegex, urlPlaceholder)
     .replace(/(?:^|[^\/\w])@(([a-z0-9_]+)@[a-z0-9\.\-]+)/ig, '@$2');
 };
diff --git a/app/javascript/mastodon/features/compose/util/url_regex.js b/app/javascript/mastodon/features/compose/util/url_regex.js
new file mode 100644
index 000000000..e676d1879
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/util/url_regex.js
@@ -0,0 +1,196 @@
+const regexen = {};
+
+const regexSupplant = function(regex, flags) {
+  flags = flags || '';
+  if (typeof regex !== 'string') {
+    if (regex.global && flags.indexOf('g') < 0) {
+      flags += 'g';
+    }
+    if (regex.ignoreCase && flags.indexOf('i') < 0) {
+      flags += 'i';
+    }
+    if (regex.multiline && flags.indexOf('m') < 0) {
+      flags += 'm';
+    }
+
+    regex = regex.source;
+  }
+  return new RegExp(regex.replace(/#\{(\w+)\}/g, function(match, name) {
+    var newRegex = regexen[name] || '';
+    if (typeof newRegex !== 'string') {
+      newRegex = newRegex.source;
+    }
+    return newRegex;
+  }), flags);
+};
+
+const stringSupplant = function(str, values) {
+  return str.replace(/#\{(\w+)\}/g, function(match, name) {
+    return values[name] || '';
+  });
+};
+
+export const urlRegex = (function() {
+  regexen.spaces_group = /\x09-\x0D\x20\x85\xA0\u1680\u180E\u2000-\u200A\u2028\u2029\u202F\u205F\u3000/;
+  regexen.invalid_chars_group = /\uFFFE\uFEFF\uFFFF\u202A-\u202E/;
+  regexen.punct = /\!'#%&'\(\)*\+,\\\-\.\/:;<=>\?@\[\]\^_{|}~\$/;
+  regexen.validUrlPrecedingChars = regexSupplant(/(?:[^A-Za-z0-9@@$###{invalid_chars_group}]|^)/);
+  regexen.invalidDomainChars = stringSupplant('#{punct}#{spaces_group}#{invalid_chars_group}', regexen);
+  regexen.validDomainChars = regexSupplant(/[^#{invalidDomainChars}]/);
+  regexen.validSubdomain = regexSupplant(/(?:(?:#{validDomainChars}(?:[_-]|#{validDomainChars})*)?#{validDomainChars}\.)/);
+  regexen.validDomainName = regexSupplant(/(?:(?:#{validDomainChars}(?:-|#{validDomainChars})*)?#{validDomainChars}\.)/);
+  regexen.validGTLD = regexSupplant(RegExp(
+  '(?:(?:' +
+    '삼성|닷컴|닷넷|香格里拉|餐厅|食品|飞利浦|電訊盈科|集团|通販|购物|谷歌|诺基亚|联通|网络|网站|网店|网址|组织机构|移动|珠宝|点看|游戏|淡马锡|机构|書籍|时尚|新闻|政府|' +
+    '政务|手表|手机|我爱你|慈善|微博|广东|工行|家電|娱乐|天主教|大拿|大众汽车|在线|嘉里大酒店|嘉里|商标|商店|商城|公益|公司|八卦|健康|信息|佛山|企业|中文网|中信|世界|' +
+    'ポイント|ファッション|セール|ストア|コム|グーグル|クラウド|みんな|คอม|संगठन|नेट|कॉम|همراه|موقع|موبايلي|كوم|كاثوليك|عرب|شبكة|' +
+    'بيتك|بازار|العليان|ارامكو|اتصالات|ابوظبي|קום|сайт|рус|орг|онлайн|москва|ком|католик|дети|' +
+    'zuerich|zone|zippo|zip|zero|zara|zappos|yun|youtube|you|yokohama|yoga|yodobashi|yandex|yamaxun|' +
+    'yahoo|yachts|xyz|xxx|xperia|xin|xihuan|xfinity|xerox|xbox|wtf|wtc|wow|world|works|work|woodside|' +
+    'wolterskluwer|wme|winners|wine|windows|win|williamhill|wiki|wien|whoswho|weir|weibo|wedding|wed|' +
+    'website|weber|webcam|weatherchannel|weather|watches|watch|warman|wanggou|wang|walter|walmart|' +
+    'wales|vuelos|voyage|voto|voting|vote|volvo|volkswagen|vodka|vlaanderen|vivo|viva|vistaprint|' +
+    'vista|vision|visa|virgin|vip|vin|villas|viking|vig|video|viajes|vet|versicherung|' +
+    'vermögensberatung|vermögensberater|verisign|ventures|vegas|vanguard|vana|vacations|ups|uol|uno|' +
+    'university|unicom|uconnect|ubs|ubank|tvs|tushu|tunes|tui|tube|trv|trust|travelersinsurance|' +
+    'travelers|travelchannel|travel|training|trading|trade|toys|toyota|town|tours|total|toshiba|' +
+    'toray|top|tools|tokyo|today|tmall|tkmaxx|tjx|tjmaxx|tirol|tires|tips|tiffany|tienda|tickets|' +
+    'tiaa|theatre|theater|thd|teva|tennis|temasek|telefonica|telecity|tel|technology|tech|team|tdk|' +
+    'tci|taxi|tax|tattoo|tatar|tatamotors|target|taobao|talk|taipei|tab|systems|symantec|sydney|' +
+    'swiss|swiftcover|swatch|suzuki|surgery|surf|support|supply|supplies|sucks|style|study|studio|' +
+    'stream|store|storage|stockholm|stcgroup|stc|statoil|statefarm|statebank|starhub|star|staples|' +
+    'stada|srt|srl|spreadbetting|spot|spiegel|space|soy|sony|song|solutions|solar|sohu|software|' +
+    'softbank|social|soccer|sncf|smile|smart|sling|skype|sky|skin|ski|site|singles|sina|silk|shriram|' +
+    'showtime|show|shouji|shopping|shop|shoes|shiksha|shia|shell|shaw|sharp|shangrila|sfr|sexy|sex|' +
+    'sew|seven|ses|services|sener|select|seek|security|secure|seat|search|scot|scor|scjohnson|' +
+    'science|schwarz|schule|school|scholarships|schmidt|schaeffler|scb|sca|sbs|sbi|saxo|save|sas|' +
+    'sarl|sapo|sap|sanofi|sandvikcoromant|sandvik|samsung|samsclub|salon|sale|sakura|safety|safe|' +
+    'saarland|ryukyu|rwe|run|ruhr|rugby|rsvp|room|rogers|rodeo|rocks|rocher|rmit|rip|rio|ril|' +
+    'rightathome|ricoh|richardli|rich|rexroth|reviews|review|restaurant|rest|republican|report|' +
+    'repair|rentals|rent|ren|reliance|reit|reisen|reise|rehab|redumbrella|redstone|red|recipes|' +
+    'realty|realtor|realestate|read|raid|radio|racing|qvc|quest|quebec|qpon|pwc|pub|prudential|pru|' +
+    'protection|property|properties|promo|progressive|prof|productions|prod|pro|prime|press|praxi|' +
+    'pramerica|post|porn|politie|poker|pohl|pnc|plus|plumbing|playstation|play|place|pizza|pioneer|' +
+    'pink|ping|pin|pid|pictures|pictet|pics|piaget|physio|photos|photography|photo|phone|philips|phd|' +
+    'pharmacy|pfizer|pet|pccw|pay|passagens|party|parts|partners|pars|paris|panerai|panasonic|' +
+    'pamperedchef|page|ovh|ott|otsuka|osaka|origins|orientexpress|organic|org|orange|oracle|open|ooo|' +
+    'onyourside|online|onl|ong|one|omega|ollo|oldnavy|olayangroup|olayan|okinawa|office|off|observer|' +
+    'obi|nyc|ntt|nrw|nra|nowtv|nowruz|now|norton|northwesternmutual|nokia|nissay|nissan|ninja|nikon|' +
+    'nike|nico|nhk|ngo|nfl|nexus|nextdirect|next|news|newholland|new|neustar|network|netflix|netbank|' +
+    'net|nec|nba|navy|natura|nationwide|name|nagoya|nadex|nab|mutuelle|mutual|museum|mtr|mtpc|mtn|' +
+    'msd|movistar|movie|mov|motorcycles|moto|moscow|mortgage|mormon|mopar|montblanc|monster|money|' +
+    'monash|mom|moi|moe|moda|mobily|mobile|mobi|mma|mls|mlb|mitsubishi|mit|mint|mini|mil|microsoft|' +
+    'miami|metlife|merckmsd|meo|menu|men|memorial|meme|melbourne|meet|media|med|mckinsey|mcdonalds|' +
+    'mcd|mba|mattel|maserati|marshalls|marriott|markets|marketing|market|map|mango|management|man|' +
+    'makeup|maison|maif|madrid|macys|luxury|luxe|lupin|lundbeck|ltda|ltd|lplfinancial|lpl|love|lotto|' +
+    'lotte|london|lol|loft|locus|locker|loans|loan|lixil|living|live|lipsy|link|linde|lincoln|limo|' +
+    'limited|lilly|like|lighting|lifestyle|lifeinsurance|life|lidl|liaison|lgbt|lexus|lego|legal|' +
+    'lefrak|leclerc|lease|lds|lawyer|law|latrobe|latino|lat|lasalle|lanxess|landrover|land|lancome|' +
+    'lancia|lancaster|lamer|lamborghini|ladbrokes|lacaixa|kyoto|kuokgroup|kred|krd|kpn|kpmg|kosher|' +
+    'komatsu|koeln|kiwi|kitchen|kindle|kinder|kim|kia|kfh|kerryproperties|kerrylogistics|kerryhotels|' +
+    'kddi|kaufen|juniper|juegos|jprs|jpmorgan|joy|jot|joburg|jobs|jnj|jmp|jll|jlc|jio|jewelry|jetzt|' +
+    'jeep|jcp|jcb|java|jaguar|iwc|iveco|itv|itau|istanbul|ist|ismaili|iselect|irish|ipiranga|' +
+    'investments|intuit|international|intel|int|insure|insurance|institute|ink|ing|info|infiniti|' +
+    'industries|immobilien|immo|imdb|imamat|ikano|iinet|ifm|ieee|icu|ice|icbc|ibm|hyundai|hyatt|' +
+    'hughes|htc|hsbc|how|house|hotmail|hotels|hoteles|hot|hosting|host|hospital|horse|honeywell|' +
+    'honda|homesense|homes|homegoods|homedepot|holiday|holdings|hockey|hkt|hiv|hitachi|hisamitsu|' +
+    'hiphop|hgtv|hermes|here|helsinki|help|healthcare|health|hdfcbank|hdfc|hbo|haus|hangout|hamburg|' +
+    'hair|guru|guitars|guide|guge|gucci|guardian|group|grocery|gripe|green|gratis|graphics|grainger|' +
+    'gov|got|gop|google|goog|goodyear|goodhands|goo|golf|goldpoint|gold|godaddy|gmx|gmo|gmbh|gmail|' +
+    'globo|global|gle|glass|glade|giving|gives|gifts|gift|ggee|george|genting|gent|gea|gdn|gbiz|' +
+    'garden|gap|games|game|gallup|gallo|gallery|gal|fyi|futbol|furniture|fund|fun|fujixerox|fujitsu|' +
+    'ftr|frontier|frontdoor|frogans|frl|fresenius|free|fox|foundation|forum|forsale|forex|ford|' +
+    'football|foodnetwork|food|foo|fly|flsmidth|flowers|florist|flir|flights|flickr|fitness|fit|' +
+    'fishing|fish|firmdale|firestone|fire|financial|finance|final|film|fido|fidelity|fiat|ferrero|' +
+    'ferrari|feedback|fedex|fast|fashion|farmers|farm|fans|fan|family|faith|fairwinds|fail|fage|' +
+    'extraspace|express|exposed|expert|exchange|everbank|events|eus|eurovision|etisalat|esurance|' +
+    'estate|esq|erni|ericsson|equipment|epson|epost|enterprises|engineering|engineer|energy|emerck|' +
+    'email|education|edu|edeka|eco|eat|earth|dvr|dvag|durban|dupont|duns|dunlop|duck|dubai|dtv|drive|' +
+    'download|dot|doosan|domains|doha|dog|dodge|doctor|docs|dnp|diy|dish|discover|discount|directory|' +
+    'direct|digital|diet|diamonds|dhl|dev|design|desi|dentist|dental|democrat|delta|deloitte|dell|' +
+    'delivery|degree|deals|dealer|deal|dds|dclk|day|datsun|dating|date|data|dance|dad|dabur|cyou|' +
+    'cymru|cuisinella|csc|cruises|cruise|crs|crown|cricket|creditunion|creditcard|credit|courses|' +
+    'coupons|coupon|country|corsica|coop|cool|cookingchannel|cooking|contractors|contact|consulting|' +
+    'construction|condos|comsec|computer|compare|company|community|commbank|comcast|com|cologne|' +
+    'college|coffee|codes|coach|clubmed|club|cloud|clothing|clinique|clinic|click|cleaning|claims|' +
+    'cityeats|city|citic|citi|citadel|cisco|circle|cipriani|church|chrysler|chrome|christmas|chloe|' +
+    'chintai|cheap|chat|chase|channel|chanel|cfd|cfa|cern|ceo|center|ceb|cbs|cbre|cbn|cba|catholic|' +
+    'catering|cat|casino|cash|caseih|case|casa|cartier|cars|careers|career|care|cards|caravan|car|' +
+    'capitalone|capital|capetown|canon|cancerresearch|camp|camera|cam|calvinklein|call|cal|cafe|cab|' +
+    'bzh|buzz|buy|business|builders|build|bugatti|budapest|brussels|brother|broker|broadway|' +
+    'bridgestone|bradesco|box|boutique|bot|boston|bostik|bosch|boots|booking|book|boo|bond|bom|bofa|' +
+    'boehringer|boats|bnpparibas|bnl|bmw|bms|blue|bloomberg|blog|blockbuster|blanco|blackfriday|' +
+    'black|biz|bio|bingo|bing|bike|bid|bible|bharti|bet|bestbuy|best|berlin|bentley|beer|beauty|' +
+    'beats|bcn|bcg|bbva|bbt|bbc|bayern|bauhaus|basketball|baseball|bargains|barefoot|barclays|' +
+    'barclaycard|barcelona|bar|bank|band|bananarepublic|banamex|baidu|baby|azure|axa|aws|avianca|' +
+    'autos|auto|author|auspost|audio|audible|audi|auction|attorney|athleta|associates|asia|asda|arte|' +
+    'art|arpa|army|archi|aramco|arab|aquarelle|apple|app|apartments|aol|anz|anquan|android|analytics|' +
+    'amsterdam|amica|amfam|amex|americanfamily|americanexpress|alstom|alsace|ally|allstate|allfinanz|' +
+    'alipay|alibaba|alfaromeo|akdn|airtel|airforce|airbus|aigo|aig|agency|agakhan|africa|afl|' +
+    'afamilycompany|aetna|aero|aeg|adult|ads|adac|actor|active|aco|accountants|accountant|accenture|' +
+    'academy|abudhabi|abogado|able|abc|abbvie|abbott|abb|abarth|aarp|aaa|onion' +
+  ')(?=[^0-9a-zA-Z@]|$))'));
+  regexen.validCCTLD = regexSupplant(RegExp(
+  '(?:(?:' +
+      '한국|香港|澳門|新加坡|台灣|台湾|中國|中国|გე|ไทย|ලංකා|ഭാരതം|ಭಾರತ|భారత్|சிங்கப்பூர்|இலங்கை|இந்தியா|ଭାରତ|ભારત|ਭਾਰਤ|' +
+      'ভাৰত|ভারত|বাংলা|भारोत|भारतम्|भारत|ڀارت|پاکستان|مليسيا|مصر|قطر|فلسطين|عمان|عراق|سورية|سودان|تونس|' +
+      'بھارت|بارت|ایران|امارات|المغرب|السعودية|الجزائر|الاردن|հայ|қаз|укр|срб|рф|мон|мкд|ею|бел|бг|ελ|' +
+      'zw|zm|za|yt|ye|ws|wf|vu|vn|vi|vg|ve|vc|va|uz|uy|us|um|uk|ug|ua|tz|tw|tv|tt|tr|tp|to|tn|tm|tl|tk|' +
+      'tj|th|tg|tf|td|tc|sz|sy|sx|sv|su|st|ss|sr|so|sn|sm|sl|sk|sj|si|sh|sg|se|sd|sc|sb|sa|rw|ru|rs|ro|' +
+      're|qa|py|pw|pt|ps|pr|pn|pm|pl|pk|ph|pg|pf|pe|pa|om|nz|nu|nr|np|no|nl|ni|ng|nf|ne|nc|na|mz|my|mx|' +
+      'mw|mv|mu|mt|ms|mr|mq|mp|mo|mn|mm|ml|mk|mh|mg|mf|me|md|mc|ma|ly|lv|lu|lt|ls|lr|lk|li|lc|lb|la|kz|' +
+      'ky|kw|kr|kp|kn|km|ki|kh|kg|ke|jp|jo|jm|je|it|is|ir|iq|io|in|im|il|ie|id|hu|ht|hr|hn|hm|hk|gy|gw|' +
+      'gu|gt|gs|gr|gq|gp|gn|gm|gl|gi|gh|gg|gf|ge|gd|gb|ga|fr|fo|fm|fk|fj|fi|eu|et|es|er|eh|eg|ee|ec|dz|' +
+      'do|dm|dk|dj|de|cz|cy|cx|cw|cv|cu|cr|co|cn|cm|cl|ck|ci|ch|cg|cf|cd|cc|ca|bz|by|bw|bv|bt|bs|br|bq|' +
+      'bo|bn|bm|bl|bj|bi|bh|bg|bf|be|bd|bb|ba|az|ax|aw|au|at|as|ar|aq|ao|an|am|al|ai|ag|af|ae|ad|ac' +
+  ')(?=[^0-9a-zA-Z@]|$))'));
+  regexen.validPunycode = /(?:xn--[0-9a-z]+)/;
+  regexen.validSpecialCCTLD = /(?:(?:co|tv)(?=[^0-9a-zA-Z@]|$))/;
+  regexen.validDomain = regexSupplant(/(?:#{validSubdomain}*#{validDomainName}(?:#{validGTLD}|#{validCCTLD}|#{validPunycode}))/);
+  regexen.validPortNumber = /[0-9]+/;
+  regexen.pd = /\u002d\u058a\u05be\u1400\u1806\u2010-\u2015\u2e17\u2e1a\u2e3a\u2e40\u301c\u3030\u30a0\ufe31\ufe58\ufe63\uff0d/;
+  regexen.validGeneralUrlPathChars = regexSupplant(/[^#{spaces_group}\(\)\?]/i);
+  // Allow URL paths to contain up to two nested levels of balanced parens
+  //  1. Used in Wikipedia URLs like /Primer_(film)
+  //  2. Used in IIS sessions like /S(dfd346)/
+  //  3. Used in Rdio URLs like /track/We_Up_(Album_Version_(Edited))/
+  regexen.validUrlBalancedParens = regexSupplant(
+    '\\('                                   +
+      '(?:'                                 +
+        '#{validGeneralUrlPathChars}+'      +
+        '|'                                 +
+        // allow one nested level of balanced parentheses
+        '(?:'                               +
+          '#{validGeneralUrlPathChars}*'    +
+          '\\('                             +
+            '#{validGeneralUrlPathChars}+'  +
+          '\\)'                             +
+          '#{validGeneralUrlPathChars}*'    +
+        ')'                                 +
+      ')'                                   +
+    '\\)'
+  , 'i');
+  // Valid end-of-path chracters (so /foo. does not gobble the period).
+  // 1. Allow =&# for empty URL parameters and other URL-join artifacts
+  regexen.validUrlPathEndingChars = regexSupplant(/[^#{spaces_group}\(\)\?!\*';:=\,\.\$%\[\]#{pd}~&\|@]|(?:#{validUrlBalancedParens})/i);
+  // Allow @ in a url, but only in the middle. Catch things like http://example.com/@user/
+  regexen.validUrlPath = regexSupplant('(?:' +
+    '(?:' +
+      '#{validGeneralUrlPathChars}*' +
+        '(?:#{validUrlBalancedParens}#{validGeneralUrlPathChars}*)*' +
+        '#{validUrlPathEndingChars}'+
+      ')|(?:@#{validGeneralUrlPathChars}+\/)'+
+    ')', 'i');
+  regexen.validUrlQueryChars = /[a-z0-9!?\*'@\(\);:&=\+\$\/%#\[\]\-_\.,~|]/i;
+  regexen.validUrlQueryEndingChars = /[a-z0-9_&=#\/]/i;
+  regexen.validUrl = regexSupplant(
+    '('                                                          + // $1 URL
+      '(https?:\\/\\/)'                                          + // $2 Protocol
+      '(#{validDomain})'                                         + // $3 Domain(s)
+      '(?::(#{validPortNumber}))?'                               + // $4 Port number (optional)
+      '(\\/#{validUrlPath}*)?'                                   + // $5 URL Path
+      '(\\?#{validUrlQueryChars}*#{validUrlQueryEndingChars})?'  + // $6 Query String
+    ')'
+  , 'gi');
+  return regexen.validUrl;
+}());
diff --git a/app/javascript/mastodon/features/favourites/index.js b/app/javascript/mastodon/features/favourites/index.js
index dc8109d16..4dbfefd87 100644
--- a/app/javascript/mastodon/features/favourites/index.js
+++ b/app/javascript/mastodon/features/favourites/index.js
@@ -11,7 +11,7 @@ import ColumnBackButton from '../../components/column_back_button';
 import ImmutablePureComponent from 'react-immutable-pure-component';
 
 const mapStateToProps = (state, props) => ({
-  accountIds: state.getIn(['user_lists', 'favourited_by', Number(props.params.statusId)]),
+  accountIds: state.getIn(['user_lists', 'favourited_by', props.params.statusId]),
 });
 
 @connect(mapStateToProps)
@@ -24,12 +24,12 @@ export default class Favourites extends ImmutablePureComponent {
   };
 
   componentWillMount () {
-    this.props.dispatch(fetchFavourites(Number(this.props.params.statusId)));
+    this.props.dispatch(fetchFavourites(this.props.params.statusId));
   }
 
   componentWillReceiveProps (nextProps) {
     if (nextProps.params.statusId !== this.props.params.statusId && nextProps.params.statusId) {
-      this.props.dispatch(fetchFavourites(Number(nextProps.params.statusId)));
+      this.props.dispatch(fetchFavourites(nextProps.params.statusId));
     }
   }
 
diff --git a/app/javascript/mastodon/features/followers/index.js b/app/javascript/mastodon/features/followers/index.js
index 2d85b9cc0..89445559f 100644
--- a/app/javascript/mastodon/features/followers/index.js
+++ b/app/javascript/mastodon/features/followers/index.js
@@ -17,8 +17,8 @@ import ColumnBackButton from '../../components/column_back_button';
 import ImmutablePureComponent from 'react-immutable-pure-component';
 
 const mapStateToProps = (state, props) => ({
-  accountIds: state.getIn(['user_lists', 'followers', Number(props.params.accountId), 'items']),
-  hasMore: !!state.getIn(['user_lists', 'followers', Number(props.params.accountId), 'next']),
+  accountIds: state.getIn(['user_lists', 'followers', props.params.accountId, 'items']),
+  hasMore: !!state.getIn(['user_lists', 'followers', props.params.accountId, 'next']),
 });
 
 @connect(mapStateToProps)
@@ -32,14 +32,14 @@ export default class Followers extends ImmutablePureComponent {
   };
 
   componentWillMount () {
-    this.props.dispatch(fetchAccount(Number(this.props.params.accountId)));
-    this.props.dispatch(fetchFollowers(Number(this.props.params.accountId)));
+    this.props.dispatch(fetchAccount(this.props.params.accountId));
+    this.props.dispatch(fetchFollowers(this.props.params.accountId));
   }
 
   componentWillReceiveProps (nextProps) {
     if (nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) {
-      this.props.dispatch(fetchAccount(Number(nextProps.params.accountId)));
-      this.props.dispatch(fetchFollowers(Number(nextProps.params.accountId)));
+      this.props.dispatch(fetchAccount(nextProps.params.accountId));
+      this.props.dispatch(fetchFollowers(nextProps.params.accountId));
     }
   }
 
@@ -47,13 +47,13 @@ export default class Followers extends ImmutablePureComponent {
     const { scrollTop, scrollHeight, clientHeight } = e.target;
 
     if (scrollTop === scrollHeight - clientHeight && this.props.hasMore) {
-      this.props.dispatch(expandFollowers(Number(this.props.params.accountId)));
+      this.props.dispatch(expandFollowers(this.props.params.accountId));
     }
   }
 
   handleLoadMore = (e) => {
     e.preventDefault();
-    this.props.dispatch(expandFollowers(Number(this.props.params.accountId)));
+    this.props.dispatch(expandFollowers(this.props.params.accountId));
   }
 
   render () {
diff --git a/app/javascript/mastodon/features/following/index.js b/app/javascript/mastodon/features/following/index.js
index e4e2a4811..c34830276 100644
--- a/app/javascript/mastodon/features/following/index.js
+++ b/app/javascript/mastodon/features/following/index.js
@@ -17,8 +17,8 @@ import ColumnBackButton from '../../components/column_back_button';
 import ImmutablePureComponent from 'react-immutable-pure-component';
 
 const mapStateToProps = (state, props) => ({
-  accountIds: state.getIn(['user_lists', 'following', Number(props.params.accountId), 'items']),
-  hasMore: !!state.getIn(['user_lists', 'following', Number(props.params.accountId), 'next']),
+  accountIds: state.getIn(['user_lists', 'following', props.params.accountId, 'items']),
+  hasMore: !!state.getIn(['user_lists', 'following', props.params.accountId, 'next']),
 });
 
 @connect(mapStateToProps)
@@ -32,14 +32,14 @@ export default class Following extends ImmutablePureComponent {
   };
 
   componentWillMount () {
-    this.props.dispatch(fetchAccount(Number(this.props.params.accountId)));
-    this.props.dispatch(fetchFollowing(Number(this.props.params.accountId)));
+    this.props.dispatch(fetchAccount(this.props.params.accountId));
+    this.props.dispatch(fetchFollowing(this.props.params.accountId));
   }
 
   componentWillReceiveProps (nextProps) {
     if (nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) {
-      this.props.dispatch(fetchAccount(Number(nextProps.params.accountId)));
-      this.props.dispatch(fetchFollowing(Number(nextProps.params.accountId)));
+      this.props.dispatch(fetchAccount(nextProps.params.accountId));
+      this.props.dispatch(fetchFollowing(nextProps.params.accountId));
     }
   }
 
@@ -47,13 +47,13 @@ export default class Following extends ImmutablePureComponent {
     const { scrollTop, scrollHeight, clientHeight } = e.target;
 
     if (scrollTop === scrollHeight - clientHeight && this.props.hasMore) {
-      this.props.dispatch(expandFollowing(Number(this.props.params.accountId)));
+      this.props.dispatch(expandFollowing(this.props.params.accountId));
     }
   }
 
   handleLoadMore = (e) => {
     e.preventDefault();
-    this.props.dispatch(expandFollowing(Number(this.props.params.accountId)));
+    this.props.dispatch(expandFollowing(this.props.params.accountId));
   }
 
   render () {
diff --git a/app/javascript/mastodon/features/reblogs/index.js b/app/javascript/mastodon/features/reblogs/index.js
index dc940ae01..f1904786a 100644
--- a/app/javascript/mastodon/features/reblogs/index.js
+++ b/app/javascript/mastodon/features/reblogs/index.js
@@ -11,7 +11,7 @@ import ColumnBackButton from '../../components/column_back_button';
 import ImmutablePureComponent from 'react-immutable-pure-component';
 
 const mapStateToProps = (state, props) => ({
-  accountIds: state.getIn(['user_lists', 'reblogged_by', Number(props.params.statusId)]),
+  accountIds: state.getIn(['user_lists', 'reblogged_by', props.params.statusId]),
 });
 
 @connect(mapStateToProps)
@@ -24,12 +24,12 @@ export default class Reblogs extends ImmutablePureComponent {
   };
 
   componentWillMount () {
-    this.props.dispatch(fetchReblogs(Number(this.props.params.statusId)));
+    this.props.dispatch(fetchReblogs(this.props.params.statusId));
   }
 
   componentWillReceiveProps(nextProps) {
     if (nextProps.params.statusId !== this.props.params.statusId && nextProps.params.statusId) {
-      this.props.dispatch(fetchReblogs(Number(nextProps.params.statusId)));
+      this.props.dispatch(fetchReblogs(nextProps.params.statusId));
     }
   }
 
diff --git a/app/javascript/mastodon/features/standalone/compose/index.js b/app/javascript/mastodon/features/standalone/compose/index.js
index 96d07fefb..0d764575f 100644
--- a/app/javascript/mastodon/features/standalone/compose/index.js
+++ b/app/javascript/mastodon/features/standalone/compose/index.js
@@ -2,6 +2,7 @@ import React from 'react';
 import ComposeFormContainer from '../../compose/containers/compose_form_container';
 import NotificationsContainer from '../../ui/containers/notifications_container';
 import LoadingBarContainer from '../../ui/containers/loading_bar_container';
+import ModalContainer from '../../ui/containers/modal_container';
 
 export default class Compose extends React.PureComponent {
 
@@ -10,6 +11,7 @@ export default class Compose extends React.PureComponent {
       <div>
         <ComposeFormContainer />
         <NotificationsContainer />
+        <ModalContainer />
         <LoadingBarContainer className='loading-bar' />
       </div>
     );
diff --git a/app/javascript/mastodon/features/status/components/action_bar.js b/app/javascript/mastodon/features/status/components/action_bar.js
index 4be013be7..3e94f7446 100644
--- a/app/javascript/mastodon/features/status/components/action_bar.js
+++ b/app/javascript/mastodon/features/status/components/action_bar.js
@@ -36,7 +36,7 @@ export default class ActionBar extends React.PureComponent {
     onReport: PropTypes.func,
     onPin: PropTypes.func,
     onEmbed: PropTypes.func,
-    me: PropTypes.number.isRequired,
+    me: PropTypes.string.isRequired,
     intl: PropTypes.object.isRequired,
   };
 
diff --git a/app/javascript/mastodon/features/status/components/card.js b/app/javascript/mastodon/features/status/components/card.js
index 6b13e15cc..41c4300d3 100644
--- a/app/javascript/mastodon/features/status/components/card.js
+++ b/app/javascript/mastodon/features/status/components/card.js
@@ -1,4 +1,5 @@
 import React from 'react';
+import PropTypes from 'prop-types';
 import ImmutablePropTypes from 'react-immutable-proptypes';
 import punycode from 'punycode';
 import classnames from 'classnames';
@@ -22,10 +23,15 @@ export default class Card extends React.PureComponent {
 
   static propTypes = {
     card: ImmutablePropTypes.map,
+    maxDescription: PropTypes.number,
+  };
+
+  static defaultProps = {
+    maxDescription: 50,
   };
 
   renderLink () {
-    const { card } = this.props;
+    const { card, maxDescription } = this.props;
 
     let image    = '';
     let provider = card.get('provider_name');
@@ -52,7 +58,7 @@ export default class Card extends React.PureComponent {
 
         <div className='status-card__content'>
           <strong className='status-card__title' title={card.get('title')}>{card.get('title')}</strong>
-          <p className='status-card__description'>{(card.get('description') || '').substring(0, 50)}</p>
+          <p className='status-card__description'>{(card.get('description') || '').substring(0, maxDescription)}</p>
           <span className='status-card__host'>{provider}</span>
         </div>
       </a>
diff --git a/app/javascript/mastodon/features/status/components/detailed_status.js b/app/javascript/mastodon/features/status/components/detailed_status.js
index b4979c603..8cd5abd3f 100644
--- a/app/javascript/mastodon/features/status/components/detailed_status.js
+++ b/app/javascript/mastodon/features/status/components/detailed_status.js
@@ -11,6 +11,7 @@ import Link from 'react-router-dom/Link';
 import { FormattedDate, FormattedNumber } from 'react-intl';
 import CardContainer from '../containers/card_container';
 import ImmutablePureComponent from 'react-immutable-pure-component';
+import Video from '../../video';
 import VisibilityIcon from '../../../../glitch/components/status/visibility_icon';
 
 export default class DetailedStatus extends ImmutablePureComponent {
@@ -36,6 +37,10 @@ export default class DetailedStatus extends ImmutablePureComponent {
     e.stopPropagation();
   }
 
+  handleOpenVideo = startTime => {
+    this.props.onOpenVideo(this.props.status.getIn(['media_attachments', 0]), startTime);
+  }
+
   render () {
     const status = this.props.status.get('reblog') ? this.props.status.get('reblog') : this.props.status;
     const { settings } = this.props;
diff --git a/app/javascript/mastodon/features/status/index.js b/app/javascript/mastodon/features/status/index.js
index 03010cf0a..fc45d5f21 100644
--- a/app/javascript/mastodon/features/status/index.js
+++ b/app/javascript/mastodon/features/status/index.js
@@ -38,10 +38,10 @@ const makeMapStateToProps = () => {
   const getStatus = makeGetStatus();
 
   const mapStateToProps = (state, props) => ({
-    status: getStatus(state, Number(props.params.statusId)),
+    status: getStatus(state, props.params.statusId),
     settings: state.get('local_settings'),
-    ancestorsIds: state.getIn(['contexts', 'ancestors', Number(props.params.statusId)]),
-    descendantsIds: state.getIn(['contexts', 'descendants', Number(props.params.statusId)]),
+    ancestorsIds: state.getIn(['contexts', 'ancestors', props.params.statusId]),
+    descendantsIds: state.getIn(['contexts', 'descendants', props.params.statusId]),
     me: state.getIn(['meta', 'me']),
     boostModal: state.getIn(['meta', 'boost_modal']),
     deleteModal: state.getIn(['meta', 'delete_modal']),
@@ -66,7 +66,7 @@ export default class Status extends ImmutablePureComponent {
     settings: ImmutablePropTypes.map.isRequired,
     ancestorsIds: ImmutablePropTypes.list,
     descendantsIds: ImmutablePropTypes.list,
-    me: PropTypes.number,
+    me: PropTypes.string,
     boostModal: PropTypes.bool,
     deleteModal: PropTypes.bool,
     autoPlayGif: PropTypes.bool,
@@ -74,12 +74,12 @@ export default class Status extends ImmutablePureComponent {
   };
 
   componentWillMount () {
-    this.props.dispatch(fetchStatus(Number(this.props.params.statusId)));
+    this.props.dispatch(fetchStatus(this.props.params.statusId));
   }
 
   componentWillReceiveProps (nextProps) {
     if (nextProps.params.statusId !== this.props.params.statusId && nextProps.params.statusId) {
-      this.props.dispatch(fetchStatus(Number(nextProps.params.statusId)));
+      this.props.dispatch(fetchStatus(nextProps.params.statusId));
     }
   }
 
diff --git a/app/javascript/mastodon/features/ui/components/actions_modal.js b/app/javascript/mastodon/features/ui/components/actions_modal.js
index 3d40033be..79a5a20ef 100644
--- a/app/javascript/mastodon/features/ui/components/actions_modal.js
+++ b/app/javascript/mastodon/features/ui/components/actions_modal.js
@@ -1,32 +1,35 @@
 import React from 'react';
 import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
 import ImmutablePureComponent from 'react-immutable-pure-component';
 import StatusContent from '../../../components/status_content';
 import Avatar from '../../../components/avatar';
 import RelativeTimestamp from '../../../components/relative_timestamp';
 import DisplayName from '../../../components/display_name';
 import IconButton from '../../../components/icon_button';
+import classNames from 'classnames';
 
 export default class ActionsModal extends ImmutablePureComponent {
 
   static propTypes = {
+    status: ImmutablePropTypes.map,
     actions: PropTypes.array,
     onClick: PropTypes.func,
   };
 
   renderAction = (action, i) => {
     if (action === null) {
-      return <li key={`sep-${i}`} className='dropdown__sep' />;
+      return <li key={`sep-${i}`} className='dropdown-menu__separator' />;
     }
 
     const { icon = null, text, meta = null, active = false, href = '#' } = action;
 
     return (
       <li key={`${text}-${i}`}>
-        <a href={href} target='_blank' rel='noopener' onClick={this.props.onClick} data-index={i} className={active && 'active'}>
+        <a href={href} target='_blank' rel='noopener' onClick={this.props.onClick} data-index={i} className={classNames({ active })}>
           {icon && <IconButton title={text} icon={icon} role='presentation' tabIndex='-1' />}
           <div>
-            <div>{text}</div>
+            <div className={classNames({ 'actions-modal__item-label': !!meta })}>{text}</div>
             <div>{meta}</div>
           </div>
         </a>
diff --git a/app/javascript/mastodon/features/ui/components/column_loading.js b/app/javascript/mastodon/features/ui/components/column_loading.js
index 1c4058926..9503a7a1a 100644
--- a/app/javascript/mastodon/features/ui/components/column_loading.js
+++ b/app/javascript/mastodon/features/ui/components/column_loading.js
@@ -3,17 +3,28 @@ import PropTypes from 'prop-types';
 
 import Column from '../../../components/column';
 import ColumnHeader from '../../../components/column_header';
+import ImmutablePureComponent from 'react-immutable-pure-component';
 
-const ColumnLoading = ({ title = '', icon = ' ' }) => (
-  <Column>
-    <ColumnHeader icon={icon} title={title} multiColumn={false} focusable={false} />
-    <div className='scrollable' />
-  </Column>
-);
+export default class ColumnLoading extends ImmutablePureComponent {
 
-ColumnLoading.propTypes = {
-  title: PropTypes.node,
-  icon: PropTypes.string,
-};
+  static propTypes = {
+    title: PropTypes.oneOfType([PropTypes.node, PropTypes.string]),
+    icon: PropTypes.string,
+  };
 
-export default ColumnLoading;
+  static defaultProps = {
+    title: '',
+    icon: '',
+  };
+
+  render() {
+    let { title, icon } = this.props;
+    return (
+      <Column>
+        <ColumnHeader icon={icon} title={title} multiColumn={false} focusable={false} />
+        <div className='scrollable' />
+      </Column>
+    );
+  }
+
+}
diff --git a/app/javascript/mastodon/features/ui/components/columns_area.js b/app/javascript/mastodon/features/ui/components/columns_area.js
index 539af8ce3..5610095b9 100644
--- a/app/javascript/mastodon/features/ui/components/columns_area.js
+++ b/app/javascript/mastodon/features/ui/components/columns_area.js
@@ -78,7 +78,7 @@ export default class ColumnsArea extends ImmutablePureComponent {
 
   handleChildrenContentChange() {
     if (!this.props.singleColumn) {
-      scrollRight(this.node, this.node.scrollWidth - window.innerWidth);
+      this._interruptScrollAnimation = scrollRight(this.node, this.node.scrollWidth - window.innerWidth);
     }
   }
 
diff --git a/app/javascript/mastodon/features/ui/components/onboarding_modal.js b/app/javascript/mastodon/features/ui/components/onboarding_modal.js
index 2facf9c44..daf6b485c 100644
--- a/app/javascript/mastodon/features/ui/components/onboarding_modal.js
+++ b/app/javascript/mastodon/features/ui/components/onboarding_modal.js
@@ -10,7 +10,10 @@ import ComposeForm from '../../compose/components/compose_form';
 import Search from '../../compose/components/search';
 import NavigationBar from '../../compose/components/navigation_bar';
 import ColumnHeader from './column_header';
-import { List as ImmutableList } from 'immutable';
+import {
+  List as ImmutableList,
+  Map as ImmutableMap,
+} from 'immutable';
 
 const noop = () => { };
 
@@ -59,7 +62,9 @@ const PageTwo = ({ me }) => (
         onClearSuggestions={noop}
         onFetchSuggestions={noop}
         onSuggestionSelected={noop}
+        onPrivacyChange={noop}
         showSearch
+        settings={ImmutableMap.of('side_arm', 'none')}
       />
     </div>
 
diff --git a/app/javascript/mastodon/features/ui/components/video_modal.js b/app/javascript/mastodon/features/ui/components/video_modal.js
index 9a9a49dfb..867c73ed5 100644
--- a/app/javascript/mastodon/features/ui/components/video_modal.js
+++ b/app/javascript/mastodon/features/ui/components/video_modal.js
@@ -1,35 +1,29 @@
 import React from 'react';
 import ImmutablePropTypes from 'react-immutable-proptypes';
 import PropTypes from 'prop-types';
-import ExtendedVideoPlayer from '../../../components/extended_video_player';
-import { defineMessages, injectIntl } from 'react-intl';
-import IconButton from '../../../components/icon_button';
+import Video from '../../video';
 import ImmutablePureComponent from 'react-immutable-pure-component';
 
-const messages = defineMessages({
-  close: { id: 'lightbox.close', defaultMessage: 'Close' },
-});
-
-@injectIntl
 export default class VideoModal extends ImmutablePureComponent {
 
   static propTypes = {
     media: ImmutablePropTypes.map.isRequired,
     time: PropTypes.number,
     onClose: PropTypes.func.isRequired,
-    intl: PropTypes.object.isRequired,
   };
 
   render () {
-    const { media, intl, time, onClose } = this.props;
-
-    const url = media.get('url');
+    const { media, time, onClose } = this.props;
 
     return (
       <div className='modal-root__modal media-modal'>
         <div>
-          <div className='media-modal__close'><IconButton title={intl.formatMessage(messages.close)} icon='times' overlay onClick={onClose} /></div>
-          <ExtendedVideoPlayer src={url} muted={false} controls time={time} />
+          <Video
+            preview={media.get('preview_url')}
+            src={media.get('url')}
+            startTime={time}
+            onCloseVideo={onClose}
+          />
         </div>
       </div>
     );
diff --git a/app/javascript/mastodon/features/ui/index.js b/app/javascript/mastodon/features/ui/index.js
index 7d12210bb..73bd23432 100644
--- a/app/javascript/mastodon/features/ui/index.js
+++ b/app/javascript/mastodon/features/ui/index.js
@@ -11,7 +11,7 @@ import { debounce } from 'lodash';
 import { uploadCompose } from '../../actions/compose';
 import { refreshHomeTimeline } from '../../actions/timelines';
 import { refreshNotifications } from '../../actions/notifications';
-import { clearStatusesHeight } from '../../actions/statuses';
+import { clearHeight } from '../../actions/height_cache';
 import { WrappedSwitch, WrappedRoute } from './util/react_router_helpers';
 import UploadArea from './components/upload_area';
 import ColumnsAreaContainer from './containers/columns_area_container';
@@ -57,7 +57,7 @@ export default class UI extends React.PureComponent {
 
   static contextTypes = {
     router: PropTypes.object.isRequired,
-  }
+  };
 
   static propTypes = {
     dispatch: PropTypes.func.isRequired,
@@ -77,7 +77,7 @@ export default class UI extends React.PureComponent {
 
   handleResize = debounce(() => {
     // The cached heights are no longer accurate, invalidate
-    this.props.dispatch(clearStatusesHeight());
+    this.props.dispatch(clearHeight());
 
     this.setState({ width: window.innerWidth });
   }, 500, {
@@ -193,14 +193,18 @@ export default class UI extends React.PureComponent {
     document.removeEventListener('dragend', this.handleDragEnd);
   }
 
-  setRef = (c) => {
+  setRef = c => {
     this.node = c;
   }
 
-  setColumnsAreaRef = (c) => {
+  setColumnsAreaRef = c => {
     this.columnsAreaNode = c.getWrappedInstance().getWrappedInstance();
   }
 
+  setOverlayRef = c => {
+    this.overlay = c;
+  }
+
   render () {
     const { width, draggingOver } = this.state;
     const { children, layout, isWide, navbarUnder } = this.props;
diff --git a/app/javascript/mastodon/features/ui/util/async-components.js b/app/javascript/mastodon/features/ui/util/async-components.js
index 2f5c52e9e..5d640810f 100644
--- a/app/javascript/mastodon/features/ui/util/async-components.js
+++ b/app/javascript/mastodon/features/ui/util/async-components.js
@@ -1,7 +1,3 @@
-export function EmojiPicker () {
-  return import(/* webpackChunkName: "emojione_picker" */'emojione-picker');
-}
-
 export function Compose () {
   return import(/* webpackChunkName: "features/compose" */'../../compose');
 }
@@ -109,6 +105,10 @@ export function VideoPlayer () {
   return import(/* webpackChunkName: "status/video_player" */'../../../components/video_player');
 }
 
+export function Video () {
+  return import(/* webpackChunkName: "features/video" */'../../video');
+}
+
 export function EmbedModal () {
   return import(/* webpackChunkName: "modals/embed_modal" */'../components/embed_modal');
 }
diff --git a/app/javascript/mastodon/features/video/index.js b/app/javascript/mastodon/features/video/index.js
new file mode 100644
index 000000000..f228e434b
--- /dev/null
+++ b/app/javascript/mastodon/features/video/index.js
@@ -0,0 +1,304 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import { throttle } from 'lodash';
+import classNames from 'classnames';
+
+const messages = defineMessages({
+  play: { id: 'video.play', defaultMessage: 'Play' },
+  pause: { id: 'video.pause', defaultMessage: 'Pause' },
+  mute: { id: 'video.mute', defaultMessage: 'Mute sound' },
+  unmute: { id: 'video.unmute', defaultMessage: 'Unmute sound' },
+  hide: { id: 'video.hide', defaultMessage: 'Hide video' },
+  expand: { id: 'video.expand', defaultMessage: 'Expand video' },
+  close: { id: 'video.close', defaultMessage: 'Close video' },
+  fullscreen: { id: 'video.fullscreen', defaultMessage: 'Full screen' },
+  exit_fullscreen: { id: 'video.exit_fullscreen', defaultMessage: 'Exit full screen' },
+});
+
+const findElementPosition = el => {
+  let box;
+
+  if (el.getBoundingClientRect && el.parentNode) {
+    box = el.getBoundingClientRect();
+  }
+
+  if (!box) {
+    return {
+      left: 0,
+      top: 0,
+    };
+  }
+
+  const docEl = document.documentElement;
+  const body  = document.body;
+
+  const clientLeft = docEl.clientLeft || body.clientLeft || 0;
+  const scrollLeft = window.pageXOffset || body.scrollLeft;
+  const left       = (box.left + scrollLeft) - clientLeft;
+
+  const clientTop = docEl.clientTop || body.clientTop || 0;
+  const scrollTop = window.pageYOffset || body.scrollTop;
+  const top       = (box.top + scrollTop) - clientTop;
+
+  return {
+    left: Math.round(left),
+    top: Math.round(top),
+  };
+};
+
+const getPointerPosition = (el, event) => {
+  const position = {};
+  const box = findElementPosition(el);
+  const boxW = el.offsetWidth;
+  const boxH = el.offsetHeight;
+  const boxY = box.top;
+  const boxX = box.left;
+
+  let pageY = event.pageY;
+  let pageX = event.pageX;
+
+  if (event.changedTouches) {
+    pageX = event.changedTouches[0].pageX;
+    pageY = event.changedTouches[0].pageY;
+  }
+
+  position.y = Math.max(0, Math.min(1, ((boxY - pageY) + boxH) / boxH));
+  position.x = Math.max(0, Math.min(1, (pageX - boxX) / boxW));
+
+  return position;
+};
+
+const isFullscreen = () => document.fullscreenElement ||
+  document.webkitFullscreenElement ||
+  document.mozFullScreenElement ||
+  document.msFullscreenElement;
+
+const exitFullscreen = () => {
+  if (document.exitFullscreen) {
+    document.exitFullscreen();
+  } else if (document.webkitExitFullscreen) {
+    document.webkitExitFullscreen();
+  } else if (document.mozCancelFullScreen) {
+    document.mozCancelFullScreen();
+  } else if (document.msExitFullscreen) {
+    document.msExitFullscreen();
+  }
+};
+
+const requestFullscreen = el => {
+  if (el.requestFullscreen) {
+    el.requestFullscreen();
+  } else if (el.webkitRequestFullscreen) {
+    el.webkitRequestFullscreen();
+  } else if (el.mozRequestFullScreen) {
+    el.mozRequestFullScreen();
+  } else if (el.msRequestFullscreen) {
+    el.msRequestFullscreen();
+  }
+};
+
+@injectIntl
+export default class Video extends React.PureComponent {
+
+  static propTypes = {
+    preview: PropTypes.string,
+    src: PropTypes.string.isRequired,
+    width: PropTypes.number,
+    height: PropTypes.number,
+    sensitive: PropTypes.bool,
+    startTime: PropTypes.number,
+    onOpenVideo: PropTypes.func,
+    onCloseVideo: PropTypes.func,
+    intl: PropTypes.object.isRequired,
+  };
+
+  state = {
+    progress: 0,
+    paused: true,
+    dragging: false,
+    fullscreen: false,
+    hovered: false,
+    muted: false,
+    revealed: !this.props.sensitive,
+  };
+
+  setPlayerRef = c => {
+    this.player = c;
+  }
+
+  setVideoRef = c => {
+    this.video = c;
+  }
+
+  setSeekRef = c => {
+    this.seek = c;
+  }
+
+  handlePlay = () => {
+    this.setState({ paused: false });
+  }
+
+  handlePause = () => {
+    this.setState({ paused: true });
+  }
+
+  handleTimeUpdate = () => {
+    this.setState({ progress: 100 * (this.video.currentTime / this.video.duration) });
+  }
+
+  handleMouseDown = e => {
+    document.addEventListener('mousemove', this.handleMouseMove, true);
+    document.addEventListener('mouseup', this.handleMouseUp, true);
+    document.addEventListener('touchmove', this.handleMouseMove, true);
+    document.addEventListener('touchend', this.handleMouseUp, true);
+
+    this.setState({ dragging: true });
+    this.video.pause();
+    this.handleMouseMove(e);
+  }
+
+  handleMouseUp = () => {
+    document.removeEventListener('mousemove', this.handleMouseMove, true);
+    document.removeEventListener('mouseup', this.handleMouseUp, true);
+    document.removeEventListener('touchmove', this.handleMouseMove, true);
+    document.removeEventListener('touchend', this.handleMouseUp, true);
+
+    this.setState({ dragging: false });
+    this.video.play();
+  }
+
+  handleMouseMove = throttle(e => {
+    const { x } = getPointerPosition(this.seek, e);
+    this.video.currentTime = this.video.duration * x;
+    this.setState({ progress: x * 100 });
+  }, 60);
+
+  togglePlay = () => {
+    if (this.state.paused) {
+      this.video.play();
+    } else {
+      this.video.pause();
+    }
+  }
+
+  toggleFullscreen = () => {
+    if (isFullscreen()) {
+      exitFullscreen();
+    } else {
+      requestFullscreen(this.player);
+    }
+  }
+
+  componentDidMount () {
+    document.addEventListener('fullscreenchange', this.handleFullscreenChange, true);
+    document.addEventListener('webkitfullscreenchange', this.handleFullscreenChange, true);
+    document.addEventListener('mozfullscreenchange', this.handleFullscreenChange, true);
+    document.addEventListener('MSFullscreenChange', this.handleFullscreenChange, true);
+  }
+
+  componentWillUnmount () {
+    document.removeEventListener('fullscreenchange', this.handleFullscreenChange, true);
+    document.removeEventListener('webkitfullscreenchange', this.handleFullscreenChange, true);
+    document.removeEventListener('mozfullscreenchange', this.handleFullscreenChange, true);
+    document.removeEventListener('MSFullscreenChange', this.handleFullscreenChange, true);
+  }
+
+  handleFullscreenChange = () => {
+    this.setState({ fullscreen: isFullscreen() });
+  }
+
+  handleMouseEnter = () => {
+    this.setState({ hovered: true });
+  }
+
+  handleMouseLeave = () => {
+    this.setState({ hovered: false });
+  }
+
+  toggleMute = () => {
+    this.video.muted = !this.video.muted;
+    this.setState({ muted: this.video.muted });
+  }
+
+  toggleReveal = () => {
+    if (this.state.revealed) {
+      this.video.pause();
+    }
+
+    this.setState({ revealed: !this.state.revealed });
+  }
+
+  handleLoadedData = () => {
+    if (this.props.startTime) {
+      this.video.currentTime = this.props.startTime;
+      this.video.play();
+    }
+  }
+
+  handleOpenVideo = () => {
+    this.video.pause();
+    this.props.onOpenVideo(this.video.currentTime);
+  }
+
+  handleCloseVideo = () => {
+    this.video.pause();
+    this.props.onCloseVideo();
+  }
+
+  render () {
+    const { preview, src, width, height, startTime, onOpenVideo, onCloseVideo, intl } = this.props;
+    const { progress, dragging, paused, fullscreen, hovered, muted, revealed } = this.state;
+
+    return (
+      <div className={classNames('video-player', { inactive: !revealed, inline: width && height && !fullscreen, fullscreen })} style={{ width, height }} ref={this.setPlayerRef} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}>
+        <video
+          ref={this.setVideoRef}
+          src={src}
+          poster={preview}
+          preload={!!startTime}
+          loop
+          role='button'
+          tabIndex='0'
+          width={width}
+          height={height}
+          onClick={this.togglePlay}
+          onPlay={this.handlePlay}
+          onPause={this.handlePause}
+          onTimeUpdate={this.handleTimeUpdate}
+          onLoadedData={this.handleLoadedData}
+        />
+
+        <button className={classNames('video-player__spoiler', { active: !revealed })} onClick={this.toggleReveal}>
+          <span className='video-player__spoiler__title'><FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' /></span>
+          <span className='video-player__spoiler__subtitle'><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span>
+        </button>
+
+        <div className={classNames('video-player__controls', { active: paused || hovered })}>
+          <div className='video-player__seek' onMouseDown={this.handleMouseDown} ref={this.setSeekRef}>
+            <div className='video-player__seek__progress' style={{ width: `${progress}%` }} />
+
+            <span
+              className={classNames('video-player__seek__handle', { active: dragging })}
+              tabIndex='0'
+              style={{ left: `${progress}%` }}
+            />
+          </div>
+
+          <div className='video-player__buttons left'>
+            <button aria-label={intl.formatMessage(paused ? messages.play : messages.pause)} onClick={this.togglePlay}><i className={classNames('fa fa-fw', { 'fa-play': paused, 'fa-pause': !paused })} /></button>
+            <button aria-label={intl.formatMessage(muted ? messages.unmute : messages.mute)} onClick={this.toggleMute}><i className={classNames('fa fa-fw', { 'fa-volume-off': muted, 'fa-volume-up': !muted })} /></button>
+            {!onCloseVideo && <button aria-label={intl.formatMessage(messages.hide)} onClick={this.toggleReveal}><i className='fa fa-fw fa-eye' /></button>}
+          </div>
+
+          <div className='video-player__buttons right'>
+            {(!fullscreen && onOpenVideo) && <button aria-label={intl.formatMessage(messages.expand)} onClick={this.handleOpenVideo}><i className='fa fa-fw fa-expand' /></button>}
+            {onCloseVideo && <button aria-label={intl.formatMessage(messages.close)} onClick={this.handleCloseVideo}><i className='fa fa-fw fa-times' /></button>}
+            <button aria-label={intl.formatMessage(fullscreen ? messages.exit_fullscreen : messages.fullscreen)} onClick={this.toggleFullscreen}><i className={classNames('fa fa-fw', { 'fa-arrows-alt': !fullscreen, 'fa-compress': fullscreen })} /></button>
+          </div>
+        </div>
+      </div>
+    );
+  }
+
+}