about summary refs log tree commit diff
diff options
context:
space:
mode:
authorEugen <eugen@zeonfederated.com>2017-03-15 22:55:22 +0100
committerGitHub <noreply@github.com>2017-03-15 22:55:22 +0100
commite245115f47082ffba27205f508301d14e792c369 (patch)
tree21a77b788dace45b734da6e64f1b0705016192f0
parent620f70e42c16c324459ca2da52c68f1def8683de (diff)
parentc1124228e857b0e85f5bf927d2c41c7fadfdf955 (diff)
Merge branch 'master' into mastodon-site-api
-rw-r--r--app/assets/images/mastodon-not-found.pngbin0 -> 19560 bytes
-rw-r--r--app/assets/javascripts/components/actions/accounts.jsx85
-rw-r--r--app/assets/javascripts/components/actions/compose.jsx19
-rw-r--r--app/assets/javascripts/components/actions/timelines.jsx12
-rw-r--r--app/assets/javascripts/components/components/autosuggest_textarea.jsx18
-rw-r--r--app/assets/javascripts/components/components/column_back_button.jsx3
-rw-r--r--app/assets/javascripts/components/components/dropdown_menu.jsx40
-rw-r--r--app/assets/javascripts/components/components/extended_video_player.jsx21
-rw-r--r--app/assets/javascripts/components/components/media_gallery.jsx233
-rw-r--r--app/assets/javascripts/components/components/status_action_bar.jsx16
-rw-r--r--app/assets/javascripts/components/components/status_content.jsx10
-rw-r--r--app/assets/javascripts/components/components/video_player.jsx64
-rw-r--r--app/assets/javascripts/components/containers/account_container.jsx12
-rw-r--r--app/assets/javascripts/components/containers/status_container.jsx11
-rw-r--r--app/assets/javascripts/components/features/account/components/action_bar.jsx45
-rw-r--r--app/assets/javascripts/components/features/account_timeline/components/header.jsx8
-rw-r--r--app/assets/javascripts/components/features/account_timeline/containers/header_container.jsx12
-rw-r--r--app/assets/javascripts/components/features/community_timeline/index.jsx16
-rw-r--r--app/assets/javascripts/components/features/compose/components/character_counter.jsx2
-rw-r--r--app/assets/javascripts/components/features/compose/components/compose_form.jsx32
-rw-r--r--app/assets/javascripts/components/features/compose/components/emoji_picker_dropdown.jsx52
-rw-r--r--app/assets/javascripts/components/features/compose/containers/compose_form_container.jsx10
-rw-r--r--app/assets/javascripts/components/features/getting_started/index.jsx3
-rw-r--r--app/assets/javascripts/components/features/notifications/components/clear_column_button.jsx3
-rw-r--r--app/assets/javascripts/components/features/notifications/index.jsx7
-rw-r--r--app/assets/javascripts/components/features/public_timeline/index.jsx16
-rw-r--r--app/assets/javascripts/components/features/status/components/action_bar.jsx9
-rw-r--r--app/assets/javascripts/components/features/status/components/detailed_status.jsx2
-rw-r--r--app/assets/javascripts/components/features/ui/containers/modal_container.jsx22
-rw-r--r--app/assets/javascripts/components/is_mobile.jsx6
-rw-r--r--app/assets/javascripts/components/locales/en.jsx19
-rw-r--r--app/assets/javascripts/components/middleware/sounds.jsx22
-rw-r--r--app/assets/javascripts/components/reducers/compose.jsx14
-rw-r--r--app/assets/javascripts/components/reducers/relationships.jsx4
-rw-r--r--app/assets/javascripts/components/reducers/timelines.jsx4
-rw-r--r--app/assets/javascripts/components/rtl.jsx27
-rw-r--r--app/assets/javascripts/components/store/configureStore.jsx11
-rw-r--r--app/assets/stylesheets/components.scss223
-rw-r--r--app/assets/stylesheets/stream_entries.scss31
-rw-r--r--app/controllers/api/v1/accounts_controller.rb37
-rw-r--r--app/controllers/api/v1/mutes_controller.rb21
-rw-r--r--app/controllers/api_controller.rb1
-rw-r--r--app/controllers/concerns/obfuscate_filename.rb6
-rw-r--r--app/controllers/settings/preferences_controller.rb3
-rw-r--r--app/helpers/stream_entries_helper.rb13
-rw-r--r--app/lib/feed_manager.rb17
-rw-r--r--app/lib/formatter.rb5
-rw-r--r--app/mailers/notification_mailer.rb13
-rw-r--r--app/models/account.rb23
-rw-r--r--app/models/media_attachment.rb73
-rw-r--r--app/models/mute.rb11
-rw-r--r--app/models/setting.rb1
-rw-r--r--app/models/status.rb17
-rw-r--r--app/models/tag.rb2
-rw-r--r--app/models/user.rb7
-rw-r--r--app/services/mute_service.rb23
-rw-r--r--app/services/process_feed_service.rb20
-rw-r--r--app/services/process_mentions_service.rb2
-rw-r--r--app/services/unmute_service.rb11
-rw-r--r--app/views/api/v1/accounts/relationship.rabl1
-rw-r--r--app/views/api/v1/media/create.rabl4
-rw-r--r--app/views/api/v1/mutes/index.rabl2
-rw-r--r--app/views/layouts/mailer.text.erb2
-rw-r--r--app/views/notification_mailer/_status.text.erb4
-rw-r--r--app/views/notification_mailer/digest.text.erb15
-rw-r--r--app/views/notification_mailer/favourite.text.erb4
-rw-r--r--app/views/notification_mailer/follow.text.erb4
-rw-r--r--app/views/notification_mailer/follow_request.text.erb4
-rw-r--r--app/views/notification_mailer/mention.text.erb4
-rw-r--r--app/views/notification_mailer/reblog.text.erb4
-rw-r--r--app/views/settings/preferences/show.html.haml1
-rw-r--r--app/views/stream_entries/_detailed_status.html.haml8
-rw-r--r--app/views/stream_entries/_media.html.haml4
-rw-r--r--app/views/stream_entries/_simple_status.html.haml17
-rw-r--r--app/workers/digest_mailer_worker.rb14
-rw-r--r--config/application.rb13
-rw-r--r--config/environments/production.rb7
-rw-r--r--config/initializers/paperclip.rb5
-rw-r--r--config/initializers/rabl_init.rb2
-rw-r--r--config/initializers/rack-attack.rb4
-rw-r--r--config/locales/en.yml11
-rw-r--r--config/locales/simple_form.en.yml1
-rw-r--r--config/routes.rb7
-rw-r--r--config/settings.yml1
-rw-r--r--db/migrate/20170301222600_create_mutes.rb12
-rw-r--r--db/migrate/20170303212857_add_last_emailed_at_to_users.rb5
-rw-r--r--db/migrate/20170304202101_add_type_to_media_attachments.rb12
-rw-r--r--db/schema.rb12
-rw-r--r--docs/Contributing-to-Mastodon/Sponsors.md27
-rw-r--r--docs/Using-Mastodon/Apps.md12
-rw-r--r--docs/Using-Mastodon/List-of-Mastodon-instances.md3
-rw-r--r--docs/Using-the-API/API.md30
-rw-r--r--docs/Using-the-API/Push-notifications.md2
-rw-r--r--lib/paperclip/gif_transcoder.rb21
-rw-r--r--lib/paperclip/video_transcoder.rb14
-rw-r--r--lib/tasks/mastodon.rake11
-rw-r--r--package.json3
-rw-r--r--spec/controllers/api/v1/accounts_controller_spec.rb38
-rw-r--r--spec/controllers/api/v1/mutes_controller_spec.rb19
-rw-r--r--spec/fabricators/mute_fabricator.rb3
-rw-r--r--spec/mailers/previews/notification_mailer_preview.rb17
-rw-r--r--spec/models/account_spec.rb9
-rw-r--r--spec/models/mute_spec.rb5
-rw-r--r--spec/models/tag_spec.rb10
-rw-r--r--spec/services/mute_service_spec.rb5
-rw-r--r--spec/services/unmute_service_spec.rb5
-rw-r--r--storybook/stories/autosuggest_textarea.story.jsx4
-rw-r--r--streaming/index.js2
-rw-r--r--yarn.lock56
109 files changed, 1572 insertions, 358 deletions
diff --git a/app/assets/images/mastodon-not-found.png b/app/assets/images/mastodon-not-found.png
new file mode 100644
index 000000000..76108d41f
--- /dev/null
+++ b/app/assets/images/mastodon-not-found.png
Binary files differdiff --git a/app/assets/javascripts/components/actions/accounts.jsx b/app/assets/javascripts/components/actions/accounts.jsx
index 47c0d9f85..05fa8e68d 100644
--- a/app/assets/javascripts/components/actions/accounts.jsx
+++ b/app/assets/javascripts/components/actions/accounts.jsx
@@ -21,6 +21,14 @@ export const ACCOUNT_UNBLOCK_REQUEST = 'ACCOUNT_UNBLOCK_REQUEST';
 export const ACCOUNT_UNBLOCK_SUCCESS = 'ACCOUNT_UNBLOCK_SUCCESS';
 export const ACCOUNT_UNBLOCK_FAIL    = 'ACCOUNT_UNBLOCK_FAIL';
 
+export const ACCOUNT_MUTE_REQUEST = 'ACCOUNT_MUTE_REQUEST';
+export const ACCOUNT_MUTE_SUCCESS = 'ACCOUNT_MUTE_SUCCESS';
+export const ACCOUNT_MUTE_FAIL    = 'ACCOUNT_MUTE_FAIL';
+
+export const ACCOUNT_UNMUTE_REQUEST = 'ACCOUNT_UNMUTE_REQUEST';
+export const ACCOUNT_UNMUTE_SUCCESS = 'ACCOUNT_UNMUTE_SUCCESS';
+export const ACCOUNT_UNMUTE_FAIL    = 'ACCOUNT_UNMUTE_FAIL';
+
 export const ACCOUNT_TIMELINE_FETCH_REQUEST = 'ACCOUNT_TIMELINE_FETCH_REQUEST';
 export const ACCOUNT_TIMELINE_FETCH_SUCCESS = 'ACCOUNT_TIMELINE_FETCH_SUCCESS';
 export const ACCOUNT_TIMELINE_FETCH_FAIL    = 'ACCOUNT_TIMELINE_FETCH_FAIL';
