about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--app/controllers/admin/custom_emojis_controller.rb12
-rw-r--r--app/controllers/api/v1/timelines/direct_controller.rb60
-rw-r--r--app/controllers/settings/keyword_mutes_controller.rb64
-rw-r--r--app/helpers/jsonld_helper.rb4
-rw-r--r--app/helpers/settings/keyword_mutes_helper.rb2
-rw-r--r--app/javascript/mastodon/actions/compose.js3
-rw-r--r--app/javascript/mastodon/actions/streaming.js1
-rw-r--r--app/javascript/mastodon/actions/timelines.js2
-rw-r--r--app/javascript/mastodon/components/column_header.js4
-rw-r--r--app/javascript/mastodon/features/compose/containers/emoji_picker_dropdown_container.js2
-rw-r--r--app/javascript/mastodon/features/direct_timeline/containers/column_settings_container.js17
-rw-r--r--app/javascript/mastodon/features/direct_timeline/index.js107
-rw-r--r--app/javascript/mastodon/features/getting_started/index.js15
-rw-r--r--app/javascript/mastodon/features/ui/components/columns_area.js3
-rw-r--r--app/javascript/mastodon/features/ui/index.js8
-rw-r--r--app/javascript/mastodon/features/ui/util/async-components.js4
-rw-r--r--app/javascript/mastodon/locales/defaultMessages.json17
-rw-r--r--app/javascript/mastodon/locales/en.json3
-rw-r--r--app/javascript/mastodon/locales/eo.json336
-rw-r--r--app/javascript/mastodon/locales/ru.json64
-rw-r--r--app/javascript/mastodon/reducers/settings.js6
-rw-r--r--app/javascript/styles/mastodon/components.scss18
-rw-r--r--app/lib/activitypub/activity/create.rb8
-rw-r--r--app/lib/feed_manager.rb15
-rw-r--r--app/models/custom_emoji.rb1
-rw-r--r--app/models/glitch.rb7
-rw-r--r--app/models/glitch/keyword_mute.rb66
-rw-r--r--app/models/status.rb8
-rw-r--r--app/serializers/rest/custom_emoji_serializer.rb2
-rw-r--r--app/services/batched_remove_status_service.rb11
-rw-r--r--app/services/fan_out_on_write_service.rb13
-rw-r--r--app/services/remove_status_service.rb8
-rw-r--r--app/views/admin/custom_emojis/_custom_emoji.html.haml7
-rw-r--r--app/views/home/index.html.haml1
-rw-r--r--app/views/settings/keyword_mutes/_fields.html.haml11
-rw-r--r--app/views/settings/keyword_mutes/_keyword_mute.html.haml10
-rw-r--r--app/views/settings/keyword_mutes/edit.html.haml6
-rw-r--r--app/views/settings/keyword_mutes/index.html.haml18
-rw-r--r--app/views/settings/keyword_mutes/new.html.haml6
-rw-r--r--config/environments/production.rb4
-rw-r--r--config/locales/en.yml13
-rw-r--r--config/locales/ru.yml181
-rw-r--r--config/locales/simple_form.pt-BR.yml4
-rw-r--r--config/locales/simple_form.ru.yml4
-rw-r--r--config/navigation.rb1
-rw-r--r--config/routes.rb10
-rw-r--r--db/migrate/20170920032311_fix_reblogs_in_feeds.rb84
-rw-r--r--db/migrate/20171009222537_create_keyword_mutes.rb12
-rw-r--r--db/migrate/20171020084748_add_visible_in_picker_to_custom_emoji.rb7
-rw-r--r--db/migrate/20171021191900_move_keyword_mutes_into_glitch_namespace.rb7
-rw-r--r--db/schema.rb13
-rw-r--r--lib/paperclip/gif_transcoder.rb1
-rw-r--r--spec/controllers/settings/keyword_mutes_controller_spec.rb5
-rw-r--r--spec/fabricators/glitch_keyword_mute_fabricator.rb2
-rw-r--r--spec/fixtures/files/mini-static.gifbin0 -> 1188 bytes
-rw-r--r--spec/helpers/settings/keyword_mutes_helper_spec.rb15
-rw-r--r--spec/lib/feed_manager_spec.rb45
-rw-r--r--spec/models/glitch/keyword_mute_spec.rb89
-rw-r--r--spec/models/media_attachment_spec.rb31
-rw-r--r--spec/models/status_spec.rb49
-rw-r--r--streaming/index.js7
61 files changed, 1255 insertions, 279 deletions
diff --git a/app/controllers/admin/custom_emojis_controller.rb b/app/controllers/admin/custom_emojis_controller.rb
index 5cce5bce4..cbd7abe95 100644
--- a/app/controllers/admin/custom_emojis_controller.rb
+++ b/app/controllers/admin/custom_emojis_controller.rb
@@ -22,6 +22,14 @@ module Admin
       end
     end
 
+    def update
+      if @custom_emoji.update(resource_params)
+        redirect_to admin_custom_emojis_path, notice: I18n.t('admin.custom_emojis.updated_msg')
+      else
+        redirect_to admin_custom_emojis_path, notice: I18n.t('admin.custom_emojis.update_failed_msg')
+      end
+    end
+
     def destroy
       @custom_emoji.destroy
       redirect_to admin_custom_emojis_path, notice: I18n.t('admin.custom_emojis.destroyed_msg')
@@ -36,7 +44,7 @@ module Admin
         flash[:alert] = I18n.t('admin.custom_emojis.copy_failed_msg')
       end
 
-      redirect_to admin_custom_emojis_path(params[:page])
+      redirect_to admin_custom_emojis_path(page: params[:page])
     end
 
     def enable
@@ -56,7 +64,7 @@ module Admin
     end
 
     def resource_params
-      params.require(:custom_emoji).permit(:shortcode, :image)
+      params.require(:custom_emoji).permit(:shortcode, :image, :visible_in_picker)
     end
 
     def filtered_custom_emojis
diff --git a/app/controllers/api/v1/timelines/direct_controller.rb b/app/controllers/api/v1/timelines/direct_controller.rb
new file mode 100644
index 000000000..d455227eb
--- /dev/null
+++ b/app/controllers/api/v1/timelines/direct_controller.rb
@@ -0,0 +1,60 @@
+# frozen_string_literal: true
+
+class Api::V1::Timelines::DirectController < Api::BaseController
+  before_action -> { doorkeeper_authorize! :read }, only: [:show]
+  before_action :require_user!, only: [:show]
+  after_action :insert_pagination_headers, unless: -> { @statuses.empty? }
+
+  respond_to :json
+
+  def show
+    @statuses = load_statuses
+    render json: @statuses, each_serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id)
+  end
+
+  private
+
+  def load_statuses
+    cached_direct_statuses
+  end
+
+  def cached_direct_statuses
+    cache_collection direct_statuses, Status
+  end
+
+  def direct_statuses
+    direct_timeline_statuses.paginate_by_max_id(
+      limit_param(DEFAULT_STATUSES_LIMIT),
+      params[:max_id],
+      params[:since_id]
+    )
+  end
+
+  def direct_timeline_statuses
+    Status.as_direct_timeline(current_account)
+  end
+
+  def insert_pagination_headers
+    set_pagination_headers(next_path, prev_path)
+  end
+
+  def pagination_params(core_params)
+    params.permit(:local, :limit).merge(core_params)
+  end
+
+  def next_path
+    api_v1_timelines_direct_url pagination_params(max_id: pagination_max_id)
+  end
+
+  def prev_path
+    api_v1_timelines_direct_url pagination_params(since_id: pagination_since_id)
+  end
+
+  def pagination_max_id
+    @statuses.last.id
+  end
+
+  def pagination_since_id
+    @statuses.first.id
+  end
+end
diff --git a/app/controllers/settings/keyword_mutes_controller.rb b/app/controllers/settings/keyword_mutes_controller.rb
new file mode 100644
index 000000000..f79e1b320
--- /dev/null
+++ b/app/controllers/settings/keyword_mutes_controller.rb
@@ -0,0 +1,64 @@
+# frozen_string_literal: true
+
+class Settings::KeywordMutesController < ApplicationController
+  layout 'admin'
+
+  before_action :authenticate_user!
+  before_action :load_keyword_mute, only: [:edit, :update, :destroy]
+
+  def index
+    @keyword_mutes = paginated_keyword_mutes_for_account
+  end
+
+  def new
+    @keyword_mute = keyword_mutes_for_account.build
+  end
+
+  def create
+    @keyword_mute = keyword_mutes_for_account.create(keyword_mute_params)
+
+    if @keyword_mute.persisted?
+      redirect_to settings_keyword_mutes_path, notice: I18n.t('generic.changes_saved_msg')
+    else
+      render :new
+    end
+  end
+
+  def update
+    if @keyword_mute.update(keyword_mute_params)
+      redirect_to settings_keyword_mutes_path, notice: I18n.t('generic.changes_saved_msg')
+    else
+      render :edit
+    end
+  end
+
+  def destroy
+    @keyword_mute.destroy!
+
+    redirect_to settings_keyword_mutes_path, notice: I18n.t('generic.changes_saved_msg')
+  end
+
+  def destroy_all
+    keyword_mutes_for_account.delete_all
+
+    redirect_to settings_keyword_mutes_path, notice: I18n.t('generic.changes_saved_msg')
+  end
+
+  private
+
+  def keyword_mutes_for_account
+    Glitch::KeywordMute.where(account: current_account)
+  end
+
+  def load_keyword_mute
+    @keyword_mute = keyword_mutes_for_account.find(params[:id])
+  end
+
+  def keyword_mute_params
+    params.require(:keyword_mute).permit(:keyword, :whole_word)
+  end
+
+  def paginated_keyword_mutes_for_account
+    keyword_mutes_for_account.order(:keyword).page params[:page]
+  end
+end
diff --git a/app/helpers/jsonld_helper.rb b/app/helpers/jsonld_helper.rb
index c23a2e095..a3441e6f9 100644
--- a/app/helpers/jsonld_helper.rb
+++ b/app/helpers/jsonld_helper.rb
@@ -9,6 +9,10 @@ module JsonLdHelper
     value.is_a?(Array) ? value.first : value
   end
 
+  def as_array(value)
+    value.is_a?(Array) ? value : [value]
+  end
+
   def value_or_id(value)
     value.is_a?(String) || value.nil? ? value : value['id']
   end
diff --git a/app/helpers/settings/keyword_mutes_helper.rb b/app/helpers/settings/keyword_mutes_helper.rb
new file mode 100644
index 000000000..7b98cd59e
--- /dev/null
+++ b/app/helpers/settings/keyword_mutes_helper.rb
@@ -0,0 +1,2 @@
+module Settings::KeywordMutesHelper
+end
diff --git a/app/javascript/mastodon/actions/compose.js b/app/javascript/mastodon/actions/compose.js
index 24e64e06c..3ee9e1e7b 100644
--- a/app/javascript/mastodon/actions/compose.js
+++ b/app/javascript/mastodon/actions/compose.js
@@ -8,6 +8,7 @@ import {
   refreshHomeTimeline,
   refreshCommunityTimeline,
   refreshPublicTimeline,
+  refreshDirectTimeline,
 } from './timelines';
 
 export const COMPOSE_CHANGE          = 'COMPOSE_CHANGE';
