about summary refs log tree commit diff
path: root/app/javascript
diff options
context:
space:
mode:
Diffstat (limited to 'app/javascript')
-rw-r--r--app/javascript/mastodon/actions/compose.js84
-rw-r--r--app/javascript/mastodon/actions/emojis.js14
-rw-r--r--app/javascript/mastodon/actions/settings.js18
-rw-r--r--app/javascript/mastodon/actions/timelines.js20
-rw-r--r--app/javascript/mastodon/components/autosuggest_emoji.js11
-rw-r--r--app/javascript/mastodon/components/autosuggest_textarea.js14
-rw-r--r--app/javascript/mastodon/components/column_header.js4
-rw-r--r--app/javascript/mastodon/components/dropdown_menu.js5
-rw-r--r--app/javascript/mastodon/components/extended_video_player.js14
-rw-r--r--app/javascript/mastodon/components/intersection_observer_article.js37
-rw-r--r--app/javascript/mastodon/components/media_gallery.js44
-rw-r--r--app/javascript/mastodon/components/relative_timestamp.js113
-rw-r--r--app/javascript/mastodon/components/scrollable_list.js39
-rw-r--r--app/javascript/mastodon/components/status.js116
-rw-r--r--app/javascript/mastodon/components/status_content.js4
-rw-r--r--app/javascript/mastodon/components/status_list.js31
-rw-r--r--app/javascript/mastodon/components/video_player.js207
-rw-r--r--app/javascript/mastodon/containers/mastodon.js5
-rw-r--r--app/javascript/mastodon/containers/timeline_container.js14
-rw-r--r--app/javascript/mastodon/emoji.js72
-rw-r--r--app/javascript/mastodon/emojione_light.js38
-rw-r--r--app/javascript/mastodon/features/account/components/action_bar.js2
-rw-r--r--app/javascript/mastodon/features/compose/components/compose_form.js5
-rw-r--r--app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js100
-rw-r--r--app/javascript/mastodon/features/compose/components/privacy_dropdown.js155
-rw-r--r--app/javascript/mastodon/features/compose/components/search.js55
-rw-r--r--app/javascript/mastodon/features/compose/components/search_results.js2
-rw-r--r--app/javascript/mastodon/features/compose/components/upload.js96
-rw-r--r--app/javascript/mastodon/features/compose/components/upload_form.js44
-rw-r--r--app/javascript/mastodon/features/compose/components/warning.js12
-rw-r--r--app/javascript/mastodon/features/compose/containers/emoji_picker_dropdown_container.js79
-rw-r--r--app/javascript/mastodon/features/compose/containers/upload_container.js21
-rw-r--r--app/javascript/mastodon/features/compose/containers/upload_form_container.js13
-rw-r--r--app/javascript/mastodon/features/compose/index.js2
-rw-r--r--app/javascript/mastodon/features/emoji/emoji.js77
-rw-r--r--app/javascript/mastodon/features/emoji/emoji_compressed.js92
-rw-r--r--app/javascript/mastodon/features/emoji/emoji_map.json (renamed from app/javascript/mastodon/emoji_map.json)0
-rw-r--r--app/javascript/mastodon/features/emoji/emoji_mart_data_light.js41
-rw-r--r--app/javascript/mastodon/features/emoji/emoji_mart_search_light.js157
-rw-r--r--app/javascript/mastodon/features/emoji/emoji_picker.js7
-rw-r--r--app/javascript/mastodon/features/emoji/emoji_unicode_mapping_light.js35
-rw-r--r--app/javascript/mastodon/features/emoji/emoji_utils.js258
-rw-r--r--app/javascript/mastodon/features/emoji/unicode_to_filename.js26
-rw-r--r--app/javascript/mastodon/features/emoji/unicode_to_unified_name.js17
-rw-r--r--app/javascript/mastodon/features/home_timeline/index.js2
-rw-r--r--app/javascript/mastodon/features/notifications/components/notification.js115
-rw-r--r--app/javascript/mastodon/features/notifications/containers/notification_container.js9
-rw-r--r--app/javascript/mastodon/features/notifications/index.js28
-rw-r--r--app/javascript/mastodon/features/standalone/hashtag_timeline/index.js70
-rw-r--r--app/javascript/mastodon/features/status/components/card.js19
-rw-r--r--app/javascript/mastodon/features/status/components/detailed_status.js2
-rw-r--r--app/javascript/mastodon/features/status/index.js153
-rw-r--r--app/javascript/mastodon/features/ui/components/column_link.js2
-rw-r--r--app/javascript/mastodon/features/ui/components/media_modal.js31
-rw-r--r--app/javascript/mastodon/features/ui/components/modal_root.js65
-rw-r--r--app/javascript/mastodon/features/ui/components/tabs_bar.js2
-rw-r--r--app/javascript/mastodon/features/ui/components/video_modal.js1
-rw-r--r--app/javascript/mastodon/features/ui/index.js227
-rw-r--r--app/javascript/mastodon/features/ui/util/async-components.js8
-rw-r--r--app/javascript/mastodon/features/ui/util/fullscreen.js46
-rw-r--r--app/javascript/mastodon/features/ui/util/react_router_helpers.js3
-rw-r--r--app/javascript/mastodon/features/video/index.js46
-rw-r--r--app/javascript/mastodon/locales/ar.json106
-rw-r--r--app/javascript/mastodon/locales/bg.json12
-rw-r--r--app/javascript/mastodon/locales/ca.json12
-rw-r--r--app/javascript/mastodon/locales/de.json146
-rw-r--r--app/javascript/mastodon/locales/defaultMessages.json94
-rw-r--r--app/javascript/mastodon/locales/en.json12
-rw-r--r--app/javascript/mastodon/locales/eo.json12
-rw-r--r--app/javascript/mastodon/locales/es.json12
-rw-r--r--app/javascript/mastodon/locales/fa.json12
-rw-r--r--app/javascript/mastodon/locales/fi.json12
-rw-r--r--app/javascript/mastodon/locales/fr.json18
-rw-r--r--app/javascript/mastodon/locales/he.json12
-rw-r--r--app/javascript/mastodon/locales/hr.json12
-rw-r--r--app/javascript/mastodon/locales/hu.json12
-rw-r--r--app/javascript/mastodon/locales/id.json12
-rw-r--r--app/javascript/mastodon/locales/io.json12
-rw-r--r--app/javascript/mastodon/locales/it.json12
-rw-r--r--app/javascript/mastodon/locales/ja.json25
-rw-r--r--app/javascript/mastodon/locales/ko.json12
-rw-r--r--app/javascript/mastodon/locales/nl.json12
-rw-r--r--app/javascript/mastodon/locales/no.json12
-rw-r--r--app/javascript/mastodon/locales/oc.json12
-rw-r--r--app/javascript/mastodon/locales/pl.json22
-rw-r--r--app/javascript/mastodon/locales/pt-BR.json12
-rw-r--r--app/javascript/mastodon/locales/pt.json12
-rw-r--r--app/javascript/mastodon/locales/ru.json12
-rw-r--r--app/javascript/mastodon/locales/th.json12
-rw-r--r--app/javascript/mastodon/locales/tr.json12
-rw-r--r--app/javascript/mastodon/locales/uk.json12
-rw-r--r--app/javascript/mastodon/locales/zh-CN.json12
-rw-r--r--app/javascript/mastodon/locales/zh-HK.json12
-rw-r--r--app/javascript/mastodon/locales/zh-TW.json12
-rw-r--r--app/javascript/mastodon/performance.js4
-rw-r--r--app/javascript/mastodon/reducers/accounts.js2
-rw-r--r--app/javascript/mastodon/reducers/compose.js21
-rw-r--r--app/javascript/mastodon/reducers/contexts.js28
-rw-r--r--app/javascript/mastodon/reducers/custom_emojis.js6
-rw-r--r--app/javascript/mastodon/reducers/notifications.js6
-rw-r--r--app/javascript/mastodon/reducers/settings.js29
-rw-r--r--app/javascript/mastodon/reducers/statuses.js9
-rw-r--r--app/javascript/packs/about.js6
-rw-r--r--app/javascript/packs/public.js2
-rw-r--r--app/javascript/styles/about.scss91
-rw-r--r--app/javascript/styles/accounts.scss17
-rw-r--r--app/javascript/styles/basics.scss26
-rw-r--r--app/javascript/styles/components.scss256
-rw-r--r--app/javascript/styles/forms.scss12
-rw-r--r--app/javascript/styles/rtl.scss18
110 files changed, 3119 insertions, 1166 deletions
diff --git a/app/javascript/mastodon/actions/compose.js b/app/javascript/mastodon/actions/compose.js
index 20cb09f58..8d035e82f 100644
--- a/app/javascript/mastodon/actions/compose.js
+++ b/app/javascript/mastodon/actions/compose.js
@@ -1,5 +1,7 @@
 import api from '../api';