@@ -67,11 +75,16 @@ export const FOLLOW_REQUEST_REJECT_FAIL    = 'FOLLOW_REQUEST_REJECT_FAIL';
 
 export function fetchAccount(id) {
   return (dispatch, getState) => {
+    dispatch(fetchRelationships([id]));
+
+    if (getState().getIn(['accounts', id], null) !== null) {
+      return;
+    }
+
     dispatch(fetchAccountRequest(id));
 
     api(getState).get(`/api/v1/accounts/${id}`).then(response => {
       dispatch(fetchAccountSuccess(response.data));
-      dispatch(fetchRelationships([id]));
     }).catch(error => {
       dispatch(fetchAccountFail(id, error));
     });
@@ -328,6 +341,76 @@ export function unblockAccountFail(error) {
   };
 };
 
+
+export function muteAccount(id) {
+  return (dispatch, getState) => {
+    dispatch(muteAccountRequest(id));
+
+    api(getState).post(`/api/v1/accounts/${id}/mute`).then(response => {
+      // Pass in entire statuses map so we can use it to filter stuff in different parts of the reducers
+      dispatch(muteAccountSuccess(response.data, getState().get('statuses')));
+    }).catch(error => {
+      dispatch(muteAccountFail(id, error));
+    });
+  };
+};
+
+export function unmuteAccount(id) {
+  return (dispatch, getState) => {
+    dispatch(unmuteAccountRequest(id));
+
+    api(getState).post(`/api/v1/accounts/${id}/unmute`).then(response => {
+      dispatch(unmuteAccountSuccess(response.data));
+    }).catch(error => {
+      dispatch(unmuteAccountFail(id, error));
+    });
+  };
+};
+
+export function muteAccountRequest(id) {
+  return {
+    type: ACCOUNT_MUTE_REQUEST,
+    id
+  };
+};
+
+export function muteAccountSuccess(relationship, statuses) {
+  return {
+    type: ACCOUNT_MUTE_SUCCESS,
+    relationship,
+    statuses
+  };
+};
+
+export function muteAccountFail(error) {
+  return {
+    type: ACCOUNT_MUTE_FAIL,
+    error
+  };
+};
+
+export function unmuteAccountRequest(id) {
+  return {
+    type: ACCOUNT_UNMUTE_REQUEST,
+    id
+  };
+};
+
+export function unmuteAccountSuccess(relationship) {
+  return {
+    type: ACCOUNT_UNMUTE_SUCCESS,
+    relationship
+  };
+};
+
+export function unmuteAccountFail(error) {
+  return {
+    type: ACCOUNT_UNMUTE_FAIL,
+    error
+  };
+};
+
+
 export function fetchFollowers(id) {
   return (dispatch, getState) => {
     dispatch(fetchFollowersRequest(id));
diff --git a/app/assets/javascripts/components/actions/compose.jsx b/app/assets/javascripts/components/actions/compose.jsx
index 8d030fd30..165e811e3 100644
--- a/app/assets/javascripts/components/actions/compose.jsx
+++ b/app/assets/javascripts/components/actions/compose.jsx
@@ -28,6 +28,8 @@ export const COMPOSE_SPOILER_TEXT_CHANGE = 'COMPOSE_SPOILER_TEXT_CHANGE';
 export const COMPOSE_VISIBILITY_CHANGE  = 'COMPOSE_VISIBILITY_CHANGE';
 export const COMPOSE_LISTABILITY_CHANGE = 'COMPOSE_LISTABILITY_CHANGE';
 
+export const COMPOSE_EMOJI_INSERT = 'COMPOSE_EMOJI_INSERT';
+
 export function changeCompose(text) {
   return {
     type: COMPOSE_CHANGE,
@@ -85,8 +87,13 @@ export function submitCompose() {
       dispatch(updateTimeline('home', { ...response.data }));
 
       if (response.data.in_reply_to_id === null && response.data.visibility === 'public') {
-        dispatch(updateTimeline('community', { ...response.data }));
-        dispatch(updateTimeline('public', { ...response.data }));
+        if (getState().getIn(['timelines', 'community', 'loaded'])) {
+          dispatch(updateTimeline('community', { ...response.data }));
+        }
+
+        if (getState().getIn(['timelines', 'public', 'loaded'])) {
+          dispatch(updateTimeline('public', { ...response.data }));
+        }
       }
     }).catch(function (error) {
       dispatch(submitComposeFail(error));
@@ -255,3 +262,11 @@ export function changeComposeListability(checked) {
     checked
   };
 };
+
+export function insertEmojiCompose(position, emoji) {
+  return {
+    type: COMPOSE_EMOJI_INSERT,
+    position,
+    emoji
+  };
+};
diff --git a/app/assets/javascripts/components/actions/timelines.jsx b/app/assets/javascripts/components/actions/timelines.jsx
index 311b08033..3e2d4ff43 100644
--- a/app/assets/javascripts/components/actions/timelines.jsx
+++ b/app/assets/javascripts/components/actions/timelines.jsx
@@ -106,18 +106,20 @@ export function expandTimeline(timeline) {
       return;
     }
 
-    const next   = getState().getIn(['timelines', timeline, 'next']);
-    const params = getState().getIn(['timelines', timeline, 'params'], {});
-
-    if (next === null) {
+    if (getState().getIn(['timelines', timeline, 'items']).size === 0) {
       return;
     }
 
+    const path   = getState().getIn(['timelines', timeline, 'path'])(getState().getIn(['timelines', timeline, 'id']));
+    const params = getState().getIn(['timelines', timeline, 'params'], {});
+    const lastId = getState().getIn(['timelines', timeline, 'items']).last();
+
     dispatch(expandTimelineRequest(timeline));
 
-    api(getState).get(next, {
+    api(getState).get(path, {
       params: {
         ...params,
+        max_id: lastId,
         limit: 10
       }
     }).then(response => {
diff --git a/app/assets/javascripts/components/components/autosuggest_textarea.jsx b/app/assets/javascripts/components/components/autosuggest_textarea.jsx
index 4e4c2090c..38deeae0e 100644
--- a/app/assets/javascripts/components/components/autosuggest_textarea.jsx
+++ b/app/assets/javascripts/components/components/autosuggest_textarea.jsx
@@ -1,5 +1,6 @@
 import AutosuggestAccountContainer from '../features/compose/containers/autosuggest_account_container';
 import ImmutablePropTypes from 'react-immutable-proptypes';
+import { isRtl } from '../rtl';
 
 const textAtCursorMatchesToken = (str, caretPosition) => {
   let word;
@@ -39,7 +40,8 @@ const AutosuggestTextarea = React.createClass({
     onSuggestionsFetchRequested: React.PropTypes.func.isRequired,
     onChange: React.PropTypes.func.isRequired,
     onKeyUp: React.PropTypes.func,
-    onKeyDown: React.PropTypes.func
+    onKeyDown: React.PropTypes.func,
+    onPaste: React.PropTypes.func.isRequired,
   },
 
   getInitialState () {
@@ -172,10 +174,22 @@ const AutosuggestTextarea = React.createClass({
     })
   },
 
+  onPaste (e) {
+    if (e.clipboardData && e.clipboardData.files.length === 1) {
+      this.props.onPaste(e.clipboardData.files)
+      e.preventDefault();
+    }
+  },
+
   render () {
     const { value, suggestions, fileDropDate, disabled, placeholder, onKeyUp } = this.props;
     const { isFileDragging, suggestionsHidden, selectedSuggestion } = this.state;
     const className = isFileDragging ? 'autosuggest-textarea__textarea file-drop' : 'autosuggest-textarea__textarea';
+    const style     = { direction: 'ltr' };
+
+    if (isRtl(value)) {
+      style.direction = 'rtl';
+    }
 
     return (
       <div className='autosuggest-textarea'>
@@ -192,6 +206,8 @@ const AutosuggestTextarea = React.createClass({
           onBlur={this.onBlur}
           onDragEnter={this.onDragEnter}
           onDragExit={this.onDragExit}
+          onPaste={this.onPaste}
+          style={style}
         />
 
         <div style={{ display: (suggestions.size > 0 && !suggestionsHidden) ? 'block' : 'none' }} className='autosuggest-textarea__suggestions'>
diff --git a/app/assets/javascripts/components/components/column_back_button.jsx b/app/assets/javascripts/components/components/column_back_button.jsx
index 6abf11239..6b5ffee53 100644
--- a/app/assets/javascripts/components/components/column_back_button.jsx
+++ b/app/assets/javascripts/components/components/column_back_button.jsx
@@ -15,7 +15,8 @@ const ColumnBackButton = React.createClass({
   mixins: [PureRenderMixin],
 
   handleClick () {
-    this.context.router.goBack();
+    if (window.history && window.history.length == 1) this.context.router.push("/");
+    else this.context.router.goBack();
   },
 
   render () {
diff --git a/app/assets/javascripts/components/components/dropdown_menu.jsx b/app/assets/javascripts/components/components/dropdown_menu.jsx
index 0a8492b56..2b42eaa60 100644
--- a/app/assets/javascripts/components/components/dropdown_menu.jsx
+++ b/app/assets/javascripts/components/components/dropdown_menu.jsx
@@ -10,12 +10,44 @@ const DropdownMenu = React.createClass({
     direction: React.PropTypes.string
   },
 
+  getDefaultProps () {
+    return {
+      direction: 'left'
+    };
+  },
+
   mixins: [PureRenderMixin],
 
   setRef (c) {
     this.dropdown = c;
   },
 
+  handleClick (i, e) {
+    const { action } = this.props.items[i];
+
+    if (typeof action === 'function') {
+      e.preventDefault();
+      action();
+      this.dropdown.hide();
+    }
+  },
+
+  renderItem (item, i) {
+    if (item === null) {
+      return <li key={i} className='dropdown__sep' />;
+    }
+
+    const { text, action, href = '#' } = item;
+
+    return (
+      <li key={i}>
+        <a href={href} target='_blank' rel='noopener' onClick={this.handleClick.bind(this, i)}>
+          {text}
+        </a>
+      </li>
+    );
+  },
+
   render () {
     const { icon, items, size, direction } = this.props;
     const directionClass = (direction === "left") ? "dropdown__left" : "dropdown__right";
@@ -28,13 +60,7 @@ const DropdownMenu = React.createClass({
 
         <DropdownContent className={directionClass} style={{ lineHeight: '18px', textAlign: 'left' }}>
           <ul>
-            {items.map(({ text, action, href = '#' }, i) => <li key={i}><a href={href} target='_blank' rel='noopener' onClick={e => {
-              if (typeof action === 'function') {
-                e.preventDefault();
-                action();
-                this.dropdown.hide();
-              }
-            }}>{text}</a></li>)}
+            {items.map(this.renderItem)}
           </ul>
         </DropdownContent>
       </Dropdown>
diff --git a/app/assets/javascripts/components/components/extended_video_player.jsx b/app/assets/javascripts/components/components/extended_video_player.jsx
new file mode 100644
index 000000000..66e5dee16
--- /dev/null
+++ b/app/assets/javascripts/components/components/extended_video_player.jsx
@@ -0,0 +1,21 @@
+import PureRenderMixin from 'react-addons-pure-render-mixin';
+
+const ExtendedVideoPlayer = React.createClass({
+
+  propTypes: {
+    src: React.PropTypes.string.isRequired
+  },
+
+  mixins: [PureRenderMixin],
+
+  render () {
+    return (
+      <div>
+        <video src={this.props.src} autoPlay muted loop />
+      </div>
+    );
+  },
+
+});
+
+export default ExtendedVideoPlayer;
diff --git a/app/assets/javascripts/components/components/media_gallery.jsx b/app/assets/javascripts/components/components/media_gallery.jsx
index b0e397e80..72b5e977f 100644
--- a/app/assets/javascripts/components/components/media_gallery.jsx
+++ b/app/assets/javascripts/components/components/media_gallery.jsx
@@ -2,6 +2,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
 import PureRenderMixin from 'react-addons-pure-render-mixin';
 import IconButton from './icon_button';
 import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import { isIOS } from '../is_mobile';
 
 const messages = defineMessages({
   toggle_visible: { id: 'media_gallery.toggle_visible', defaultMessage: 'Toggle visibility' }
@@ -43,6 +44,141 @@ const spoilerButtonStyle = {
   zIndex: '100'
 };
 
+const itemStyle = {
+  boxSizing: 'border-box',
+  position: 'relative',
+  float: 'left',
+  border: 'none',
+  display: 'block'
+};
+
+const thumbStyle = {
+  display: 'block',
+  width: '100%',
+  height: '100%',
+  textDecoration: 'none',
+  backgroundSize: 'cover',
+  cursor: 'zoom-in'
+};
+
+const gifvThumbStyle = {
+  position: 'relative',
+  zIndex: '1',
+  width: '100%',
+  height: '100%',
+  objectFit: 'cover',
+  top: '50%',
+  transform: 'translateY(-50%)',
+  cursor: 'zoom-in'
+};
+
+const Item = React.createClass({
+
+  propTypes: {
+    attachment: ImmutablePropTypes.map.isRequired,
+    index: React.PropTypes.number.isRequired,
+    size: React.PropTypes.number.isRequired,
+    onClick: React.PropTypes.func.isRequired
+  },
+
+  mixins: [PureRenderMixin],
+
+  handleClick (e) {
+    const { index, onClick } = this.props;
+
+    if (e.button === 0) {
+      e.preventDefault();
+      onClick(index);
+    }
+
+    e.stopPropagation();
+  },
+
+  render () {
+    const { attachment, index, size } = this.props;
+
+    let width  = 50;
+    let height = 100;
+    let top    = 'auto';
+    let left   = 'auto';
+    let bottom = 'auto';
+    let right  = 'auto';
+
+    if (size === 1) {
+      width = 100;
+    }
+
+    if (size === 4 || (size === 3 && index > 0)) {
+      height = 50;
+    }
+
+    if (size === 2) {
+      if (index === 0) {
+        right = '2px';
+      } else {
+        left = '2px';
+      }
+    } else if (size === 3) {
+      if (index === 0) {
+        right = '2px';
+      } else if (index > 0) {
+        left = '2px';
+      }
+
+      if (index === 1) {
+        bottom = '2px';
+      } else if (index > 1) {
+        top = '2px';
+      }
+    } else if (size === 4) {
+      if (index === 0 || index === 2) {
+        right = '2px';
+      }
+
+      if (index === 1 || index === 3) {
+        left = '2px';
+      }
+
+      if (index < 2) {
+        bottom = '2px';
+      } else {
+        top = '2px';
+      }
+    }
+
+    let thumbnail = '';
+
+    if (attachment.get('type') === 'image') {
+      thumbnail = (
+        <a
+          href={attachment.get('remote_url') ? attachment.get('remote_url') : attachment.get('url')}
+          onClick={this.handleClick}
+          target='_blank'
+          style={{ background: `url(${attachment.get('preview_url')}) no-repeat center`, ...thumbStyle }}
+        />
+      );
+    } else if (attachment.get('type') === 'gifv') {
+      thumbnail = (
+        <video
+          src={attachment.get('url')}
+          onClick={this.handleClick}
+          autoPlay={!isIOS()}
+          loop={true}
+          muted={true}
+          style={gifvThumbStyle}
+        />
+      );
+    }
+
+    return (
+      <div key={attachment.get('id')} style={{ ...itemStyle, left: left, top: top, right: right, bottom: bottom, width: `${width}%`, height: `${height}%` }}>
+        {thumbnail}
+      </div>
+    );
+  }
+
+});
+
 const MediaGallery = React.createClass({
 
   getInitialState () {
@@ -61,17 +197,12 @@ const MediaGallery = React.createClass({
 
   mixins: [PureRenderMixin],
 
-  handleClick (index, e) {
-    if (e.button === 0) {
-      e.preventDefault();
-      this.props.onOpenMedia(this.props.media, index);
-    }
-
-    e.stopPropagation();
+  handleOpen (e) {
+    this.setState({ visible: !this.state.visible });
   },
 
-  handleOpen () {
-    this.setState({ visible: !this.state.visible });
+  handleClick (index) {
+    this.props.onOpenMedia(this.props.media, index);
   },
 
   render () {
@@ -80,87 +211,31 @@ const MediaGallery = React.createClass({
     let children;
 
     if (!this.state.visible) {
+      let warning;
+
       if (sensitive) {
-        children = (
-          <div style={spoilerStyle} className='media-spoiler' onClick={this.handleOpen}>
-            <span style={spoilerSpanStyle}><FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' /></span>
-            <span style={spoilerSubSpanStyle}><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span>
-          </div>
-        );
+        warning = <FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' />;
       } else {
-        children = (
-          <div style={spoilerStyle} className='media-spoiler' onClick={this.handleOpen}>
-            <span style={spoilerSpanStyle}><FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' /></span>
-            <span style={spoilerSubSpanStyle}><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span>
-          </div>
-        );
+        warning = <FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' />;
       }
+
+      children = (
+        <div style={spoilerStyle} className='media-spoiler' onClick={this.handleOpen}>
+          <span style={spoilerSpanStyle}>{warning}</span>
+          <span style={spoilerSubSpanStyle}><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span>
+        </div>
+      );
     } else {
       const size = media.take(4).size;
-
-      children = media.take(4).map((attachment, i) => {
-        let width  = 50;
-        let height = 100;
-        let top    = 'auto';
-        let left   = 'auto';
-        let bottom = 'auto';
-        let right  = 'auto';
-
-        if (size === 1) {
-          width = 100;
-        }
-
-        if (size === 4 || (size === 3 && i > 0)) {
-          height = 50;
-        }
-
-        if (size === 2) {
-          if (i === 0) {
-            right = '2px';
-          } else {
-            left = '2px';
-          }
-        } else if (size === 3) {
-          if (i === 0) {
-            right = '2px';
-          } else if (i > 0) {
-            left = '2px';
-          }
-
-          if (i === 1) {
-            bottom = '2px';
-          } else if (i > 1) {
-            top = '2px';
-          }
-        } else if (size === 4) {
-          if (i === 0 || i === 2) {
-            right = '2px';
-          }
-
-          if (i === 1 || i === 3) {
-            left = '2px';
-          }
-
-          if (i < 2) {
-            bottom = '2px';
-          } else {
-            top = '2px';
-          }
-        }
-
-        return (
-          <div key={attachment.get('id')} style={{ boxSizing: 'border-box', position: 'relative', left: left, top: top, right: right, bottom: bottom, float: 'left', border: 'none', display: 'block', width: `${width}%`, height: `${height}%` }}>
-            <a href={attachment.get('remote_url') ? attachment.get('remote_url') : attachment.get('url')} onClick={this.handleClick.bind(this, i)} target='_blank' style={{ display: 'block', width: '100%', height: '100%', background: `url(${attachment.get('preview_url')}) no-repeat center`, textDecoration: 'none', backgroundSize: 'cover', cursor: 'zoom-in' }} />
-          </div>
-        );
-      });
+      children = media.take(4).map((attachment, i) => <Item key={attachment.get('id')} onClick={this.handleClick} attachment={attachment} index={i} size={size} />);
     }
 
     return (
       <div style={{ ...outerStyle, height: `${this.props.height}px` }}>
-        <div style={spoilerButtonStyle} >
+        <div style={spoilerButtonStyle}>
           <IconButton title={intl.formatMessage(messages.toggle_visible)} icon={this.state.visible ? 'eye' : 'eye-slash'} onClick={this.handleOpen} />
         </div>
+
         {children}
       </div>
     );
diff --git a/app/assets/javascripts/components/components/status_action_bar.jsx b/app/assets/javascripts/components/components/status_action_bar.jsx
index 35c458b5e..469506f2f 100644
--- a/app/assets/javascripts/components/components/status_action_bar.jsx
+++ b/app/assets/javascripts/components/components/status_action_bar.jsx
@@ -6,13 +6,13 @@ import { defineMessages, injectIntl } from 'react-intl';
 
 const messages = defineMessages({
   delete: { id: 'status.delete', defaultMessage: 'Delete' },
-  mention: { id: 'status.mention', defaultMessage: 'Mention' },
-  block: { id: 'account.block', defaultMessage: 'Block' },
+  mention: { id: 'status.mention', defaultMessage: 'Mention @{name}' },
+  block: { id: 'account.block', defaultMessage: 'Block @{name}' },
   reply: { id: 'status.reply', defaultMessage: 'Reply' },
   reblog: { id: 'status.reblog', defaultMessage: 'Reblog' },
   favourite: { id: 'status.favourite', defaultMessage: 'Favourite' },
-  open: { id: 'status.open', defaultMessage: 'Expand' },
-  report: { id: 'status.report', defaultMessage: 'Report' }
+  open: { id: 'status.open', defaultMessage: 'Expand this status' },
+  report: { id: 'status.report', defaultMessage: 'Report @{name}' }
 });
 
 const StatusActionBar = React.createClass({
@@ -74,13 +74,15 @@ const StatusActionBar = React.createClass({
     let menu = [];
 
     menu.push({ text: intl.formatMessage(messages.open), action: this.handleOpen });
+    menu.push(null);
 
     if (status.getIn(['account', 'id']) === me) {
       menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick });
     } else {
-      menu.push({ text: intl.formatMessage(messages.mention), action: this.handleMentionClick });
-      menu.push({ text: intl.formatMessage(messages.block), action: this.handleBlockClick });
-      menu.push({ text: intl.formatMessage(messages.report), action: this.handleReport });
+      menu.push({ text: intl.formatMessage(messages.mention, { name: status.getIn(['account', 'username']) }), action: this.handleMentionClick });
+      menu.push(null);
+      menu.push({ text: intl.formatMessage(messages.block, { name: status.getIn(['account', 'username']) }), action: this.handleBlockClick });
+      menu.push({ text: intl.formatMessage(messages.report, { name: status.getIn(['account', 'username']) }), action: this.handleReport });
     }
 
     return (
diff --git a/app/assets/javascripts/components/components/status_content.jsx b/app/assets/javascripts/components/components/status_content.jsx
index 43bbb9582..6c25afdea 100644
--- a/app/assets/javascripts/components/components/status_content.jsx
+++ b/app/assets/javascripts/components/components/status_content.jsx
@@ -2,6 +2,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
 import PureRenderMixin from 'react-addons-pure-render-mixin';
 import escapeTextContentForBrowser from 'escape-html';
 import emojify from '../emoji';
+import { isRtl } from '../rtl';
 import { FormattedMessage } from 'react-intl';
 import Permalink from './permalink';
 
@@ -92,6 +93,11 @@ const StatusContent = React.createClass({
 
     const content = { __html: emojify(status.get('content')) };
     const spoilerContent = { __html: emojify(escapeTextContentForBrowser(status.get('spoiler_text', ''))) };
+    const directionStyle = { direction: 'ltr' };
+
+    if (isRtl(status.get('content'))) {
+      directionStyle.direction = 'rtl';
+    }
 
     if (status.get('spoiler_text').length > 0) {
       let mentionsPlaceholder = '';
@@ -116,14 +122,14 @@ const StatusContent = React.createClass({
 
           {mentionsPlaceholder}
 
-          <div style={{ display: hidden ? 'none' : 'block' }} dangerouslySetInnerHTML={content} />
+          <div style={{ display: hidden ? 'none' : 'block', ...directionStyle }} dangerouslySetInnerHTML={content} />
         </div>
       );
     } else {
       return (
         <div
           className='status__content'
-          style={{ cursor: 'pointer' }}
+          style={{ cursor: 'pointer', ...directionStyle }}
           onMouseDown={this.handleMouseDown}
           onMouseUp={this.handleMouseUp}
           dangerouslySetInnerHTML={content}
diff --git a/app/assets/javascripts/components/components/video_player.jsx b/app/assets/javascripts/components/components/video_player.jsx
index ccd67ddf0..92597a2ec 100644
--- a/app/assets/javascripts/components/components/video_player.jsx
+++ b/app/assets/javascripts/components/components/video_player.jsx
@@ -2,6 +2,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
 import PureRenderMixin from 'react-addons-pure-render-mixin';
 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' },
@@ -61,12 +62,14 @@ const VideoPlayer = React.createClass({
     media: ImmutablePropTypes.map.isRequired,
     width: React.PropTypes.number,
     height: React.PropTypes.number,
-    sensitive: React.PropTypes.bool
+    sensitive: React.PropTypes.bool,
+    intl: React.PropTypes.object.isRequired,
+    autoplay: React.PropTypes.bool
   },
 
   getDefaultProps () {
     return {
-      width: 196,
+      width: 239,
       height: 110
     };
   },
@@ -75,7 +78,8 @@ const VideoPlayer = React.createClass({
     return {
       visible: !this.props.sensitive,
       preview: true,
-      muted: true
+      muted: true,
+      hasAudio: true
     };
   },
 
@@ -108,8 +112,42 @@ const VideoPlayer = React.createClass({
     });
   },
 
+  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 });
+    }
+  },
+
+  componentDidMount () {
+    if (!this.video) {
+      return;
+    }
+
+    this.video.addEventListener('loadeddata', this.handleLoadedData);
+  },
+
+  componentDidUpdate () {
+    if (!this.video) {
+      return;
+    }
+
+    this.video.addEventListener('loadeddata', this.handleLoadedData);
+  },
+
+  componentWillUnmount () {
+    if (!this.video) {
+      return;
+    }
+
+    this.video.removeEventListener('loadeddata', this.handleLoadedData);
+  },
+
   render () {
-    const { media, intl, width, height, sensitive } = this.props;
+    const { media, intl, width, height, sensitive, autoplay } = this.props;
 
     let spoilerButton = (
       <div style={spoilerButtonStyle} >
@@ -117,6 +155,16 @@ const VideoPlayer = React.createClass({
       </div>
     );
 
+    let muteButton = '';
+
+    if (this.state.hasAudio) {
+      muteButton = (
+        <div style={muteStyle}>
+          <IconButton 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 (
@@ -128,7 +176,7 @@ const VideoPlayer = React.createClass({
         );
       } else {
         return (
-          <div style={{...spoilerStyle, width: `${width}px`, height: `${height}px` }} className='media-spoiler' onClick={this.handleOpen}>
+          <div style={{...spoilerStyle, width: `${width}px`, height: `${height}px` }} className='media-spoiler' onClick={this.handleVisibility}>
             {spoilerButton}
             <span style={spoilerSpanStyle}><FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' /></span>
             <span style={spoilerSubSpanStyle}><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span>
@@ -137,7 +185,7 @@ const VideoPlayer = React.createClass({
       }
     }
 
-    if (this.state.preview) {
+    if (this.state.preview && !autoplay) {
       return (
         <div style={{ cursor: 'pointer', position: 'relative', marginTop: '8px', width: `${width}px`, height: `${height}px`, background: `url(${media.get('preview_url')}) no-repeat center`, backgroundSize: 'cover' }} onClick={this.handleOpen}>
           {spoilerButton}
@@ -149,8 +197,8 @@ const VideoPlayer = React.createClass({
     return (
       <div style={{ cursor: 'default', marginTop: '8px', overflow: 'hidden', width: `${width}px`, height: `${height}px`, boxSizing: 'border-box', background: '#000', position: 'relative' }}>
         {spoilerButton}
-        <div style={muteStyle}><IconButton title={intl.formatMessage(messages.toggle_sound)} icon={this.state.muted ? 'volume-off' : 'volume-up'} onClick={this.handleClick} /></div>
-        <video src={media.get('url')} autoPlay='true' loop={true} muted={this.state.muted} style={videoStyle} onClick={this.handleVideoClick} />
+        {muteButton}
+        <video ref={this.setRef} src={media.get('url')} autoPlay={!isIOS()} loop={true} muted={this.state.muted} style={videoStyle} onClick={this.handleVideoClick} />
       </div>
     );
   }
diff --git a/app/assets/javascripts/components/containers/account_container.jsx b/app/assets/javascripts/components/containers/account_container.jsx
index 889c0ac4c..3c30be715 100644
--- a/app/assets/javascripts/components/containers/account_container.jsx
+++ b/app/assets/javascripts/components/containers/account_container.jsx
@@ -5,7 +5,9 @@ import {
   followAccount,
   unfollowAccount,
   blockAccount,
-  unblockAccount
+  unblockAccount,
+  muteAccount,
+  unmuteAccount,
 } from '../actions/accounts';
 
 const makeMapStateToProps = () => {
@@ -34,6 +36,14 @@ const mapDispatchToProps = (dispatch) => ({
     } else {
       dispatch(blockAccount(account.get('id')));
     }
+  },
+
+  onMute (account) {
+    if (account.getIn(['relationship', 'muting'])) {
+      dispatch(unmuteAccount(account.get('id')));
+    } else {
+      dispatch(muteAccount(account.get('id')));
+    }
   }
 });
 
diff --git a/app/assets/javascripts/components/containers/status_container.jsx b/app/assets/javascripts/components/containers/status_container.jsx
index 81265bc50..e7543bc39 100644
--- a/app/assets/javascripts/components/containers/status_container.jsx
+++ b/app/assets/javascripts/components/containers/status_container.jsx
@@ -11,7 +11,10 @@ import {
   unreblog,
   unfavourite
 } from '../actions/interactions';
-import { blockAccount } from '../actions/accounts';
+import {
+  blockAccount,
+  muteAccount
+} from '../actions/accounts';
 import { deleteStatus } from '../actions/statuses';
 import { initReport } from '../actions/reports';
 import { openMedia } from '../actions/modal';
@@ -69,7 +72,11 @@ const mapDispatchToProps = (dispatch) => ({
 
   onReport (status) {
     dispatch(initReport(status.get('account'), status));
-  }
+  },
+
+  onMute (account) {
+    dispatch(muteAccount(account.get('id')));
+  },
 
 });
 
diff --git a/app/assets/javascripts/components/features/account/components/action_bar.jsx b/app/assets/javascripts/components/features/account/components/action_bar.jsx
index a2ab8172b..80a32d3e2 100644
--- a/app/assets/javascripts/components/features/account/components/action_bar.jsx
+++ b/app/assets/javascripts/components/features/account/components/action_bar.jsx
@@ -5,14 +5,16 @@ import { Link } from 'react-router';
 import { defineMessages, injectIntl, FormattedMessage, FormattedNumber } from 'react-intl';
 
 const messages = defineMessages({
-  mention: { id: 'account.mention', defaultMessage: 'Mention' },
+  mention: { id: 'account.mention', defaultMessage: 'Mention @{name}' },
   edit_profile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' },
-  unblock: { id: 'account.unblock', defaultMessage: 'Unblock' },
+  unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' },
   unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
-  block: { id: 'account.block', defaultMessage: 'Block' },
+  unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' },
+  block: { id: 'account.block', defaultMessage: 'Block @{name}' },
+  mute: { id: 'account.mute', defaultMessage: 'Mute @{name}' },
   follow: { id: 'account.follow', defaultMessage: 'Follow' },
-  block: { id: 'account.block', defaultMessage: 'Block' },
-  report: { id: 'account.report', defaultMessage: 'Report' }
+  report: { id: 'account.report', defaultMessage: 'Report @{name}' },
+  disclaimer: { id: 'account.disclaimer', defaultMessage: 'This user is from another instance. This number may be larger.' }
 });
 
 const outerDropdownStyle = {
@@ -35,6 +37,7 @@ const ActionBar = React.createClass({
     onBlock: React.PropTypes.func.isRequired,
     onMention: React.PropTypes.func.isRequired,
     onReport: React.PropTypes.func.isRequired,
+    onMute: React.PropTypes.func.isRequired,
     intl: React.PropTypes.object.isRequired
   },
 
@@ -44,21 +47,31 @@ const ActionBar = React.createClass({
     const { account, me, intl } = this.props;
 
     let menu = [];
+    let extraInfo = '';
 
-    menu.push({ text: intl.formatMessage(messages.mention), action: this.props.onMention });
+    menu.push({ text: intl.formatMessage(messages.mention, { name: account.get('username') }), action: this.props.onMention });
+    menu.push(null);
 
     if (account.get('id') === me) {
       menu.push({ text: intl.formatMessage(messages.edit_profile), href: '/settings/profile' });
-    } else if (account.getIn(['relationship', 'blocking'])) {
-      menu.push({ text: intl.formatMessage(messages.unblock), action: this.props.onBlock });
-    } else if (account.getIn(['relationship', 'following'])) {
-      menu.push({ text: intl.formatMessage(messages.block), action: this.props.onBlock });
     } else {
-      menu.push({ text: intl.formatMessage(messages.block), action: this.props.onBlock });
+      if (account.getIn(['relationship', 'muting'])) {
+        menu.push({ text: intl.formatMessage(messages.unmute, { name: account.get('username') }), action: this.props.onMute });
+      } else {
+        menu.push({ text: intl.formatMessage(messages.mute, { name: account.get('username') }), action: this.props.onMute });
+      }
+
+      if (account.getIn(['relationship', 'blocking'])) {
+        menu.push({ text: intl.formatMessage(messages.unblock, { name: account.get('username') }), action: this.props.onBlock });
+      } else {
+        menu.push({ text: intl.formatMessage(messages.block, { name: account.get('username') }), action: this.props.onBlock });
+      }
+
+      menu.push({ text: intl.formatMessage(messages.report, { name: account.get('username') }), action: this.props.onReport });
     }
 
-    if (account.get('id') !== me) {
-      menu.push({ text: intl.formatMessage(messages.report), action: this.props.onReport });
+    if (account.get('acct') !== account.get('username')) {
+      extraInfo = <abbr title={intl.formatMessage(messages.disclaimer)}>*</abbr>;
     }
 
     return (
@@ -70,17 +83,17 @@ const ActionBar = React.createClass({
         <div style={outerLinksStyle}>
           <Link className='account__action-bar__tab' to={`/accounts/${account.get('id')}`}>
             <span><FormattedMessage id='account.posts' defaultMessage='Posts' /></span>
-            <strong><FormattedNumber value={account.get('statuses_count')} /></strong>
+            <strong><FormattedNumber value={account.get('statuses_count')} /> {extraInfo}</strong>
           </Link>
 
           <Link className='account__action-bar__tab' to={`/accounts/${account.get('id')}/following`}>
             <span><FormattedMessage id='account.follows' defaultMessage='Follows' /></span>
-            <strong><FormattedNumber value={account.get('following_count')} /></strong>
+            <strong><FormattedNumber value={account.get('following_count')} /> {extraInfo}</strong>
           </Link>
 
           <Link className='account__action-bar__tab' to={`/accounts/${account.get('id')}/followers`}>
             <span><FormattedMessage id='account.followers' defaultMessage='Followers' /></span>
-            <strong><FormattedNumber value={account.get('followers_count')} /></strong>
+            <strong><FormattedNumber value={account.get('followers_count')} /> {extraInfo}</strong>
           </Link>
         </div>
       </div>
diff --git a/app/assets/javascripts/components/features/account_timeline/components/header.jsx b/app/assets/javascripts/components/features/account_timeline/components/header.jsx
index 2dd3ca7b1..99a10562e 100644
--- a/app/assets/javascripts/components/features/account_timeline/components/header.jsx
+++ b/app/assets/javascripts/components/features/account_timeline/components/header.jsx
@@ -15,7 +15,8 @@ const Header = React.createClass({
     onFollow: React.PropTypes.func.isRequired,
     onBlock: React.PropTypes.func.isRequired,
     onMention: React.PropTypes.func.isRequired,
-    onReport: React.PropTypes.func.isRequired
+    onReport: React.PropTypes.func.isRequired,
+    onMute: React.PropTypes.func.isRequired
   },
 
   mixins: [PureRenderMixin],
@@ -37,6 +38,10 @@ const Header = React.createClass({
     this.context.router.push('/report');
   },
 
+  handleMute() {
+    this.props.onMute(this.props.account);
+  },
+
   render () {
     const { account, me } = this.props;
 
@@ -58,6 +63,7 @@ const Header = React.createClass({
           onBlock={this.handleBlock}
           onMention={this.handleMention}
           onReport={this.handleReport}
+          onMute={this.handleMute}
         />
       </div>
     );
diff --git a/app/assets/javascripts/components/features/account_timeline/containers/header_container.jsx b/app/assets/javascripts/components/features/account_timeline/containers/header_container.jsx
index e4ce905fe..8472d25a5 100644
--- a/app/assets/javascripts/components/features/account_timeline/containers/header_container.jsx
+++ b/app/assets/javascripts/components/features/account_timeline/containers/header_container.jsx
@@ -5,7 +5,9 @@ import {
   followAccount,
   unfollowAccount,
   blockAccount,
-  unblockAccount
+  unblockAccount,
+  muteAccount,
+  unmuteAccount
 } from '../../../actions/accounts';
 import { mentionCompose } from '../../../actions/compose';
 import { initReport } from '../../../actions/reports';
@@ -44,6 +46,14 @@ const mapDispatchToProps = dispatch => ({
 
   onReport (account) {
     dispatch(initReport(account));
+  },
+
+  onMute (account) {
+    if (account.getIn(['relationship', 'muting'])) {
+      dispatch(unmuteAccount(account.get('id')));
+    } else {
+      dispatch(muteAccount(account.get('id')));
+    }
   }
 });
 
diff --git a/app/assets/javascripts/components/features/community_timeline/index.jsx b/app/assets/javascripts/components/features/community_timeline/index.jsx
index aa1b8368e..2cfd7b2fe 100644
--- a/app/assets/javascripts/components/features/community_timeline/index.jsx
+++ b/app/assets/javascripts/components/features/community_timeline/index.jsx
@@ -20,6 +20,8 @@ const mapStateToProps = state => ({
   accessToken: state.getIn(['meta', 'access_token'])
 });
 
+let subscription;
+
 const CommunityTimeline = React.createClass({
 
   propTypes: {
@@ -36,7 +38,11 @@ const CommunityTimeline = React.createClass({
 
     dispatch(refreshTimeline('community'));
 
-    this.subscription = createStream(accessToken, 'public:local', {
+    if (typeof subscription !== 'undefined') {
+      return;
+    }
+
+    subscription = createStream(accessToken, 'public:local', {
 
       received (data) {
         switch(data.event) {
@@ -53,10 +59,10 @@ const CommunityTimeline = React.createClass({
   },
 
   componentWillUnmount () {
-    if (typeof this.subscription !== 'undefined') {
-      this.subscription.close();
-      this.subscription = null;
-    }
+    // if (typeof subscription !== 'undefined') {
+    //   subscription.close();
+    //   subscription = null;
+    // }
   },
 
   render () {
diff --git a/app/assets/javascripts/components/features/compose/components/character_counter.jsx b/app/assets/javascripts/components/features/compose/components/character_counter.jsx
index f0c1b7c8d..e6b675354 100644
--- a/app/assets/javascripts/components/features/compose/components/character_counter.jsx
+++ b/app/assets/javascripts/components/features/compose/components/character_counter.jsx
@@ -10,7 +10,7 @@ const CharacterCounter = React.createClass({
   mixins: [PureRenderMixin],
 
   render () {
-    const diff = this.props.max - this.props.text.length;
+    const diff = this.props.max - this.props.text.replace(/[\uD800-\uDBFF][\uDC00-\uDFFF]/g, "_").length;
 
     return (
       <span style={{ fontSize: '16px', cursor: 'default' }}>
diff --git a/app/assets/javascripts/components/features/compose/components/compose_form.jsx b/app/assets/javascripts/components/features/compose/components/compose_form.jsx
index 31ae8e034..047c974f2 100644
--- a/app/assets/javascripts/components/features/compose/components/compose_form.jsx
+++ b/app/assets/javascripts/components/features/compose/components/compose_form.jsx
@@ -15,6 +15,7 @@ import UnlistedToggleContainer from '../containers/unlisted_toggle_container';
 import SpoilerToggleContainer from '../containers/spoiler_toggle_container';
 import PrivateToggleContainer from '../containers/private_toggle_container';
 import SensitiveToggleContainer from '../containers/sensitive_toggle_container';
+import EmojiPickerDropdown from './emoji_picker_dropdown';
 
 const messages = defineMessages({
   placeholder: { id: 'compose_form.placeholder', defaultMessage: 'What is on your mind?' },
@@ -47,6 +48,8 @@ const ComposeForm = React.createClass({
     onFetchSuggestions: React.PropTypes.func.isRequired,
     onSuggestionSelected: React.PropTypes.func.isRequired,
     onChangeSpoilerText: React.PropTypes.func.isRequired,
+    onPaste: React.PropTypes.func.isRequired,
+    onPickEmoji: React.PropTypes.func.isRequired
   },
 
   mixins: [PureRenderMixin],
@@ -75,6 +78,7 @@ const ComposeForm = React.createClass({
   },
 
   onSuggestionSelected (tokenStart, token, value) {
+    this._restoreCaret = null;
     this.props.onSuggestionSelected(tokenStart, token, value);
   },
 
@@ -87,8 +91,18 @@ const ComposeForm = React.createClass({
       // If replying to zero or one users, places the cursor at the end of the textbox.
       // If replying to more than one user, selects any usernames past the first;
       // this provides a convenient shortcut to drop everyone else from the conversation.
-      const selectionEnd   = this.props.text.length;
-      const selectionStart = (this.props.preselectDate !== prevProps.preselectDate) ? (this.props.text.search(/\s/) + 1) : selectionEnd;
+      let selectionEnd, selectionStart;
+
+      if (this.props.preselectDate !== prevProps.preselectDate) {
+        selectionEnd   = this.props.text.length;
+        selectionStart = this.props.text.search(/\s/) + 1;
+      } else if (typeof this._restoreCaret === 'number') {
+        selectionStart = this._restoreCaret;
+        selectionEnd   = this._restoreCaret;
+      } else {
+        selectionEnd   = this.props.text.length;
+        selectionStart = selectionEnd;
+      }
 
       this.autosuggestTextarea.textarea.setSelectionRange(selectionStart, selectionEnd);
       this.autosuggestTextarea.textarea.focus();
@@ -99,8 +113,14 @@ const ComposeForm = React.createClass({
     this.autosuggestTextarea = c;
   },
 
+  handleEmojiPick (data) {
+    const position     = this.autosuggestTextarea.textarea.selectionStart;
+    this._restoreCaret = position + data.shortname.length + 1;
+    this.props.onPickEmoji(position, data);
+  },
+
   render () {
-    const { intl, needsPrivacyWarning, mentionedDomains } = this.props;
+    const { intl, needsPrivacyWarning, mentionedDomains, onPaste } = this.props;
     const disabled = this.props.is_submitting || this.props.is_uploading;
 
     let publishText    = '';
@@ -149,12 +169,16 @@ const ComposeForm = React.createClass({
           onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
           onSuggestionsClearRequested={this.onSuggestionsClearRequested}
           onSuggestionSelected={this.onSuggestionSelected}
+          onPaste={onPaste}
         />
 
         <div style={{ marginTop: '10px', overflow: 'hidden' }}>
           <div style={{ float: 'right' }}><Button text={publishText} onClick={this.handleSubmit} disabled={disabled} /></div>
           <div style={{ float: 'right', marginRight: '16px', lineHeight: '36px' }}><CharacterCounter max={500} text={[this.props.spoiler_text, this.props.text].join('')} /></div>
-          <UploadButtonContainer style={{ paddingTop: '4px' }} />
+          <div style={{ display: 'flex', paddingTop: '4px' }}>
+            <UploadButtonContainer />
+            <EmojiPickerDropdown onPickEmoji={this.handleEmojiPick} />
+          </div>
         </div>
 
         <SpoilerToggleContainer />
diff --git a/app/assets/javascripts/components/features/compose/components/emoji_picker_dropdown.jsx b/app/assets/javascripts/components/features/compose/components/emoji_picker_dropdown.jsx
new file mode 100644
index 000000000..3a454a5fb
--- /dev/null
+++ b/app/assets/javascripts/components/features/compose/components/emoji_picker_dropdown.jsx
@@ -0,0 +1,52 @@
+import Dropdown, { DropdownTrigger, DropdownContent } from 'react-simple-dropdown';
+import EmojiPicker from 'emojione-picker';
+import PureRenderMixin from 'react-addons-pure-render-mixin';
+import { defineMessages, injectIntl } from 'react-intl';
+
+const messages = defineMessages({
+  emoji: { id: 'emoji_button.label', defaultMessage: 'Emoji' }
+});
+
+const settings = {
+  imageType: 'png',
+  sprites: false,
+  imagePathPNG: '/emoji/'
+};
+
+const EmojiPickerDropdown = React.createClass({
+
+  propTypes: {
+    intl: React.PropTypes.object.isRequired,
+    onPickEmoji: React.PropTypes.func.isRequired
+  },
+
+  mixins: [PureRenderMixin],
+
+  setRef (c) {
+    this.dropdown = c;
+  },
+
+  handleChange (data) {
+    this.dropdown.hide();
+    this.props.onPickEmoji(data);
+  },
+
+  render () {
+    const { intl } = this.props;
+
+    return (
+      <Dropdown ref={this.setRef} style={{ marginLeft: '5px' }}>
+        <DropdownTrigger className='icon-button' title={intl.formatMessage(messages.emoji)} style={{ fontSize: `24px`, width: `24px`, lineHeight: `24px`, display: 'block', marginLeft: '2px' }}>
+          <i className={`fa fa-smile-o`} style={{ verticalAlign: 'middle' }} />
+        </DropdownTrigger>
+
+        <DropdownContent>
+          <EmojiPicker emojione={settings} onChange={this.handleChange} />
+        </DropdownContent>
+      </Dropdown>
+    );
+  }
+
+});
+
+export default injectIntl(EmojiPickerDropdown);
diff --git a/app/assets/javascripts/components/features/compose/containers/compose_form_container.jsx b/app/assets/javascripts/components/features/compose/containers/compose_form_container.jsx
index 53129af6e..a67adbdd6 100644
--- a/app/assets/javascripts/components/features/compose/containers/compose_form_container.jsx
+++ b/app/assets/javascripts/components/features/compose/containers/compose_form_container.jsx
@@ -1,5 +1,6 @@
 import { connect } from 'react-redux';
 import ComposeForm from '../components/compose_form';
+import { uploadCompose } from '../../../actions/compose';
 import { createSelector } from 'reselect';
 import {
   changeCompose,
@@ -8,6 +9,7 @@ import {
   fetchComposeSuggestions,
   selectComposeSuggestion,
   changeComposeSpoilerText,
+  insertEmojiCompose
 } from '../../../actions/compose';
 
 const getMentionedUsernames = createSelector(state => state.getIn(['compose', 'text']), text => text.match(/(?:^|[^\/\w])@([a-z0-9_]+@[a-z0-9\.\-]+)/ig));
@@ -65,6 +67,14 @@ const mapDispatchToProps = (dispatch) => ({
     dispatch(changeComposeSpoilerText(checked));
   },
 
+  onPaste (files) {
+    dispatch(uploadCompose(files));
+  },
+
+  onPickEmoji (position, data) {
+    dispatch(insertEmojiCompose(position, data));
+  },
+
 });
 
 export default connect(mapStateToProps, mapDispatchToProps)(ComposeForm);
diff --git a/app/assets/javascripts/components/features/getting_started/index.jsx b/app/assets/javascripts/components/features/getting_started/index.jsx
index f8433b8f4..48b4a6b8e 100644
--- a/app/assets/javascripts/components/features/getting_started/index.jsx
+++ b/app/assets/javascripts/components/features/getting_started/index.jsx
@@ -45,8 +45,7 @@ const GettingStarted = ({ intl, me }) => {
         <div className='static-content getting-started'>
           <p><FormattedMessage id='getting_started.about_addressing' defaultMessage='You can follow people if you know their username and the domain they are on by entering an e-mail-esque address into the form at the top of the sidebar.' /></p>
           <p><FormattedMessage id='getting_started.about_shortcuts' defaultMessage='If the target user is on the same domain as you, just the username will work. The same rule applies to mentioning people in statuses.' /></p>
-          <p><FormattedMessage id='getting_started.about_developer' defaultMessage='The developer of this project can be followed as Gargron@mastodon.social' /></p>
-          <p><FormattedMessage id='getting_started.open_source_notice' defaultMessage='Mastodon is open source software. You can contribute or report issues on github at {github}' values={{ github: <a href="https://github.com/tootsuite/mastodon" target="_blank">tootsuite/mastodon</a> }} /></p>
+          <p><FormattedMessage id='getting_started.open_source_notice' defaultMessage='Mastodon is open source software. You can contribute or report issues on github at {github}. Various apps are available.' values={{ github: <a href="https://github.com/tootsuite/mastodon" target="_blank">tootsuite/mastodon</a> }} /></p>
         </div>
       </div>
     </Column>
diff --git a/app/assets/javascripts/components/features/notifications/components/clear_column_button.jsx b/app/assets/javascripts/components/features/notifications/components/clear_column_button.jsx
index 0b7c737c6..d75149a0e 100644
--- a/app/assets/javascripts/components/features/notifications/components/clear_column_button.jsx
+++ b/app/assets/javascripts/components/features/notifications/components/clear_column_button.jsx
@@ -4,7 +4,8 @@ const iconStyle = {
   position: 'absolute',
   right: '48px',
   top: '0',
-  cursor: 'pointer'
+  cursor: 'pointer',
+  zIndex: '2'
 };
 
 const ClearColumnButton = ({ onClick }) => (
diff --git a/app/assets/javascripts/components/features/notifications/index.jsx b/app/assets/javascripts/components/features/notifications/index.jsx
index 0da3544f6..74b914ffd 100644
--- a/app/assets/javascripts/components/features/notifications/index.jsx
+++ b/app/assets/javascripts/components/features/notifications/index.jsx
@@ -13,7 +13,8 @@ import LoadMore from '../../components/load_more';
 import ClearColumnButton from './components/clear_column_button';
 
 const messages = defineMessages({
-  title: { id: 'column.notifications', defaultMessage: 'Notifications' }
+  title: { id: 'column.notifications', defaultMessage: 'Notifications' },
+  confirm: { id: 'notifications.clear_confirmation', defaultMessage: 'Are you sure you want to clear all your notifications?' }
 });
 
 const getNotifications = createSelector([
@@ -72,7 +73,9 @@ const Notifications = React.createClass({
   },
 
   handleClear () {
-    this.props.dispatch(clearNotifications());
+    if (window.confirm(this.props.intl.formatMessage(messages.confirm))) {
+      this.props.dispatch(clearNotifications());
+    }
   },
 
   setRef (c) {
diff --git a/app/assets/javascripts/components/features/public_timeline/index.jsx b/app/assets/javascripts/components/features/public_timeline/index.jsx
index ce4eacc92..b2342abbd 100644
--- a/app/assets/javascripts/components/features/public_timeline/index.jsx
+++ b/app/assets/javascripts/components/features/public_timeline/index.jsx
@@ -20,6 +20,8 @@ const mapStateToProps = state => ({
   accessToken: state.getIn(['meta', 'access_token'])
 });
 
+let subscription;
+
 const PublicTimeline = React.createClass({
 
   propTypes: {
@@ -36,7 +38,11 @@ const PublicTimeline = React.createClass({
 
     dispatch(refreshTimeline('public'));
 
-    this.subscription = createStream(accessToken, 'public', {
+    if (typeof subscription !== 'undefined') {
+      return;
+    }
+
+    subscription = createStream(accessToken, 'public', {
 
       received (data) {
         switch(data.event) {
@@ -53,10 +59,10 @@ const PublicTimeline = React.createClass({
   },
 
   componentWillUnmount () {
-    if (typeof this.subscription !== 'undefined') {
-      this.subscription.close();
-      this.subscription = null;
-    }
+    // if (typeof subscription !== 'undefined') {
+    //   subscription.close();
+    //   subscription = null;
+    // }
   },
 
   render () {
diff --git a/app/assets/javascripts/components/features/status/components/action_bar.jsx b/app/assets/javascripts/components/features/status/components/action_bar.jsx
index cc4d5cca4..2acf94274 100644
--- a/app/assets/javascripts/components/features/status/components/action_bar.jsx
+++ b/app/assets/javascripts/components/features/status/components/action_bar.jsx
@@ -6,11 +6,11 @@ import { defineMessages, injectIntl } from 'react-intl';
 
 const messages = defineMessages({
   delete: { id: 'status.delete', defaultMessage: 'Delete' },
-  mention: { id: 'status.mention', defaultMessage: 'Mention' },
+  mention: { id: 'status.mention', defaultMessage: 'Mention @{name}' },
   reply: { id: 'status.reply', defaultMessage: 'Reply' },
   reblog: { id: 'status.reblog', defaultMessage: 'Reblog' },
   favourite: { id: 'status.favourite', defaultMessage: 'Favourite' },
-  report: { id: 'status.report', defaultMessage: 'Report' }
+  report: { id: 'status.report', defaultMessage: 'Report @{name}' }
 });
 
 const ActionBar = React.createClass({
@@ -66,8 +66,9 @@ const ActionBar = React.createClass({
     if (me === status.getIn(['account', 'id'])) {
       menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick });
     } else {
-      menu.push({ text: intl.formatMessage(messages.mention), action: this.handleMentionClick });
-      menu.push({ text: intl.formatMessage(messages.report), action: this.handleReport });
+      menu.push({ text: intl.formatMessage(messages.mention, { name: status.getIn(['account', 'username']) }), action: this.handleMentionClick });
+      menu.push(null);
+      menu.push({ text: intl.formatMessage(messages.report, { name: status.getIn(['account', 'username']) }), action: this.handleReport });
     }
 
     return (
diff --git a/app/assets/javascripts/components/features/status/components/detailed_status.jsx b/app/assets/javascripts/components/features/status/components/detailed_status.jsx
index 8a7c0c5d5..caa46ff3c 100644
--- a/app/assets/javascripts/components/features/status/components/detailed_status.jsx
+++ b/app/assets/javascripts/components/features/status/components/detailed_status.jsx
@@ -39,7 +39,7 @@ const DetailedStatus = React.createClass({
 
     if (status.get('media_attachments').size > 0) {
       if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
-        media = <VideoPlayer sensitive={status.get('sensitive')} media={status.getIn(['media_attachments', 0])} width={317} height={178} />;
+        media = <VideoPlayer sensitive={status.get('sensitive')} media={status.getIn(['media_attachments', 0])} width={300} height={150} autoplay />;
       } else {
         media = <MediaGallery sensitive={status.get('sensitive')} media={status.get('media_attachments')} height={300} onOpenMedia={this.props.onOpenMedia} />;
       }
diff --git a/app/assets/javascripts/components/features/ui/containers/modal_container.jsx b/app/assets/javascripts/components/features/ui/containers/modal_container.jsx
index d8301b20f..e3c4281b9 100644
--- a/app/assets/javascripts/components/features/ui/containers/modal_container.jsx
+++ b/app/assets/javascripts/components/features/ui/containers/modal_container.jsx
@@ -9,6 +9,7 @@ import ImageLoader from 'react-imageloader';
 import LoadingIndicator from '../../../components/loading_indicator';
 import PureRenderMixin from 'react-addons-pure-render-mixin';
 import ImmutablePropTypes from 'react-immutable-proptypes';
+import ExtendedVideoPlayer from '../../../components/extended_video_player';
 
 const mapStateToProps = state => ({
   media: state.getIn(['modal', 'media']),
@@ -131,27 +132,34 @@ const Modal = React.createClass({
       return null;
     }
 
-    const url = media.get(index).get('url');
+    const attachment = media.get(index);
+    const url = attachment.get('url');
 
-    let leftNav, rightNav;
+    let leftNav, rightNav, content;
 
-    leftNav = rightNav = '';
+    leftNav = rightNav = content = '';
 
     if (media.size > 1) {
       leftNav  = <div style={leftNavStyle} className='modal-container--nav' onClick={this.handlePrevClick}><i className='fa fa-fw fa-chevron-left' /></div>;
       rightNav = <div style={rightNavStyle} className='modal-container--nav' onClick={this.handleNextClick}><i className='fa fa-fw fa-chevron-right' /></div>;
     }
 
-    return (
-      <Lightbox {...other}>
-        {leftNav}
-
+    if (attachment.get('type') === 'image') {
+      content = (
         <ImageLoader
           src={url}
           preloader={preloader}
           imgProps={{ style: imageStyle }}
         />
+      );
+    } else if (attachment.get('type') === 'gifv') {
+      content = <ExtendedVideoPlayer src={url} />;
+    }
 
+    return (
+      <Lightbox {...other}>
+        {leftNav}
+        {content}
         {rightNav}
       </Lightbox>
     );
diff --git a/app/assets/javascripts/components/is_mobile.jsx b/app/assets/javascripts/components/is_mobile.jsx
index eaa6221e4..992e63727 100644
--- a/app/assets/javascripts/components/is_mobile.jsx
+++ b/app/assets/javascripts/components/is_mobile.jsx
@@ -3,3 +3,9 @@ const LAYOUT_BREAKPOINT = 1024;
 export function isMobile(width) {
   return width <= LAYOUT_BREAKPOINT;
 };
+
+const iOS = /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream;
+
+export function isIOS() {
+  return iOS;
+};
diff --git a/app/assets/javascripts/components/locales/en.jsx b/app/assets/javascripts/components/locales/en.jsx
index f1d6a6dbc..3131dca1a 100644
--- a/app/assets/javascripts/components/locales/en.jsx
+++ b/app/assets/javascripts/components/locales/en.jsx
@@ -2,7 +2,7 @@ const en = {
   "column_back_button.label": "Back",
   "lightbox.close": "Close",
   "loading_indicator.label": "Loading...",
-  "status.mention": "Mention",
+  "status.mention": "Mention @{name}",
   "status.delete": "Delete",
   "status.reply": "Reply",
   "status.reblog": "Boost",
@@ -11,11 +11,11 @@ const en = {
   "status.sensitive_warning": "Sensitive content",
   "status.sensitive_toggle": "Click to view",
   "video_player.toggle_sound": "Toggle sound",
-  "account.mention": "Mention",
+  "account.mention": "Mention @{name}",
   "account.edit_profile": "Edit profile",
-  "account.unblock": "Unblock",
+  "account.unblock": "Unblock @{name}",
   "account.unfollow": "Unfollow",
-  "account.block": "Block",
+  "account.block": "Block @{name}",
   "account.follow": "Follow",
   "account.posts": "Posts",
   "account.follows": "Follows",
@@ -25,16 +25,15 @@ const en = {
   "getting_started.heading": "Getting started",
   "getting_started.about_addressing": "You can follow people if you know their username and the domain they are on by entering an e-mail-esque address into the search form.",
   "getting_started.about_shortcuts": "If the target user is on the same domain as you, just the username will work. The same rule applies to mentioning people in statuses.",
-  "getting_started.about_developer": "The developer of this project can be followed as Gargron@mastodon.social",
-  "getting_started.open_source_notice": "Mastodon is open source software. You can contribute or report issues on github at {github}.",
+  "getting_started.open_source_notice": "Mastodon is open source software. You can contribute or report issues on github at {github}. Various apps are available.",
   "column.home": "Home",
-  "column.community": "Local",
-  "column.public": "Whole Known Network",
+  "column.community": "Local timeline",
+  "column.public": "Federated timeline",
   "column.notifications": "Notifications",
   "tabs_bar.compose": "Compose",
   "tabs_bar.home": "Home",
   "tabs_bar.mentions": "Mentions",
-  "tabs_bar.public": "Whole Known Network",
+  "tabs_bar.public": "Federated timeline",
   "tabs_bar.notifications": "Notifications",
   "compose_form.placeholder": "What is on your mind?",
   "compose_form.publish": "Toot",
@@ -46,7 +45,7 @@ const en = {
   "navigation_bar.edit_profile": "Edit profile",
   "navigation_bar.preferences": "Preferences",
   "navigation_bar.community_timeline": "Local timeline",
-  "navigation_bar.public_timeline": "Whole Known Network",
+  "navigation_bar.public_timeline": "Federated timeline",
   "navigation_bar.logout": "Logout",
   "reply_indicator.cancel": "Cancel",
   "search.placeholder": "Search",
diff --git a/app/assets/javascripts/components/middleware/sounds.jsx b/app/assets/javascripts/components/middleware/sounds.jsx
new file mode 100644
index 000000000..200efa3d7
--- /dev/null
+++ b/app/assets/javascripts/components/middleware/sounds.jsx
@@ -0,0 +1,22 @@
+const play = audio => {
+  if (!audio.paused) {
+    audio.pause();
+    audio.fastSeek(0);
+  }
+
+  audio.play();
+};
+
+export default function soundsMiddleware() {
+  const soundCache = {
+    boop: new Audio(['/sounds/boop.mp3'])
+  };
+
+  return ({ dispatch }) => next => (action) => {
+    if (action.meta && action.meta.sound && soundCache[action.meta.sound]) {
+      play(soundCache[action.meta.sound]);
+    }
+
+    return next(action);
+  };
+};
diff --git a/app/assets/javascripts/components/reducers/compose.jsx b/app/assets/javascripts/components/reducers/compose.jsx
index dead5fd77..b0001351f 100644
--- a/app/assets/javascripts/components/reducers/compose.jsx
+++ b/app/assets/javascripts/components/reducers/compose.jsx
@@ -20,7 +20,8 @@ import {
   COMPOSE_SPOILERNESS_CHANGE,
   COMPOSE_SPOILER_TEXT_CHANGE,
   COMPOSE_VISIBILITY_CHANGE,
-  COMPOSE_LISTABILITY_CHANGE
+  COMPOSE_LISTABILITY_CHANGE,
+  COMPOSE_EMOJI_INSERT
 } from '../actions/compose';
 import { TIMELINE_DELETE } from '../actions/timelines';
 import { STORE_HYDRATE } from '../actions/store';
@@ -105,6 +106,15 @@ const insertSuggestion = (state, position, token, completion) => {
   });
 };
 
+const insertEmoji = (state, position, emojiData) => {
+  const emoji = emojiData.shortname;
+
+  return state.withMutations(map => {
+    map.update('text', oldText => `${oldText.slice(0, position)}${emoji} ${oldText.slice(position)}`);
+    map.set('focusDate', new Date());
+  });
+};
+
 export default function compose(state = initialState, action) {
   switch(action.type) {
   case STORE_HYDRATE:
@@ -177,6 +187,8 @@ export default function compose(state = initialState, action) {
     } else {
       return state;
     }
+  case COMPOSE_EMOJI_INSERT:
+    return insertEmoji(state, action.position, action.emoji);
   default:
     return state;
   }
diff --git a/app/assets/javascripts/components/reducers/relationships.jsx b/app/assets/javascripts/components/reducers/relationships.jsx
index e4af1f028..591f8034b 100644
--- a/app/assets/javascripts/components/reducers/relationships.jsx
+++ b/app/assets/javascripts/components/reducers/relationships.jsx
@@ -3,6 +3,8 @@ import {
   ACCOUNT_UNFOLLOW_SUCCESS,
   ACCOUNT_BLOCK_SUCCESS,
   ACCOUNT_UNBLOCK_SUCCESS,
+  ACCOUNT_MUTE_SUCCESS,
+  ACCOUNT_UNMUTE_SUCCESS,
   RELATIONSHIPS_FETCH_SUCCESS
 } from '../actions/accounts';
 import Immutable from 'immutable';
@@ -25,6 +27,8 @@ export default function relationships(state = initialState, action) {
     case ACCOUNT_UNFOLLOW_SUCCESS:
     case ACCOUNT_BLOCK_SUCCESS:
     case ACCOUNT_UNBLOCK_SUCCESS:
+    case ACCOUNT_MUTE_SUCCESS:
+    case ACCOUNT_UNMUTE_SUCCESS:
       return normalizeRelationship(state, action.relationship);
     case RELATIONSHIPS_FETCH_SUCCESS:
       return normalizeRelationships(state, action.relationships);
diff --git a/app/assets/javascripts/components/reducers/timelines.jsx b/app/assets/javascripts/components/reducers/timelines.jsx
index 6472ac6a0..c67d05423 100644
--- a/app/assets/javascripts/components/reducers/timelines.jsx
+++ b/app/assets/javascripts/components/reducers/timelines.jsx
@@ -22,7 +22,8 @@ import {
   ACCOUNT_TIMELINE_EXPAND_REQUEST,
   ACCOUNT_TIMELINE_EXPAND_SUCCESS,
   ACCOUNT_TIMELINE_EXPAND_FAIL,
-  ACCOUNT_BLOCK_SUCCESS
+  ACCOUNT_BLOCK_SUCCESS,
+  ACCOUNT_MUTE_SUCCESS
 } from '../actions/accounts';
 import {
   CONTEXT_FETCH_SUCCESS
@@ -295,6 +296,7 @@ export default function timelines(state = initialState, action) {
   case ACCOUNT_TIMELINE_EXPAND_SUCCESS:
     return appendNormalizedAccountTimeline(state, action.id, Immutable.fromJS(action.statuses));
   case ACCOUNT_BLOCK_SUCCESS:
+  case ACCOUNT_MUTE_SUCCESS:
     return filterTimelines(state, action.relationship, action.statuses);
   case TIMELINE_SCROLL_TOP:
     return updateTop(state, action.timeline, action.top);
diff --git a/app/assets/javascripts/components/rtl.jsx b/app/assets/javascripts/components/rtl.jsx
new file mode 100644
index 000000000..8f14bb338
--- /dev/null
+++ b/app/assets/javascripts/components/rtl.jsx
@@ -0,0 +1,27 @@
+// U+0590  to U+05FF  - Hebrew
+// U+0600  to U+06FF  - Arabic
+// U+0700  to U+074F  - Syriac
+// U+0750  to U+077F  - Arabic Supplement
+// U+0780  to U+07BF  - Thaana
+// U+07C0  to U+07FF  - N'Ko
+// U+0800  to U+083F  - Samaritan
+// U+08A0  to U+08FF  - Arabic Extended-A
+// U+FB1D  to U+FB4F  - Hebrew presentation forms
+// U+FB50  to U+FDFF  - Arabic presentation forms A
+// U+FE70  to U+FEFF  - Arabic presentation forms B
+
+const rtlChars = /[\u0590-\u083F]|[\u08A0-\u08FF]|[\uFB1D-\uFDFF]|[\uFE70-\uFEFF]/mg;
+
+export function isRtl(text) {
+  if (text.length === 0) {
+    return false;
+  }
+
+  const matches = text.match(rtlChars);
+
+  if (!matches) {
+    return false;
+  }
+
+  return matches.length / text.trim().length > 0.3;
+};
diff --git a/app/assets/javascripts/components/store/configureStore.jsx b/app/assets/javascripts/components/store/configureStore.jsx
index ad0427b52..a92d756f5 100644
--- a/app/assets/javascripts/components/store/configureStore.jsx
+++ b/app/assets/javascripts/components/store/configureStore.jsx
@@ -3,21 +3,14 @@ import thunk from 'redux-thunk';
 import appReducer from '../reducers';
 import loadingBarMiddleware from '../middleware/loading_bar';
 import errorsMiddleware from '../middleware/errors';
-import soundsMiddleware from 'redux-sounds';
-import Howler from 'howler';
+import soundsMiddleware from '../middleware/sounds';
 import Immutable from 'immutable';
 
-Howler.mobileAutoEnable = false;
-
-const soundsData = {
-  boop: '/sounds/boop.mp3'
-};
-
 export default function configureStore() {
   return createStore(appReducer, compose(applyMiddleware(
     thunk,
     loadingBarMiddleware({ promiseTypeSuffixes: ['REQUEST', 'SUCCESS', 'FAIL'] }),
     errorsMiddleware(),
-    soundsMiddleware(soundsData)
+    soundsMiddleware()
   ), window.devToolsExtension ? window.devToolsExtension() : f => f));
 };
diff --git a/app/assets/stylesheets/components.scss b/app/assets/stylesheets/components.scss
index 5fc67d9c1..4b1e86aca 100644
--- a/app/assets/stylesheets/components.scss
+++ b/app/assets/stylesheets/components.scss
@@ -1,3 +1,5 @@
+@import 'variables';
+
 .button {
   background-color: darken($color4, 3%);
   font-family: inherit;
@@ -59,6 +61,14 @@
   &.active {
     color: $color4;
   }
+
+  &:focus {
+    outline: none;
+  }
+}
+
+.dropdown--active .icon-button {
+  color: $color4;
 }
 
 .invisible {
@@ -387,6 +397,10 @@ a.status__content__spoiler-link {
     font-weight: 500;
     color: $color5;
   }
+
+  abbr {
+    color: lighten($color1, 26%);
+  }
 }
 
 .status__display-name, .status__relative-time, .detailed-status__display-name, .detailed-status__datetime, .detailed-status__application, .account__display-name {
@@ -516,6 +530,12 @@ a.status__content__spoiler-link {
   position: absolute;
 }
 
+.dropdown__sep {
+  border-bottom: 1px solid darken($color2, 8%);
+  margin: 5px 7px 6px;
+  padding-top: 1px;
+}
+
 .dropdown--active .dropdown__content {
   display: block;
   z-index: 9999;
@@ -533,23 +553,40 @@ a.status__content__spoiler-link {
     left: 8px;
   }
 
-  ul {
+  & > ul {
     list-style: none;
     background: $color2;
     padding: 4px 0;
     border-radius: 4px;
     box-shadow: 0 0 15px rgba($color8, 0.4);
-    min-width: 100px;
+    min-width: 140px;
+    position: relative;
+    left: -10px;
   }
 
-  a {
+  &.dropdown__left {
+    & > ul {
+      left: -98px;
+    }
+  }
+
+  & > ul > li > a {
     font-size: 13px;
+    line-height: 18px;
     display: block;
-    padding: 6px 16px;
-    width: 100px;
+    padding: 4px 14px;
+    box-sizing: border-box;
+    width: 140px;
     text-decoration: none;
     background: $color2;
     color: $color1;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+
+    &:focus {
+      outline: none;
+    }
 
     &:hover {
       background: $color4;
@@ -983,15 +1020,6 @@ a.status__content__spoiler-link {
   }
 }
 
-.dropdown__content.dropdown__left {
-  transform: translateX(-108px);
-
-  &::before {
-    right: 8px !important;
-    left: initial !important;
-  }
-}
-
 .setting-text {
   color: $color3;
   background: transparent;
@@ -1074,8 +1102,10 @@ button.active i.fa-retweet {
   text-align: center;
   font-size: 16px;
   font-weight: 500;
-  color: lighten($color1, 26%);
-  padding-top: 120px;
+  color: lighten($color1, 16%);
+  padding-top: 210px;
+  background: image-url('mastodon-not-found.png') no-repeat center -50px;
+  cursor: default;
 }
 
 .column-header {
@@ -1230,3 +1260,164 @@ button.active i.fa-retweet {
   z-index: 1;
   background: radial-gradient(ellipse, rgba($color4, 0.23) 0%, rgba($color4, 0) 60%);
 }
+
+.emoji-dialog {
+  width: 280px;
+  height: 220px;
+  background: $color2;
+  box-sizing: border-box;
+  border-radius: 2px;
+  overflow: hidden;
+  position: relative;
+  box-shadow: 0 0 15px rgba($color8, 0.4);
+
+  .emojione {
+    margin: 0;
+  }
+
+  .emoji-dialog-header {
+    padding: 0 10px;
+    background-color: $color3;
+
+    ul {
+      padding: 0;
+      margin: 0;
+      list-style: none;
+    }
+
+    li {
+      display: inline-block;
+      box-sizing: border-box;
+      height: 42px;
+      padding: 9px 5px;
+      cursor: pointer;
+
+      img, svg {
+        width: 22px;
+        height: 22px;
+        filter: grayscale(100%);
+      }
+
+      &.active {
+        background: lighten($color3, 6%);
+
+        img, svg {
+          filter: grayscale(0);
+        }
+      }
+    }
+  }
+
+  .emoji-row {
+    box-sizing: border-box;
+    overflow-y: hidden;
+    padding-left: 10px;
+
+    .emoji {
+      display: inline-block;
+      padding: 5px;
+      border-radius: 4px;
+    }
+  }
+
+  .emoji-category-header {
+    box-sizing: border-box;
+    overflow-y: hidden;
+    padding: 8px 16px 0;
+    display: table;
+
+    > * {
+      display: table-cell;
+      vertical-align: middle;
+    }
+  }
+
+  .emoji-category-title {
+    font-size: 14px;
+    font-family: sans-serif;
+    font-weight: normal;
+    color: $color1;
+    cursor: default;
+  }
+
+  .emoji-category-heading-decoration {
+    text-align: right;
+  }
+
+  .modifiers {
+    list-style: none;
+    padding: 0;
+    margin: 0;
+    vertical-align: middle;
+    white-space: nowrap;
+    margin-top: 4px;
+
+    li {
+      display: inline-block;
+      padding: 0 2px;
+
+      &:last-of-type {
+        padding-right: 0;
+      }
+    }
+
+    .modifier {
+      display: inline-block;
+      border-radius: 10px;
+      width: 15px;
+      height: 15px;
+      position: relative;
+      cursor: pointer;
+
+      &.active:after {
+        content: "";
+        display: block;
+        position: absolute;
+        width: 7px;
+        height: 7px;
+        border-radius: 10px;
+        border: 2px solid $color1;
+        top: 2px;
+        left: 2px;
+      }
+    }
+  }
+
+  .emoji-search-wrapper {
+    padding: 6px 16px;
+  }
+
+  .emoji-search {
+    font-size: 12px;
+    padding: 6px 4px;
+    width: 100%;
+    border: 1px solid #ddd;
+    border-radius: 4px;
+  }
+
+  .emoji-categories-wrapper {
+    position: absolute;
+    top: 42px;
+    bottom: 0;
+    left: 0;
+    right: 0;
+  }
+
+  .emoji-search-wrapper + .emoji-categories-wrapper {
+    top: 83px;
+  }
+
+  .emoji-row .emoji:hover {
+    background: lighten($color2, 3%);
+  }
+
+  .emoji {
+    width: 22px;
+    height: 22px;
+    cursor: pointer;
+
+    &:focus {
+      outline: none;
+    }
+  }
+}
diff --git a/app/assets/stylesheets/stream_entries.scss b/app/assets/stylesheets/stream_entries.scss
index 3b2e88f6d..b9a9a1da3 100644
--- a/app/assets/stylesheets/stream_entries.scss
+++ b/app/assets/stylesheets/stream_entries.scss
@@ -104,8 +104,12 @@
       overflow: hidden;
       width: 100%;
       box-sizing: border-box;
-      height: 110px;
-      display: flex;
+      position: relative;
+
+      .status__attachments__inner {
+        display: flex;
+        height: 214px;
+      }
     }
   }
 
@@ -184,8 +188,12 @@
       overflow: hidden;
       width: 100%;
       box-sizing: border-box;
-      height: 300px;
-      display: flex;
+      position: relative;
+
+      .status__attachments__inner {
+        display: flex;
+        height: 360px;
+      }
     }
 
     .video-player {
@@ -231,11 +239,19 @@
       text-decoration: none;
       cursor: zoom-in;
     }
+
+    video {
+      position: relative;
+      z-index: 1;
+      width: 100%;
+      height: 100%;
+      object-fit: cover;
+      top: 50%;
+      transform: translateY(-50%);
+    }
   }
 
   .video-item {
-    max-width: 196px;
-
     a {
       cursor: pointer;
     }
@@ -258,6 +274,9 @@
     width: 100%;
     height: 100%;
     cursor: pointer;
+    position: absolute;
+    top: 0;
+    left: 0;
     display: flex;
     align-items: center;
     justify-content: center;
diff --git a/app/controllers/api/v1/accounts_controller.rb b/app/controllers/api/v1/accounts_controller.rb
index 94dba1d03..9c84e0a1b 100644
--- a/app/controllers/api/v1/accounts_controller.rb
+++ b/app/controllers/api/v1/accounts_controller.rb
@@ -1,8 +1,8 @@
 # frozen_string_literal: true
 
 class Api::V1::AccountsController < ApiController
-  before_action -> { doorkeeper_authorize! :read }, except: [:follow, :unfollow, :block, :unblock]
-  before_action -> { doorkeeper_authorize! :follow }, only: [:follow, :unfollow, :block, :unblock]
+  before_action -> { doorkeeper_authorize! :read }, except: [:follow, :unfollow, :block, :unblock, :mute, :unmute]
+  before_action -> { doorkeeper_authorize! :follow }, only: [:follow, :unfollow, :block, :unblock, :mute, :unmute]
   before_action :require_user!, except: [:show, :following, :followers, :statuses]
   before_action :set_account, except: [:verify_credentials, :suggestions, :search]
 
@@ -47,10 +47,13 @@ class Api::V1::AccountsController < ApiController
 
   def statuses
     @statuses = @account.statuses.permitted_for(@account, current_account).paginate_by_max_id(limit_param(DEFAULT_STATUSES_LIMIT), params[:max_id], params[:since_id])
+    @statuses = @statuses.where(id: MediaAttachment.where(account: @account).where.not(status_id: nil).reorder('').select('distinct status_id')) if params[:only_media]
+    @statuses = @statuses.without_replies if params[:exclude_replies]
     @statuses = cache_collection(@statuses, Status)
 
     set_maps(@statuses)
     set_counters_maps(@statuses)
+    set_account_counters_maps(@statuses.flat_map { |s| [s.account, s.reblog? ? s.reblog.account : nil] }.compact.uniq)
 
     next_path = statuses_api_v1_account_url(max_id: @statuses.last.id)    unless @statuses.empty?
     prev_path = statuses_api_v1_account_url(since_id: @statuses.first.id) unless @statuses.empty?
@@ -58,21 +61,6 @@ class Api::V1::AccountsController < ApiController
     set_pagination_headers(next_path, prev_path)
   end
 
-  def media_statuses
-    media_ids = MediaAttachment.where(account: @account).where.not(status_id: nil).reorder('').select('distinct status_id')
-    @statuses = @account.statuses.where(id: media_ids).permitted_for(@account, current_account).paginate_by_max_id(limit_param(DEFAULT_STATUSES_LIMIT), params[:max_id], params[:since_id])
-    @statuses = cache_collection(@statuses, Status)
-
-    set_maps(@statuses)
-    set_counters_maps(@statuses)
-
-    next_path = media_statuses_api_v1_account_url(max_id: @statuses.last.id)    unless @statuses.empty?
-    prev_path = media_statuses_api_v1_account_url(since_id: @statuses.first.id) unless @statuses.empty?
-
-    set_pagination_headers(next_path, prev_path)
-    render action: :statuses
-  end
-
   def follow
     FollowService.new.call(current_user.account, @account.acct)
     set_relationship
@@ -86,10 +74,17 @@ class Api::V1::AccountsController < ApiController
     @followed_by = { @account.id => false }
     @blocking    = { @account.id => true }
     @requested   = { @account.id => false }
+    @muting      = { @account.id => current_user.account.muting?(@account.id) }
 
     render action: :relationship
   end
 
+  def mute
+    MuteService.new.call(current_user.account, @account)
+    set_relationship
+    render action: :relationship
+  end
+
   def unfollow
     UnfollowService.new.call(current_user.account, @account)
     set_relationship
@@ -102,6 +97,12 @@ class Api::V1::AccountsController < ApiController
     render action: :relationship
   end
 
+  def unmute
+    UnmuteService.new.call(current_user.account, @account)
+    set_relationship
+    render action: :relationship
+  end
+
   def relationships
     ids = params[:id].is_a?(Enumerable) ? params[:id].map(&:to_i) : [params[:id].to_i]
 
@@ -109,6 +110,7 @@ class Api::V1::AccountsController < ApiController
     @following   = Account.following_map(ids, current_user.account_id)
     @followed_by = Account.followed_by_map(ids, current_user.account_id)
     @blocking    = Account.blocking_map(ids, current_user.account_id)
+    @muting      = Account.muting_map(ids, current_user.account_id)
     @requested   = Account.requested_map(ids, current_user.account_id)
   end
 
@@ -130,6 +132,7 @@ class Api::V1::AccountsController < ApiController
     @following   = Account.following_map([@account.id], current_user.account_id)
     @followed_by = Account.followed_by_map([@account.id], current_user.account_id)
     @blocking    = Account.blocking_map([@account.id], current_user.account_id)
+    @muting      = Account.muting_map([@account.id], current_user.account_id)
     @requested   = Account.requested_map([@account.id], current_user.account_id)
   end
 end
diff --git a/app/controllers/api/v1/mutes_controller.rb b/app/controllers/api/v1/mutes_controller.rb
new file mode 100644
index 000000000..42a9ed412
--- /dev/null
+++ b/app/controllers/api/v1/mutes_controller.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+class Api::V1::MutesController < ApiController
+  before_action -> { doorkeeper_authorize! :follow }
+  before_action :require_user!
+
+  respond_to :json
+
+  def index
+    results   = Mute.where(account: current_account).paginate_by_max_id(limit_param(DEFAULT_ACCOUNTS_LIMIT), params[:max_id], params[:since_id])
+    accounts  = Account.where(id: results.map(&:target_account_id)).map { |a| [a.id, a] }.to_h
+    @accounts = results.map { |f| accounts[f.target_account_id] }
+
+    set_account_counters_maps(@accounts)
+
+    next_path = api_v1_mutes_url(max_id: results.last.id)    if results.size == limit_param(DEFAULT_ACCOUNTS_LIMIT)
+    prev_path = api_v1_mutes_url(since_id: results.first.id) unless results.empty?
+
+    set_pagination_headers(next_path, prev_path)
+  end
+end
diff --git a/app/controllers/api_controller.rb b/app/controllers/api_controller.rb
index c2002cb79..db16f82e5 100644
--- a/app/controllers/api_controller.rb
+++ b/app/controllers/api_controller.rb
@@ -79,6 +79,7 @@ class ApiController < ApplicationController
 
   def require_user!
     current_resource_owner
+    set_user_activity
   rescue ActiveRecord::RecordNotFound
     render json: { error: 'This method requires an authenticated user' }, status: 422
   end
diff --git a/app/controllers/concerns/obfuscate_filename.rb b/app/controllers/concerns/obfuscate_filename.rb
index dde7ce8c6..9c896fb09 100644
--- a/app/controllers/concerns/obfuscate_filename.rb
+++ b/app/controllers/concerns/obfuscate_filename.rb
@@ -13,6 +13,10 @@ module ObfuscateFilename
     file = params.dig(*path)
     return if file.nil?
 
-    file.original_filename = 'media' + File.extname(file.original_filename)
+    file.original_filename = secure_token + File.extname(file.original_filename)
+  end
+
+  def secure_token(length = 16)
+    SecureRandom.hex(length / 2)
   end
 end
diff --git a/app/controllers/settings/preferences_controller.rb b/app/controllers/settings/preferences_controller.rb
index b7479bf8c..60400e465 100644
--- a/app/controllers/settings/preferences_controller.rb
+++ b/app/controllers/settings/preferences_controller.rb
@@ -14,6 +14,7 @@ class Settings::PreferencesController < ApplicationController
       reblog:         user_params[:notification_emails][:reblog]         == '1',
       favourite:      user_params[:notification_emails][:favourite]      == '1',
       mention:        user_params[:notification_emails][:mention]        == '1',
+      digest:         user_params[:notification_emails][:digest]         == '1',
     }
 
     current_user.settings['interactions'] = {
@@ -33,6 +34,6 @@ class Settings::PreferencesController < ApplicationController
   private
 
   def user_params
-    params.require(:user).permit(:locale, :setting_default_privacy, notification_emails: [:follow, :follow_request, :reblog, :favourite, :mention], interactions: [:must_be_follower, :must_be_following])
+    params.require(:user).permit(:locale, :setting_default_privacy, notification_emails: [:follow, :follow_request, :reblog, :favourite, :mention, :digest], interactions: [:must_be_follower, :must_be_following])
   end
 end
diff --git a/app/helpers/stream_entries_helper.rb b/app/helpers/stream_entries_helper.rb
index 15601a079..a26e912a3 100644
--- a/app/helpers/stream_entries_helper.rb
+++ b/app/helpers/stream_entries_helper.rb
@@ -37,4 +37,17 @@ module StreamEntriesHelper
   def proper_status(status)
     status.reblog? ? status.reblog : status
   end
+
+  def rtl?(text)
+    return false if text.empty?
+
+    matches = /[\p{Hebrew}|\p{Arabic}|\p{Syriac}|\p{Thaana}|\p{Nko}]+/m.match(text)
+
+    return false unless matches
+
+    rtl_size = matches.to_a.reduce(0) { |acc, elem| acc + elem.size }.to_f
+    ltr_size = text.strip.size.to_f
+
+    rtl_size / ltr_size > 0.3
+  end
 end
diff --git a/app/lib/feed_manager.rb b/app/lib/feed_manager.rb
index 623a1af03..3a26c5c05 100644
--- a/app/lib/feed_manager.rb
+++ b/app/lib/feed_manager.rb
@@ -22,8 +22,18 @@ class FeedManager
   end
 
   def push(timeline_type, account, status)
-    redis.zadd(key(timeline_type, account.id), status.id, status.reblog? ? status.reblog_of_id : status.id)
-    trim(timeline_type, account.id)
+    timeline_key = key(timeline_type, account.id)
+
+    if status.reblog?
+      # If the original status is within 40 statuses from top, do not re-insert it into the feed
+      rank = redis.zrevrank(timeline_key, status.reblog_of_id)
+      return if !rank.nil? && rank < 40
+      redis.zadd(timeline_key, status.id, status.reblog_of_id)
+    else
+      redis.zadd(timeline_key, status.id, status.id)
+      trim(timeline_type, account.id)
+    end
+
     broadcast(account.id, event: 'update', payload: inline_render(account, 'api/v1/statuses/show', status))
   end
 
@@ -85,6 +95,8 @@ class FeedManager
   end
 
   def filter_from_home?(status, receiver)
+    return true if receiver.muting?(status.account)
+
     should_filter = false
 
     if status.reply? && status.in_reply_to_id.nil?
@@ -95,6 +107,7 @@ class FeedManager
       should_filter &&= !(status.account_id == status.in_reply_to_account_id) # and it's not a self-reply
     elsif status.reblog?                                                      # Filter out a reblog
       should_filter = receiver.blocking?(status.reblog.account)               # if I'm blocking the reblogged person
+      should_filter ||= receiver.muting?(status.reblog.account)               # or muting that person
     end
 
     should_filter ||= receiver.blocking?(status.mentions.map(&:account_id))   # or if it mentions someone I blocked
diff --git a/app/lib/formatter.rb b/app/lib/formatter.rb
index e353c3504..b58952ae0 100644
--- a/app/lib/formatter.rb
+++ b/app/lib/formatter.rb
@@ -29,6 +29,11 @@ class Formatter
     sanitize(html, tags: %w(a br p span), attributes: %w(href rel class))
   end
 
+  def plaintext(status)
+    return status.text if status.local?
+    strip_tags(status.text)
+  end
+
   def simplified_format(account)
     return reformat(account.note) unless account.local?
 
diff --git a/app/mailers/notification_mailer.rb b/app/mailers/notification_mailer.rb
index a1b084682..bf4c16e43 100644
--- a/app/mailers/notification_mailer.rb
+++ b/app/mailers/notification_mailer.rb
@@ -49,4 +49,17 @@ class NotificationMailer < ApplicationMailer
       mail to: @me.user.email, subject: I18n.t('notification_mailer.follow_request.subject', name: @account.acct)
     end
   end
+
+  def digest(recipient, opts = {})
+    @me            = recipient
+    @since         = opts[:since] || @me.user.last_emailed_at || @me.user.current_sign_in_at
+    @notifications = Notification.where(account: @me, activity_type: 'Mention').where('created_at > ?', @since)
+    @follows_since = Notification.where(account: @me, activity_type: 'Follow').where('created_at > ?', @since).count
+
+    return if @notifications.empty?
+
+    I18n.with_locale(@me.user.locale || I18n.default_locale) do
+      mail to: @me.user.email, subject: I18n.t('notification_mailer.digest.subject', count: @notifications.size)
+    end
+  end
 end
diff --git a/app/models/account.rb b/app/models/account.rb
index a93a0668a..078078945 100644
--- a/app/models/account.rb
+++ b/app/models/account.rb
@@ -4,7 +4,7 @@ class Account < ApplicationRecord
   include Targetable
   include PgSearch
 
-  MENTION_RE = /(?:^|[^\/\w])@([a-z0-9_]+(?:@[a-z0-9\.\-]+)?)/i
+  MENTION_RE = /(?:^|[^\/\w])@([a-z0-9_]+(?:@[a-z0-9\.\-]+[a-z0-9]+)?)/i
   IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif'].freeze
 
   # Local users
@@ -46,6 +46,10 @@ class Account < ApplicationRecord
   has_many :block_relationships, class_name: 'Block', foreign_key: 'account_id', dependent: :destroy
   has_many :blocking, -> { order('blocks.id desc') }, through: :block_relationships, source: :target_account
 
+  # Mute relationships
+  has_many :mute_relationships, class_name: 'Mute', foreign_key: 'account_id', dependent: :destroy
+  has_many :muting, -> { order('mutes.id desc') }, through: :mute_relationships, source: :target_account
+
   # Media
   has_many :media_attachments, dependent: :destroy
 
@@ -73,6 +77,10 @@ class Account < ApplicationRecord
     block_relationships.where(target_account: other_account).first_or_create!(target_account: other_account)
   end
 
+  def mute!(other_account)
+    mute_relationships.where(target_account: other_account).first_or_create!(target_account: other_account)
+  end
+
   def unfollow!(other_account)
     follow = active_relationships.find_by(target_account: other_account)
     follow&.destroy
@@ -83,6 +91,11 @@ class Account < ApplicationRecord
     block&.destroy
   end
 
+  def unmute!(other_account)
+    mute = mute_relationships.find_by(target_account: other_account)
+    mute&.destroy
+  end
+
   def following?(other_account)
     following.include?(other_account)
   end
@@ -91,6 +104,10 @@ class Account < ApplicationRecord
     blocking.include?(other_account)
   end
 
+  def muting?(other_account)
+    muting.include?(other_account)
+  end
+
   def requested?(other_account)
     follow_requests.where(target_account: other_account).exists?
   end
@@ -188,6 +205,10 @@ class Account < ApplicationRecord
       follow_mapping(Block.where(target_account_id: target_account_ids, account_id: account_id), :target_account_id)
     end
 
+    def muting_map(target_account_ids, account_id)
+      follow_mapping(Mute.where(target_account_id: target_account_ids, account_id: account_id), :target_account_id)
+    end
+
     def requested_map(target_account_ids, account_id)
       follow_mapping(FollowRequest.where(target_account_id: target_account_ids, account_id: account_id), :target_account_id)
     end
diff --git a/app/models/media_attachment.rb b/app/models/media_attachment.rb
index 6925f9b0d..818190214 100644
--- a/app/models/media_attachment.rb
+++ b/app/models/media_attachment.rb
@@ -1,15 +1,32 @@
 # frozen_string_literal: true
 
 class MediaAttachment < ApplicationRecord
+  self.inheritance_column = nil
+
+  enum type: [:image, :gifv, :video]
+
   IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif'].freeze
   VIDEO_MIME_TYPES = ['video/webm', 'video/mp4'].freeze
 
+  IMAGE_STYLES = { original: '1280x1280>', small: '400x400>' }.freeze
+  VIDEO_STYLES = {
+    small: {
+      convert_options: {
+        output: {
+          vf: 'scale=\'min(400\, iw):min(400\, ih)\':force_original_aspect_ratio=decrease',
+        },
+      },
+      format: 'png',
+      time: 0,
+    },
+  }.freeze
+
   belongs_to :account, inverse_of: :media_attachments
   belongs_to :status,  inverse_of: :media_attachments
 
   has_attached_file :file,
-                    styles: -> (f) { file_styles f },
-                    processors: -> (f) { f.video? ? [:transcoder] : [:thumbnail] },
+                    styles: ->(f) { file_styles f },
+                    processors: ->(f) { file_processors f },
                     convert_options: { all: '-quality 90 -strip' }
   validates_attachment_content_type :file, content_type: IMAGE_MIME_TYPES + VIDEO_MIME_TYPES
   validates_attachment_size :file, less_than: 8.megabytes
@@ -27,45 +44,49 @@ class MediaAttachment < ApplicationRecord
     self.file = URI.parse(url)
   end
 
-  def image?
-    IMAGE_MIME_TYPES.include? file_content_type
-  end
-
-  def video?
-    VIDEO_MIME_TYPES.include? file_content_type
-  end
-
-  def type
-    image? ? 'image' : 'video'
-  end
-
   def to_param
     shortcode
   end
 
   before_create :set_shortcode
+  before_post_process :set_type
 
   class << self
     private
 
     def file_styles(f)
-      if f.instance.image?
+      if f.instance.file_content_type == 'image/gif'
         {
-          original: '1280x1280>',
-          small: '400x400>',
-        }
-      else
-        {
-          small: {
+          small: IMAGE_STYLES[:small],
+          original: {
+            format: 'mp4',
             convert_options: {
               output: {
-                vf: 'scale=\'min(400\, iw):min(400\, ih)\':force_original_aspect_ratio=decrease',
+                'movflags' => 'faststart',
+                'pix_fmt'  => 'yuv420p',
+                'vf'       => 'scale=\'trunc(iw/2)*2:trunc(ih/2)*2\'',
+                'vsync'    => 'cfr',
+                'b:v'      => '1300K',
+                'maxrate'  => '500K',
+                'crf'      => 6,
               },
             },
-            format: 'png',
-            time: 1,
           },
         }
+      elsif IMAGE_MIME_TYPES.include? f.instance.file_content_type
+        IMAGE_STYLES
+      else
+        VIDEO_STYLES
+      end
+    end
+
+    def file_processors(f)
+      if f.file_content_type == 'image/gif'
+        [:gif_transcoder]
+      elsif VIDEO_MIME_TYPES.include? f.file_content_type
+        [:video_transcoder]
+      else
+        [:thumbnail]
       end
     end
   end
@@ -80,4 +101,8 @@ class MediaAttachment < ApplicationRecord
       break if MediaAttachment.find_by(shortcode: shortcode).nil?
     end
   end
+
+  def set_type
+    self.type = VIDEO_MIME_TYPES.include?(file_content_type) ? :video : :image
+  end
 end
diff --git a/app/models/mute.rb b/app/models/mute.rb
new file mode 100644
index 000000000..a5b334c85
--- /dev/null
+++ b/app/models/mute.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+class Mute < ApplicationRecord
+  include Paginable
+
+  belongs_to :account
+  belongs_to :target_account, class_name: 'Account'
+
+  validates :account, :target_account, presence: true
+  validates :account_id, uniqueness: { scope: :target_account_id }
+end
diff --git a/app/models/setting.rb b/app/models/setting.rb
index 3796253d4..31e1ee198 100644
--- a/app/models/setting.rb
+++ b/app/models/setting.rb
@@ -2,7 +2,6 @@
 
 class Setting < RailsSettings::Base
   source Rails.root.join('config/settings.yml')
-  namespace Rails.env
 
   def to_param
     var
diff --git a/app/models/status.rb b/app/models/status.rb
index 1b40897f3..663ac1e34 100644
--- a/app/models/status.rb
+++ b/app/models/status.rb
@@ -37,6 +37,9 @@ class Status < ApplicationRecord
   scope :remote, -> { where.not(uri: nil) }
   scope :local, -> { where(uri: nil) }
 
+  scope :without_replies, -> { where('statuses.reply = FALSE OR statuses.in_reply_to_account_id = statuses.account_id') }
+  scope :without_reblogs, -> { where('statuses.reblog_of_id IS NULL') }
+
   cache_associated :account, :application, :media_attachments, :tags, :stream_entry, mentions: :account, reblog: [:account, :application, :stream_entry, :tags, :media_attachments, mentions: :account], thread: :account
 
   def reply?
@@ -109,8 +112,8 @@ class Status < ApplicationRecord
     def as_public_timeline(account = nil, local_only = false)
       query = joins('LEFT OUTER JOIN accounts ON statuses.account_id = accounts.id')
               .where(visibility: :public)
-              .where('(statuses.reply = false OR statuses.in_reply_to_account_id = statuses.account_id)')
-              .where('statuses.reblog_of_id IS NULL')
+              .without_replies
+              .without_reblogs
 
       query = query.where('accounts.domain IS NULL') if local_only
 
@@ -121,7 +124,7 @@ class Status < ApplicationRecord
       query = tag.statuses
                  .joins('LEFT OUTER JOIN accounts ON statuses.account_id = accounts.id')
                  .where(visibility: :public)
-                 .where('statuses.reblog_of_id IS NULL')
+                 .without_reblogs
 
       query = query.where('accounts.domain IS NULL') if local_only
 
@@ -168,9 +171,9 @@ class Status < ApplicationRecord
     private
 
     def filter_timeline(query, account)
-      blocked = Block.where(account: account).pluck(:target_account_id) + Block.where(target_account: account).pluck(:account_id)
-      query   = query.where('statuses.account_id NOT IN (?)', blocked) unless blocked.empty?
-      query   = query.where('accounts.silenced = TRUE') if account.silenced?
+      blocked = Block.where(account: account).pluck(:target_account_id) + Block.where(target_account: account).pluck(:account_id) + Mute.where(account: account).pluck(:target_account_id)
+      query   = query.where('statuses.account_id NOT IN (?)', blocked) unless blocked.empty?  # Only give us statuses from people we haven't blocked, or muted, or that have blocked us
+      query   = query.where('accounts.silenced = TRUE') if account.silenced?                  # and if we're hellbanned, only people who are also hellbanned
       query
     end
 
@@ -192,6 +195,6 @@ class Status < ApplicationRecord
   private
 
   def filter_from_context?(status, account)
-    account&.blocking?(status.account_id) || (status.account.silenced? && !account&.following?(status.account_id)) || !status.permitted?(account)
+    account&.blocking?(status.account_id) || account&.muting?(status.account_id) || (status.account.silenced? && !account&.following?(status.account_id)) || !status.permitted?(account)
   end
 end
diff --git a/app/models/tag.rb b/app/models/tag.rb
index 77a73cce8..0d2fe43b8 100644
--- a/app/models/tag.rb
+++ b/app/models/tag.rb
@@ -3,7 +3,7 @@
 class Tag < ApplicationRecord
   has_and_belongs_to_many :statuses
 
-  HASHTAG_RE = /(?:^|[^\/\w])#([[:word:]_]*[[:alpha:]_][[:word:]_]*)/i
+  HASHTAG_RE = /(?:^|[^\/\)\w])#([[:word:]_]*[[:alpha:]_][[:word:]_]*)/i
 
   validates :name, presence: true, uniqueness: true
 
diff --git a/app/models/user.rb b/app/models/user.rb
index 08aac2679..bf2916d90 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -14,9 +14,10 @@ class User < ApplicationRecord
   validates :locale, inclusion: I18n.available_locales.map(&:to_s), unless: 'locale.nil?'
   validates :email, email: true
 
-  scope :prolific, -> { joins('inner join statuses on statuses.account_id = users.account_id').select('users.*, count(statuses.id) as statuses_count').group('users.id').order('statuses_count desc') }
-  scope :recent,   -> { order('id desc') }
-  scope :admins,   -> { where(admin: true) }
+  scope :prolific,  -> { joins('inner join statuses on statuses.account_id = users.account_id').select('users.*, count(statuses.id) as statuses_count').group('users.id').order('statuses_count desc') }
+  scope :recent,    -> { order('id desc') }
+  scope :admins,    -> { where(admin: true) }
+  scope :confirmed, -> { where.not(confirmed_at: nil) }
 
   def send_devise_notification(notification, *args)
     devise_mailer.send(notification, self, *args).deliver_later
diff --git a/app/services/mute_service.rb b/app/services/mute_service.rb
new file mode 100644
index 000000000..0050cfc8d
--- /dev/null
+++ b/app/services/mute_service.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+class MuteService < BaseService
+  def call(account, target_account)
+    return if account.id == target_account.id
+    clear_home_timeline(account, target_account)
+    account.mute!(target_account)
+  end
+
+  private
+
+  def clear_home_timeline(account, target_account)
+    home_key = FeedManager.instance.key(:home, account.id)
+
+    target_account.statuses.select('id').find_each do |status|
+      redis.zrem(home_key, status.id)
+    end
+  end
+
+  def redis
+    Redis.current
+  end
+end
diff --git a/app/services/process_feed_service.rb b/app/services/process_feed_service.rb
index 5d952df6f..69911abc5 100644
--- a/app/services/process_feed_service.rb
+++ b/app/services/process_feed_service.rb
@@ -61,12 +61,25 @@ class ProcessFeedService < BaseService
 
       status.save!
 
-      NotifyService.new.call(status.reblog.account, status) if status.reblog? && status.reblog.account.local?
+      notify_about_mentions!(status) unless status.reblog?
+      notify_about_reblog!(status) if status.reblog? && status.reblog.account.local?
       Rails.logger.debug "Queuing remote status #{status.id} (#{id}) for distribution"
       DistributionWorker.perform_async(status.id)
       status
     end
 
+    def notify_about_mentions!(status)
+      status.mentions.includes(:account).each do |mention|
+        mentioned_account = mention.account
+        next unless mentioned_account.local?
+        NotifyService.new.call(mentioned_account, mention)
+      end
+    end
+
+    def notify_about_reblog!(status)
+      NotifyService.new.call(status.reblog.account, status)
+    end
+
     def delete_status
       Rails.logger.debug "Deleting remote status #{id}"
       status = Status.find_by(uri: id)
@@ -159,10 +172,7 @@ class ProcessFeedService < BaseService
 
         next if mentioned_account.nil? || processed_account_ids.include?(mentioned_account.id)
 
-        mention = mentioned_account.mentions.where(status: parent).first_or_create(status: parent)
-
-        # Notify local user
-        NotifyService.new.call(mentioned_account, mention) if mentioned_account.local?
+        mentioned_account.mentions.where(status: parent).first_or_create(status: parent)
 
         # So we can skip duplicate mentions
         processed_account_ids << mentioned_account.id
diff --git a/app/services/process_mentions_service.rb b/app/services/process_mentions_service.rb
index d3d3af8af..aa0a4d71b 100644
--- a/app/services/process_mentions_service.rb
+++ b/app/services/process_mentions_service.rb
@@ -27,7 +27,7 @@ class ProcessMentionsService < BaseService
       mentioned_account.mentions.where(status: status).first_or_create(status: status)
     end
 
-    status.mentions.each do |mention|
+    status.mentions.includes(:account).each do |mention|
       mentioned_account = mention.account
 
       if mentioned_account.local?
diff --git a/app/services/unmute_service.rb b/app/services/unmute_service.rb
new file mode 100644
index 000000000..6aeea358f
--- /dev/null
+++ b/app/services/unmute_service.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+class UnmuteService < BaseService
+  def call(account, target_account)
+    return unless account.muting?(target_account)
+
+    account.unmute!(target_account)
+
+    MergeWorker.perform_async(target_account.id, account.id) if account.following?(target_account)
+  end
+end
diff --git a/app/views/api/v1/accounts/relationship.rabl b/app/views/api/v1/accounts/relationship.rabl
index 22b37586e..d6f1dd48a 100644
--- a/app/views/api/v1/accounts/relationship.rabl
+++ b/app/views/api/v1/accounts/relationship.rabl
@@ -4,4 +4,5 @@ attribute :id
 node(:following)   { |account| @following[account.id]   || false }
 node(:followed_by) { |account| @followed_by[account.id] || false }
 node(:blocking)    { |account| @blocking[account.id]    || false }
+node(:muting)      { |account| @muting[account.id]      || false }
 node(:requested)   { |account| @requested[account.id]   || false }
diff --git a/app/views/api/v1/media/create.rabl b/app/views/api/v1/media/create.rabl
index 0b42e6e3d..916217cbd 100644
--- a/app/views/api/v1/media/create.rabl
+++ b/app/views/api/v1/media/create.rabl
@@ -1,5 +1,5 @@
 object @media
 attribute :id, :type
-node(:url) { |media| full_asset_url(media.file.url( :original)) }
-node(:preview_url) { |media| full_asset_url(media.file.url( :small)) }
+node(:url) { |media| full_asset_url(media.file.url(:original)) }
+node(:preview_url) { |media| full_asset_url(media.file.url(:small)) }
 node(:text_url) { |media| medium_url(media) }
diff --git a/app/views/api/v1/mutes/index.rabl b/app/views/api/v1/mutes/index.rabl
new file mode 100644
index 000000000..9f3b13a53
--- /dev/null
+++ b/app/views/api/v1/mutes/index.rabl
@@ -0,0 +1,2 @@
+collection @accounts
+extends 'api/v1/accounts/show'
diff --git a/app/views/layouts/mailer.text.erb b/app/views/layouts/mailer.text.erb
index ae52173b5..21bf444c3 100644
--- a/app/views/layouts/mailer.text.erb
+++ b/app/views/layouts/mailer.text.erb
@@ -1,5 +1,5 @@
 <%= yield %>
-
 ---
 
 <%= t('application_mailer.signature', instance: Rails.configuration.x.local_domain) %>
+<%= t('application_mailer.settings', link: settings_preferences_url) %>
diff --git a/app/views/notification_mailer/_status.text.erb b/app/views/notification_mailer/_status.text.erb
index b089a7b73..85a0136b7 100644
--- a/app/views/notification_mailer/_status.text.erb
+++ b/app/views/notification_mailer/_status.text.erb
@@ -1,3 +1,3 @@
-<%= strip_tags(@status.content) %>
+<%= raw Formatter.instance.plaintext(status) %>
 
-<%= web_url("statuses/#{@status.id}") %>
+<%= raw t('application_mailer.view')%> <%= web_url("statuses/#{status.id}") %>
diff --git a/app/views/notification_mailer/digest.text.erb b/app/views/notification_mailer/digest.text.erb
new file mode 100644
index 000000000..95aed6793
--- /dev/null
+++ b/app/views/notification_mailer/digest.text.erb
@@ -0,0 +1,15 @@
+<%= display_name(@me) %>,
+
+<%= raw t('notification_mailer.digest.body', since: @since, instance: root_url) %>
+<% @notifications.each do |notification| %>
+
+* <%= raw t('notification_mailer.digest.mention', name: notification.from_account.acct) %>
+
+  <%= raw Formatter.instance.plaintext(notification.target_status) %>
+
+  <%= raw t('application_mailer.view')%> <%= web_url("statuses/#{notification.target_status.id}") %>
+<% end %>
+<% if @follows_since > 0 %>
+
+<%= raw t('notification_mailer.digest.new_followers_summary', count: @follows_since) %>
+<% end %>
diff --git a/app/views/notification_mailer/favourite.text.erb b/app/views/notification_mailer/favourite.text.erb
index b2e1e3e9e..99852592f 100644
--- a/app/views/notification_mailer/favourite.text.erb
+++ b/app/views/notification_mailer/favourite.text.erb
@@ -1,5 +1,5 @@
 <%= display_name(@me) %>,
 
-<%= t('notification_mailer.favourite.body', name: @account.acct) %>
+<%= raw t('notification_mailer.favourite.body', name: @account.acct) %>
 
-<%= render partial: 'status' %>
+<%= render partial: 'status', locals: { status: @status } %>
diff --git a/app/views/notification_mailer/follow.text.erb b/app/views/notification_mailer/follow.text.erb
index 4b2ec142c..af41a3080 100644
--- a/app/views/notification_mailer/follow.text.erb
+++ b/app/views/notification_mailer/follow.text.erb
@@ -1,5 +1,5 @@
 <%= display_name(@me) %>,
 
-<%= t('notification_mailer.follow.body', name: @account.acct) %>
+<%= raw t('notification_mailer.follow.body', name: @account.acct) %>
 
-<%= web_url("accounts/#{@account.id}") %>
+<%= raw t('application_mailer.view')%> <%= web_url("accounts/#{@account.id}") %>
diff --git a/app/views/notification_mailer/follow_request.text.erb b/app/views/notification_mailer/follow_request.text.erb
index c0d38ec67..49087a575 100644
--- a/app/views/notification_mailer/follow_request.text.erb
+++ b/app/views/notification_mailer/follow_request.text.erb
@@ -1,5 +1,5 @@
 <%= display_name(@me) %>,
 
-<%= t('notification_mailer.follow_request.body', name: @account.acct) %>
+<%= raw t('notification_mailer.follow_request.body', name: @account.acct) %>
 
-<%= web_url("follow_requests") %>
+<%= raw t('application_mailer.view')%> <%= web_url("follow_requests") %>
diff --git a/app/views/notification_mailer/mention.text.erb b/app/views/notification_mailer/mention.text.erb
index 31a294bb9..c0d4be1d8 100644
--- a/app/views/notification_mailer/mention.text.erb
+++ b/app/views/notification_mailer/mention.text.erb
@@ -1,5 +1,5 @@
 <%= display_name(@me) %>,
 
-<%= t('notification_mailer.mention.body', name: @status.account.acct) %>
+<%= raw t('notification_mailer.mention.body', name: @status.account.acct) %>
 
-<%= render partial: 'status' %>
+<%= render partial: 'status', locals: { status: @status } %>
diff --git a/app/views/notification_mailer/reblog.text.erb b/app/views/notification_mailer/reblog.text.erb
index 7af8052ca..c32b48650 100644
--- a/app/views/notification_mailer/reblog.text.erb
+++ b/app/views/notification_mailer/reblog.text.erb
@@ -1,5 +1,5 @@
 <%= display_name(@me) %>,
 
-<%= t('notification_mailer.reblog.body', name: @account.acct) %>
+<%= raw t('notification_mailer.reblog.body', name: @account.acct) %>
 
-<%= render partial: 'status' %>
+<%= render partial: 'status', locals: { status: @status } %>
diff --git a/app/views/settings/preferences/show.html.haml b/app/views/settings/preferences/show.html.haml
index aee0540d2..a17279b1e 100644
--- a/app/views/settings/preferences/show.html.haml
+++ b/app/views/settings/preferences/show.html.haml
@@ -16,6 +16,7 @@
       = ff.input :reblog, as: :boolean, wrapper: :with_label
       = ff.input :favourite, as: :boolean, wrapper: :with_label
       = ff.input :mention, as: :boolean, wrapper: :with_label
+      = ff.input :digest, as: :boolean, wrapper: :with_label
 
   = f.simple_fields_for :interactions, hash_to_object(current_user.settings.interactions) do |ff|
     = ff.input :must_be_follower, as: :boolean, wrapper: :with_label
diff --git a/app/views/stream_entries/_detailed_status.html.haml b/app/views/stream_entries/_detailed_status.html.haml
index 235dc6086..8c0456b1f 100644
--- a/app/views/stream_entries/_detailed_status.html.haml
+++ b/app/views/stream_entries/_detailed_status.html.haml
@@ -10,7 +10,7 @@
   .status__content.e-content.p-name.emojify<
     - unless status.spoiler_text.blank?
       %p= status.spoiler_text
-    = Formatter.instance.format(status)
+    %div{ style: "direction: #{rtl?(status.content) ? 'rtl' : 'ltr'}" }= Formatter.instance.format(status)
 
   - unless status.media_attachments.empty?
     - if status.media_attachments.first.video?
@@ -22,9 +22,9 @@
       .detailed-status__attachments
         - if status.sensitive?
           = render partial: 'stream_entries/content_spoiler'
-        - status.media_attachments.each do |media|
-          .media-item
-            = link_to '', (media.remote_url.blank? ? media.file.url(:original) : media.remote_url), style: "background-image: url(#{media.file.url(:original)})", target: '_blank', rel: 'noopener', class: "u-#{media.video? ? 'video' : 'photo'}"
+        .status__attachments__inner
+          - status.media_attachments.each do |media|
+            = render partial: 'stream_entries/media', locals: { media: media }
 
   %div.detailed-status__meta
     %data.dt-published{ value: status.created_at.to_time.iso8601 }
diff --git a/app/views/stream_entries/_media.html.haml b/app/views/stream_entries/_media.html.haml
new file mode 100644
index 000000000..cd7faa700
--- /dev/null
+++ b/app/views/stream_entries/_media.html.haml
@@ -0,0 +1,4 @@
+.media-item
+  = link_to media.remote_url.blank? ? media.file.url(:original) : media.remote_url, style: media.image? ? "background-image: url(#{media.file.url(:original)})" : "", target: '_blank', rel: 'noopener', class: "u-#{media.video? || media.gifv? ? 'video' : 'photo'}" do
+    - unless media.image?
+      %video{ src: media.file.url(:original), autoplay: true, loop: true }/
diff --git a/app/views/stream_entries/_simple_status.html.haml b/app/views/stream_entries/_simple_status.html.haml
index 95f90abd9..cb2c976ce 100644
--- a/app/views/stream_entries/_simple_status.html.haml
+++ b/app/views/stream_entries/_simple_status.html.haml
@@ -15,18 +15,19 @@
   .status__content.e-content.p-name.emojify<
     - unless status.spoiler_text.blank?
       %p= status.spoiler_text
-    = Formatter.instance.format(status)
+    %div{ style: "direction: #{rtl?(status.content) ? 'rtl' : 'ltr'}" }= Formatter.instance.format(status)
 
   - unless status.media_attachments.empty?
     .status__attachments
       - if status.sensitive?
         = render partial: 'stream_entries/content_spoiler'
       - if status.media_attachments.first.video?
-        .video-item
-          = link_to (status.media_attachments.first.remote_url.blank? ? status.media_attachments.first.file.url(:original) : status.media_attachments.first.remote_url), style: "background-image: url(#{status.media_attachments.first.file.url(:small)})", target: '_blank', rel: 'noopener', class: 'u-video' do
-            .video-item__play
-              = fa_icon('play')
+        .status__attachments__inner
+          .video-item
+            = link_to (status.media_attachments.first.remote_url.blank? ? status.media_attachments.first.file.url(:original) : status.media_attachments.first.remote_url), style: "background-image: url(#{status.media_attachments.first.file.url(:small)})", target: '_blank', rel: 'noopener', class: 'u-video' do
+              .video-item__play
+                = fa_icon('play')
       - else
-        - status.media_attachments.each do |media|
-          .media-item
-            = link_to '', (media.remote_url.blank? ? media.file.url(:original) : media.remote_url), style: "background-image: url(#{media.file.url(:original)})", target: '_blank', rel: 'noopener', class: "u-#{media.video? ? 'video' : 'photo'}"
+        .status__attachments__inner
+          - status.media_attachments.each do |media|
+            = render partial: 'stream_entries/media', locals: { media: media }
diff --git a/app/workers/digest_mailer_worker.rb b/app/workers/digest_mailer_worker.rb
new file mode 100644
index 000000000..dedb21e4e
--- /dev/null
+++ b/app/workers/digest_mailer_worker.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+class DigestMailerWorker
+  include Sidekiq::Worker
+
+  sidekiq_options queue: 'mailers'
+
+  def perform(user_id)
+    user = User.find(user_id)
+    return unless user.settings.notification_emails['digest']
+    NotificationMailer.digest(user.account).deliver_now!
+    user.touch(:last_emailed_at)
+  end
+end
diff --git a/config/application.rb b/config/application.rb
index 8da5ade3c..cb009b24c 100644
--- a/config/application.rb
+++ b/config/application.rb
@@ -2,12 +2,14 @@ require_relative 'boot'
 
 require 'rails/all'
 
-require_relative '../app/lib/exceptions'
-
 # Require the gems listed in Gemfile, including any gems
 # you've limited to :test, :development, or :production.
 Bundler.require(*Rails.groups)
 
+require_relative '../app/lib/exceptions'
+require_relative '../lib/paperclip/gif_transcoder'
+require_relative '../lib/paperclip/video_transcoder'
+
 Dotenv::Railtie.load
 
 module Mastodon
@@ -49,12 +51,5 @@ module Mastodon
       Doorkeeper::AuthorizedApplicationsController.layout 'admin'
       Doorkeeper::Application.send :include, ApplicationExtension
     end
-
-    config.action_dispatch.default_headers = {
-      'Server'                 => 'Mastodon',
-      'X-Frame-Options'        => 'DENY',
-      'X-Content-Type-Options' => 'nosniff',
-      'X-XSS-Protection'       => '1; mode=block',
-    }
   end
 end
diff --git a/config/environments/production.rb b/config/environments/production.rb
index 67ff63914..dc5dd4afd 100644
--- a/config/environments/production.rb
+++ b/config/environments/production.rb
@@ -109,4 +109,11 @@ Rails.application.configure do
   config.to_prepare do
     StatsD.backend = StatsD::Instrument::Backends::NullBackend.new if ENV['STATSD_ADDR'].blank?
   end
+
+  config.action_dispatch.default_headers = {
+    'Server'                 => 'Mastodon',
+    'X-Frame-Options'        => 'DENY',
+    'X-Content-Type-Options' => 'nosniff',
+    'X-XSS-Protection'       => '1; mode=block',
+  }
 end
diff --git a/config/initializers/paperclip.rb b/config/initializers/paperclip.rb
index 71a7b514e..580a3196e 100644
--- a/config/initializers/paperclip.rb
+++ b/config/initializers/paperclip.rb
@@ -2,6 +2,11 @@
 
 Paperclip.options[:read_timeout] = 60
 
+Paperclip.interpolates :filename do |attachment, style|
+  return attachment.original_filename if style == :original
+  [basename(attachment, style), extension(attachment, style)].delete_if(&:empty?).join('.')
+end
+
 if ENV['S3_ENABLED'] == 'true'
   Aws.eager_autoload!(services: %w(S3))
 
diff --git a/config/initializers/rabl_init.rb b/config/initializers/rabl_init.rb
index 325bf0c78..f7be0c607 100644
--- a/config/initializers/rabl_init.rb
+++ b/config/initializers/rabl_init.rb
@@ -1,6 +1,6 @@
 Rabl.configure do |config|
   config.cache_all_output  = false
-  config.cache_sources     = !!Rails.env.production?
+  config.cache_sources     = Rails.env.production?
   config.include_json_root = false
   config.view_paths        = [Rails.root.join('app/views')]
 end
diff --git a/config/initializers/rack-attack.rb b/config/initializers/rack-attack.rb
index 3f0ee1d7a..70f7846d1 100644
--- a/config/initializers/rack-attack.rb
+++ b/config/initializers/rack-attack.rb
@@ -1,6 +1,6 @@
 class Rack::Attack
   # Rate limits for the API
-  throttle('api', limit: 150, period: 5.minutes) do |req|
+  throttle('api', limit: 300, period: 5.minutes) do |req|
     req.ip if req.path.match(/\A\/api\/v/)
   end
 
@@ -11,7 +11,7 @@ class Rack::Attack
     headers = {
       'X-RateLimit-Limit'     => match_data[:limit].to_s,
       'X-RateLimit-Remaining' => '0',
-      'X-RateLimit-Reset'     => (now + (match_data[:period] - now.to_i % match_data[:period])).iso8601(6)
+      'X-RateLimit-Reset'     => (now + (match_data[:period] - now.to_i % match_data[:period])).iso8601(6),
     }
 
     [429, headers, [{ error: 'Throttled' }.to_json]]
diff --git a/config/locales/en.yml b/config/locales/en.yml
index 6da30acda..f11a689e4 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -29,6 +29,8 @@ en:
     unfollow: Unfollow
   application_mailer:
     signature: Mastodon notifications from %{instance}
+    settings: 'Change e-mail preferences: %{link}'
+    view: 'View:'
   applications:
     invalid_url: The provided URL is invalid
   auth:
@@ -83,6 +85,15 @@ en:
     reblog:
       body: 'Your status was boosted by %{name}:'
       subject: "%{name} boosted your status"
+    digest:
+      subject:
+        one: "1 new notification since your last visit 🐘"
+        other: "%{count} new notifications since your last visit 🐘"
+      body: 'Here is a brief summary of what you missed on %{instance} since your last visit on %{since}:'
+      mention: "%{name} mentioned you in:"
+      new_followers_summary:
+        one: You have acquired one new follower! Yay!
+        other: You have gotten %{count} new followers! Amazing!
   pagination:
     next: Next
     prev: Prev
diff --git a/config/locales/simple_form.en.yml b/config/locales/simple_form.en.yml
index 4d1758f82..170af01cf 100644
--- a/config/locales/simple_form.en.yml
+++ b/config/locales/simple_form.en.yml
@@ -34,6 +34,7 @@ en:
         follow_request: Send e-mail when someone requests to follow you
         mention: Send e-mail when someone mentions you
         reblog: Send e-mail when someone reblogs your status
+        digest: Send digest e-mails
     'no': 'No'
     required:
       mark: "*"
diff --git a/config/routes.rb b/config/routes.rb
index 4595b4ba3..1a2e3c19d 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -127,6 +127,7 @@ Rails.application.routes.draw do
       resources :media,      only: [:create]
       resources :apps,       only: [:create]
       resources :blocks,     only: [:index]
+      resources :mutes,      only: [:index]
       resources :favourites, only: [:index]
       resources :reports,    only: [:index, :create]
       resources :site,       only: [:index]
@@ -153,7 +154,6 @@ Rails.application.routes.draw do
 
         member do
           get :statuses
-          get 'statuses/media', to: 'accounts#media_statuses', as: :media_statuses
           get :followers
           get :following
 
@@ -161,6 +161,8 @@ Rails.application.routes.draw do
           post :unfollow
           post :block
           post :unblock
+          post :mute
+          post :unmute
         end
       end
     end
@@ -178,5 +180,8 @@ Rails.application.routes.draw do
 
   root 'home#index'
 
+  get '/:username', to: redirect('/users/%{username}')
+  get '/:username/:id', to: redirect('/users/%{username}/updates/%{id}')
+
   match '*unmatched_route', via: :all, to: 'application#raise_not_found'
 end
diff --git a/config/settings.yml b/config/settings.yml
index 71ce12e63..6ae9217a4 100644
--- a/config/settings.yml
+++ b/config/settings.yml
@@ -11,6 +11,7 @@ defaults: &defaults
     favourite: false
     mention: false
     follow_request: true
+    digest: true
   interactions:
     must_be_follower: false
     must_be_following: false
diff --git a/db/migrate/20170301222600_create_mutes.rb b/db/migrate/20170301222600_create_mutes.rb
new file mode 100644
index 000000000..8f1bb22f5
--- /dev/null
+++ b/db/migrate/20170301222600_create_mutes.rb
@@ -0,0 +1,12 @@
+class CreateMutes < ActiveRecord::Migration[5.0]
+  def change
+    create_table :mutes do |t|
+      t.integer :account_id, null: false
+      t.integer :target_account_id, null: false
+      t.timestamps null: false
+    end
+
+    add_index :mutes, [:account_id, :target_account_id], unique: true
+
+  end
+end
diff --git a/db/migrate/20170303212857_add_last_emailed_at_to_users.rb b/db/migrate/20170303212857_add_last_emailed_at_to_users.rb
new file mode 100644
index 000000000..9ae3da4fb
--- /dev/null
+++ b/db/migrate/20170303212857_add_last_emailed_at_to_users.rb
@@ -0,0 +1,5 @@
+class AddLastEmailedAtToUsers < ActiveRecord::Migration[5.0]
+  def change
+    add_column :users, :last_emailed_at, :datetime, null: true, default: nil
+  end
+end
diff --git a/db/migrate/20170304202101_add_type_to_media_attachments.rb b/db/migrate/20170304202101_add_type_to_media_attachments.rb
new file mode 100644
index 000000000..514079958
--- /dev/null
+++ b/db/migrate/20170304202101_add_type_to_media_attachments.rb
@@ -0,0 +1,12 @@
+class AddTypeToMediaAttachments < ActiveRecord::Migration[5.0]
+  def up
+    add_column :media_attachments, :type, :integer, default: 0, null: false
+
+    MediaAttachment.where(file_content_type: MediaAttachment::IMAGE_MIME_TYPES).update_all(type: MediaAttachment.types[:image])
+    MediaAttachment.where(file_content_type: MediaAttachment::VIDEO_MIME_TYPES).update_all(type: MediaAttachment.types[:video])
+  end
+
+  def down
+    remove_column :media_attachments, :type
+  end
+end
diff --git a/db/schema.rb b/db/schema.rb
index fa5c40774..4ec85ef2b 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
 #
 # It's strongly recommended that you check this file into your version control system.
 
-ActiveRecord::Schema.define(version: 20170217012631) do
+ActiveRecord::Schema.define(version: 20170304202101) do
 
   # These are extensions that must be enabled in order to support this database
   enable_extension "plpgsql"
@@ -98,6 +98,7 @@ ActiveRecord::Schema.define(version: 20170217012631) do
     t.datetime "created_at",                     null: false
     t.datetime "updated_at",                     null: false
     t.string   "shortcode"
+    t.integer  "type",              default: 0,  null: false
     t.index ["shortcode"], name: "index_media_attachments_on_shortcode", unique: true, using: :btree
     t.index ["status_id"], name: "index_media_attachments_on_status_id", using: :btree
   end
@@ -110,6 +111,14 @@ ActiveRecord::Schema.define(version: 20170217012631) do
     t.index ["account_id", "status_id"], name: "index_mentions_on_account_id_and_status_id", unique: true, using: :btree
   end
 
+  create_table "mutes", force: :cascade do |t|
+    t.integer  "account_id",        null: false
+    t.integer  "target_account_id", null: false
+    t.datetime "created_at",        null: false
+    t.datetime "updated_at",        null: false
+    t.index ["account_id", "target_account_id"], name: "index_mutes_on_account_id_and_target_account_id", unique: true, using: :btree
+  end
+
   create_table "notifications", force: :cascade do |t|
     t.integer  "account_id"
     t.integer  "activity_id"
@@ -275,6 +284,7 @@ ActiveRecord::Schema.define(version: 20170217012631) do
     t.string   "encrypted_otp_secret_salt"
     t.integer  "consumed_timestep"
     t.boolean  "otp_required_for_login"
+    t.datetime "last_emailed_at"
     t.index ["account_id"], name: "index_users_on_account_id", using: :btree
     t.index ["confirmation_token"], name: "index_users_on_confirmation_token", unique: true, using: :btree
     t.index ["email"], name: "index_users_on_email", unique: true, using: :btree
diff --git a/docs/Contributing-to-Mastodon/Sponsors.md b/docs/Contributing-to-Mastodon/Sponsors.md
index 3fee6e1e0..475791684 100644
--- a/docs/Contributing-to-Mastodon/Sponsors.md
+++ b/docs/Contributing-to-Mastodon/Sponsors.md
@@ -6,23 +6,16 @@ These people make the development of Mastodon possible through [Patreon](https:/
 **Extra special Patrons**
 
 - [World'sTallestLadder](https://mastodon.social/users/carcinoGeneticist)
-- [glocal](https://mastodon.social/users/glocal)
 - [Jimmy Tidey](https://mastodon.social/users/jimmytidey)
 - [Kurtis Rainbolt-Greene](https://mastodon.social/users/krainboltgreene)
 - [Kit Redgrave](https://socially.constructed.space/users/KitRedgrave)
-- [Zeiphner](https://mastodon.social/users/Zeipher)
+- [Zeipher](https://mastodon.social/users/Zeipher)
 - [Effy Elden](https://toot.zone/users/effy)
 - [Zoë Quinn](https://mastodon.social/users/zoequinn)
 
 **Thank you to the following people**
 
-- [Sophia Park](https://mastodon.social/users/sophia)
-- [WelshPixie](https://mastodon.social/users/WelshPixie)
-- [John Parker](https://mastodon.social/users/Middaparka)
-- [Christina Hendricks](https://mastodon.social/users/clhendricksbc)
-- [Jelle](http://jelv.nl)
 - [Harris Bomberguy](https://mastodon.social/users/Hbomberguy)
-- [Martin Tithonium](https://mastodon.social/users/tithonium)
 - [Edward Saperia](https://nwspk.com)
 - [Yoz Grahame](http://yoz.com/)
 - [Jenn Kaplan](https://gay.crime.team/users/jkap)
@@ -33,5 +26,21 @@ These people make the development of Mastodon possible through [Patreon](https:/
 - [Niels Roesen Abildgaard](http://hypesystem.dk/)
 - [Zatnosk](https://github.com/Zatnosk)
 - [Spex Bluefox](https://mastodon.social/users/Spex)
-- [Sam Waldie](https://mastodon.social/users/denjin)
 - [J. C. Holder](http://jcholder.com/)
+- [glocal](https://mastodon.social/users/glocal)
+- [jk](https://mastodon.social/users/jk)
+- [C418](https://mastodon.social/users/C418)
+- [halcy](https://icosahedron.website/users/halcy)
+- [Extropic](https://gnusocial.no/extropic)
+- [Pat Monaghan](http://iwrite.software/)
+- TBD
+- TBD
+- TBD
+- TBD
+- TBD
+- TBD
+- TBD
+- TBD
+- TBD
+- TBD
+- TBD
diff --git a/docs/Using-Mastodon/Apps.md b/docs/Using-Mastodon/Apps.md
index e350e5f95..62d48c5ec 100644
--- a/docs/Using-Mastodon/Apps.md
+++ b/docs/Using-Mastodon/Apps.md
@@ -5,11 +5,13 @@ Some people have started working on apps for the Mastodon API. Here is a list of
 
 |App|Platform|Link|Developer(s)|
 |---|--------|----|------------|
-|Matodor|iOS/Android|<https://github.com/jeroensmeets/mastodon-app>|[@jeroensmeets@mastodon.social](https://mastodon.social/users/jeroensmeets)|
 |Tusky|Android|<https://github.com/Vavassor/Tusky>|[@Vavassor@mastodon.social](https://mastodon.social/users/Vavassor)|
-|Albatross|iOS||[@goldie_ice@mastodon.social](https://mastodon.social/users/goldie_ice)|
-|tootstream|command-line|<https://github.com/magicalraccoon/tootstream>|[@Raccoon@mastodon.social](https://mastodon.social/users/Raccoon)|
-|mastodroid|Android|<https://github.com/alin-rautoiu/mastodroid>||
-|Tooter|Chrome extension|<https://github.com/ineffyble/tooter>|[@effy@mastodon.social](https://mastodon.social/users/effy)|
+|mastodroid|Android|<https://github.com/alin-rautoiu/mastodroid>|[@charlag@mastodon.social](https://mastodon.social/users/charlag)|
 |TootyFruity|Android|<https://play.google.com/store/apps/details?id=ch.kevinegli.tootyfruity221258>|[@eggplant@mastodon.social](https://mastodon.social/users/eggplant)|
+|Matodor|iOS/Android|<https://github.com/jeroensmeets/mastodon-app>|[@jeroensmeets@mastodon.social](https://mastodon.social/users/jeroensmeets)|
+|Amarok|iOS|<https://itunes.apple.com/us/app/amarok-for-mastodon/id1214116200?ls=1&mt=8>|[@eurasierboy@mastodon.social](https://mastodon.social/users/eurasierboy)|
+|Albatross|iOS||[@goldie_ice@mastodon.social](https://mastodon.social/users/goldie_ice)|
+|Tooter|Chrome|<https://github.com/ineffyble/tooter>|[@effy@mastodon.social](https://mastodon.social/users/effy)|
+|tootstream|CLI|<https://github.com/magicalraccoon/tootstream>|[@Raccoon@mastodon.social](https://mastodon.social/users/Raccoon)|
+
 If you have a project like this, let me know so I can add it to the list!
diff --git a/docs/Using-Mastodon/List-of-Mastodon-instances.md b/docs/Using-Mastodon/List-of-Mastodon-instances.md
index ed3c74294..ef3c835de 100644
--- a/docs/Using-Mastodon/List-of-Mastodon-instances.md
+++ b/docs/Using-Mastodon/List-of-Mastodon-instances.md
@@ -11,8 +11,9 @@ List of Known Mastodon instances
 | [epiktistes.com](https://epiktistes.com) |N/A|Yes|
 | [on.vu](https://on.vu) | Appears defunct|No|
 | [gay.crime.team](https://gay.crime.team) |N/A|Yes(?)|
-| [gnusocial.me](https://gnusocial.me) |Yes, it's a mastodon instance now|Yes|
 | [icosahedron.website](https://icosahedron.website/) |Icosahedron-themed (well, visually), open registration.|Yes|
 | [memetastic.space](https://memetastic.space) |Memes|Yes|
+| [social.diskseven.com](https://social.diskseven.com) |Single user|No|
+| [social.gestaltzerfall.net](https://social.gestaltzerfall.net) |Single user|No|
 
 Let me know if you start running one so I can add it to the list! (Alternatively, add it yourself as a pull request).
diff --git a/docs/Using-the-API/API.md b/docs/Using-the-API/API.md
index 8dcf2a842..2c323d559 100644
--- a/docs/Using-the-API/API.md
+++ b/docs/Using-the-API/API.md
@@ -76,6 +76,10 @@ Query parameters:
 - `max_id` (optional): Skip statuses younger than ID (e.g. navigate backwards in time)
 - `since_id` (optional): Skip statuses older than ID (e.g. check for updates)
 
+Query parameters for public and tag timelines only:
+
+- `local` (optional): Only return statuses originating from this instance
+
 ### Notifications
 
 **GET /api/v1/notifications**
@@ -116,7 +120,14 @@ Returns authenticated user's account.
 
 **GET /api/v1/accounts/:id/statuses**
 
-Returns statuses by user. Same options as timeline are permitted.
+Returns statuses by user.
+
+Query parameters:
+
+- `max_id` (optional): Skip statuses younger than ID (e.g. navigate backwards in time)
+- `since_id` (optional): Skip statuses older than ID (e.g. check for updates)
+- `only_media` (optional): Only return statuses that have media attachments
+- `exclude_replies` (optional): Skip statuses that reply to other statuses
 
 **GET /api/v1/accounts/:id/following**
 
@@ -128,7 +139,7 @@ Returns users the given user is followed by.
 
 **GET /api/v1/accounts/relationships**
 
-Returns relationships (`following`, `followed_by`, `blocking`) of the current user to a list of given accounts.
+Returns relationships (`following`, `followed_by`, `blocking`, `muting`, `requested`) of the current user to a list of given accounts.
 
 Query parameters:
 
@@ -147,6 +158,14 @@ Query parameters:
 
 Returns accounts blocked by authenticated user.
 
+**GET /api/v1/mutes**
+
+Returns accounts muted by authenticated user.
+
+**GET /api/v1/follow_requests**
+
+Returns accounts that want to follow the authenticated user but are waiting for approval.
+
 **GET /api/v1/favourites**
 
 Returns statuses favourited by authenticated user.
@@ -215,6 +234,13 @@ Returns the updated relationship to the user.
 Returns an object containing the `title`, character limit (`max_chars`), and an object of `links` for the site.
 Does not require authentication.
 
+# Muting and unmuting users
+
+**POST /api/v1/accounts/:id/mute**
+**POST /api/v1/accounts/:id/unmute**
+
+Returns the updated relationship to the user.
+
 ### OAuth apps
 
 **POST /api/v1/apps**
diff --git a/docs/Using-the-API/Push-notifications.md b/docs/Using-the-API/Push-notifications.md
index d98c8833a..fc373e723 100644
--- a/docs/Using-the-API/Push-notifications.md
+++ b/docs/Using-the-API/Push-notifications.md
@@ -1,4 +1,4 @@
 Push notifications
 ==================
 
-**Note: This push notification design turned out to not be fully operational on the side of Firebase. A different approach is in consideration**
+See <https://github.com/Gargron/tusky-api> for an example of how to create push notifications for a mobile app. It involves using the Mastodon streaming API on behalf of the app's users, as a sort of proxy.
diff --git a/lib/paperclip/gif_transcoder.rb b/lib/paperclip/gif_transcoder.rb
new file mode 100644
index 000000000..8337448b2
--- /dev/null
+++ b/lib/paperclip/gif_transcoder.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+module Paperclip
+  # This transcoder is only to be used for the MediaAttachment model
+  # to convert animated gifs to webm
+  class GifTranscoder < Paperclip::Processor
+    def make
+      num_frames = identify('-format %n :file', file: file.path).to_i
+
+      return file unless options[:style] == :original && num_frames > 1
+
+      final_file = Paperclip::Transcoder.make(file, options, attachment)
+
+      attachment.instance.file_file_name    = 'media.mp4'
+      attachment.instance.file_content_type = 'video/mp4'
+      attachment.instance.type              = MediaAttachment.types[:gifv]
+
+      final_file
+    end
+  end
+end
diff --git a/lib/paperclip/video_transcoder.rb b/lib/paperclip/video_transcoder.rb
new file mode 100644
index 000000000..c3504c17c
--- /dev/null
+++ b/lib/paperclip/video_transcoder.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+module Paperclip
+  # This transcoder is only to be used for the MediaAttachment model
+  # to check when uploaded videos are actually gifv's
+  class VideoTranscoder < Paperclip::Processor
+    def make
+      meta = ::Av.cli.identify(@file.path)
+      attachment.instance.type = MediaAttachment.types[:gifv] unless meta[:audio_encode]
+
+      Paperclip::Transcoder.make(file, options, attachment)
+    end
+  end
+end
diff --git a/lib/tasks/mastodon.rake b/lib/tasks/mastodon.rake
index 8482d4124..bb10410b5 100644
--- a/lib/tasks/mastodon.rake
+++ b/lib/tasks/mastodon.rake
@@ -43,7 +43,7 @@ namespace :mastodon do
   namespace :feeds do
     desc 'Clear timelines of inactive users'
     task clear: :environment do
-      User.where('current_sign_in_at < ?', 14.days.ago).find_each do |user|
+      User.confirmed.where('current_sign_in_at < ?', 14.days.ago).find_each do |user|
         Redis.current.del(FeedManager.instance.key(:home, user.account_id))
       end
     end
@@ -53,4 +53,13 @@ namespace :mastodon do
       Redis.current.keys('feed:*').each { |key| Redis.current.del(key) }
     end
   end
+
+  namespace :emails do
+    desc 'Send out digest e-mails'
+    task digest: :environment do
+      User.confirmed.joins(:account).where(accounts: { silenced: false, suspended: false }).where('current_sign_in_at < ?', 20.days.ago).find_each do |user|
+        DigestMailerWorker.perform_async(user.id)
+      end
+    end
+  end
 end
diff --git a/package.json b/package.json
index 45702d5f4..35ce56ee5 100644
--- a/package.json
+++ b/package.json
@@ -24,6 +24,7 @@
     "css-loader": "^0.26.2",
     "dotenv": "^4.0.0",
     "emojione": "latest",
+    "emojione-picker": "^2.0.1",
     "enzyme": "^2.7.1",
     "es6-promise": "^3.2.1",
     "escape-html": "^1.0.3",
@@ -40,6 +41,7 @@
     "react": "^15.4.2",
     "react-addons-perf": "^15.4.2",
     "react-addons-pure-render-mixin": "^15.4.2",
+    "react-addons-shallow-compare": "^15.4.2",
     "react-addons-test-utils": "^15.4.2",
     "react-autosuggest": "^7.0.1",
     "react-decoration": "^1.4.0",
@@ -60,7 +62,6 @@
     "redis": "^2.6.5",
     "redux": "^3.6.0",
     "redux-immutable": "^3.1.0",
-    "redux-sounds": "^1.1.1",
     "redux-thunk": "^2.2.0",
     "reselect": "^2.5.4",
     "sass-loader": "^6.0.2",
diff --git a/spec/controllers/api/v1/accounts_controller_spec.rb b/spec/controllers/api/v1/accounts_controller_spec.rb
index 98b284f7a..5d36b0159 100644
--- a/spec/controllers/api/v1/accounts_controller_spec.rb
+++ b/spec/controllers/api/v1/accounts_controller_spec.rb
@@ -116,6 +116,44 @@ RSpec.describe Api::V1::AccountsController, type: :controller do
     end
   end
 
+  describe 'POST #mute' do
+    let(:other_account) { Fabricate(:user, email: 'bob@example.com', account: Fabricate(:account, username: 'bob')).account }
+
+    before do
+      user.account.follow!(other_account)
+      post :mute, params: {id: other_account.id }
+    end
+
+    it 'returns http success' do
+      expect(response).to have_http_status(:success)
+    end
+
+    it 'does not remove the following relation between user and target user' do
+      expect(user.account.following?(other_account)).to be true
+    end
+
+    it 'creates a muting relation' do
+      expect(user.account.muting?(other_account)).to be true
+    end
+  end
+
+  describe 'POST #unmute' do
+    let(:other_account) { Fabricate(:user, email: 'bob@example.com', account: Fabricate(:account, username: 'bob')).account }
+
+    before do
+      user.account.mute!(other_account)
+      post :unmute, params: { id: other_account.id }
+    end
+
+    it 'returns http success' do
+      expect(response).to have_http_status(:success)
+    end
+
+    it 'removes the muting relation between user and target user' do
+      expect(user.account.muting?(other_account)).to be false
+    end
+  end
+
   describe 'GET #relationships' do
     let(:simon) { Fabricate(:user, email: 'simon@example.com', account: Fabricate(:account, username: 'simon')).account }
     let(:lewis) { Fabricate(:user, email: 'lewis@example.com', account: Fabricate(:account, username: 'lewis')).account }
diff --git a/spec/controllers/api/v1/mutes_controller_spec.rb b/spec/controllers/api/v1/mutes_controller_spec.rb
new file mode 100644
index 000000000..be8a5e7dd
--- /dev/null
+++ b/spec/controllers/api/v1/mutes_controller_spec.rb
@@ -0,0 +1,19 @@
+require 'rails_helper'
+
+RSpec.describe Api::V1::MutesController, type: :controller do
+  render_views
+
+  let(:user)  { Fabricate(:user, account: Fabricate(:account, username: 'alice')) }
+  let(:token) { double acceptable?: true, resource_owner_id: user.id }
+
+  before do
+    allow(controller).to receive(:doorkeeper_token) { token }
+  end
+
+  describe 'GET #index' do
+    it 'returns http success' do
+      get :index
+      expect(response).to have_http_status(:success)
+    end
+  end
+end
diff --git a/spec/fabricators/mute_fabricator.rb b/spec/fabricators/mute_fabricator.rb
new file mode 100644
index 000000000..fc150c1d6
--- /dev/null
+++ b/spec/fabricators/mute_fabricator.rb
@@ -0,0 +1,3 @@
+Fabricator(:mute) do
+
+end
diff --git a/spec/mailers/previews/notification_mailer_preview.rb b/spec/mailers/previews/notification_mailer_preview.rb
index 8fc8d0d34..a08a80d17 100644
--- a/spec/mailers/previews/notification_mailer_preview.rb
+++ b/spec/mailers/previews/notification_mailer_preview.rb
@@ -1,24 +1,31 @@
 # Preview all emails at http://localhost:3000/rails/mailers/notification_mailer
 class NotificationMailerPreview < ActionMailer::Preview
-
   # Preview this email at http://localhost:3000/rails/mailers/notification_mailer/mention
   def mention
-    # NotificationMailer.mention
+    m = Mention.last
+    NotificationMailer.mention(m.account, Notification.find_by(activity: m))
   end
 
   # Preview this email at http://localhost:3000/rails/mailers/notification_mailer/follow
   def follow
-    # NotificationMailer.follow
+    f = Follow.last
+    NotificationMailer.follow(f.target_account, Notification.find_by(activity: f))
   end
 
   # Preview this email at http://localhost:3000/rails/mailers/notification_mailer/favourite
   def favourite
-    # NotificationMailer.favourite
+    f = Favourite.last
+    NotificationMailer.favourite(f.status.account, Notification.find_by(activity: f))
   end
 
   # Preview this email at http://localhost:3000/rails/mailers/notification_mailer/reblog
   def reblog
-    # NotificationMailer.reblog
+    r = Status.where.not(reblog_of_id: nil).first
+    NotificationMailer.reblog(r.reblog.account, Notification.find_by(activity: r))
   end
 
+  # Preview this email at http://localhost:3000/rails/mailers/notification_mailer/digest
+  def digest
+    NotificationMailer.digest(Account.first, since: 90.days.ago)
+  end
 end
diff --git a/spec/models/account_spec.rb b/spec/models/account_spec.rb
index 287f389ac..91c8d75cf 100644
--- a/spec/models/account_spec.rb
+++ b/spec/models/account_spec.rb
@@ -178,7 +178,6 @@ RSpec.describe Account, type: :model do
     end
   end
 
-
   describe 'MENTION_RE' do
     subject { Account::MENTION_RE }
 
@@ -190,6 +189,14 @@ RSpec.describe Account, type: :model do
       expect(subject.match('@alice Hey how are you?')[1]).to eq 'alice'
     end
 
+    it 'matches full usernames' do
+      expect(subject.match('@alice@example.com')[1]).to eq 'alice@example.com'
+    end
+
+    it 'matches full usernames with a dot at the end' do
+      expect(subject.match('Hello @alice@example.com.')[1]).to eq 'alice@example.com'
+    end
+
     it 'matches dot-prepended usernames' do
       expect(subject.match('.@alice I want everybody to see this')[1]).to eq 'alice'
     end
diff --git a/spec/models/mute_spec.rb b/spec/models/mute_spec.rb
new file mode 100644
index 000000000..83ba793b2
--- /dev/null
+++ b/spec/models/mute_spec.rb
@@ -0,0 +1,5 @@
+require 'rails_helper'
+
+RSpec.describe Mute, type: :model do
+
+end
diff --git a/spec/models/tag_spec.rb b/spec/models/tag_spec.rb
index 9a7f481e4..360bbc16d 100644
--- a/spec/models/tag_spec.rb
+++ b/spec/models/tag_spec.rb
@@ -1,5 +1,15 @@
 require 'rails_helper'
 
 RSpec.describe Tag, type: :model do
+  describe 'HASHTAG_RE' do
+    subject { Tag::HASHTAG_RE }
 
+    it 'does not match URLs with anchors with non-hashtag characters' do
+      expect(subject.match('Check this out https://medium.com/@alice/some-article#.abcdef123')).to be_nil
+    end
+
+    it 'does not match URLs with hashtag-like anchors' do
+      expect(subject.match('https://en.wikipedia.org/wiki/Ghostbusters_(song)#Lawsuit')).to be_nil
+    end
+  end
 end
diff --git a/spec/services/mute_service_spec.rb b/spec/services/mute_service_spec.rb
new file mode 100644
index 000000000..397368416
--- /dev/null
+++ b/spec/services/mute_service_spec.rb
@@ -0,0 +1,5 @@
+require 'rails_helper'
+
+RSpec.describe MuteService do
+  subject { MuteService.new }
+end
diff --git a/spec/services/unmute_service_spec.rb b/spec/services/unmute_service_spec.rb
new file mode 100644
index 000000000..5dc971fb1
--- /dev/null
+++ b/spec/services/unmute_service_spec.rb
@@ -0,0 +1,5 @@
+require 'rails_helper'
+
+RSpec.describe UnmuteService do
+  subject { UnmuteService.new }
+end
diff --git a/storybook/stories/autosuggest_textarea.story.jsx b/storybook/stories/autosuggest_textarea.story.jsx
index 7d84ff1e1..72a4b525d 100644
--- a/storybook/stories/autosuggest_textarea.story.jsx
+++ b/storybook/stories/autosuggest_textarea.story.jsx
@@ -2,5 +2,5 @@ import { storiesOf } from '@kadira/storybook';
 import AutosuggestTextarea from '../../app/assets/javascripts/components/components/autosuggest_textarea.jsx'
 
 storiesOf('AutosuggestTextarea', module)
-  .add('default state', () => <AutosuggestTextarea />)
-  .add('with text', () => <AutosuggestTextarea value='Hello' />)
+  .add('default state', () => <AutosuggestTextarea value='' suggestions={[]} />)
+  .add('with text', () => <AutosuggestTextarea value='Hello' suggestions={[]} />)
diff --git a/streaming/index.js b/streaming/index.js
index 125b35bb4..0f838e411 100644
--- a/streaming/index.js
+++ b/streaming/index.js
@@ -164,7 +164,7 @@ const streamFrom = (id, req, output, attachCloseHandler, needsFiltering = false)
         const unpackedPayload  = JSON.parse(payload)
         const targetAccountIds = [unpackedPayload.account.id].concat(unpackedPayload.mentions.map(item => item.id)).concat(unpackedPayload.reblog ? [unpackedPayload.reblog.account.id] : [])
 
-        client.query(`SELECT target_account_id FROM blocks WHERE account_id = $1 AND target_account_id IN (${placeholders(targetAccountIds, 1)})`, [req.accountId].concat(targetAccountIds), (err, result) => {
+        client.query(`SELECT target_account_id FROM blocks WHERE account_id = $1 AND target_account_id IN (${placeholders(targetAccountIds, 1)}) UNION SELECT target_account_id FROM mutes WHERE account_id = $1 AND target_account_id IN (${placeholders(targetAccountIds, 1)})`, [req.accountId].concat(targetAccountIds), (err, result) => {
           done()
 
           if (err) {
diff --git a/yarn.lock b/yarn.lock
index a77fe59eb..0904354e9 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -2204,7 +2204,7 @@ doctrine@^2.0.0:
     esutils "^2.0.2"
     isarray "^1.0.0"
 
-dom-helpers@^2.4.0:
+dom-helpers@^2.4.0, "dom-helpers@^2.4.0 || ^3.0.0":
   version "2.4.0"
   resolved "https://registry.yarnpkg.com/dom-helpers/-/dom-helpers-2.4.0.tgz#9bb4b245f637367b1fa670274272aa28fe06c367"
 
@@ -2287,7 +2287,17 @@ elliptic@^6.0.0:
     hash.js "^1.0.0"
     inherits "^2.0.1"
 
-emojione@latest:
+emojione-picker@^2.0.1:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/emojione-picker/-/emojione-picker-2.0.1.tgz#62e58db67d37a400a883c82d39abb1cc1c8ed65a"
+  dependencies:
+    emojione "^2.2.6"
+    escape-string-regexp "^1.0.5"
+    lodash "^4.15.0"
+    react-virtualized "^8.11.4"
+    store "^1.3.20"
+
+emojione@^2.2.6, emojione@latest:
   version "2.2.7"
   resolved "https://registry.yarnpkg.com/emojione/-/emojione-2.2.7.tgz#46457cf6b9b2f8da13ae8a2e4e547de06ee15e96"
 
@@ -2413,7 +2423,7 @@ escape-html@^1.0.3, escape-html@~1.0.3:
   version "1.0.3"
   resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988"
 
-escape-string-regexp@1.0.5, escape-string-regexp@^1.0.2:
+escape-string-regexp@1.0.5, escape-string-regexp@^1.0.2, escape-string-regexp@^1.0.5:
   version "1.0.5"
   resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4"
 
@@ -2905,10 +2915,6 @@ hosted-git-info@^2.1.4:
   version "2.1.5"
   resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.1.5.tgz#0ba81d90da2e25ab34a332e6ec77936e1598118b"
 
-howler@^1.1.28:
-  version "1.1.29"
-  resolved "https://registry.yarnpkg.com/howler/-/howler-1.1.29.tgz#9a3a7fa69e9b9d805c65ad98f66e35893a597b63"
-
 html-comment-regex@^1.1.0:
   version "1.1.1"
   resolved "https://registry.yarnpkg.com/html-comment-regex/-/html-comment-regex-1.1.1.tgz#668b93776eaae55ebde8f3ad464b307a4963625e"
@@ -3632,7 +3638,7 @@ lodash@4.x.x, lodash@^4.0.0, lodash@^4.14.0, lodash@^4.17.2, lodash@^4.17.4, lod
   version "4.17.4"
   resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.4.tgz#78203a4d1c328ae1d86dca6460e369b57f4055ae"
 
-lodash@^4.2.0, lodash@^4.6.1, lodash@~4.16.4:
+lodash@^4.15.0, lodash@^4.2.0, lodash@^4.6.1, lodash@~4.16.4:
   version "4.16.4"
   resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.16.4.tgz#01ce306b9bad1319f2a5528674f88297aeb70127"
 
@@ -3644,7 +3650,13 @@ longest@^1.0.1:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/longest/-/longest-1.0.1.tgz#30a0b2da38f73770e8294a0d22e6625ed77d0097"
 
-loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.2.0:
+loose-envify@^1.0.0, loose-envify@^1.3.0:
+  version "1.3.1"
+  resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.3.1.tgz#d1a8ad33fa9ce0e713d65fdd0ac8b748d478c848"
+  dependencies:
+    js-tokens "^3.0.0"
+
+loose-envify@^1.1.0, loose-envify@^1.2.0:
   version "1.2.0"
   resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.2.0.tgz#69a65aad3de542cf4ee0f4fe74e8e33c709ccb0f"
   dependencies:
@@ -4833,6 +4845,13 @@ react-addons-pure-render-mixin@>=0.14.0, react-addons-pure-render-mixin@^15.4.2:
     fbjs "^0.8.4"
     object-assign "^4.1.0"
 
+react-addons-shallow-compare@^15.4.2:
+  version "15.4.2"
+  resolved "https://registry.yarnpkg.com/react-addons-shallow-compare/-/react-addons-shallow-compare-15.4.2.tgz#027ffd9720e3a1e0b328dcd8fc62e214a0d174a5"
+  dependencies:
+    fbjs "^0.8.4"
+    object-assign "^4.1.0"
+
 react-addons-test-utils@^15.4.2:
   version "15.4.2"
   resolved "https://registry.yarnpkg.com/react-addons-test-utils/-/react-addons-test-utils-15.4.2.tgz#93bcaa718fcae7360d42e8fb1c09756cc36302a2"
@@ -5051,6 +5070,15 @@ react-toggle@^2.1.1:
     classnames "~2.2"
     react-addons-pure-render-mixin ">=0.14.0"
 
+react-virtualized@^8.11.4:
+  version "8.11.4"
+  resolved "https://registry.yarnpkg.com/react-virtualized/-/react-virtualized-8.11.4.tgz#0bb94f1ecbd286d07145ce63983d0a11724522c0"
+  dependencies:
+    babel-runtime "^6.11.6"
+    classnames "^2.2.3"
+    dom-helpers "^2.4.0 || ^3.0.0"
+    loose-envify "^1.3.0"
+
 react@^15.4.2:
   version "15.4.2"
   resolved "https://registry.yarnpkg.com/react/-/react-15.4.2.tgz#41f7991b26185392ba9bae96c8889e7e018397ef"
@@ -5170,12 +5198,6 @@ redux-immutable@^3.1.0:
   dependencies:
     immutable "^3.8.1"
 
-redux-sounds@^1.1.1:
-  version "1.1.1"
-  resolved "https://registry.yarnpkg.com/redux-sounds/-/redux-sounds-1.1.1.tgz#7a31052dbc617d419c53056215865762f44adb7e"
-  dependencies:
-    howler "^1.1.28"
-
 redux-thunk@^2.2.0:
   version "2.2.0"
   resolved "https://registry.yarnpkg.com/redux-thunk/-/redux-thunk-2.2.0.tgz#e615a16e16b47a19a515766133d1e3e99b7852e5"
@@ -5623,6 +5645,10 @@ stdout-stream@^1.4.0:
   dependencies:
     readable-stream "^2.0.1"
 
+store@^1.3.20:
+  version "1.3.20"
+  resolved "https://registry.yarnpkg.com/store/-/store-1.3.20.tgz#13ea7e3fb2d6c239868265d686b1d84e99c5be3e"
+
 stream-browserify@^2.0.0, stream-browserify@^2.0.1:
   version "2.0.1"
   resolved "https://registry.yarnpkg.com/stream-browserify/-/stream-browserify-2.0.1.tgz#66266ee5f9bdb9940a4e4514cafb43bb71e5c9db"