@@ -133,6 +134,8 @@ export function submitCompose() {
       if (response.data.in_reply_to_id === null && response.data.visibility === 'public') {
         insertOrRefresh('community', refreshCommunityTimeline);
         insertOrRefresh('public', refreshPublicTimeline);
+      } else if (response.data.visibility === 'direct') {
+        insertOrRefresh('direct', refreshDirectTimeline);
       }
     }).catch(function (error) {
       dispatch(submitComposeFail(error));
diff --git a/app/javascript/mastodon/actions/streaming.js b/app/javascript/mastodon/actions/streaming.js
index 7802694a3..a2e25c930 100644
--- a/app/javascript/mastodon/actions/streaming.js
+++ b/app/javascript/mastodon/actions/streaming.js
@@ -92,3 +92,4 @@ export const connectCommunityStream = () => connectTimelineStream('community', '
 export const connectMediaStream = () => connectTimelineStream('community', 'public:local');
 export const connectPublicStream = () => connectTimelineStream('public', 'public');
 export const connectHashtagStream = (tag) => connectTimelineStream(`hashtag:${tag}`, `hashtag&tag=${tag}`);
+export const connectDirectStream = () => connectTimelineStream('direct', 'direct');
diff --git a/app/javascript/mastodon/actions/timelines.js b/app/javascript/mastodon/actions/timelines.js
index 09abe2702..935bbb6f0 100644
--- a/app/javascript/mastodon/actions/timelines.js
+++ b/app/javascript/mastodon/actions/timelines.js
@@ -115,6 +115,7 @@ export function refreshTimeline(timelineId, path, params = {}) {
 export const refreshHomeTimeline         = () => refreshTimeline('home', '/api/v1/timelines/home');
 export const refreshPublicTimeline       = () => refreshTimeline('public', '/api/v1/timelines/public');
 export const refreshCommunityTimeline    = () => refreshTimeline('community', '/api/v1/timelines/public', { local: true });
+export const refreshDirectTimeline       = () => refreshTimeline('direct', '/api/v1/timelines/direct');
 export const refreshAccountTimeline      = accountId => refreshTimeline(`account:${accountId}`, `/api/v1/accounts/${accountId}/statuses`);
 export const refreshAccountMediaTimeline = accountId => refreshTimeline(`account:${accountId}:media`, `/api/v1/accounts/${accountId}/statuses`, { only_media: true });
 export const refreshHashtagTimeline      = hashtag => refreshTimeline(`hashtag:${hashtag}`, `/api/v1/timelines/tag/${hashtag}`);
@@ -155,6 +156,7 @@ export function expandTimeline(timelineId, path, params = {}) {
 export const expandHomeTimeline         = () => expandTimeline('home', '/api/v1/timelines/home');
 export const expandPublicTimeline       = () => expandTimeline('public', '/api/v1/timelines/public');
 export const expandCommunityTimeline    = () => expandTimeline('community', '/api/v1/timelines/public', { local: true });
+export const expandDirectTimeline       = () => expandTimeline('direct', '/api/v1/timelines/direct');
 export const expandAccountTimeline      = accountId => expandTimeline(`account:${accountId}`, `/api/v1/accounts/${accountId}/statuses`);
 export const expandAccountMediaTimeline = accountId => expandTimeline(`account:${accountId}:media`, `/api/v1/accounts/${accountId}/statuses`, { only_media: true });
 export const expandHashtagTimeline      = hashtag => expandTimeline(`hashtag:${hashtag}`, `/api/v1/timelines/tag/${hashtag}`);
diff --git a/app/javascript/mastodon/components/column_header.js b/app/javascript/mastodon/components/column_header.js
index c47296a51..71530ffdd 100644
--- a/app/javascript/mastodon/components/column_header.js
+++ b/app/javascript/mastodon/components/column_header.js
@@ -175,7 +175,9 @@ export default class ColumnHeader extends React.PureComponent {
       <div className={wrapperClassName}>
         <h1 tabIndex={focusable ? 0 : null} role='button' className={buttonClassName} aria-label={title} onClick={this.handleTitleClick}>
           <i className={`fa fa-fw fa-${icon} column-header__icon`} />
-          {title}
+          <span className='column-header__title'>
+            {title}
+          </span>
           <div className='column-header__buttons'>
             {backButton}
             { notifCleaning ? (
diff --git a/app/javascript/mastodon/features/compose/containers/emoji_picker_dropdown_container.js b/app/javascript/mastodon/features/compose/containers/emoji_picker_dropdown_container.js
index 71944128c..699687c69 100644
--- a/app/javascript/mastodon/features/compose/containers/emoji_picker_dropdown_container.js
+++ b/app/javascript/mastodon/features/compose/containers/emoji_picker_dropdown_container.js
@@ -46,7 +46,7 @@ const getFrequentlyUsedEmojis = createSelector([
 
 const getCustomEmojis = createSelector([
   state => state.get('custom_emojis'),
-], emojis => emojis.sort((a, b) => {
+], emojis => emojis.filter(e => e.get('visible_in_picker')).sort((a, b) => {
   const aShort = a.get('shortcode').toLowerCase();
   const bShort = b.get('shortcode').toLowerCase();
 
diff --git a/app/javascript/mastodon/features/direct_timeline/containers/column_settings_container.js b/app/javascript/mastodon/features/direct_timeline/containers/column_settings_container.js
new file mode 100644
index 000000000..1833f69e5
--- /dev/null
+++ b/app/javascript/mastodon/features/direct_timeline/containers/column_settings_container.js
@@ -0,0 +1,17 @@
+import { connect } from 'react-redux';
+import ColumnSettings from '../../community_timeline/components/column_settings';
+import { changeSetting } from '../../../actions/settings';
+
+const mapStateToProps = state => ({
+  settings: state.getIn(['settings', 'direct']),
+});
+
+const mapDispatchToProps = dispatch => ({
+
+  onChange (key, checked) {
+    dispatch(changeSetting(['direct', ...key], checked));
+  },
+
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(ColumnSettings);
diff --git a/app/javascript/mastodon/features/direct_timeline/index.js b/app/javascript/mastodon/features/direct_timeline/index.js
new file mode 100644
index 000000000..05e092ee0
--- /dev/null
+++ b/app/javascript/mastodon/features/direct_timeline/index.js
@@ -0,0 +1,107 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import StatusListContainer from '../ui/containers/status_list_container';
+import Column from '../../components/column';
+import ColumnHeader from '../../components/column_header';
+import {
+  refreshDirectTimeline,
+  expandDirectTimeline,
+} from '../../actions/timelines';
+import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import ColumnSettingsContainer from './containers/column_settings_container';
+import { connectDirectStream } from '../../actions/streaming';
+
+const messages = defineMessages({
+  title: { id: 'column.direct', defaultMessage: 'Direct messages' },
+});
+
+const mapStateToProps = state => ({
+  hasUnread: state.getIn(['timelines', 'direct', 'unread']) > 0,
+});
+
+@connect(mapStateToProps)
+@injectIntl
+export default class DirectTimeline extends React.PureComponent {
+
+  static propTypes = {
+    dispatch: PropTypes.func.isRequired,
+    columnId: PropTypes.string,
+    intl: PropTypes.object.isRequired,
+    hasUnread: PropTypes.bool,
+    multiColumn: PropTypes.bool,
+  };
+
+  handlePin = () => {
+    const { columnId, dispatch } = this.props;
+
+    if (columnId) {
+      dispatch(removeColumn(columnId));
+    } else {
+      dispatch(addColumn('DIRECT', {}));
+    }
+  }
+
+  handleMove = (dir) => {
+    const { columnId, dispatch } = this.props;
+    dispatch(moveColumn(columnId, dir));
+  }
+
+  handleHeaderClick = () => {
+    this.column.scrollTop();
+  }
+
+  componentDidMount () {
+    const { dispatch } = this.props;
+
+    dispatch(refreshDirectTimeline());
+    this.disconnect = dispatch(connectDirectStream());
+  }
+
+  componentWillUnmount () {
+    if (this.disconnect) {
+      this.disconnect();
+      this.disconnect = null;
+    }
+  }
+
+  setRef = c => {
+    this.column = c;
+  }
+
+  handleLoadMore = () => {
+    this.props.dispatch(expandDirectTimeline());
+  }
+
+  render () {
+    const { intl, hasUnread, columnId, multiColumn } = this.props;
+    const pinned = !!columnId;
+
+    return (
+      <Column ref={this.setRef}>
+        <ColumnHeader
+          icon='envelope'
+          active={hasUnread}
+          title={intl.formatMessage(messages.title)}
+          onPin={this.handlePin}
+          onMove={this.handleMove}
+          onClick={this.handleHeaderClick}
+          pinned={pinned}
+          multiColumn={multiColumn}
+        >
+          <ColumnSettingsContainer />
+        </ColumnHeader>
+
+        <StatusListContainer
+          trackScroll={!pinned}
+          scrollKey={`direct_timeline-${columnId}`}
+          timelineId='direct'
+          loadMore={this.handleLoadMore}
+          emptyMessage={<FormattedMessage id='empty_column.direct' defaultMessage="You don't have any direct messages yet. When you send or receive one, it will show up here." />}
+        />
+      </Column>
+    );
+  }
+
+}
diff --git a/app/javascript/mastodon/features/getting_started/index.js b/app/javascript/mastodon/features/getting_started/index.js
index 68267c54f..9b94b9830 100644
--- a/app/javascript/mastodon/features/getting_started/index.js
+++ b/app/javascript/mastodon/features/getting_started/index.js
@@ -17,6 +17,7 @@ const messages = defineMessages({
   navigation_subheading: { id: 'column_subheading.navigation', defaultMessage: 'Navigation' },
   settings_subheading: { id: 'column_subheading.settings', defaultMessage: 'Settings' },
   community_timeline: { id: 'navigation_bar.community_timeline', defaultMessage: 'Local timeline' },
+  direct: { id: 'navigation_bar.direct', defaultMessage: 'Direct messages' },
   preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' },
   settings: { id: 'navigation_bar.app_settings', defaultMessage: 'App settings' },
   follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' },
@@ -78,18 +79,22 @@ export default class GettingStarted extends ImmutablePureComponent {
       }
     }
 
+    if (!multiColumn || !columns.find(item => item.get('id') === 'DIRECT')) {
+      navItems.push(<ColumnLink key='4' icon='envelope' text={intl.formatMessage(messages.direct)} to='/timelines/direct' />);
+    }
+
     navItems = navItems.concat([
-      <ColumnLink key='4' icon='star' text={intl.formatMessage(messages.favourites)} to='/favourites' />,
-      <ColumnLink key='5' icon='thumb-tack' text={intl.formatMessage(messages.pins)} to='/pinned' />,
+      <ColumnLink key='5' icon='star' text={intl.formatMessage(messages.favourites)} to='/favourites' />,
+      <ColumnLink key='6' icon='thumb-tack' text={intl.formatMessage(messages.pins)} to='/pinned' />,
     ]);
 
     if (me.get('locked')) {
-      navItems.push(<ColumnLink key='6' icon='users' text={intl.formatMessage(messages.follow_requests)} to='/follow_requests' />);
+      navItems.push(<ColumnLink key='7' icon='users' text={intl.formatMessage(messages.follow_requests)} to='/follow_requests' />);
     }
 
     navItems = navItems.concat([
-      <ColumnLink key='7' icon='volume-off' text={intl.formatMessage(messages.mutes)} to='/mutes' />,
-      <ColumnLink key='8' icon='ban' text={intl.formatMessage(messages.blocks)} to='/blocks' />,
+      <ColumnLink key='8' icon='volume-off' text={intl.formatMessage(messages.mutes)} to='/mutes' />,
+      <ColumnLink key='9' icon='ban' text={intl.formatMessage(messages.blocks)} to='/blocks' />,
     ]);
 
     return (
diff --git a/app/javascript/mastodon/features/ui/components/columns_area.js b/app/javascript/mastodon/features/ui/components/columns_area.js
index 5610095b9..ee1064229 100644
--- a/app/javascript/mastodon/features/ui/components/columns_area.js
+++ b/app/javascript/mastodon/features/ui/components/columns_area.js
@@ -11,7 +11,7 @@ import BundleContainer from '../containers/bundle_container';
 import ColumnLoading from './column_loading';
 import DrawerLoading from './drawer_loading';
 import BundleColumnError from './bundle_column_error';
-import { Compose, Notifications, HomeTimeline, CommunityTimeline, PublicTimeline, HashtagTimeline, FavouritedStatuses } from '../../ui/util/async-components';
+import { Compose, Notifications, HomeTimeline, CommunityTimeline, PublicTimeline, HashtagTimeline, DirectTimeline, FavouritedStatuses } from '../../ui/util/async-components';
 
 import detectPassiveEvents from 'detect-passive-events';
 import { scrollRight } from '../../../scroll';
@@ -23,6 +23,7 @@ const componentMap = {
   'PUBLIC': PublicTimeline,
   'COMMUNITY': CommunityTimeline,
   'HASHTAG': HashtagTimeline,
+  'DIRECT': DirectTimeline,
   'FAVOURITES': FavouritedStatuses,
 };
 
diff --git a/app/javascript/mastodon/features/ui/index.js b/app/javascript/mastodon/features/ui/index.js
index 883bfe055..9f77ab5aa 100644
--- a/app/javascript/mastodon/features/ui/index.js
+++ b/app/javascript/mastodon/features/ui/index.js
@@ -29,6 +29,7 @@ import {
   Following,
   Reblogs,
   Favourites,
+  DirectTimeline,
   HashtagTimeline,
   Notifications,
   FollowRequests,
@@ -71,6 +72,7 @@ const keyMap = {
   goToNotifications: 'g n',
   goToLocal: 'g l',
   goToFederated: 'g t',
+  goToDirect: 'g d',
   goToStart: 'g s',
   goToFavourites: 'g f',
   goToPinned: 'g p',
@@ -302,6 +304,10 @@ export default class UI extends React.Component {
     this.context.router.history.push('/timelines/public');
   }
 
+  handleHotkeyGoToDirect = () => {
+    this.context.router.history.push('/timelines/direct');
+  }
+
   handleHotkeyGoToStart = () => {
     this.context.router.history.push('/getting-started');
   }
@@ -357,6 +363,7 @@ export default class UI extends React.Component {
       goToNotifications: this.handleHotkeyGoToNotifications,
       goToLocal: this.handleHotkeyGoToLocal,
       goToFederated: this.handleHotkeyGoToFederated,
+      goToDirect: this.handleHotkeyGoToDirect,
       goToStart: this.handleHotkeyGoToStart,
       goToFavourites: this.handleHotkeyGoToFavourites,
       goToPinned: this.handleHotkeyGoToPinned,
@@ -377,6 +384,7 @@ export default class UI extends React.Component {
               <WrappedRoute path='/timelines/home' component={HomeTimeline} content={children} />
               <WrappedRoute path='/timelines/public' exact component={PublicTimeline} content={children} />
               <WrappedRoute path='/timelines/public/local' component={CommunityTimeline} content={children} />
+              <WrappedRoute path='/timelines/direct' component={DirectTimeline} content={children} />
               <WrappedRoute path='/timelines/tag/:id' component={HashtagTimeline} content={children} />
 
               <WrappedRoute path='/notifications' component={Notifications} content={children} />
diff --git a/app/javascript/mastodon/features/ui/util/async-components.js b/app/javascript/mastodon/features/ui/util/async-components.js
index 7f2b303a7..dc8e9dfb9 100644
--- a/app/javascript/mastodon/features/ui/util/async-components.js
+++ b/app/javascript/mastodon/features/ui/util/async-components.js
@@ -26,6 +26,10 @@ export function HashtagTimeline () {
   return import(/* webpackChunkName: "features/hashtag_timeline" */'../../hashtag_timeline');
 }
 
+export function DirectTimeline() {
+  return import(/* webpackChunkName: "features/direct_timeline" */'../../direct_timeline');
+}
+
 export function Status () {
   return import(/* webpackChunkName: "features/status" */'../../status');
 }
diff --git a/app/javascript/mastodon/locales/defaultMessages.json b/app/javascript/mastodon/locales/defaultMessages.json
index f400b283f..ebb514e69 100644
--- a/app/javascript/mastodon/locales/defaultMessages.json
+++ b/app/javascript/mastodon/locales/defaultMessages.json
@@ -758,6 +758,19 @@
   {
     "descriptors": [
       {
+        "defaultMessage": "Direct messages",
+        "id": "column.direct"
+      },
+      {
+        "defaultMessage": "You don't have any direct messages yet. When you send or receive one, it will show up here.",
+        "id": "empty_column.direct"
+      }
+    ],
+    "path": "app/javascript/mastodon/features/direct_timeline/index.json"
+  },
+  {
+    "descriptors": [
+      {
         "defaultMessage": "Favourites",
         "id": "column.favourites"
       }
@@ -817,6 +830,10 @@
         "id": "navigation_bar.community_timeline"
       },
       {
+        "defaultMessage": "Direct messages",
+        "id": "navigation_bar.direct"
+      },
+      {
         "defaultMessage": "Preferences",
         "id": "navigation_bar.preferences"
       },
diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json
index 1d0bbcee5..efe0e1de9 100644
--- a/app/javascript/mastodon/locales/en.json
+++ b/app/javascript/mastodon/locales/en.json
@@ -28,6 +28,7 @@
   "bundle_modal_error.retry": "Try again",
   "column.blocks": "Blocked users",
   "column.community": "Local timeline",
+  "column.direct": "Direct messages",
   "column.favourites": "Favourites",
   "column.follow_requests": "Follow requests",
   "column.home": "Home",
@@ -80,6 +81,7 @@
   "emoji_button.symbols": "Symbols",
   "emoji_button.travel": "Travel & Places",
   "empty_column.community": "The local timeline is empty. Write something publicly to get the ball rolling!",
+  "empty_column.direct": "You don't have any direct messages yet. When you send or receive one, it will show up here.",
   "empty_column.hashtag": "There is nothing in this hashtag yet.",
   "empty_column.home": "Your home timeline is empty! Visit {public} or use search to get started and meet other users.",
   "empty_column.home.public_timeline": "the public timeline",
@@ -106,6 +108,7 @@
   "missing_indicator.label": "Not found",
   "navigation_bar.blocks": "Blocked users",
   "navigation_bar.community_timeline": "Local timeline",
+  "navigation_bar.direct": "Direct messages",
   "navigation_bar.edit_profile": "Edit profile",
   "navigation_bar.favourites": "Favourites",
   "navigation_bar.follow_requests": "Follow requests",
diff --git a/app/javascript/mastodon/locales/eo.json b/app/javascript/mastodon/locales/eo.json
index 22639f6f9..3f67a8fff 100644
--- a/app/javascript/mastodon/locales/eo.json
+++ b/app/javascript/mastodon/locales/eo.json
@@ -1,221 +1,221 @@
 {
   "account.block": "Bloki @{name}",
-  "account.block_domain": "Hide everything from {domain}",
-  "account.disclaimer_full": "Information below may reflect the user's profile incompletely.",
+  "account.block_domain": "Kaŝi ĉion el {domain}",
+  "account.disclaimer_full": "La ĉi-subaj informoj povas ne plene reflekti la profilon de la uzanto.",
   "account.edit_profile": "Redakti la profilon",
   "account.follow": "Sekvi",
   "account.followers": "Sekvantoj",
   "account.follows": "Sekvatoj",
   "account.follows_you": "Sekvas vin",
-  "account.media": "Media",
+  "account.media": "Sonbildaĵoj",
   "account.mention": "Mencii @{name}",
-  "account.mute": "Mute @{name}",
+  "account.mute": "Silentigi @{name}",
   "account.posts": "Mesaĝoj",
-  "account.report": "Report @{name}",
+  "account.report": "Signali @{name}",
   "account.requested": "Atendas aprobon",
-  "account.share": "Share @{name}'s profile",
+  "account.share": "Diskonigi la profilon de @{name}",
   "account.unblock": "Malbloki @{name}",
-  "account.unblock_domain": "Unhide {domain}",
-  "account.unfollow": "Malsekvi",
-  "account.unmute": "Unmute @{name}",
-  "account.view_full_profile": "View full profile",
-  "boost_modal.combo": "You can press {combo} to skip this next time",
-  "bundle_column_error.body": "Something went wrong while loading this component.",
-  "bundle_column_error.retry": "Try again",
-  "bundle_column_error.title": "Network error",
-  "bundle_modal_error.close": "Close",
-  "bundle_modal_error.message": "Something went wrong while loading this component.",
-  "bundle_modal_error.retry": "Try again",
-  "column.blocks": "Blocked users",
+  "account.unblock_domain": "Malkaŝi {domain}",
+  "account.unfollow": "Ne plus sekvi",
+  "account.unmute": "Malsilentigi @{name}",
+  "account.view_full_profile": "Vidi plenan profilon",
+  "boost_modal.combo": "La proksiman fojon, premu {combo} por pasigi",
+  "bundle_column_error.body": "Io malfunkciis ŝargante tiun ĉi komponanton.",
+  "bundle_column_error.retry": "Bonvolu reprovi",
+  "bundle_column_error.title": "Reta eraro",
+  "bundle_modal_error.close": "Fermi",
+  "bundle_modal_error.message": "Io malfunkciis ŝargante tiun ĉi komponanton.",
+  "bundle_modal_error.retry": "Bonvolu reprovi",
+  "column.blocks": "Blokitaj uzantoj",
   "column.community": "Loka tempolinio",
-  "column.favourites": "Favourites",
-  "column.follow_requests": "Follow requests",
+  "column.favourites": "Favoritoj",
+  "column.follow_requests": "Abonpetoj",
   "column.home": "Hejmo",
-  "column.mutes": "Muted users",
+  "column.mutes": "Silentigitaj uzantoj",
   "column.notifications": "Sciigoj",
-  "column.pins": "Pinned toot",
+  "column.pins": "Alpinglitaj pepoj",
   "column.public": "Fratara tempolinio",
   "column_back_button.label": "Reveni",
-  "column_header.hide_settings": "Hide settings",
-  "column_header.moveLeft_settings": "Move column to the left",
-  "column_header.moveRight_settings": "Move column to the right",
-  "column_header.pin": "Pin",
-  "column_header.show_settings": "Show settings",
-  "column_header.unpin": "Unpin",
-  "column_subheading.navigation": "Navigation",
-  "column_subheading.settings": "Settings",
-  "compose_form.lock_disclaimer": "Your account is not {locked}. Anyone can follow you to view your follower-only posts.",
-  "compose_form.lock_disclaimer.lock": "locked",
+  "column_header.hide_settings": "Kaŝi agordojn",
+  "column_header.moveLeft_settings": "Movi kolumnon maldekstren",
+  "column_header.moveRight_settings": "Movi kolumnon dekstren",
+  "column_header.pin": "Alpingli",
+  "column_header.show_settings": "Malkaŝi agordojn",
+  "column_header.unpin": "Depingli",
+  "column_subheading.navigation": "Navigado",
+  "column_subheading.settings": "Agordoj",
+  "compose_form.lock_disclaimer": "Via konta ne estas ŝlosita. Iu ajn povas sekvi vin por vidi viajn privatajn pepojn.",
+  "compose_form.lock_disclaimer.lock": "ŝlosita",
   "compose_form.placeholder": "Pri kio vi pensas?",
   "compose_form.publish": "Hup",
   "compose_form.publish_loud": "{publish}!",
   "compose_form.sensitive": "Marki ke la enhavo estas tikla",
   "compose_form.spoiler": "Kaŝi la tekston malantaŭ averto",
-  "compose_form.spoiler_placeholder": "Content warning",
-  "confirmation_modal.cancel": "Cancel",
-  "confirmations.block.confirm": "Block",
-  "confirmations.block.message": "Are you sure you want to block {name}?",
-  "confirmations.delete.confirm": "Delete",
-  "confirmations.delete.message": "Are you sure you want to delete this status?",
-  "confirmations.domain_block.confirm": "Hide entire domain",
-  "confirmations.domain_block.message": "Are you really, really sure you want to block the entire {domain}? In most cases a few targeted blocks or mutes are sufficient and preferable.",
-  "confirmations.mute.confirm": "Mute",
-  "confirmations.mute.message": "Are you sure you want to mute {name}?",
-  "confirmations.unfollow.confirm": "Unfollow",
-  "confirmations.unfollow.message": "Are you sure you want to unfollow {name}?",
-  "embed.instructions": "Embed this status on your website by copying the code below.",
-  "embed.preview": "Here is what it will look like:",
-  "emoji_button.activity": "Activity",
-  "emoji_button.custom": "Custom",
-  "emoji_button.flags": "Flags",
-  "emoji_button.food": "Food & Drink",
-  "emoji_button.label": "Insert emoji",
-  "emoji_button.nature": "Nature",
-  "emoji_button.not_found": "No emojos!! (╯°□°)╯︵ ┻━┻",
-  "emoji_button.objects": "Objects",
-  "emoji_button.people": "People",
-  "emoji_button.recent": "Frequently used",
-  "emoji_button.search": "Search...",
-  "emoji_button.search_results": "Search results",
-  "emoji_button.symbols": "Symbols",
-  "emoji_button.travel": "Travel & Places",
-  "empty_column.community": "The local timeline is empty. Write something publicly to get the ball rolling!",
-  "empty_column.hashtag": "There is nothing in this hashtag yet.",
-  "empty_column.home": "Your home timeline is empty! Visit {public} or use search to get started and meet other users.",
-  "empty_column.home.public_timeline": "the public timeline",
-  "empty_column.notifications": "You don't have any notifications yet. Interact with others to start the conversation.",
-  "empty_column.public": "There is nothing here! Write something publicly, or manually follow users from other instances to fill it up",
-  "follow_request.authorize": "Authorize",
-  "follow_request.reject": "Reject",
-  "getting_started.appsshort": "Apps",
-  "getting_started.faq": "FAQ",
+  "compose_form.spoiler_placeholder": "Skribu tie vian averton",
+  "confirmation_modal.cancel": "Malfari",
+  "confirmations.block.confirm": "Bloki",
+  "confirmations.block.message": "Ĉu vi konfirmas la blokadon de {name}?",
+  "confirmations.delete.confirm": "Malaperigi",
+  "confirmations.delete.message": "Ĉu vi konfirmas la malaperigon de tiun pepon?",
+  "confirmations.domain_block.confirm": "Kaŝi la tutan reton",
+  "confirmations.domain_block.message": "Ĉu vi vere, vere certas, ke vi volas bloki {domain} tute? Plej ofte, kelkaj celitaj blokadoj aŭ silentigoj estas sufiĉaj kaj preferindaj.",
+  "confirmations.mute.confirm": "Silentigi",
+  "confirmations.mute.message": "Ĉu vi konfirmas la silentigon de {name}?",
+  "confirmations.unfollow.confirm": "Ne plu sekvi",
+  "confirmations.unfollow.message": "Ĉu vi volas ĉesi sekvi {name}?",
+  "embed.instructions": "Enmetu tiun statkonigon ĉe vian retejon kopiante la ĉi-suban kodon.",
+  "embed.preview": "Ĝi aperos tiel:",
+  "emoji_button.activity": "Aktivecoj",
+  "emoji_button.custom": "Personaj",
+  "emoji_button.flags": "Flagoj",
+  "emoji_button.food": "Manĝi kaj trinki",
+  "emoji_button.label": "Enmeti mieneton",
+  "emoji_button.nature": "Naturo",
+  "emoji_button.not_found": "Neniuj mienetoj!! (╯°□°)╯︵ ┻━┻",
+  "emoji_button.objects": "Objektoj",
+  "emoji_button.people": "Homoj",
+  "emoji_button.recent": "Ofte uzataj",
+  "emoji_button.search": "Serĉo…",
+  "emoji_button.search_results": "Rezultatoj de serĉo",
+  "emoji_button.symbols": "Simboloj",
+  "emoji_button.travel": "Vojaĝoj & lokoj",
+  "empty_column.community": "La loka tempolinio estas malplena. Skribu ion por plenigi ĝin!",
+  "empty_column.hashtag": "Ĝise, neniu enhavo estas asociita kun tiu kradvorto.",
+  "empty_column.home": "Via hejma tempolinio estas malplena! Vizitu {public} aŭ uzu la serĉilon por renkonti aliajn uzantojn.",
+  "empty_column.home.public_timeline": "la publika tempolinio",
+  "empty_column.notifications": "Vi dume ne havas sciigojn. Interagi kun aliajn uzantojn por komenci la konversacion.",
+  "empty_column.public": "Estas nenio ĉi tie! Publike skribu ion, aŭ mane sekvu uzantojn de aliaj instancoj por plenigi la publikan tempolinion.",
+  "follow_request.authorize": "Akcepti",
+  "follow_request.reject": "Rifuzi",
+  "getting_started.appsshort": "Aplikaĵoj",
+  "getting_started.faq": "Oftaj demandoj",
   "getting_started.heading": "Por komenci",
-  "getting_started.open_source_notice": "Mastodon estas malfermitkoda programo. Vi povas kontribui aŭ raporti problemojn en github je {github}.",
-  "getting_started.userguide": "User Guide",
-  "home.column_settings.advanced": "Advanced",
-  "home.column_settings.basic": "Basic",
-  "home.column_settings.filter_regex": "Filter out by regular expressions",
-  "home.column_settings.show_reblogs": "Show boosts",
-  "home.column_settings.show_replies": "Show replies",
-  "home.settings": "Column settings",
+  "getting_started.open_source_notice": "Mastodono estas malfermkoda programo. Vi povas kontribui aŭ raporti problemojn en GitHub je {github}.",
+  "getting_started.userguide": "Gvidilo de uzo",
+  "home.column_settings.advanced": "Precizaj agordoj",
+  "home.column_settings.basic": "Bazaj agordoj",
+  "home.column_settings.filter_regex": "Forfiltri per regulesprimo",
+  "home.column_settings.show_reblogs": "Montri diskonigojn",
+  "home.column_settings.show_replies": "Montri respondojn",
+  "home.settings": "Agordoj de la kolumno",
   "lightbox.close": "Fermi",
-  "lightbox.next": "Next",
-  "lightbox.previous": "Previous",
-  "loading_indicator.label": "Ŝarĝanta...",
-  "media_gallery.toggle_visible": "Toggle visibility",
-  "missing_indicator.label": "Not found",
-  "navigation_bar.blocks": "Blocked users",
+  "lightbox.next": "Malantaŭa",
+  "lightbox.previous": "Antaŭa",
+  "loading_indicator.label": "Ŝarganta…",
+  "media_gallery.toggle_visible": "Baskuli videblecon",
+  "missing_indicator.label": "Ne trovita",
+  "navigation_bar.blocks": "Blokitaj uzantoj",
   "navigation_bar.community_timeline": "Loka tempolinio",
   "navigation_bar.edit_profile": "Redakti la profilon",
-  "navigation_bar.favourites": "Favourites",
-  "navigation_bar.follow_requests": "Follow requests",
-  "navigation_bar.info": "Extended information",
+  "navigation_bar.favourites": "Favoritaj",
+  "navigation_bar.follow_requests": "Abonpetoj",
+  "navigation_bar.info": "Plia informo",
   "navigation_bar.logout": "Elsaluti",
-  "navigation_bar.mutes": "Muted users",
-  "navigation_bar.pins": "Pinned toots",
+  "navigation_bar.mutes": "Silentigitaj uzantoj",
+  "navigation_bar.pins": "Alpinglitaj pepoj",
   "navigation_bar.preferences": "Preferoj",
   "navigation_bar.public_timeline": "Fratara tempolinio",
   "notification.favourite": "{name} favoris vian mesaĝon",
   "notification.follow": "{name} sekvis vin",
   "notification.mention": "{name} menciis vin",
   "notification.reblog": "{name} diskonigis vian mesaĝon",
-  "notifications.clear": "Clear notifications",
-  "notifications.clear_confirmation": "Are you sure you want to permanently clear all your notifications?",
+  "notifications.clear": "Forviŝi la sciigojn",
+  "notifications.clear_confirmation": "Ĉu vi certe volas malaperigi ĉiujn viajn sciigojn?",
   "notifications.column_settings.alert": "Retumilaj atentigoj",
-  "notifications.column_settings.favourite": "Favoroj:",
+  "notifications.column_settings.favourite": "Favoritoj:",
   "notifications.column_settings.follow": "Novaj sekvantoj:",
   "notifications.column_settings.mention": "Mencioj:",
-  "notifications.column_settings.push": "Push notifications",
-  "notifications.column_settings.push_meta": "This device",
+  "notifications.column_settings.push": "Puŝsciigoj",
+  "notifications.column_settings.push_meta": "Tiu ĉi aparato",
   "notifications.column_settings.reblog": "Diskonigoj:",
   "notifications.column_settings.show": "Montri en kolono",
-  "notifications.column_settings.sound": "Play sound",
-  "onboarding.done": "Done",
-  "onboarding.next": "Next",
-  "onboarding.page_five.public_timelines": "The local timeline shows public posts from everyone on {domain}. The federated timeline shows public posts from everyone who people on {domain} follow. These are the Public Timelines, a great way to discover new people.",
-  "onboarding.page_four.home": "The home timeline shows posts from people you follow.",
-  "onboarding.page_four.notifications": "The notifications column shows when someone interacts with you.",
-  "onboarding.page_one.federation": "Mastodon is a network of independent servers joining up to make one larger social network. We call these servers instances.",
-  "onboarding.page_one.handle": "You are on {domain}, so your full handle is {handle}",
-  "onboarding.page_one.welcome": "Welcome to Mastodon!",
-  "onboarding.page_six.admin": "Your instance's admin is {admin}.",
-  "onboarding.page_six.almost_done": "Almost done...",
-  "onboarding.page_six.appetoot": "Bon Appetoot!",
-  "onboarding.page_six.apps_available": "There are {apps} available for iOS, Android and other platforms.",
-  "onboarding.page_six.github": "Mastodon is free open-source software. You can report bugs, request features, or contribute to the code on {github}.",
-  "onboarding.page_six.guidelines": "community guidelines",
-  "onboarding.page_six.read_guidelines": "Please read {domain}'s {guidelines}!",
-  "onboarding.page_six.various_app": "mobile apps",
-  "onboarding.page_three.profile": "Edit your profile to change your avatar, bio, and display name. There, you will also find other preferences.",
-  "onboarding.page_three.search": "Use the search bar to find people and look at hashtags, such as {illustration} and {introductions}. To look for a person who is not on this instance, use their full handle.",
-  "onboarding.page_two.compose": "Write posts from the compose column. You can upload images, change privacy settings, and add content warnings with the icons below.",
-  "onboarding.skip": "Skip",
-  "privacy.change": "Adjust status privacy",
-  "privacy.direct.long": "Post to mentioned users only",
-  "privacy.direct.short": "Direct",
-  "privacy.private.long": "Post to followers only",
-  "privacy.private.short": "Followers-only",
-  "privacy.public.long": "Post to public timelines",
-  "privacy.public.short": "Public",
-  "privacy.unlisted.long": "Do not show in public timelines",
-  "privacy.unlisted.short": "Unlisted",
-  "relative_time.days": "{number}d",
+  "notifications.column_settings.sound": "Eligi sonon",
+  "onboarding.done": "Farita",
+  "onboarding.next": "Malantaŭa",
+  "onboarding.page_five.public_timelines": "La loka tempolinio enhavas mesaĝojn de ĉiuj ĉe {domain}. La federacia tempolinio enhavas ĉiujn mesaĝojn de uzantoj, kiujn iu ĉe {domain} sekvas. Ambaŭ tre utilas por trovi novajn kunparolantojn.",
+  "onboarding.page_four.home": "La hejma tempolinio enhavas la mesaĝojn de ĉiuj uzantoj, kiuj vi sekvas.",
+  "onboarding.page_four.notifications": "La sciiga kolumno informas vin kiam iu interagas kun vi.",
+  "onboarding.page_one.federation": "Mastodono estas reto de nedependaj serviloj, unuiĝintaj por krei pligrandan socian retejon. Ni nomas tiujn servilojn instancoj.",
+  "onboarding.page_one.handle": "Vi estas ĉe {domain}, unu el la multaj instancoj de Mastodono. Via kompleta uznomo do estas {handle}",
+  "onboarding.page_one.welcome": "Bonvenon al Mastodono!",
+  "onboarding.page_six.admin": "Via instancestro estas {admin}.",
+  "onboarding.page_six.almost_done": "Estas preskaŭ finita…",
+  "onboarding.page_six.appetoot": "Bonan a‘pepi’ton!",
+  "onboarding.page_six.apps_available": "{apps} estas elŝuteblaj por iOS, Androido kaj alioj. Kaj nun… bonan a‘pepi’ton!",
+  "onboarding.page_six.github": "Mastodono estas libera, senpaga kaj malfermkoda programaro. Vi povas signali cimojn, proponi funkciojn aŭ kontribui al gîa kreskado ĉe {github}.",
+  "onboarding.page_six.guidelines": "komunreguloj",
+  "onboarding.page_six.read_guidelines": "Ni petas vin: ne forgesu legi la {guidelines}n de {domain}!",
+  "onboarding.page_six.various_app": "telefon-aplikaĵoj",
+  "onboarding.page_three.profile": "Redaktu vian profilon por ŝanĝi vian avataron, priskribon kaj vian nomon. Vi tie trovos ankoraŭ aliajn agordojn.",
+  "onboarding.page_three.search": "Uzu la serĉokampo por trovi uzantojn kaj esplori kradvortojn tiel ke {illustration} kaj {introductions}. Por trovi iun, kiu ne estas ĉe ĉi tiu instanco, uzu ĝian kompletan uznomon.",
+  "onboarding.page_two.compose": "Skribu pepojn en la verkkolumno. Vi povas aldoni bildojn, ŝanĝi la agordojn de privateco kaj aldoni tiklavertojn (« content warning ») dank' al la piktogramoj malsupre.",
+  "onboarding.skip": "Pasigi",
+  "privacy.change": "Alĝustigi la privateco de la mesaĝo",
+  "privacy.direct.long": "Vidigi nur al la menciitaj personoj",
+  "privacy.direct.short": "Rekta",
+  "privacy.private.long": "Vidigi nur al viaj sekvantoj",
+  "privacy.private.short": "Nursekvanta",
+  "privacy.public.long": "Vidigi en publikaj tempolinioj",
+  "privacy.public.short": "Publika",
+  "privacy.unlisted.long": "Ne vidigi en publikaj tempolinioj",
+  "privacy.unlisted.short": "Nelistigita",
+  "relative_time.days": "{number}t",
   "relative_time.hours": "{number}h",
-  "relative_time.just_now": "now",
+  "relative_time.just_now": "nun",
   "relative_time.minutes": "{number}m",
   "relative_time.seconds": "{number}s",
-  "reply_indicator.cancel": "Rezigni",
-  "report.placeholder": "Additional comments",
-  "report.submit": "Submit",
-  "report.target": "Reporting",
+  "reply_indicator.cancel": "Malfari",
+  "report.placeholder": "Pliaj komentoj",
+  "report.submit": "Sendi",
+  "report.target": "Signalaĵo",
   "search.placeholder": "Serĉi",
-  "search_popout.search_format": "Advanced search format",
-  "search_popout.tips.hashtag": "hashtag",
-  "search_popout.tips.status": "status",
-  "search_popout.tips.text": "Simple text returns matching display names, usernames and hashtags",
-  "search_popout.tips.user": "user",
-  "search_results.total": "{count, number} {count, plural, one {result} other {results}}",
-  "standalone.public_title": "A look inside...",
-  "status.cannot_reblog": "This post cannot be boosted",
+  "search_popout.search_format": "Detala serĉo",
+  "search_popout.tips.hashtag": "kradvorto",
+  "search_popout.tips.status": "statkonigo",
+  "search_popout.tips.text": "Simpla teksto eligas la kongruajn afiŝnomojn, uznomojn kaj kradvortojn.",
+  "search_popout.tips.user": "uzanto",
+  "search_results.total": "{count, number} {count, plural, one {rezultato} other {rezultatoj}}",
+  "standalone.public_title": "Rigardeti…",
+  "status.cannot_reblog": "Tiun publikaĵon oni ne povas diskonigi",
   "status.delete": "Forigi",
-  "status.embed": "Embed",
+  "status.embed": "Enmeti",
   "status.favourite": "Favori",
-  "status.load_more": "Load more",
-  "status.media_hidden": "Media hidden",
+  "status.load_more": "Ŝargi plie",
+  "status.media_hidden": "Sonbildaĵo kaŝita",
   "status.mention": "Mencii @{name}",
-  "status.more": "More",
-  "status.mute_conversation": "Mute conversation",
-  "status.open": "Expand this status",
-  "status.pin": "Pin on profile",
+  "status.more": "Pli",
+  "status.mute_conversation": "Silentigi konversacion",
+  "status.open": "Disfaldi statkonigon",
+  "status.pin": "Pingli al la profilo",
   "status.reblog": "Diskonigi",
-  "status.reblogged_by": "{name} diskonigita",
+  "status.reblogged_by": "{name} diskonigis",
   "status.reply": "Respondi",
-  "status.replyAll": "Reply to thread",
-  "status.report": "Report @{name}",
+  "status.replyAll": "Respondi al la fadeno",
+  "status.report": "Signali @{name}",
   "status.sensitive_toggle": "Alklaki por vidi",
   "status.sensitive_warning": "Tikla enhavo",
-  "status.share": "Share",
-  "status.show_less": "Show less",
-  "status.show_more": "Show more",
-  "status.unmute_conversation": "Unmute conversation",
-  "status.unpin": "Unpin from profile",
+  "status.share": "Diskonigi",
+  "status.show_less": "Refaldi",
+  "status.show_more": "Disfaldi",
+  "status.unmute_conversation": "Malsilentigi konversacion",
+  "status.unpin": "Depingli de profilo",
   "tabs_bar.compose": "Ekskribi",
-  "tabs_bar.federated_timeline": "Federated",
+  "tabs_bar.federated_timeline": "Federacia tempolinio",
   "tabs_bar.home": "Hejmo",
-  "tabs_bar.local_timeline": "Local",
+  "tabs_bar.local_timeline": "Loka tempolinio",
   "tabs_bar.notifications": "Sciigoj",
-  "upload_area.title": "Drag & drop to upload",
-  "upload_button.label": "Aldoni enhavaĵon",
-  "upload_form.description": "Describe for the visually impaired",
+  "upload_area.title": "Algliti por alŝuti",
+  "upload_button.label": "Aldoni sonbildaĵon",
+  "upload_form.description": "Priskribi por la misvidantaj",
   "upload_form.undo": "Malfari",
-  "upload_progress.label": "Uploading...",
-  "video.close": "Close video",
-  "video.exit_fullscreen": "Exit full screen",
-  "video.expand": "Expand video",
-  "video.fullscreen": "Full screen",
-  "video.hide": "Hide video",
-  "video.mute": "Mute sound",
-  "video.pause": "Pause",
-  "video.play": "Play",
-  "video.unmute": "Unmute sound"
+  "upload_progress.label": "Alŝutanta…",
+  "video.close": "Fermi videon",
+  "video.exit_fullscreen": "Eliri el plenekrano",
+  "video.expand": "Vastigi videon",
+  "video.fullscreen": "Igi plenekrane",
+  "video.hide": "Kaŝi videon",
+  "video.mute": "Silentigi",
+  "video.pause": "Paŭzi",
+  "video.play": "Legi",
+  "video.unmute": "Malsilentigi"
 }
diff --git a/app/javascript/mastodon/locales/ru.json b/app/javascript/mastodon/locales/ru.json
index 104b063f5..65bc5b374 100644
--- a/app/javascript/mastodon/locales/ru.json
+++ b/app/javascript/mastodon/locales/ru.json
@@ -63,20 +63,20 @@
   "confirmations.mute.message": "Вы уверены, что хотите заглушить {name}?",
   "confirmations.unfollow.confirm": "Отписаться",
   "confirmations.unfollow.message": "Вы уверены, что хотите отписаться от {name}?",
-  "embed.instructions": "Embed this status on your website by copying the code below.",
-  "embed.preview": "Here is what it will look like:",
+  "embed.instructions": "Встройте этот статус на Вашем сайте, скопировав код внизу.",
+  "embed.preview": "Так это будет выглядеть:",
   "emoji_button.activity": "Занятия",
-  "emoji_button.custom": "Custom",
+  "emoji_button.custom": "Собственные",
   "emoji_button.flags": "Флаги",
   "emoji_button.food": "Еда и напитки",
   "emoji_button.label": "Вставить эмодзи",
   "emoji_button.nature": "Природа",
-  "emoji_button.not_found": "No emojos!! (╯°□°)╯︵ ┻━┻",
+  "emoji_button.not_found": "Нет эмодзи!! (╯°□°)╯︵ ┻━┻",
   "emoji_button.objects": "Предметы",
   "emoji_button.people": "Люди",
-  "emoji_button.recent": "Frequently used",
+  "emoji_button.recent": "Последние",
   "emoji_button.search": "Найти...",
-  "emoji_button.search_results": "Search results",
+  "emoji_button.search_results": "Результаты поиска",
   "emoji_button.symbols": "Символы",
   "emoji_button.travel": "Путешествия",
   "empty_column.community": "Локальная лента пуста. Напишите что-нибудь, чтобы разогреть народ!",
@@ -159,34 +159,34 @@
   "privacy.public.short": "Публичный",
   "privacy.unlisted.long": "Не показывать в лентах",
   "privacy.unlisted.short": "Скрытый",
-  "relative_time.days": "{number}d",
-  "relative_time.hours": "{number}h",
-  "relative_time.just_now": "now",
-  "relative_time.minutes": "{number}m",
-  "relative_time.seconds": "{number}s",
+  "relative_time.days": "{number}д",
+  "relative_time.hours": "{number}ч",
+  "relative_time.just_now": "только что",
+  "relative_time.minutes": "{number}м",
+  "relative_time.seconds": "{number}с",
   "reply_indicator.cancel": "Отмена",
   "report.placeholder": "Комментарий",
   "report.submit": "Отправить",
   "report.target": "Жалуемся на",
   "search.placeholder": "Поиск",
-  "search_popout.search_format": "Advanced search format",
-  "search_popout.tips.hashtag": "hashtag",
-  "search_popout.tips.status": "status",
-  "search_popout.tips.text": "Simple text returns matching display names, usernames and hashtags",
-  "search_popout.tips.user": "user",
+  "search_popout.search_format": "Продвинутый формат поиска",
+  "search_popout.tips.hashtag": "хэштег",
+  "search_popout.tips.status": "статус",
+  "search_popout.tips.text": "Простой ввод текста покажет совпадающие имена пользователей, отображаемые имена и хэштеги",
+  "search_popout.tips.user": "пользователь",
   "search_results.total": "{count, number} {count, plural, one {результат} few {результата} many {результатов} other {результатов}}",
-  "standalone.public_title": "A look inside...",
+  "standalone.public_title": "Прямо сейчас",
   "status.cannot_reblog": "Этот статус не может быть продвинут",
   "status.delete": "Удалить",
-  "status.embed": "Embed",
+  "status.embed": "Встроить",
   "status.favourite": "Нравится",
   "status.load_more": "Показать еще",
   "status.media_hidden": "Медиаконтент скрыт",
   "status.mention": "Упомянуть @{name}",
-  "status.more": "More",
+  "status.more": "Больше",
   "status.mute_conversation": "Заглушить тред",
   "status.open": "Развернуть статус",
-  "status.pin": "Pin on profile",
+  "status.pin": "Закрепить в профиле",
   "status.reblog": "Продвинуть",
   "status.reblogged_by": "{name} продвинул(а)",
   "status.reply": "Ответить",
@@ -194,11 +194,11 @@
   "status.report": "Пожаловаться",
   "status.sensitive_toggle": "Нажмите для просмотра",
   "status.sensitive_warning": "Чувствительный контент",
-  "status.share": "Share",
+  "status.share": "Поделиться",
   "status.show_less": "Свернуть",
   "status.show_more": "Развернуть",
   "status.unmute_conversation": "Снять глушение с треда",
-  "status.unpin": "Unpin from profile",
+  "status.unpin": "Открепить от профиля",
   "tabs_bar.compose": "Написать",
   "tabs_bar.federated_timeline": "Глобальная",
   "tabs_bar.home": "Главная",
@@ -206,16 +206,16 @@
   "tabs_bar.notifications": "Уведомления",
   "upload_area.title": "Перетащите сюда, чтобы загрузить",
   "upload_button.label": "Добавить медиаконтент",
-  "upload_form.description": "Describe for the visually impaired",
+  "upload_form.description": "Описать для людей с нарушениями зрения",
   "upload_form.undo": "Отменить",
   "upload_progress.label": "Загрузка...",
-  "video.close": "Close video",
-  "video.exit_fullscreen": "Exit full screen",
-  "video.expand": "Expand video",
-  "video.fullscreen": "Full screen",
-  "video.hide": "Hide video",
-  "video.mute": "Mute sound",
-  "video.pause": "Pause",
-  "video.play": "Play",
-  "video.unmute": "Unmute sound"
+  "video.close": "Закрыть видео",
+  "video.exit_fullscreen": "Покинуть полноэкранный режим",
+  "video.expand": "Развернуть видео",
+  "video.fullscreen": "Полноэкранный режим",
+  "video.hide": "Скрыть видео",
+  "video.mute": "Заглушить звук",
+  "video.pause": "Пауза",
+  "video.play": "Пуск",
+  "video.unmute": "Включить звук"
 }
diff --git a/app/javascript/mastodon/reducers/settings.js b/app/javascript/mastodon/reducers/settings.js
index 0c0dae388..4b8a652d1 100644
--- a/app/javascript/mastodon/reducers/settings.js
+++ b/app/javascript/mastodon/reducers/settings.js
@@ -58,6 +58,12 @@ const initialState = ImmutableMap({
       body: '',
     }),
   }),
+
+  direct: ImmutableMap({
+    regex: ImmutableMap({
+      body: '',
+    }),
+  }),
 });
 
 const defaultColumns = fromJS([
diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss
index 076aa9576..2506bbe62 100644
--- a/app/javascript/styles/mastodon/components.scss
+++ b/app/javascript/styles/mastodon/components.scss
@@ -2503,6 +2503,7 @@ button.icon-button.active i.fa-retweet {
 }
 
 .column-header {
+  display: flex;
   padding: 15px;
   font-size: 16px;
   background: lighten($ui-base-color, 4%);
@@ -2528,12 +2529,10 @@ button.icon-button.active i.fa-retweet {
 }
 
 .column-header__buttons {
-  position: absolute;
-  right: 0;
-  top: 0;
-  height: 100%;
-  display: flex;
   height: 48px;
+  display: flex;
+  margin: -15px;
+  margin-left: 0;
 }
 
 .column-header__button {
@@ -2692,6 +2691,14 @@ button.icon-button.active i.fa-retweet {
   }
 }
 
+.column-header__title {
+  display: inline-block;
+  text-overflow: ellipsis;
+  overflow: hidden;
+  white-space: nowrap;
+  flex: 1;
+}
+
 .text-btn {
   display: inline-block;
   padding: 0;
@@ -3465,7 +3472,6 @@ button.icon-button.active i.fa-retweet {
   right: 0;
   bottom: 0;
   background: rgba($base-overlay-background, 0.7);
-  transform: translateZ(0);
 }
 
 .modal-root__container {
diff --git a/app/lib/activitypub/activity/create.rb b/app/lib/activitypub/activity/create.rb
index d6e9bc1de..376684c00 100644
--- a/app/lib/activitypub/activity/create.rb
+++ b/app/lib/activitypub/activity/create.rb
@@ -53,9 +53,9 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
   end
 
   def process_tags(status)
-    return unless @object['tag'].is_a?(Array)
+    return if @object['tag'].nil?
 
-    @object['tag'].each do |tag|
+    as_array(@object['tag']).each do |tag|
       case tag['type']
       when 'Hashtag'
         process_hashtag tag, status
@@ -103,9 +103,9 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
   end
 
   def process_attachments(status)
-    return unless @object['attachment'].is_a?(Array)
+    return if @object['attachment'].nil?
 
-    @object['attachment'].each do |attachment|
+    as_array(@object['attachment']).each do |attachment|
       next if unsupported_media_type?(attachment['mediaType']) || attachment['url'].blank?
 
       href             = Addressable::URI.parse(attachment['url']).normalize.to_s
diff --git a/app/lib/feed_manager.rb b/app/lib/feed_manager.rb
index ca15745cb..2ddfac336 100644
--- a/app/lib/feed_manager.rb
+++ b/app/lib/feed_manager.rb
@@ -141,6 +141,8 @@ class FeedManager
     return false if receiver_id == status.account_id
     return true  if status.reply? && (status.in_reply_to_id.nil? || status.in_reply_to_account_id.nil?)
 
+    return true if keyword_filter?(status, Glitch::KeywordMute.matcher_for(receiver_id))
+
     check_for_mutes = [status.account_id]
     check_for_mutes.concat(status.mentions.pluck(:account_id))
     check_for_mutes.concat([status.reblog.account_id]) if status.reblog?
@@ -166,6 +168,18 @@ class FeedManager
     false
   end
 
+  def keyword_filter?(status, matcher)
+    should_filter   = matcher =~ status.text
+    should_filter ||= matcher =~ status.spoiler_text
+
+    if status.reblog?
+      should_filter ||= matcher =~ status.reblog.text
+      should_filter ||= matcher =~ status.reblog.spoiler_text
+    end
+
+    !!should_filter
+  end
+
   def filter_from_mentions?(status, receiver_id)
     return true if receiver_id == status.account_id
 
@@ -175,6 +189,7 @@ class FeedManager
 
     should_filter   = Block.where(account_id: receiver_id, target_account_id: check_for_blocks).any?                                     # Filter if it's from someone I blocked, in reply to someone I blocked, or mentioning someone I blocked
     should_filter ||= (status.account.silenced? && !Follow.where(account_id: receiver_id, target_account_id: status.account_id).exists?) # of if the account is silenced and I'm not following them
+    should_filter ||= keyword_filter?(status, Glitch::KeywordMute.matcher_for(receiver_id))                                              # or if the mention contains a muted keyword
 
     should_filter
   end
diff --git a/app/models/custom_emoji.rb b/app/models/custom_emoji.rb
index 65d9840d5..28b6a2b0b 100644
--- a/app/models/custom_emoji.rb
+++ b/app/models/custom_emoji.rb
@@ -15,6 +15,7 @@
 #  disabled           :boolean          default(FALSE), not null
 #  uri                :string
 #  image_remote_url   :string
+#  visible_in_picker  :boolean          default(TRUE), not null
 #
 
 class CustomEmoji < ApplicationRecord
diff --git a/app/models/glitch.rb b/app/models/glitch.rb
new file mode 100644
index 000000000..0e497babc
--- /dev/null
+++ b/app/models/glitch.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+module Glitch
+  def self.table_name_prefix
+    'glitch_'
+  end
+end
diff --git a/app/models/glitch/keyword_mute.rb b/app/models/glitch/keyword_mute.rb
new file mode 100644
index 000000000..73de4d4b7
--- /dev/null
+++ b/app/models/glitch/keyword_mute.rb
@@ -0,0 +1,66 @@
+# frozen_string_literal: true
+# == Schema Information
+#
+# Table name: glitch_keyword_mutes
+#
+#  id         :integer          not null, primary key
+#  account_id :integer          not null
+#  keyword    :string           not null
+#  whole_word :boolean          default(TRUE), not null
+#  created_at :datetime         not null
+#  updated_at :datetime         not null
+#
+
+class Glitch::KeywordMute < ApplicationRecord
+  belongs_to :account, required: true
+
+  validates_presence_of :keyword
+
+  after_commit :invalidate_cached_matcher
+
+  def self.matcher_for(account_id)
+    Matcher.new(account_id)
+  end
+
+  private
+
+  def invalidate_cached_matcher
+    Rails.cache.delete("keyword_mutes:regex:#{account_id}")
+  end
+
+  class Matcher
+    attr_reader :account_id
+    attr_reader :regex
+
+    def initialize(account_id)
+      @account_id = account_id
+      regex_text = Rails.cache.fetch("keyword_mutes:regex:#{account_id}") { regex_text_for_account }
+      @regex = /#{regex_text}/i
+    end
+
+    def =~(str)
+      regex =~ str
+    end
+
+    private
+
+    def keywords
+      Glitch::KeywordMute.where(account_id: account_id).select(:keyword, :id, :whole_word)
+    end
+
+    def regex_text_for_account
+      kws = keywords.find_each.with_object([]) do |kw, a|
+        a << (kw.whole_word ? boundary_regex_for_keyword(kw.keyword) : kw.keyword)
+      end
+
+      Regexp.union(kws).source
+    end
+
+    def boundary_regex_for_keyword(keyword)
+      sb = keyword =~ /\A[[:word:]]/ ? '\b' : ''
+      eb = keyword =~ /[[:word:]]\Z/ ? '\b' : ''
+
+      /#{sb}#{Regexp.escape(keyword)}#{eb}/
+    end
+  end
+end
diff --git a/app/models/status.rb b/app/models/status.rb
index 30d53f298..d78a921b5 100644
--- a/app/models/status.rb
+++ b/app/models/status.rb
@@ -154,6 +154,14 @@ class Status < ApplicationRecord
       where(account: [account] + account.following).where(visibility: [:public, :unlisted, :private])
     end
 
+    def as_direct_timeline(account)
+      query = joins("LEFT OUTER JOIN mentions ON statuses.id = mentions.status_id AND mentions.account_id = #{account.id}")
+              .where("mentions.account_id = #{account.id} OR statuses.account_id = #{account.id}")
+              .where(visibility: [:direct])
+
+      apply_timeline_filters(query, account, false)
+    end
+
     def as_public_timeline(account = nil, local_only = false)
       query = timeline_scope(local_only).without_replies
 
diff --git a/app/serializers/rest/custom_emoji_serializer.rb b/app/serializers/rest/custom_emoji_serializer.rb
index b958e6a5d..65686a866 100644
--- a/app/serializers/rest/custom_emoji_serializer.rb
+++ b/app/serializers/rest/custom_emoji_serializer.rb
@@ -3,7 +3,7 @@
 class REST::CustomEmojiSerializer < ActiveModel::Serializer
   include RoutingHelper
 
-  attributes :shortcode, :url, :static_url
+  attributes :shortcode, :url, :static_url, :visible_in_picker
 
   def url
     full_asset_url(object.image.url)
diff --git a/app/services/batched_remove_status_service.rb b/app/services/batched_remove_status_service.rb
index 5d83771c9..aa2229f13 100644
--- a/app/services/batched_remove_status_service.rb
+++ b/app/services/batched_remove_status_service.rb
@@ -40,6 +40,7 @@ class BatchedRemoveStatusService < BaseService
     # Cannot be batched
     statuses.each do |status|
       unpush_from_public_timelines(status)
+      unpush_from_direct_timelines(status) if status.direct_visibility?
       batch_salmon_slaps(status) if status.local?
     end
 
@@ -100,6 +101,16 @@ class BatchedRemoveStatusService < BaseService
     end
   end
 
+  def unpush_from_direct_timelines(status)
+    payload = @json_payloads[status.id]
+    redis.pipelined do
+      @mentions[status.id].each do |mention|
+        redis.publish("timeline:direct:#{mention.account.id}", payload) if mention.account.local?
+      end
+      redis.publish("timeline:direct:#{status.account.id}", payload) if status.account.local?
+    end
+  end
+
   def batch_salmon_slaps(status)
     return if @mentions[status.id].empty?
 
diff --git a/app/services/fan_out_on_write_service.rb b/app/services/fan_out_on_write_service.rb
index 47a47a735..2214d73dd 100644
--- a/app/services/fan_out_on_write_service.rb
+++ b/app/services/fan_out_on_write_service.rb
@@ -10,15 +10,17 @@ class FanOutOnWriteService < BaseService
 
     deliver_to_self(status) if status.account.local?
 
+    render_anonymous_payload(status)
+
     if status.direct_visibility?
       deliver_to_mentioned_followers(status)
+      deliver_to_direct_timelines(status)
     else
       deliver_to_followers(status)
     end
 
     return if status.account.silenced? || !status.public_visibility? || status.reblog?
 
-    render_anonymous_payload(status)
     deliver_to_hashtags(status)
 
     return if status.reply? && status.in_reply_to_account_id != status.account_id
@@ -73,4 +75,13 @@ class FanOutOnWriteService < BaseService
     Redis.current.publish('timeline:public', @payload)
     Redis.current.publish('timeline:public:local', @payload) if status.local?
   end
+
+  def deliver_to_direct_timelines(status)
+    Rails.logger.debug "Delivering status #{status.id} to direct timelines"
+
+    status.mentions.includes(:account).each do |mention|
+      Redis.current.publish("timeline:direct:#{mention.account.id}", @payload) if mention.account.local?
+    end
+    Redis.current.publish("timeline:direct:#{status.account.id}", @payload) if status.account.local?
+  end
 end
diff --git a/app/services/remove_status_service.rb b/app/services/remove_status_service.rb
index 96d9208cc..8eef3e57e 100644
--- a/app/services/remove_status_service.rb
+++ b/app/services/remove_status_service.rb
@@ -18,6 +18,7 @@ class RemoveStatusService < BaseService
     remove_reblogs
     remove_from_hashtags
     remove_from_public
+    remove_from_direct if status.direct_visibility?
 
     @status.destroy!
 
@@ -121,6 +122,13 @@ class RemoveStatusService < BaseService
     Redis.current.publish('timeline:public:local', @payload) if @status.local?
   end
 
+  def remove_from_direct
+    @mentions.each do |mention|
+      Redis.current.publish("timeline:direct:#{mention.account.id}", @payload) if mention.account.local?
+    end
+    Redis.current.publish("timeline:direct:#{@account.id}", @payload) if @account.local?
+  end
+
   def redis
     Redis.current
   end
diff --git a/app/views/admin/custom_emojis/_custom_emoji.html.haml b/app/views/admin/custom_emojis/_custom_emoji.html.haml
index 1fa64908c..399d13bbd 100644
--- a/app/views/admin/custom_emojis/_custom_emoji.html.haml
+++ b/app/views/admin/custom_emojis/_custom_emoji.html.haml
@@ -9,7 +9,12 @@
     - else
       = custom_emoji.domain
   %td
-    - unless custom_emoji.local?
+    - if custom_emoji.local?
+      - if custom_emoji.visible_in_picker
+        = table_link_to 'eye', t('admin.custom_emojis.listed'), admin_custom_emoji_path(custom_emoji, custom_emoji: { visible_in_picker: false }), method: :patch
+      - else
+        = table_link_to 'eye-slash', t('admin.custom_emojis.unlisted'), admin_custom_emoji_path(custom_emoji, custom_emoji: { visible_in_picker: true }), method: :patch
+    - else
       = table_link_to 'copy', t('admin.custom_emojis.copy'), copy_admin_custom_emoji_path(custom_emoji, page: params[:page]), method: :post
   %td
     - if custom_emoji.disabled?
diff --git a/app/views/home/index.html.haml b/app/views/home/index.html.haml
index c3f5cb842..cba2bbbd4 100644
--- a/app/views/home/index.html.haml
+++ b/app/views/home/index.html.haml
@@ -5,6 +5,7 @@
   %link{ href: asset_pack_path('features/notifications.js'), crossorigin: 'anonymous', rel: 'preload', as: 'script' }/
   %link{ href: asset_pack_path('features/community_timeline.js'), crossorigin: 'anonymous', rel: 'preload', as: 'script' }/
   %link{ href: asset_pack_path('features/public_timeline.js'), crossorigin: 'anonymous', rel: 'preload', as: 'script' }/
+  %link{ href: asset_pack_path('features/direct_timeline.js'), crossorigin: 'anonymous', rel: 'preload', as: 'script' }/
   %meta{name: 'applicationServerKey', content: Rails.configuration.x.vapid_public_key}
   %script#initial-state{ type: 'application/json' }!= json_escape(@initial_state_json)
 
diff --git a/app/views/settings/keyword_mutes/_fields.html.haml b/app/views/settings/keyword_mutes/_fields.html.haml
new file mode 100644
index 000000000..892676f18
--- /dev/null
+++ b/app/views/settings/keyword_mutes/_fields.html.haml
@@ -0,0 +1,11 @@
+.fields-group
+  = f.input :keyword
+  = f.check_box :whole_word
+  = f.label :whole_word, t('keyword_mutes.match_whole_word')
+
+.actions
+  - if f.object.persisted?
+    = f.button :button, t('generic.save_changes'), type: :submit
+    = link_to t('keyword_mutes.remove'), settings_keyword_mute_path(f.object), class: 'negative button', method: :delete, data: { confirm: t('admin.accounts.are_you_sure') }
+  - else
+    = f.button :button, t('keyword_mutes.add_keyword'), type: :submit
diff --git a/app/views/settings/keyword_mutes/_keyword_mute.html.haml b/app/views/settings/keyword_mutes/_keyword_mute.html.haml
new file mode 100644
index 000000000..c45cc64fb
--- /dev/null
+++ b/app/views/settings/keyword_mutes/_keyword_mute.html.haml
@@ -0,0 +1,10 @@
+%tr
+  %td
+    = keyword_mute.keyword
+  %td
+    - if keyword_mute.whole_word
+      %i.fa.fa-check
+  %td
+    = table_link_to 'edit', t('keyword_mutes.edit'), edit_settings_keyword_mute_path(keyword_mute)
+  %td
+    = table_link_to 'times', t('keyword_mutes.remove'), settings_keyword_mute_path(keyword_mute), method: :delete, data: { confirm: t('admin.accounts.are_you_sure') }
diff --git a/app/views/settings/keyword_mutes/edit.html.haml b/app/views/settings/keyword_mutes/edit.html.haml
new file mode 100644
index 000000000..af3949be2
--- /dev/null
+++ b/app/views/settings/keyword_mutes/edit.html.haml
@@ -0,0 +1,6 @@
+- content_for :page_title do
+  = t('keyword_mutes.edit_keyword')
+
+= simple_form_for @keyword_mute, url: settings_keyword_mute_path(@keyword_mute), as: :keyword_mute do |f|
+  = render 'shared/error_messages', object: @keyword_mute
+  = render 'fields', f: f
diff --git a/app/views/settings/keyword_mutes/index.html.haml b/app/views/settings/keyword_mutes/index.html.haml
new file mode 100644
index 000000000..9ef8d55bc
--- /dev/null
+++ b/app/views/settings/keyword_mutes/index.html.haml
@@ -0,0 +1,18 @@
+- content_for :page_title do
+  = t('settings.keyword_mutes')
+
+.table-wrapper
+  %table.table
+    %thead
+      %tr
+        %th= t('keyword_mutes.keyword')
+        %th= t('keyword_mutes.match_whole_word')
+        %th
+        %th
+      %tbody
+        = render partial: 'keyword_mute', collection: @keyword_mutes, as: :keyword_mute
+
+= paginate @keyword_mutes
+.simple_form
+  = link_to t('keyword_mutes.add_keyword'), new_settings_keyword_mute_path, class: 'button'
+  = link_to t('keyword_mutes.remove_all'), destroy_all_settings_keyword_mutes_path, class: 'button negative', method: :delete, data: { confirm: t('admin.accounts.are_you_sure') }
diff --git a/app/views/settings/keyword_mutes/new.html.haml b/app/views/settings/keyword_mutes/new.html.haml
new file mode 100644
index 000000000..5c999c8d2
--- /dev/null
+++ b/app/views/settings/keyword_mutes/new.html.haml
@@ -0,0 +1,6 @@
+- content_for :page_title do
+  = t('keyword_mutes.add_keyword')
+
+= simple_form_for @keyword_mute, url: settings_keyword_mutes_path, as: :keyword_mute do |f|
+  = render 'shared/error_messages', object: @keyword_mute
+  = render 'fields', f: f
diff --git a/config/environments/production.rb b/config/environments/production.rb
index e0ee393c1..f7cb4b08a 100644
--- a/config/environments/production.rb
+++ b/config/environments/production.rb
@@ -97,6 +97,8 @@ Rails.application.configure do
     'X-XSS-Protection'        => '1; mode=block',
     'Content-Security-Policy' => "frame-ancestors 'none'; object-src 'none'; script-src 'self' https://dev-static.glitch.social 'unsafe-inline'; base-uri 'none';" , 
     'Referrer-Policy'         => 'no-referrer, strict-origin-when-cross-origin',
-    'Strict-Transport-Security' => 'max-age=63072000; includeSubDomains; preload'
+    'Strict-Transport-Security' => 'max-age=63072000; includeSubDomains; preload',
+    'X-Clacks-Overhead' => 'GNU Natalie Nguyen'
+
   }
 end
diff --git a/config/locales/en.yml b/config/locales/en.yml
index 45929e97d..d5c46470c 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -130,11 +130,15 @@ en:
       enable: Enable
       enabled_msg: Successfully enabled that emoji
       image_hint: PNG up to 50KB
+      listed: Listed
       new:
         title: Add new custom emoji
       shortcode: Shortcode
       shortcode_hint: At least 2 characters, only alphanumeric characters and underscores
       title: Custom emojis
+      unlisted: Unlisted
+      update_failed_msg: Could not update that emoji
+      updated_msg: Emoji successfully updated!
       upload: Upload
     domain_blocks:
       add_new: Add new
@@ -373,6 +377,14 @@ en:
       following: Following list
       muting: Muting list
     upload: Upload
+  keyword_mutes:
+    add_keyword: Add keyword
+    edit: Edit
+    edit_keyword: Edit keyword
+    keyword: Keyword
+    match_whole_word: Match whole word
+    remove: Remove
+    remove_all: Remove all
   landing_strip_html: "<strong>%{name}</strong> is a user on %{link_to_root_path}. You can follow them or interact with them if you have an account anywhere in the fediverse."
   landing_strip_signup_html: If you don't, you can <a href="%{sign_up_path}">sign up here</a>.
   media_attachments:
@@ -491,6 +503,7 @@ en:
     export: Data export
     followers: Authorized followers
     import: Import
+    keyword_mutes: Muted keywords
     notifications: Notifications
     preferences: Preferences
     settings: Settings
diff --git a/config/locales/ru.yml b/config/locales/ru.yml
index 9ca08831e..7c9caec14 100644
--- a/config/locales/ru.yml
+++ b/config/locales/ru.yml
@@ -1,39 +1,77 @@
 ---
 ru:
   about:
+    about_hashtag_html: Это публичные статусы, отмеченные хэштегом <strong>#%{hashtag}</strong>. Вы можете взаимодействовать с ними при наличии у Вас аккаунта в глобальной сети Mastodon.
     about_mastodon_html: Mastodon - это <em>свободная</em> социальная сеть с <em>открытым исходным кодом</em>. Как <em>децентрализованная</em> альтернатива коммерческим платформам, Mastodon предотвращает риск монополизации Вашего общения одной компанией. Выберите сервер, которому Вы доверяете &mdash; что бы Вы ни выбрали, Вы сможете общаться со всеми остальными. Любой может запустить свой собственный узел Mastodon и участвовать в <em>социальной сети</em> совершенно бесшовно.
     about_this: Об этом узле
     closed_registrations: В данный момент регистрация на этом узле закрыта.
     contact: Связаться
+    contact_missing: Не установлено
+    contact_unavailable: Недоступен
     description_headline: Что такое %{domain}?
     domain_count_after: другими узлами
     domain_count_before: Связан с
+    extended_description_html: |
+      <h3>Хорошее место для правил</h3>
+      <p>Расширенное описание еще не настроено.</p>
+    features:
+      humane_approach_body: Наученный ошибками других проектов, Mastodon направлен на выбор этичных решений в борьбе со злоупотреблениями возможностями социальных сетей.
+      humane_approach_title: Человечный подход
+      not_a_product_body: Mastodon -  не коммерческая сеть. Здесь нет рекламы, сбора данных, отгороженных мест. Здесь нет централизованного управления.
+      not_a_product_title: Вы - человек, а не продукт
+      real_conversation_body: С 500 символами в Вашем распоряжении и поддержкой предупреждений о содержании статусов Вы сможете выражать свои мысли так, как Вы этого хотите.
+      real_conversation_title: Создан для настоящего общения
+      within_reach_body: Различные приложения для iOS, Android и других платформ, написанные благодаря дружественной к разработчикам экосистеме API, позволят Вам держать связь с Вашими друзьями где угодно.
+      within_reach_title: Всегда под рукой
+    find_another_instance: Найти другой узел
+    generic_description: "%{domain} - один из серверов сети"
+    hosted_on: Mastodon размещен на %{domain}
+    learn_more: Узнать больше
     other_instances: Другие узлы
     source_code: Исходный код
     status_count_after: статусов
     status_count_before: Опубликовано
     user_count_after: пользователей
     user_count_before: Здесь живет
+    what_is_mastodon: Что такое Mastodon?
   accounts:
     follow: Подписаться
     followers: Подписчики
     following: Подписан(а)
+    media: Медиаконтент
     nothing_here: Здесь ничего нет!
     people_followed_by: Люди, на которых подписан(а) %{name}
     people_who_follow: Подписчики %{name}
     posts: Посты
+    posts_with_replies: Посты с ответами
     remote_follow: Подписаться на удаленном узле
+    reserved_username: Имя пользователя зарезервировано
+    roles:
+      admin: Администратор
     unfollow: Отписаться
   admin:
+    account_moderation_notes:
+      account: Модератор
+      create: Создать
+      created_at: Дата
+      created_msg: Заметка модератора успешно создана!
+      delete: Удалить
+      destroyed_msg: Заметка модератора успешно удалена!
     accounts:
       are_you_sure: Вы уверены?
+      confirm: Подтвердить
+      confirmed: Подтверждено
+      disable_two_factor_authentication: Отключить 2FA
       display_name: Отображаемое имя
       domain: Домен
       edit: Изменить
       email: E-mail
       feed_url: URL фида
       followers: Подписчики
+      followers_url: URL подписчиков
       follows: Подписки
+      inbox_url: URL входящих
+      ip: IP
       location:
         all: Все
         local: Локальные
@@ -45,6 +83,7 @@ ru:
         silenced: Заглушенные
         suspended: Заблокированные
         title: Модерация
+      moderation_notes: Заметки модератора
       most_recent_activity: Последняя активность
       most_recent_ip: Последний IP
       not_subscribed: Не подписаны
@@ -52,19 +91,51 @@ ru:
         alphabetic: По алфавиту
         most_recent: По дате
         title: Порядок
+      outbox_url: URL исходящих
       perform_full_suspension: Полная блокировка
       profile_url: URL профиля
+      protocol: Протокол
       public: Публичный
       push_subscription_expires: Подписка PuSH истекает
+      redownload: Обновить аватар
+      reset: Сбросить
       reset_password: Сбросить пароль
+      resubscribe: Переподписаться
       salmon_url: Salmon URL
+      search: Поиск
+      shared_inbox_url: URL общих входящих
+      show:
+        created_reports: Жалобы, отправленные этим аккаунтом
+        report: жалоба
+        targeted_reports: Жалобы на этот аккаунт
       silence: Глушение
       statuses: Статусы
+      subscribe: Подписаться
       title: Аккаунты
       undo_silenced: Снять глушение
       undo_suspension: Снять блокировку
+      unsubscribe: Отписаться
       username: Имя пользователя
       web: WWW
+    custom_emojis:
+      copied_msg: Локальная копия эмодзи успешно создана
+      copy: Скопироват
+      copy_failed_msg: Не удалось создать локальную копию эмодзи
+      created_msg: Эмодзи успешно создано!
+      delete: Удалить
+      destroyed_msg: Эмодзи успешно удалено!
+      disable: Отключить
+      disabled_msg: Эмодзи успешно отключено
+      emoji: Эмодзи
+      enable: Включить
+      enabled_msg: Эмодзи успешно включено
+      image_hint: PNG до 50KB
+      new:
+        title: Добавить новое эмодзи
+      shortcode: Шорткод
+      shortcode_hint: Как минимум 2 символа, только алфавитно-цифровые символы и подчеркивания
+      title: Собственные эмодзи
+      upload: Загрузить
     domain_blocks:
       add_new: Добавить новую
       created_msg: Блокировка домена обрабатывается
@@ -74,13 +145,15 @@ ru:
         create: Создать блокировку
         hint: Блокировка домена не предотвратит создание новых аккаунтов в базе данных, но ретроактивно и автоматически применит указанные методы модерации для этих аккаунтов.
         severity:
-          desc_html: "<strong>Глушение</strong> сделает статусы аккаунта невидимыми для всех, кроме их подписчиков. <strong>Блокировка</strong> удалит весь контент аккаунта, включая мультимедийные вложения и данные профиля."
+          desc_html: "<strong>Глушение</strong> сделает статусы аккаунта невидимыми для всех, кроме их подписчиков. <strong>Блокировка</strong> удалит весь контент аккаунта, включая мультимедийные вложения и данные профиля. Используйте <strong>Ничего</strong>, если хотите только запретить медиаконтент."
+          noop: Ничего
           silence: Глушение
           suspend: Блокировка
         title: Новая доменная блокировка
       reject_media: Запретить медиаконтент
       reject_media_hint: Удаляет локально хранимый медиаконтент и запрещает его загрузку в будущем. Не имеет значения в случае блокировки.
       severities:
+        noop: Ничего
         silence: Глушение
         suspend: Блокировка
       severity: Строгость
@@ -97,13 +170,34 @@ ru:
         undo: Отменить
       title: Доменные блокировки
       undo: Отемнить
+    email_domain_blocks:
+      add_new: Добавить новую
+      created_msg: Доменная блокировка еmail успешно создана
+      delete: Удалить
+      destroyed_msg: Доменная блокировка еmail успешно удалена
+      domain: Домен
+      new:
+        create: Создать блокировку
+        title: Новая доменная блокировка еmail
+      title: Доменная блокировка email
+    instances:
+      account_count: Известных аккаунтов
+      domain_name: Домен
+      reset: Сбросить
+      search: Поиск
+      title: Известные узлы
     reports:
+      action_taken_by: 'Действие предпринято:'
+      are_you_sure: Вы уверены?
       comment:
         label: Комментарий
         none: Нет
       delete: Удалить
       id: ID
       mark_as_resolved: Отметить как разрешенную
+      nsfw:
+        'false': Показать мультимедийные вложения
+        'true': Скрыть мультимедийные вложения
       report: 'Жалоба #%{id}'
       reported_account: Аккаунт нарушителя
       reported_by: Отправитель жалобы
@@ -116,6 +210,9 @@ ru:
       unresolved: Неразрешенные
       view: Просмотреть
     settings:
+      bootstrap_timeline_accounts:
+        desc_html: Разделяйте имена пользователей запятыми. Сработает только для локальных незакрытых аккаунтов. По умолчанию включены все локальные администраторы.
+        title: Подписки по умолчанию для новых пользователей
       contact_information:
         email: Введите публичный e-mail
         username: Введите имя пользователя
@@ -123,7 +220,11 @@ ru:
         closed_message:
           desc_html: Отображается на титульной странице, когда закрыта регистрация<br>Можно использовать HTML-теги
           title: Сообщение о закрытой регистрации
+        deletion:
+          desc_html: Позволяет всем удалять собственные аккаунты
+          title: Разрешить удаление аккаунтов
         open:
+          desc_html: Позволяет любому создавать аккаунт
           title: Открыть регистрацию
       site_description:
         desc_html: Отображается в качестве параграфа на титульной странице и используется в качестве мета-тега.<br>Можно использовать HTML-теги, в особенности <code>&lt;a&gt;</code> и <code>&lt;em&gt;</code>.
@@ -131,8 +232,32 @@ ru:
       site_description_extended:
         desc_html: Отображается на странице дополнительной информации<br>Можно использовать HTML-теги
         title: Расширенное описание сайта
+      site_terms:
+        desc_html: Вы можете добавить сюда собственную политику конфиденциальности, пользовательское соглашение и другие документы. Можно использовать теги HTML.
+        title: Условия использования
       site_title: Название сайта
+      thumbnail:
+        desc_html: Используется для предпросмотра с помощью OpenGraph и API. Рекомендуется разрешение 1200x630px
+        title: Картинка узла
+      timeline_preview:
+        desc_html: Показывать публичную ленту на целевой странице
+        title: Предпросмотр ленты
       title: Настройки сайта
+    statuses:
+      back_to_account: Назад к странице аккаунта
+      batch:
+        delete: Удалить
+        nsfw_off: Выключить NSFW
+        nsfw_on: Включить NSFW
+      execute: Выполнить
+      failed_to_execute: Не удалось выполнить
+      media:
+        hide: Скрыть медиаконтент
+        show: Показать медиаконтент
+        title: Медиаконтент
+      no_media: Без медиаконтента
+      title: Статусы аккаунта
+      with_media: С медиаконтентом
     subscriptions:
       callback_url: Callback URL
       confirmed: Подтверждено
@@ -141,18 +266,31 @@ ru:
       title: WebSub
       topic: Тема
     title: Администрирование
+  admin_mailer:
+    new_report:
+      body: "%{reporter} подал(а) жалобу на %{target}"
+      subject: Новая жалоба, узел %{instance} (#%{id})
   application_mailer:
+    salutation: "%{name},"
     settings: 'Изменить настройки e-mail: %{link}'
     signature: Уведомления Mastodon от %{instance}
     view: 'Просмотр:'
   applications:
+    created: Приложение успешно создано
+    destroyed: Приложение успешно удалено
     invalid_url: Введенный URL неверен
+    regenerate_token: Повторно сгенерировать токен доступа
+    token_regenerated: Токен доступа успешно сгенерирован
+    warning: Будьте очень внимательны с этими данными. Не делитесь ими ни с кем!
+    your_token: Ваш токен доступа
   auth:
+    agreement_html: Создавая аккаунт, вы соглашаетесь с <a href="%{rules_path}">нашими правилами поведения</a> и <a href="%{terms_path}">политикой конфиденциальности</a>.
     change_password: Изменить пароль
     delete_account: Удалить аккаунт
     delete_account_html: Если Вы хотите удалить свой аккаунт, вы можете <a href="%{path}">перейти сюда</a>. У Вас будет запрошено подтверждение.
     didnt_get_confirmation: Не получили инструкцию для подтверждения?
     forgot_password: Забыли пароль?
+    invalid_reset_password_token: Токен сброса пароля неверен или устарел. Пожалуйста, запросите новый.
     login: Войти
     logout: Выйти
     register: Зарегистрироваться
@@ -162,6 +300,12 @@ ru:
   authorize_follow:
     error: К сожалению, при поиске удаленного аккаунта возникла ошибка
     follow: Подписаться
+    follow_request: 'Вы отправили запрос на подписку:'
+    following: 'Ура! Теперь Вы подписаны на:'
+    post_follow:
+      close: Или просто закрыть это окно.
+      return: Вернуться к профилю пользователя
+      web: Перейти к WWW
     title: Подписаться на %{acct}
   datetime:
     distance_in_words:
@@ -193,7 +337,10 @@ ru:
       content: Проверка безопасности не удалась. Возможно, Вы блокируете cookies?
       title: Проверка безопасности не удалась.
     '429': Слишком много запросов
-    noscript_html: Для работы с Mastodon, пожалуйста, включите JavaScript.
+    '500':
+      content: Приносим извинения, но на нашей стороне что-то пошло не так.
+      title: Страница неверна
+    noscript_html: Для работы с Mastodon, пожалуйста, включите JavaScript. Кроме того, вы можете использовать одно из <a href="https://github.com/tootsuite/documentation/blob/master/Using-Mastodon/Apps.md">приложений</a> Mastodon для Вашей платформы.
   exports:
     blocks: Список блокировки
     csv: CSV
@@ -265,23 +412,30 @@ ru:
   number:
     human:
       decimal_units:
-        format: "%n%u"
+        format: "%n %u"
         units:
-          billion: B
-          million: M
+          billion: млрд
+          million: млн
           quadrillion: Q
-          thousand: K
-          trillion: T
+          thousand: тыс
+          trillion: трлн
           unit: ''
   pagination:
     next: След
     prev: Пред
     truncate: "&hellip;"
+  preferences:
+    languages: Языки
+    other: Другое
+    publishing: Публикация
+    web: WWW
   push_notifications:
     favourite:
       title: Ваш статус понравился %{name}
     follow:
       title: "%{name} теперь подписан(а) на Вас"
+    group:
+      title: "%{count} уведомлений"
     mention:
       action_boost: Продвинуть
       action_expand: Развернуть
@@ -335,16 +489,24 @@ ru:
     authorized_apps: Авторизованные приложения
     back: Назад в Mastodon
     delete: Удаление аккаунта
+    development: Разработка
     edit_profile: Изменить профиль
     export: Экспорт данных
     followers: Авторизованные подписчики
     import: Импорт
+    notifications: Уведомления
     preferences: Настройки
     settings: Опции
     two_factor_authentication: Двухфакторная аутентификация
+    your_apps: Ваши приложения
   statuses:
     open_in_web: Открыть в WWW
     over_character_limit: превышен лимит символов (%{max})
+    pin_errors:
+      limit: Слишком много закрепленных статусов
+      ownership: Нельзя закрепить чужой статус
+      private: Нельзя закрепить непубличный статус
+      reblog: Нельзя закрепить продвинутый статус
     show_more: Подробнее
     visibilities:
       private: Для подписчиков
@@ -359,6 +521,8 @@ ru:
     sensitive_content: Чувствительный контент
   terms:
     title: Условия обслуживания и политика конфиденциальности %{instance}
+  themes:
+    default: Mastodon
   time:
     formats:
       default: "%b %d, %Y, %H:%M"
@@ -367,11 +531,13 @@ ru:
     description_html: При включении <strong>двухфакторной аутентификации</strong>, вход потребует от Вас использования Вашего телефона, который сгенерирует входные токены.
     disable: Отключить
     enable: Включить
+    enabled: Двухфакторная аутентификация включена
     enabled_success: Двухфакторная аутентификация успешно включена
     generate_recovery_codes: Сгенерировать коды восстановления
     instructions_html: "<strong>Отсканируйте этот QR-код с помощью Google Authenticator или другого подобного приложения на Вашем телефоне</strong>. С этого момента приложение будет генерировать токены, которые будет необходимо ввести для входа."
     lost_recovery_codes: Коды восстановления позволяют вернуть доступ к аккаунту в случае утери телефона. Если Вы потеряли Ваши коды восстановления, вы можете заново сгенерировать их здесь. Ваши старые коды восстановления будут аннулированы.
     manual_instructions: 'Если Вы не можете отсканировать QR-код и хотите ввести его вручную, секрет представлен здесь открытым текстом:'
+    recovery_codes: Коды восстановления
     recovery_codes_regenerated: Коды восстановления успешно сгенерированы
     recovery_instructions_html: В случае утери доступа к Вашему телефону Вы можете использовать один из кодов восстановления, указанных ниже, чтобы вернуть доступ к аккаунту. Держите коды восстановления в безопасности, например, распечатав их и храня с другими важными документами.
     setup: Настроить
@@ -379,3 +545,4 @@ ru:
   users:
     invalid_email: Введенный e-mail неверен
     invalid_otp_token: Введен неверный код
+    signed_in_as: 'Выполнен вход под именем:'
diff --git a/config/locales/simple_form.pt-BR.yml b/config/locales/simple_form.pt-BR.yml
index 22cae5271..9d60e0171 100644
--- a/config/locales/simple_form.pt-BR.yml
+++ b/config/locales/simple_form.pt-BR.yml
@@ -4,6 +4,7 @@ pt-BR:
     hints:
       defaults:
         avatar: PNG, GIF or JPG. Arquivos de até 2MB. Eles serão diminuídos para 120x120px
+        digest: Enviado após um longo período de inatividade com um resumo das menções que você recebeu em sua ausência.
         display_name:
           one: <span class="name-counter">1</span> caracter restante
           other: <span class="name-counter">%{count}</span> caracteres restantes
@@ -13,6 +14,7 @@ pt-BR:
           one: <span class="note-counter">1</span> caracter restante
           other: <span class="note-counter">%{count}</span> caracteres restantes
         setting_noindex: Afeta seu perfil público e as páginas de suas postagens
+        setting_theme: Afeta a aparência do Mastodon quando em sua conta em qualquer aparelho.
       imports:
         data: Arquivo CSV exportado de outra instância do Mastodon
       sessions:
@@ -42,7 +44,9 @@ pt-BR:
         setting_default_sensitive: Sempre marcar mídia como sensível
         setting_delete_modal: Mostrar diálogo de confirmação antes de deletar uma postagem
         setting_noindex: Não quero ser indexado por mecanismos de busca
+        setting_reduce_motion: Reduz movimento em animações
         setting_system_font_ui: Usar a fonte padrão de seu sistema
+        setting_theme: Tema do site
         setting_unfollow_modal: Mostrar diálogo de confirmação antes de deixar de seguir alguém
         severity: Gravidade
         type: Tipo de importação
diff --git a/config/locales/simple_form.ru.yml b/config/locales/simple_form.ru.yml
index 3bdb7870f..1b780ac26 100644
--- a/config/locales/simple_form.ru.yml
+++ b/config/locales/simple_form.ru.yml
@@ -4,6 +4,7 @@ ru:
     hints:
       defaults:
         avatar: PNG, GIF или JPG. Максимально 2MB. Будет уменьшено до 120x120px
+        digest: Отсылается после долгого периода неактивности с общей информацией упоминаний, полученных в Ваше отсутствие
         display_name:
           few: Осталось <span class="name-counter">%{count}</span> символа
           many: Осталось <span class="name-counter">%{count}</span> символов
@@ -17,6 +18,7 @@ ru:
           one: Остался <span class="name-counter">1</span> символ
           other: Осталось <span class="name-counter">%{count}</span> символов
         setting_noindex: Относится к Вашему публичному профилю и страницам статусов
+        setting_theme: Влияет на внешний вид Mastodon при выполненном входе в аккаунт.
       imports:
         data: Файл CSV, экспортированный с другого узла Mastodon
       sessions:
@@ -46,6 +48,8 @@ ru:
         setting_default_sensitive: Всегда отмечать медиаконтент как чувствительный
         setting_delete_modal: Показывать диалог подтверждения перед удалением
         setting_noindex: Отказаться от индексации в поисковых машинах
+        setting_reduce_motion: Уменьшить движение в анимации
+        setting_site_theme: Тема сайта
         setting_system_font_ui: Использовать шрифт системы по умолчанию
         setting_unfollow_modal: Показывать диалог подтверждения перед тем, как отписаться от аккаунта
         severity: Строгость
diff --git a/config/navigation.rb b/config/navigation.rb
index 50bfbd480..9fa029b72 100644
--- a/config/navigation.rb
+++ b/config/navigation.rb
@@ -7,6 +7,7 @@ SimpleNavigation::Configuration.run do |navigation|
     primary.item :settings, safe_join([fa_icon('cog fw'), t('settings.settings')]), settings_profile_url do |settings|
       settings.item :profile, safe_join([fa_icon('user fw'), t('settings.edit_profile')]), settings_profile_url
       settings.item :preferences, safe_join([fa_icon('sliders fw'), t('settings.preferences')]), settings_preferences_url
+      settings.item :keyword_mutes, safe_join([fa_icon('volume-off fw'), t('settings.keyword_mutes')]), settings_keyword_mutes_url
       settings.item :notifications, safe_join([fa_icon('bell fw'), t('settings.notifications')]), settings_notifications_url
       settings.item :password, safe_join([fa_icon('lock fw'), t('auth.change_password')]), edit_user_registration_url, highlights_on: %r{/auth/edit|/settings/delete}
       settings.item :two_factor_authentication, safe_join([fa_icon('mobile fw'), t('settings.two_factor_authentication')]), settings_two_factor_authentication_url, highlights_on: %r{/settings/two_factor_authentication}
diff --git a/config/routes.rb b/config/routes.rb
index 9ed081e50..aca613ed2 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -66,6 +66,13 @@ Rails.application.routes.draw do
 
   namespace :settings do
     resource :profile, only: [:show, :update]
+
+    resources :keyword_mutes do
+      collection do
+        delete :destroy_all
+      end
+    end
+
     resource :preferences, only: [:show, :update]
     resource :notifications, only: [:show, :update]
     resource :import, only: [:show, :create]
@@ -140,7 +147,7 @@ Rails.application.routes.draw do
       resource :two_factor_authentication, only: [:destroy]
     end
 
-    resources :custom_emojis, only: [:index, :new, :create, :destroy] do
+    resources :custom_emojis, only: [:index, :new, :create, :update, :destroy] do
       member do
         post :copy
         post :enable
@@ -193,6 +200,7 @@ Rails.application.routes.draw do
       end
 
       namespace :timelines do
+        resource :direct, only: :show, controller: :direct
         resource :home, only: :show, controller: :home
         resource :public, only: :show, controller: :public
         resources :tag, only: :show
diff --git a/db/migrate/20170920032311_fix_reblogs_in_feeds.rb b/db/migrate/20170920032311_fix_reblogs_in_feeds.rb
index c813ecd46..439c5fca0 100644
--- a/db/migrate/20170920032311_fix_reblogs_in_feeds.rb
+++ b/db/migrate/20170920032311_fix_reblogs_in_feeds.rb
@@ -3,48 +3,62 @@ class FixReblogsInFeeds < ActiveRecord::Migration[5.1]
     redis = Redis.current
     fm = FeedManager.instance
 
-    # find_each is batched on the database side.
-    User.includes(:account).find_each do |user|
-      account = user.account
+    # Old scheme:
+    # Each user's feed zset had a series of score:value entries,
+    # where "regular" statuses had the same score and value (their
+    # ID). Reblogs had a score of the reblogging status' ID, and a
+    # value of the reblogged status' ID.
 
-      # Old scheme:
-      # Each user's feed zset had a series of score:value entries,
-      # where "regular" statuses had the same score and value (their
-      # ID). Reblogs had a score of the reblogging status' ID, and a
-      # value of the reblogged status' ID.
-
-      # New scheme:
-      # The feed contains only entries with the same score and value.
-      # Reblogs result in the reblogging status being added to the
-      # feed, with an entry in a reblog tracking zset (where the score
-      # is once again set to the reblogging status' ID, and the value
-      # is set to the reblogged status' ID). This is safe for Redis'
-      # float coersion because in this reblog tracking zset, we only
-      # need the rebloggging status' ID to be able to stop tracking
-      # entries after they have gotten too far down the feed, which
-      # does not require an exact value.
-
-      # So, first, we iterate over the user's feed to find any reblogs.
-      timeline_key = fm.key(:home, account.id)
-      reblog_key = fm.key(:home, account.id, 'reblogs')
-      redis.zrange(timeline_key, 0, -1, with_scores: true).each do |entry|
-        next if entry[0] == entry[1]
+    # New scheme:
+    # The feed contains only entries with the same score and value.
+    # Reblogs result in the reblogging status being added to the
+    # feed, with an entry in a reblog tracking zset (where the score
+    # is once again set to the reblogging status' ID, and the value
+    # is set to the reblogged status' ID). This is safe for Redis'
+    # float coersion because in this reblog tracking zset, we only
+    # need the rebloggging status' ID to be able to stop tracking
+    # entries after they have gotten too far down the feed, which
+    # does not require an exact value.
+
+    # This process reads all feeds and writes 3 times for each reblogs.
+    # So we use Lua script to avoid overhead between Ruby and Redis.
+    script = <<-LUA
+      local timeline_key = KEYS[1]
+      local reblog_key = KEYS[2]
 
-        # The score and value don't match, so this is a reblog.
-        # (note that we're transitioning from IDs < 53 bits so we
-        # don't have to worry about the loss of precision)
+      -- So, first, we iterate over the user's feed to find any reblogs.
+      local items = redis.call('zrange', timeline_key, 0, -1, 'withscores')
+      
+      for i = 1, #items, 2 do
+        local reblogged_id = items[i]
+        local reblogging_id = items[i + 1]
+        if (reblogged_id ~= reblogging_id) then
 
-        reblogged_id, reblogging_id = entry
+          -- The score and value don't match, so this is a reblog.
+          -- (note that we're transitioning from IDs < 53 bits so we
+          -- don't have to worry about the loss of precision)
 
-        # Remove the old entry
-        redis.zrem(timeline_key, reblogged_id)
+          -- Remove the old entry
+          redis.call('zrem', timeline_key, reblogged_id)
 
-        # Add a new one for the reblogging status
-        redis.zadd(timeline_key, reblogging_id, reblogging_id)
+          -- Add a new one for the reblogging status
+          redis.call('zadd', timeline_key, reblogging_id, reblogging_id)
 
-        # Track the fact that this was a reblog
-        redis.zadd(reblog_key, reblogging_id, reblogged_id)
+          -- Track the fact that this was a reblog
+          redis.call('zadd', reblog_key, reblogging_id, reblogged_id)
+        end
       end
+    LUA
+    script_hash = redis.script(:load, script)
+
+    # find_each is batched on the database side.
+    User.includes(:account).find_each do |user|
+      account = user.account
+
+      timeline_key = fm.key(:home, account.id)
+      reblog_key = fm.key(:home, account.id, 'reblogs')
+
+      redis.evalsha(script_hash, [timeline_key, reblog_key])
     end
   end
 
diff --git a/db/migrate/20171009222537_create_keyword_mutes.rb b/db/migrate/20171009222537_create_keyword_mutes.rb
new file mode 100644
index 000000000..ec0c756fb
--- /dev/null
+++ b/db/migrate/20171009222537_create_keyword_mutes.rb
@@ -0,0 +1,12 @@
+class CreateKeywordMutes < ActiveRecord::Migration[5.1]
+  def change
+    create_table :keyword_mutes do |t|
+      t.references :account, null: false
+      t.string :keyword, null: false
+      t.boolean :whole_word, null: false, default: true
+      t.timestamps
+    end
+
+    add_foreign_key :keyword_mutes, :accounts, on_delete: :cascade
+  end
+end
diff --git a/db/migrate/20171020084748_add_visible_in_picker_to_custom_emoji.rb b/db/migrate/20171020084748_add_visible_in_picker_to_custom_emoji.rb
new file mode 100644
index 000000000..60a287101
--- /dev/null
+++ b/db/migrate/20171020084748_add_visible_in_picker_to_custom_emoji.rb
@@ -0,0 +1,7 @@
+class AddVisibleInPickerToCustomEmoji < ActiveRecord::Migration[5.1]
+  def change
+    safety_assured {
+      add_column :custom_emojis, :visible_in_picker, :boolean, default: true, null: false
+    }
+  end
+end
diff --git a/db/migrate/20171021191900_move_keyword_mutes_into_glitch_namespace.rb b/db/migrate/20171021191900_move_keyword_mutes_into_glitch_namespace.rb
new file mode 100644
index 000000000..269bb49d6
--- /dev/null
+++ b/db/migrate/20171021191900_move_keyword_mutes_into_glitch_namespace.rb
@@ -0,0 +1,7 @@
+class MoveKeywordMutesIntoGlitchNamespace < ActiveRecord::Migration[5.1]
+  def change
+    safety_assured do
+      rename_table :keyword_mutes, :glitch_keyword_mutes
+    end
+  end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 128f51ee7..f96a5340f 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: 20171010025614) do
+ActiveRecord::Schema.define(version: 20171021191900) do
 
   # These are extensions that must be enabled in order to support this database
   enable_extension "plpgsql"
@@ -111,6 +111,7 @@ ActiveRecord::Schema.define(version: 20171010025614) do
     t.boolean "disabled", default: false, null: false
     t.string "uri"
     t.string "image_remote_url"
+    t.boolean "visible_in_picker", default: true, null: false
     t.index ["shortcode", "domain"], name: "index_custom_emojis_on_shortcode_and_domain", unique: true
   end
 
@@ -155,6 +156,15 @@ ActiveRecord::Schema.define(version: 20171010025614) do
     t.index ["account_id", "target_account_id"], name: "index_follows_on_account_id_and_target_account_id", unique: true
   end
 
+  create_table "glitch_keyword_mutes", force: :cascade do |t|
+    t.bigint "account_id", null: false
+    t.string "keyword", null: false
+    t.boolean "whole_word", default: true, null: false
+    t.datetime "created_at", null: false
+    t.datetime "updated_at", null: false
+    t.index ["account_id"], name: "index_glitch_keyword_mutes_on_account_id"
+  end
+
   create_table "imports", force: :cascade do |t|
     t.integer "type", null: false
     t.boolean "approved", default: false, null: false
@@ -472,6 +482,7 @@ ActiveRecord::Schema.define(version: 20171010025614) do
   add_foreign_key "follow_requests", "accounts", name: "fk_76d644b0e7", on_delete: :cascade
   add_foreign_key "follows", "accounts", column: "target_account_id", name: "fk_745ca29eac", on_delete: :cascade
   add_foreign_key "follows", "accounts", name: "fk_32ed1b5560", on_delete: :cascade
+  add_foreign_key "glitch_keyword_mutes", "accounts", on_delete: :cascade
   add_foreign_key "imports", "accounts", name: "fk_6db1b6e408", on_delete: :cascade
   add_foreign_key "media_attachments", "accounts", name: "fk_96dd81e81b", on_delete: :nullify
   add_foreign_key "media_attachments", "statuses", on_delete: :nullify
diff --git a/lib/paperclip/gif_transcoder.rb b/lib/paperclip/gif_transcoder.rb
index cbe53b27b..629e37581 100644
--- a/lib/paperclip/gif_transcoder.rb
+++ b/lib/paperclip/gif_transcoder.rb
@@ -10,6 +10,7 @@ module Paperclip
       unless options[:style] == :original && num_frames > 1
         tmp_file = Paperclip::TempfileFactory.new.generate(attachment.instance.file_file_name)
         tmp_file << file.read
+        tmp_file.flush
         return tmp_file
       end
 
diff --git a/spec/controllers/settings/keyword_mutes_controller_spec.rb b/spec/controllers/settings/keyword_mutes_controller_spec.rb
new file mode 100644
index 000000000..a8c37a072
--- /dev/null
+++ b/spec/controllers/settings/keyword_mutes_controller_spec.rb
@@ -0,0 +1,5 @@
+require 'rails_helper'
+
+RSpec.describe Settings::KeywordMutesController, type: :controller do
+
+end
diff --git a/spec/fabricators/glitch_keyword_mute_fabricator.rb b/spec/fabricators/glitch_keyword_mute_fabricator.rb
new file mode 100644
index 000000000..20d393320
--- /dev/null
+++ b/spec/fabricators/glitch_keyword_mute_fabricator.rb
@@ -0,0 +1,2 @@
+Fabricator('Glitch::KeywordMute') do
+end
diff --git a/spec/fixtures/files/mini-static.gif b/spec/fixtures/files/mini-static.gif
new file mode 100644
index 000000000..fe597b215
--- /dev/null
+++ b/spec/fixtures/files/mini-static.gif
Binary files differdiff --git a/spec/helpers/settings/keyword_mutes_helper_spec.rb b/spec/helpers/settings/keyword_mutes_helper_spec.rb
new file mode 100644
index 000000000..a19d518dd
--- /dev/null
+++ b/spec/helpers/settings/keyword_mutes_helper_spec.rb
@@ -0,0 +1,15 @@
+require 'rails_helper'
+
+# Specs in this file have access to a helper object that includes
+# the Settings::KeywordMutesHelper. For example:
+#
+# describe Settings::KeywordMutesHelper do
+#   describe "string concat" do
+#     it "concats two strings with spaces" do
+#       expect(helper.concat_strings("this","that")).to eq("this that")
+#     end
+#   end
+# end
+RSpec.describe Settings::KeywordMutesHelper, type: :helper do
+  pending "add some examples to (or delete) #{__FILE__}"
+end
diff --git a/spec/lib/feed_manager_spec.rb b/spec/lib/feed_manager_spec.rb
index 1861cc6ed..e678d3ca4 100644
--- a/spec/lib/feed_manager_spec.rb
+++ b/spec/lib/feed_manager_spec.rb
@@ -119,6 +119,44 @@ RSpec.describe FeedManager do
         reblog = Fabricate(:status, reblog: status, account: jeff)
         expect(FeedManager.instance.filter?(:home, reblog, alice.id)).to be true
       end
+
+      it 'returns true for a status containing a muted keyword' do
+        Fabricate('Glitch::KeywordMute', account: alice, keyword: 'take')
+        status = Fabricate(:status, text: 'This is a hot take', account: bob)
+
+        expect(FeedManager.instance.filter?(:home, status, alice.id)).to be true
+      end
+
+      it 'returns true for a reply containing a muted keyword' do
+        Fabricate('Glitch::KeywordMute', account: alice, keyword: 'take')
+        s1 = Fabricate(:status, text: 'Something', account: alice)
+        s2 = Fabricate(:status, text: 'This is a hot take', thread: s1, account: bob)
+
+        expect(FeedManager.instance.filter?(:home, s2, alice.id)).to be true
+      end
+
+      it 'returns true for a status whose spoiler text contains a muted keyword' do
+        Fabricate('Glitch::KeywordMute', account: alice, keyword: 'take')
+        status = Fabricate(:status, spoiler_text: 'This is a hot take', account: bob)
+
+        expect(FeedManager.instance.filter?(:home, status, alice.id)).to be true
+      end
+
+      it 'returns true for a reblog containing a muted keyword' do
+        Fabricate('Glitch::KeywordMute', account: alice, keyword: 'take')
+        status = Fabricate(:status, text: 'This is a hot take', account: bob)
+        reblog = Fabricate(:status, reblog: status, account: jeff)
+
+        expect(FeedManager.instance.filter?(:home, reblog, alice.id)).to be true
+      end
+
+      it 'returns true for a reblog whose spoiler text contains a muted keyword' do
+        Fabricate('Glitch::KeywordMute', account: alice, keyword: 'take')
+        status = Fabricate(:status, spoiler_text: 'This is a hot take', account: bob)
+        reblog = Fabricate(:status, reblog: status, account: jeff)
+
+        expect(FeedManager.instance.filter?(:home, reblog, alice.id)).to be true
+      end
     end
 
     context 'for mentions feed' do
@@ -147,6 +185,13 @@ RSpec.describe FeedManager do
         bob.follow!(alice)
         expect(FeedManager.instance.filter?(:mentions, status, bob.id)).to be false
       end
+
+      it 'returns true for status that contains a muted keyword' do
+        Fabricate('Glitch::KeywordMute', account: bob, keyword: 'take')
+        status = Fabricate(:status, text: 'This is a hot take', account: alice)
+        bob.follow!(alice)
+        expect(FeedManager.instance.filter?(:mentions, status, bob.id)).to be true
+      end
     end
   end
 
diff --git a/spec/models/glitch/keyword_mute_spec.rb b/spec/models/glitch/keyword_mute_spec.rb
new file mode 100644
index 000000000..1423823ba
--- /dev/null
+++ b/spec/models/glitch/keyword_mute_spec.rb
@@ -0,0 +1,89 @@
+require 'rails_helper'
+
+RSpec.describe Glitch::KeywordMute, type: :model do
+  let(:alice) { Fabricate(:account, username: 'alice').tap(&:save!) }
+  let(:bob) { Fabricate(:account, username: 'bob').tap(&:save!) }
+
+  describe '.matcher_for' do
+    let(:matcher) { Glitch::KeywordMute.matcher_for(alice) }
+
+    describe 'with no mutes' do
+      before do
+        Glitch::KeywordMute.delete_all
+      end
+
+      it 'does not match' do
+        expect(matcher =~ 'This is a hot take').to be_falsy
+      end
+    end
+
+    describe 'with mutes' do
+      it 'does not match keywords set by a different account' do
+        Glitch::KeywordMute.create!(account: bob, keyword: 'take')
+
+        expect(matcher =~ 'This is a hot take').to be_falsy
+      end
+
+      it 'does not match if no keywords match the status text' do
+        Glitch::KeywordMute.create!(account: alice, keyword: 'cold')
+
+        expect(matcher =~ 'This is a hot take').to be_falsy
+      end
+
+      it 'considers word boundaries when matching' do
+        Glitch::KeywordMute.create!(account: alice, keyword: 'bob', whole_word: true)
+
+        expect(matcher =~ 'bobcats').to be_falsy
+      end
+
+      it 'matches substrings if whole_word is false' do
+        Glitch::KeywordMute.create!(account: alice, keyword: 'take', whole_word: false)
+
+        expect(matcher =~ 'This is a shiitake mushroom').to be_truthy
+      end
+
+      it 'matches keywords at the beginning of the text' do
+        Glitch::KeywordMute.create!(account: alice, keyword: 'take')
+
+        expect(matcher =~ 'Take this').to be_truthy
+      end
+
+      it 'matches keywords at the end of the text' do
+        Glitch::KeywordMute.create!(account: alice, keyword: 'take')
+
+        expect(matcher =~ 'This is a hot take').to be_truthy
+      end
+
+      it 'matches if at least one keyword case-insensitively matches the text' do
+        Glitch::KeywordMute.create!(account: alice, keyword: 'hot')
+
+        expect(matcher =~ 'This is a HOT take').to be_truthy
+      end
+
+      it 'matches keywords surrounded by non-alphanumeric ornamentation' do
+        Glitch::KeywordMute.create!(account: alice, keyword: 'hot')
+
+        expect(matcher =~ '(hot take)').to be_truthy
+      end
+
+      it 'escapes metacharacters in keywords' do
+        Glitch::KeywordMute.create!(account: alice, keyword: '(hot take)')
+
+        expect(matcher =~ '(hot take)').to be_truthy
+      end
+
+      it 'uses case-folding rules appropriate for more than just English' do
+        Glitch::KeywordMute.create!(account: alice, keyword: 'großeltern')
+
+        expect(matcher =~ 'besuch der grosseltern').to be_truthy
+      end
+
+      it 'matches keywords that are composed of multiple words' do
+        Glitch::KeywordMute.create!(account: alice, keyword: 'a shiitake')
+
+        expect(matcher =~ 'This is a shiitake').to be_truthy
+        expect(matcher =~ 'This is shiitake').to_not be_truthy
+      end
+    end
+  end
+end
diff --git a/spec/models/media_attachment_spec.rb b/spec/models/media_attachment_spec.rb
index 9fce5bc4f..435b4f326 100644
--- a/spec/models/media_attachment_spec.rb
+++ b/spec/models/media_attachment_spec.rb
@@ -20,20 +20,29 @@ RSpec.describe MediaAttachment, type: :model do
   end
 
   describe 'non-animated gif non-conversion' do
-    let(:media) { MediaAttachment.create(account: Fabricate(:account), file: attachment_fixture('attachment.gif')) }
+    fixtures = [
+      { filename: 'attachment.gif', width: 600, height: 400, aspect: 1.5 },
+      { filename: 'mini-static.gif', width: 32, height: 32, aspect: 1.0 },
+    ]
 
-    it 'sets type to image' do
-      expect(media.type).to eq 'image'
-    end
+    fixtures.each do |fixture|
+      context fixture[:filename] do
+        let(:media) { MediaAttachment.create(account: Fabricate(:account), file: attachment_fixture(fixture[:filename])) }
 
-    it 'leaves original file as-is' do
-      expect(media.file_content_type).to eq 'image/gif'
-    end
+        it 'sets type to image' do
+          expect(media.type).to eq 'image'
+        end
 
-    it 'sets meta' do
-      expect(media.file.meta["original"]["width"]).to eq 600
-      expect(media.file.meta["original"]["height"]).to eq 400
-      expect(media.file.meta["original"]["aspect"]).to eq 1.5
+        it 'leaves original file as-is' do
+          expect(media.file_content_type).to eq 'image/gif'
+        end
+
+        it 'sets meta' do
+          expect(media.file.meta["original"]["width"]).to eq fixture[:width]
+          expect(media.file.meta["original"]["height"]).to eq fixture[:height]
+          expect(media.file.meta["original"]["aspect"]).to eq fixture[:aspect]
+        end
+      end
     end
   end
 
diff --git a/spec/models/status_spec.rb b/spec/models/status_spec.rb
index 9cb71d715..12e857169 100644
--- a/spec/models/status_spec.rb
+++ b/spec/models/status_spec.rb
@@ -232,6 +232,55 @@ RSpec.describe Status, type: :model do
     end
   end
 
+  describe '.as_direct_timeline' do
+    let(:account) { Fabricate(:account) }
+    let(:followed) { Fabricate(:account) }
+    let(:not_followed) { Fabricate(:account) }
+
+    before do
+      Fabricate(:follow, account: account, target_account: followed)
+
+      @self_public_status = Fabricate(:status, account: account, visibility: :public)
+      @self_direct_status = Fabricate(:status, account: account, visibility: :direct)
+      @followed_public_status = Fabricate(:status, account: followed, visibility: :public)
+      @followed_direct_status = Fabricate(:status, account: followed, visibility: :direct)
+      @not_followed_direct_status = Fabricate(:status, account: not_followed, visibility: :direct)
+
+      @results = Status.as_direct_timeline(account)
+    end
+
+    it 'does not include public statuses from self' do
+      expect(@results).to_not include(@self_public_status)
+    end
+
+    it 'includes direct statuses from self' do
+      expect(@results).to include(@self_direct_status)
+    end
+
+    it 'does not include public statuses from followed' do
+      expect(@results).to_not include(@followed_public_status)
+    end
+
+    it 'includes direct statuses mentioning recipient from followed' do
+      Fabricate(:mention, account: account, status: @followed_direct_status)
+      expect(@results).to include(@followed_direct_status)
+    end
+
+    it 'does not include direct statuses not mentioning recipient from followed' do
+      expect(@results).to_not include(@followed_direct_status)
+    end
+
+    it 'includes direct statuses mentioning recipient from non-followed' do
+      Fabricate(:mention, account: account, status: @not_followed_direct_status)
+      expect(@results).to include(@not_followed_direct_status)
+    end
+
+    it 'does not include direct statuses not mentioning recipient from non-followed' do
+      expect(@results).to_not include(@not_followed_direct_status)
+    end
+
+  end
+
   describe '.as_public_timeline' do
     it 'only includes statuses with public visibility' do
       public_status = Fabricate(:status, visibility: :public)
diff --git a/streaming/index.js b/streaming/index.js
index 83903b89b..8adc5174a 100644
--- a/streaming/index.js
+++ b/streaming/index.js
@@ -402,6 +402,10 @@ const startWorker = (workerId) => {
     streamFrom('timeline:public:local', req, streamToHttp(req, res), streamHttpEnd(req), true);
   });
 
+  app.get('/api/v1/streaming/direct', (req, res) => {
+    streamFrom(`timeline:direct:${req.accountId}`, req, streamToHttp(req, res), streamHttpEnd(req), true);
+  });
+
   app.get('/api/v1/streaming/hashtag', (req, res) => {
     streamFrom(`timeline:hashtag:${req.query.tag.toLowerCase()}`, req, streamToHttp(req, res), streamHttpEnd(req), true);
   });
@@ -437,6 +441,9 @@ const startWorker = (workerId) => {
     case 'public:local':
       streamFrom('timeline:public:local', req, streamToWs(req, ws), streamWsEnd(req, ws), true);
       break;
+    case 'direct':
+      streamFrom(`timeline:direct:${req.accountId}`, req, streamToWs(req, ws), streamWsEnd(req, ws), true);
+      break;
     case 'hashtag':
       streamFrom(`timeline:hashtag:${location.query.tag.toLowerCase()}`, req, streamToWs(req, ws), streamWsEnd(req, ws), true);
       break;