-import { emojiIndex } from 'emoji-mart';
+import { throttle } from 'lodash';
+import { search as emojiSearch } from '../features/emoji/emoji_mart_search_light';
+import { useEmoji } from './emojis';
 
 import {
   updateTimeline,
@@ -15,6 +17,7 @@ export const COMPOSE_SUBMIT_FAIL     = 'COMPOSE_SUBMIT_FAIL';
 export const COMPOSE_REPLY           = 'COMPOSE_REPLY';
 export const COMPOSE_REPLY_CANCEL    = 'COMPOSE_REPLY_CANCEL';
 export const COMPOSE_MENTION         = 'COMPOSE_MENTION';
+export const COMPOSE_RESET           = 'COMPOSE_RESET';
 export const COMPOSE_UPLOAD_REQUEST  = 'COMPOSE_UPLOAD_REQUEST';
 export const COMPOSE_UPLOAD_SUCCESS  = 'COMPOSE_UPLOAD_SUCCESS';
 export const COMPOSE_UPLOAD_FAIL     = 'COMPOSE_UPLOAD_FAIL';
@@ -38,6 +41,10 @@ export const COMPOSE_COMPOSING_CHANGE = 'COMPOSE_COMPOSING_CHANGE';
 
 export const COMPOSE_EMOJI_INSERT = 'COMPOSE_EMOJI_INSERT';
 
+export const COMPOSE_UPLOAD_CHANGE_REQUEST     = 'COMPOSE_UPLOAD_UPDATE_REQUEST';
+export const COMPOSE_UPLOAD_CHANGE_SUCCESS     = 'COMPOSE_UPLOAD_UPDATE_SUCCESS';
+export const COMPOSE_UPLOAD_CHANGE_FAIL        = 'COMPOSE_UPLOAD_UPDATE_FAIL';
+
 export function changeCompose(text) {
   return {
     type: COMPOSE_CHANGE,
@@ -64,6 +71,12 @@ export function cancelReplyCompose() {
   };
 };
 
+export function resetCompose() {
+  return {
+    type: COMPOSE_RESET,
+  };
+};
+
 export function mentionCompose(account, router) {
   return (dispatch, getState) => {
     dispatch({
@@ -168,6 +181,40 @@ export function uploadCompose(files) {
   };
 };
 
+export function changeUploadCompose(id, description) {
+  return (dispatch, getState) => {
+    dispatch(changeUploadComposeRequest());
+
+    api(getState).put(`/api/v1/media/${id}`, { description }).then(response => {
+      dispatch(changeUploadComposeSuccess(response.data));
+    }).catch(error => {
+      dispatch(changeUploadComposeFail(id, error));
+    });
+  };
+};
+
+export function changeUploadComposeRequest() {
+  return {
+    type: COMPOSE_UPLOAD_CHANGE_REQUEST,
+    skipLoading: true,
+  };
+};
+export function changeUploadComposeSuccess(media) {
+  return {
+    type: COMPOSE_UPLOAD_CHANGE_SUCCESS,
+    media: media,
+    skipLoading: true,
+  };
+};
+
+export function changeUploadComposeFail(error) {
+  return {
+    type: COMPOSE_UPLOAD_CHANGE_FAIL,
+    error: error,
+    skipLoading: true,
+  };
+};
+
 export function uploadComposeRequest() {
   return {
     type: COMPOSE_UPLOAD_REQUEST,
@@ -212,23 +259,30 @@ export function clearComposeSuggestions() {
   };
 };
 
+const fetchComposeSuggestionsAccounts = throttle((dispatch, getState, token) => {
+  api(getState).get('/api/v1/accounts/search', {
+    params: {
+      q: token.slice(1),
+      resolve: false,
+      limit: 4,
+    },
+  }).then(response => {
+    dispatch(readyComposeSuggestionsAccounts(token, response.data));
+  });
+}, 200, { leading: true, trailing: true });
+
+const fetchComposeSuggestionsEmojis = (dispatch, getState, token) => {
+  const results = emojiSearch(token.replace(':', ''), { maxResults: 5 });
+  dispatch(readyComposeSuggestionsEmojis(token, results));
+};
+
 export function fetchComposeSuggestions(token) {
   return (dispatch, getState) => {
     if (token[0] === ':') {
-      const results = emojiIndex.search(token.replace(':', ''), { maxResults: 3 });
-      dispatch(readyComposeSuggestionsEmojis(token, results));
-      return;
+      fetchComposeSuggestionsEmojis(dispatch, getState, token);
+    } else {
+      fetchComposeSuggestionsAccounts(dispatch, getState, token);
     }
-
-    api(getState).get('/api/v1/accounts/search', {
-      params: {
-        q: token.slice(1),
-        resolve: false,
-        limit: 4,
-      },
-    }).then(response => {
-      dispatch(readyComposeSuggestionsAccounts(token, response.data));
-    });
   };
 };
 
@@ -255,6 +309,8 @@ export function selectComposeSuggestion(position, token, suggestion) {
     if (typeof suggestion === 'object' && suggestion.id) {
       completion    = suggestion.native || suggestion.colons;
       startPosition = position - 1;
+
+      dispatch(useEmoji(suggestion));
     } else {
       completion    = getState().getIn(['accounts', suggestion, 'acct']);
       startPosition = position;
diff --git a/app/javascript/mastodon/actions/emojis.js b/app/javascript/mastodon/actions/emojis.js
new file mode 100644
index 000000000..7cd9d4b7b
--- /dev/null
+++ b/app/javascript/mastodon/actions/emojis.js
@@ -0,0 +1,14 @@
+import { saveSettings } from './settings';
+
+export const EMOJI_USE = 'EMOJI_USE';
+
+export function useEmoji(emoji) {
+  return dispatch => {
+    dispatch({
+      type: EMOJI_USE,
+      emoji,
+    });
+
+    dispatch(saveSettings());
+  };
+};
diff --git a/app/javascript/mastodon/actions/settings.js b/app/javascript/mastodon/actions/settings.js
index f9d304c96..79adca18c 100644
--- a/app/javascript/mastodon/actions/settings.js
+++ b/app/javascript/mastodon/actions/settings.js
@@ -1,6 +1,8 @@
 import axios from 'axios';
+import { debounce } from 'lodash';
 
 export const SETTING_CHANGE = 'SETTING_CHANGE';
+export const SETTING_SAVE   = 'SETTING_SAVE';
 
 export function changeSetting(key, value) {
   return dispatch => {
@@ -14,10 +16,16 @@ export function changeSetting(key, value) {
   };
 };
 
+const debouncedSave = debounce((dispatch, getState) => {
+  if (getState().getIn(['settings', 'saved'])) {
+    return;
+  }
+
+  const data = getState().get('settings').filter((_, key) => key !== 'saved').toJS();
+
+  axios.put('/api/web/settings', { data }).then(() => dispatch({ type: SETTING_SAVE }));
+}, 5000, { trailing: true });
+
 export function saveSettings() {
-  return (_, getState) => {
-    axios.put('/api/web/settings', {
-      data: getState().get('settings').toJS(),
-    });
-  };
+  return (dispatch, getState) => debouncedSave(dispatch, getState);
 };
diff --git a/app/javascript/mastodon/actions/timelines.js b/app/javascript/mastodon/actions/timelines.js
index 5c0cd93c7..09abe2702 100644
--- a/app/javascript/mastodon/actions/timelines.js
+++ b/app/javascript/mastodon/actions/timelines.js
@@ -17,6 +17,8 @@ export const TIMELINE_SCROLL_TOP = 'TIMELINE_SCROLL_TOP';
 export const TIMELINE_CONNECT    = 'TIMELINE_CONNECT';
 export const TIMELINE_DISCONNECT = 'TIMELINE_DISCONNECT';
 
+export const TIMELINE_CONTEXT_UPDATE = 'CONTEXT_UPDATE';
+
 export function refreshTimelineSuccess(timeline, statuses, skipLoading, next) {
   return {
     type: TIMELINE_REFRESH_SUCCESS,
@@ -30,6 +32,16 @@ export function refreshTimelineSuccess(timeline, statuses, skipLoading, next) {
 export function updateTimeline(timeline, status) {
   return (dispatch, getState) => {
     const references = status.reblog ? getState().get('statuses').filter((item, itemId) => (itemId === status.reblog.id || item.get('reblog') === status.reblog.id)).map((_, itemId) => itemId) : [];
+    const parents = [];
+
+    if (status.in_reply_to_id) {
+      let parent = getState().getIn(['statuses', status.in_reply_to_id]);
+
+      while (parent && parent.get('in_reply_to_id')) {
+        parents.push(parent.get('id'));
+        parent = getState().getIn(['statuses', parent.get('in_reply_to_id')]);
+      }
+    }
 
     dispatch({
       type: TIMELINE_UPDATE,
@@ -37,6 +49,14 @@ export function updateTimeline(timeline, status) {
       status,
       references,
     });
+
+    if (parents.length > 0) {
+      dispatch({
+        type: TIMELINE_CONTEXT_UPDATE,
+        status,
+        references: parents,
+      });
+    }
   };
 };
 
diff --git a/app/javascript/mastodon/components/autosuggest_emoji.js b/app/javascript/mastodon/components/autosuggest_emoji.js
index e2866e8e4..ce4383a60 100644
--- a/app/javascript/mastodon/components/autosuggest_emoji.js
+++ b/app/javascript/mastodon/components/autosuggest_emoji.js
@@ -1,6 +1,6 @@
 import React from 'react';
 import PropTypes from 'prop-types';
-import { unicodeMapping } from '../emojione_light';
+import unicodeMapping from '../features/emoji/emoji_unicode_mapping_light';
 
 const assetHost = process.env.CDN_HOST || '';
 
@@ -17,8 +17,13 @@ export default class AutosuggestEmoji extends React.PureComponent {
     if (emoji.custom) {
       url = emoji.imageUrl;
     } else {
-      const [ filename ] = unicodeMapping[emoji.native];
-      url = `${assetHost}/emoji/${filename}.svg`;
+      const mapping = unicodeMapping[emoji.native] || unicodeMapping[emoji.native.replace(/\uFE0F$/, '')];
+
+      if (!mapping) {
+        return null;
+      }
+
+      url = `${assetHost}/emoji/${mapping.filename}.svg`;
     }
 
     return (
diff --git a/app/javascript/mastodon/components/autosuggest_textarea.js b/app/javascript/mastodon/components/autosuggest_textarea.js
index 6f725885d..14a8d4c38 100644
--- a/app/javascript/mastodon/components/autosuggest_textarea.js
+++ b/app/javascript/mastodon/components/autosuggest_textarea.js
@@ -125,6 +125,16 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
     this.props.onKeyDown(e);
   }
 
+  onKeyUp = e => {
+    if (e.key === 'Escape' && this.state.suggestionsHidden) {
+      document.querySelector('.ui').parentElement.focus();
+    }
+
+    if (this.props.onKeyUp) {
+      this.props.onKeyUp(e);
+    }
+  }
+
   onBlur = () => {
     this.setState({ suggestionsHidden: true });
   }
@@ -173,7 +183,7 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
   }
 
   render () {
-    const { value, suggestions, disabled, placeholder, onKeyUp, autoFocus } = this.props;
+    const { value, suggestions, disabled, placeholder, autoFocus } = this.props;
     const { suggestionsHidden } = this.state;
     const style = { direction: 'ltr' };
 
@@ -195,7 +205,7 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
             value={value}
             onChange={this.onChange}
             onKeyDown={this.onKeyDown}
-            onKeyUp={onKeyUp}
+            onKeyUp={this.onKeyUp}
             onBlur={this.onBlur}
             onPaste={this.onPaste}
             style={style}
diff --git a/app/javascript/mastodon/components/column_header.js b/app/javascript/mastodon/components/column_header.js
index e0042b055..c47296a51 100644
--- a/app/javascript/mastodon/components/column_header.js
+++ b/app/javascript/mastodon/components/column_header.js
@@ -173,7 +173,7 @@ export default class ColumnHeader extends React.PureComponent {
 
     return (
       <div className={wrapperClassName}>
-        <h1 tabIndex={focusable && '0'} role='button' className={buttonClassName} aria-label={title} onClick={this.handleTitleClick}>
+        <h1 tabIndex={focusable ? 0 : null} role='button' className={buttonClassName} aria-label={title} onClick={this.handleTitleClick}>
           <i className={`fa fa-fw fa-${icon} column-header__icon`} />
           {title}
           <div className='column-header__buttons'>
@@ -200,7 +200,7 @@ export default class ColumnHeader extends React.PureComponent {
           </div>
         ) : null}
 
-        <div className={collapsibleClassName} tabIndex={collapsed && -1} onTransitionEnd={this.handleTransitionEnd}>
+        <div className={collapsibleClassName} tabIndex={collapsed ? -1 : null} onTransitionEnd={this.handleTransitionEnd}>
           <div className='column-header__collapsible-inner'>
             {(!collapsed || animating) && collapsedContent}
           </div>
diff --git a/app/javascript/mastodon/components/dropdown_menu.js b/app/javascript/mastodon/components/dropdown_menu.js
index c0fbcab6d..73ad46bb7 100644
--- a/app/javascript/mastodon/components/dropdown_menu.js
+++ b/app/javascript/mastodon/components/dropdown_menu.js
@@ -2,8 +2,9 @@ import React from 'react';
 import PropTypes from 'prop-types';
 import ImmutablePropTypes from 'react-immutable-proptypes';
 import IconButton from './icon_button';
-import { Overlay } from 'react-overlays';
-import { Motion, spring } from 'react-motion';
+import Overlay from 'react-overlays/lib/Overlay';
+import Motion from 'react-motion/lib/Motion';
+import spring from 'react-motion/lib/spring';
 import detectPassiveEvents from 'detect-passive-events';
 
 const listenerOptions = detectPassiveEvents.hasSupport ? { passive: true } : false;
diff --git a/app/javascript/mastodon/components/extended_video_player.js b/app/javascript/mastodon/components/extended_video_player.js
index 5ab5e9e58..f8bd067e8 100644
--- a/app/javascript/mastodon/components/extended_video_player.js
+++ b/app/javascript/mastodon/components/extended_video_player.js
@@ -5,6 +5,7 @@ export default class ExtendedVideoPlayer extends React.PureComponent {
 
   static propTypes = {
     src: PropTypes.string.isRequired,
+    alt: PropTypes.string,
     width: PropTypes.number,
     height: PropTypes.number,
     time: PropTypes.number,
@@ -31,15 +32,20 @@ export default class ExtendedVideoPlayer extends React.PureComponent {
   }
 
   render () {
+    const { src, muted, controls, alt } = this.props;
+
     return (
       <div className='extended-video-player'>
         <video
           ref={this.setRef}
-          src={this.props.src}
+          src={src}
           autoPlay
-          muted={this.props.muted}
-          controls={this.props.controls}
-          loop={!this.props.controls}
+          role='button'
+          tabIndex='0'
+          aria-label={alt}
+          muted={muted}
+          controls={controls}
+          loop={!controls}
         />
       </div>
     );
diff --git a/app/javascript/mastodon/components/intersection_observer_article.js b/app/javascript/mastodon/components/intersection_observer_article.js
index 575743350..e2ce9ec96 100644
--- a/app/javascript/mastodon/components/intersection_observer_article.js
+++ b/app/javascript/mastodon/components/intersection_observer_article.js
@@ -58,26 +58,31 @@ export default class IntersectionObserverArticle extends React.Component {
   }
 
   handleIntersection = (entry) => {
-    const { onHeightChange, saveHeightKey, id } = this.props;
+    this.entry = entry;
 
-    if (this.node && this.node.children.length !== 0) {
-      // save the height of the fully-rendered element
-      this.height = getRectFromEntry(entry).height;
+    scheduleIdleTask(this.calculateHeight);
+    this.setState(this.updateStateAfterIntersection);
+  }
 
-      if (onHeightChange && saveHeightKey) {
-        onHeightChange(saveHeightKey, id, this.height);
-      }
+  updateStateAfterIntersection = (prevState) => {
+    if (prevState.isIntersecting && !this.entry.isIntersecting) {
+      scheduleIdleTask(this.hideIfNotIntersecting);
     }
+    return {
+      isIntersecting: this.entry.isIntersecting,
+      isHidden: false,
+    };
+  }
 
-    this.setState((prevState) => {
-      if (prevState.isIntersecting && !entry.isIntersecting) {
-        scheduleIdleTask(this.hideIfNotIntersecting);
-      }
-      return {
-        isIntersecting: entry.isIntersecting,
-        isHidden: false,
-      };
-    });
+  calculateHeight = () => {
+    const { onHeightChange, saveHeightKey, id } = this.props;
+    // save the height of the fully-rendered element (this is expensive
+    // on Chrome, where we need to fall back to getBoundingClientRect)
+    this.height = getRectFromEntry(this.entry).height;
+
+    if (onHeightChange && saveHeightKey) {
+      onHeightChange(saveHeightKey, id, this.height);
+    }
   }
 
   hideIfNotIntersecting = () => {
diff --git a/app/javascript/mastodon/components/media_gallery.js b/app/javascript/mastodon/components/media_gallery.js
index 8bc1427d9..83cf8b871 100644
--- a/app/javascript/mastodon/components/media_gallery.js
+++ b/app/javascript/mastodon/components/media_gallery.js
@@ -9,7 +9,6 @@ import IconButton from './icon_button';
 import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
 import { isIOS } from '../is_mobile';
 import classNames from 'classnames';
-import sizeMe from 'react-sizeme';
 
 const messages = defineMessages({
   toggle_visible: { id: 'media_gallery.toggle_visible', defaultMessage: 'Toggle visibility' },
@@ -139,7 +138,7 @@ class Item extends React.PureComponent {
           onClick={this.handleClick}
           target='_blank'
         >
-          <img src={previewUrl} srcSet={srcSet} sizes={sizes} alt='' />
+          <img src={previewUrl} srcSet={srcSet} sizes={sizes} alt={attachment.get('description')} title={attachment.get('description')} />
         </a>
       );
     } else if (attachment.get('type') === 'gifv') {
@@ -149,6 +148,7 @@ class Item extends React.PureComponent {
         <div className={classNames('media-gallery__gifv', { autoplay: autoPlay })}>
           <video
             className='media-gallery__item-gifv-thumbnail'
+            aria-label={attachment.get('description')}
             role='application'
             src={attachment.get('url')}
             onClick={this.handleClick}
@@ -174,7 +174,6 @@ class Item extends React.PureComponent {
 }
 
 @injectIntl
-@sizeMe({})
 export default class MediaGallery extends React.PureComponent {
 
   static propTypes = {
@@ -211,21 +210,42 @@ export default class MediaGallery extends React.PureComponent {
     this.props.onOpenMedia(this.props.media, index);
   }
 
+  handleRef = (node) => {
+    if (node && this.isStandaloneEligible()) {
+      // offsetWidth triggers a layout, so only calculate when we need to
+      this.setState({
+        width: node.offsetWidth,
+      });
+    }
+  }
+
+  isStandaloneEligible() {
+    const { media, standalone } = this.props;
+    return standalone && media.size === 1 && media.getIn([0, 'meta', 'small', 'aspect']);
+  }
+
   render () {
-    const { media, intl, sensitive, height, standalone, size } = this.props;
+    const { media, intl, sensitive, height } = this.props;
+    const { width, visible } = this.state;
 
     let children;
 
-    const standaloneEligible = standalone && size.width && media.size === 1 && media.getIn([0, 'meta', 'small', 'aspect']);
     const style = {};
 
-    if (standaloneEligible) {
-      style.height = size.width / media.getIn([0, 'meta', 'small', 'aspect']);
+    if (this.isStandaloneEligible()) {
+      if (!visible && width) {
+        // only need to forcibly set the height in "sensitive" mode
+        style.height = width / this.props.media.getIn([0, 'meta', 'small', 'aspect']);
+      } else {
+        // layout automatically, using image's natural aspect ratio
+        style.height = '';
+      }
     } else {
+      // crop the image
       style.height = height;
     }
 
-    if (!this.state.visible) {
+    if (!visible) {
       let warning;
 
       if (sensitive) {
@@ -235,7 +255,7 @@ export default class MediaGallery extends React.PureComponent {
       }
 
       children = (
-        <button className='media-spoiler' onClick={this.handleOpen} style={style}>
+        <button className='media-spoiler' onClick={this.handleOpen} style={style} ref={this.handleRef}>
           <span className='media-spoiler__warning'>{warning}</span>
           <span className='media-spoiler__trigger'><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span>
         </button>
@@ -243,7 +263,7 @@ export default class MediaGallery extends React.PureComponent {
     } else {
       const size = media.take(4).size;
 
-      if (standaloneEligible) {
+      if (this.isStandaloneEligible()) {
         children = <Item standalone onClick={this.handleClick} attachment={media.get(0)} autoPlayGif={this.props.autoPlayGif} />;
       } else {
         children = media.take(4).map((attachment, i) => <Item key={attachment.get('id')} onClick={this.handleClick} attachment={attachment} autoPlayGif={this.props.autoPlayGif} index={i} size={size} />);
@@ -252,8 +272,8 @@ export default class MediaGallery extends React.PureComponent {
 
     return (
       <div className='media-gallery' style={style}>
-        <div className={classNames('spoiler-button', { 'spoiler-button--visible': this.state.visible })}>
-          <IconButton title={intl.formatMessage(messages.toggle_visible)} icon={this.state.visible ? 'eye' : 'eye-slash'} overlay onClick={this.handleOpen} />
+        <div className={classNames('spoiler-button', { 'spoiler-button--visible': visible })}>
+          <IconButton title={intl.formatMessage(messages.toggle_visible)} icon={visible ? 'eye' : 'eye-slash'} overlay onClick={this.handleOpen} />
         </div>
 
         {children}
diff --git a/app/javascript/mastodon/components/relative_timestamp.js b/app/javascript/mastodon/components/relative_timestamp.js
index 2717d2326..534d83fac 100644
--- a/app/javascript/mastodon/components/relative_timestamp.js
+++ b/app/javascript/mastodon/components/relative_timestamp.js
@@ -1,7 +1,15 @@
 import React from 'react';
-import { injectIntl, FormattedRelative } from 'react-intl';
+import { injectIntl, defineMessages } from 'react-intl';
 import PropTypes from 'prop-types';
 
+const messages = defineMessages({
+  just_now: { id: 'relative_time.just_now', defaultMessage: 'now' },
+  seconds: { id: 'relative_time.seconds', defaultMessage: '{number}s' },
+  minutes: { id: 'relative_time.minutes', defaultMessage: '{number}m' },
+  hours: { id: 'relative_time.hours', defaultMessage: '{number}h' },
+  days: { id: 'relative_time.days', defaultMessage: '{number}d' },
+});
+
 const dateFormatOptions = {
   hour12: false,
   year: 'numeric',
@@ -11,6 +19,47 @@ const dateFormatOptions = {
   minute: '2-digit',
 };
 
+const shortDateFormatOptions = {
+  month: 'numeric',
+  day: 'numeric',
+};
+
+const SECOND = 1000;
+const MINUTE = 1000 * 60;
+const HOUR   = 1000 * 60 * 60;
+const DAY    = 1000 * 60 * 60 * 24;
+
+const MAX_DELAY = 2147483647;
+
+const selectUnits = delta => {
+  const absDelta = Math.abs(delta);
+
+  if (absDelta < MINUTE) {
+    return 'second';
+  } else if (absDelta < HOUR) {
+    return 'minute';
+  } else if (absDelta < DAY) {
+    return 'hour';
+  }
+
+  return 'day';
+};
+
+const getUnitDelay = units => {
+  switch (units) {
+  case 'second':
+    return SECOND;
+  case 'minute':
+    return MINUTE;
+  case 'hour':
+    return HOUR;
+  case 'day':
+    return DAY;
+  default:
+    return MAX_DELAY;
+  }
+};
+
 @injectIntl
 export default class RelativeTimestamp extends React.Component {
 
@@ -19,20 +68,74 @@ export default class RelativeTimestamp extends React.Component {
     timestamp: PropTypes.string.isRequired,
   };
 
-  shouldComponentUpdate (nextProps) {
+  state = {
+    now: this.props.intl.now(),
+  };
+
+  shouldComponentUpdate (nextProps, nextState) {
     // As of right now the locale doesn't change without a new page load,
     // but we might as well check in case that ever changes.
     return this.props.timestamp !== nextProps.timestamp ||
-      this.props.intl.locale !== nextProps.intl.locale;
+      this.props.intl.locale !== nextProps.intl.locale ||
+      this.state.now !== nextState.now;
+  }
+
+  componentWillReceiveProps (nextProps) {
+    if (this.props.timestamp !== nextProps.timestamp) {
+      this.setState({ now: this.props.intl.now() });
+    }
+  }
+
+  componentDidMount () {
+    this._scheduleNextUpdate(this.props, this.state);
+  }
+
+  componentWillUpdate (nextProps, nextState) {
+    this._scheduleNextUpdate(nextProps, nextState);
+  }
+
+  _scheduleNextUpdate (props, state) {
+    clearTimeout(this._timer);
+
+    const { timestamp }  = props;
+    const delta          = (new Date(timestamp)).getTime() - state.now;
+    const unitDelay      = getUnitDelay(selectUnits(delta));
+    const unitRemainder  = Math.abs(delta % unitDelay);
+    const updateInterval = 1000 * 10;
+    const delay          = delta < 0 ? Math.max(updateInterval, unitDelay - unitRemainder) : Math.max(updateInterval, unitRemainder);
+
+    this._timer = setTimeout(() => {
+      this.setState({ now: this.props.intl.now() });
+    }, delay);
   }
 
   render () {
     const { timestamp, intl } = this.props;
-    const date = new Date(timestamp);
+
+    const date  = new Date(timestamp);
+    const delta = this.state.now - date.getTime();
+
+    let relativeTime;
+
+    if (delta < 10 * SECOND) {
+      relativeTime = intl.formatMessage(messages.just_now);
+    } else if (delta < 3 * DAY) {
+      if (delta < MINUTE) {
+        relativeTime = intl.formatMessage(messages.seconds, { number: Math.floor(delta / SECOND) });
+      } else if (delta < HOUR) {
+        relativeTime = intl.formatMessage(messages.minutes, { number: Math.floor(delta / MINUTE) });
+      } else if (delta < DAY) {
+        relativeTime = intl.formatMessage(messages.hours, { number: Math.floor(delta / HOUR) });
+      } else {
+        relativeTime = intl.formatMessage(messages.days, { number: Math.floor(delta / DAY) });
+      }
+    } else {
+      relativeTime = intl.formatDate(date, shortDateFormatOptions);
+    }
 
     return (
       <time dateTime={timestamp} title={intl.formatDate(date, dateFormatOptions)}>
-        <FormattedRelative value={date} />
+        {relativeTime}
       </time>
     );
   }
diff --git a/app/javascript/mastodon/components/scrollable_list.js b/app/javascript/mastodon/components/scrollable_list.js
index ff0540e5d..ab9d48510 100644
--- a/app/javascript/mastodon/components/scrollable_list.js
+++ b/app/javascript/mastodon/components/scrollable_list.js
@@ -6,6 +6,8 @@ import LoadMore from './load_more';
 import IntersectionObserverWrapper from '../features/ui/util/intersection_observer_wrapper';
 import { throttle } from 'lodash';
 import { List as ImmutableList } from 'immutable';
+import classNames from 'classnames';
+import { attachFullscreenListener, detachFullscreenListener, isFullscreen } from '../features/ui/util/fullscreen';
 
 export default class ScrollableList extends PureComponent {
 
@@ -66,6 +68,7 @@ export default class ScrollableList extends PureComponent {
   componentDidMount () {
     this.attachScrollListener();
     this.attachIntersectionObserver();
+    attachFullscreenListener(this.onFullScreenChange);
 
     // Handle initial scroll posiiton
     this.handleScroll();
@@ -92,6 +95,11 @@ export default class ScrollableList extends PureComponent {
   componentWillUnmount () {
     this.detachScrollListener();
     this.detachIntersectionObserver();
+    detachFullscreenListener(this.onFullScreenChange);
+  }
+
+  onFullScreenChange = () => {
+    this.setState({ fullscreen: isFullscreen() });
   }
 
   attachIntersectionObserver () {
@@ -137,34 +145,9 @@ export default class ScrollableList extends PureComponent {
     return this._lastMouseMove !== null && ((new Date()) - this._lastMouseMove < 600);
   }
 
-  handleKeyDown = (e) => {
-    if (['PageDown', 'PageUp'].includes(e.key) || (e.ctrlKey && ['End', 'Home'].includes(e.key))) {
-      const article = (() => {
-        switch (e.key) {
-        case 'PageDown':
-          return e.target.nodeName === 'ARTICLE' && e.target.nextElementSibling;
-        case 'PageUp':
-          return e.target.nodeName === 'ARTICLE' && e.target.previousElementSibling;
-        case 'End':
-          return this.node.querySelector('[role="feed"] > article:last-of-type');
-        case 'Home':
-          return this.node.querySelector('[role="feed"] > article:first-of-type');
-        default:
-          return null;
-        }
-      })();
-
-
-      if (article) {
-        e.preventDefault();
-        article.focus();
-        article.scrollIntoView();
-      }
-    }
-  }
-
   render () {
     const { children, scrollKey, trackScroll, shouldUpdateScroll, isLoading, hasMore, prepend, emptyMessage } = this.props;
+    const { fullscreen } = this.state;
     const childrenCount = React.Children.count(children);
 
     const loadMore     = (hasMore && childrenCount > 0) ? <LoadMore visible={!isLoading} onClick={this.handleLoadMore} /> : null;
@@ -172,8 +155,8 @@ export default class ScrollableList extends PureComponent {
 
     if (isLoading || childrenCount > 0 || !emptyMessage) {
       scrollableArea = (
-        <div className='scrollable' ref={this.setRef} onMouseMove={this.handleMouseMove} onMouseLeave={this.handleMouseLeave}>
-          <div role='feed' className='item-list' onKeyDown={this.handleKeyDown}>
+        <div className={classNames('scrollable', { fullscreen })} ref={this.setRef} onMouseMove={this.handleMouseMove} onMouseLeave={this.handleMouseLeave}>
+          <div role='feed' className='item-list'>
             {prepend}
 
             {React.Children.map(this.props.children, (child, index) => (
diff --git a/app/javascript/mastodon/components/status.js b/app/javascript/mastodon/components/status.js
index 9e65db85c..b9be20033 100644
--- a/app/javascript/mastodon/components/status.js
+++ b/app/javascript/mastodon/components/status.js
@@ -13,6 +13,8 @@ import StatusActionBar from './status_action_bar';
 import { FormattedMessage } from 'react-intl';
 import ImmutablePureComponent from 'react-immutable-pure-component';
 import { MediaGallery, Video } from '../features/ui/util/async-components';
+import { HotKeys } from 'react-hotkeys';
+import classNames from 'classnames';
 
 // We use the component (and not the container) since we do not want
 // to use the progress bar to show download progress
@@ -42,6 +44,8 @@ export default class Status extends ImmutablePureComponent {
     autoPlayGif: PropTypes.bool,
     muted: PropTypes.bool,
     hidden: PropTypes.bool,
+    onMoveUp: PropTypes.func,
+    onMoveDown: PropTypes.func,
   };
 
   state = {
@@ -92,16 +96,62 @@ export default class Status extends ImmutablePureComponent {
   }
 
   handleOpenVideo = startTime => {
-    this.props.onOpenVideo(this.props.status.getIn(['media_attachments', 0]), startTime);
+    this.props.onOpenVideo(this._properStatus().getIn(['media_attachments', 0]), startTime);
+  }
+
+  handleHotkeyReply = e => {
+    e.preventDefault();
+    this.props.onReply(this._properStatus(), this.context.router.history);
+  }
+
+  handleHotkeyFavourite = () => {
+    this.props.onFavourite(this._properStatus());
+  }
+
+  handleHotkeyBoost = e => {
+    this.props.onReblog(this._properStatus(), e);
+  }
+
+  handleHotkeyMention = e => {
+    e.preventDefault();
+    this.props.onMention(this._properStatus().get('account'), this.context.router.history);
+  }
+
+  handleHotkeyOpen = () => {
+    this.context.router.history.push(`/statuses/${this._properStatus().get('id')}`);
+  }
+
+  handleHotkeyOpenProfile = () => {
+    this.context.router.history.push(`/accounts/${this._properStatus().getIn(['account', 'id'])}`);
+  }
+
+  handleHotkeyMoveUp = () => {
+    this.props.onMoveUp(this.props.status.get('id'));
+  }
+
+  handleHotkeyMoveDown = () => {
+    this.props.onMoveDown(this.props.status.get('id'));
+  }
+
+  _properStatus () {
+    const { status } = this.props;
+
+    if (status.get('reblog', null) !== null && typeof status.get('reblog') === 'object') {
+      return status.get('reblog');
+    } else {
+      return status;
+    }
   }
 
   render () {
     let media = null;
-    let statusAvatar;
+    let statusAvatar, prepend;
 
-    const { status, account, hidden, ...other } = this.props;
+    const { hidden }     = this.props;
     const { isExpanded } = this.state;
 
+    let { status, account, ...other } = this.props;
+
     if (status === null) {
       return null;
     }
@@ -118,16 +168,15 @@ export default class Status extends ImmutablePureComponent {
     if (status.get('reblog', null) !== null && typeof status.get('reblog') === 'object') {
       const display_name_html = { __html: status.getIn(['account', 'display_name_html']) };
 
-      return (
-        <div className='status__wrapper' data-id={status.get('id')} >
-          <div className='status__prepend'>
-            <div className='status__prepend-icon-wrapper'><i className='fa fa-fw fa-retweet status__prepend-icon' /></div>
-            <FormattedMessage id='status.reblogged_by' defaultMessage='{name} boosted' values={{ name: <a onClick={this.handleAccountClick} data-id={status.getIn(['account', 'id'])} href={status.getIn(['account', 'url'])} className='status__display-name muted'><strong dangerouslySetInnerHTML={display_name_html} /></a> }} />
-          </div>
-
-          <Status {...other} status={status.get('reblog')} account={status.get('account')} />
+      prepend = (
+        <div className='status__prepend'>
+          <div className='status__prepend-icon-wrapper'><i className='fa fa-fw fa-retweet status__prepend-icon' /></div>
+          <FormattedMessage id='status.reblogged_by' defaultMessage='{name} boosted' values={{ name: <a onClick={this.handleAccountClick} data-id={status.getIn(['account', 'id'])} href={status.getIn(['account', 'url'])} className='status__display-name muted'><strong dangerouslySetInnerHTML={display_name_html} /></a> }} />
         </div>
       );
+
+      account = status.get('account');
+      status  = status.get('reblog');
     }
 
     if (status.get('media_attachments').size > 0 && !this.props.muted) {
@@ -163,26 +212,43 @@ export default class Status extends ImmutablePureComponent {
       statusAvatar = <AvatarOverlay account={status.get('account')} friend={account} />;
     }
 
+    const handlers = this.props.muted ? {} : {
+      reply: this.handleHotkeyReply,
+      favourite: this.handleHotkeyFavourite,
+      boost: this.handleHotkeyBoost,
+      mention: this.handleHotkeyMention,
+      open: this.handleHotkeyOpen,
+      openProfile: this.handleHotkeyOpenProfile,
+      moveUp: this.handleHotkeyMoveUp,
+      moveDown: this.handleHotkeyMoveDown,
+    };
+
     return (
-      <div className={`status ${this.props.muted ? 'muted' : ''} status-${status.get('visibility')}`} data-id={status.get('id')}>
-        <div className='status__info'>
-          <a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener'><RelativeTimestamp timestamp={status.get('created_at')} /></a>
+      <HotKeys handlers={handlers}>
+        <div className={classNames('status__wrapper', `status__wrapper-${status.get('visibility')}`, { focusable: !this.props.muted })} tabIndex={this.props.muted ? null : 0}>
+          {prepend}
 
-          <a onClick={this.handleAccountClick} target='_blank' data-id={status.getIn(['account', 'id'])} href={status.getIn(['account', 'url'])} className='status__display-name'>
-            <div className='status__avatar'>
-              {statusAvatar}
-            </div>
+          <div className={classNames('status', `status-${status.get('visibility')}`, { muted: this.props.muted })} data-id={status.get('id')}>
+            <div className='status__info'>
+              <a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener'><RelativeTimestamp timestamp={status.get('created_at')} /></a>
 
-            <DisplayName account={status.get('account')} />
-          </a>
-        </div>
+              <a onClick={this.handleAccountClick} target='_blank' data-id={status.getIn(['account', 'id'])} href={status.getIn(['account', 'url'])} title={status.getIn(['account', 'acct'])} className='status__display-name'>
+                <div className='status__avatar'>
+                  {statusAvatar}
+                </div>
 
-        <StatusContent status={status} onClick={this.handleClick} expanded={isExpanded} onExpandedToggle={this.handleExpandedToggle} />
+                <DisplayName account={status.get('account')} />
+              </a>
+            </div>
+
+            <StatusContent status={status} onClick={this.handleClick} expanded={isExpanded} onExpandedToggle={this.handleExpandedToggle} />
 
-        {media}
+            {media}
 
-        <StatusActionBar {...this.props} />
-      </div>
+            <StatusActionBar status={status} account={account} {...other} />
+          </div>
+        </div>
+      </HotKeys>
     );
   }
 
diff --git a/app/javascript/mastodon/components/status_content.js b/app/javascript/mastodon/components/status_content.js
index d1381f176..8ad60b9d6 100644
--- a/app/javascript/mastodon/components/status_content.js
+++ b/app/javascript/mastodon/components/status_content.js
@@ -147,7 +147,7 @@ export default class StatusContent extends React.PureComponent {
       }
 
       return (
-        <div className={classNames} ref={this.setRef} tabIndex='0' aria-label={status.get('search_index')} onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp}>
+        <div className={classNames} ref={this.setRef} tabIndex='0' onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp}>
           <p style={{ marginBottom: hidden && status.get('mentions').isEmpty() ? '0px' : null }}>
             <span dangerouslySetInnerHTML={spoilerContent} />
             {' '}
@@ -164,7 +164,6 @@ export default class StatusContent extends React.PureComponent {
         <div
           ref={this.setRef}
           tabIndex='0'
-          aria-label={status.get('search_index')}
           className={classNames}
           style={directionStyle}
           onMouseDown={this.handleMouseDown}
@@ -176,7 +175,6 @@ export default class StatusContent extends React.PureComponent {
       return (
         <div
           tabIndex='0'
-          aria-label={status.get('search_index')}
           ref={this.setRef}
           className='status__content'
           style={directionStyle}
diff --git a/app/javascript/mastodon/components/status_list.js b/app/javascript/mastodon/components/status_list.js
index 9026ebb0c..214955591 100644
--- a/app/javascript/mastodon/components/status_list.js
+++ b/app/javascript/mastodon/components/status_list.js
@@ -25,18 +25,45 @@ export default class StatusList extends ImmutablePureComponent {
     trackScroll: true,
   };
 
+  handleMoveUp = id => {
+    const elementIndex = this.props.statusIds.indexOf(id) - 1;
+    this._selectChild(elementIndex);
+  }
+
+  handleMoveDown = id => {
+    const elementIndex = this.props.statusIds.indexOf(id) + 1;
+    this._selectChild(elementIndex);
+  }
+
+  _selectChild (index) {
+    const element = this.node.node.querySelector(`article:nth-of-type(${index + 1}) .focusable`);
+
+    if (element) {
+      element.focus();
+    }
+  }
+
+  setRef = c => {
+    this.node = c;
+  }
+
   render () {
     const { statusIds, ...other } = this.props;
     const { isLoading } = other;
 
     const scrollableContent = (isLoading || statusIds.size > 0) ? (
       statusIds.map((statusId) => (
-        <StatusContainer key={statusId} id={statusId} />
+        <StatusContainer
+          key={statusId}
+          id={statusId}
+          onMoveUp={this.handleMoveUp}
+          onMoveDown={this.handleMoveDown}
+        />
       ))
     ) : null;
 
     return (
-      <ScrollableList {...other}>
+      <ScrollableList {...other} ref={this.setRef}>
         {scrollableContent}
       </ScrollableList>
     );
diff --git a/app/javascript/mastodon/components/video_player.js b/app/javascript/mastodon/components/video_player.js
deleted file mode 100644
index 26914f113..000000000
--- a/app/javascript/mastodon/components/video_player.js
+++ /dev/null
@@ -1,207 +0,0 @@
-//  THIS FILE EXISTS FOR UPSTREAM COMPATIBILITY & SHOULDN'T BE USED !!
-//  SEE INSTEAD : glitch/components/status/player
-
-import React from 'react';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import PropTypes from 'prop-types';
-import IconButton from './icon_button';
-import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
-import { isIOS } from '../is_mobile';
-
-const messages = defineMessages({
-  toggle_sound: { id: 'video_player.toggle_sound', defaultMessage: 'Toggle sound' },
-  toggle_visible: { id: 'video_player.toggle_visible', defaultMessage: 'Toggle visibility' },
-  expand_video: { id: 'video_player.expand', defaultMessage: 'Expand video' },
-});
-
-@injectIntl
-export default class VideoPlayer extends React.PureComponent {
-
-  static contextTypes = {
-    router: PropTypes.object,
-  };
-
-  static propTypes = {
-    media: ImmutablePropTypes.map.isRequired,
-    width: PropTypes.number,
-    height: PropTypes.number,
-    sensitive: PropTypes.bool,
-    intl: PropTypes.object.isRequired,
-    autoplay: PropTypes.bool,
-    onOpenVideo: PropTypes.func.isRequired,
-  };
-
-  static defaultProps = {
-    width: 239,
-    height: 110,
-  };
-
-  state = {
-    visible: !this.props.sensitive,
-    preview: true,
-    muted: true,
-    hasAudio: true,
-    videoError: false,
-  };
-
-  handleClick = () => {
-    this.setState({ muted: !this.state.muted });
-  }
-
-  handleVideoClick = (e) => {
-    e.stopPropagation();
-
-    const node = this.video;
-
-    if (node.paused) {
-      node.play();
-    } else {
-      node.pause();
-    }
-  }
-
-  handleOpen = () => {
-    this.setState({ preview: !this.state.preview });
-  }
-
-  handleVisibility = () => {
-    this.setState({
-      visible: !this.state.visible,
-      preview: true,
-    });
-  }
-
-  handleExpand = () => {
-    this.video.pause();
-    this.props.onOpenVideo(this.props.media, this.video.currentTime);
-  }
-
-  setRef = (c) => {
-    this.video = c;
-  }
-
-  handleLoadedData = () => {
-    if (('WebkitAppearance' in document.documentElement.style && this.video.audioTracks.length === 0) || this.video.mozHasAudio === false) {
-      this.setState({ hasAudio: false });
-    }
-  }
-
-  handleVideoError = () => {
-    this.setState({ videoError: true });
-  }
-
-  componentDidMount () {
-    if (!this.video) {
-      return;
-    }
-
-    this.video.addEventListener('loadeddata', this.handleLoadedData);
-    this.video.addEventListener('error', this.handleVideoError);
-  }
-
-  componentDidUpdate () {
-    if (!this.video) {
-      return;
-    }
-
-    this.video.addEventListener('loadeddata', this.handleLoadedData);
-    this.video.addEventListener('error', this.handleVideoError);
-  }
-
-  componentWillUnmount () {
-    if (!this.video) {
-      return;
-    }
-
-    this.video.removeEventListener('loadeddata', this.handleLoadedData);
-    this.video.removeEventListener('error', this.handleVideoError);
-  }
-
-  render () {
-    const { media, intl, width, height, sensitive, autoplay } = this.props;
-
-    let spoilerButton = (
-      <div className={`status__video-player-spoiler ${this.state.visible ? 'status__video-player-spoiler--visible' : ''}`}>
-        <IconButton overlay title={intl.formatMessage(messages.toggle_visible)} icon={this.state.visible ? 'eye' : 'eye-slash'} onClick={this.handleVisibility} />
-      </div>
-    );
-
-    let expandButton = '';
-
-    if (this.context.router) {
-      expandButton = (
-        <div className='status__video-player-expand'>
-          <IconButton overlay title={intl.formatMessage(messages.expand_video)} icon='expand' onClick={this.handleExpand} />
-        </div>
-      );
-    }
-
-    let muteButton = '';
-
-    if (this.state.hasAudio) {
-      muteButton = (
-        <div className='status__video-player-mute'>
-          <IconButton overlay title={intl.formatMessage(messages.toggle_sound)} icon={this.state.muted ? 'volume-off' : 'volume-up'} onClick={this.handleClick} />
-        </div>
-      );
-    }
-
-    if (!this.state.visible) {
-      if (sensitive) {
-        return (
-          <button style={{ width: `${width}px`, height: `${height}px`, marginTop: '8px' }} className='media-spoiler' onClick={this.handleVisibility}>
-            {spoilerButton}
-            <span className='media-spoiler__warning'><FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' /></span>
-            <span className='media-spoiler__trigger'><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span>
-          </button>
-        );
-      } else {
-        return (
-          <button style={{ width: `${width}px`, height: `${height}px`, marginTop: '8px' }} className='media-spoiler' onClick={this.handleVisibility}>
-            {spoilerButton}
-            <span className='media-spoiler__warning'><FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' /></span>
-            <span className='media-spoiler__trigger'><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span>
-          </button>
-        );
-      }
-    }
-
-    if (this.state.preview && !autoplay) {
-      return (
-        <button className='media-spoiler-video' style={{ width: `${width}px`, height: `${height}px`, backgroundImage: `url(${media.get('preview_url')})` }} onClick={this.handleOpen}>
-          {spoilerButton}
-          <div className='media-spoiler-video-play-icon'><i className='fa fa-play' /></div>
-        </button>
-      );
-    }
-
-    if (this.state.videoError) {
-      return (
-        <div style={{ width: `${width}px`, height: `${height}px` }} className='video-error-cover' >
-          <span className='media-spoiler__warning'><FormattedMessage id='video_player.video_error' defaultMessage='Video could not be played' /></span>
-        </div>
-      );
-    }
-
-    return (
-      <div className='status__video-player' style={{ width: `${width}px`, height: `${height}px` }}>
-        {spoilerButton}
-        {muteButton}
-        {expandButton}
-
-        <video
-          className='status__video-player-video'
-          role='button'
-          tabIndex='0'
-          ref={this.setRef}
-          src={media.get('url')}
-          autoPlay={!isIOS()}
-          loop
-          muted={this.state.muted}
-          onClick={this.handleVideoClick}
-        />
-      </div>
-    );
-  }
-
-}
diff --git a/app/javascript/mastodon/containers/mastodon.js b/app/javascript/mastodon/containers/mastodon.js
index db2a5f269..6beffca1c 100644
--- a/app/javascript/mastodon/containers/mastodon.js
+++ b/app/javascript/mastodon/containers/mastodon.js
@@ -3,9 +3,8 @@ import { Provider } from 'react-redux';
 import PropTypes from 'prop-types';
 import configureStore from '../store/configureStore';
 import { showOnboardingOnce } from '../actions/onboarding';
-import BrowserRouter from 'react-router-dom/BrowserRouter';
-import Route from 'react-router-dom/Route';
-import ScrollContext from 'react-router-scroll/lib/ScrollBehaviorContext';
+import { BrowserRouter, Route } from 'react-router-dom';
+import { ScrollContext } from 'react-router-scroll';
 import UI from '../features/ui';
 import { hydrateStore } from '../actions/store';
 import { connectUserStream } from '../actions/streaming';
diff --git a/app/javascript/mastodon/containers/timeline_container.js b/app/javascript/mastodon/containers/timeline_container.js
index 6b545ef09..4be037955 100644
--- a/app/javascript/mastodon/containers/timeline_container.js
+++ b/app/javascript/mastodon/containers/timeline_container.js
@@ -6,6 +6,7 @@ import { hydrateStore } from '../actions/store';
 import { IntlProvider, addLocaleData } from 'react-intl';
 import { getLocale } from '../locales';
 import PublicTimeline from '../features/standalone/public_timeline';
+import HashtagTimeline from '../features/standalone/hashtag_timeline';
 
 const { localeData, messages } = getLocale();
 addLocaleData(localeData);
@@ -22,15 +23,24 @@ export default class TimelineContainer extends React.PureComponent {
 
   static propTypes = {
     locale: PropTypes.string.isRequired,
+    hashtag: PropTypes.string,
   };
 
   render () {
-    const { locale } = this.props;
+    const { locale, hashtag } = this.props;
+
+    let timeline;
+
+    if (hashtag) {
+      timeline = <HashtagTimeline hashtag={hashtag} />;
+    } else {
+      timeline = <PublicTimeline />;
+    }
 
     return (
       <IntlProvider locale={locale} messages={messages}>
         <Provider store={store}>
-          <PublicTimeline />
+          {timeline}
         </Provider>
       </IntlProvider>
     );
diff --git a/app/javascript/mastodon/emoji.js b/app/javascript/mastodon/emoji.js
deleted file mode 100644
index d75f6f598..000000000
--- a/app/javascript/mastodon/emoji.js
+++ /dev/null
@@ -1,72 +0,0 @@
-import { unicodeMapping } from './emojione_light';
-import Trie from 'substring-trie';
-
-const trie = new Trie(Object.keys(unicodeMapping));
-
-const assetHost = process.env.CDN_HOST || '';
-
-const emojify = (str, customEmojis = {}) => {
-  let rtn = '';
-  for (;;) {
-    let match, i = 0, tag;
-    while (i < str.length && (tag = '<&'.indexOf(str[i])) === -1 && str[i] !== ':' && !(match = trie.search(str.slice(i)))) {
-      i += str.codePointAt(i) < 65536 ? 1 : 2;
-    }
-    if (i === str.length)
-      break;
-    else if (tag >= 0) {
-      const tagend = str.indexOf('>;'[tag], i + 1) + 1;
-      if (!tagend)
-        break;
-      rtn += str.slice(0, tagend);
-      str = str.slice(tagend);
-    } else if (str[i] === ':') {
-      try {
-        // if replacing :shortname: succeed, exit this block with "continue"
-        const closeColon = str.indexOf(':', i + 1) + 1;
-        if (!closeColon) throw null; // no pair of ':'
-        const lt = str.indexOf('<', i + 1);
-        if (!(lt === -1 || lt >= closeColon)) throw null; // tag appeared before closing ':'
-        const shortname = str.slice(i, closeColon);
-        if (shortname in customEmojis) {
-          rtn += str.slice(0, i) + `<img draggable="false" class="emojione" alt="${shortname}" title="${shortname}" src="${customEmojis[shortname]}" />`;
-          str = str.slice(closeColon);
-          continue;
-        }
-      } catch (e) {}
-      // replacing :shortname: failed
-      rtn += str.slice(0, i + 1);
-      str = str.slice(i + 1);
-    } else {
-      const [filename, shortCode] = unicodeMapping[match];
-      rtn += str.slice(0, i) + `<img draggable="false" class="emojione" alt="${match}" title=":${shortCode}:" src="${assetHost}/emoji/${filename}.svg" />`;
-      str = str.slice(i + match.length);
-    }
-  }
-  return rtn + str;
-};
-
-export default emojify;
-
-export const buildCustomEmojis = customEmojis => {
-  const emojis = [];
-
-  customEmojis.forEach(emoji => {
-    const shortcode = emoji.get('shortcode');
-    const url       = emoji.get('url');
-    const name      = shortcode.replace(':', '');
-
-    emojis.push({
-      id: name,
-      name,
-      short_names: [name],
-      text: '',
-      emoticons: [],
-      keywords: [name],
-      imageUrl: url,
-      custom: true,
-    });
-  });
-
-  return emojis;
-};
diff --git a/app/javascript/mastodon/emojione_light.js b/app/javascript/mastodon/emojione_light.js
deleted file mode 100644
index 2296497b0..000000000
--- a/app/javascript/mastodon/emojione_light.js
+++ /dev/null
@@ -1,38 +0,0 @@
-// @preval
-// http://www.unicode.org/Public/emoji/5.0/emoji-test.txt
-
-const emojis         = require('./emoji_map.json');
-const { emojiIndex } = require('emoji-mart');
-const excluded       = ['®', '©', '™'];
-const skins          = ['🏻', '🏼', '🏽', '🏾', '🏿'];
-const shortcodeMap   = {};
-
-Object.keys(emojiIndex.emojis).forEach(key => {
-  shortcodeMap[emojiIndex.emojis[key].native] = emojiIndex.emojis[key].id;
-});
-
-const stripModifiers = unicode => {
-  skins.forEach(tone => {
-    unicode = unicode.replace(tone, '');
-  });
-
-  return unicode;
-};
-
-Object.keys(emojis).forEach(key => {
-  if (excluded.includes(key)) {
-    delete emojis[key];
-    return;
-  }
-
-  const normalizedKey = stripModifiers(key);
-  let shortcode       = shortcodeMap[normalizedKey];
-
-  if (!shortcode) {
-    shortcode = shortcodeMap[normalizedKey + '\uFE0F'];
-  }
-
-  emojis[key] = [emojis[key], shortcode];
-});
-
-module.exports.unicodeMapping = emojis;
diff --git a/app/javascript/mastodon/features/account/components/action_bar.js b/app/javascript/mastodon/features/account/components/action_bar.js
index 9e8fea69d..2819ae252 100644
--- a/app/javascript/mastodon/features/account/components/action_bar.js
+++ b/app/javascript/mastodon/features/account/components/action_bar.js
@@ -2,7 +2,7 @@ import React from 'react';
 import ImmutablePropTypes from 'react-immutable-proptypes';
 import PropTypes from 'prop-types';
 import DropdownMenuContainer from '../../../containers/dropdown_menu_container';
-import Link from 'react-router-dom/Link';
+import { Link } from 'react-router-dom';
 import { defineMessages, injectIntl, FormattedMessage, FormattedNumber } from 'react-intl';
 
 const messages = defineMessages({
diff --git a/app/javascript/mastodon/features/compose/components/compose_form.js b/app/javascript/mastodon/features/compose/components/compose_form.js
index b85105c53..097dccfb4 100644
--- a/app/javascript/mastodon/features/compose/components/compose_form.js
+++ b/app/javascript/mastodon/features/compose/components/compose_form.js
@@ -5,7 +5,6 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
 import PropTypes from 'prop-types';
 import ReplyIndicatorContainer from '../containers/reply_indicator_container';
 import AutosuggestTextarea from '../../../components/autosuggest_textarea';
-import { debounce } from 'lodash';
 import UploadButtonContainer from '../containers/upload_button_container';
 import { defineMessages, injectIntl } from 'react-intl';
 import Collapsable from '../../../components/collapsable';
@@ -94,9 +93,9 @@ export default class ComposeForm extends ImmutablePureComponent {
     this.props.onClearSuggestions();
   }
 
-  onSuggestionsFetchRequested = debounce((token) => {
+  onSuggestionsFetchRequested = (token) => {
     this.props.onFetchSuggestions(token);
-  }, 500, { trailing: true })
+  }
 
   onSuggestionSelected = (tokenStart, token, value) => {
     this._restoreCaret = null;
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 621cc21ce..dffa04ff0 100644
--- a/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js
+++ b/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js
@@ -1,11 +1,12 @@
 import React from 'react';
 import PropTypes from 'prop-types';
 import { defineMessages, injectIntl } from 'react-intl';
-import { Picker, Emoji } from 'emoji-mart';
-import { Overlay } from 'react-overlays';
+import { EmojiPicker as EmojiPickerAsync } from '../../ui/util/async-components';
+import Overlay from 'react-overlays/lib/Overlay';
 import classNames from 'classnames';
 import ImmutablePropTypes from 'react-immutable-proptypes';
 import detectPassiveEvents from 'detect-passive-events';
+import { buildCustomEmojis } from '../../emoji/emoji';
 
 const messages = defineMessages({
   emoji: { id: 'emoji_button.label', defaultMessage: 'Insert emoji' },
@@ -25,9 +26,24 @@ const messages = defineMessages({
 });
 
 const assetHost = process.env.CDN_HOST || '';
+let EmojiPicker, Emoji; // load asynchronously
+
 const backgroundImageFn = () => `${assetHost}/emoji/sheet.png`;
 const listenerOptions = detectPassiveEvents.hasSupport ? { passive: true } : false;
 
+const categoriesSort = [
+  'recent',
+  'custom',
+  'people',
+  'nature',
+  'foods',
+  'activity',
+  'places',
+  'objects',
+  'symbols',
+  'flags',
+];
+
 class ModifierPickerMenu extends React.PureComponent {
 
   static propTypes = {
@@ -36,9 +52,8 @@ class ModifierPickerMenu extends React.PureComponent {
     onClose: PropTypes.func.isRequired,
   };
 
-  handleClick = (e) => {
-    const modifier = [].slice.call(e.currentTarget.parentNode.children).indexOf(e.target) + 1;
-    this.props.onSelect(modifier);
+  handleClick = e => {
+    this.props.onSelect(e.currentTarget.getAttribute('data-index') * 1);
   }
 
   componentWillReceiveProps (nextProps) {
@@ -78,12 +93,12 @@ class ModifierPickerMenu extends React.PureComponent {
 
     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>
+        <button onClick={this.handleClick} data-index={1}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={1} backgroundImageFn={backgroundImageFn} /></button>
+        <button onClick={this.handleClick} data-index={2}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={2} backgroundImageFn={backgroundImageFn} /></button>
+        <button onClick={this.handleClick} data-index={3}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={3} backgroundImageFn={backgroundImageFn} /></button>
+        <button onClick={this.handleClick} data-index={4}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={4} backgroundImageFn={backgroundImageFn} /></button>
+        <button onClick={this.handleClick} data-index={5}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={5} backgroundImageFn={backgroundImageFn} /></button>
+        <button onClick={this.handleClick} data-index={6}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={6} backgroundImageFn={backgroundImageFn} /></button>
       </div>
     );
   }
@@ -131,6 +146,8 @@ class EmojiPickerMenu extends React.PureComponent {
 
   static propTypes = {
     custom_emojis: ImmutablePropTypes.list,
+    frequentlyUsedEmojis: PropTypes.arrayOf(PropTypes.string),
+    loading: PropTypes.bool,
     onClose: PropTypes.func.isRequired,
     onPick: PropTypes.func.isRequired,
     style: PropTypes.object,
@@ -138,16 +155,20 @@ class EmojiPickerMenu extends React.PureComponent {
     arrowOffsetLeft: PropTypes.string,
     arrowOffsetTop: PropTypes.string,
     intl: PropTypes.object.isRequired,
+    skinTone: PropTypes.number.isRequired,
+    onSkinTone: PropTypes.func.isRequired,
+    autoPlay: PropTypes.bool,
   };
 
   static defaultProps = {
     style: {},
+    loading: true,
     placement: 'bottom',
+    frequentlyUsedEmojis: [],
   };
 
   state = {
     modifierOpen: false,
-    modifier: 1,
   };
 
   handleDocumentClick = e => {
@@ -210,35 +231,43 @@ class EmojiPickerMenu extends React.PureComponent {
   }
 
   handleModifierChange = modifier => {
-    if (modifier !== this.state.modifier) {
-      this.setState({ modifier });
-    }
+    this.props.onSkinTone(modifier);
   }
 
   render () {
-    const { style, intl } = this.props;
+    const { loading, style, intl, custom_emojis, autoPlay, skinTone, frequentlyUsedEmojis } = this.props;
+
+    if (loading) {
+      return <div style={{ width: 299 }} />;
+    }
+
     const title = intl.formatMessage(messages.emoji);
-    const { modifierOpen, modifier } = this.state;
+    const { modifierOpen } = this.state;
 
     return (
       <div className={classNames('emoji-picker-dropdown__menu', { selecting: modifierOpen })} style={style} ref={this.setRef}>
-        <Picker
+        <EmojiPicker
           perLine={8}
           emojiSize={22}
           sheetSize={32}
+          custom={buildCustomEmojis(custom_emojis, autoPlay)}
           color=''
           emoji=''
           set='twitter'
           title={title}
           i18n={this.getI18n()}
           onClick={this.handleClick}
-          skin={modifier}
+          include={categoriesSort}
+          recent={frequentlyUsedEmojis}
+          skin={skinTone}
+          showPreview={false}
           backgroundImageFn={backgroundImageFn}
+          emojiTooltip
         />
 
         <ModifierPicker
           active={modifierOpen}
-          modifier={modifier}
+          modifier={skinTone}
           onOpen={this.handleModifierOpen}
           onClose={this.handleModifierClose}
           onChange={this.handleModifierChange}
@@ -254,12 +283,17 @@ export default class EmojiPickerDropdown extends React.PureComponent {
 
   static propTypes = {
     custom_emojis: ImmutablePropTypes.list,
+    frequentlyUsedEmojis: PropTypes.arrayOf(PropTypes.string),
+    autoPlay: PropTypes.bool,
     intl: PropTypes.object.isRequired,
     onPickEmoji: PropTypes.func.isRequired,
+    onSkinTone: PropTypes.func.isRequired,
+    skinTone: PropTypes.number.isRequired,
   };
 
   state = {
     active: false,
+    loading: false,
   };
 
   setRef = (c) => {
@@ -268,6 +302,19 @@ export default class EmojiPickerDropdown extends React.PureComponent {
 
   onShowDropdown = () => {
     this.setState({ active: true });
+
+    if (!EmojiPicker) {
+      this.setState({ loading: true });
+
+      EmojiPickerAsync().then(EmojiMart => {
+        EmojiPicker = EmojiMart.Picker;
+        Emoji       = EmojiMart.Emoji;
+
+        this.setState({ loading: false });
+      }).catch(() => {
+        this.setState({ loading: false });
+      });
+    }
   }
 
   onHideDropdown = () => {
@@ -275,7 +322,7 @@ export default class EmojiPickerDropdown extends React.PureComponent {
   }
 
   onToggle = (e) => {
-    if (!e.key || e.key === 'Enter') {
+    if (!this.state.loading && (!e.key || e.key === 'Enter')) {
       if (this.state.active) {
         this.onHideDropdown();
       } else {
@@ -299,15 +346,15 @@ export default class EmojiPickerDropdown extends React.PureComponent {
   }
 
   render () {
-    const { intl, onPickEmoji } = this.props;
+    const { intl, onPickEmoji, autoPlay, onSkinTone, skinTone, frequentlyUsedEmojis } = this.props;
     const title = intl.formatMessage(messages.emoji);
-    const { active } = this.state;
+    const { active, loading } = this.state;
 
     return (
       <div className='emoji-picker-dropdown' onKeyDown={this.handleKeyDown}>
         <div ref={this.setTargetRef} className='emoji-button' title={title} aria-label={title} aria-expanded={active} role='button' onClick={this.onToggle} onKeyDown={this.onToggle} tabIndex={0}>
           <img
-            className='emojione'
+            className={classNames('emojione', { 'pulse-loading': active && loading })}
             alt='🙂'
             src={`${assetHost}/emoji/1f602.svg`}
           />
@@ -316,8 +363,13 @@ export default class EmojiPickerDropdown extends React.PureComponent {
         <Overlay show={active} placement='bottom' target={this.findTarget}>
           <EmojiPickerMenu
             custom_emojis={this.props.custom_emojis}
+            loading={loading}
             onClose={this.onHideDropdown}
             onPick={onPickEmoji}
+            autoPlay={autoPlay}
+            onSkinTone={onSkinTone}
+            skinTone={skinTone}
+            frequentlyUsedEmojis={frequentlyUsedEmojis}
           />
         </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 0474dfb4e..e38ed38c1 100644
--- a/app/javascript/mastodon/features/compose/components/privacy_dropdown.js
+++ b/app/javascript/mastodon/features/compose/components/privacy_dropdown.js
@@ -2,7 +2,11 @@ import React from 'react';
 import PropTypes from 'prop-types';
 import { injectIntl, defineMessages } from 'react-intl';
 import IconButton from '../../../components/icon_button';
+import Overlay from 'react-overlays/lib/Overlay';
+import Motion from 'react-motion/lib/Motion';
+import spring from 'react-motion/lib/spring';
 import detectPassiveEvents from 'detect-passive-events';
+import classNames from 'classnames';
 
 const messages = defineMessages({
   public_short: { id: 'privacy.public.short', defaultMessage: 'Public' },
@@ -16,10 +20,77 @@ const messages = defineMessages({
   change_privacy: { id: 'privacy.change', defaultMessage: 'Adjust status privacy' },
 });
 
-const iconStyle = {
-  height: null,
-  lineHeight: '27px',
-};
+const listenerOptions = detectPassiveEvents.hasSupport ? { passive: true } : false;
+
+class PrivacyDropdownMenu extends React.PureComponent {
+
+  static propTypes = {
+    style: PropTypes.object,
+    items: PropTypes.array.isRequired,
+    value: PropTypes.string.isRequired,
+    onClose: PropTypes.func.isRequired,
+    onChange: PropTypes.func.isRequired,
+  };
+
+  handleDocumentClick = e => {
+    if (this.node && !this.node.contains(e.target)) {
+      this.props.onClose();
+    }
+  }
+
+  handleClick = e => {
+    if (e.key === 'Escape') {
+      this.props.onClose();
+    } else if (!e.key || e.key === 'Enter') {
+      const value = e.currentTarget.getAttribute('data-index');
+
+      e.preventDefault();
+
+      this.props.onClose();
+      this.props.onChange(value);
+    }
+  }
+
+  componentDidMount () {
+    document.addEventListener('click', this.handleDocumentClick, false);
+    document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);
+  }
+
+  componentWillUnmount () {
+    document.removeEventListener('click', this.handleDocumentClick, false);
+    document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions);
+  }
+
+  setRef = c => {
+    this.node = c;
+  }
+
+  render () {
+    const { style, items, value } = this.props;
+
+    return (
+      <Motion defaultStyle={{ opacity: 0, scaleX: 0.85, scaleY: 0.75 }} style={{ opacity: spring(1, { damping: 35, stiffness: 400 }), scaleX: spring(1, { damping: 35, stiffness: 400 }), scaleY: spring(1, { damping: 35, stiffness: 400 }) }}>
+        {({ opacity, scaleX, scaleY }) => (
+          <div className='privacy-dropdown__dropdown' style={{ ...style, opacity: opacity, transform: `scale(${scaleX}, ${scaleY})` }} ref={this.setRef}>
+            {items.map(item =>
+              <div role='button' tabIndex='0' key={item.value} data-index={item.value} onKeyDown={this.handleClick} onClick={this.handleClick} className={classNames('privacy-dropdown__option', { active: item.value === value })}>
+                <div className='privacy-dropdown__option__icon'>
+                  <i className={`fa fa-fw fa-${item.icon}`} />
+                </div>
+
+                <div className='privacy-dropdown__option__content'>
+                  <strong>{item.text}</strong>
+                  {item.meta}
+                </div>
+              </div>
+            )}
+          </div>
+        )}
+      </Motion>
+    );
+  }
+
+}
 
 @injectIntl
 export default class PrivacyDropdown extends React.PureComponent {
@@ -55,26 +126,30 @@ export default class PrivacyDropdown extends React.PureComponent {
 
   handleModalActionClick = (e) => {
     e.preventDefault();
+
     const { value } = this.options[e.currentTarget.getAttribute('data-index')];
+
     this.props.onModalClose();
     this.props.onChange(value);
   }
 
-  handleClick = (e) => {
-    if (e.key === 'Escape') {
-      this.setState({ open: false });
-    } else if (!e.key || e.key === 'Enter') {
-      const value = e.currentTarget.getAttribute('data-index');
-      e.preventDefault();
-      this.setState({ open: false });
-      this.props.onChange(value);
+  handleKeyDown = e => {
+    switch(e.key) {
+    case 'Enter':
+      this.handleToggle();
+      break;
+    case 'Escape':
+      this.handleClose();
+      break;
     }
   }
 
-  onGlobalClick = (e) => {
-    if (e.target !== this.node && !this.node.contains(e.target) && this.state.open) {
-      this.setState({ open: false });
-    }
+  handleClose = () => {
+    this.setState({ open: false });
+  }
+
+  handleChange = value => {
+    this.props.onChange(value);
   }
 
   componentWillMount () {
@@ -88,20 +163,6 @@ export default class PrivacyDropdown extends React.PureComponent {
     ];
   }
 
-  componentDidMount () {
-    window.addEventListener('click', this.onGlobalClick);
-    window.addEventListener('touchstart', this.onGlobalClick, detectPassiveEvents.hasSupport ? { passive: true } : false);
-  }
-
-  componentWillUnmount () {
-    window.removeEventListener('click', this.onGlobalClick);
-    window.removeEventListener('touchstart', this.onGlobalClick, detectPassiveEvents.hasSupport ? { passive: true } : false);
-  }
-
-  setRef = (c) => {
-    this.node = c;
-  }
-
   render () {
     const { value, intl } = this.props;
     const { open } = this.state;
@@ -109,19 +170,29 @@ export default class PrivacyDropdown extends React.PureComponent {
     const valueOption = this.options.find(item => item.value === value);
 
     return (
-      <div ref={this.setRef} className={`privacy-dropdown ${open ? 'active' : ''}`}>
-        <div className='privacy-dropdown__value'><IconButton className='privacy-dropdown__value-icon' icon={valueOption.icon} title={intl.formatMessage(messages.change_privacy)} size={18} expanded={open} active={open} inverted onClick={this.handleToggle} style={iconStyle} /></div>
-        <div className='privacy-dropdown__dropdown'>
-          {open && this.options.map(item =>
-            <div role='button' tabIndex='0' key={item.value} data-index={item.value} onKeyDown={this.handleClick} onClick={this.handleClick} 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.text}</strong>
-                {item.meta}
-              </div>
-            </div>
-          )}
+      <div className={classNames('privacy-dropdown', { active: open })} onKeyDown={this.handleKeyDown}>
+        <div className={classNames('privacy-dropdown__value', { active: this.options.indexOf(valueOption) === 0 })}>
+          <IconButton
+            className='privacy-dropdown__value-icon'
+            icon={valueOption.icon}
+            title={intl.formatMessage(messages.change_privacy)}
+            size={18}
+            expanded={open}
+            active={open}
+            inverted
+            onClick={this.handleToggle}
+            style={{ height: null, lineHeight: '27px' }}
+          />
         </div>
+
+        <Overlay show={open} placement='bottom' target={this}>
+          <PrivacyDropdownMenu
+            items={this.options}
+            value={value}
+            onClose={this.handleClose}
+            onChange={this.handleChange}
+          />
+        </Overlay>
       </div>
     );
   }
diff --git a/app/javascript/mastodon/features/compose/components/search.js b/app/javascript/mastodon/features/compose/components/search.js
index 85ef767ab..f57d54618 100644
--- a/app/javascript/mastodon/features/compose/components/search.js
+++ b/app/javascript/mastodon/features/compose/components/search.js
@@ -1,11 +1,47 @@
 import React from 'react';
 import PropTypes from 'prop-types';
-import { defineMessages, injectIntl } from 'react-intl';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import Overlay from 'react-overlays/lib/Overlay';
+import Motion from 'react-motion/lib/Motion';
+import spring from 'react-motion/lib/spring';
 
 const messages = defineMessages({
   placeholder: { id: 'search.placeholder', defaultMessage: 'Search' },
 });
 
+class SearchPopout extends React.PureComponent {
+
+  static propTypes = {
+    style: PropTypes.object,
+  };
+
+  render () {
+    const { style } = this.props;
+
+    return (
+      <div style={{ ...style, position: 'absolute', width: 285 }}>
+        <Motion defaultStyle={{ opacity: 0, scaleX: 0.85, scaleY: 0.75 }} style={{ opacity: spring(1, { damping: 35, stiffness: 400 }), scaleX: spring(1, { damping: 35, stiffness: 400 }), scaleY: spring(1, { damping: 35, stiffness: 400 }) }}>
+          {({ opacity, scaleX, scaleY }) => (
+            <div className='search-popout' style={{ opacity: opacity, transform: `scale(${scaleX}, ${scaleY})` }}>
+              <h4><FormattedMessage id='search_popout.search_format' defaultMessage='Advanced search format' /></h4>
+
+              <ul>
+                <li><em>#example</em> <FormattedMessage id='search_popout.tips.hashtag' defaultMessage='hashtag' /></li>
+                <li><em>@username@domain</em> <FormattedMessage id='search_popout.tips.user' defaultMessage='user' /></li>
+                <li><em>URL</em> <FormattedMessage id='search_popout.tips.user' defaultMessage='user' /></li>
+                <li><em>URL</em> <FormattedMessage id='search_popout.tips.status' defaultMessage='status' /></li>
+              </ul>
+
+              <FormattedMessage id='search_popout.tips.text' defaultMessage='Simple text returns matching display names, usernames and hashtags' />
+            </div>
+          )}
+        </Motion>
+      </div>
+    );
+  }
+
+}
+
 @injectIntl
 export default class Search extends React.PureComponent {
 
@@ -19,6 +55,10 @@ export default class Search extends React.PureComponent {
     intl: PropTypes.object.isRequired,
   };
 
+  state = {
+    expanded: false,
+  };
+
   handleChange = (e) => {
     this.props.onChange(e.target.value);
   }
@@ -35,6 +75,8 @@ export default class Search extends React.PureComponent {
     if (e.key === 'Enter') {
       e.preventDefault();
       this.props.onSubmit();
+    } else if (e.key === 'Escape') {
+      document.querySelector('.ui').parentElement.focus();
     }
   }
 
@@ -43,11 +85,17 @@ export default class Search extends React.PureComponent {
   }
 
   handleFocus = () => {
+    this.setState({ expanded: true });
     this.props.onShow();
   }
 
+  handleBlur = () => {
+    this.setState({ expanded: false });
+  }
+
   render () {
     const { intl, value, submitted } = this.props;
+    const { expanded } = this.state;
     const hasValue = value.length > 0 || submitted;
 
     return (
@@ -62,6 +110,7 @@ export default class Search extends React.PureComponent {
             onChange={this.handleChange}
             onKeyUp={this.handleKeyDown}
             onFocus={this.handleFocus}
+            onBlur={this.handleBlur}
           />
         </label>
 
@@ -69,6 +118,10 @@ export default class Search extends React.PureComponent {
           <i className={`fa fa-search ${hasValue ? '' : 'active'}`} />
           <i aria-label={intl.formatMessage(messages.placeholder)} className={`fa fa-times-circle ${hasValue ? 'active' : ''}`} />
         </div>
+
+        <Overlay show={expanded && !hasValue} placement='bottom' target={this}>
+          <SearchPopout />
+        </Overlay>
       </div>
     );
   }
diff --git a/app/javascript/mastodon/features/compose/components/search_results.js b/app/javascript/mastodon/features/compose/components/search_results.js
index cae4ca412..a3e68643f 100644
--- a/app/javascript/mastodon/features/compose/components/search_results.js
+++ b/app/javascript/mastodon/features/compose/components/search_results.js
@@ -3,7 +3,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
 import { FormattedMessage } from 'react-intl';
 import AccountContainer from '../../../containers/account_container';
 import StatusContainer from '../../../../glitch/components/status/container';
-import Link from 'react-router-dom/Link';
+import { Link } from 'react-router-dom';
 import ImmutablePureComponent from 'react-immutable-pure-component';
 
 export default class SearchResults extends ImmutablePureComponent {
diff --git a/app/javascript/mastodon/features/compose/components/upload.js b/app/javascript/mastodon/features/compose/components/upload.js
new file mode 100644
index 000000000..cd9e08360
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/components/upload.js
@@ -0,0 +1,96 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import IconButton from '../../../components/icon_button';
+import Motion from 'react-motion/lib/Motion';
+import spring from 'react-motion/lib/spring';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import { defineMessages, injectIntl } from 'react-intl';
+import classNames from 'classnames';
+
+const messages = defineMessages({
+  undo: { id: 'upload_form.undo', defaultMessage: 'Undo' },
+  description: { id: 'upload_form.description', defaultMessage: 'Describe for the visually impaired' },
+});
+
+@injectIntl
+export default class Upload extends ImmutablePureComponent {
+
+  static propTypes = {
+    media: ImmutablePropTypes.map.isRequired,
+    intl: PropTypes.object.isRequired,
+    onUndo: PropTypes.func.isRequired,
+    onDescriptionChange: PropTypes.func.isRequired,
+  };
+
+  state = {
+    hovered: false,
+    focused: false,
+    dirtyDescription: null,
+  };
+
+  handleUndoClick = () => {
+    this.props.onUndo(this.props.media.get('id'));
+  }
+
+  handleInputChange = e => {
+    this.setState({ dirtyDescription: e.target.value });
+  }
+
+  handleMouseEnter = () => {
+    this.setState({ hovered: true });
+  }
+
+  handleMouseLeave = () => {
+    this.setState({ hovered: false });
+  }
+
+  handleInputFocus = () => {
+    this.setState({ focused: true });
+  }
+
+  handleInputBlur = () => {
+    const { dirtyDescription } = this.state;
+
+    this.setState({ focused: false, dirtyDescription: null });
+
+    if (dirtyDescription !== null) {
+      this.props.onDescriptionChange(this.props.media.get('id'), dirtyDescription);
+    }
+  }
+
+  render () {
+    const { intl, media } = this.props;
+    const active          = this.state.hovered || this.state.focused;
+    const description     = this.state.dirtyDescription || media.get('description') || '';
+
+    return (
+      <div className='compose-form__upload' onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}>
+        <Motion defaultStyle={{ scale: 0.8 }} style={{ scale: spring(1, { stiffness: 180, damping: 12 }) }}>
+          {({ scale }) => (
+            <div className='compose-form__upload-thumbnail' style={{ transform: `translateZ(0) scale(${scale})`, backgroundImage: `url(${media.get('preview_url')})` }}>
+              <IconButton icon='times' title={intl.formatMessage(messages.undo)} size={36} onClick={this.handleUndoClick} />
+
+              <div className={classNames('compose-form__upload-description', { active })}>
+                <label>
+                  <span style={{ display: 'none' }}>{intl.formatMessage(messages.description)}</span>
+
+                  <input
+                    placeholder={intl.formatMessage(messages.description)}
+                    type='text'
+                    value={description}
+                    maxLength={420}
+                    onFocus={this.handleInputFocus}
+                    onChange={this.handleInputChange}
+                    onBlur={this.handleInputBlur}
+                  />
+                </label>
+              </div>
+            </div>
+          )}
+        </Motion>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/mastodon/features/compose/components/upload_form.js b/app/javascript/mastodon/features/compose/components/upload_form.js
index cf2d2658a..b7f112205 100644
--- a/app/javascript/mastodon/features/compose/components/upload_form.js
+++ b/app/javascript/mastodon/features/compose/components/upload_form.js
@@ -1,49 +1,27 @@
 import React from 'react';
 import ImmutablePropTypes from 'react-immutable-proptypes';
-import PropTypes from 'prop-types';
-import IconButton from '../../../components/icon_button';
-import { defineMessages, injectIntl } from 'react-intl';
 import UploadProgressContainer from '../containers/upload_progress_container';
-import Motion from 'react-motion/lib/Motion';
-import spring from 'react-motion/lib/spring';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import UploadContainer from '../containers/upload_container';
 
-const messages = defineMessages({
-  undo: { id: 'upload_form.undo', defaultMessage: 'Undo' },
-});
-
-@injectIntl
-export default class UploadForm extends React.PureComponent {
+export default class UploadForm extends ImmutablePureComponent {
 
   static propTypes = {
-    media: ImmutablePropTypes.list.isRequired,
-    onRemoveFile: PropTypes.func.isRequired,
-    intl: PropTypes.object.isRequired,
+    mediaIds: ImmutablePropTypes.list.isRequired,
   };
 
-  onRemoveFile = (e) => {
-    const id = e.currentTarget.parentElement.getAttribute('data-id');
-    this.props.onRemoveFile(id);
-  }
-
   render () {
-    const { intl, media } = this.props;
-
-    const uploads = media.map(attachment =>
-      <div className='compose-form__upload' key={attachment.get('id')}>
-        <Motion defaultStyle={{ scale: 0.8 }} style={{ scale: spring(1, { stiffness: 180, damping: 12 }) }}>
-          {({ scale }) =>
-            <div className='compose-form__upload-thumbnail' data-id={attachment.get('id')} style={{ transform: `translateZ(0) scale(${scale})`, backgroundImage: `url(${attachment.get('preview_url')})` }}>
-              <IconButton icon='times' title={intl.formatMessage(messages.undo)} size={36} onClick={this.onRemoveFile} />
-            </div>
-          }
-        </Motion>
-      </div>
-    );
+    const { mediaIds } = this.props;
 
     return (
       <div className='compose-form__upload-wrapper'>
         <UploadProgressContainer />
-        <div className='compose-form__uploads-wrapper'>{uploads}</div>
+
+        <div className='compose-form__uploads-wrapper'>
+          {mediaIds.map(id => (
+            <UploadContainer id={id} key={id} />
+          ))}
+        </div>
       </div>
     );
   }
diff --git a/app/javascript/mastodon/features/compose/components/warning.js b/app/javascript/mastodon/features/compose/components/warning.js
index 75f36b840..a0814e984 100644
--- a/app/javascript/mastodon/features/compose/components/warning.js
+++ b/app/javascript/mastodon/features/compose/components/warning.js
@@ -1,5 +1,7 @@
 import React from 'react';
 import PropTypes from 'prop-types';
+import Motion from 'react-motion/lib/Motion';
+import spring from 'react-motion/lib/spring';
 
 export default class Warning extends React.PureComponent {
 
@@ -11,9 +13,13 @@ export default class Warning extends React.PureComponent {
     const { message } = this.props;
 
     return (
-      <div className='compose-form__warning'>
-        {message}
-      </div>
+      <Motion defaultStyle={{ opacity: 0, scaleX: 0.85, scaleY: 0.75 }} style={{ opacity: spring(1, { damping: 35, stiffness: 400 }), scaleX: spring(1, { damping: 35, stiffness: 400 }), scaleY: spring(1, { damping: 35, stiffness: 400 }) }}>
+        {({ opacity, scaleX, scaleY }) => (
+          <div className='compose-form__warning' style={{ opacity: opacity, transform: `scale(${scaleX}, ${scaleY})` }}>
+            {message}
+          </div>
+        )}
+      </Motion>
     );
   }
 
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
index 7a8026bbc..71944128c 100644
--- a/app/javascript/mastodon/features/compose/containers/emoji_picker_dropdown_container.js
+++ b/app/javascript/mastodon/features/compose/containers/emoji_picker_dropdown_container.js
@@ -1,8 +1,83 @@
 import { connect } from 'react-redux';
 import EmojiPickerDropdown from '../components/emoji_picker_dropdown';
+import { changeSetting } from '../../../actions/settings';
+import { createSelector } from 'reselect';
+import { Map as ImmutableMap } from 'immutable';
+import { useEmoji } from '../../../actions/emojis';
+
+const perLine = 8;
+const lines   = 2;
+
+const DEFAULTS = [
+  '+1',
+  'grinning',
+  'kissing_heart',
+  'heart_eyes',
+  'laughing',
+  'stuck_out_tongue_winking_eye',
+  'sweat_smile',
+  'joy',
+  'yum',
+  'disappointed',
+  'thinking_face',
+  'weary',
+  'sob',
+  'sunglasses',
+  'heart',
+  'ok_hand',
+];
+
+const getFrequentlyUsedEmojis = createSelector([
+  state => state.getIn(['settings', 'frequentlyUsedEmojis'], ImmutableMap()),
+], emojiCounters => {
+  let emojis = emojiCounters
+    .keySeq()
+    .sort((a, b) => emojiCounters.get(a) - emojiCounters.get(b))
+    .reverse()
+    .slice(0, perLine * lines)
+    .toArray();
+
+  if (emojis.length < DEFAULTS.length) {
+    emojis = emojis.concat(DEFAULTS.slice(0, DEFAULTS.length - emojis.length));
+  }
+
+  return emojis;
+});
+
+const getCustomEmojis = createSelector([
+  state => state.get('custom_emojis'),
+], emojis => emojis.sort((a, b) => {
+  const aShort = a.get('shortcode').toLowerCase();
+  const bShort = b.get('shortcode').toLowerCase();
+
+  if (aShort < bShort) {
+    return -1;
+  } else if (aShort > bShort ) {
+    return 1;
+  } else {
+    return 0;
+  }
+}));
 
 const mapStateToProps = state => ({
-  custom_emojis: state.get('custom_emojis'),
+  custom_emojis: getCustomEmojis(state),
+  autoPlay: state.getIn(['meta', 'auto_play_gif']),
+  skinTone: state.getIn(['settings', 'skinTone']),
+  frequentlyUsedEmojis: getFrequentlyUsedEmojis(state),
+});
+
+const mapDispatchToProps = (dispatch, { onPickEmoji }) => ({
+  onSkinTone: skinTone => {
+    dispatch(changeSetting(['skinTone'], skinTone));
+  },
+
+  onPickEmoji: emoji => {
+    dispatch(useEmoji(emoji));
+
+    if (onPickEmoji) {
+      onPickEmoji(emoji);
+    }
+  },
 });
 
-export default connect(mapStateToProps)(EmojiPickerDropdown);
+export default connect(mapStateToProps, mapDispatchToProps)(EmojiPickerDropdown);
diff --git a/app/javascript/mastodon/features/compose/containers/upload_container.js b/app/javascript/mastodon/features/compose/containers/upload_container.js
new file mode 100644
index 000000000..ca9c3b704
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/containers/upload_container.js
@@ -0,0 +1,21 @@
+import { connect } from 'react-redux';
+import Upload from '../components/upload';
+import { undoUploadCompose, changeUploadCompose } from '../../../actions/compose';
+
+const mapStateToProps = (state, { id }) => ({
+  media: state.getIn(['compose', 'media_attachments']).find(item => item.get('id') === id),
+});
+
+const mapDispatchToProps = dispatch => ({
+
+  onUndo: id => {
+    dispatch(undoUploadCompose(id));
+  },
+
+  onDescriptionChange: (id, description) => {
+    dispatch(changeUploadCompose(id, description));
+  },
+
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(Upload);
diff --git a/app/javascript/mastodon/features/compose/containers/upload_form_container.js b/app/javascript/mastodon/features/compose/containers/upload_form_container.js
index 4612599f1..a6798bf51 100644
--- a/app/javascript/mastodon/features/compose/containers/upload_form_container.js
+++ b/app/javascript/mastodon/features/compose/containers/upload_form_container.js
@@ -1,17 +1,8 @@
 import { connect } from 'react-redux';
 import UploadForm from '../components/upload_form';
-import { undoUploadCompose } from '../../../actions/compose';
 
 const mapStateToProps = state => ({
-  media: state.getIn(['compose', 'media_attachments']),
+  mediaIds: state.getIn(['compose', 'media_attachments']).map(item => item.get('id')),
 });
 
-const mapDispatchToProps = dispatch => ({
-
-  onRemoveFile (media_id) {
-    dispatch(undoUploadCompose(media_id));
-  },
-
-});
-
-export default connect(mapStateToProps, mapDispatchToProps)(UploadForm);
+export default connect(mapStateToProps)(UploadForm);
diff --git a/app/javascript/mastodon/features/compose/index.js b/app/javascript/mastodon/features/compose/index.js
index f0bce1e40..9068648bd 100644
--- a/app/javascript/mastodon/features/compose/index.js
+++ b/app/javascript/mastodon/features/compose/index.js
@@ -7,7 +7,7 @@ import { connect } from 'react-redux';
 import { mountCompose, unmountCompose } from '../../actions/compose';
 import { openModal } from '../../actions/modal';
 import { changeLocalSetting } from '../../../glitch/actions/local_settings';
-import Link from 'react-router-dom/Link';
+import { Link } from 'react-router-dom';
 import { injectIntl, defineMessages } from 'react-intl';
 import SearchContainer from './containers/search_container';
 import Motion from 'react-motion/lib/Motion';
diff --git a/app/javascript/mastodon/features/emoji/emoji.js b/app/javascript/mastodon/features/emoji/emoji.js
new file mode 100644
index 000000000..b70fc2b37
--- /dev/null
+++ b/app/javascript/mastodon/features/emoji/emoji.js
@@ -0,0 +1,77 @@
+import unicodeMapping from './emoji_unicode_mapping_light';
+import Trie from 'substring-trie';
+
+const trie = new Trie(Object.keys(unicodeMapping));
+
+const assetHost = process.env.CDN_HOST || '';
+
+let allowAnimations = false;
+
+const emojify = (str, customEmojis = {}) => {
+  let rtn = '';
+  for (;;) {
+    let match, i = 0, tag;
+    while (i < str.length && (tag = '<&:'.indexOf(str[i])) === -1 && !(match = trie.search(str.slice(i)))) {
+      i += str.codePointAt(i) < 65536 ? 1 : 2;
+    }
+    let rend, replacement = '';
+    if (i === str.length) {
+      break;
+    } else if (str[i] === ':') {
+      if (!(() => {
+        rend = str.indexOf(':', i + 1) + 1;
+        if (!rend) return false; // no pair of ':'
+        const lt = str.indexOf('<', i + 1);
+        if (!(lt === -1 || lt >= rend)) return false; // tag appeared before closing ':'
+        const shortname = str.slice(i, rend);
+        // now got a replacee as ':shortname:'
+        // if you want additional emoji handler, add statements below which set replacement and return true.
+        if (shortname in customEmojis) {
+          const filename = allowAnimations ? customEmojis[shortname].url : customEmojis[shortname].static_url;
+          replacement = `<img draggable="false" class="emojione" alt="${shortname}" title="${shortname}" src="${filename}" />`;
+          return true;
+        }
+        return false;
+      })()) rend = ++i;
+    } else if (tag >= 0) { // <, &
+      rend = str.indexOf('>;'[tag], i + 1) + 1;
+      if (!rend) break;
+      i = rend;
+    } else { // matched to unicode emoji
+      const { filename, shortCode } = unicodeMapping[match];
+      const title = shortCode ? `:${shortCode}:` : '';
+      replacement = `<img draggable="false" class="emojione" alt="${match}" title="${title}" src="${assetHost}/emoji/${filename}.svg" />`;
+      rend = i + match.length;
+    }
+    rtn += str.slice(0, i) + replacement;
+    str = str.slice(rend);
+  }
+  return rtn + str;
+};
+
+export default emojify;
+
+export const buildCustomEmojis = (customEmojis, overrideAllowAnimations = false) => {
+  const emojis = [];
+
+  allowAnimations = overrideAllowAnimations;
+
+  customEmojis.forEach(emoji => {
+    const shortcode = emoji.get('shortcode');
+    const url       = allowAnimations ? emoji.get('url') : emoji.get('static_url');
+    const name      = shortcode.replace(':', '');
+
+    emojis.push({
+      id: name,
+      name,
+      short_names: [name],
+      text: '',
+      emoticons: [],
+      keywords: [name],
+      imageUrl: url,
+      custom: true,
+    });
+  });
+
+  return emojis;
+};
diff --git a/app/javascript/mastodon/features/emoji/emoji_compressed.js b/app/javascript/mastodon/features/emoji/emoji_compressed.js
new file mode 100644
index 000000000..3bd89cf3b
--- /dev/null
+++ b/app/javascript/mastodon/features/emoji/emoji_compressed.js
@@ -0,0 +1,92 @@
+// @preval
+// http://www.unicode.org/Public/emoji/5.0/emoji-test.txt
+// This file contains the compressed version of the emoji data from
+// both emoji_map.json and from emoji-mart's emojiIndex and data objects.
+// It's designed to be emitted in an array format to take up less space
+// over the wire.
+
+const { unicodeToFilename } = require('./unicode_to_filename');
+const { unicodeToUnifiedName } = require('./unicode_to_unified_name');
+const emojiMap         = require('./emoji_map.json');
+const { emojiIndex } = require('emoji-mart');
+const emojiMartData = require('emoji-mart/dist/data').default;
+const excluded       = ['®', '©', '™'];
+const skins          = ['🏻', '🏼', '🏽', '🏾', '🏿'];
+const shortcodeMap   = {};
+
+const shortCodesToEmojiData = {};
+const emojisWithoutShortCodes = [];
+
+Object.keys(emojiIndex.emojis).forEach(key => {
+  shortcodeMap[emojiIndex.emojis[key].native] = emojiIndex.emojis[key].id;
+});
+
+const stripModifiers = unicode => {
+  skins.forEach(tone => {
+    unicode = unicode.replace(tone, '');
+  });
+
+  return unicode;
+};
+
+Object.keys(emojiMap).forEach(key => {
+  if (excluded.includes(key)) {
+    delete emojiMap[key];
+    return;
+  }
+
+  const normalizedKey = stripModifiers(key);
+  let shortcode       = shortcodeMap[normalizedKey];
+
+  if (!shortcode) {
+    shortcode = shortcodeMap[normalizedKey + '\uFE0F'];
+  }
+
+  const filename = emojiMap[key];
+
+  const filenameData = [key];
+
+  if (unicodeToFilename(key) !== filename) {
+    // filename can't be derived using unicodeToFilename
+    filenameData.push(filename);
+  }
+
+  if (typeof shortcode === 'undefined') {
+    emojisWithoutShortCodes.push(filenameData);
+  } else {
+    if (!Array.isArray(shortCodesToEmojiData[shortcode])) {
+      shortCodesToEmojiData[shortcode] = [[]];
+    }
+    shortCodesToEmojiData[shortcode][0].push(filenameData);
+  }
+});
+
+Object.keys(emojiIndex.emojis).forEach(key => {
+  const { native } = emojiIndex.emojis[key];
+  const { short_names, search, unified } = emojiMartData.emojis[key];
+  if (short_names[0] !== key) {
+    throw new Error('The compresser expects the first short_code to be the ' +
+      'key. It may need to be rewritten if the emoji change such that this ' +
+      'is no longer the case.');
+  }
+
+  short_names.splice(0, 1); // first short name can be inferred from the key
+
+  const searchData = [native, short_names, search];
+  if (unicodeToUnifiedName(native) !== unified) {
+    // unified name can't be derived from unicodeToUnifiedName
+    searchData.push(unified);
+  }
+
+  shortCodesToEmojiData[key].push(searchData);
+});
+
+// JSON.parse/stringify is to emulate what @preval is doing and avoid any
+// inconsistent behavior in dev mode
+module.exports = JSON.parse(JSON.stringify([
+  shortCodesToEmojiData,
+  emojiMartData.skins,
+  emojiMartData.categories,
+  emojiMartData.short_names,
+  emojisWithoutShortCodes,
+]));
diff --git a/app/javascript/mastodon/emoji_map.json b/app/javascript/mastodon/features/emoji/emoji_map.json
index 13753ba84..13753ba84 100644
--- a/app/javascript/mastodon/emoji_map.json
+++ b/app/javascript/mastodon/features/emoji/emoji_map.json
diff --git a/app/javascript/mastodon/features/emoji/emoji_mart_data_light.js b/app/javascript/mastodon/features/emoji/emoji_mart_data_light.js
new file mode 100644
index 000000000..45086fc4c
--- /dev/null
+++ b/app/javascript/mastodon/features/emoji/emoji_mart_data_light.js
@@ -0,0 +1,41 @@
+// The output of this module is designed to mimic emoji-mart's
+// "data" object, such that we can use it for a light version of emoji-mart's
+// emojiIndex.search functionality.
+const { unicodeToUnifiedName } = require('./unicode_to_unified_name');
+const [ shortCodesToEmojiData, skins, categories, short_names ] = require('./emoji_compressed');
+
+const emojis = {};
+
+// decompress
+Object.keys(shortCodesToEmojiData).forEach((shortCode) => {
+  let [
+    filenameData, // eslint-disable-line no-unused-vars
+    searchData,
+  ] = shortCodesToEmojiData[shortCode];
+  let [
+    native,
+    short_names,
+    search,
+    unified,
+  ] = searchData;
+
+  if (!unified) {
+    // unified name can be derived from unicodeToUnifiedName
+    unified = unicodeToUnifiedName(native);
+  }
+
+  short_names = [shortCode].concat(short_names);
+  emojis[shortCode] = {
+    native,
+    search,
+    short_names,
+    unified,
+  };
+});
+
+module.exports = {
+  emojis,
+  skins,
+  categories,
+  short_names,
+};
diff --git a/app/javascript/mastodon/features/emoji/emoji_mart_search_light.js b/app/javascript/mastodon/features/emoji/emoji_mart_search_light.js
new file mode 100644
index 000000000..5755bf1c4
--- /dev/null
+++ b/app/javascript/mastodon/features/emoji/emoji_mart_search_light.js
@@ -0,0 +1,157 @@
+// This code is largely borrowed from:
+// https://github.com/missive/emoji-mart/blob/5f2ffcc/src/utils/emoji-index.js
+
+import data from './emoji_mart_data_light';
+import { getData, getSanitizedData, intersect } from './emoji_utils';
+
+let originalPool = {};
+let index = {};
+let emojisList = {};
+let emoticonsList = {};
+
+for (let emoji in data.emojis) {
+  let emojiData = data.emojis[emoji];
+  let { short_names, emoticons } = emojiData;
+  let id = short_names[0];
+
+  if (emoticons) {
+    emoticons.forEach(emoticon => {
+      if (emoticonsList[emoticon]) {
+        return;
+      }
+
+      emoticonsList[emoticon] = id;
+    });
+  }
+
+  emojisList[id] = getSanitizedData(id);
+  originalPool[id] = emojiData;
+}
+
+function addCustomToPool(custom, pool) {
+  custom.forEach((emoji) => {
+    let emojiId = emoji.id || emoji.short_names[0];
+
+    if (emojiId && !pool[emojiId]) {
+      pool[emojiId] = getData(emoji);
+      emojisList[emojiId] = getSanitizedData(emoji);
+    }
+  });
+}
+
+function search(value, { emojisToShowFilter, maxResults, include, exclude, custom = [] } = {}) {
+  addCustomToPool(custom, originalPool);
+
+  maxResults = maxResults || 75;
+  include = include || [];
+  exclude = exclude || [];
+
+  let results = null,
+    pool = originalPool;
+
+  if (value.length) {
+    if (value === '-' || value === '-1') {
+      return [emojisList['-1']];
+    }
+
+    let values = value.toLowerCase().split(/[\s|,|\-|_]+/),
+      allResults = [];
+
+    if (values.length > 2) {
+      values = [values[0], values[1]];
+    }
+
+    if (include.length || exclude.length) {
+      pool = {};
+
+      data.categories.forEach(category => {
+        let isIncluded = include && include.length ? include.indexOf(category.name.toLowerCase()) > -1 : true;
+        let isExcluded = exclude && exclude.length ? exclude.indexOf(category.name.toLowerCase()) > -1 : false;
+        if (!isIncluded || isExcluded) {
+          return;
+        }
+
+        category.emojis.forEach(emojiId => pool[emojiId] = data.emojis[emojiId]);
+      });
+
+      if (custom.length) {
+        let customIsIncluded = include && include.length ? include.indexOf('custom') > -1 : true;
+        let customIsExcluded = exclude && exclude.length ? exclude.indexOf('custom') > -1 : false;
+        if (customIsIncluded && !customIsExcluded) {
+          addCustomToPool(custom, pool);
+        }
+      }
+    }
+
+    allResults = values.map((value) => {
+      let aPool = pool,
+        aIndex = index,
+        length = 0;
+
+      for (let charIndex = 0; charIndex < value.length; charIndex++) {
+        const char = value[charIndex];
+        length++;
+
+        aIndex[char] = aIndex[char] || {};
+        aIndex = aIndex[char];
+
+        if (!aIndex.results) {
+          let scores = {};
+
+          aIndex.results = [];
+          aIndex.pool = {};
+
+          for (let id in aPool) {
+            let emoji = aPool[id],
+              { search } = emoji,
+              sub = value.substr(0, length),
+              subIndex = search.indexOf(sub);
+
+            if (subIndex !== -1) {
+              let score = subIndex + 1;
+              if (sub === id) score = 0;
+
+              aIndex.results.push(emojisList[id]);
+              aIndex.pool[id] = emoji;
+
+              scores[id] = score;
+            }
+          }
+
+          aIndex.results.sort((a, b) => {
+            let aScore = scores[a.id],
+              bScore = scores[b.id];
+
+            return aScore - bScore;
+          });
+        }
+
+        aPool = aIndex.pool;
+      }
+
+      return aIndex.results;
+    }).filter(a => a);
+
+    if (allResults.length > 1) {
+      results = intersect.apply(null, allResults);
+    } else if (allResults.length) {
+      results = allResults[0];
+    } else {
+      results = [];
+    }
+  }
+
+  if (results) {
+    if (emojisToShowFilter) {
+      results = results.filter((result) => emojisToShowFilter(data.emojis[result.id].unified));
+    }
+
+    if (results && results.length > maxResults) {
+      results = results.slice(0, maxResults);
+    }
+  }
+
+  return results;
+}
+
+export { search };
diff --git a/app/javascript/mastodon/features/emoji/emoji_picker.js b/app/javascript/mastodon/features/emoji/emoji_picker.js
new file mode 100644
index 000000000..7e145381e
--- /dev/null
+++ b/app/javascript/mastodon/features/emoji/emoji_picker.js
@@ -0,0 +1,7 @@
+import Picker from 'emoji-mart/dist-es/components/picker';
+import Emoji from 'emoji-mart/dist-es/components/emoji';
+
+export {
+  Picker,
+  Emoji,
+};
diff --git a/app/javascript/mastodon/features/emoji/emoji_unicode_mapping_light.js b/app/javascript/mastodon/features/emoji/emoji_unicode_mapping_light.js
new file mode 100644
index 000000000..918684c31
--- /dev/null
+++ b/app/javascript/mastodon/features/emoji/emoji_unicode_mapping_light.js
@@ -0,0 +1,35 @@
+// A mapping of unicode strings to an object containing the filename
+// (i.e. the svg filename) and a shortCode intended to be shown
+// as a "title" attribute in an HTML element (aka tooltip).
+
+const [
+  shortCodesToEmojiData,
+  skins, // eslint-disable-line no-unused-vars
+  categories, // eslint-disable-line no-unused-vars
+  short_names, // eslint-disable-line no-unused-vars
+  emojisWithoutShortCodes,
+] = require('./emoji_compressed');
+const { unicodeToFilename } = require('./unicode_to_filename');
+
+// decompress
+const unicodeMapping = {};
+
+function processEmojiMapData(emojiMapData, shortCode) {
+  let [ native, filename ] = emojiMapData;
+  if (!filename) {
+    // filename name can be derived from unicodeToFilename
+    filename = unicodeToFilename(native);
+  }
+  unicodeMapping[native] = {
+    shortCode: shortCode,
+    filename: filename,
+  };
+}
+
+Object.keys(shortCodesToEmojiData).forEach((shortCode) => {
+  let [ filenameData ] = shortCodesToEmojiData[shortCode];
+  filenameData.forEach(emojiMapData => processEmojiMapData(emojiMapData, shortCode));
+});
+emojisWithoutShortCodes.forEach(emojiMapData => processEmojiMapData(emojiMapData));
+
+module.exports = unicodeMapping;
diff --git a/app/javascript/mastodon/features/emoji/emoji_utils.js b/app/javascript/mastodon/features/emoji/emoji_utils.js
new file mode 100644
index 000000000..dbf725c1f
--- /dev/null
+++ b/app/javascript/mastodon/features/emoji/emoji_utils.js
@@ -0,0 +1,258 @@
+// This code is largely borrowed from:
+// https://github.com/missive/emoji-mart/blob/5f2ffcc/src/utils/index.js
+
+import data from './emoji_mart_data_light';
+
+const buildSearch = (data) => {
+  const search = [];
+
+  let addToSearch = (strings, split) => {
+    if (!strings) {
+      return;
+    }
+
+    (Array.isArray(strings) ? strings : [strings]).forEach((string) => {
+      (split ? string.split(/[-|_|\s]+/) : [string]).forEach((s) => {
+        s = s.toLowerCase();
+
+        if (search.indexOf(s) === -1) {
+          search.push(s);
+        }
+      });
+    });
+  };
+
+  addToSearch(data.short_names, true);
+  addToSearch(data.name, true);
+  addToSearch(data.keywords, false);
+  addToSearch(data.emoticons, false);
+
+  return search.join(',');
+};
+
+const _String = String;
+
+const stringFromCodePoint = _String.fromCodePoint || function () {
+  let MAX_SIZE = 0x4000;
+  let codeUnits = [];
+  let highSurrogate;
+  let lowSurrogate;
+  let index = -1;
+  let length = arguments.length;
+  if (!length) {
+    return '';
+  }
+  let result = '';
+  while (++index < length) {
+    let codePoint = Number(arguments[index]);
+    if (
+      !isFinite(codePoint) ||       // `NaN`, `+Infinity`, or `-Infinity`
+      codePoint < 0 ||              // not a valid Unicode code point
+      codePoint > 0x10FFFF ||       // not a valid Unicode code point
+      Math.floor(codePoint) !== codePoint // not an integer
+    ) {
+      throw RangeError('Invalid code point: ' + codePoint);
+    }
+    if (codePoint <= 0xFFFF) { // BMP code point
+      codeUnits.push(codePoint);
+    } else { // Astral code point; split in surrogate halves
+      // http://mathiasbynens.be/notes/javascript-encoding#surrogate-formulae
+      codePoint -= 0x10000;
+      highSurrogate = (codePoint >> 10) + 0xD800;
+      lowSurrogate = (codePoint % 0x400) + 0xDC00;
+      codeUnits.push(highSurrogate, lowSurrogate);
+    }
+    if (index + 1 === length || codeUnits.length > MAX_SIZE) {
+      result += String.fromCharCode.apply(null, codeUnits);
+      codeUnits.length = 0;
+    }
+  }
+  return result;
+};
+
+
+const _JSON = JSON;
+
+const COLONS_REGEX = /^(?:\:([^\:]+)\:)(?:\:skin-tone-(\d)\:)?$/;
+const SKINS = [
+  '1F3FA', '1F3FB', '1F3FC',
+  '1F3FD', '1F3FE', '1F3FF',
+];
+
+function unifiedToNative(unified) {
+  let unicodes = unified.split('-'),
+    codePoints = unicodes.map((u) => `0x${u}`);
+
+  return stringFromCodePoint.apply(null, codePoints);
+}
+
+function sanitize(emoji) {
+  let { name, short_names, skin_tone, skin_variations, emoticons, unified, custom, imageUrl } = emoji,
+    id = emoji.id || short_names[0],
+    colons = `:${id}:`;
+
+  if (custom) {
+    return {
+      id,
+      name,
+      colons,
+      emoticons,
+      custom,
+      imageUrl,
+    };
+  }
+
+  if (skin_tone) {
+    colons += `:skin-tone-${skin_tone}:`;
+  }
+
+  return {
+    id,
+    name,
+    colons,
+    emoticons,
+    unified: unified.toLowerCase(),
+    skin: skin_tone || (skin_variations ? 1 : null),
+    native: unifiedToNative(unified),
+  };
+}
+
+function getSanitizedData() {
+  return sanitize(getData(...arguments));
+}
+
+function getData(emoji, skin, set) {
+  let emojiData = {};
+
+  if (typeof emoji === 'string') {
+    let matches = emoji.match(COLONS_REGEX);
+
+    if (matches) {
+      emoji = matches[1];
+
+      if (matches[2]) {
+        skin = parseInt(matches[2]);
+      }
+    }
+
+    if (data.short_names.hasOwnProperty(emoji)) {
+      emoji = data.short_names[emoji];
+    }
+
+    if (data.emojis.hasOwnProperty(emoji)) {
+      emojiData = data.emojis[emoji];
+    }
+  } else if (emoji.id) {
+    if (data.short_names.hasOwnProperty(emoji.id)) {
+      emoji.id = data.short_names[emoji.id];
+    }
+
+    if (data.emojis.hasOwnProperty(emoji.id)) {
+      emojiData = data.emojis[emoji.id];
+      skin = skin || emoji.skin;
+    }
+  }
+
+  if (!Object.keys(emojiData).length) {
+    emojiData = emoji;
+    emojiData.custom = true;
+
+    if (!emojiData.search) {
+      emojiData.search = buildSearch(emoji);
+    }
+  }
+
+  emojiData.emoticons = emojiData.emoticons || [];
+  emojiData.variations = emojiData.variations || [];
+
+  if (emojiData.skin_variations && skin > 1 && set) {
+    emojiData = JSON.parse(_JSON.stringify(emojiData));
+
+    let skinKey = SKINS[skin - 1],
+      variationData = emojiData.skin_variations[skinKey];
+
+    if (!variationData.variations && emojiData.variations) {
+      delete emojiData.variations;
+    }
+
+    if (variationData[`has_img_${set}`]) {
+      emojiData.skin_tone = skin;
+
+      for (let k in variationData) {
+        let v = variationData[k];
+        emojiData[k] = v;
+      }
+    }
+  }
+
+  if (emojiData.variations && emojiData.variations.length) {
+    emojiData = JSON.parse(_JSON.stringify(emojiData));
+    emojiData.unified = emojiData.variations.shift();
+  }
+
+  return emojiData;
+}
+
+function uniq(arr) {
+  return arr.reduce((acc, item) => {
+    if (acc.indexOf(item) === -1) {
+      acc.push(item);
+    }
+    return acc;
+  }, []);
+}
+
+function intersect(a, b) {
+  const uniqA = uniq(a);
+  const uniqB = uniq(b);
+
+  return uniqA.filter(item => uniqB.indexOf(item) >= 0);
+}
+
+function deepMerge(a, b) {
+  let o = {};
+
+  for (let key in a) {
+    let originalValue = a[key],
+      value = originalValue;
+
+    if (b.hasOwnProperty(key)) {
+      value = b[key];
+    }
+
+    if (typeof value === 'object') {
+      value = deepMerge(originalValue, value);
+    }
+
+    o[key] = value;
+  }
+
+  return o;
+}
+
+// https://github.com/sonicdoe/measure-scrollbar
+function measureScrollbar() {
+  const div = document.createElement('div');
+
+  div.style.width = '100px';
+  div.style.height = '100px';
+  div.style.overflow = 'scroll';
+  div.style.position = 'absolute';
+  div.style.top = '-9999px';
+
+  document.body.appendChild(div);
+  const scrollbarWidth = div.offsetWidth - div.clientWidth;
+  document.body.removeChild(div);
+
+  return scrollbarWidth;
+}
+
+export {
+  getData,
+  getSanitizedData,
+  uniq,
+  intersect,
+  deepMerge,
+  unifiedToNative,
+  measureScrollbar,
+};
diff --git a/app/javascript/mastodon/features/emoji/unicode_to_filename.js b/app/javascript/mastodon/features/emoji/unicode_to_filename.js
new file mode 100644
index 000000000..c75c4cd7d
--- /dev/null
+++ b/app/javascript/mastodon/features/emoji/unicode_to_filename.js
@@ -0,0 +1,26 @@
+// taken from:
+// https://github.com/twitter/twemoji/blob/47732c7/twemoji-generator.js#L848-L866
+exports.unicodeToFilename = (str) => {
+  let result = '';
+  let charCode = 0;
+  let p = 0;
+  let i = 0;
+  while (i < str.length) {
+    charCode = str.charCodeAt(i++);
+    if (p) {
+      if (result.length > 0) {
+        result += '-';
+      }
+      result += (0x10000 + ((p - 0xD800) << 10) + (charCode - 0xDC00)).toString(16);
+      p = 0;
+    } else if (0xD800 <= charCode && charCode <= 0xDBFF) {
+      p = charCode;
+    } else {
+      if (result.length > 0) {
+        result += '-';
+      }
+      result += charCode.toString(16);
+    }
+  }
+  return result;
+};
diff --git a/app/javascript/mastodon/features/emoji/unicode_to_unified_name.js b/app/javascript/mastodon/features/emoji/unicode_to_unified_name.js
new file mode 100644
index 000000000..808ac197e
--- /dev/null
+++ b/app/javascript/mastodon/features/emoji/unicode_to_unified_name.js
@@ -0,0 +1,17 @@
+function padLeft(str, num) {
+  while (str.length < num) {
+    str = '0' + str;
+  }
+  return str;
+}
+
+exports.unicodeToUnifiedName = (str) => {
+  let output = '';
+  for (let i = 0; i < str.length; i += 2) {
+    if (i > 0) {
+      output += '-';
+    }
+    output += padLeft(str.codePointAt(i).toString(16).toUpperCase(), 4);
+  }
+  return output;
+};
diff --git a/app/javascript/mastodon/features/home_timeline/index.js b/app/javascript/mastodon/features/home_timeline/index.js
index 1bb4bd279..8d74783a3 100644
--- a/app/javascript/mastodon/features/home_timeline/index.js
+++ b/app/javascript/mastodon/features/home_timeline/index.js
@@ -8,7 +8,7 @@ import ColumnHeader from '../../components/column_header';
 import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
 import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
 import ColumnSettingsContainer from './containers/column_settings_container';
-import Link from 'react-router-dom/Link';
+import { Link } from 'react-router-dom';
 
 const messages = defineMessages({
   title: { id: 'column.home', defaultMessage: 'Home' },
diff --git a/app/javascript/mastodon/features/notifications/components/notification.js b/app/javascript/mastodon/features/notifications/components/notification.js
index b52c3c934..903526822 100644
--- a/app/javascript/mastodon/features/notifications/components/notification.js
+++ b/app/javascript/mastodon/features/notifications/components/notification.js
@@ -9,61 +9,126 @@ import AccountContainer from '../../../containers/account_container';
 import { FormattedMessage } from 'react-intl';
 import Permalink from '../../../components/permalink';
 import ImmutablePureComponent from 'react-immutable-pure-component';
+import { HotKeys } from 'react-hotkeys';
 
 export default class Notification extends ImmutablePureComponent {
 
+  static contextTypes = {
+    router: PropTypes.object,
+  };
+
   static propTypes = {
     notification: ImmutablePropTypes.map.isRequired,
     hidden: PropTypes.bool,
+    onMoveUp: PropTypes.func.isRequired,
+    onMoveDown: PropTypes.func.isRequired,
+    onMention: PropTypes.func.isRequired,
   };
 
+  handleMoveUp = () => {
+    const { notification, onMoveUp } = this.props;
+    onMoveUp(notification.get('id'));
+  }
+
+  handleMoveDown = () => {
+    const { notification, onMoveDown } = this.props;
+    onMoveDown(notification.get('id'));
+  }
+
+  handleOpen = () => {
+    const { notification } = this.props;
+
+    if (notification.get('status')) {
+      this.context.router.history.push(`/statuses/${notification.get('status')}`);
+    } else {
+      this.handleOpenProfile();
+    }
+  }
+
+  handleOpenProfile = () => {
+    const { notification } = this.props;
+    this.context.router.history.push(`/accounts/${notification.getIn(['account', 'id'])}`);
+  }
+
+  handleMention = e => {
+    e.preventDefault();
+
+    const { notification, onMention } = this.props;
+    onMention(notification.get('account'), this.context.router.history);
+  }
+
+  getHandlers () {
+    return {
+      moveUp: this.handleMoveUp,
+      moveDown: this.handleMoveDown,
+      open: this.handleOpen,
+      openProfile: this.handleOpenProfile,
+      mention: this.handleMention,
+      reply: this.handleMention,
+    };
+  }
+
   renderFollow (account, link) {
     return (
-      <div className='notification notification-follow'>
-        <div className='notification__message'>
-          <div className='notification__favourite-icon-wrapper'>
-            <i className='fa fa-fw fa-user-plus' />
+      <HotKeys handlers={this.getHandlers()}>
+        <div className='notification notification-follow focusable' tabIndex='0'>
+          <div className='notification__message'>
+            <div className='notification__favourite-icon-wrapper'>
+              <i className='fa fa-fw fa-user-plus' />
+            </div>
+
+            <FormattedMessage id='notification.follow' defaultMessage='{name} followed you' values={{ name: link }} />
           </div>
 
-          <FormattedMessage id='notification.follow' defaultMessage='{name} followed you' values={{ name: link }} />
+          <AccountContainer id={account.get('id')} withNote={false} hidden={this.props.hidden} />
         </div>
-
-        <AccountContainer id={account.get('id')} withNote={false} hidden={this.props.hidden} />
-      </div>
+      </HotKeys>
     );
   }
 
   renderMention (notification) {
-    return <StatusContainer id={notification.get('status')} withDismiss hidden={this.props.hidden} />;
+    return (
+      <StatusContainer
+        id={notification.get('status')}
+        withDismiss
+        hidden={this.props.hidden}
+        onMoveDown={this.handleMoveDown}
+        onMoveUp={this.handleMoveUp}
+      />
+    );
   }
 
   renderFavourite (notification, link) {
     return (
-      <div className='notification notification-favourite'>
-        <div className='notification__message'>
-          <div className='notification__favourite-icon-wrapper'>
-            <i className='fa fa-fw fa-star star-icon' />
+      <HotKeys handlers={this.getHandlers()}>
+        <div className='notification notification-favourite focusable' tabIndex='0'>
+          <div className='notification__message'>
+            <div className='notification__favourite-icon-wrapper'>
+              <i className='fa fa-fw fa-star star-icon' />
+            </div>
+            <FormattedMessage id='notification.favourite' defaultMessage='{name} favourited your status' values={{ name: link }} />
           </div>
-          <FormattedMessage id='notification.favourite' defaultMessage='{name} favourited your status' values={{ name: link }} />
-        </div>
 
-        <StatusContainer id={notification.get('status')} account={notification.get('account')} muted withDismiss hidden={!!this.props.hidden} />
-      </div>
+          <StatusContainer id={notification.get('status')} account={notification.get('account')} muted withDismiss hidden={!!this.props.hidden} />
+        </div>
+      </HotKeys>
     );
   }
 
   renderReblog (notification, link) {
     return (
-      <div className='notification notification-reblog'>
-        <div className='notification__message'>
-          <div className='notification__favourite-icon-wrapper'>
-            <i className='fa fa-fw fa-retweet' />
+      <HotKeys handlers={this.getHandlers()}>
+        <div className='notification notification-reblog focusable' tabIndex='0'>
+          <div className='notification__message'>
+            <div className='notification__favourite-icon-wrapper'>
+              <i className='fa fa-fw fa-retweet' />
+            </div>
+            <FormattedMessage id='notification.reblog' defaultMessage='{name} boosted your status' values={{ name: link }} />
           </div>
-          <FormattedMessage id='notification.reblog' defaultMessage='{name} boosted your status' values={{ name: link }} />
-        </div>
 
-        <StatusContainer id={notification.get('status')} account={notification.get('account')} muted withDismiss hidden={this.props.hidden} />
-      </div>
+          <StatusContainer id={notification.get('status')} account={notification.get('account')} muted withDismiss hidden={this.props.hidden} />
+        </div>
+      </HotKeys>
     );
   }
 
diff --git a/app/javascript/mastodon/features/notifications/containers/notification_container.js b/app/javascript/mastodon/features/notifications/containers/notification_container.js
index 1f98a66d2..fd16c4331 100644
--- a/app/javascript/mastodon/features/notifications/containers/notification_container.js
+++ b/app/javascript/mastodon/features/notifications/containers/notification_container.js
@@ -4,6 +4,7 @@
 import { connect } from 'react-redux';
 import { makeGetNotification } from '../../../selectors';
 import Notification from '../components/notification';
+import { mentionCompose } from '../../../actions/compose';
 
 const makeMapStateToProps = () => {
   const getNotification = makeGetNotification();
@@ -15,4 +16,10 @@ const makeMapStateToProps = () => {
   return mapStateToProps;
 };
 
-export default connect(makeMapStateToProps)(Notification);
+const mapDispatchToProps = dispatch => ({
+  onMention: (account, router) => {
+    dispatch(mentionCompose(account, router));
+  },
+});
+
+export default connect(makeMapStateToProps, mapDispatchToProps)(Notification);
diff --git a/app/javascript/mastodon/features/notifications/index.js b/app/javascript/mastodon/features/notifications/index.js
index 0ed940c6d..9c6802482 100644
--- a/app/javascript/mastodon/features/notifications/index.js
+++ b/app/javascript/mastodon/features/notifications/index.js
@@ -103,6 +103,24 @@ export default class Notifications extends React.PureComponent {
     this.column = c;
   }
 
+  handleMoveUp = id => {
+    const elementIndex = this.props.notifications.findIndex(item => item.get('id') === id) - 1;
+    this._selectChild(elementIndex);
+  }
+
+  handleMoveDown = id => {
+    const elementIndex = this.props.notifications.findIndex(item => item.get('id') === id) + 1;
+    this._selectChild(elementIndex);
+  }
+
+  _selectChild (index) {
+    const element = this.column.node.querySelector(`article:nth-of-type(${index + 1}) .focusable`);
+
+    if (element) {
+      element.focus();
+    }
+  }
+
   render () {
     const { intl, notifications, shouldUpdateScroll, isLoading, isUnread, columnId, multiColumn, hasMore } = this.props;
     const pinned = !!columnId;
@@ -113,7 +131,15 @@ export default class Notifications extends React.PureComponent {
     if (isLoading && this.scrollableContent) {
       scrollableContent = this.scrollableContent;
     } else if (notifications.size > 0 || hasMore) {
-      scrollableContent = notifications.map((item) => <NotificationContainer key={item.get('id')} notification={item} accountId={item.get('account')} />);
+      scrollableContent = notifications.map((item) => (
+        <NotificationContainer
+          key={item.get('id')}
+          notification={item}
+          accountId={item.get('account')}
+          onMoveUp={this.handleMoveUp}
+          onMoveDown={this.handleMoveDown}
+        />
+      ));
     } else {
       scrollableContent = null;
     }
diff --git a/app/javascript/mastodon/features/standalone/hashtag_timeline/index.js b/app/javascript/mastodon/features/standalone/hashtag_timeline/index.js
new file mode 100644
index 000000000..f15fbb2f4
--- /dev/null
+++ b/app/javascript/mastodon/features/standalone/hashtag_timeline/index.js
@@ -0,0 +1,70 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import StatusListContainer from '../../ui/containers/status_list_container';
+import {
+  refreshHashtagTimeline,
+  expandHashtagTimeline,
+} from '../../../actions/timelines';
+import Column from '../../../components/column';
+import ColumnHeader from '../../../components/column_header';
+
+@connect()
+export default class HashtagTimeline extends React.PureComponent {
+
+  static propTypes = {
+    dispatch: PropTypes.func.isRequired,
+    hashtag: PropTypes.string.isRequired,
+  };
+
+  handleHeaderClick = () => {
+    this.column.scrollTop();
+  }
+
+  setRef = c => {
+    this.column = c;
+  }
+
+  componentDidMount () {
+    const { dispatch, hashtag } = this.props;
+
+    dispatch(refreshHashtagTimeline(hashtag));
+
+    this.polling = setInterval(() => {
+      dispatch(refreshHashtagTimeline(hashtag));
+    }, 10000);
+  }
+
+  componentWillUnmount () {
+    if (typeof this.polling !== 'undefined') {
+      clearInterval(this.polling);
+      this.polling = null;
+    }
+  }
+
+  handleLoadMore = () => {
+    this.props.dispatch(expandHashtagTimeline(this.props.hashtag));
+  }
+
+  render () {
+    const { hashtag } = this.props;
+
+    return (
+      <Column ref={this.setRef}>
+        <ColumnHeader
+          icon='hashtag'
+          title={hashtag}
+          onClick={this.handleHeaderClick}
+        />
+
+        <StatusListContainer
+          trackScroll={false}
+          scrollKey='standalone_hashtag_timeline'
+          timelineId={`hashtag:${hashtag}`}
+          loadMore={this.handleLoadMore}
+        />
+      </Column>
+    );
+  }
+
+}
diff --git a/app/javascript/mastodon/features/status/components/card.js b/app/javascript/mastodon/features/status/components/card.js
index 41c4300d3..bb83374b9 100644
--- a/app/javascript/mastodon/features/status/components/card.js
+++ b/app/javascript/mastodon/features/status/components/card.js
@@ -30,6 +30,10 @@ export default class Card extends React.PureComponent {
     maxDescription: 50,
   };
 
+  state = {
+    width: 0,
+  };
+
   renderLink () {
     const { card, maxDescription } = this.props;
 
@@ -75,14 +79,25 @@ export default class Card extends React.PureComponent {
     );
   }
 
+  setRef = c => {
+    if (c) {
+      this.setState({ width: c.offsetWidth });
+    }
+  }
+
   renderVideo () {
-    const { card } = this.props;
-    const content  = { __html: card.get('html') };
+    const { card }  = this.props;
+    const content   = { __html: card.get('html') };
+    const { width } = this.state;
+    const ratio     = card.get('width') / card.get('height');
+    const height    = card.get('width') > card.get('height') ? (width / ratio) : (width * ratio);
 
     return (
       <div
+        ref={this.setRef}
         className='status-card-video'
         dangerouslySetInnerHTML={content}
+        style={{ height }}
       />
     );
   }
diff --git a/app/javascript/mastodon/features/status/components/detailed_status.js b/app/javascript/mastodon/features/status/components/detailed_status.js
index 232eccf70..816f83e45 100644
--- a/app/javascript/mastodon/features/status/components/detailed_status.js
+++ b/app/javascript/mastodon/features/status/components/detailed_status.js
@@ -7,7 +7,7 @@ import StatusContent from '../../../../glitch/components/status/content';
 import StatusGallery from '../../../../glitch/components/status/gallery';
 import StatusPlayer from '../../../../glitch/components/status/player';
 import AttachmentList from '../../../components/attachment_list';
-import Link from 'react-router-dom/Link';
+import { Link } from 'react-router-dom';
 import { FormattedDate, FormattedNumber } from 'react-intl';
 import CardContainer from '../containers/card_container';
 import ImmutablePureComponent from 'react-immutable-pure-component';
diff --git a/app/javascript/mastodon/features/status/index.js b/app/javascript/mastodon/features/status/index.js
index fc45d5f21..fff5f529c 100644
--- a/app/javascript/mastodon/features/status/index.js
+++ b/app/javascript/mastodon/features/status/index.js
@@ -28,6 +28,7 @@ import StatusContainer from '../../../glitch/components/status/container';
 import { openModal } from '../../actions/modal';
 import { defineMessages, injectIntl } from 'react-intl';
 import ImmutablePureComponent from 'react-immutable-pure-component';
+import { HotKeys } from 'react-hotkeys';
 
 const messages = defineMessages({
   deleteConfirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' },
@@ -153,8 +154,100 @@ export default class Status extends ImmutablePureComponent {
     this.props.dispatch(openModal('EMBED', { url: status.get('url') }));
   }
 
+  handleHotkeyMoveUp = () => {
+    this.handleMoveUp(this.props.status.get('id'));
+  }
+
+  handleHotkeyMoveDown = () => {
+    this.handleMoveDown(this.props.status.get('id'));
+  }
+
+  handleHotkeyReply = e => {
+    e.preventDefault();
+    this.handleReplyClick(this.props.status);
+  }
+
+  handleHotkeyFavourite = () => {
+    this.handleFavouriteClick(this.props.status);
+  }
+
+  handleHotkeyBoost = () => {
+    this.handleReblogClick(this.props.status);
+  }
+
+  handleHotkeyMention = e => {
+    e.preventDefault();
+    this.handleMentionClick(this.props.status);
+  }
+
+  handleHotkeyOpenProfile = () => {
+    this.context.router.history.push(`/accounts/${this.props.status.getIn(['account', 'id'])}`);
+  }
+
+  handleMoveUp = id => {
+    const { status, ancestorsIds, descendantsIds } = this.props;
+
+    if (id === status.get('id')) {
+      this._selectChild(ancestorsIds.size - 1);
+    } else {
+      let index = ancestorsIds.indexOf(id);
+
+      if (index === -1) {
+        index = descendantsIds.indexOf(id);
+        this._selectChild(ancestorsIds.size + index);
+      } else {
+        this._selectChild(index - 1);
+      }
+    }
+  }
+
+  handleMoveDown = id => {
+    const { status, ancestorsIds, descendantsIds } = this.props;
+
+    if (id === status.get('id')) {
+      this._selectChild(ancestorsIds.size + 1);
+    } else {
+      let index = ancestorsIds.indexOf(id);
+
+      if (index === -1) {
+        index = descendantsIds.indexOf(id);
+        this._selectChild(ancestorsIds.size + index + 2);
+      } else {
+        this._selectChild(index + 1);
+      }
+    }
+  }
+
+  _selectChild (index) {
+    const element = this.node.querySelectorAll('.focusable')[index];
+
+    if (element) {
+      element.focus();
+    }
+  }
+
   renderChildren (list) {
-    return list.map(id => <StatusContainer key={id} id={id} />);
+    return list.map(id => (
+      <StatusContainer
+        key={id}
+        id={id}
+        onMoveUp={this.handleMoveUp}
+        onMoveDown={this.handleMoveDown}
+      />
+    ));
+  }
+
+  setRef = c => {
+    this.node = c;
+  }
+
+  componentDidUpdate () {
+    const { status, ancestorsIds } = this.props;
+
+    if (status && ancestorsIds && ancestorsIds.size > 0) {
+      const element = this.node.querySelectorAll('.focusable')[ancestorsIds.size];
+      element.scrollIntoView();
+    }
   }
 
   render () {
@@ -178,35 +271,49 @@ export default class Status extends ImmutablePureComponent {
       descendants = <div>{this.renderChildren(descendantsIds)}</div>;
     }
 
+    const handlers = {
+      moveUp: this.handleHotkeyMoveUp,
+      moveDown: this.handleHotkeyMoveDown,
+      reply: this.handleHotkeyReply,
+      favourite: this.handleHotkeyFavourite,
+      boost: this.handleHotkeyBoost,
+      mention: this.handleHotkeyMention,
+      openProfile: this.handleHotkeyOpenProfile,
+    };
+
     return (
       <Column>
         <ColumnBackButton />
 
         <ScrollContainer scrollKey='thread'>
-          <div className='scrollable detailed-status__wrapper'>
+          <div className='scrollable detailed-status__wrapper' ref={this.setRef}>
             {ancestors}
 
-            <DetailedStatus
-              status={status}
-              settings={settings}
-              autoPlayGif={autoPlayGif}
-              me={me}
-              onOpenVideo={this.handleOpenVideo}
-              onOpenMedia={this.handleOpenMedia}
-            />
-
-            <ActionBar
-              status={status}
-              me={me}
-              onReply={this.handleReplyClick}
-              onFavourite={this.handleFavouriteClick}
-              onReblog={this.handleReblogClick}
-              onDelete={this.handleDeleteClick}
-              onMention={this.handleMentionClick}
-              onReport={this.handleReport}
-              onPin={this.handlePin}
-              onEmbed={this.handleEmbed}
-            />
+            <HotKeys handlers={handlers}>
+              <div className='focusable' tabIndex='0'>
+                <DetailedStatus
+                  status={status}
+                  settings={settings}
+                  autoPlayGif={autoPlayGif}
+                  me={me}
+                  onOpenVideo={this.handleOpenVideo}
+                  onOpenMedia={this.handleOpenMedia}
+                />
+
+                <ActionBar
+                  status={status}
+                  me={me}
+                  onReply={this.handleReplyClick}
+                  onFavourite={this.handleFavouriteClick}
+                  onReblog={this.handleReblogClick}
+                  onDelete={this.handleDeleteClick}
+                  onMention={this.handleMentionClick}
+                  onReport={this.handleReport}
+                  onPin={this.handlePin}
+                  onEmbed={this.handleEmbed}
+                />
+              </div>
+            </HotKeys>
 
             {descendants}
           </div>
diff --git a/app/javascript/mastodon/features/ui/components/column_link.js b/app/javascript/mastodon/features/ui/components/column_link.js
index 62aab9a23..b845d1895 100644
--- a/app/javascript/mastodon/features/ui/components/column_link.js
+++ b/app/javascript/mastodon/features/ui/components/column_link.js
@@ -1,6 +1,6 @@
 import React from 'react';
 import PropTypes from 'prop-types';
-import Link from 'react-router-dom/Link';
+import { Link } from 'react-router-dom';
 
 const ColumnLink = ({ icon, text, to, onClick, href, method }) => {
   if (href) {
diff --git a/app/javascript/mastodon/features/ui/components/media_modal.js b/app/javascript/mastodon/features/ui/components/media_modal.js
index 828419d5a..f41a83089 100644
--- a/app/javascript/mastodon/features/ui/components/media_modal.js
+++ b/app/javascript/mastodon/features/ui/components/media_modal.js
@@ -29,7 +29,7 @@ export default class MediaModal extends ImmutablePureComponent {
   };
 
   handleSwipe = (index) => {
-    this.setState({ index: (index) % this.props.media.size });
+    this.setState({ index: index % this.props.media.size });
   }
 
   handleNextClick = () => {
@@ -40,6 +40,11 @@ export default class MediaModal extends ImmutablePureComponent {
     this.setState({ index: (this.props.media.size + this.getIndex() - 1) % this.props.media.size });
   }
 
+  handleChangeIndex = (e) => {
+    const index = Number(e.currentTarget.getAttribute('data-index'));
+    this.setState({ index: index % this.props.media.size });
+  }
+
   handleKeyUp = (e) => {
     switch(e.key) {
     case 'ArrowLeft':
@@ -67,33 +72,51 @@ export default class MediaModal extends ImmutablePureComponent {
     const { media, intl, onClose } = this.props;
 
     const index = this.getIndex();
+    let pagination = [];
 
     const leftNav  = media.size > 1 && <button tabIndex='0' className='modal-container__nav modal-container__nav--left' onClick={this.handlePrevClick} aria-label={intl.formatMessage(messages.previous)}><i className='fa fa-fw fa-chevron-left' /></button>;
     const rightNav = media.size > 1 && <button tabIndex='0' className='modal-container__nav  modal-container__nav--right' onClick={this.handleNextClick} aria-label={intl.formatMessage(messages.next)}><i className='fa fa-fw fa-chevron-right' /></button>;
 
+    if (media.size > 1) {
+      pagination = media.map((item, i) => {
+        const classes = ['media-modal__button'];
+        if (i === index) {
+          classes.push('media-modal__button--active');
+        }
+        return (<li className='media-modal__page-dot' key={i}><button tabIndex='0' className={classes.join(' ')} onClick={this.handleChangeIndex} data-index={i}>{i + 1}</button></li>);
+      });
+    }
+
     const content = media.map((image) => {
       const width  = image.getIn(['meta', 'original', 'width']) || null;
       const height = image.getIn(['meta', 'original', 'height']) || null;
 
       if (image.get('type') === 'image') {
-        return <ImageLoader previewSrc={image.get('preview_url')} src={image.get('url')} width={width} height={height} key={image.get('preview_url')} />;
+        return <ImageLoader previewSrc={image.get('preview_url')} src={image.get('url')} width={width} height={height} alt={image.get('description')} key={image.get('preview_url')} />;
       } else if (image.get('type') === 'gifv') {
-        return <ExtendedVideoPlayer src={image.get('url')} muted controls={false} width={width} height={height} key={image.get('preview_url')} />;
+        return <ExtendedVideoPlayer src={image.get('url')} muted controls={false} width={width} height={height} key={image.get('preview_url')} alt={image.get('description')} />;
       }
 
       return null;
     }).toArray();
 
+    const containerStyle = {
+      alignItems: 'center', // center vertically
+    };
+
     return (
       <div className='modal-root__modal media-modal'>
         {leftNav}
 
         <div className='media-modal__content'>
           <IconButton className='media-modal__close' title={intl.formatMessage(messages.close)} icon='times' onClick={onClose} size={16} />
-          <ReactSwipeableViews onChangeIndex={this.handleSwipe} index={index} animateHeight>
+          <ReactSwipeableViews containerStyle={containerStyle} onChangeIndex={this.handleSwipe} index={index}>
             {content}
           </ReactSwipeableViews>
         </div>
+        <ul className='media-modal__pagination'>
+          {pagination}
+        </ul>
 
         {rightNav}
       </div>
diff --git a/app/javascript/mastodon/features/ui/components/modal_root.js b/app/javascript/mastodon/features/ui/components/modal_root.js
index 6347c4b22..88a4d0a59 100644
--- a/app/javascript/mastodon/features/ui/components/modal_root.js
+++ b/app/javascript/mastodon/features/ui/components/modal_root.js
@@ -1,7 +1,5 @@
 import React from 'react';
 import PropTypes from 'prop-types';
-import TransitionMotion from 'react-motion/lib/TransitionMotion';
-import spring from 'react-motion/lib/spring';
 import BundleContainer from '../containers/bundle_container';
 import BundleModalError from './bundle_modal_error';
 import ModalLoading from './modal_loading';
@@ -39,6 +37,10 @@ export default class ModalRoot extends React.PureComponent {
     onClose: PropTypes.func.isRequired,
   };
 
+  state = {
+    revealed: false,
+  };
+
   handleKeyUp = (e) => {
     if ((e.key === 'Escape' || e.key === 'Esc' || e.keyCode === 27)
          && !!this.props.type) {
@@ -55,6 +57,8 @@ export default class ModalRoot extends React.PureComponent {
       this.activeElement = document.activeElement;
 
       this.getSiblings().forEach(sibling => sibling.setAttribute('inert', true));
+    } else if (!nextProps.type) {
+      this.setState({ revealed: false });
     }
   }
 
@@ -64,6 +68,11 @@ export default class ModalRoot extends React.PureComponent {
       this.activeElement.focus();
       this.activeElement = null;
     }
+    if (this.props.type) {
+      requestAnimationFrame(() => {
+        this.setState({ revealed: true });
+      });
+    }
   }
 
   componentWillUnmount () {
@@ -78,14 +87,6 @@ export default class ModalRoot extends React.PureComponent {
     this.node = ref;
   }
 
-  willEnter () {
-    return { opacity: 0, scale: 0.98 };
-  }
-
-  willLeave () {
-    return { opacity: spring(0), scale: spring(0.98) };
-  }
-
   renderLoading = modalId => () => {
     return ['MEDIA', 'VIDEO', 'BOOST', 'CONFIRM', 'ACTIONS'].indexOf(modalId) === -1 ? <ModalLoading /> : null;
   }
@@ -98,38 +99,30 @@ export default class ModalRoot extends React.PureComponent {
 
   render () {
     const { type, props, onClose } = this.props;
+    const { revealed } = this.state;
     const visible = !!type;
-    const items = [];
 
-    if (visible) {
-      items.push({
-        key: type,
-        data: { type, props },
-        style: { opacity: spring(1), scale: spring(1, { stiffness: 120, damping: 14 }) },
-      });
+    if (!visible) {
+      return (
+        <div className='modal-root' ref={this.setRef} style={{ opacity: 0 }} />
+      );
     }
 
     return (
-      <TransitionMotion
-        styles={items}
-        willEnter={this.willEnter}
-        willLeave={this.willLeave}
-      >
-        {interpolatedStyles =>
-          <div className='modal-root' ref={this.setRef}>
-            {interpolatedStyles.map(({ key, data: { type, props }, style }) => (
-              <div key={key} style={{ pointerEvents: visible ? 'auto' : 'none' }}>
-                <div role='presentation' className='modal-root__overlay' style={{ opacity: style.opacity }} onClick={onClose} />
-                <div role='dialog' className='modal-root__container' style={{ opacity: style.opacity, transform: `translateZ(0px) scale(${style.scale})` }}>
-                  <BundleContainer fetchComponent={MODAL_COMPONENTS[type]} loading={this.renderLoading(type)} error={this.renderError} renderDelay={200}>
-                    {(SpecificComponent) => <SpecificComponent {...props} onClose={onClose} />}
-                  </BundleContainer>
-                </div>
-              </div>
-            ))}
+      <div className='modal-root' ref={this.setRef} style={{ opacity: revealed ? 1 : 0 }}>
+        <div style={{ pointerEvents: visible ? 'auto' : 'none' }}>
+          <div role='presentation' className='modal-root__overlay' onClick={onClose} />
+          <div role='dialog' className='modal-root__container'>
+            {
+              visible ?
+                (<BundleContainer fetchComponent={MODAL_COMPONENTS[type]} loading={this.renderLoading(type)} error={this.renderError} renderDelay={200}>
+                  {(SpecificComponent) => <SpecificComponent {...props} onClose={onClose} />}
+                </BundleContainer>) :
+              null
+            }
           </div>
-        }
-      </TransitionMotion>
+        </div>
+      </div>
     );
   }
 
diff --git a/app/javascript/mastodon/features/ui/components/tabs_bar.js b/app/javascript/mastodon/features/ui/components/tabs_bar.js
index af9e6bf45..7694e5ab3 100644
--- a/app/javascript/mastodon/features/ui/components/tabs_bar.js
+++ b/app/javascript/mastodon/features/ui/components/tabs_bar.js
@@ -1,6 +1,6 @@
 import React from 'react';
 import PropTypes from 'prop-types';
-import NavLink from 'react-router-dom/NavLink';
+import { NavLink } from 'react-router-dom';
 import { FormattedMessage, injectIntl } from 'react-intl';
 import { debounce } from 'lodash';
 import { isUserTouching } from '../../../is_mobile';
diff --git a/app/javascript/mastodon/features/ui/components/video_modal.js b/app/javascript/mastodon/features/ui/components/video_modal.js
index 867c73ed5..1437deeb0 100644
--- a/app/javascript/mastodon/features/ui/components/video_modal.js
+++ b/app/javascript/mastodon/features/ui/components/video_modal.js
@@ -23,6 +23,7 @@ export default class VideoModal extends ImmutablePureComponent {
             src={media.get('url')}
             startTime={time}
             onCloseVideo={onClose}
+            description={media.get('description')}
           />
         </div>
       </div>
diff --git a/app/javascript/mastodon/features/ui/index.js b/app/javascript/mastodon/features/ui/index.js
index 73bd23432..14a5f6224 100644
--- a/app/javascript/mastodon/features/ui/index.js
+++ b/app/javascript/mastodon/features/ui/index.js
@@ -8,7 +8,7 @@ import { connect } from 'react-redux';
 import { Redirect, withRouter } from 'react-router-dom';
 import { isMobile } from '../../is_mobile';
 import { debounce } from 'lodash';
-import { uploadCompose } from '../../actions/compose';
+import { uploadCompose, resetCompose } from '../../actions/compose';
 import { refreshHomeTimeline } from '../../actions/timelines';
 import { refreshNotifications } from '../../actions/notifications';
 import { clearHeight } from '../../actions/height_cache';
@@ -38,6 +38,7 @@ import {
   Mutes,
   PinnedStatuses,
 } from './util/async-components';
+import { HotKeys } from 'react-hotkeys';
 
 // Dummy import, to make sure that <Status /> ends up in the application bundle.
 // Without this it ends up in ~8 very commonly used bundles.
@@ -48,12 +49,39 @@ const mapStateToProps = state => ({
   layout: state.getIn(['local_settings', 'layout']),
   isWide: state.getIn(['local_settings', 'stretch']),
   navbarUnder: state.getIn(['local_settings', 'navbar_under']),
+  me: state.getIn(['meta', 'me']),
   isComposing: state.getIn(['compose', 'is_composing']),
 });
 
+const keyMap = {
+  new: 'n',
+  search: 's',
+  forceNew: 'option+n',
+  focusColumn: ['1', '2', '3', '4', '5', '6', '7', '8', '9'],
+  reply: 'r',
+  favourite: 'f',
+  boost: 'b',
+  mention: 'm',
+  open: ['enter', 'o'],
+  openProfile: 'p',
+  moveDown: ['down', 'j'],
+  moveUp: ['up', 'k'],
+  back: 'backspace',
+  goToHome: 'g h',
+  goToNotifications: 'g n',
+  goToLocal: 'g l',
+  goToFederated: 'g t',
+  goToStart: 'g s',
+  goToFavourites: 'g f',
+  goToPinned: 'g p',
+  goToProfile: 'g u',
+  goToBlocked: 'g b',
+  goToMuted: 'g m',
+};
+
 @connect(mapStateToProps)
 @withRouter
-export default class UI extends React.PureComponent {
+export default class UI extends React.Component {
 
   static contextTypes = {
     router: PropTypes.object.isRequired,
@@ -67,6 +95,7 @@ export default class UI extends React.PureComponent {
     systemFontUi: PropTypes.bool,
     navbarUnder: PropTypes.bool,
     isComposing: PropTypes.bool,
+    me: PropTypes.string,
     location: PropTypes.object,
   };
 
@@ -164,6 +193,12 @@ export default class UI extends React.PureComponent {
     this.props.dispatch(refreshNotifications());
   }
 
+  componentDidMount () {
+    this.hotkeys.__mousetrap__.stopCallback = (e, element) => {
+      return ['TEXTAREA', 'SELECT', 'INPUT'].includes(element.tagName);
+    };
+  }
+
   shouldComponentUpdate (nextProps) {
     if (nextProps.isComposing !== this.props.isComposing) {
       // Avoid expensive update just to toggle a class
@@ -201,8 +236,94 @@ export default class UI extends React.PureComponent {
     this.columnsAreaNode = c.getWrappedInstance().getWrappedInstance();
   }
 
-  setOverlayRef = c => {
-    this.overlay = c;
+  handleHotkeyNew = e => {
+    e.preventDefault();
+
+    const element = this.node.querySelector('.compose-form__autosuggest-wrapper textarea');
+
+    if (element) {
+      element.focus();
+    }
+  }
+
+  handleHotkeySearch = e => {
+    e.preventDefault();
+
+    const element = this.node.querySelector('.search__input');
+
+    if (element) {
+      element.focus();
+    }
+  }
+
+  handleHotkeyForceNew = e => {
+    this.handleHotkeyNew(e);
+    this.props.dispatch(resetCompose());
+  }
+
+  handleHotkeyFocusColumn = e => {
+    const index  = (e.key * 1) + 1; // First child is drawer, skip that
+    const column = this.node.querySelector(`.column:nth-child(${index})`);
+
+    if (column) {
+      const status = column.querySelector('.focusable');
+
+      if (status) {
+        status.focus();
+      }
+    }
+  }
+
+  handleHotkeyBack = () => {
+    if (window.history && window.history.length === 1) {
+      this.context.router.history.push('/');
+    } else {
+      this.context.router.history.goBack();
+    }
+  }
+
+  setHotkeysRef = c => {
+    this.hotkeys = c;
+  }
+
+  handleHotkeyGoToHome = () => {
+    this.context.router.history.push('/timelines/home');
+  }
+
+  handleHotkeyGoToNotifications = () => {
+    this.context.router.history.push('/notifications');
+  }
+
+  handleHotkeyGoToLocal = () => {
+    this.context.router.history.push('/timelines/public/local');
+  }
+
+  handleHotkeyGoToFederated = () => {
+    this.context.router.history.push('/timelines/public');
+  }
+
+  handleHotkeyGoToStart = () => {
+    this.context.router.history.push('/getting-started');
+  }
+
+  handleHotkeyGoToFavourites = () => {
+    this.context.router.history.push('/favourites');
+  }
+
+  handleHotkeyGoToPinned = () => {
+    this.context.router.history.push('/pinned');
+  }
+
+  handleHotkeyGoToProfile = () => {
+    this.context.router.history.push(`/accounts/${this.props.me}`);
+  }
+
+  handleHotkeyGoToBlocked = () => {
+    this.context.router.history.push('/blocks');
+  }
+
+  handleHotkeyGoToMuted = () => {
+    this.context.router.history.push('/mutes');
   }
 
   render () {
@@ -226,45 +347,67 @@ export default class UI extends React.PureComponent {
       'navbar-under': navbarUnder,
     });
 
+    const handlers = {
+      new: this.handleHotkeyNew,
+      search: this.handleHotkeySearch,
+      forceNew: this.handleHotkeyForceNew,
+      focusColumn: this.handleHotkeyFocusColumn,
+      back: this.handleHotkeyBack,
+      goToHome: this.handleHotkeyGoToHome,
+      goToNotifications: this.handleHotkeyGoToNotifications,
+      goToLocal: this.handleHotkeyGoToLocal,
+      goToFederated: this.handleHotkeyGoToFederated,
+      goToStart: this.handleHotkeyGoToStart,
+      goToFavourites: this.handleHotkeyGoToFavourites,
+      goToPinned: this.handleHotkeyGoToPinned,
+      goToProfile: this.handleHotkeyGoToProfile,
+      goToBlocked: this.handleHotkeyGoToBlocked,
+      goToMuted: this.handleHotkeyGoToMuted,
+    };
+
     return (
-      <div className={className} ref={this.setRef}>
-        {navbarUnder ? null : (<TabsBar />)}
-        <ColumnsAreaContainer ref={this.setColumnsAreaRef} singleColumn={isMobile(width, layout)}>
-          <WrappedSwitch>
-            <Redirect from='/' to='/getting-started' exact />
-            <WrappedRoute path='/getting-started' component={GettingStarted} content={children} />
-            <WrappedRoute path='/timelines/home' component={HomeTimeline} content={children} />
-            <WrappedRoute path='/timelines/public' exact component={PublicTimeline} content={children} />
-            <WrappedRoute path='/timelines/public/local' component={CommunityTimeline} content={children} />
-            <WrappedRoute path='/timelines/tag/:id' component={HashtagTimeline} content={children} />
-
-            <WrappedRoute path='/notifications' component={Notifications} content={children} />
-            <WrappedRoute path='/favourites' component={FavouritedStatuses} content={children} />
-            <WrappedRoute path='/pinned' component={PinnedStatuses} content={children} />
-
-            <WrappedRoute path='/statuses/new' component={Compose} content={children} />
-            <WrappedRoute path='/statuses/:statusId' exact component={Status} content={children} />
-            <WrappedRoute path='/statuses/:statusId/reblogs' component={Reblogs} content={children} />
-            <WrappedRoute path='/statuses/:statusId/favourites' component={Favourites} content={children} />
-
-            <WrappedRoute path='/accounts/:accountId' exact component={AccountTimeline} content={children} />
-            <WrappedRoute path='/accounts/:accountId/followers' component={Followers} content={children} />
-            <WrappedRoute path='/accounts/:accountId/following' component={Following} content={children} />
-            <WrappedRoute path='/accounts/:accountId/media' component={AccountGallery} content={children} />
-
-            <WrappedRoute path='/follow_requests' component={FollowRequests} content={children} />
-            <WrappedRoute path='/blocks' component={Blocks} content={children} />
-            <WrappedRoute path='/mutes' component={Mutes} content={children} />
-
-            <WrappedRoute component={GenericNotFound} content={children} />
-          </WrappedSwitch>
-        </ColumnsAreaContainer>
-        <NotificationsContainer />
-        {navbarUnder ? (<TabsBar />) : null}
-        <LoadingBarContainer className='loading-bar' />
-        <ModalContainer />
-        <UploadArea active={draggingOver} onClose={this.closeUploadModal} />
-      </div>
+      <HotKeys keyMap={keyMap} handlers={handlers} ref={this.setHotkeysRef}>
+        <div className={className} ref={this.setRef}>
+        	{navbarUnder ? null : (<TabsBar />)}
+
+          <ColumnsAreaContainer ref={this.setColumnsAreaRef} singleColumn={isMobile(width, layout)}>
+            <WrappedSwitch>
+              <Redirect from='/' to='/getting-started' exact />
+              <WrappedRoute path='/getting-started' component={GettingStarted} content={children} />
+              <WrappedRoute path='/timelines/home' component={HomeTimeline} content={children} />
+              <WrappedRoute path='/timelines/public' exact component={PublicTimeline} content={children} />
+              <WrappedRoute path='/timelines/public/local' component={CommunityTimeline} content={children} />
+              <WrappedRoute path='/timelines/tag/:id' component={HashtagTimeline} content={children} />
+
+              <WrappedRoute path='/notifications' component={Notifications} content={children} />
+              <WrappedRoute path='/favourites' component={FavouritedStatuses} content={children} />
+              <WrappedRoute path='/pinned' component={PinnedStatuses} content={children} />
+
+              <WrappedRoute path='/statuses/new' component={Compose} content={children} />
+              <WrappedRoute path='/statuses/:statusId' exact component={Status} content={children} />
+              <WrappedRoute path='/statuses/:statusId/reblogs' component={Reblogs} content={children} />
+              <WrappedRoute path='/statuses/:statusId/favourites' component={Favourites} content={children} />
+
+              <WrappedRoute path='/accounts/:accountId' exact component={AccountTimeline} content={children} />
+              <WrappedRoute path='/accounts/:accountId/followers' component={Followers} content={children} />
+              <WrappedRoute path='/accounts/:accountId/following' component={Following} content={children} />
+              <WrappedRoute path='/accounts/:accountId/media' component={AccountGallery} content={children} />
+
+              <WrappedRoute path='/follow_requests' component={FollowRequests} content={children} />
+              <WrappedRoute path='/blocks' component={Blocks} content={children} />
+              <WrappedRoute path='/mutes' component={Mutes} content={children} />
+
+              <WrappedRoute component={GenericNotFound} content={children} />
+            </WrappedSwitch>
+          </ColumnsAreaContainer>
+
+          <NotificationsContainer />
+        	{navbarUnder ? (<TabsBar />) : null}
+          <LoadingBarContainer className='loading-bar' />
+          <ModalContainer />
+          <UploadArea active={draggingOver} onClose={this.closeUploadModal} />
+        </div>
+      </HotKeys>
     );
   }
 
diff --git a/app/javascript/mastodon/features/ui/util/async-components.js b/app/javascript/mastodon/features/ui/util/async-components.js
index 5d640810f..7f2b303a7 100644
--- a/app/javascript/mastodon/features/ui/util/async-components.js
+++ b/app/javascript/mastodon/features/ui/util/async-components.js
@@ -1,3 +1,7 @@
+export function EmojiPicker () {
+  return import(/* webpackChunkName: "emoji_picker" */'../../emoji/emoji_picker');
+}
+
 export function Compose () {
   return import(/* webpackChunkName: "features/compose" */'../../compose');
 }
@@ -101,10 +105,6 @@ export function MediaGallery () {
   return import(/* webpackChunkName: "status/media_gallery" */'../../../components/media_gallery');
 }
 
-export function VideoPlayer () {
-  return import(/* webpackChunkName: "status/video_player" */'../../../components/video_player');
-}
-
 export function Video () {
   return import(/* webpackChunkName: "features/video" */'../../video');
 }
diff --git a/app/javascript/mastodon/features/ui/util/fullscreen.js b/app/javascript/mastodon/features/ui/util/fullscreen.js
new file mode 100644
index 000000000..cf5d0cf98
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/util/fullscreen.js
@@ -0,0 +1,46 @@
+// APIs for normalizing fullscreen operations. Note that Edge uses
+// the WebKit-prefixed APIs currently (as of Edge 16).
+
+export const isFullscreen = () => document.fullscreenElement ||
+  document.webkitFullscreenElement ||
+  document.mozFullScreenElement;
+
+export const exitFullscreen = () => {
+  if (document.exitFullscreen) {
+    document.exitFullscreen();
+  } else if (document.webkitExitFullscreen) {
+    document.webkitExitFullscreen();
+  } else if (document.mozCancelFullScreen) {
+    document.mozCancelFullScreen();
+  }
+};
+
+export const requestFullscreen = el => {
+  if (el.requestFullscreen) {
+    el.requestFullscreen();
+  } else if (el.webkitRequestFullscreen) {
+    el.webkitRequestFullscreen();
+  } else if (el.mozRequestFullScreen) {
+    el.mozRequestFullScreen();
+  }
+};
+
+export const attachFullscreenListener = (listener) => {
+  if ('onfullscreenchange' in document) {
+    document.addEventListener('fullscreenchange', listener);
+  } else if ('onwebkitfullscreenchange' in document) {
+    document.addEventListener('webkitfullscreenchange', listener);
+  } else if ('onmozfullscreenchange' in document) {
+    document.addEventListener('mozfullscreenchange', listener);
+  }
+};
+
+export const detachFullscreenListener = (listener) => {
+  if ('onfullscreenchange' in document) {
+    document.removeEventListener('fullscreenchange', listener);
+  } else if ('onwebkitfullscreenchange' in document) {
+    document.removeEventListener('webkitfullscreenchange', listener);
+  } else if ('onmozfullscreenchange' in document) {
+    document.removeEventListener('mozfullscreenchange', listener);
+  }
+};
diff --git a/app/javascript/mastodon/features/ui/util/react_router_helpers.js b/app/javascript/mastodon/features/ui/util/react_router_helpers.js
index ede578e56..86b30d488 100644
--- a/app/javascript/mastodon/features/ui/util/react_router_helpers.js
+++ b/app/javascript/mastodon/features/ui/util/react_router_helpers.js
@@ -1,7 +1,6 @@
 import React from 'react';
 import PropTypes from 'prop-types';
-import Switch from 'react-router-dom/Switch';
-import Route from 'react-router-dom/Route';
+import { Switch, Route } from 'react-router-dom';
 
 import ColumnLoading from '../components/column_loading';
 import BundleColumnError from '../components/bundle_column_error';
diff --git a/app/javascript/mastodon/features/video/index.js b/app/javascript/mastodon/features/video/index.js
index f228e434b..003bf23a8 100644
--- a/app/javascript/mastodon/features/video/index.js
+++ b/app/javascript/mastodon/features/video/index.js
@@ -3,6 +3,7 @@ import PropTypes from 'prop-types';
 import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
 import { throttle } from 'lodash';
 import classNames from 'classnames';
+import { isFullscreen, requestFullscreen, exitFullscreen } from '../ui/util/fullscreen';
 
 const messages = defineMessages({
   play: { id: 'video.play', defaultMessage: 'Play' },
@@ -69,41 +70,13 @@ const getPointerPosition = (el, event) => {
   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,
+    alt: PropTypes.string,
     width: PropTypes.number,
     height: PropTypes.number,
     sensitive: PropTypes.bool,
@@ -236,6 +209,12 @@ export default class Video extends React.PureComponent {
     }
   }
 
+  handleProgress = () => {
+    if (this.video.buffered.length > 0) {
+      this.setState({ buffer: this.video.buffered.end(0) / this.video.duration * 100 });
+    }
+  }
+
   handleOpenVideo = () => {
     this.video.pause();
     this.props.onOpenVideo(this.video.currentTime);
@@ -247,8 +226,8 @@ export default class Video extends React.PureComponent {
   }
 
   render () {
-    const { preview, src, width, height, startTime, onOpenVideo, onCloseVideo, intl } = this.props;
-    const { progress, dragging, paused, fullscreen, hovered, muted, revealed } = this.state;
+    const { preview, src, width, height, startTime, onOpenVideo, onCloseVideo, intl, alt } = this.props;
+    const { progress, buffer, 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}>
@@ -256,10 +235,11 @@ export default class Video extends React.PureComponent {
           ref={this.setVideoRef}
           src={src}
           poster={preview}
-          preload={!!startTime}
+          preload={startTime ? 'auto' : 'none'}
           loop
           role='button'
           tabIndex='0'
+          aria-label={alt}
           width={width}
           height={height}
           onClick={this.togglePlay}
@@ -267,6 +247,7 @@ export default class Video extends React.PureComponent {
           onPause={this.handlePause}
           onTimeUpdate={this.handleTimeUpdate}
           onLoadedData={this.handleLoadedData}
+          onProgress={this.handleProgress}
         />
 
         <button className={classNames('video-player__spoiler', { active: !revealed })} onClick={this.toggleReveal}>
@@ -276,6 +257,7 @@ export default class Video extends React.PureComponent {
 
         <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__buffer' style={{ width: `${buffer}%` }} />
             <div className='video-player__seek__progress' style={{ width: `${progress}%` }} />
 
             <span
diff --git a/app/javascript/mastodon/locales/ar.json b/app/javascript/mastodon/locales/ar.json
index bd09f1970..799819c7c 100644
--- a/app/javascript/mastodon/locales/ar.json
+++ b/app/javascript/mastodon/locales/ar.json
@@ -1,31 +1,31 @@
 {
   "account.block": "حظر @{name}",
-  "account.block_domain": "Hide everything from {domain}",
-  "account.disclaimer_full": "Information below may reflect the user's profile incompletely.",
+  "account.block_domain": "إخفاء كل شيئ قادم من إسم النطاق {domain}",
+  "account.disclaimer_full": "قد لا تعكس المعلومات أدناه الملف الشخصي الكامل للمستخدم.",
   "account.edit_profile": "تعديل الملف الشخصي",
   "account.follow": "تابِع",
   "account.followers": "المتابعون",
   "account.follows": "يتبع",
   "account.follows_you": "يتابعك",
-  "account.media": "Media",
+  "account.media": "وسائط",
   "account.mention": "أُذكُر @{name}",
   "account.mute": "أكتم @{name}",
   "account.posts": "المشاركات",
   "account.report": "أبلغ عن @{name}",
   "account.requested": "في انتظار الموافقة",
-  "account.share": "Share @{name}'s profile",
+  "account.share": "مشاركة @{name}'s profile",
   "account.unblock": "إلغاء الحظر عن @{name}",
-  "account.unblock_domain": "Unhide {domain}",
+  "account.unblock_domain": "فك حظر {domain}",
   "account.unfollow": "إلغاء المتابعة",
   "account.unmute": "إلغاء الكتم عن @{name}",
-  "account.view_full_profile": "View full profile",
+  "account.view_full_profile": "عرض الملف الشخصي كاملا",
   "boost_modal.combo": "يمكنك ضغط {combo} لتخطّي هذه في المرّة القادمة",
-  "bundle_column_error.body": "Something went wrong while loading this component.",
-  "bundle_column_error.retry": "Try again",
-  "bundle_column_error.title": "Network error",
-  "bundle_modal_error.close": "Close",
-  "bundle_modal_error.message": "Something went wrong while loading this component.",
-  "bundle_modal_error.retry": "Try again",
+  "bundle_column_error.body": "لقد وقع هناك خطأ أثناء عملية تحميل هذا العنصر.",
+  "bundle_column_error.retry": "إعادة المحاولة",
+  "bundle_column_error.title": "خطأ في الشبكة",
+  "bundle_modal_error.close": "أغلق",
+  "bundle_modal_error.message": "لقد وقع هناك خطأ أثناء عملية تحميل هذا العنصر.",
+  "bundle_modal_error.retry": "إعادة المحاولة",
   "column.blocks": "الحسابات المحجوبة",
   "column.community": "الخيط العام المحلي",
   "column.favourites": "المفضلة",
@@ -33,15 +33,15 @@
   "column.home": "الرئيسية",
   "column.mutes": "الحسابات المكتومة",
   "column.notifications": "الإشعارات",
-  "column.pins": "Pinned toot",
+  "column.pins": "التبويقات المثبتة",
   "column.public": "الخيط العام الموحد",
   "column_back_button.label": "العودة",
-  "column_header.hide_settings": "Hide settings",
-  "column_header.moveLeft_settings": "Move column to the left",
-  "column_header.moveRight_settings": "Move column to the right",
-  "column_header.pin": "Pin",
-  "column_header.show_settings": "Show settings",
-  "column_header.unpin": "Unpin",
+  "column_header.hide_settings": "إخفاء الإعدادات",
+  "column_header.moveLeft_settings": "نقل القائمة إلى اليسار",
+  "column_header.moveRight_settings": "نقل القائمة إلى اليمين",
+  "column_header.pin": "تدبيس",
+  "column_header.show_settings": "عرض الإعدادات",
+  "column_header.unpin": "فك التدبيس",
   "column_subheading.navigation": "التصفح",
   "column_subheading.settings": "الإعدادات",
   "compose_form.lock_disclaimer": "حسابك ليس {locked}. يمكن لأي شخص متابعتك و عرض المنشورات.",
@@ -57,16 +57,16 @@
   "confirmations.block.message": "هل أنت متأكد أنك تريد حجب {name} ؟",
   "confirmations.delete.confirm": "حذف",
   "confirmations.delete.message": "هل أنت متأكد أنك تريد حذف هذا المنشور ؟",
-  "confirmations.domain_block.confirm": "Hide entire domain",
+  "confirmations.domain_block.confirm": "إخفاء إسم النطاق كاملا",
   "confirmations.domain_block.message": "Are you really, really sure you want to block the entire {domain}? In most cases a few targeted blocks or mutes are sufficient and preferable.",
   "confirmations.mute.confirm": "أكتم",
   "confirmations.mute.message": "هل أنت متأكد أنك تريد كتم {name} ؟",
-  "confirmations.unfollow.confirm": "Unfollow",
-  "confirmations.unfollow.message": "Are you sure you want to unfollow {name}?",
-  "embed.instructions": "Embed this status on your website by copying the code below.",
-  "embed.preview": "Here is what it will look like:",
+  "confirmations.unfollow.confirm": "إلغاء المتابعة",
+  "confirmations.unfollow.message": "متأكد من أنك تريد إلغاء متابعة {name} ؟",
+  "embed.instructions": "يمكنكم إدماج هذه الحالة على موقعكم الإلكتروني عن طريق نسخ الشفرة أدناه.",
+  "embed.preview": "هكذا ما سوف يبدو عليه :",
   "emoji_button.activity": "الأنشطة",
-  "emoji_button.custom": "Custom",
+  "emoji_button.custom": "مخصص",
   "emoji_button.flags": "الأعلام",
   "emoji_button.food": "الطعام والشراب",
   "emoji_button.label": "أدرج إيموجي",
@@ -74,9 +74,9 @@
   "emoji_button.not_found": "No emojos!! (╯°□°)╯︵ ┻━┻",
   "emoji_button.objects": "أشياء",
   "emoji_button.people": "الناس",
-  "emoji_button.recent": "Frequently used",
+  "emoji_button.recent": "الشائعة الإستخدام",
   "emoji_button.search": "ابحث...",
-  "emoji_button.search_results": "Search results",
+  "emoji_button.search_results": "نتائج البحث",
   "emoji_button.symbols": "رموز",
   "emoji_button.travel": "أماكن و أسفار",
   "empty_column.community": "الخط الزمني المحلي فارغ. اكتب شيئا ما للعامة كبداية.",
@@ -100,8 +100,8 @@
   "home.column_settings.show_replies": "عرض الردود",
   "home.settings": "إعدادات العمود",
   "lightbox.close": "إغلاق",
-  "lightbox.next": "Next",
-  "lightbox.previous": "Previous",
+  "lightbox.next": "التالي",
+  "lightbox.previous": "العودة",
   "loading_indicator.label": "تحميل ...",
   "media_gallery.toggle_visible": "عرض / إخفاء",
   "missing_indicator.label": "تعذر العثور عليه",
@@ -113,7 +113,7 @@
   "navigation_bar.info": "معلومات إضافية",
   "navigation_bar.logout": "خروج",
   "navigation_bar.mutes": "الحسابات المكتومة",
-  "navigation_bar.pins": "Pinned toots",
+  "navigation_bar.pins": "التبويقات المثبتة",
   "navigation_bar.preferences": "التفضيلات",
   "navigation_bar.public_timeline": "الخيط العام الموحد",
   "notification.favourite": "{name} أعجب بمنشورك",
@@ -126,8 +126,8 @@
   "notifications.column_settings.favourite": "المُفَضَّلة :",
   "notifications.column_settings.follow": "متابعُون جُدُد :",
   "notifications.column_settings.mention": "الإشارات :",
-  "notifications.column_settings.push": "Push notifications",
-  "notifications.column_settings.push_meta": "This device",
+  "notifications.column_settings.push": "الإخطارات المدفوعة",
+  "notifications.column_settings.push_meta": "هذا الجهاز",
   "notifications.column_settings.reblog": "الترقيّات:",
   "notifications.column_settings.show": "إعرِضها في عمود",
   "notifications.column_settings.sound": "أصدر صوتا",
@@ -165,18 +165,23 @@
   "report.submit": "إرسال",
   "report.target": "إبلاغ",
   "search.placeholder": "ابحث",
+  "search_popout.search_format": "نمط البحث المتقدم",
+  "search_popout.tips.hashtag": "وسم",
+  "search_popout.tips.status": "حالة",
+  "search_popout.tips.text": "Simple text returns matching display names, usernames and hashtags",
+  "search_popout.tips.user": "مستخدِم",
   "search_results.total": "{count, number} {count, plural, one {result} other {results}}",
-  "standalone.public_title": "A look inside...",
+  "standalone.public_title": "نظرة على ...",
   "status.cannot_reblog": "تعذرت ترقية هذا المنشور",
   "status.delete": "إحذف",
-  "status.embed": "Embed",
+  "status.embed": "إدماج",
   "status.favourite": "أضف إلى المفضلة",
   "status.load_more": "حمّل المزيد",
   "status.media_hidden": "الصورة مستترة",
   "status.mention": "أذكُر @{name}",
-  "status.mute_conversation": "Mute conversation",
+  "status.mute_conversation": "كتم المحادثة",
   "status.open": "وسع هذه المشاركة",
-  "status.pin": "Pin on profile",
+  "status.pin": "تدبيس على الملف الشخصي",
   "status.reblog": "رَقِّي",
   "status.reblogged_by": "{name} رقى",
   "status.reply": "ردّ",
@@ -184,11 +189,11 @@
   "status.report": "إبلِغ عن @{name}",
   "status.sensitive_toggle": "اضغط للعرض",
   "status.sensitive_warning": "محتوى حساس",
-  "status.share": "Share",
+  "status.share": "مشاركة",
   "status.show_less": "إعرض أقلّ",
   "status.show_more": "أظهر المزيد",
-  "status.unmute_conversation": "Unmute conversation",
-  "status.unpin": "Unpin from profile",
+  "status.unmute_conversation": "فك الكتم عن المحادثة",
+  "status.unpin": "فك التدبيس من الملف الشخصي",
   "tabs_bar.compose": "تحرير",
   "tabs_bar.federated_timeline": "الموحَّد",
   "tabs_bar.home": "الرئيسية",
@@ -196,19 +201,16 @@
   "tabs_bar.notifications": "الإخطارات",
   "upload_area.title": "إسحب ثم أفلت للرفع",
   "upload_button.label": "إضافة وسائط",
+  "upload_form.description": "وصف للمعاقين بصريا",
   "upload_form.undo": "إلغاء",
   "upload_progress.label": "يرفع...",
-  "video.close": "Close video",
-  "video.exit_fullscreen": "Exit full screen",
-  "video.expand": "Expand video",
-  "video.fullscreen": "Full screen",
-  "video.hide": "Hide video",
-  "video.mute": "Mute sound",
-  "video.pause": "Pause",
-  "video.play": "Play",
-  "video.unmute": "Unmute sound",
-  "video_player.expand": "وسّع الفيديو",
-  "video_player.toggle_sound": "تبديل الصوت",
-  "video_player.toggle_visible": "إظهار / إخفاء الفيديو",
-  "video_player.video_error": "تعذر تشغيل الفيديو"
+  "video.close": "إغلاق الفيديو",
+  "video.exit_fullscreen": "الخروج من وضع الشاشة المليئة",
+  "video.expand": "توسيع الفيديو",
+  "video.fullscreen": "ملء الشاشة",
+  "video.hide": "إخفاء الفيديو",
+  "video.mute": "كتم الصوت",
+  "video.pause": "إيقاف مؤقت",
+  "video.play": "تشغيل",
+  "video.unmute": "تشغيل الصوت"
 }
diff --git a/app/javascript/mastodon/locales/bg.json b/app/javascript/mastodon/locales/bg.json
index d391a57ba..240e3725e 100644
--- a/app/javascript/mastodon/locales/bg.json
+++ b/app/javascript/mastodon/locales/bg.json
@@ -165,6 +165,11 @@
   "report.submit": "Submit",
   "report.target": "Reporting",
   "search.placeholder": "Търсене",
+  "search_popout.search_format": "Advanced search format",
+  "search_popout.tips.hashtag": "hashtag",
+  "search_popout.tips.status": "status",
+  "search_popout.tips.text": "Simple text returns matching display names, usernames and hashtags",
+  "search_popout.tips.user": "user",
   "search_results.total": "{count, number} {count, plural, one {result} other {results}}",
   "standalone.public_title": "A look inside...",
   "status.cannot_reblog": "This post cannot be boosted",
@@ -196,6 +201,7 @@
   "tabs_bar.notifications": "Известия",
   "upload_area.title": "Drag & drop to upload",
   "upload_button.label": "Добави медия",
+  "upload_form.description": "Describe for the visually impaired",
   "upload_form.undo": "Отмяна",
   "upload_progress.label": "Uploading...",
   "video.close": "Close video",
@@ -206,9 +212,5 @@
   "video.mute": "Mute sound",
   "video.pause": "Pause",
   "video.play": "Play",
-  "video.unmute": "Unmute sound",
-  "video_player.expand": "Expand video",
-  "video_player.toggle_sound": "Звук",
-  "video_player.toggle_visible": "Toggle visibility",
-  "video_player.video_error": "Video could not be played"
+  "video.unmute": "Unmute sound"
 }
diff --git a/app/javascript/mastodon/locales/ca.json b/app/javascript/mastodon/locales/ca.json
index 286da3ac6..b5051a32d 100644
--- a/app/javascript/mastodon/locales/ca.json
+++ b/app/javascript/mastodon/locales/ca.json
@@ -165,6 +165,11 @@
   "report.submit": "Enviar",
   "report.target": "Informes",
   "search.placeholder": "Cercar",
+  "search_popout.search_format": "Advanced search format",
+  "search_popout.tips.hashtag": "hashtag",
+  "search_popout.tips.status": "status",
+  "search_popout.tips.text": "Simple text returns matching display names, usernames and hashtags",
+  "search_popout.tips.user": "user",
   "search_results.total": "{count, number} {count, plural, one {result} other {results}}",
   "standalone.public_title": "A look inside...",
   "status.cannot_reblog": "Aquesta publicació no pot ser retootejada",
@@ -196,6 +201,7 @@
   "tabs_bar.notifications": "Notificacions",
   "upload_area.title": "Arrossega i deixa anar per carregar",
   "upload_button.label": "Afegir multimèdia",
+  "upload_form.description": "Describe for the visually impaired",
   "upload_form.undo": "Desfer",
   "upload_progress.label": "Pujant...",
   "video.close": "Close video",
@@ -206,9 +212,5 @@
   "video.mute": "Mute sound",
   "video.pause": "Pause",
   "video.play": "Play",
-  "video.unmute": "Unmute sound",
-  "video_player.expand": "Ampliar el vídeo",
-  "video_player.toggle_sound": "Alternar so",
-  "video_player.toggle_visible": "Alternar visibilitat",
-  "video_player.video_error": "El vídeo no es pot reproduir"
+  "video.unmute": "Unmute sound"
 }
diff --git a/app/javascript/mastodon/locales/de.json b/app/javascript/mastodon/locales/de.json
index 461e7e304..b79b1b2f0 100644
--- a/app/javascript/mastodon/locales/de.json
+++ b/app/javascript/mastodon/locales/de.json
@@ -1,7 +1,7 @@
 {
   "account.block": "@{name} blocken",
   "account.block_domain": "Alles von {domain} verstecken",
-  "account.disclaimer_full": "Hier aufgeführten Informationen können unvollständig sein.",
+  "account.disclaimer_full": "Das Profil wird möglicherweise unvollständig wiedergegeben.",
   "account.edit_profile": "Profil bearbeiten",
   "account.follow": "Folgen",
   "account.followers": "Folgende",
@@ -18,11 +18,11 @@
   "account.unblock_domain": "{domain} wieder anzeigen",
   "account.unfollow": "Entfolgen",
   "account.unmute": "@{name} nicht mehr stummschalten",
-  "account.view_full_profile": "Komplettes Profil anzeigen",
+  "account.view_full_profile": "Vollständiges Profil anzeigen",
   "boost_modal.combo": "Du kannst {combo} drücken, um dies beim nächsten Mal zu überspringen",
   "bundle_column_error.body": "Etwas ist beim Laden schiefgelaufen.",
   "bundle_column_error.retry": "Erneut versuchen",
-  "bundle_column_error.title": "Netzwerkfehlher",
+  "bundle_column_error.title": "Netzwerkfehler",
   "bundle_modal_error.close": "Schließen",
   "bundle_modal_error.message": "Etwas ist beim Laden schiefgelaufen.",
   "bundle_modal_error.retry": "Erneut versuchen",
@@ -33,18 +33,18 @@
   "column.home": "Startseite",
   "column.mutes": "Stummgeschaltete Profile",
   "column.notifications": "Mitteilungen",
-  "column.pins": "Pinned toot",
+  "column.pins": "Angeheftete Beiträge",
   "column.public": "Gesamtes bekanntes Netz",
   "column_back_button.label": "Zurück",
   "column_header.hide_settings": "Einstellungen verbergen",
-  "column_header.moveLeft_settings": "Spalte links verschieben",
-  "column_header.moveRight_settings": "Spalte rechts verschieben",
+  "column_header.moveLeft_settings": "Spalte nach links verschieben",
+  "column_header.moveRight_settings": "Spalte nach rechts verschieben",
   "column_header.pin": "Anheften",
   "column_header.show_settings": "Einstellungen anzeigen",
   "column_header.unpin": "Lösen",
   "column_subheading.navigation": "Navigation",
   "column_subheading.settings": "Einstellungen",
-  "compose_form.lock_disclaimer": "Dein Profil ist nicht {locked}. Jeder kann dir jederzeit folgen, um deine privaten Beiträge einzusehen.",
+  "compose_form.lock_disclaimer": "Dein Profil ist nicht {locked}. Wer dir folgen will, kann das jederzeit tun und dann auch deine privaten Beiträge sehen.",
   "compose_form.lock_disclaimer.lock": "gesperrt",
   "compose_form.placeholder": "Worüber möchtest du schreiben?",
   "compose_form.publish": "Tröt",
@@ -56,106 +56,106 @@
   "confirmations.block.confirm": "Blockieren",
   "confirmations.block.message": "Bist du dir sicher, dass du {name} blockieren möchtest?",
   "confirmations.delete.confirm": "Löschen",
-  "confirmations.delete.message": "Bist du dir sicher, dass du diesen Beitrag löschen möchstest?",
+  "confirmations.delete.message": "Bist du dir sicher, dass du diesen Beitrag löschen möchtest?",
   "confirmations.domain_block.confirm": "Die ganze Domain verbergen",
-  "confirmations.domain_block.message": "Bist du dir wirklich sicher, dass du die ganze Domain {domain} verbergen willst? In den meisten Fällen sind ein paar gezielte Blocks genug.",
+  "confirmations.domain_block.message": "Bist du dir wirklich sicher, dass du die ganze Domain {domain} verbergen willst? In den meisten Fällen reichen ein paar gezielte Blocks aus.",
   "confirmations.mute.confirm": "Stummschalten",
-  "confirmations.mute.message": "Bist du dir sicher, dass du {name} stummschalten möchstest?",
+  "confirmations.mute.message": "Bist du dir sicher, dass du {name} stummschalten möchtest?",
   "confirmations.unfollow.confirm": "Entfolgen",
-  "confirmations.unfollow.message": "Bist du dir sicher, dass du {name} entfolgen möchstest?",
-  "embed.instructions": "Du kannst diesen Beitrag auf deiner Webseite einbetten, in dem du den folgenden Code einfügst.",
+  "confirmations.unfollow.message": "Bist du dir sicher, dass du {name} entfolgen möchtest?",
+  "embed.instructions": "Du kannst diesen Beitrag auf deiner Webseite einbetten, indem du den folgenden Code einfügst.",
   "embed.preview": "So wird es aussehen:",
   "emoji_button.activity": "Aktivitäten",
-  "emoji_button.custom": "Custom",
+  "emoji_button.custom": "Eigene",
   "emoji_button.flags": "Flaggen",
   "emoji_button.food": "Essen und Trinken",
   "emoji_button.label": "Emoji einfügen",
   "emoji_button.nature": "Natur",
-  "emoji_button.not_found": "No emojos!! (╯°□°)╯︵ ┻━┻",
-  "emoji_button.objects": "Dinge",
-  "emoji_button.people": "Leute",
-  "emoji_button.recent": "Frequently used",
-  "emoji_button.search": "Suche…",
-  "emoji_button.search_results": "Search results",
+  "emoji_button.not_found": "Keine Emojis!! (╯°□°)╯︵ ┻━┻",
+  "emoji_button.objects": "Gegenstände",
+  "emoji_button.people": "Personen",
+  "emoji_button.recent": "Häufig benutzt",
+  "emoji_button.search": "Suchen",
+  "emoji_button.search_results": "Suchergebnisse",
   "emoji_button.symbols": "Symbole",
-  "emoji_button.travel": "Reise und Orte",
-  "empty_column.community": "Die lokale Zeitleiste ist leer. Schreibe etwas öffentlich, um den Ball ins Rollen zu bringen!",
-  "empty_column.hashtag": "Es gibt noch nichts unter diesem Hashtag.",
-  "empty_column.home": "Du folgst noch niemandem. Besuche {public} oder benutze die Suche, um zu starten oder andere Profile zu finden.",
-  "empty_column.home.inactivity": "Deine Zeitleiste ist leer. Falls du eine längere Zeit inaktiv gewesen bist, wird sie für dich so schnell wie möglich wiedererstellt.",
+  "emoji_button.travel": "Reisen und Orte",
+  "empty_column.community": "Die lokale Zeitleiste ist leer. Schreibe einen öffentlichen Beitrag, um den Ball ins Rollen zu bringen!",
+  "empty_column.hashtag": "Unter diesem Hashtag gibt es noch nichts.",
+  "empty_column.home": "Du folgst noch niemandem. Besuche {public} oder nutze die Suche, um loszulegen und andere Leute zu finden.",
+  "empty_column.home.inactivity": "Deine Zeitleiste ist leer. Falls du eine längere Zeit inaktiv warst, wird sie für dich so schnell wie möglich neu erstellt.",
   "empty_column.home.public_timeline": "die öffentliche Zeitleiste",
-  "empty_column.notifications": "Du hast noch keine Mitteilungen. Interagiere mit anderen, um die Konversation zu starten.",
-  "empty_column.public": "Hier ist nichts zu sehen! Schreibe etwas öffentlich oder folge Profilen von anderen Instanzen, um es aufzufüllen.",
+  "empty_column.notifications": "Du hast noch keine Mitteilungen. Interagiere mit anderen, um ins Gespräch zu kommen.",
+  "empty_column.public": "Hier ist nichts zu sehen! Schreibe etwas öffentlich oder folge Profilen von anderen Instanzen, um die Zeitleiste aufzufüllen",
   "follow_request.authorize": "Erlauben",
   "follow_request.reject": "Ablehnen",
-  "getting_started.appsshort": "Anwendungen",
+  "getting_started.appsshort": "Apps",
   "getting_started.faq": "Häufig gestellte Fragen",
   "getting_started.heading": "Erste Schritte",
-  "getting_started.open_source_notice": "Mastodon ist quelloffene Software. Du kannst auf {github} dazu beitragen oder Probleme melden.",
+  "getting_started.open_source_notice": "Mastodon ist quelloffene Software. Du kannst auf GitHub unter {github} dazu beitragen oder Probleme melden.",
   "getting_started.userguide": "Bedienungsanleitung",
-  "home.column_settings.advanced": "Fortgeschritten",
+  "home.column_settings.advanced": "Erweitert",
   "home.column_settings.basic": "Einfach",
-  "home.column_settings.filter_regex": "Filter durch reguläre Ausdrücke",
+  "home.column_settings.filter_regex": "Mit regulären Ausdrücken filtern",
   "home.column_settings.show_reblogs": "Geteilte Beiträge anzeigen",
   "home.column_settings.show_replies": "Antworten anzeigen",
   "home.settings": "Spalteneinstellungen",
   "lightbox.close": "Schließen",
   "lightbox.next": "Weiter",
   "lightbox.previous": "Zurück",
-  "loading_indicator.label": "Lade…",
-  "media_gallery.toggle_visible": "Sichtbarkeit einstellen",
+  "loading_indicator.label": "Wird geladen …",
+  "media_gallery.toggle_visible": "Sichtbarkeit umschalten",
   "missing_indicator.label": "Nicht gefunden",
   "navigation_bar.blocks": "Blockierte Profile",
   "navigation_bar.community_timeline": "Lokale Zeitleiste",
   "navigation_bar.edit_profile": "Profil bearbeiten",
   "navigation_bar.favourites": "Favoriten",
   "navigation_bar.follow_requests": "Folgeanfragen",
-  "navigation_bar.info": "Erweiterte Informationen",
+  "navigation_bar.info": "Über diese Instanz",
   "navigation_bar.logout": "Abmelden",
   "navigation_bar.mutes": "Stummgeschaltete Profile",
-  "navigation_bar.pins": "Pinned toots",
+  "navigation_bar.pins": "Angeheftete Beiträge",
   "navigation_bar.preferences": "Einstellungen",
   "navigation_bar.public_timeline": "Föderierte Zeitleiste",
-  "notification.favourite": "{name} favorisierte deinen Status",
+  "notification.favourite": "{name} hat deinen Beitrag favorisiert",
   "notification.follow": "{name} folgt dir",
-  "notification.mention": "{name} erwähnte dich",
-  "notification.reblog": "{name} teilte deinen Status",
+  "notification.mention": "{name} hat dich erwähnt",
+  "notification.reblog": "{name} hat deinen Beitrag geteilt",
   "notifications.clear": "Mitteilungen löschen",
-  "notifications.clear_confirmation": "Bist du dir sicher, dass du alle Mitteilungen löschen möchstest?",
+  "notifications.clear_confirmation": "Bist du dir sicher, dass du alle Mitteilungen löschen möchtest?",
   "notifications.column_settings.alert": "Desktop-Benachrichtigungen",
   "notifications.column_settings.favourite": "Favorisierungen:",
   "notifications.column_settings.follow": "Neue Folgende:",
   "notifications.column_settings.mention": "Erwähnungen:",
-  "notifications.column_settings.push": "Push notifications",
-  "notifications.column_settings.push_meta": "This device",
+  "notifications.column_settings.push": "Push-Benachrichtigungen",
+  "notifications.column_settings.push_meta": "Auf diesem Gerät",
   "notifications.column_settings.reblog": "Geteilte Beiträge:",
   "notifications.column_settings.show": "In der Spalte anzeigen",
   "notifications.column_settings.sound": "Ton abspielen",
   "onboarding.done": "Fertig",
   "onboarding.next": "Weiter",
-  "onboarding.page_five.public_timelines": "Die lokale Zeitleiste zeigt alle Beiträge von Leuten, die auch auf deiner Instanz {domain} sind. Das gesamte bekannte Netz zeigt Beiträge von allen, die kollektiv aus deiner Instanz heraus gefolgt werden. Zusammen werden die beiden Leisten auch öffentliche Zeitleisten genannt, durch sie kannst du viel neues entdecken.",
+  "onboarding.page_five.public_timelines": "Die lokale Zeitleiste zeigt alle Beiträge von Leuten, die auch auf {domain} sind. Das gesamte bekannte Netz zeigt Beiträge von allen, denen von Leuten auf {domain} gefolgt wird. Zusammen sind sie die öffentlichen Zeitleisten. In ihnen kannst du viel Neues entdecken!",
   "onboarding.page_four.home": "Die Startseite zeigt dir Beiträge von Leuten, denen du folgst.",
-  "onboarding.page_four.notifications": "Wenn jemand mir dir interagiert, bekommst du eine Mitteilung.",
+  "onboarding.page_four.notifications": "Wenn jemand mit dir interagiert, bekommst du eine Mitteilung.",
   "onboarding.page_one.federation": "Mastodon ist ein soziales Netzwerk, das aus unabhängigen Servern besteht. Diese Server nennen wir auch Instanzen.",
   "onboarding.page_one.handle": "Du bist auf der Instanz {domain}, also ist dein vollständiger Profilname im Netzwerk {handle}",
   "onboarding.page_one.welcome": "Willkommen bei Mastodon!",
   "onboarding.page_six.admin": "Für deine Instanz ist {admin} zuständig.",
-  "onboarding.page_six.almost_done": "Fast fertig…",
+  "onboarding.page_six.almost_done": "Fast fertig …",
   "onboarding.page_six.appetoot": "Guten Appetröt!",
-  "onboarding.page_six.apps_available": "Es gibt verschiedene {apps} für iOS, Android und andere Plattformen.",
-  "onboarding.page_six.github": "Mastodon ist freie, quelloffene Software. Du kannst auf {github} dazu beitragen oder Probleme melden.",
+  "onboarding.page_six.apps_available": "Es gibt verschiedene {apps} für iOS, Android und weitere Plattformen.",
+  "onboarding.page_six.github": "Mastodon ist freie, quelloffene Software. Du kannst auf GitHub unter {github} dazu beitragen, Probleme melden und Wünsche äußern.",
   "onboarding.page_six.guidelines": "Richtlinien",
-  "onboarding.page_six.read_guidelines": "Bitte mach dich mit den {guidelines} von {domain} vertraut!",
-  "onboarding.page_six.various_app": "mobile Anwendungen",
-  "onboarding.page_three.profile": "Bearbeite dein Profil, um dein Bild, deinen Namen oder deine Beschreibung anzupassen. Dort findest du auch andere Einstellungen.",
-  "onboarding.page_three.search": "Benutze die Suchfunktion, um Leute oder Themen zu finden. Zum Beispiel, die Hashtags {illustration} oder {introductions}. Um eine Person zu finden, die auf einer anderen Instanz ist, benutze den vollständigen Profilnamen.",
-  "onboarding.page_two.compose": "Schreibe Beiträge aus der Schreiben-Spalte. Du kannst Bilder und kurze Videos hochladen, Sichtbarkeitseinstellungen ändern und Inhaltswarnungen hinzufügen.",
+  "onboarding.page_six.read_guidelines": "Bitte mach dich mit den {guidelines} von {domain} vertraut.",
+  "onboarding.page_six.various_app": "Apps",
+  "onboarding.page_three.profile": "Bearbeite dein Profil, um dein Bild, deinen Namen und deine Beschreibung anzupassen. Dort findest du auch weitere Einstellungen.",
+  "onboarding.page_three.search": "Benutze die Suchfunktion, um Leute zu finden und mit Hashtags wie {illustration} oder {introductions} nach Beiträgen zu suchen. Um eine Person zu finden, die auf einer anderen Instanz ist, benutze den vollständigen Profilnamen.",
+  "onboarding.page_two.compose": "Schreibe deine Beiträge in der Schreiben-Spalte. Mit den Symbolen unter dem Eingabefeld kannst du Bilder hochladen, Sichtbarkeits-Einstellungen ändern und Inhaltswarnungen hinzufügen.",
   "onboarding.skip": "Überspringen",
-  "privacy.change": "Privatsphäre des Status anpassen",
+  "privacy.change": "Sichtbarkeit des Beitrags anpassen",
   "privacy.direct.long": "Beitrag nur an erwähnte Profile",
   "privacy.direct.short": "Direkt",
   "privacy.private.long": "Beitrag nur an Folgende",
-  "privacy.private.short": "Privat",
+  "privacy.private.short": "Nur Folgende",
   "privacy.public.long": "Beitrag an öffentliche Zeitleisten",
   "privacy.public.short": "Öffentlich",
   "privacy.unlisted.long": "Nicht in öffentlichen Zeitleisten anzeigen",
@@ -163,26 +163,31 @@
   "reply_indicator.cancel": "Abbrechen",
   "report.placeholder": "Zusätzliche Kommentare",
   "report.submit": "Absenden",
-  "report.target": "Melden",
+  "report.target": "{target} melden",
   "search.placeholder": "Suche",
+  "search_popout.search_format": "Advanced search format",
+  "search_popout.tips.hashtag": "hashtag",
+  "search_popout.tips.status": "status",
+  "search_popout.tips.text": "Simple text returns matching display names, usernames and hashtags",
+  "search_popout.tips.user": "user",
   "search_results.total": "{count, number} {count, plural, one {Ergebnis} other {Ergebnisse}}",
-  "standalone.public_title": "Vorschau…",
+  "standalone.public_title": "Ein kleiner Einblick …",
   "status.cannot_reblog": "Dieser Beitrag kann nicht geteilt werden",
   "status.delete": "Löschen",
   "status.embed": "Einbetten",
   "status.favourite": "Favorisieren",
   "status.load_more": "Weitere laden",
   "status.media_hidden": "Medien versteckt",
-  "status.mention": "Erwähnen",
+  "status.mention": "@{name} erwähnen",
   "status.mute_conversation": "Thread stummschalten",
-  "status.open": "Öffnen",
-  "status.pin": "Auf dem Profil anheften",
+  "status.open": "Diesen Beitrag öffnen",
+  "status.pin": "Im Profil anheften",
   "status.reblog": "Teilen",
   "status.reblogged_by": "{name} teilte",
   "status.reply": "Antworten",
   "status.replyAll": "Auf Thread antworten",
   "status.report": "@{name} melden",
-  "status.sensitive_toggle": "Klicke, um sie zu sehen",
+  "status.sensitive_toggle": "Zum Ansehen klicken",
   "status.sensitive_warning": "Heikle Inhalte",
   "status.share": "Teilen",
   "status.show_less": "Weniger anzeigen",
@@ -194,21 +199,18 @@
   "tabs_bar.home": "Startseite",
   "tabs_bar.local_timeline": "Lokal",
   "tabs_bar.notifications": "Mitteilungen",
-  "upload_area.title": "Hereinziehen zum Hochladen",
+  "upload_area.title": "Zum Hochladen hereinziehen",
   "upload_button.label": "Mediendatei hinzufügen",
+  "upload_form.description": "Für Menschen mit Sehbehinderung beschreiben",
   "upload_form.undo": "Entfernen",
-  "upload_progress.label": "Lade hoch…",
-  "video.close": "Close video",
-  "video.exit_fullscreen": "Exit full screen",
-  "video.expand": "Expand video",
-  "video.fullscreen": "Full screen",
-  "video.hide": "Hide video",
-  "video.mute": "Mute sound",
+  "upload_progress.label": "Wird hochgeladen …",
+  "video.close": "Video schließen",
+  "video.exit_fullscreen": "Vollbild verlassen",
+  "video.expand": "Video vergrößern",
+  "video.fullscreen": "Vollbild",
+  "video.hide": "Video verbergen",
+  "video.mute": "Stummschalten",
   "video.pause": "Pause",
-  "video.play": "Play",
-  "video.unmute": "Unmute sound",
-  "video_player.expand": "Videoanzeige vergrößern",
-  "video_player.toggle_sound": "Ton umschalten",
-  "video_player.toggle_visible": "Sichtbarkeit umschalten",
-  "video_player.video_error": "Video konnte nicht abgespielt werden"
+  "video.play": "Abspielen",
+  "video.unmute": "Ton einschalten"
 }
diff --git a/app/javascript/mastodon/locales/defaultMessages.json b/app/javascript/mastodon/locales/defaultMessages.json
index 5b711fd26..8fdb8c44c 100644
--- a/app/javascript/mastodon/locales/defaultMessages.json
+++ b/app/javascript/mastodon/locales/defaultMessages.json
@@ -135,6 +135,31 @@
   {
     "descriptors": [
       {
+        "defaultMessage": "now",
+        "id": "relative_time.just_now"
+      },
+      {
+        "defaultMessage": "{number}s",
+        "id": "relative_time.seconds"
+      },
+      {
+        "defaultMessage": "{number}m",
+        "id": "relative_time.minutes"
+      },
+      {
+        "defaultMessage": "{number}h",
+        "id": "relative_time.hours"
+      },
+      {
+        "defaultMessage": "{number}d",
+        "id": "relative_time.days"
+      }
+    ],
+    "path": "app/javascript/mastodon/components/relative_timestamp.json"
+  },
+  {
+    "descriptors": [
+      {
         "defaultMessage": "Delete",
         "id": "status.delete"
       },
@@ -230,39 +255,6 @@
   {
     "descriptors": [
       {
-        "defaultMessage": "Toggle sound",
-        "id": "video_player.toggle_sound"
-      },
-      {
-        "defaultMessage": "Toggle visibility",
-        "id": "video_player.toggle_visible"
-      },
-      {
-        "defaultMessage": "Expand video",
-        "id": "video_player.expand"
-      },
-      {
-        "defaultMessage": "Sensitive content",
-        "id": "status.sensitive_warning"
-      },
-      {
-        "defaultMessage": "Click to view",
-        "id": "status.sensitive_toggle"
-      },
-      {
-        "defaultMessage": "Media hidden",
-        "id": "status.media_hidden"
-      },
-      {
-        "defaultMessage": "Video could not be played",
-        "id": "video_player.video_error"
-      }
-    ],
-    "path": "app/javascript/mastodon/components/video_player.json"
-  },
-  {
-    "descriptors": [
-      {
         "defaultMessage": "Unfollow",
         "id": "confirmations.unfollow.confirm"
       },
@@ -640,6 +632,26 @@
       {
         "defaultMessage": "Search",
         "id": "search.placeholder"
+      },
+      {
+        "defaultMessage": "Advanced search format",
+        "id": "search_popout.search_format"
+      },
+      {
+        "defaultMessage": "hashtag",
+        "id": "search_popout.tips.hashtag"
+      },
+      {
+        "defaultMessage": "user",
+        "id": "search_popout.tips.user"
+      },
+      {
+        "defaultMessage": "status",
+        "id": "search_popout.tips.status"
+      },
+      {
+        "defaultMessage": "Simple text returns matching display names, usernames and hashtags",
+        "id": "search_popout.tips.text"
       }
     ],
     "path": "app/javascript/mastodon/features/compose/components/search.json"
@@ -656,20 +668,24 @@
   {
     "descriptors": [
       {
-        "defaultMessage": "Undo",
-        "id": "upload_form.undo"
+        "defaultMessage": "Uploading...",
+        "id": "upload_progress.label"
       }
     ],
-    "path": "app/javascript/mastodon/features/compose/components/upload_form.json"
+    "path": "app/javascript/mastodon/features/compose/components/upload_progress.json"
   },
   {
     "descriptors": [
       {
-        "defaultMessage": "Uploading...",
-        "id": "upload_progress.label"
+        "defaultMessage": "Undo",
+        "id": "upload_form.undo"
+      },
+      {
+        "defaultMessage": "Describe for the visually impaired",
+        "id": "upload_form.description"
       }
     ],
-    "path": "app/javascript/mastodon/features/compose/components/upload_progress.json"
+    "path": "app/javascript/mastodon/features/compose/components/upload.json"
   },
   {
     "descriptors": [
diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json
index fc6aa4280..b0dbc46bd 100644
--- a/app/javascript/mastodon/locales/en.json
+++ b/app/javascript/mastodon/locales/en.json
@@ -165,6 +165,11 @@
   "report.submit": "Submit",
   "report.target": "Reporting {target}",
   "search.placeholder": "Search",
+  "search_popout.search_format": "Advanced search format",
+  "search_popout.tips.hashtag": "hashtag",
+  "search_popout.tips.status": "status",
+  "search_popout.tips.text": "Simple text returns matching display names, usernames and hashtags",
+  "search_popout.tips.user": "user",
   "search_results.total": "{count, number} {count, plural, one {result} other {results}}",
   "standalone.public_title": "A look inside...",
   "status.cannot_reblog": "This post cannot be boosted",
@@ -196,6 +201,7 @@
   "tabs_bar.notifications": "Notifications",
   "upload_area.title": "Drag & drop to upload",
   "upload_button.label": "Add media",
+  "upload_form.description": "Describe for the visually impaired",
   "upload_form.undo": "Undo",
   "upload_progress.label": "Uploading...",
   "video.close": "Close video",
@@ -206,9 +212,5 @@
   "video.mute": "Mute sound",
   "video.pause": "Pause",
   "video.play": "Play",
-  "video.unmute": "Unmute sound",
-  "video_player.expand": "Expand video",
-  "video_player.toggle_sound": "Toggle sound",
-  "video_player.toggle_visible": "Toggle visibility",
-  "video_player.video_error": "Video could not be played"
+  "video.unmute": "Unmute sound"
 }
diff --git a/app/javascript/mastodon/locales/eo.json b/app/javascript/mastodon/locales/eo.json
index 21b92ed3a..1ccd2b817 100644
--- a/app/javascript/mastodon/locales/eo.json
+++ b/app/javascript/mastodon/locales/eo.json
@@ -165,6 +165,11 @@
   "report.submit": "Submit",
   "report.target": "Reporting",
   "search.placeholder": "Serĉi",
+  "search_popout.search_format": "Advanced search format",
+  "search_popout.tips.hashtag": "hashtag",
+  "search_popout.tips.status": "status",
+  "search_popout.tips.text": "Simple text returns matching display names, usernames and hashtags",
+  "search_popout.tips.user": "user",
   "search_results.total": "{count, number} {count, plural, one {result} other {results}}",
   "standalone.public_title": "A look inside...",
   "status.cannot_reblog": "This post cannot be boosted",
@@ -196,6 +201,7 @@
   "tabs_bar.notifications": "Sciigoj",
   "upload_area.title": "Drag & drop to upload",
   "upload_button.label": "Aldoni enhavaĵon",
+  "upload_form.description": "Describe for the visually impaired",
   "upload_form.undo": "Malfari",
   "upload_progress.label": "Uploading...",
   "video.close": "Close video",
@@ -206,9 +212,5 @@
   "video.mute": "Mute sound",
   "video.pause": "Pause",
   "video.play": "Play",
-  "video.unmute": "Unmute sound",
-  "video_player.expand": "Expand video",
-  "video_player.toggle_sound": "Aktivigi sonojn",
-  "video_player.toggle_visible": "Toggle visibility",
-  "video_player.video_error": "Video could not be played"
+  "video.unmute": "Unmute sound"
 }
diff --git a/app/javascript/mastodon/locales/es.json b/app/javascript/mastodon/locales/es.json
index 59c7dc5a7..f6bfbb04d 100644
--- a/app/javascript/mastodon/locales/es.json
+++ b/app/javascript/mastodon/locales/es.json
@@ -165,6 +165,11 @@
   "report.submit": "Publicar",
   "report.target": "Reportando",
   "search.placeholder": "Buscar",
+  "search_popout.search_format": "Advanced search format",
+  "search_popout.tips.hashtag": "hashtag",
+  "search_popout.tips.status": "status",
+  "search_popout.tips.text": "Simple text returns matching display names, usernames and hashtags",
+  "search_popout.tips.user": "user",
   "search_results.total": "{count, number} {count, plural, one {resultado} other {resultados}}",
   "standalone.public_title": "Un pequeño vistazo...",
   "status.cannot_reblog": "Este toot no puede retootearse",
@@ -196,6 +201,7 @@
   "tabs_bar.notifications": "Notificaciones",
   "upload_area.title": "Arrastra y suelta para subir",
   "upload_button.label": "Subir multimedia",
+  "upload_form.description": "Describe for the visually impaired",
   "upload_form.undo": "Deshacer",
   "upload_progress.label": "Subiendo…",
   "video.close": "Cerrar video",
@@ -206,9 +212,5 @@
   "video.mute": "Silenciar sonido",
   "video.pause": "Pausar",
   "video.play": "Reproducir",
-  "video.unmute": "Dejar de silenciar sonido",
-  "video_player.expand": "Expandir vídeo",
-  "video_player.toggle_sound": "Activar/Desactivar sonido",
-  "video_player.toggle_visible": "Cambiar visibilidad",
-  "video_player.video_error": "No se pudo reproducir el vídeo"
+  "video.unmute": "Dejar de silenciar sonido"
 }
diff --git a/app/javascript/mastodon/locales/fa.json b/app/javascript/mastodon/locales/fa.json
index 6e4771392..13fb91278 100644
--- a/app/javascript/mastodon/locales/fa.json
+++ b/app/javascript/mastodon/locales/fa.json
@@ -165,6 +165,11 @@
   "report.submit": "بفرست",
   "report.target": "گزارش‌دادن",
   "search.placeholder": "جستجو",
+  "search_popout.search_format": "Advanced search format",
+  "search_popout.tips.hashtag": "hashtag",
+  "search_popout.tips.status": "status",
+  "search_popout.tips.text": "Simple text returns matching display names, usernames and hashtags",
+  "search_popout.tips.user": "user",
   "search_results.total": "{count, number} {count, plural, one {نتیجه} other {نتیجه}}",
   "standalone.public_title": "نگاهی به کاربران این سرور...",
   "status.cannot_reblog": "این نوشته را نمی‌شود بازبوقید",
@@ -196,6 +201,7 @@
   "tabs_bar.notifications": "اعلان‌ها",
   "upload_area.title": "برای بارگذاری به این‌جا بکشید",
   "upload_button.label": "افزودن تصویر",
+  "upload_form.description": "Describe for the visually impaired",
   "upload_form.undo": "واگردانی",
   "upload_progress.label": "بارگذاری...",
   "video.close": "بستن ویدیو",
@@ -206,9 +212,5 @@
   "video.mute": "قطع صدا",
   "video.pause": "توقف",
   "video.play": "پخش",
-  "video.unmute": "پخش صدا",
-  "video_player.expand": "بازکردن ویدیو",
-  "video_player.toggle_sound": "تغییر صداداری",
-  "video_player.toggle_visible": "تغییر پیدایی",
-  "video_player.video_error": "ویدیو نمی‌تواند پخش شود"
+  "video.unmute": "پخش صدا"
 }
diff --git a/app/javascript/mastodon/locales/fi.json b/app/javascript/mastodon/locales/fi.json
index ccdf19dd6..425b3d82a 100644
--- a/app/javascript/mastodon/locales/fi.json
+++ b/app/javascript/mastodon/locales/fi.json
@@ -165,6 +165,11 @@
   "report.submit": "Submit",
   "report.target": "Reporting",
   "search.placeholder": "Hae",
+  "search_popout.search_format": "Advanced search format",
+  "search_popout.tips.hashtag": "hashtag",
+  "search_popout.tips.status": "status",
+  "search_popout.tips.text": "Simple text returns matching display names, usernames and hashtags",
+  "search_popout.tips.user": "user",
   "search_results.total": "{count, number} {count, plural, one {result} other {results}}",
   "standalone.public_title": "A look inside...",
   "status.cannot_reblog": "This post cannot be boosted",
@@ -196,6 +201,7 @@
   "tabs_bar.notifications": "Ilmoitukset",
   "upload_area.title": "Drag & drop to upload",
   "upload_button.label": "Lisää mediaa",
+  "upload_form.description": "Describe for the visually impaired",
   "upload_form.undo": "Peru",
   "upload_progress.label": "Uploading...",
   "video.close": "Close video",
@@ -206,9 +212,5 @@
   "video.mute": "Mute sound",
   "video.pause": "Pause",
   "video.play": "Play",
-  "video.unmute": "Unmute sound",
-  "video_player.expand": "Expand video",
-  "video_player.toggle_sound": "Äänet päälle/pois",
-  "video_player.toggle_visible": "Toggle visibility",
-  "video_player.video_error": "Video could not be played"
+  "video.unmute": "Unmute sound"
 }
diff --git a/app/javascript/mastodon/locales/fr.json b/app/javascript/mastodon/locales/fr.json
index 417c1062a..350d92c44 100644
--- a/app/javascript/mastodon/locales/fr.json
+++ b/app/javascript/mastodon/locales/fr.json
@@ -66,7 +66,7 @@
   "embed.instructions": "Intégrez ce statut à votre site en copiant ce code ci-dessous.",
   "embed.preview": "Il apparaîtra comme cela : ",
   "emoji_button.activity": "Activités",
-  "emoji_button.custom": "Custom",
+  "emoji_button.custom": "Personnalisés",
   "emoji_button.flags": "Drapeaux",
   "emoji_button.food": "Boire et manger",
   "emoji_button.label": "Insérer un emoji",
@@ -74,9 +74,9 @@
   "emoji_button.not_found": "No emojos!! (╯°□°)╯︵ ┻━┻",
   "emoji_button.objects": "Objets",
   "emoji_button.people": "Personnages",
-  "emoji_button.recent": "Frequently used",
+  "emoji_button.recent": "Fréquemment utilisés",
   "emoji_button.search": "Recherche…",
-  "emoji_button.search_results": "Search results",
+  "emoji_button.search_results": "Résultats de la recherche",
   "emoji_button.symbols": "Symboles",
   "emoji_button.travel": "Lieux et voyages",
   "empty_column.community": "Le fil public local est vide. Écrivez-donc quelque chose pour le remplir !",
@@ -165,6 +165,11 @@
   "report.submit": "Envoyer",
   "report.target": "Signalement",
   "search.placeholder": "Rechercher",
+  "search_popout.search_format": "Recherche avancée",
+  "search_popout.tips.hashtag": "hashtag",
+  "search_popout.tips.status": "statuts",
+  "search_popout.tips.text": "Un texte simple renvoie les noms affichés, les noms d'utilisateur et les hashtags correspondants",
+  "search_popout.tips.user": "utilisateur",
   "search_results.total": "{count, number} {count, plural, one {résultat} other {résultats}}",
   "standalone.public_title": "Jeter un coup d’œil…",
   "status.cannot_reblog": "Cette publication ne peut être boostée",
@@ -196,6 +201,7 @@
   "tabs_bar.notifications": "Notifications",
   "upload_area.title": "Glissez et déposez pour envoyer",
   "upload_button.label": "Joindre un média",
+  "upload_form.description": "Décrire pour les malvoyants",
   "upload_form.undo": "Annuler",
   "upload_progress.label": "Envoi en cours…",
   "video.close": "Fermer la vidéo",
@@ -206,9 +212,5 @@
   "video.mute": "Couper le son",
   "video.pause": "Pause",
   "video.play": "Lecture",
-  "video.unmute": "Rétablir le son",
-  "video_player.expand": "Agrandir la vidéo",
-  "video_player.toggle_sound": "Activer/Désactiver le son",
-  "video_player.toggle_visible": "Afficher/Cacher la vidéo",
-  "video_player.video_error": "Erreur lors de la lecture de la vidéo"
+  "video.unmute": "Rétablir le son"
 }
diff --git a/app/javascript/mastodon/locales/he.json b/app/javascript/mastodon/locales/he.json
index f78c31a46..beaa4fd3a 100644
--- a/app/javascript/mastodon/locales/he.json
+++ b/app/javascript/mastodon/locales/he.json
@@ -165,6 +165,11 @@
   "report.submit": "שליחה",
   "report.target": "דיווח",
   "search.placeholder": "חיפוש",
+  "search_popout.search_format": "Advanced search format",
+  "search_popout.tips.hashtag": "hashtag",
+  "search_popout.tips.status": "status",
+  "search_popout.tips.text": "Simple text returns matching display names, usernames and hashtags",
+  "search_popout.tips.user": "user",
   "search_results.total": "{count, number} {count, plural, one {תוצאה} other {תוצאות}}",
   "standalone.public_title": "A look inside...",
   "status.cannot_reblog": "לא ניתן להדהד הודעה זו",
@@ -196,6 +201,7 @@
   "tabs_bar.notifications": "התראות",
   "upload_area.title": "ניתן להעלות על ידי Drag & drop",
   "upload_button.label": "הוספת מדיה",
+  "upload_form.description": "Describe for the visually impaired",
   "upload_form.undo": "ביטול",
   "upload_progress.label": "עולה...",
   "video.close": "Close video",
@@ -206,9 +212,5 @@
   "video.mute": "Mute sound",
   "video.pause": "Pause",
   "video.play": "Play",
-  "video.unmute": "Unmute sound",
-  "video_player.expand": "הרחבת וידאו",
-  "video_player.toggle_sound": "הפעלת\\ביטול שמע",
-  "video_player.toggle_visible": "הפעלת\\ביטול תצוגה",
-  "video_player.video_error": "לא ניתן לנגן וידאו"
+  "video.unmute": "Unmute sound"
 }
diff --git a/app/javascript/mastodon/locales/hr.json b/app/javascript/mastodon/locales/hr.json
index 43fe95eb8..cef61f15e 100644
--- a/app/javascript/mastodon/locales/hr.json
+++ b/app/javascript/mastodon/locales/hr.json
@@ -165,6 +165,11 @@
   "report.submit": "Pošalji",
   "report.target": "Prijavljivanje",
   "search.placeholder": "Traži",
+  "search_popout.search_format": "Advanced search format",
+  "search_popout.tips.hashtag": "hashtag",
+  "search_popout.tips.status": "status",
+  "search_popout.tips.text": "Simple text returns matching display names, usernames and hashtags",
+  "search_popout.tips.user": "user",
   "search_results.total": "{count, number} {count, plural, one {result} other {results}}",
   "standalone.public_title": "A look inside...",
   "status.cannot_reblog": "Ovaj post ne može biti boostan",
@@ -196,6 +201,7 @@
   "tabs_bar.notifications": "Notifikacije",
   "upload_area.title": "Povuci i spusti kako bi uploadao",
   "upload_button.label": "Dodaj media",
+  "upload_form.description": "Describe for the visually impaired",
   "upload_form.undo": "Poništi",
   "upload_progress.label": "Uploadam...",
   "video.close": "Close video",
@@ -206,9 +212,5 @@
   "video.mute": "Mute sound",
   "video.pause": "Pause",
   "video.play": "Play",
-  "video.unmute": "Unmute sound",
-  "video_player.expand": "Proširi video",
-  "video_player.toggle_sound": "Toggle zvuk",
-  "video_player.toggle_visible": "Preklopi vidljivost",
-  "video_player.video_error": "Video ne može biti reproduciran"
+  "video.unmute": "Unmute sound"
 }
diff --git a/app/javascript/mastodon/locales/hu.json b/app/javascript/mastodon/locales/hu.json
index f73295dca..7b9c1b293 100644
--- a/app/javascript/mastodon/locales/hu.json
+++ b/app/javascript/mastodon/locales/hu.json
@@ -165,6 +165,11 @@
   "report.submit": "Submit",
   "report.target": "Reporting",
   "search.placeholder": "Keresés",
+  "search_popout.search_format": "Advanced search format",
+  "search_popout.tips.hashtag": "hashtag",
+  "search_popout.tips.status": "status",
+  "search_popout.tips.text": "Simple text returns matching display names, usernames and hashtags",
+  "search_popout.tips.user": "user",
   "search_results.total": "{count, number} {count, plural, one {result} other {results}}",
   "standalone.public_title": "A look inside...",
   "status.cannot_reblog": "This post cannot be boosted",
@@ -196,6 +201,7 @@
   "tabs_bar.notifications": "Notifications",
   "upload_area.title": "Drag & drop to upload",
   "upload_button.label": "Média hozzáadása",
+  "upload_form.description": "Describe for the visually impaired",
   "upload_form.undo": "Mégsem",
   "upload_progress.label": "Uploading...",
   "video.close": "Close video",
@@ -206,9 +212,5 @@
   "video.mute": "Mute sound",
   "video.pause": "Pause",
   "video.play": "Play",
-  "video.unmute": "Unmute sound",
-  "video_player.expand": "Expand video",
-  "video_player.toggle_sound": "Hang kapcsolása",
-  "video_player.toggle_visible": "Toggle visibility",
-  "video_player.video_error": "Video could not be played"
+  "video.unmute": "Unmute sound"
 }
diff --git a/app/javascript/mastodon/locales/id.json b/app/javascript/mastodon/locales/id.json
index 4d5f0a5d8..cc48aa996 100644
--- a/app/javascript/mastodon/locales/id.json
+++ b/app/javascript/mastodon/locales/id.json
@@ -165,6 +165,11 @@
   "report.submit": "Kirim",
   "report.target": "Melaporkan",
   "search.placeholder": "Pencarian",
+  "search_popout.search_format": "Advanced search format",
+  "search_popout.tips.hashtag": "hashtag",
+  "search_popout.tips.status": "status",
+  "search_popout.tips.text": "Simple text returns matching display names, usernames and hashtags",
+  "search_popout.tips.user": "user",
   "search_results.total": "{count} {count, plural, one {hasil} other {hasil}}",
   "standalone.public_title": "A look inside...",
   "status.cannot_reblog": "This post cannot be boosted",
@@ -196,6 +201,7 @@
   "tabs_bar.notifications": "Notifikasi",
   "upload_area.title": "Seret & lepaskan untuk mengunggah",
   "upload_button.label": "Tambahkan media",
+  "upload_form.description": "Describe for the visually impaired",
   "upload_form.undo": "Undo",
   "upload_progress.label": "Mengunggah...",
   "video.close": "Close video",
@@ -206,9 +212,5 @@
   "video.mute": "Mute sound",
   "video.pause": "Pause",
   "video.play": "Play",
-  "video.unmute": "Unmute sound",
-  "video_player.expand": "Tampilkan video",
-  "video_player.toggle_sound": "Suara",
-  "video_player.toggle_visible": "Tampilan",
-  "video_player.video_error": "Video tidak dapat diputar"
+  "video.unmute": "Unmute sound"
 }
diff --git a/app/javascript/mastodon/locales/io.json b/app/javascript/mastodon/locales/io.json
index d2c1ee73d..b484bebc7 100644
--- a/app/javascript/mastodon/locales/io.json
+++ b/app/javascript/mastodon/locales/io.json
@@ -165,6 +165,11 @@
   "report.submit": "Sendar",
   "report.target": "Denuncante",
   "search.placeholder": "Serchez",
+  "search_popout.search_format": "Advanced search format",
+  "search_popout.tips.hashtag": "hashtag",
+  "search_popout.tips.status": "status",
+  "search_popout.tips.text": "Simple text returns matching display names, usernames and hashtags",
+  "search_popout.tips.user": "user",
   "search_results.total": "{count, number} {count, plural, one {rezulto} other {rezulti}}",
   "standalone.public_title": "A look inside...",
   "status.cannot_reblog": "This post cannot be boosted",
@@ -196,6 +201,7 @@
   "tabs_bar.notifications": "Savigi",
   "upload_area.title": "Tranar faligar por kargar",
   "upload_button.label": "Adjuntar kontenajo",
+  "upload_form.description": "Describe for the visually impaired",
   "upload_form.undo": "Desfacar",
   "upload_progress.label": "Kargante...",
   "video.close": "Close video",
@@ -206,9 +212,5 @@
   "video.mute": "Mute sound",
   "video.pause": "Pause",
   "video.play": "Play",
-  "video.unmute": "Unmute sound",
-  "video_player.expand": "Extensar video",
-  "video_player.toggle_sound": "Acendar sono",
-  "video_player.toggle_visible": "Chanjar videbleso",
-  "video_player.video_error": "Video ne povus pleesar"
+  "video.unmute": "Unmute sound"
 }
diff --git a/app/javascript/mastodon/locales/it.json b/app/javascript/mastodon/locales/it.json
index 33f0e7fdc..4d73fbea8 100644
--- a/app/javascript/mastodon/locales/it.json
+++ b/app/javascript/mastodon/locales/it.json
@@ -165,6 +165,11 @@
   "report.submit": "Invia",
   "report.target": "Invio la segnalazione",
   "search.placeholder": "Cerca",
+  "search_popout.search_format": "Advanced search format",
+  "search_popout.tips.hashtag": "hashtag",
+  "search_popout.tips.status": "status",
+  "search_popout.tips.text": "Simple text returns matching display names, usernames and hashtags",
+  "search_popout.tips.user": "user",
   "search_results.total": "{count} {count, plural, one {risultato} other {risultati}}",
   "standalone.public_title": "A look inside...",
   "status.cannot_reblog": "This post cannot be boosted",
@@ -196,6 +201,7 @@
   "tabs_bar.notifications": "Notifiche",
   "upload_area.title": "Trascina per caricare",
   "upload_button.label": "Aggiungi file multimediale",
+  "upload_form.description": "Describe for the visually impaired",
   "upload_form.undo": "Annulla",
   "upload_progress.label": "Sto caricando...",
   "video.close": "Close video",
@@ -206,9 +212,5 @@
   "video.mute": "Mute sound",
   "video.pause": "Pause",
   "video.play": "Play",
-  "video.unmute": "Unmute sound",
-  "video_player.expand": "Espandi video",
-  "video_player.toggle_sound": "Attiva suono",
-  "video_player.toggle_visible": "Attiva visibilità",
-  "video_player.video_error": "Il video non può essere riprodotto"
+  "video.unmute": "Unmute sound"
 }
diff --git a/app/javascript/mastodon/locales/ja.json b/app/javascript/mastodon/locales/ja.json
index c3d96baf3..ce797a7c7 100644
--- a/app/javascript/mastodon/locales/ja.json
+++ b/app/javascript/mastodon/locales/ja.json
@@ -66,17 +66,17 @@
   "embed.instructions": "下記のコードをコピーしてウェブサイトに埋め込みます。",
   "embed.preview": "表示例:",
   "emoji_button.activity": "活動",
-  "emoji_button.custom": "Custom",
+  "emoji_button.custom": "カスタム絵文字",
   "emoji_button.flags": "国旗",
   "emoji_button.food": "食べ物",
   "emoji_button.label": "絵文字を追加",
   "emoji_button.nature": "自然",
-  "emoji_button.not_found": "No emojos!! (╯°□°)╯︵ ┻━┻",
+  "emoji_button.not_found": "絵文字がない!! (╯°□°)╯︵ ┻━┻",
   "emoji_button.objects": "物",
   "emoji_button.people": "人々",
-  "emoji_button.recent": "Frequently used",
+  "emoji_button.recent": "よく使う絵文字",
   "emoji_button.search": "検索...",
-  "emoji_button.search_results": "Search results",
+  "emoji_button.search_results": "検索結果",
   "emoji_button.symbols": "記号",
   "emoji_button.travel": "旅行と場所",
   "empty_column.community": "ローカルタイムラインはまだ使われていません。何か書いてみましょう!",
@@ -160,11 +160,21 @@
   "privacy.public.short": "公開",
   "privacy.unlisted.long": "公開TLで表示しない",
   "privacy.unlisted.short": "未収載",
+  "relative_time.days": "{number}日前",
+  "relative_time.hours": "{number}時間前",
+  "relative_time.just_now": "今",
+  "relative_time.minutes": "{number}分前",
+  "relative_time.seconds": "{number}秒前",
   "reply_indicator.cancel": "キャンセル",
   "report.placeholder": "コメント",
   "report.submit": "通報する",
   "report.target": "{target} を通報する",
   "search.placeholder": "検索",
+  "search_popout.search_format": "高度な検索フォーマット",
+  "search_popout.tips.hashtag": "ハッシュタグ",
+  "search_popout.tips.status": "トゥート",
+  "search_popout.tips.text": "表示名やユーザー名、ハッシュタグに一致する単純なテキスト",
+  "search_popout.tips.user": "ユーザー",
   "search_results.total": "{count, number}件の結果",
   "standalone.public_title": "今こんな話をしています",
   "status.cannot_reblog": "この投稿はブーストできません",
@@ -196,6 +206,7 @@
   "tabs_bar.notifications": "通知",
   "upload_area.title": "ドラッグ&ドロップでアップロード",
   "upload_button.label": "メディアを追加",
+  "upload_form.description": "視覚障害者のための説明",
   "upload_form.undo": "やり直す",
   "upload_progress.label": "アップロード中...",
   "video.close": "動画を閉じる",
@@ -206,9 +217,5 @@
   "video.mute": "ミュート",
   "video.pause": "一時停止",
   "video.play": "再生",
-  "video.unmute": "ミュートを解除する",
-  "video_player.expand": "動画の詳細",
-  "video_player.toggle_sound": "音の切り替え",
-  "video_player.toggle_visible": "表示切り替え",
-  "video_player.video_error": "動画の再生に失敗しました"
+  "video.unmute": "ミュートを解除する"
 }
diff --git a/app/javascript/mastodon/locales/ko.json b/app/javascript/mastodon/locales/ko.json
index c50bb2f34..c1768cf8f 100644
--- a/app/javascript/mastodon/locales/ko.json
+++ b/app/javascript/mastodon/locales/ko.json
@@ -165,6 +165,11 @@
   "report.submit": "신고하기",
   "report.target": "문제가 된 사용자",
   "search.placeholder": "검색",
+  "search_popout.search_format": "Advanced search format",
+  "search_popout.tips.hashtag": "hashtag",
+  "search_popout.tips.status": "status",
+  "search_popout.tips.text": "Simple text returns matching display names, usernames and hashtags",
+  "search_popout.tips.user": "user",
   "search_results.total": "{count, number}건의 결과",
   "standalone.public_title": "A look inside...",
   "status.cannot_reblog": "이 포스트는 부스트 할 수 없습니다",
@@ -196,6 +201,7 @@
   "tabs_bar.notifications": "알림",
   "upload_area.title": "드래그 & 드롭으로 업로드",
   "upload_button.label": "미디어 추가",
+  "upload_form.description": "Describe for the visually impaired",
   "upload_form.undo": "재시도",
   "upload_progress.label": "업로드 중...",
   "video.close": "Close video",
@@ -206,9 +212,5 @@
   "video.mute": "Mute sound",
   "video.pause": "Pause",
   "video.play": "Play",
-  "video.unmute": "Unmute sound",
-  "video_player.expand": "동영상 자세히 보기",
-  "video_player.toggle_sound": "소리 토글하기",
-  "video_player.toggle_visible": "표시 전환",
-  "video_player.video_error": "동영상 재생에 실패했습니다"
+  "video.unmute": "Unmute sound"
 }
diff --git a/app/javascript/mastodon/locales/nl.json b/app/javascript/mastodon/locales/nl.json
index c333bec70..bad2d78c5 100644
--- a/app/javascript/mastodon/locales/nl.json
+++ b/app/javascript/mastodon/locales/nl.json
@@ -165,6 +165,11 @@
   "report.submit": "Verzenden",
   "report.target": "Rapporteren van",
   "search.placeholder": "Zoeken",
+  "search_popout.search_format": "Advanced search format",
+  "search_popout.tips.hashtag": "hashtag",
+  "search_popout.tips.status": "status",
+  "search_popout.tips.text": "Simple text returns matching display names, usernames and hashtags",
+  "search_popout.tips.user": "user",
   "search_results.total": "{count, number} {count, plural, one {resultaat} other {resultaten}}",
   "standalone.public_title": "Een kijkje binnenin...",
   "status.cannot_reblog": "Deze toot kan niet geboost worden",
@@ -196,6 +201,7 @@
   "tabs_bar.notifications": "Meldingen",
   "upload_area.title": "Hierin slepen om te uploaden",
   "upload_button.label": "Media toevoegen",
+  "upload_form.description": "Describe for the visually impaired",
   "upload_form.undo": "Ongedaan maken",
   "upload_progress.label": "Uploaden...",
   "video.close": "Close video",
@@ -206,9 +212,5 @@
   "video.mute": "Geluid uitschakelen",
   "video.pause": "Pauze",
   "video.play": "Afspelen",
-  "video.unmute": "Geluid inschakelen",
-  "video_player.expand": "Video groter maken",
-  "video_player.toggle_sound": "Geluid in-/uitschakelen",
-  "video_player.toggle_visible": "Video wel/niet tonen",
-  "video_player.video_error": "Video kon niet afgespeeld worden"
+  "video.unmute": "Geluid inschakelen"
 }
diff --git a/app/javascript/mastodon/locales/no.json b/app/javascript/mastodon/locales/no.json
index d28190faf..26556b290 100644
--- a/app/javascript/mastodon/locales/no.json
+++ b/app/javascript/mastodon/locales/no.json
@@ -165,6 +165,11 @@
   "report.submit": "Send inn",
   "report.target": "Rapporterer",
   "search.placeholder": "Søk",
+  "search_popout.search_format": "Advanced search format",
+  "search_popout.tips.hashtag": "hashtag",
+  "search_popout.tips.status": "status",
+  "search_popout.tips.text": "Simple text returns matching display names, usernames and hashtags",
+  "search_popout.tips.user": "user",
   "search_results.total": "{count, number} {count, plural, one {resultat} other {resultater}}",
   "standalone.public_title": "A look inside...",
   "status.cannot_reblog": "Denne posten kan ikke fremheves",
@@ -196,6 +201,7 @@
   "tabs_bar.notifications": "Varslinger",
   "upload_area.title": "Dra og slipp for å laste opp",
   "upload_button.label": "Legg til media",
+  "upload_form.description": "Describe for the visually impaired",
   "upload_form.undo": "Angre",
   "upload_progress.label": "Laster opp...",
   "video.close": "Close video",
@@ -206,9 +212,5 @@
   "video.mute": "Mute sound",
   "video.pause": "Pause",
   "video.play": "Play",
-  "video.unmute": "Unmute sound",
-  "video_player.expand": "Utvid video",
-  "video_player.toggle_sound": "Veksle lyd",
-  "video_player.toggle_visible": "Veksle synlighet",
-  "video_player.video_error": "Video kunne ikke spilles av"
+  "video.unmute": "Unmute sound"
 }
diff --git a/app/javascript/mastodon/locales/oc.json b/app/javascript/mastodon/locales/oc.json
index 8e9d06642..d730b47f4 100644
--- a/app/javascript/mastodon/locales/oc.json
+++ b/app/javascript/mastodon/locales/oc.json
@@ -165,6 +165,11 @@
   "report.submit": "Mandar",
   "report.target": "Senhalar {target}",
   "search.placeholder": "Recercar",
+  "search_popout.search_format": "Format recèrca avançada",
+  "search_popout.tips.hashtag": "etiqueta",
+  "search_popout.tips.status": "estatut",
+  "search_popout.tips.text": "Tèxt brut tòrna escais, noms d’utilizaire e etiquetas correspondents",
+  "search_popout.tips.user": "utilizaire",
   "search_results.total": "{count, number} {count, plural, one {resultat} other {resultats}}",
   "standalone.public_title": "Una ulhada dedins…",
   "status.cannot_reblog": "Aqueste estatut pòt pas èsser partejat",
@@ -196,6 +201,7 @@
   "tabs_bar.notifications": "Notificacions",
   "upload_area.title": "Lisatz e depausatz per mandar",
   "upload_button.label": "Ajustar un mèdia",
+  "upload_form.description": "Descripcion se per cas i aja un problèma",
   "upload_form.undo": "Anullar",
   "upload_progress.label": "Mandadís…",
   "video.close": "Tampar la vidèo",
@@ -206,9 +212,5 @@
   "video.mute": "Copar lo son",
   "video.pause": "Pausa",
   "video.play": "Lectura",
-  "video.unmute": "Restablir lo son",
-  "video_player.expand": "Mostrar la vidèo",
-  "video_player.toggle_sound": "Activar/Desactivar lo son",
-  "video_player.toggle_visible": "Mostrar/Rescondre la vidèo",
-  "video_player.video_error": "Fracàs de la lectura de la vidèo"
+  "video.unmute": "Restablir lo son"
 }
diff --git a/app/javascript/mastodon/locales/pl.json b/app/javascript/mastodon/locales/pl.json
index 35b1a3101..c8228c0cb 100644
--- a/app/javascript/mastodon/locales/pl.json
+++ b/app/javascript/mastodon/locales/pl.json
@@ -56,14 +56,14 @@
   "confirmations.block.confirm": "Zablokuj",
   "confirmations.block.message": "Czy na pewno chcesz zablokować {name}?",
   "confirmations.delete.confirm": "Usuń",
-  "confirmations.delete.message": "Czy na pewno chcesz usunąć ten status?",
+  "confirmations.delete.message": "Czy na pewno chcesz usunąć ten wpis?",
   "confirmations.domain_block.confirm": "Ukryj wszysyko z domeny",
   "confirmations.domain_block.message": "Czy na pewno chcesz zablokować całą domenę {domain}? Zwykle lepszym rozwiązaniem jest blokada lub wyciszenie kilku użytkowników.",
   "confirmations.mute.confirm": "Wycisz",
   "confirmations.mute.message": "Czy na pewno chcesz wyciszyć {name}?",
   "confirmations.unfollow.confirm": "Przestań śledzić",
   "confirmations.unfollow.message": "Czy na pewno zamierzasz przestać śledzić {name}?",
-  "embed.instructions": "Osadź ten status na swojej stronie wklejając poniższy kod.",
+  "embed.instructions": "Osadź ten wpis na swojej stronie wklejając poniższy kod.",
   "embed.preview": "Tak będzie to wyglądać:",
   "emoji_button.activity": "Aktywność",
   "emoji_button.custom": "Niestandardowe",
@@ -116,10 +116,10 @@
   "navigation_bar.pins": "Przypięte wpisy",
   "navigation_bar.preferences": "Preferencje",
   "navigation_bar.public_timeline": "Oś czasu federacji",
-  "notification.favourite": "{name} dodał Twój status do ulubionych",
+  "notification.favourite": "{name} dodał Twój wpis do ulubionych",
   "notification.follow": "{name} zaczął Cię śledzić",
   "notification.mention": "{name} wspomniał o tobie",
-  "notification.reblog": "{name} podbił Twój status",
+  "notification.reblog": "{name} podbił Twój wpis",
   "notifications.clear": "Wyczyść powiadomienia",
   "notifications.clear_confirmation": "Czy na pewno chcesz bezpowrotnie usunąć wszystkie powiadomienia?",
   "notifications.column_settings.alert": "Powiadomienia na pulpicie",
@@ -165,6 +165,11 @@
   "report.submit": "Wyślij",
   "report.target": "Zgłaszanie {target}",
   "search.placeholder": "Szukaj",
+  "search_popout.search_format": "Zaawansowane wyszukiwanie",
+  "search_popout.tips.hashtag": "hashtag",
+  "search_popout.tips.status": "wpis",
+  "search_popout.tips.text": "Proste wyszukiwanie pasujących pseudonimów, nazw użytkowników i hashtagów",
+  "search_popout.tips.user": "użytkownik",
   "search_results.total": "{count, number} {count, plural, one {wynik} more {wyniki}}",
   "standalone.public_title": "Spojrzenie w głąb…",
   "status.cannot_reblog": "Ten wpis nie może zostać podbity",
@@ -175,7 +180,7 @@
   "status.media_hidden": "Zawartość multimedialna ukryta",
   "status.mention": "Wspomnij o @{name}",
   "status.mute_conversation": "Wycisz konwersację",
-  "status.open": "Rozszerz ten status",
+  "status.open": "Rozszerz ten wpis",
   "status.pin": "Przypnij do profilu",
   "status.reblog": "Podbij",
   "status.reblogged_by": "{name} podbił",
@@ -196,6 +201,7 @@
   "tabs_bar.notifications": "Powiadomienia",
   "upload_area.title": "Przeciągnij i upuść aby wysłać",
   "upload_button.label": "Dodaj zawartość multimedialną",
+  "upload_form.description": "Wprowadź opis dla niewidomych i niedowidzących",
   "upload_form.undo": "Cofnij",
   "upload_progress.label": "Wysyłanie",
   "video.close": "Zamknij film",
@@ -206,9 +212,5 @@
   "video.mute": "Wycisz",
   "video.pause": "Pauzuj",
   "video.play": "Odtwórz",
-  "video.unmute": "Cofnij wyciszenie",
-  "video_player.expand": "Rozszerz film",
-  "video_player.toggle_sound": "Przełącz dźwięk",
-  "video_player.toggle_visible": "Przełącz widoczność",
-  "video_player.video_error": "Nie można odtworzyć pliku wideo"
+  "video.unmute": "Cofnij wyciszenie"
 }
diff --git a/app/javascript/mastodon/locales/pt-BR.json b/app/javascript/mastodon/locales/pt-BR.json
index 187343e83..61674b37e 100644
--- a/app/javascript/mastodon/locales/pt-BR.json
+++ b/app/javascript/mastodon/locales/pt-BR.json
@@ -165,6 +165,11 @@
   "report.submit": "Enviar",
   "report.target": "Denunciar",
   "search.placeholder": "Pesquisar",
+  "search_popout.search_format": "Advanced search format",
+  "search_popout.tips.hashtag": "hashtag",
+  "search_popout.tips.status": "status",
+  "search_popout.tips.text": "Simple text returns matching display names, usernames and hashtags",
+  "search_popout.tips.user": "user",
   "search_results.total": "{count, number} {count, plural, one {resultado} other {resultados}}",
   "standalone.public_title": "Dê uma espiada...",
   "status.cannot_reblog": "Esta postagem não pode ser compartilhada",
@@ -196,6 +201,7 @@
   "tabs_bar.notifications": "Notificações",
   "upload_area.title": "Arraste e solte para enviar",
   "upload_button.label": "Adicionar mídia",
+  "upload_form.description": "Describe for the visually impaired",
   "upload_form.undo": "Anular",
   "upload_progress.label": "Salvando...",
   "video.close": "Fechar vídeo",
@@ -206,9 +212,5 @@
   "video.mute": "Silenciar vídeo",
   "video.pause": "Parar",
   "video.play": "Reproduzir",
-  "video.unmute": "Retirar silêncio",
-  "video_player.expand": "Expandir vídeo",
-  "video_player.toggle_sound": "Ligar/Desligar som",
-  "video_player.toggle_visible": "Ligar/Desligar vídeo",
-  "video_player.video_error": "Não é possível ver o vídeo"
+  "video.unmute": "Retirar silêncio"
 }
diff --git a/app/javascript/mastodon/locales/pt.json b/app/javascript/mastodon/locales/pt.json
index 782aaf114..ecd0689df 100644
--- a/app/javascript/mastodon/locales/pt.json
+++ b/app/javascript/mastodon/locales/pt.json
@@ -165,6 +165,11 @@
   "report.submit": "Enviar",
   "report.target": "Denunciar",
   "search.placeholder": "Pesquisar",
+  "search_popout.search_format": "Advanced search format",
+  "search_popout.tips.hashtag": "hashtag",
+  "search_popout.tips.status": "status",
+  "search_popout.tips.text": "Simple text returns matching display names, usernames and hashtags",
+  "search_popout.tips.user": "user",
   "search_results.total": "{count, number} {count, plural, one {resultado} other {resultados}}",
   "standalone.public_title": "A look inside...",
   "status.cannot_reblog": "This post cannot be boosted",
@@ -196,6 +201,7 @@
   "tabs_bar.notifications": "Notificações",
   "upload_area.title": "Arraste e solte para enviar",
   "upload_button.label": "Adicionar media",
+  "upload_form.description": "Describe for the visually impaired",
   "upload_form.undo": "Anular",
   "upload_progress.label": "A gravar...",
   "video.close": "Close video",
@@ -206,9 +212,5 @@
   "video.mute": "Mute sound",
   "video.pause": "Pause",
   "video.play": "Play",
-  "video.unmute": "Unmute sound",
-  "video_player.expand": "Expandir vídeo",
-  "video_player.toggle_sound": "Ligar/Desligar som",
-  "video_player.toggle_visible": "Ligar/Desligar vídeo",
-  "video_player.video_error": "Não é possível ver o vídeo"
+  "video.unmute": "Unmute sound"
 }
diff --git a/app/javascript/mastodon/locales/ru.json b/app/javascript/mastodon/locales/ru.json
index 6f39d098c..bf32c820d 100644
--- a/app/javascript/mastodon/locales/ru.json
+++ b/app/javascript/mastodon/locales/ru.json
@@ -165,6 +165,11 @@
   "report.submit": "Отправить",
   "report.target": "Жалуемся на",
   "search.placeholder": "Поиск",
+  "search_popout.search_format": "Advanced search format",
+  "search_popout.tips.hashtag": "hashtag",
+  "search_popout.tips.status": "status",
+  "search_popout.tips.text": "Simple text returns matching display names, usernames and hashtags",
+  "search_popout.tips.user": "user",
   "search_results.total": "{count, number} {count, plural, one {результат} few {результата} many {результатов} other {результатов}}",
   "standalone.public_title": "A look inside...",
   "status.cannot_reblog": "Этот статус не может быть продвинут",
@@ -196,6 +201,7 @@
   "tabs_bar.notifications": "Уведомления",
   "upload_area.title": "Перетащите сюда, чтобы загрузить",
   "upload_button.label": "Добавить медиаконтент",
+  "upload_form.description": "Describe for the visually impaired",
   "upload_form.undo": "Отменить",
   "upload_progress.label": "Загрузка...",
   "video.close": "Close video",
@@ -206,9 +212,5 @@
   "video.mute": "Mute sound",
   "video.pause": "Pause",
   "video.play": "Play",
-  "video.unmute": "Unmute sound",
-  "video_player.expand": "Развернуть видео",
-  "video_player.toggle_sound": "Вкл./выкл. звук",
-  "video_player.toggle_visible": "Показать/скрыть",
-  "video_player.video_error": "Видео не может быть проиграно"
+  "video.unmute": "Unmute sound"
 }
diff --git a/app/javascript/mastodon/locales/th.json b/app/javascript/mastodon/locales/th.json
index ecc7a00db..f3ec9c532 100644
--- a/app/javascript/mastodon/locales/th.json
+++ b/app/javascript/mastodon/locales/th.json
@@ -165,6 +165,11 @@
   "report.submit": "Submit",
   "report.target": "Reporting",
   "search.placeholder": "Search",
+  "search_popout.search_format": "Advanced search format",
+  "search_popout.tips.hashtag": "hashtag",
+  "search_popout.tips.status": "status",
+  "search_popout.tips.text": "Simple text returns matching display names, usernames and hashtags",
+  "search_popout.tips.user": "user",
   "search_results.total": "{count, number} {count, plural, one {result} other {results}}",
   "standalone.public_title": "A look inside...",
   "status.cannot_reblog": "This post cannot be boosted",
@@ -196,6 +201,7 @@
   "tabs_bar.notifications": "Notifications",
   "upload_area.title": "Drag & drop to upload",
   "upload_button.label": "Add media",
+  "upload_form.description": "Describe for the visually impaired",
   "upload_form.undo": "Undo",
   "upload_progress.label": "Uploading...",
   "video.close": "Close video",
@@ -206,9 +212,5 @@
   "video.mute": "Mute sound",
   "video.pause": "Pause",
   "video.play": "Play",
-  "video.unmute": "Unmute sound",
-  "video_player.expand": "Expand video",
-  "video_player.toggle_sound": "Toggle sound",
-  "video_player.toggle_visible": "Toggle visibility",
-  "video_player.video_error": "Video could not be played"
+  "video.unmute": "Unmute sound"
 }
diff --git a/app/javascript/mastodon/locales/tr.json b/app/javascript/mastodon/locales/tr.json
index b7ecd2cdb..afc6383b4 100644
--- a/app/javascript/mastodon/locales/tr.json
+++ b/app/javascript/mastodon/locales/tr.json
@@ -165,6 +165,11 @@
   "report.submit": "Gönder",
   "report.target": "Raporlama",
   "search.placeholder": "Ara",
+  "search_popout.search_format": "Advanced search format",
+  "search_popout.tips.hashtag": "hashtag",
+  "search_popout.tips.status": "status",
+  "search_popout.tips.text": "Simple text returns matching display names, usernames and hashtags",
+  "search_popout.tips.user": "user",
   "search_results.total": "{count, number} {count, plural, one {sonuç} other {sonuçlar}}",
   "standalone.public_title": "A look inside...",
   "status.cannot_reblog": "Bu gönderi boost edilemez",
@@ -196,6 +201,7 @@
   "tabs_bar.notifications": "Bildirimler",
   "upload_area.title": "Upload için sürükle bırak yapınız",
   "upload_button.label": "Görsel ekle",
+  "upload_form.description": "Describe for the visually impaired",
   "upload_form.undo": "Geri al",
   "upload_progress.label": "Yükleniyor...",
   "video.close": "Close video",
@@ -206,9 +212,5 @@
   "video.mute": "Mute sound",
   "video.pause": "Pause",
   "video.play": "Play",
-  "video.unmute": "Unmute sound",
-  "video_player.expand": "Videoyu genişlet",
-  "video_player.toggle_sound": "Sesi aç/kapa",
-  "video_player.toggle_visible": "Göster/gizle",
-  "video_player.video_error": "Video oynatılamadı"
+  "video.unmute": "Unmute sound"
 }
diff --git a/app/javascript/mastodon/locales/uk.json b/app/javascript/mastodon/locales/uk.json
index 45b2c2ee0..d0aae032b 100644
--- a/app/javascript/mastodon/locales/uk.json
+++ b/app/javascript/mastodon/locales/uk.json
@@ -165,6 +165,11 @@
   "report.submit": "Відправити",
   "report.target": "Скаржимося на",
   "search.placeholder": "Пошук",
+  "search_popout.search_format": "Advanced search format",
+  "search_popout.tips.hashtag": "hashtag",
+  "search_popout.tips.status": "status",
+  "search_popout.tips.text": "Simple text returns matching display names, usernames and hashtags",
+  "search_popout.tips.user": "user",
   "search_results.total": "{count, number} {count, plural, one {результат} few {результати} many {результатів} other {результатів}}",
   "standalone.public_title": "A look inside...",
   "status.cannot_reblog": "Цей допис не може бути передмухнутий",
@@ -196,6 +201,7 @@
   "tabs_bar.notifications": "Сповіщення",
   "upload_area.title": "Перетягніть сюди, щоб завантажити",
   "upload_button.label": "Додати медіаконтент",
+  "upload_form.description": "Describe for the visually impaired",
   "upload_form.undo": "Відмінити",
   "upload_progress.label": "Завантаження...",
   "video.close": "Close video",
@@ -206,9 +212,5 @@
   "video.mute": "Mute sound",
   "video.pause": "Pause",
   "video.play": "Play",
-  "video.unmute": "Unmute sound",
-  "video_player.expand": "Розгорнути ",
-  "video_player.toggle_sound": "Увімкнути/вимкнути звук",
-  "video_player.toggle_visible": "Показати/приховати",
-  "video_player.video_error": "Відео не може бути відтворено"
+  "video.unmute": "Unmute sound"
 }
diff --git a/app/javascript/mastodon/locales/zh-CN.json b/app/javascript/mastodon/locales/zh-CN.json
index 58e3d6780..e0ffc16df 100644
--- a/app/javascript/mastodon/locales/zh-CN.json
+++ b/app/javascript/mastodon/locales/zh-CN.json
@@ -165,6 +165,11 @@
   "report.submit": "提交",
   "report.target": "Reporting",
   "search.placeholder": "搜索",
+  "search_popout.search_format": "Advanced search format",
+  "search_popout.tips.hashtag": "hashtag",
+  "search_popout.tips.status": "status",
+  "search_popout.tips.text": "Simple text returns matching display names, usernames and hashtags",
+  "search_popout.tips.user": "user",
   "search_results.total": "{count, number} {count, plural, one {result} other {results}}",
   "standalone.public_title": "大家都在干啥?",
   "status.cannot_reblog": "没法转嘟这条嘟文啦……",
@@ -196,6 +201,7 @@
   "tabs_bar.notifications": "通知",
   "upload_area.title": "将文件拖放至此上传",
   "upload_button.label": "上传媒体文件",
+  "upload_form.description": "Describe for the visually impaired",
   "upload_form.undo": "还原",
   "upload_progress.label": "上传中……",
   "video.close": "关闭影片",
@@ -206,9 +212,5 @@
   "video.mute": "静音",
   "video.pause": "暂停",
   "video.play": "播放",
-  "video.unmute": "解除静音",
-  "video_player.expand": "展开影片",
-  "video_player.toggle_sound": "开关音效",
-  "video_player.toggle_visible": "打开或关上",
-  "video_player.video_error": "视频无法播放啦……"
+  "video.unmute": "解除静音"
 }
diff --git a/app/javascript/mastodon/locales/zh-HK.json b/app/javascript/mastodon/locales/zh-HK.json
index 610aa6daf..053e971aa 100644
--- a/app/javascript/mastodon/locales/zh-HK.json
+++ b/app/javascript/mastodon/locales/zh-HK.json
@@ -165,6 +165,11 @@
   "report.submit": "提交",
   "report.target": "舉報",
   "search.placeholder": "搜尋",
+  "search_popout.search_format": "Advanced search format",
+  "search_popout.tips.hashtag": "hashtag",
+  "search_popout.tips.status": "status",
+  "search_popout.tips.text": "Simple text returns matching display names, usernames and hashtags",
+  "search_popout.tips.user": "user",
   "search_results.total": "{count, number} 項結果",
   "standalone.public_title": "站點一瞥…",
   "status.cannot_reblog": "這篇文章無法被轉推",
@@ -196,6 +201,7 @@
   "tabs_bar.notifications": "通知",
   "upload_area.title": "將檔案拖放至此上載",
   "upload_button.label": "上載媒體檔案",
+  "upload_form.description": "Describe for the visually impaired",
   "upload_form.undo": "還原",
   "upload_progress.label": "上載中……",
   "video.close": "關閉影片",
@@ -206,9 +212,5 @@
   "video.mute": "靜音",
   "video.pause": "暫停",
   "video.play": "播放",
-  "video.unmute": "解除靜音",
-  "video_player.expand": "展開影片",
-  "video_player.toggle_sound": "開關音效",
-  "video_player.toggle_visible": "打開或關上",
-  "video_player.video_error": "無法播放影片"
+  "video.unmute": "解除靜音"
 }
diff --git a/app/javascript/mastodon/locales/zh-TW.json b/app/javascript/mastodon/locales/zh-TW.json
index ad2f1a05a..a22d66fa1 100644
--- a/app/javascript/mastodon/locales/zh-TW.json
+++ b/app/javascript/mastodon/locales/zh-TW.json
@@ -165,6 +165,11 @@
   "report.submit": "送出",
   "report.target": "通報中",
   "search.placeholder": "搜尋",
+  "search_popout.search_format": "Advanced search format",
+  "search_popout.tips.hashtag": "hashtag",
+  "search_popout.tips.status": "status",
+  "search_popout.tips.text": "Simple text returns matching display names, usernames and hashtags",
+  "search_popout.tips.user": "user",
   "search_results.total": "{count, number} 項結果",
   "standalone.public_title": "站點一瞥…",
   "status.cannot_reblog": "此貼文無法轉推",
@@ -196,6 +201,7 @@
   "tabs_bar.notifications": "通知",
   "upload_area.title": "拖放來上傳",
   "upload_button.label": "增加媒體",
+  "upload_form.description": "Describe for the visually impaired",
   "upload_form.undo": "復原",
   "upload_progress.label": "上傳中...",
   "video.close": "關閉影片",
@@ -206,9 +212,5 @@
   "video.mute": "消音",
   "video.pause": "暫停",
   "video.play": "播放",
-  "video.unmute": "解除消音",
-  "video_player.expand": "展開影片",
-  "video_player.toggle_sound": "切換音效",
-  "video_player.toggle_visible": "切換可見性",
-  "video_player.video_error": "無法播放這影片"
+  "video.unmute": "解除消音"
 }
diff --git a/app/javascript/mastodon/performance.js b/app/javascript/mastodon/performance.js
index 396c605e4..450a90626 100644
--- a/app/javascript/mastodon/performance.js
+++ b/app/javascript/mastodon/performance.js
@@ -14,8 +14,8 @@ if (process.env.NODE_ENV === 'development') {
   }
   marky = require('marky');
   // allows us to easily do e.g. ReactPerf.printWasted() while debugging
-  window.ReactPerf = require('react-addons-perf');
-  window.ReactPerf.start();
+  //window.ReactPerf = require('react-addons-perf');
+  //window.ReactPerf.start();
 }
 
 export function start(name) {
diff --git a/app/javascript/mastodon/reducers/accounts.js b/app/javascript/mastodon/reducers/accounts.js
index 5391a93ae..8a4d69f26 100644
--- a/app/javascript/mastodon/reducers/accounts.js
+++ b/app/javascript/mastodon/reducers/accounts.js
@@ -44,7 +44,7 @@ import {
   FAVOURITED_STATUSES_EXPAND_SUCCESS,
 } from '../actions/favourites';
 import { STORE_HYDRATE } from '../actions/store';
-import emojify from '../emoji';
+import emojify from '../features/emoji/emoji';
 import { Map as ImmutableMap, fromJS } from 'immutable';
 import escapeTextContentForBrowser from 'escape-html';
 
diff --git a/app/javascript/mastodon/reducers/compose.js b/app/javascript/mastodon/reducers/compose.js
index 5756a393f..b1d590748 100644
--- a/app/javascript/mastodon/reducers/compose.js
+++ b/app/javascript/mastodon/reducers/compose.js
@@ -23,6 +23,10 @@ import {
   COMPOSE_VISIBILITY_CHANGE,
   COMPOSE_COMPOSING_CHANGE,
   COMPOSE_EMOJI_INSERT,
+  COMPOSE_UPLOAD_CHANGE_REQUEST,
+  COMPOSE_UPLOAD_CHANGE_SUCCESS,
+  COMPOSE_UPLOAD_CHANGE_FAIL,
+  COMPOSE_RESET,
 } from '../actions/compose';
 import { TIMELINE_DELETE } from '../actions/timelines';
 import { STORE_HYDRATE } from '../actions/store';
@@ -227,6 +231,7 @@ export default function compose(state = initialState, action) {
       }
     });
   case COMPOSE_REPLY_CANCEL:
+  case COMPOSE_RESET:
     return state.withMutations(map => {
       map.set('in_reply_to', null);
       map.set('text', '');
@@ -237,15 +242,15 @@ export default function compose(state = initialState, action) {
       map.set('idempotencyKey', uuid());
     });
   case COMPOSE_SUBMIT_REQUEST:
+  case COMPOSE_UPLOAD_CHANGE_REQUEST:
     return state.set('is_submitting', true);
   case COMPOSE_SUBMIT_SUCCESS:
     return clearAll(state);
   case COMPOSE_SUBMIT_FAIL:
+  case COMPOSE_UPLOAD_CHANGE_FAIL:
     return state.set('is_submitting', false);
   case COMPOSE_UPLOAD_REQUEST:
-    return state.withMutations(map => {
-      map.set('is_uploading', true);
-    });
+    return state.set('is_uploading', true);
   case COMPOSE_UPLOAD_SUCCESS:
     return appendMedia(state, fromJS(action.media));
   case COMPOSE_UPLOAD_FAIL:
@@ -273,6 +278,16 @@ export default function compose(state = initialState, action) {
     }
   case COMPOSE_EMOJI_INSERT:
     return insertEmoji(state, action.position, action.emoji);
+  case COMPOSE_UPLOAD_CHANGE_SUCCESS:
+    return state
+      .set('is_submitting', false)
+      .update('media_attachments', list => list.map(item => {
+        if (item.get('id') === action.media.id) {
+          return item.set('description', action.media.description);
+        }
+
+        return item;
+      }));
   default:
     return state;
   }
diff --git a/app/javascript/mastodon/reducers/contexts.js b/app/javascript/mastodon/reducers/contexts.js
index 9bfc09aa7..64d584a01 100644
--- a/app/javascript/mastodon/reducers/contexts.js
+++ b/app/javascript/mastodon/reducers/contexts.js
@@ -1,6 +1,6 @@
 import { CONTEXT_FETCH_SUCCESS } from '../actions/statuses';
-import { TIMELINE_DELETE } from '../actions/timelines';
-import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable';
+import { TIMELINE_DELETE, TIMELINE_CONTEXT_UPDATE } from '../actions/timelines';
+import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
 
 const initialState = ImmutableMap({
   ancestors: ImmutableMap(),
@@ -8,8 +8,8 @@ const initialState = ImmutableMap({
 });
 
 const normalizeContext = (state, id, ancestors, descendants) => {
-  const ancestorsIds   = ancestors.map(ancestor => ancestor.get('id'));
-  const descendantsIds = descendants.map(descendant => descendant.get('id'));
+  const ancestorsIds   = ImmutableList(ancestors.map(ancestor => ancestor.id));
+  const descendantsIds = ImmutableList(descendants.map(descendant => descendant.id));
 
   return state.withMutations(map => {
     map.setIn(['ancestors', id], ancestorsIds);
@@ -31,12 +31,30 @@ const deleteFromContexts = (state, id) => {
   return state;
 };
 
+const updateContext = (state, status, references) => {
+  return state.update('descendants', map => {
+    references.forEach(parentId => {
+      map = map.update(parentId, ImmutableList(), list => {
+        if (list.includes(status.id)) {
+          return list;
+        }
+
+        return list.push(status.id);
+      });
+    });
+
+    return map;
+  });
+};
+
 export default function contexts(state = initialState, action) {
   switch(action.type) {
   case CONTEXT_FETCH_SUCCESS:
-    return normalizeContext(state, action.id, fromJS(action.ancestors), fromJS(action.descendants));
+    return normalizeContext(state, action.id, action.ancestors, action.descendants);
   case TIMELINE_DELETE:
     return deleteFromContexts(state, action.id);
+  case TIMELINE_CONTEXT_UPDATE:
+    return updateContext(state, action.status, action.references);
   default:
     return state;
   }
diff --git a/app/javascript/mastodon/reducers/custom_emojis.js b/app/javascript/mastodon/reducers/custom_emojis.js
index d80c0d156..f2a8ca5d2 100644
--- a/app/javascript/mastodon/reducers/custom_emojis.js
+++ b/app/javascript/mastodon/reducers/custom_emojis.js
@@ -1,14 +1,14 @@
 import { List as ImmutableList } from 'immutable';
 import { STORE_HYDRATE } from '../actions/store';
-import { emojiIndex } from 'emoji-mart';
-import { buildCustomEmojis } from '../emoji';
+import { search as emojiSearch } from '../features/emoji/emoji_mart_search_light';
+import { buildCustomEmojis } from '../features/emoji/emoji';
 
 const initialState = ImmutableList();
 
 export default function custom_emojis(state = initialState, action) {
   switch(action.type) {
   case STORE_HYDRATE:
-    emojiIndex.search('', { custom: buildCustomEmojis(action.state.get('custom_emojis', [])) });
+    emojiSearch('', { custom: buildCustomEmojis(action.state.get('custom_emojis', []), action.state.getIn(['meta', 'auto_play_gif'], false)) });
     return action.state.get('custom_emojis');
   default:
     return state;
diff --git a/app/javascript/mastodon/reducers/notifications.js b/app/javascript/mastodon/reducers/notifications.js
index ecce8dcb6..48850ab01 100644
--- a/app/javascript/mastodon/reducers/notifications.js
+++ b/app/javascript/mastodon/reducers/notifications.js
@@ -15,7 +15,10 @@ import {
   NOTIFICATIONS_ENTER_CLEARING_MODE,
   NOTIFICATIONS_MARK_ALL_FOR_DELETE,
 } from '../actions/notifications';
-import { ACCOUNT_BLOCK_SUCCESS } from '../actions/accounts';
+import {
+  ACCOUNT_BLOCK_SUCCESS,
+  ACCOUNT_MUTE_SUCCESS,
+} from '../actions/accounts';
 import { TIMELINE_DELETE } from '../actions/timelines';
 import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
 
@@ -151,6 +154,7 @@ export default function notifications(state = initialState, action) {
   case NOTIFICATIONS_EXPAND_SUCCESS:
     return appendNormalizedNotifications(state, action.notifications, action.next);
   case ACCOUNT_BLOCK_SUCCESS:
+  case ACCOUNT_MUTE_SUCCESS:
     return filterNotifications(state, action.relationship);
   case NOTIFICATIONS_CLEAR:
     return state.set('items', ImmutableList()).set('next', null);
diff --git a/app/javascript/mastodon/reducers/settings.js b/app/javascript/mastodon/reducers/settings.js
index 1bdee7356..0c0dae388 100644
--- a/app/javascript/mastodon/reducers/settings.js
+++ b/app/javascript/mastodon/reducers/settings.js
@@ -1,13 +1,18 @@
-import { SETTING_CHANGE } from '../actions/settings';
+import { SETTING_CHANGE, SETTING_SAVE } from '../actions/settings';
 import { COLUMN_ADD, COLUMN_REMOVE, COLUMN_MOVE } from '../actions/columns';
 import { STORE_HYDRATE } from '../actions/store';
+import { EMOJI_USE } from '../actions/emojis';
 import { Map as ImmutableMap, fromJS } from 'immutable';
 import uuid from '../uuid';
 
 const initialState = ImmutableMap({
+  saved: true,
+
   onboarded: false,
   layout: 'auto',
 
+  skinTone: 1,
+
   home: ImmutableMap({
     shows: ImmutableMap({
       reblog: true,
@@ -73,21 +78,35 @@ const moveColumn = (state, uuid, direction) => {
   newColumns = columns.splice(index, 1);
   newColumns = newColumns.splice(newIndex, 0, columns.get(index));
 
-  return state.set('columns', newColumns);
+  return state
+    .set('columns', newColumns)
+    .set('saved', false);
 };
 
+const updateFrequentEmojis = (state, emoji) => state.update('frequentlyUsedEmojis', ImmutableMap(), map => map.update(emoji.id, 0, count => count + 1)).set('saved', false);
+
 export default function settings(state = initialState, action) {
   switch(action.type) {
   case STORE_HYDRATE:
     return hydrate(state, action.state.get('settings'));
   case SETTING_CHANGE:
-    return state.setIn(action.key, action.value);
+    return state
+      .setIn(action.key, action.value)
+      .set('saved', false);
   case COLUMN_ADD:
-    return state.update('columns', list => list.push(fromJS({ id: action.id, uuid: uuid(), params: action.params })));
+    return state
+      .update('columns', list => list.push(fromJS({ id: action.id, uuid: uuid(), params: action.params })))
+      .set('saved', false);
   case COLUMN_REMOVE:
-    return state.update('columns', list => list.filterNot(item => item.get('uuid') === action.uuid));
+    return state
+      .update('columns', list => list.filterNot(item => item.get('uuid') === action.uuid))
+      .set('saved', false);
   case COLUMN_MOVE:
     return moveColumn(state, action.uuid, action.direction);
+  case EMOJI_USE:
+    return updateFrequentEmojis(state, action.emoji);
+  case SETTING_SAVE:
+    return state.set('saved', true);
   default:
     return state;
   }
diff --git a/app/javascript/mastodon/reducers/statuses.js b/app/javascript/mastodon/reducers/statuses.js
index 38b23504e..b1fb4c5da 100644
--- a/app/javascript/mastodon/reducers/statuses.js
+++ b/app/javascript/mastodon/reducers/statuses.js
@@ -24,6 +24,7 @@ import {
 } from '../actions/timelines';
 import {
   ACCOUNT_BLOCK_SUCCESS,
+  ACCOUNT_MUTE_SUCCESS,
 } from '../actions/accounts';
 import {
   NOTIFICATIONS_UPDATE,
@@ -38,7 +39,7 @@ import {
   PINNED_STATUSES_FETCH_SUCCESS,
 } from '../actions/pin_statuses';
 import { SEARCH_FETCH_SUCCESS } from '../actions/search';
-import emojify from '../emoji';
+import emojify from '../features/emoji/emoji';
 import { Map as ImmutableMap, fromJS } from 'immutable';
 import escapeTextContentForBrowser from 'escape-html';
 
@@ -57,9 +58,10 @@ const normalizeStatus = (state, status) => {
     normalStatus.reblog = status.reblog.id;
   }
 
-  const searchContent = [status.spoiler_text, status.content].join(' ').replace(/<br \/>/g, '\n').replace(/<\/p><p>/g, '\n\n');
+  const searchContent = [status.spoiler_text, status.content].join('\n\n').replace(/<br \/>/g, '\n').replace(/<\/p><p>/g, '\n\n');
+
   const emojiMap = normalStatus.emojis.reduce((obj, emoji) => {
-    obj[`:${emoji.shortcode}:`] = emoji.url;
+    obj[`:${emoji.shortcode}:`] = emoji;
     return obj;
   }, {});
 
@@ -138,6 +140,7 @@ export default function statuses(state = initialState, action) {
   case TIMELINE_DELETE:
     return deleteStatus(state, action.id, action.references);
   case ACCOUNT_BLOCK_SUCCESS:
+  case ACCOUNT_MUTE_SUCCESS:
     return filterStatuses(state, action.relationship);
   default:
     return state;
diff --git a/app/javascript/packs/about.js b/app/javascript/packs/about.js
index 6705377c1..50c81198e 100644
--- a/app/javascript/packs/about.js
+++ b/app/javascript/packs/about.js
@@ -4,9 +4,9 @@ require.context('../images/', true);
 
 function loaded() {
   const TimelineContainer = require('../mastodon/containers/timeline_container').default;
-  const React = require('react');
-  const ReactDOM = require('react-dom');
-  const mountNode = document.getElementById('mastodon-timeline');
+  const React             = require('react');
+  const ReactDOM          = require('react-dom');
+  const mountNode         = document.getElementById('mastodon-timeline');
 
   if (mountNode !== null) {
     const props = JSON.parse(mountNode.getAttribute('data-props'));
diff --git a/app/javascript/packs/public.js b/app/javascript/packs/public.js
index 8842d6dcb..59d0e98dd 100644
--- a/app/javascript/packs/public.js
+++ b/app/javascript/packs/public.js
@@ -22,7 +22,7 @@ function main() {
   const { length } = require('stringz');
   const IntlRelativeFormat = require('intl-relativeformat').default;
   const { delegate } = require('rails-ujs');
-  const emojify = require('../mastodon/emoji').default;
+  const emojify = require('../mastodon/features/emoji/emoji').default;
   const { getLocale } = require('../mastodon/locales');
   const { localeData } = getLocale();
   const VideoContainer = require('../mastodon/containers/video_container').default;
diff --git a/app/javascript/styles/about.scss b/app/javascript/styles/about.scss
index 2adcb5ba2..a15afc32c 100644
--- a/app/javascript/styles/about.scss
+++ b/app/javascript/styles/about.scss
@@ -481,6 +481,7 @@
       flex: 0 0 auto;
       background: $ui-base-color;
       overflow: hidden;
+      border-radius: 4px;
       box-shadow: 0 0 6px rgba($black, 0.1);
 
       .column-header {
@@ -703,8 +704,98 @@
     .features #mastodon-timeline {
       height: 70vh;
       width: 100%;
+      min-width: 330px;
       margin-bottom: 50px;
+
+      .column {
+        width: 100%;
+      }
+    }
+  }
+
+  .cta {
+    margin: 20px;
+  }
+
+  &.tag-page {
+    .brand {
+      padding-top: 20px;
+      margin-bottom: 20px;
+
+      img {
+        height: 48px;
+        width: auto;
+      }
+    }
+
+    .container {
+      max-width: 690px;
+    }
+
+    .cta {
+      margin: 40px 0;
+      margin-bottom: 80px;
+
+      .button {
+        margin-right: 4px;
+      }
+    }
+
+    .about-mastodon {
+      max-width: 330px;
+
+      p {
+        strong {
+          color: $ui-secondary-color;
+          font-weight: 700;
+        }
+      }
     }
+
+    @media screen and (max-width: 675px) {
+      .container {
+        display: flex;
+        flex-direction: column;
+      }
+
+      .features {
+        padding: 20px 0;
+      }
+
+      .about-mastodon {
+        order: 1;
+        flex: 0 0 auto;
+        max-width: 100%;
+      }
+
+      #mastodon-timeline {
+        order: 2;
+        flex: 0 0 auto;
+        height: 60vh;
+      }
+
+      .cta {
+        margin: 20px 0;
+        margin-bottom: 30px;
+      }
+
+      .features-list {
+        display: none;
+      }
+
+      .stripe {
+        display: none;
+      }
+    }
+  }
+
+  .stripe {
+    width: 100%;
+    height: 360px;
+    overflow: hidden;
+    background: darken($ui-base-color, 4%);
+    position: absolute;
+    z-index: -1;
   }
 }
 
diff --git a/app/javascript/styles/accounts.scss b/app/javascript/styles/accounts.scss
index 9d1e322b9..e2db9992d 100644
--- a/app/javascript/styles/accounts.scss
+++ b/app/javascript/styles/accounts.scss
@@ -69,12 +69,16 @@
     position: relative;
     z-index: 2;
     margin-bottom: 30px;
+    overflow: hidden;
+    text-overflow: ellipsis;
 
     small {
       display: block;
       font-size: 14px;
       color: $ui-highlight-color;
       font-weight: 400;
+      overflow: hidden;
+      text-overflow: ellipsis;
     }
   }
 
@@ -328,21 +332,15 @@
     color: lighten($ui-base-color, 10%);
   }
 
-  @media screen and (max-width: 360px) {
+  @media screen and (max-width: 700px) {
     padding: 30px 20px;
 
-    a,
-    .current,
-    .next,
-    .prev,
-    .gap {
+    .page {
       display: none;
     }
 
     .next,
-    .prev,
-    .next a,
-    .prev a {
+    .prev {
       display: inline-block;
     }
   }
@@ -419,6 +417,7 @@
         height: 80px;
         border-radius: 80px;
         border: 2px solid $simple-background-color;
+        background: $simple-background-color;
       }
     }
 
diff --git a/app/javascript/styles/basics.scss b/app/javascript/styles/basics.scss
index 96f0023c3..b829191ad 100644
--- a/app/javascript/styles/basics.scss
+++ b/app/javascript/styles/basics.scss
@@ -29,7 +29,8 @@ body {
     font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", mastodon-font-sans-serif, sans-serif;
   }
 
-  &.app-body {
+  &.app-body,
+  &.error {
     position: fixed;
     width: 100%;
     height: 100%;
@@ -42,6 +43,11 @@ body {
     padding-bottom: 0;
   }
 
+  &.tag-body {
+    background: darken($ui-base-color, 8%);
+    padding-bottom: 0;
+  }
+
   &.embed {
     background: transparent;
     margin: 0;
@@ -67,13 +73,16 @@ body {
     text-align: center;
     color: $ui-primary-color;
     padding: 20px;
+    display: flex;
+    justify-content: center;
+    align-items: center;
 
     .dialog img {
       display: block;
-      margin: 0 auto;
       max-width: 470px;
       width: 100%;
       height: auto;
+      margin-top: -120px;
     }
 
     .dialog h1 {
@@ -94,9 +103,12 @@ button {
 }
 
 .app-holder {
-  display: flex;
-  width: 100%;
-  height: 100%;
-  align-items: center;
-  justify-content: center;
+  &,
+  & > div {
+    display: flex;
+    width: 100%;
+    height: 100%;
+    align-items: center;
+    justify-content: center;
+  }
 }
diff --git a/app/javascript/styles/components.scss b/app/javascript/styles/components.scss
index 2f02af098..8ecc0b91b 100644
--- a/app/javascript/styles/components.scss
+++ b/app/javascript/styles/components.scss
@@ -75,6 +75,7 @@
     text-transform: none;
     background: transparent;
     padding: 3px 15px;
+    border-radius: 4px;
     border: 1px solid $ui-primary-color;
 
     &:active,
@@ -344,12 +345,57 @@
 
 .compose-form__uploads-wrapper {
   display: flex;
+  flex-direction: row;
   padding: 5px;
+  flex-wrap: wrap;
 }
 
 .compose-form__upload {
   flex: 1 1 0;
+  min-width: 40%;
   margin: 5px;
+
+  &-description {
+    position: absolute;
+    z-index: 2;
+    bottom: 0;
+    left: 0;
+    right: 0;
+    box-sizing: border-box;
+    background: linear-gradient(0deg, rgba($base-shadow-color, 0.8) 0, rgba($base-shadow-color, 0.35) 80%, transparent);
+    padding: 10px;
+    opacity: 0;
+    transition: opacity .1s ease;
+
+    input {
+      background: transparent;
+      color: $ui-secondary-color;
+      border: 0;
+      padding: 0;
+      margin: 0;
+      width: 100%;
+      font-family: inherit;
+      font-size: 14px;
+      font-weight: 500;
+
+      &:focus {
+        color: $white;
+      }
+
+      &::placeholder {
+        opacity: 0.54;
+        color: $ui-secondary-color;
+      }
+    }
+
+    &.active {
+      opacity: 1;
+    }
+  }
+
+  .icon-button {
+    mix-blend-mode: difference;
+  }
 }
 
 .compose-form__upload-thumbnail {
@@ -361,13 +407,6 @@
   width: 100%;
 }
 
-.compose-form__upload-cancel {
-  background-size: cover;
-  border-radius: 4px;
-  height: 100px;
-  width: 100px;
-}
-
 .compose-form__label {
   display: block;
   line-height: 24px;
@@ -614,6 +653,22 @@
   }
 }
 
+.focusable {
+  &:focus {
+    outline: 0;
+    background: lighten($ui-base-color, 4%);
+
+    &.status-direct {
+      background: lighten($ui-base-color, 12%);
+    }
+
+    .detailed-status,
+    .detailed-status__action-bar {
+      background: lighten($ui-base-color, 8%);
+    }
+  }
+}
+
 .status {
   padding: 8px 10px;
   position: relative;
@@ -792,6 +847,12 @@
   .status__display-name strong {
     color: $ui-base-lighter-color;
   }
+
+  > span {
+    display: block;
+    overflow: hidden;
+    text-overflow: ellipsis;
+  }
 }
 
 .status__action-bar {
@@ -978,9 +1039,12 @@
   .account__header__display-name {
     color: $primary-text-color;
     display: inline-block;
+    width: 100%;
     font-size: 20px;
     line-height: 27px;
     font-weight: 500;
+    overflow: hidden;
+    text-overflow: ellipsis;
   }
 
   .account__header__username {
@@ -989,6 +1053,8 @@
     font-weight: 400;
     display: block;
     margin-bottom: 10px;
+    overflow: hidden;
+    text-overflow: ellipsis;
   }
 }
 
@@ -1199,8 +1265,16 @@
   }
 }
 
+.muted {
+  .emojione {
+    opacity: 0.5;
+  }
+}
+
 .account__display-name strong {
   display: block;
+  overflow: hidden;
+  text-overflow: ellipsis;
 }
 
 .detailed-status__application,
@@ -1275,6 +1349,12 @@
   .fa {
     color: $ui-highlight-color;
   }
+
+  > span {
+    display: block;
+    overflow: hidden;
+    text-overflow: ellipsis;
+  }
 }
 
 .notification__favourite-icon-wrapper {
@@ -1403,11 +1483,14 @@
 .navigation-bar__profile {
   flex: 1 1 auto;
   margin-left: 8px;
+  overflow: hidden;
 }
 
 .navigation-bar__profile-account {
   display: block;
   font-weight: 500;
+  overflow: hidden;
+  text-overflow: ellipsis;
 }
 
 .navigation-bar__profile-edit {
@@ -1434,7 +1517,7 @@
   background: $ui-secondary-color;
   padding: 4px 0;
   border-radius: 4px;
-  box-shadow: 0 0 15px rgba($base-shadow-color, 0.4);
+  box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4);
 
   ul {
     list-style: none;
@@ -1851,6 +1934,16 @@
   &.optionally-scrollable {
     overflow-y: auto;
   }
+
+  @supports(display: grid) { // hack to fix Chrome <57
+    contain: strict;
+  }
+}
+
+.scrollable.fullscreen {
+  @supports(display: grid) { // hack to fix Chrome <57
+    contain: none;
+  }
 }
 
 .column-back-button {
@@ -2280,22 +2373,9 @@ button.icon-button.active i.fa-retweet {
 }
 
 .status-card-video {
-  position: relative;
-  width: 100%;
-  height: auto;
-  padding-top: 56.25%;
-
   iframe {
-    position: absolute;
-    top: 0;
-    left: 0;
-    bottom: 0;
-    right: 0;
-    width: 1px;
-    min-width: 100%;
-    height: 1px;
-    min-height: 100%;
-    margin: auto;
+    width: 100%;
+    height: 100%;
   }
 }
 
@@ -2876,19 +2956,36 @@ button.icon-button.active i.fa-retweet {
   flex-direction: column;
 }
 
-@keyframes pulse {
-  0% {
-    opacity: 1;
+@keyframes heartbeat {
+  from {
+    transform: scale(1);
+    transform-origin: center center;
+    animation-timing-function: ease-out;
   }
 
-  100% {
-    opacity: 0.5;
+  10% {
+    transform: scale(0.91);
+    animation-timing-function: ease-in;
+  }
+
+  17% {
+    transform: scale(0.98);
+    animation-timing-function: ease-out;
+  }
+
+  33% {
+    transform: scale(0.87);
+    animation-timing-function: ease-in;
+  }
+
+  45% {
+    transform: scale(1);
+    animation-timing-function: ease-out;
   }
 }
 
 .pulse-loading {
-  animation: pulse 1s ease-in-out infinite;
-  animation-direction: alternate;
+  animation: heartbeat 1.5s ease-in-out infinite both;
 }
 
 .emoji-picker-dropdown__menu {
@@ -3081,19 +3178,12 @@ button.icon-button.active i.fa-retweet {
   filter: none;
 }
 
-.privacy-dropdown {
-  position: relative;
-}
-
 .privacy-dropdown__dropdown {
-  display: none;
   position: absolute;
-  left: 0;
-  top: 27px;
-  width: 230px;
   background: $simple-background-color;
-  border-radius: 0 4px 4px;
-  z-index: 2;
+  box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4);
+  border-radius: 4px;
+  margin-left: 40px;
   overflow: hidden;
 }
 
@@ -3145,6 +3235,18 @@ button.icon-button.active i.fa-retweet {
     background: $simple-background-color;
     border-radius: 4px 4px 0 0;
     box-shadow: 0 -4px 4px rgba($base-shadow-color, 0.1);
+
+    .icon-button {
+      transition: none;
+    }
+
+    &.active {
+      background: $ui-highlight-color;
+
+      .icon-button {
+        color: $primary-text-color;
+      }
+    }
   }
 
   .privacy-dropdown__dropdown {
@@ -3337,14 +3439,18 @@ button.icon-button.active i.fa-retweet {
   }
 }
 
+.modal-root {
+  transition: opacity 0.3s linear;
+  will-change: opacity;
+  z-index: 9999;
+}
+
 .modal-root__overlay {
   position: absolute;
   top: 0;
   left: 0;
   right: 0;
   bottom: 0;
-  z-index: 9999;
-  opacity: 0;
   background: rgba($base-overlay-background, 0.7);
   transform: translateZ(0);
 }
@@ -3361,7 +3467,6 @@ button.icon-button.active i.fa-retweet {
   justify-content: center;
   align-content: space-around;
   z-index: 9999;
-  opacity: 0;
   pointer-events: none;
   user-select: none;
 }
@@ -3411,6 +3516,33 @@ button.icon-button.active i.fa-retweet {
   background: $base-overlay-background;
 }
 
+.media-modal__pagination {
+  width: 100%;
+  text-align: center;
+  position: absolute;
+  left: 0;
+  bottom: -40px;
+}
+
+.media-modal__page-dot {
+  display: inline-block;
+}
+
+.media-modal__button {
+  background-color: $white;
+  height: 12px;
+  width: 12px;
+  border-radius: 6px;
+  margin: 10px;
+  padding: 0;
+  border: 0;
+  font-size: 0;
+}
+
+.media-modal__button--active {
+  background-color: $ui-highlight-color;
+}
+
 .media-modal__close {
   position: absolute;
   right: 4px;
@@ -4293,7 +4425,8 @@ button.icon-button.active i.fa-retweet {
       top: 10px;
     }
 
-    &__progress {
+    &__progress,
+    &__buffer {
       display: block;
       position: absolute;
       height: 4px;
@@ -4301,6 +4434,10 @@ button.icon-button.active i.fa-retweet {
       background: $ui-highlight-color;
     }
 
+    &__buffer {
+      background: rgba($white, 0.2);
+    }
+
     &__handle {
       position: absolute;
       z-index: 3;
@@ -4420,6 +4557,37 @@ button.icon-button.active i.fa-retweet {
   border-radius: 0;
 }
 
+.search-popout {
+  background: $simple-background-color;
+  border-radius: 4px;
+  padding: 10px 14px;
+  padding-bottom: 14px;
+  margin-top: 10px;
+  color: $ui-primary-color;
+  box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4);
+
+  h4 {
+    text-transform: uppercase;
+    color: $ui-primary-color;
+    font-size: 13px;
+    font-weight: 500;
+    margin-bottom: 10px;
+  }
+
+  li {
+    padding: 4px 0;
+  }
+
+  ul {
+    margin-bottom: 10px;
+  }
+
+  em {
+    font-weight: 500;
+    color: $ui-base-color;
+  }
+}
+
 noscript {
   text-align: center;
 
diff --git a/app/javascript/styles/forms.scss b/app/javascript/styles/forms.scss
index 0526f174c..61fcf286f 100644
--- a/app/javascript/styles/forms.scss
+++ b/app/javascript/styles/forms.scss
@@ -22,6 +22,16 @@ code {
     margin-top: 4px;
   }
 
+  h4 {
+    text-transform: uppercase;
+    font-size: 13px;
+    font-weight: 500;
+    color: $ui-primary-color;
+    padding-bottom: 8px;
+    margin-bottom: 8px;
+    border-bottom: 1px solid lighten($ui-base-color, 8%);
+  }
+
   p.hint {
     margin-bottom: 15px;
     color: $ui-primary-color;
@@ -316,6 +326,7 @@ code {
 
   select {
     font-size: 16px;
+    max-height: 29px;
   }
 
   .input-with-append {
@@ -504,6 +515,7 @@ code {
 
 .action-pagination {
   display: flex;
+  flex-wrap: wrap;
   align-items: center;
 
   .actions,
diff --git a/app/javascript/styles/rtl.scss b/app/javascript/styles/rtl.scss
index 0fdeccd9c..67bfa8a38 100644
--- a/app/javascript/styles/rtl.scss
+++ b/app/javascript/styles/rtl.scss
@@ -128,22 +128,8 @@ body.rtl {
   }
 
   .privacy-dropdown__dropdown {
-    left: auto;
-    right: 0;
-  }
-
-  .dropdown--active .dropdown__content {
-    text-align: right;
-  }
-
-  .dropdown--active .dropdown__content::before {
-    left: auto;
-    right: 8px;
-  }
-
-  .dropdown--active .dropdown__content > ul {
-    left: auto;
-    right: -10px;
+    margin-left: 0;
+    margin-right: 40px;
   }
 
   .privacy-dropdown__option__icon {