about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--Gemfile4
-rw-r--r--Gemfile.lock23
-rw-r--r--app/controllers/admin/settings_controller.rb2
-rw-r--r--app/controllers/api/v1/conversations_controller.rb55
-rw-r--r--app/controllers/concerns/signature_verification.rb10
-rw-r--r--app/javascript/mastodon/actions/conversations.js59
-rw-r--r--app/javascript/mastodon/actions/streaming.js4
-rw-r--r--app/javascript/mastodon/actions/timelines.js1
-rw-r--r--app/javascript/mastodon/components/display_name.js11
-rw-r--r--app/javascript/mastodon/features/direct_timeline/components/conversation.js85
-rw-r--r--app/javascript/mastodon/features/direct_timeline/components/conversations_list.js68
-rw-r--r--app/javascript/mastodon/features/direct_timeline/containers/conversation_container.js15
-rw-r--r--app/javascript/mastodon/features/direct_timeline/containers/conversations_list_container.js15
-rw-r--r--app/javascript/mastodon/features/direct_timeline/index.js27
-rw-r--r--app/javascript/mastodon/reducers/conversations.js79
-rw-r--r--app/javascript/mastodon/reducers/index.js2
-rw-r--r--app/javascript/mastodon/reducers/notifications.js2
-rw-r--r--app/javascript/styles/mastodon/components.scss42
-rw-r--r--app/lib/inline_renderer.rb2
-rw-r--r--app/models/account_conversation.rb111
-rw-r--r--app/models/concerns/omniauthable.rb4
-rw-r--r--app/models/status.rb13
-rw-r--r--app/presenters/instance_presenter.rb4
-rw-r--r--app/serializers/rest/conversation_serializer.rb7
-rw-r--r--app/services/after_block_service.rb37
-rw-r--r--app/services/fan_out_on_write_service.rb6
-rw-r--r--app/services/mute_service.rb4
-rw-r--r--app/services/notify_service.rb22
-rw-r--r--app/views/about/more.html.haml2
-rw-r--r--app/views/about/show.html.haml4
-rw-r--r--app/views/admin/settings/edit.html.haml2
-rw-r--r--app/workers/block_worker.rb5
-rw-r--r--app/workers/mute_worker.rb12
-rw-r--r--app/workers/push_conversation_worker.rb15
-rw-r--r--config/environments/development.rb2
-rw-r--r--config/initializers/open_uri_redirection.rb2
-rw-r--r--config/locales/en.yml3
-rw-r--r--config/routes.rb1
-rw-r--r--db/migrate/20180929222014_create_account_conversations.rb14
-rw-r--r--db/schema.rb17
-rw-r--r--lib/mastodon/migration_helpers.rb10
-rw-r--r--spec/controllers/api/salmon_controller_spec.rb4
-rw-r--r--spec/controllers/api/subscriptions_controller_spec.rb2
-rw-r--r--spec/controllers/api/v1/conversations_controller_spec.rb37
-rw-r--r--spec/fabricators/conversation_account_fabricator.rb6
-rw-r--r--spec/fabricators/user_fabricator.rb2
-rw-r--r--spec/features/log_in_spec.rb2
-rw-r--r--spec/lib/ostatus/atom_serializer_spec.rb24
-rw-r--r--spec/models/account_conversation_spec.rb72
-rw-r--r--spec/models/status_spec.rb2
-rw-r--r--spec/models/user_spec.rb2
-rw-r--r--spec/rails_helper.rb4
-rw-r--r--spec/services/batched_remove_status_service_spec.rb2
-rw-r--r--spec/services/fetch_remote_account_service_spec.rb2
-rw-r--r--spec/services/process_feed_service_spec.rb2
-rw-r--r--spec/services/update_remote_profile_service_spec.rb2
-rw-r--r--spec/views/about/show.html.haml_spec.rb1
-rw-r--r--streaming/index.js12
58 files changed, 875 insertions, 106 deletions
diff --git a/Gemfile b/Gemfile
index b1b422097..d77a29b79 100644
--- a/Gemfile
+++ b/Gemfile
@@ -15,7 +15,7 @@ gem 'makara', '~> 0.4'
 gem 'pghero', '~> 2.2'
 gem 'dotenv-rails', '~> 2.2', '< 2.3'
 
-gem 'aws-sdk-s3', '~> 1.20', require: false
+gem 'aws-sdk-s3', '~> 1.21', require: false
 gem 'fog-core', '~> 2.1'
 gem 'fog-openstack', '~> 1.0', require: false
 gem 'paperclip', '~> 6.0'
@@ -107,7 +107,7 @@ group :production, :test do
 end
 
 group :test do
-  gem 'capybara', '~> 3.8'
+  gem 'capybara', '~> 3.9'
   gem 'climate_control', '~> 0.2'
   gem 'faker', '~> 1.8'
   gem 'microformats', '~> 4.0'
diff --git a/Gemfile.lock b/Gemfile.lock
index 0efe99db0..3583bc396 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -77,7 +77,7 @@ GEM
       cocaine (~> 0.5.3)
     aws-eventstream (1.0.1)
     aws-partitions (1.105.0)
-    aws-sdk-core (3.29.0)
+    aws-sdk-core (3.30.0)
       aws-eventstream (~> 1.0)
       aws-partitions (~> 1.0)
       aws-sigv4 (~> 1.0)
@@ -85,7 +85,7 @@ GEM
     aws-sdk-kms (1.9.0)
       aws-sdk-core (~> 3, >= 3.26.0)
       aws-sigv4 (~> 1.0)
-    aws-sdk-s3 (1.20.0)
+    aws-sdk-s3 (1.21.0)
       aws-sdk-core (~> 3, >= 3.26.0)
       aws-sdk-kms (~> 1)
       aws-sigv4 (~> 1.0)
@@ -126,7 +126,7 @@ GEM
       sshkit (~> 1.3)
     capistrano-yarn (2.0.2)
       capistrano (~> 3.0)
-    capybara (3.8.2)
+    capybara (3.9.0)
       addressable
       mini_mime (>= 0.1.3)
       nokogiri (~> 1.8)
@@ -188,9 +188,6 @@ GEM
     dotenv-rails (2.2.2)
       dotenv (= 2.2.2)
       railties (>= 3.2, < 6.0)
-    easy_translate (0.5.1)
-      thread
-      thread_safe
     elasticsearch (6.0.2)
       elasticsearch-api (= 6.0.2)
       elasticsearch-transport (= 6.0.2)
@@ -255,7 +252,7 @@ GEM
     hashdiff (0.3.7)
     hashie (3.5.7)
     heapy (0.1.4)
-    highline (1.7.10)
+    highline (2.0.0)
     hiredis (0.6.1)
     hitimes (1.3.0)
     hkdf (0.3.0)
@@ -276,12 +273,11 @@ GEM
       rainbow (>= 2.0.0)
     i18n (1.1.0)
       concurrent-ruby (~> 1.0)
-    i18n-tasks (0.9.21)
+    i18n-tasks (0.9.25)
       activesupport (>= 4.0.2)
       ast (>= 2.1.0)
-      easy_translate (>= 0.5.1)
       erubi
-      highline (>= 1.7.3)
+      highline (>= 2.0.0)
       i18n
       parser (>= 2.2.3.0)
       rainbow (>= 2.2.2, < 4.0)
@@ -599,7 +595,6 @@ GEM
     terrapin (0.6.0)
       climate_control (>= 0.0.3, < 1.0)
     thor (0.20.0)
-    thread (0.2.2)
     thread_safe (0.3.6)
     tilt (2.0.8)
     timers (4.1.2)
@@ -608,7 +603,7 @@ GEM
     tty-command (0.8.2)
       pastel (~> 0.7.0)
     tty-cursor (0.6.0)
-    tty-prompt (0.17.0)
+    tty-prompt (0.17.1)
       necromancer (~> 0.4.0)
       pastel (~> 0.7.0)
       timers (~> 4.0)
@@ -658,7 +653,7 @@ DEPENDENCIES
   active_record_query_trace (~> 1.5)
   addressable (~> 2.5)
   annotate (~> 2.7)
-  aws-sdk-s3 (~> 1.20)
+  aws-sdk-s3 (~> 1.21)
   better_errors (~> 2.4)
   binding_of_caller (~> 0.7)
   bootsnap (~> 1.3)
@@ -670,7 +665,7 @@ DEPENDENCIES
   capistrano-rails (~> 1.3)
   capistrano-rbenv (~> 2.1)
   capistrano-yarn (~> 2.0)
-  capybara (~> 3.8)
+  capybara (~> 3.9)
   charlock_holmes (~> 0.7.6)
   chewy (~> 5.0)
   cld3 (~> 3.2.0)
diff --git a/app/controllers/admin/settings_controller.rb b/app/controllers/admin/settings_controller.rb
index c05c4c841..46e9b2c07 100644
--- a/app/controllers/admin/settings_controller.rb
+++ b/app/controllers/admin/settings_controller.rb
@@ -20,6 +20,7 @@ module Admin
       skin
       thumbnail
       hero
+      mascot
       min_invite_role
       activity_api_enabled
       peers_api_enabled
@@ -42,6 +43,7 @@ module Admin
     UPLOAD_SETTINGS = %w(
       thumbnail
       hero
+      mascot
     ).freeze
 
     def edit
diff --git a/app/controllers/api/v1/conversations_controller.rb b/app/controllers/api/v1/conversations_controller.rb
new file mode 100644
index 000000000..736cb21ca
--- /dev/null
+++ b/app/controllers/api/v1/conversations_controller.rb
@@ -0,0 +1,55 @@
+# frozen_string_literal: true
+
+class Api::V1::ConversationsController < Api::BaseController
+  LIMIT = 20
+
+  before_action -> { doorkeeper_authorize! :read, :'read:statuses' }
+  before_action :require_user!
+  after_action :insert_pagination_headers
+
+  respond_to :json
+
+  def index
+    @conversations = paginated_conversations
+    render json: @conversations, each_serializer: REST::ConversationSerializer
+  end
+
+  private
+
+  def paginated_conversations
+    AccountConversation.where(account: current_account)
+                       .paginate_by_id(limit_param(LIMIT), params_slice(:max_id, :since_id, :min_id))
+  end
+
+  def insert_pagination_headers
+    set_pagination_headers(next_path, prev_path)
+  end
+
+  def next_path
+    if records_continue?
+      api_v1_conversations_url pagination_params(max_id: pagination_max_id)
+    end
+  end
+
+  def prev_path
+    unless @conversations.empty?
+      api_v1_conversations_url pagination_params(min_id: pagination_since_id)
+    end
+  end
+
+  def pagination_max_id
+    @conversations.last.last_status_id
+  end
+
+  def pagination_since_id
+    @conversations.first.last_status_id
+  end
+
+  def records_continue?
+    @conversations.size == limit_param(LIMIT)
+  end
+
+  def pagination_params(core_params)
+    params.slice(:limit).permit(:limit).merge(core_params)
+  end
+end
diff --git a/app/controllers/concerns/signature_verification.rb b/app/controllers/concerns/signature_verification.rb
index 4d77fa432..5f95fa346 100644
--- a/app/controllers/concerns/signature_verification.rb
+++ b/app/controllers/concerns/signature_verification.rb
@@ -87,16 +87,6 @@ module SignatureVerification
     end.join("\n")
   end
 
-  def matches_time_window?
-    begin
-      time_sent = DateTime.httpdate(request.headers['Date'])
-    rescue ArgumentError
-      return false
-    end
-
-    (Time.now.utc - time_sent).abs <= 30
-  end
-
   def body_digest
     "SHA-256=#{Digest::SHA256.base64digest(request_body)}"
   end
diff --git a/app/javascript/mastodon/actions/conversations.js b/app/javascript/mastodon/actions/conversations.js
new file mode 100644
index 000000000..3840d23ca
--- /dev/null
+++ b/app/javascript/mastodon/actions/conversations.js
@@ -0,0 +1,59 @@
+import api, { getLinks } from '../api';
+import {
+  importFetchedAccounts,
+  importFetchedStatuses,
+  importFetchedStatus,
+} from './importer';
+
+export const CONVERSATIONS_FETCH_REQUEST = 'CONVERSATIONS_FETCH_REQUEST';
+export const CONVERSATIONS_FETCH_SUCCESS = 'CONVERSATIONS_FETCH_SUCCESS';
+export const CONVERSATIONS_FETCH_FAIL    = 'CONVERSATIONS_FETCH_FAIL';
+export const CONVERSATIONS_UPDATE        = 'CONVERSATIONS_UPDATE';
+
+export const expandConversations = ({ maxId } = {}) => (dispatch, getState) => {
+  dispatch(expandConversationsRequest());
+
+  const params = { max_id: maxId };
+
+  if (!maxId) {
+    params.since_id = getState().getIn(['conversations', 0, 'last_status']);
+  }
+
+  api(getState).get('/api/v1/conversations', { params })
+    .then(response => {
+      const next = getLinks(response).refs.find(link => link.rel === 'next');
+
+      dispatch(importFetchedAccounts(response.data.reduce((aggr, item) => aggr.concat(item.accounts), [])));
+      dispatch(importFetchedStatuses(response.data.map(item => item.last_status).filter(x => !!x)));
+      dispatch(expandConversationsSuccess(response.data, next ? next.uri : null));
+    })
+    .catch(err => dispatch(expandConversationsFail(err)));
+};
+
+export const expandConversationsRequest = () => ({
+  type: CONVERSATIONS_FETCH_REQUEST,
+});
+
+export const expandConversationsSuccess = (conversations, next) => ({
+  type: CONVERSATIONS_FETCH_SUCCESS,
+  conversations,
+  next,
+});
+
+export const expandConversationsFail = error => ({
+  type: CONVERSATIONS_FETCH_FAIL,
+  error,
+});
+
+export const updateConversations = conversation => dispatch => {
+  dispatch(importFetchedAccounts(conversation.accounts));
+
+  if (conversation.last_status) {
+    dispatch(importFetchedStatus(conversation.last_status));
+  }
+
+  dispatch({
+    type: CONVERSATIONS_UPDATE,
+    conversation,
+  });
+};
diff --git a/app/javascript/mastodon/actions/streaming.js b/app/javascript/mastodon/actions/streaming.js
index 32fc67e67..8cf055540 100644
--- a/app/javascript/mastodon/actions/streaming.js
+++ b/app/javascript/mastodon/actions/streaming.js
@@ -6,6 +6,7 @@ import {
   disconnectTimeline,
 } from './timelines';
 import { updateNotifications, expandNotifications } from './notifications';
+import { updateConversations } from './conversations';
 import { fetchFilters } from './filters';
 import { getLocale } from '../locales';
 
@@ -31,6 +32,9 @@ export function connectTimelineStream (timelineId, path, pollingRefresh = null)
         case 'notification':
           dispatch(updateNotifications(JSON.parse(data.payload), messages, locale));
           break;
+        case 'conversation':
+          dispatch(updateConversations(JSON.parse(data.payload)));
+          break;
         case 'filters_changed':
           dispatch(fetchFilters());
           break;
diff --git a/app/javascript/mastodon/actions/timelines.js b/app/javascript/mastodon/actions/timelines.js
index e8fd441e1..c4fc6448c 100644
--- a/app/javascript/mastodon/actions/timelines.js
+++ b/app/javascript/mastodon/actions/timelines.js
@@ -76,7 +76,6 @@ export function expandTimeline(timelineId, path, params = {}, done = noOp) {
 export const expandHomeTimeline            = ({ maxId } = {}, done = noOp) => expandTimeline('home', '/api/v1/timelines/home', { max_id: maxId }, done);
 export const expandPublicTimeline          = ({ maxId, onlyMedia } = {}, done = noOp) => expandTimeline(`public${onlyMedia ? ':media' : ''}`, '/api/v1/timelines/public', { max_id: maxId, only_media: !!onlyMedia }, done);
 export const expandCommunityTimeline       = ({ maxId, onlyMedia } = {}, done = noOp) => expandTimeline(`community${onlyMedia ? ':media' : ''}`, '/api/v1/timelines/public', { local: true, max_id: maxId, only_media: !!onlyMedia }, done);
-export const expandDirectTimeline          = ({ maxId } = {}, done = noOp) => expandTimeline('direct', '/api/v1/timelines/direct', { max_id: maxId }, done);
 export const expandAccountTimeline         = (accountId, { maxId, withReplies } = {}) => expandTimeline(`account:${accountId}${withReplies ? ':with_replies' : ''}`, `/api/v1/accounts/${accountId}/statuses`, { exclude_replies: !withReplies, max_id: maxId });
 export const expandAccountFeaturedTimeline = accountId => expandTimeline(`account:${accountId}:pinned`, `/api/v1/accounts/${accountId}/statuses`, { pinned: true });
 export const expandAccountMediaTimeline    = (accountId, { maxId } = {}) => expandTimeline(`account:${accountId}:media`, `/api/v1/accounts/${accountId}/statuses`, { max_id: maxId, only_media: true });
diff --git a/app/javascript/mastodon/components/display_name.js b/app/javascript/mastodon/components/display_name.js
index a1c56ae35..c3a9ab921 100644
--- a/app/javascript/mastodon/components/display_name.js
+++ b/app/javascript/mastodon/components/display_name.js
@@ -1,18 +1,25 @@
 import React from 'react';
 import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
 
 export default class DisplayName extends React.PureComponent {
 
   static propTypes = {
     account: ImmutablePropTypes.map.isRequired,
+    withAcct: PropTypes.bool,
+  };
+
+  static defaultProps = {
+    withAcct: true,
   };
 
   render () {
-    const displayNameHtml = { __html: this.props.account.get('display_name_html') };
+    const { account, withAcct } = this.props;
+    const displayNameHtml = { __html: account.get('display_name_html') };
 
     return (
       <span className='display-name'>
-        <bdi><strong className='display-name__html' dangerouslySetInnerHTML={displayNameHtml} /></bdi> <span className='display-name__account'>@{this.props.account.get('acct')}</span>
+        <bdi><strong className='display-name__html' dangerouslySetInnerHTML={displayNameHtml} /></bdi> {withAcct && <span className='display-name__account'>@{account.get('acct')}</span>}
       </span>
     );
   }
diff --git a/app/javascript/mastodon/features/direct_timeline/components/conversation.js b/app/javascript/mastodon/features/direct_timeline/components/conversation.js
new file mode 100644
index 000000000..f9a8d4f72
--- /dev/null
+++ b/app/javascript/mastodon/features/direct_timeline/components/conversation.js
@@ -0,0 +1,85 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import StatusContent from '../../../components/status_content';
+import RelativeTimestamp from '../../../components/relative_timestamp';
+import DisplayName from '../../../components/display_name';
+import Avatar from '../../../components/avatar';
+import AttachmentList from '../../../components/attachment_list';
+import { HotKeys } from 'react-hotkeys';
+
+export default class Conversation extends ImmutablePureComponent {
+
+  static contextTypes = {
+    router: PropTypes.object,
+  };
+
+  static propTypes = {
+    conversationId: PropTypes.string.isRequired,
+    accounts: ImmutablePropTypes.list.isRequired,
+    lastStatus: ImmutablePropTypes.map.isRequired,
+    onMoveUp: PropTypes.func,
+    onMoveDown: PropTypes.func,
+  };
+
+  handleClick = () => {
+    if (!this.context.router) {
+      return;
+    }
+
+    const { lastStatus } = this.props;
+    this.context.router.history.push(`/statuses/${lastStatus.get('id')}`);
+  }
+
+  handleHotkeyMoveUp = () => {
+    this.props.onMoveUp(this.props.conversationId);
+  }
+
+  handleHotkeyMoveDown = () => {
+    this.props.onMoveDown(this.props.conversationId);
+  }
+
+  render () {
+    const { accounts, lastStatus, lastAccount } = this.props;
+
+    if (lastStatus === null) {
+      return null;
+    }
+
+    const handlers = {
+      moveDown: this.handleHotkeyMoveDown,
+      moveUp: this.handleHotkeyMoveUp,
+      open: this.handleClick,
+    };
+
+    let media;
+
+    if (lastStatus.get('media_attachments').size > 0) {
+      media = <AttachmentList compact media={lastStatus.get('media_attachments')} />;
+    }
+
+    return (
+      <HotKeys handlers={handlers}>
+        <div className='conversation focusable' tabIndex='0' onClick={this.handleClick} role='button'>
+          <div className='conversation__header'>
+            <div className='conversation__avatars'>
+              <div>{accounts.map(account => <Avatar key={account.get('id')} size={36} account={account} />)}</div>
+            </div>
+
+            <div className='conversation__time'>
+              <RelativeTimestamp timestamp={lastStatus.get('created_at')} />
+              <br />
+              <DisplayName account={lastAccount} withAcct={false} />
+            </div>
+          </div>
+
+          <StatusContent status={lastStatus} onClick={this.handleClick} />
+
+          {media}
+        </div>
+      </HotKeys>
+    );
+  }
+
+}
diff --git a/app/javascript/mastodon/features/direct_timeline/components/conversations_list.js b/app/javascript/mastodon/features/direct_timeline/components/conversations_list.js
new file mode 100644
index 000000000..4684548e0
--- /dev/null
+++ b/app/javascript/mastodon/features/direct_timeline/components/conversations_list.js
@@ -0,0 +1,68 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import ConversationContainer from '../containers/conversation_container';
+import ScrollableList from '../../../components/scrollable_list';
+import { debounce } from 'lodash';
+
+export default class ConversationsList extends ImmutablePureComponent {
+
+  static propTypes = {
+    conversationIds: ImmutablePropTypes.list.isRequired,
+    hasMore: PropTypes.bool,
+    isLoading: PropTypes.bool,
+    onLoadMore: PropTypes.func,
+    shouldUpdateScroll: PropTypes.func,
+  };
+
+  getCurrentIndex = id => this.props.conversationIds.indexOf(id)
+
+  handleMoveUp = id => {
+    const elementIndex = this.getCurrentIndex(id) - 1;
+    this._selectChild(elementIndex);
+  }
+
+  handleMoveDown = id => {
+    const elementIndex = this.getCurrentIndex(id) + 1;
+    this._selectChild(elementIndex);
+  }
+
+  _selectChild (index) {
+    const element = this.node.node.querySelector(`article:nth-of-type(${index + 1}) .focusable`);
+
+    if (element) {
+      element.focus();
+    }
+  }
+
+  setRef = c => {
+    this.node = c;
+  }
+
+  handleLoadOlder = debounce(() => {
+    const last = this.props.conversationIds.last();
+
+    if (last) {
+      this.props.onLoadMore(last);
+    }
+  }, 300, { leading: true })
+
+  render () {
+    const { conversationIds, onLoadMore, ...other } = this.props;
+
+    return (
+      <ScrollableList {...other} onLoadMore={onLoadMore && this.handleLoadOlder} scrollKey='direct' ref={this.setRef}>
+        {conversationIds.map(item => (
+          <ConversationContainer
+            key={item}
+            conversationId={item}
+            onMoveUp={this.handleMoveUp}
+            onMoveDown={this.handleMoveDown}
+          />
+        ))}
+      </ScrollableList>
+    );
+  }
+
+}
diff --git a/app/javascript/mastodon/features/direct_timeline/containers/conversation_container.js b/app/javascript/mastodon/features/direct_timeline/containers/conversation_container.js
new file mode 100644
index 000000000..4166ee2ac
--- /dev/null
+++ b/app/javascript/mastodon/features/direct_timeline/containers/conversation_container.js
@@ -0,0 +1,15 @@
+import { connect } from 'react-redux';
+import Conversation from '../components/conversation';
+
+const mapStateToProps = (state, { conversationId }) => {
+  const conversation = state.getIn(['conversations', 'items']).find(x => x.get('id') === conversationId);
+  const lastStatus   = state.getIn(['statuses', conversation.get('last_status')], null);
+
+  return {
+    accounts: conversation.get('accounts').map(accountId => state.getIn(['accounts', accountId], null)),
+    lastStatus,
+    lastAccount: lastStatus === null ? null : state.getIn(['accounts', lastStatus.get('account')], null),
+  };
+};
+
+export default connect(mapStateToProps)(Conversation);
diff --git a/app/javascript/mastodon/features/direct_timeline/containers/conversations_list_container.js b/app/javascript/mastodon/features/direct_timeline/containers/conversations_list_container.js
new file mode 100644
index 000000000..81ea812ad
--- /dev/null
+++ b/app/javascript/mastodon/features/direct_timeline/containers/conversations_list_container.js
@@ -0,0 +1,15 @@
+import { connect } from 'react-redux';
+import ConversationsList from '../components/conversations_list';
+import { expandConversations } from '../../../actions/conversations';
+
+const mapStateToProps = state => ({
+  conversationIds: state.getIn(['conversations', 'items']).map(x => x.get('id')),
+  isLoading: state.getIn(['conversations', 'isLoading'], true),
+  hasMore: state.getIn(['conversations', 'hasMore'], false),
+});
+
+const mapDispatchToProps = dispatch => ({
+  onLoadMore: maxId => dispatch(expandConversations({ maxId })),
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(ConversationsList);
diff --git a/app/javascript/mastodon/features/direct_timeline/index.js b/app/javascript/mastodon/features/direct_timeline/index.js
index 3c7e2d007..4c8485690 100644
--- a/app/javascript/mastodon/features/direct_timeline/index.js
+++ b/app/javascript/mastodon/features/direct_timeline/index.js
@@ -1,23 +1,19 @@
 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 { expandDirectTimeline } from '../../actions/timelines';
+import { expandConversations } from '../../actions/conversations';
 import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
-import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import { defineMessages, injectIntl } from 'react-intl';
 import { connectDirectStream } from '../../actions/streaming';
+import ConversationsListContainer from './containers/conversations_list_container';
 
 const messages = defineMessages({
   title: { id: 'column.direct', defaultMessage: 'Direct messages' },
 });
 
-const mapStateToProps = state => ({
-  hasUnread: state.getIn(['timelines', 'direct', 'unread']) > 0,
-});
-
-export default @connect(mapStateToProps)
+export default @connect()
 @injectIntl
 class DirectTimeline extends React.PureComponent {
 
@@ -52,7 +48,7 @@ class DirectTimeline extends React.PureComponent {
   componentDidMount () {
     const { dispatch } = this.props;
 
-    dispatch(expandDirectTimeline());
+    dispatch(expandConversations());
     this.disconnect = dispatch(connectDirectStream());
   }
 
@@ -68,11 +64,11 @@ class DirectTimeline extends React.PureComponent {
   }
 
   handleLoadMore = maxId => {
-    this.props.dispatch(expandDirectTimeline({ maxId }));
+    this.props.dispatch(expandConversations({ maxId }));
   }
 
   render () {
-    const { intl, shouldUpdateScroll, hasUnread, columnId, multiColumn } = this.props;
+    const { intl, hasUnread, columnId, multiColumn, shouldUpdateScroll } = this.props;
     const pinned = !!columnId;
 
     return (
@@ -88,14 +84,7 @@ class DirectTimeline extends React.PureComponent {
           multiColumn={multiColumn}
         />
 
-        <StatusListContainer
-          trackScroll={!pinned}
-          scrollKey={`direct_timeline-${columnId}`}
-          timelineId='direct'
-          onLoadMore={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." />}
-          shouldUpdateScroll={shouldUpdateScroll}
-        />
+        <ConversationsListContainer shouldUpdateScroll={shouldUpdateScroll} />
       </Column>
     );
   }
diff --git a/app/javascript/mastodon/reducers/conversations.js b/app/javascript/mastodon/reducers/conversations.js
new file mode 100644
index 000000000..f339abf56
--- /dev/null
+++ b/app/javascript/mastodon/reducers/conversations.js
@@ -0,0 +1,79 @@
+import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
+import {
+  CONVERSATIONS_FETCH_REQUEST,
+  CONVERSATIONS_FETCH_SUCCESS,
+  CONVERSATIONS_FETCH_FAIL,
+  CONVERSATIONS_UPDATE,
+} from '../actions/conversations';
+import compareId from '../compare_id';
+
+const initialState = ImmutableMap({
+  items: ImmutableList(),
+  isLoading: false,
+  hasMore: true,
+});
+
+const conversationToMap = item => ImmutableMap({
+  id: item.id,
+  accounts: ImmutableList(item.accounts.map(a => a.id)),
+  last_status: item.last_status.id,
+});
+
+const updateConversation = (state, item) => state.update('items', list => {
+  const index   = list.findIndex(x => x.get('id') === item.id);
+  const newItem = conversationToMap(item);
+
+  if (index === -1) {
+    return list.unshift(newItem);
+  } else {
+    return list.set(index, newItem);
+  }
+});
+
+const expandNormalizedConversations = (state, conversations, next) => {
+  let items = ImmutableList(conversations.map(conversationToMap));
+
+  return state.withMutations(mutable => {
+    if (!items.isEmpty()) {
+      mutable.update('items', list => {
+        list = list.map(oldItem => {
+          const newItemIndex = items.findIndex(x => x.get('id') === oldItem.get('id'));
+
+          if (newItemIndex === -1) {
+            return oldItem;
+          }
+
+          const newItem = items.get(newItemIndex);
+          items = items.delete(newItemIndex);
+
+          return newItem;
+        });
+
+        list = list.concat(items);
+
+        return list.sortBy(x => x.get('last_status'), (a, b) => compareId(a, b) * -1);
+      });
+    }
+
+    if (!next) {
+      mutable.set('hasMore', false);
+    }
+
+    mutable.set('isLoading', false);
+  });
+};
+
+export default function conversations(state = initialState, action) {
+  switch (action.type) {
+  case CONVERSATIONS_FETCH_REQUEST:
+    return state.set('isLoading', true);
+  case CONVERSATIONS_FETCH_FAIL:
+    return state.set('isLoading', false);
+  case CONVERSATIONS_FETCH_SUCCESS:
+    return expandNormalizedConversations(state, action.conversations, action.next);
+  case CONVERSATIONS_UPDATE:
+    return updateConversation(state, action.conversation);
+  default:
+    return state;
+  }
+};
diff --git a/app/javascript/mastodon/reducers/index.js b/app/javascript/mastodon/reducers/index.js
index 4a981fada..d3b98d4f6 100644
--- a/app/javascript/mastodon/reducers/index.js
+++ b/app/javascript/mastodon/reducers/index.js
@@ -27,6 +27,7 @@ import custom_emojis from './custom_emojis';
 import lists from './lists';
 import listEditor from './list_editor';
 import filters from './filters';
+import conversations from './conversations';
 
 const reducers = {
   dropdown_menu,
@@ -57,6 +58,7 @@ const reducers = {
   lists,
   listEditor,
   filters,
+  conversations,
 };
 
 export default combineReducers(reducers);
diff --git a/app/javascript/mastodon/reducers/notifications.js b/app/javascript/mastodon/reducers/notifications.js
index 0b29f19fa..d71ae00ae 100644
--- a/app/javascript/mastodon/reducers/notifications.js
+++ b/app/javascript/mastodon/reducers/notifications.js
@@ -69,7 +69,7 @@ const expandNormalizedNotifications = (state, notifications, next) => {
     }
 
     if (!next) {
-      mutable.set('hasMore', true);
+      mutable.set('hasMore', false);
     }
 
     mutable.set('isLoading', false);
diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss
index ecd1a8063..6aabf5777 100644
--- a/app/javascript/styles/mastodon/components.scss
+++ b/app/javascript/styles/mastodon/components.scss
@@ -825,6 +825,7 @@
 
   &.status-direct {
     background: lighten($ui-base-color, 8%);
+    border-bottom-color: lighten($ui-base-color, 12%);
   }
 
   &.light {
@@ -5496,3 +5497,44 @@ noscript {
     }
   }
 }
+
+.conversation {
+  padding: 14px 10px;
+  border-bottom: 1px solid lighten($ui-base-color, 8%);
+  cursor: pointer;
+
+  &__header {
+    display: flex;
+    margin-bottom: 15px;
+  }
+
+  &__avatars {
+    overflow: hidden;
+    flex: 1 1 auto;
+
+    & > div {
+      display: flex;
+      flex-wrap: none;
+      width: 900px;
+    }
+
+    .account__avatar {
+      margin-right: 10px;
+    }
+  }
+
+  &__time {
+    flex: 0 0 auto;
+    font-size: 14px;
+    color: $darker-text-color;
+    text-align: right;
+
+    .display-name {
+      color: $secondary-text-color;
+    }
+  }
+
+  .attachment-list.compact {
+    margin-top: 15px;
+  }
+}
diff --git a/app/lib/inline_renderer.rb b/app/lib/inline_renderer.rb
index 7cd9758ec..761a8822d 100644
--- a/app/lib/inline_renderer.rb
+++ b/app/lib/inline_renderer.rb
@@ -13,6 +13,8 @@ class InlineRenderer
       serializer = REST::StatusSerializer
     when :notification
       serializer = REST::NotificationSerializer
+    when :conversation
+      serializer = REST::ConversationSerializer
     else
       return
     end
diff --git a/app/models/account_conversation.rb b/app/models/account_conversation.rb
new file mode 100644
index 000000000..a7205ec1a
--- /dev/null
+++ b/app/models/account_conversation.rb
@@ -0,0 +1,111 @@
+# frozen_string_literal: true
+# == Schema Information
+#
+# Table name: account_conversations
+#
+#  id                      :bigint(8)        not null, primary key
+#  account_id              :bigint(8)
+#  conversation_id         :bigint(8)
+#  participant_account_ids :bigint(8)        default([]), not null, is an Array
+#  status_ids              :bigint(8)        default([]), not null, is an Array
+#  last_status_id          :bigint(8)
+#  lock_version            :integer          default(0), not null
+#
+
+class AccountConversation < ApplicationRecord
+  after_commit :push_to_streaming_api
+
+  belongs_to :account
+  belongs_to :conversation
+  belongs_to :last_status, class_name: 'Status'
+
+  before_validation :set_last_status
+
+  def participant_account_ids=(arr)
+    self[:participant_account_ids] = arr.sort
+  end
+
+  def participant_accounts
+    if participant_account_ids.empty?
+      [account]
+    else
+      Account.where(id: participant_account_ids)
+    end
+  end
+
+  class << self
+    def paginate_by_id(limit, options = {})
+      if options[:min_id]
+        paginate_by_min_id(limit, options[:min_id]).reverse
+      else
+        paginate_by_max_id(limit, options[:max_id], options[:since_id])
+      end
+    end
+
+    def paginate_by_min_id(limit, min_id = nil)
+      query = order(arel_table[:last_status_id].asc).limit(limit)
+      query = query.where(arel_table[:last_status_id].gt(min_id)) if min_id.present?
+      query
+    end
+
+    def paginate_by_max_id(limit, max_id = nil, since_id = nil)
+      query = order(arel_table[:last_status_id].desc).limit(limit)
+      query = query.where(arel_table[:last_status_id].lt(max_id)) if max_id.present?
+      query = query.where(arel_table[:last_status_id].gt(since_id)) if since_id.present?
+      query
+    end
+
+    def add_status(recipient, status)
+      conversation = find_or_initialize_by(account: recipient, conversation_id: status.conversation_id, participant_account_ids: participants_from_status(recipient, status))
+      conversation.status_ids << status.id
+      conversation.save
+      conversation
+    rescue ActiveRecord::StaleObjectError
+      retry
+    end
+
+    def remove_status(recipient, status)
+      conversation = find_by(account: recipient, conversation_id: status.conversation_id, participant_account_ids: participants_from_status(recipient, status))
+
+      return if conversation.nil?
+
+      conversation.status_ids.delete(status.id)
+
+      if conversation.status_ids.empty?
+        conversation.destroy
+      else
+        conversation.save
+      end
+
+      conversation
+    rescue ActiveRecord::StaleObjectError
+      retry
+    end
+
+    private
+
+    def participants_from_status(recipient, status)
+      ((status.mentions.pluck(:account_id) + [status.account_id]).uniq - [recipient.id]).sort
+    end
+  end
+
+  private
+
+  def set_last_status
+    self.status_ids     = status_ids.sort
+    self.last_status_id = status_ids.last
+  end
+
+  def push_to_streaming_api
+    return if destroyed? || !subscribed_to_timeline?
+    PushConversationWorker.perform_async(id)
+  end
+
+  def subscribed_to_timeline?
+    Redis.current.exists("subscribed:#{streaming_channel}")
+  end
+
+  def streaming_channel
+    "timeline:direct:#{account_id}"
+  end
+end
diff --git a/app/models/concerns/omniauthable.rb b/app/models/concerns/omniauthable.rb
index 50288e700..f263fe7af 100644
--- a/app/models/concerns/omniauthable.rb
+++ b/app/models/concerns/omniauthable.rb
@@ -26,7 +26,7 @@ module Omniauthable
       # to prevent the identity being locked with accidentally created accounts.
       # Note that this may leave zombie accounts (with no associated identity) which
       # can be cleaned up at a later date.
-      user = signed_in_resource ? signed_in_resource : identity.user
+      user = signed_in_resource || identity.user
       user = create_for_oauth(auth) if user.nil?
 
       if identity.user.nil?
@@ -61,7 +61,7 @@ module Omniauthable
       display_name      = auth.info.full_name || [auth.info.first_name, auth.info.last_name].join(' ')
 
       {
-        email: email ? email : "#{TEMP_EMAIL_PREFIX}-#{auth.uid}-#{auth.provider}.com",
+        email: email || "#{TEMP_EMAIL_PREFIX}-#{auth.uid}-#{auth.provider}.com",
         password: Devise.friendly_token[0, 20],
         account_attributes: {
           username: ensure_unique_username(auth.uid),
diff --git a/app/models/status.rb b/app/models/status.rb
index 028927cc3..ad25cc8df 100644
--- a/app/models/status.rb
+++ b/app/models/status.rb
@@ -26,6 +26,8 @@
 #
 
 class Status < ApplicationRecord
+  before_destroy :unlink_from_conversations
+
   include Paginable
   include Streamable
   include Cacheable
@@ -499,4 +501,15 @@ class Status < ApplicationRecord
     reblog&.decrement_count!(:reblogs_count) if reblog?
     thread&.decrement_count!(:replies_count) if in_reply_to_id.present? && (public_visibility? || unlisted_visibility?)
   end
+
+  def unlink_from_conversations
+    return unless direct_visibility?
+
+    mentioned_accounts = mentions.includes(:account).map(&:account)
+    inbox_owners       = mentioned_accounts.select(&:local?) + (account.local? ? [account] : [])
+
+    inbox_owners.each do |inbox_owner|
+      AccountConversation.remove_status(inbox_owner, self)
+    end
+  end
 end
diff --git a/app/presenters/instance_presenter.rb b/app/presenters/instance_presenter.rb
index 0249c134f..5d22962cf 100644
--- a/app/presenters/instance_presenter.rb
+++ b/app/presenters/instance_presenter.rb
@@ -53,4 +53,8 @@ class InstancePresenter
   def hero
     @hero ||= Rails.cache.fetch('site_uploads/hero') { SiteUpload.find_by(var: 'hero') }
   end
+
+  def mascot
+    @mascot ||= Rails.cache.fetch('site_uploads/mascot') { SiteUpload.find_by(var: 'mascot') }
+  end
 end
diff --git a/app/serializers/rest/conversation_serializer.rb b/app/serializers/rest/conversation_serializer.rb
new file mode 100644
index 000000000..08cea47d2
--- /dev/null
+++ b/app/serializers/rest/conversation_serializer.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+class REST::ConversationSerializer < ActiveModel::Serializer
+  attribute :id
+  has_many :participant_accounts, key: :accounts, serializer: REST::AccountSerializer
+  has_one :last_status, serializer: REST::StatusSerializer
+end
diff --git a/app/services/after_block_service.rb b/app/services/after_block_service.rb
index a7dce08c7..706db0d63 100644
--- a/app/services/after_block_service.rb
+++ b/app/services/after_block_service.rb
@@ -2,16 +2,43 @@
 
 class AfterBlockService < BaseService
   def call(account, target_account)
-    FeedManager.instance.clear_from_timeline(account, target_account)
+    clear_home_feed(account, target_account)
     clear_notifications(account, target_account)
+    clear_conversations(account, target_account)
   end
 
   private
 
+  def clear_home_feed(account, target_account)
+    FeedManager.instance.clear_from_timeline(account, target_account)
+  end
+
+  def clear_conversations(account, target_account)
+    AccountConversation.where(account: account)
+                       .where('? = ANY(participant_account_ids)', target_account.id)
+                       .in_batches
+                       .destroy_all
+  end
+
   def clear_notifications(account, target_account)
-    Notification.where(account: account).joins(:follow).where(activity_type: 'Follow', follows: { account_id: target_account.id }).delete_all
-    Notification.where(account: account).joins(mention: :status).where(activity_type: 'Mention', statuses: { account_id: target_account.id }).delete_all
-    Notification.where(account: account).joins(:favourite).where(activity_type: 'Favourite', favourites: { account_id: target_account.id }).delete_all
-    Notification.where(account: account).joins(:status).where(activity_type: 'Status', statuses: { account_id: target_account.id }).delete_all
+    Notification.where(account: account)
+                .joins(:follow)
+                .where(activity_type: 'Follow', follows: { account_id: target_account.id })
+                .delete_all
+
+    Notification.where(account: account)
+                .joins(mention: :status)
+                .where(activity_type: 'Mention', statuses: { account_id: target_account.id })
+                .delete_all
+
+    Notification.where(account: account)
+                .joins(:favourite)
+                .where(activity_type: 'Favourite', favourites: { account_id: target_account.id })
+                .delete_all
+
+    Notification.where(account: account)
+                .joins(:status)
+                .where(activity_type: 'Status', statuses: { account_id: target_account.id })
+                .delete_all
   end
 end
diff --git a/app/services/fan_out_on_write_service.rb b/app/services/fan_out_on_write_service.rb
index 5efd3edb2..ab520276b 100644
--- a/app/services/fan_out_on_write_service.rb
+++ b/app/services/fan_out_on_write_service.rb
@@ -13,6 +13,7 @@ class FanOutOnWriteService < BaseService
     if status.direct_visibility?
       deliver_to_mentioned_followers(status)
       deliver_to_direct_timelines(status)
+      deliver_to_own_conversation(status)
     else
       deliver_to_followers(status)
       deliver_to_lists(status)
@@ -99,6 +100,11 @@ class FanOutOnWriteService < BaseService
     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
+
+  def deliver_to_own_conversation(status)
+    AccountConversation.add_status(status.account, status)
+  end
 end
diff --git a/app/services/mute_service.rb b/app/services/mute_service.rb
index c6122a152..676804cb9 100644
--- a/app/services/mute_service.rb
+++ b/app/services/mute_service.rb
@@ -5,11 +5,13 @@ class MuteService < BaseService
     return if account.id == target_account.id
 
     mute = account.mute!(target_account, notifications: notifications)
+
     if mute.hide_notifications?
       BlockWorker.perform_async(account.id, target_account.id)
     else
-      FeedManager.instance.clear_from_timeline(account, target_account)
+      MuteWorker.perform_async(account.id, target_account.id)
     end
+
     mute
   end
 end
diff --git a/app/services/notify_service.rb b/app/services/notify_service.rb
index 7d0dcc7ad..63bf8f17a 100644
--- a/app/services/notify_service.rb
+++ b/app/services/notify_service.rb
@@ -8,9 +8,10 @@ class NotifyService < BaseService
 
     return if recipient.user.nil? || blocked?
 
-    create_notification
-    push_notification if @notification.browserable?
-    send_email if email_enabled?
+    create_notification!
+    push_notification! if @notification.browserable?
+    push_to_conversation! if direct_message?
+    send_email! if email_enabled?
   rescue ActiveRecord::RecordInvalid
     return
   end
@@ -100,18 +101,23 @@ class NotifyService < BaseService
     end
   end
 
-  def create_notification
+  def create_notification!
     @notification.save!
   end
 
-  def push_notification
+  def push_notification!
     return if @notification.activity.nil?
 
     Redis.current.publish("timeline:#{@recipient.id}", Oj.dump(event: :notification, payload: InlineRenderer.render(@notification, @recipient, :notification)))
-    send_push_notifications
+    send_push_notifications!
   end
 
-  def send_push_notifications
+  def push_to_conversation!
+    return if @notification.activity.nil?
+    AccountConversation.add_status(@recipient, @notification.target_status)
+  end
+
+  def send_push_notifications!
     subscriptions_ids = ::Web::PushSubscription.where(user_id: @recipient.user.id)
                                                .select { |subscription| subscription.pushable?(@notification) }
                                                .map(&:id)
@@ -121,7 +127,7 @@ class NotifyService < BaseService
     end
   end
 
-  def send_email
+  def send_email!
     return if @notification.activity.nil?
     NotificationMailer.public_send(@notification.type, @recipient, @notification).deliver_later(wait: 2.minutes)
   end
diff --git a/app/views/about/more.html.haml b/app/views/about/more.html.haml
index 99028935f..87f1071d9 100644
--- a/app/views/about/more.html.haml
+++ b/app/views/about/more.html.haml
@@ -24,7 +24,7 @@
             %span= t 'about.status_count_after', count: @instance_presenter.status_count
         .row__mascot
           .landing-page__mascot
-            = image_tag asset_pack_path('elephant_ui_plane.svg'), alt: ''
+            = image_tag @instance_presenter.mascot&.file&.url || asset_pack_path('elephant_ui_plane.svg'), alt: ''
 
   .column-2
     .landing-page__information.contact-widget
diff --git a/app/views/about/show.html.haml b/app/views/about/show.html.haml
index 48435fe9c..6c28f83ce 100644
--- a/app/views/about/show.html.haml
+++ b/app/views/about/show.html.haml
@@ -62,7 +62,7 @@
                   %span= t 'about.status_count_after', count: @instance_presenter.status_count
               .row__mascot
                 .landing-page__mascot
-                  = image_tag asset_pack_path('elephant_ui_plane.svg'), alt: ''
+                  = image_tag @instance_presenter.mascot&.file&.url || asset_pack_path('elephant_ui_plane.svg'), alt: ''
 
       - else
         .column-2.non-preview
@@ -94,7 +94,7 @@
                   %span= t 'about.status_count_after', count: @instance_presenter.status_count
               .row__mascot
                 .landing-page__mascot
-                  = image_tag asset_pack_path('elephant_ui_plane.svg'), alt: ''
+                  = image_tag @instance_presenter.mascot&.file&.url || asset_pack_path('elephant_ui_plane.svg'), alt: ''
 
       - if Setting.timeline_preview
         .column-3
diff --git a/app/views/admin/settings/edit.html.haml b/app/views/admin/settings/edit.html.haml
index b4abbf815..361e249e0 100644
--- a/app/views/admin/settings/edit.html.haml
+++ b/app/views/admin/settings/edit.html.haml
@@ -26,6 +26,8 @@
       = f.input :thumbnail, as: :file, wrapper: :with_block_label, label: t('admin.settings.thumbnail.title'), hint: t('admin.settings.thumbnail.desc_html')
     .fields-row__column.fields-row__column-6.fields-group
       = f.input :hero, as: :file, wrapper: :with_block_label, label: t('admin.settings.hero.title'), hint: t('admin.settings.hero.desc_html')
+    .fields-row__column.fields-row__column-6.fields-group
+      = f.input :mascot, as: :file, wrapper: :with_block_label, label: t('admin.settings.mascot.title'), hint: t('admin.settings.mascot.desc_html')
 
   %hr.spacer/
 
diff --git a/app/workers/block_worker.rb b/app/workers/block_worker.rb
index 0820490d3..25f5dd808 100644
--- a/app/workers/block_worker.rb
+++ b/app/workers/block_worker.rb
@@ -4,6 +4,9 @@ class BlockWorker
   include Sidekiq::Worker
 
   def perform(account_id, target_account_id)
-    AfterBlockService.new.call(Account.find(account_id), Account.find(target_account_id))
+    AfterBlockService.new.call(
+      Account.find(account_id),
+      Account.find(target_account_id)
+    )
   end
 end
diff --git a/app/workers/mute_worker.rb b/app/workers/mute_worker.rb
new file mode 100644
index 000000000..7bf0923a5
--- /dev/null
+++ b/app/workers/mute_worker.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+class MuteWorker
+  include Sidekiq::Worker
+
+  def perform(account_id, target_account_id)
+    FeedManager.instance.clear_from_timeline(
+      Account.find(account_id),
+      Account.find(target_account_id)
+    )
+  end
+end
diff --git a/app/workers/push_conversation_worker.rb b/app/workers/push_conversation_worker.rb
new file mode 100644
index 000000000..16f538215
--- /dev/null
+++ b/app/workers/push_conversation_worker.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+class PushConversationWorker
+  include Sidekiq::Worker
+
+  def perform(conversation_account_id)
+    conversation = AccountConversation.find(conversation_account_id)
+    message      = InlineRenderer.render(conversation, conversation.account, :conversation)
+    timeline_id  = "timeline:direct:#{conversation.account_id}"
+
+    Redis.current.publish(timeline_id, Oj.dump(event: :conversation, payload: message, queued_at: (Time.now.to_f * 1000.0).to_i))
+  rescue ActiveRecord::RecordNotFound
+    true
+  end
+end
diff --git a/config/environments/development.rb b/config/environments/development.rb
index b6478f16e..0791b82ab 100644
--- a/config/environments/development.rb
+++ b/config/environments/development.rb
@@ -87,7 +87,7 @@ Rails.application.configure do
   config.x.otp_secret = ENV.fetch('OTP_SECRET', '1fc2b87989afa6351912abeebe31ffc5c476ead9bf8b3d74cbc4a302c7b69a45b40b1bbef3506ddad73e942e15ed5ca4b402bf9a66423626051104f4b5f05109')
 end
 
-ActiveRecordQueryTrace.enabled = ENV.fetch('QUERY_TRACE_ENABLED') { false }
+ActiveRecordQueryTrace.enabled = ENV['QUERY_TRACE_ENABLED'] == 'true'
 
 module PrivateAddressCheck
   def self.private_address?(*)
diff --git a/config/initializers/open_uri_redirection.rb b/config/initializers/open_uri_redirection.rb
index ea2dcffea..e9de85bdc 100644
--- a/config/initializers/open_uri_redirection.rb
+++ b/config/initializers/open_uri_redirection.rb
@@ -2,7 +2,7 @@ require 'open-uri'
 
 module OpenURI
   def self.redirectable?(uri1, uri2) # :nodoc:
-    uri1.scheme.downcase == uri2.scheme.downcase ||
+    uri1.scheme.casecmp(uri2.scheme).zero? ||
       (/\A(?:http|https|ftp)\z/i =~ uri1.scheme && /\A(?:http|https|ftp)\z/i =~ uri2.scheme)
   end
 end
diff --git a/config/locales/en.yml b/config/locales/en.yml
index fd36d4727..553c62e58 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -368,6 +368,9 @@ en:
       hero:
         desc_html: Displayed on the frontpage. At least 600x100px recommended. When not set, falls back to instance thumbnail
         title: Hero image
+      mascot:
+        desc_html: Displayed on multiple pages. At least 293px × 205px recommended. When not set, falls back to instance thumbnail
+        title: Mascot image
       peers_api_enabled:
         desc_html: Domain names this instance has encountered in the fediverse
         title: Publish list of discovered instances
diff --git a/config/routes.rb b/config/routes.rb
index 01cb6b140..8895b7272 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -267,6 +267,7 @@ Rails.application.routes.draw do
       resources :streaming, only: [:index]
       resources :custom_emojis, only: [:index]
       resources :suggestions, only: [:index, :destroy]
+      resources :conversations, only: [:index]
 
       get '/search', to: 'search#index', as: :search
 
diff --git a/db/migrate/20180929222014_create_account_conversations.rb b/db/migrate/20180929222014_create_account_conversations.rb
new file mode 100644
index 000000000..53fa137e1
--- /dev/null
+++ b/db/migrate/20180929222014_create_account_conversations.rb
@@ -0,0 +1,14 @@
+class CreateAccountConversations < ActiveRecord::Migration[5.2]
+  def change
+    create_table :account_conversations do |t|
+      t.belongs_to :account, foreign_key: { on_delete: :cascade }
+      t.belongs_to :conversation, foreign_key: { on_delete: :cascade }
+      t.bigint :participant_account_ids, array: true, null: false, default: []
+      t.bigint :status_ids, array: true, null: false, default: []
+      t.bigint :last_status_id, null: true, default: nil
+      t.integer :lock_version, null: false, default: 0
+    end
+
+    add_index :account_conversations, [:account_id, :conversation_id, :participant_account_ids], unique: true, name: 'index_unique_conversations'
+  end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 1e1f0ed99..8ab3ea3c0 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,10 +10,23 @@
 #
 # It's strongly recommended that you check this file into your version control system.
 
-ActiveRecord::Schema.define(version: 2018_08_20_232245) do
+ActiveRecord::Schema.define(version: 2018_09_29_222014) do
+
   # These are extensions that must be enabled in order to support this database
   enable_extension "plpgsql"
 
+  create_table "account_conversations", force: :cascade do |t|
+    t.bigint "account_id"
+    t.bigint "conversation_id"
+    t.bigint "participant_account_ids", default: [], null: false, array: true
+    t.bigint "status_ids", default: [], null: false, array: true
+    t.bigint "last_status_id"
+    t.integer "lock_version", default: 0, null: false
+    t.index ["account_id", "conversation_id", "participant_account_ids"], name: "index_unique_conversations", unique: true
+    t.index ["account_id"], name: "index_account_conversations_on_account_id"
+    t.index ["conversation_id"], name: "index_account_conversations_on_conversation_id"
+  end
+
   create_table "account_domain_blocks", force: :cascade do |t|
     t.string "domain"
     t.datetime "created_at", null: false
@@ -608,6 +621,8 @@ ActiveRecord::Schema.define(version: 2018_08_20_232245) do
     t.index ["user_id"], name: "index_web_settings_on_user_id", unique: true
   end
 
+  add_foreign_key "account_conversations", "accounts", on_delete: :cascade
+  add_foreign_key "account_conversations", "conversations", on_delete: :cascade
   add_foreign_key "account_domain_blocks", "accounts", name: "fk_206c6029bd", on_delete: :cascade
   add_foreign_key "account_moderation_notes", "accounts"
   add_foreign_key "account_moderation_notes", "accounts", column: "target_account_id"
diff --git a/lib/mastodon/migration_helpers.rb b/lib/mastodon/migration_helpers.rb
index 5c135685f..f5dc7e1c6 100644
--- a/lib/mastodon/migration_helpers.rb
+++ b/lib/mastodon/migration_helpers.rb
@@ -342,8 +342,8 @@ module Mastodon
 
       say "Migrating #{table_name}.#{column} (~#{total.to_i} rows)"
 
-      started_time = Time.now
-      last_time = Time.now
+      started_time = Time.zone.now
+      last_time = Time.zone.now
       migrated = 0
       loop do
         stop_row = nil
@@ -375,13 +375,13 @@ module Mastodon
         end
 
         migrated += batch_size
-        if Time.now - last_time > 1
+        if Time.zone.now - last_time > 1
           status = "Migrated #{migrated} rows"
 
           percentage = 100.0 * migrated / total
           status += " (~#{sprintf('%.2f', percentage)}%, "
 
-          remaining_time = (100.0 - percentage) * (Time.now - started_time) / percentage
+          remaining_time = (100.0 - percentage) * (Time.zone.now - started_time) / percentage
 
           status += "#{(remaining_time / 60).to_i}:"
           status += sprintf('%02d', remaining_time.to_i % 60)
@@ -397,7 +397,7 @@ module Mastodon
           status += ')'
 
           say status, true
-          last_time = Time.now
+          last_time = Time.zone.now
         end
 
         # There are no more rows left to update.
diff --git a/spec/controllers/api/salmon_controller_spec.rb b/spec/controllers/api/salmon_controller_spec.rb
index 8ce4913a5..235a29af0 100644
--- a/spec/controllers/api/salmon_controller_spec.rb
+++ b/spec/controllers/api/salmon_controller_spec.rb
@@ -15,7 +15,7 @@ RSpec.describe Api::SalmonController, type: :controller do
   describe 'POST #update' do
     context 'with valid post data' do
       before do
-        post :update, params: { id: account.id }, body: File.read(File.join(Rails.root, 'spec', 'fixtures', 'salmon', 'mention.xml'))
+        post :update, params: { id: account.id }, body: File.read(Rails.root.join('spec', 'fixtures', 'salmon', 'mention.xml'))
       end
 
       it 'contains XML in the request body' do
@@ -54,7 +54,7 @@ RSpec.describe Api::SalmonController, type: :controller do
         service = double(call: false)
         allow(VerifySalmonService).to receive(:new).and_return(service)
 
-        post :update, params: { id: account.id }, body: File.read(File.join(Rails.root, 'spec', 'fixtures', 'salmon', 'mention.xml'))
+        post :update, params: { id: account.id }, body: File.read(Rails.root.join('spec', 'fixtures', 'salmon', 'mention.xml'))
       end
 
       it 'returns http client error' do
diff --git a/spec/controllers/api/subscriptions_controller_spec.rb b/spec/controllers/api/subscriptions_controller_spec.rb
index b46971a54..7a4252fe6 100644
--- a/spec/controllers/api/subscriptions_controller_spec.rb
+++ b/spec/controllers/api/subscriptions_controller_spec.rb
@@ -33,7 +33,7 @@ RSpec.describe Api::SubscriptionsController, type: :controller do
   end
 
   describe 'POST #update' do
-    let(:feed) { File.read(File.join(Rails.root, 'spec', 'fixtures', 'push', 'feed.atom')) }
+    let(:feed) { File.read(Rails.root.join('spec', 'fixtures', 'push', 'feed.atom')) }
 
     before do
       stub_request(:post, "https://quitter.no/main/push/hub").to_return(:status => 200, :body => "", :headers => {})
diff --git a/spec/controllers/api/v1/conversations_controller_spec.rb b/spec/controllers/api/v1/conversations_controller_spec.rb
new file mode 100644
index 000000000..2e9525855
--- /dev/null
+++ b/spec/controllers/api/v1/conversations_controller_spec.rb
@@ -0,0 +1,37 @@
+require 'rails_helper'
+
+RSpec.describe Api::V1::ConversationsController, type: :controller do
+  render_views
+
+  let!(:user) { Fabricate(:user, account: Fabricate(:account, username: 'alice')) }
+  let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) }
+  let(:other) { Fabricate(:user, account: Fabricate(:account, username: 'bob')) }
+
+  before do
+    allow(controller).to receive(:doorkeeper_token) { token }
+  end
+
+  describe 'GET #index' do
+    let(:scopes) { 'read:statuses' }
+
+    before do
+      PostStatusService.new.call(other.account, 'Hey @alice', nil, visibility: 'direct')
+    end
+
+    it 'returns http success' do
+      get :index
+      expect(response).to have_http_status(200)
+    end
+
+    it 'returns pagination headers' do
+      get :index, params: { limit: 1 }
+      expect(response.headers['Link'].links.size).to eq(2)
+    end
+
+    it 'returns conversations' do
+      get :index
+      json = body_as_json
+      expect(json.size).to eq 1
+    end
+  end
+end
diff --git a/spec/fabricators/conversation_account_fabricator.rb b/spec/fabricators/conversation_account_fabricator.rb
new file mode 100644
index 000000000..f57ffd535
--- /dev/null
+++ b/spec/fabricators/conversation_account_fabricator.rb
@@ -0,0 +1,6 @@
+Fabricator(:conversation_account) do
+  account                 nil
+  conversation            nil
+  participant_account_ids ""
+  last_status             nil
+end
diff --git a/spec/fabricators/user_fabricator.rb b/spec/fabricators/user_fabricator.rb
index cf51fe81d..7dfbdb52d 100644
--- a/spec/fabricators/user_fabricator.rb
+++ b/spec/fabricators/user_fabricator.rb
@@ -2,5 +2,5 @@ Fabricator(:user) do
   account
   email        { sequence(:email) { |i| "#{i}#{Faker::Internet.email}" } }
   password     "123456789"
-  confirmed_at { Time.now }
+  confirmed_at { Time.zone.now }
 end
diff --git a/spec/features/log_in_spec.rb b/spec/features/log_in_spec.rb
index ed626d880..53a1f9b12 100644
--- a/spec/features/log_in_spec.rb
+++ b/spec/features/log_in_spec.rb
@@ -3,7 +3,7 @@ require "rails_helper"
 feature "Log in" do
   given(:email)        { "test@examle.com" }
   given(:password)     { "password" }
-  given(:confirmed_at) { Time.now }
+  given(:confirmed_at) { Time.zone.now }
 
   background do
     Fabricate(:user, email: email, password: password, confirmed_at: confirmed_at)
diff --git a/spec/lib/ostatus/atom_serializer_spec.rb b/spec/lib/ostatus/atom_serializer_spec.rb
index 3bc4b7efb..891871c1c 100644
--- a/spec/lib/ostatus/atom_serializer_spec.rb
+++ b/spec/lib/ostatus/atom_serializer_spec.rb
@@ -728,9 +728,9 @@ RSpec.describe OStatus::AtomSerializer do
     it 'appends id element with unique tag' do
       block = Fabricate(:block)
 
-      time_before = Time.now
+      time_before = Time.zone.now
       block_salmon = OStatus::AtomSerializer.new.block_salmon(block)
-      time_after = Time.now
+      time_after = Time.zone.now
 
       expect(block_salmon.id.text).to(
         eq(OStatus::TagManager.instance.unique_tag(time_before.utc, block.id, 'Block'))
@@ -815,9 +815,9 @@ RSpec.describe OStatus::AtomSerializer do
     it 'appends id element with unique tag' do
       block = Fabricate(:block)
 
-      time_before = Time.now
+      time_before = Time.zone.now
       unblock_salmon = OStatus::AtomSerializer.new.unblock_salmon(block)
-      time_after = Time.now
+      time_after = Time.zone.now
 
       expect(unblock_salmon.id.text).to(
         eq(OStatus::TagManager.instance.unique_tag(time_before.utc, block.id, 'Block'))
@@ -994,9 +994,9 @@ RSpec.describe OStatus::AtomSerializer do
     it 'appends id element with unique tag' do
       favourite = Fabricate(:favourite)
 
-      time_before = Time.now
+      time_before = Time.zone.now
       unfavourite_salmon = OStatus::AtomSerializer.new.unfavourite_salmon(favourite)
-      time_after = Time.now
+      time_after = Time.zone.now
 
       expect(unfavourite_salmon.id.text).to(
         eq(OStatus::TagManager.instance.unique_tag(time_before.utc, favourite.id, 'Favourite'))
@@ -1179,9 +1179,9 @@ RSpec.describe OStatus::AtomSerializer do
       follow = Fabricate(:follow)
       follow.destroy!
 
-      time_before = Time.now
+      time_before = Time.zone.now
       unfollow_salmon = OStatus::AtomSerializer.new.unfollow_salmon(follow)
-      time_after = Time.now
+      time_after = Time.zone.now
 
       expect(unfollow_salmon.id.text).to(
         eq(OStatus::TagManager.instance.unique_tag(time_before.utc, follow.id, 'Follow'))
@@ -1327,9 +1327,9 @@ RSpec.describe OStatus::AtomSerializer do
     it 'appends id element with unique tag' do
       follow_request = Fabricate(:follow_request)
 
-      time_before = Time.now
+      time_before = Time.zone.now
       authorize_follow_request_salmon = OStatus::AtomSerializer.new.authorize_follow_request_salmon(follow_request)
-      time_after = Time.now
+      time_after = Time.zone.now
 
       expect(authorize_follow_request_salmon.id.text).to(
         eq(OStatus::TagManager.instance.unique_tag(time_before.utc, follow_request.id, 'FollowRequest'))
@@ -1396,9 +1396,9 @@ RSpec.describe OStatus::AtomSerializer do
     it 'appends id element with unique tag' do
       follow_request = Fabricate(:follow_request)
 
-      time_before = Time.now
+      time_before = Time.zone.now
       reject_follow_request_salmon = OStatus::AtomSerializer.new.reject_follow_request_salmon(follow_request)
-      time_after = Time.now
+      time_after = Time.zone.now
 
       expect(reject_follow_request_salmon.id.text).to(
         eq(OStatus::TagManager.instance.unique_tag(time_before.utc, follow_request.id, 'FollowRequest'))
diff --git a/spec/models/account_conversation_spec.rb b/spec/models/account_conversation_spec.rb
new file mode 100644
index 000000000..70a76281e
--- /dev/null
+++ b/spec/models/account_conversation_spec.rb
@@ -0,0 +1,72 @@
+require 'rails_helper'
+
+RSpec.describe AccountConversation, type: :model do
+  let!(:alice) { Fabricate(:account, username: 'alice') }
+  let!(:bob)   { Fabricate(:account, username: 'bob') }
+  let!(:mark)  { Fabricate(:account, username: 'mark') }
+
+  describe '.add_status' do
+    it 'creates new record when no others exist' do
+      status = Fabricate(:status, account: alice, visibility: :direct)
+      status.mentions.create(account: bob)
+
+      conversation = AccountConversation.add_status(alice, status)
+
+      expect(conversation.participant_accounts).to include(bob)
+      expect(conversation.last_status).to eq status
+      expect(conversation.status_ids).to eq [status.id]
+    end
+
+    it 'appends to old record when there is a match' do
+      last_status  = Fabricate(:status, account: alice, visibility: :direct)
+      conversation = AccountConversation.create!(account: alice, conversation: last_status.conversation, participant_account_ids: [bob.id], status_ids: [last_status.id])
+
+      status = Fabricate(:status, account: bob, visibility: :direct, thread: last_status)
+      status.mentions.create(account: alice)
+
+      new_conversation = AccountConversation.add_status(alice, status)
+
+      expect(new_conversation.id).to eq conversation.id
+      expect(new_conversation.participant_accounts).to include(bob)
+      expect(new_conversation.last_status).to eq status
+      expect(new_conversation.status_ids).to eq [last_status.id, status.id]
+    end
+
+    it 'creates new record when new participants are added' do
+      last_status  = Fabricate(:status, account: alice, visibility: :direct)
+      conversation = AccountConversation.create!(account: alice, conversation: last_status.conversation, participant_account_ids: [bob.id], status_ids: [last_status.id])
+
+      status = Fabricate(:status, account: bob, visibility: :direct, thread: last_status)
+      status.mentions.create(account: alice)
+      status.mentions.create(account: mark)
+
+      new_conversation = AccountConversation.add_status(alice, status)
+
+      expect(new_conversation.id).to_not eq conversation.id
+      expect(new_conversation.participant_accounts).to include(bob, mark)
+      expect(new_conversation.last_status).to eq status
+      expect(new_conversation.status_ids).to eq [status.id]
+    end
+  end
+
+  describe '.remove_status' do
+    it 'updates last status to a previous value' do
+      last_status  = Fabricate(:status, account: alice, visibility: :direct)
+      status       = Fabricate(:status, account: alice, visibility: :direct)
+      conversation = AccountConversation.create!(account: alice, conversation: last_status.conversation, participant_account_ids: [bob.id], status_ids: [status.id, last_status.id])
+      last_status.mentions.create(account: bob)
+      last_status.destroy!
+      conversation.reload
+      expect(conversation.last_status).to eq status
+      expect(conversation.status_ids).to eq [status.id]
+    end
+
+    it 'removes the record if no other statuses are referenced' do
+      last_status  = Fabricate(:status, account: alice, visibility: :direct)
+      conversation = AccountConversation.create!(account: alice, conversation: last_status.conversation, participant_account_ids: [bob.id], status_ids: [last_status.id])
+      last_status.mentions.create(account: bob)
+      last_status.destroy!
+      expect(AccountConversation.where(id: conversation.id).count).to eq 0
+    end
+  end
+end
diff --git a/spec/models/status_spec.rb b/spec/models/status_spec.rb
index 512dc258e..8e90b92d0 100644
--- a/spec/models/status_spec.rb
+++ b/spec/models/status_spec.rb
@@ -154,7 +154,7 @@ RSpec.describe Status, type: :model do
 
   describe '#target' do
     it 'returns nil if the status is self-contained' do
-     expect(subject.target).to be_nil
+      expect(subject.target).to be_nil
     end
 
     it 'returns nil if the status is a reply' do
diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb
index 42198cb4d..8c6778edc 100644
--- a/spec/models/user_spec.rb
+++ b/spec/models/user_spec.rb
@@ -67,7 +67,7 @@ RSpec.describe User, type: :model do
     describe 'confirmed' do
       it 'returns an array of users who are confirmed' do
         user_1 = Fabricate(:user, confirmed_at: nil)
-        user_2 = Fabricate(:user, confirmed_at: Time.now)
+        user_2 = Fabricate(:user, confirmed_at: Time.zone.now)
         expect(User.confirmed).to match_array([user_2])
       end
     end
diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb
index 79e80220c..1ded751ab 100644
--- a/spec/rails_helper.rb
+++ b/spec/rails_helper.rb
@@ -72,11 +72,11 @@ RSpec::Sidekiq.configure do |config|
 end
 
 def request_fixture(name)
-  File.read(File.join(Rails.root, 'spec', 'fixtures', 'requests', name))
+  File.read(Rails.root.join('spec', 'fixtures', 'requests', name))
 end
 
 def attachment_fixture(name)
-  File.open(File.join(Rails.root, 'spec', 'fixtures', 'files', name))
+  File.open(Rails.root.join('spec', 'fixtures', 'files', name))
 end
 
 def stub_jsonld_contexts!
diff --git a/spec/services/batched_remove_status_service_spec.rb b/spec/services/batched_remove_status_service_spec.rb
index 23c122e59..c66214555 100644
--- a/spec/services/batched_remove_status_service_spec.rb
+++ b/spec/services/batched_remove_status_service_spec.rb
@@ -19,7 +19,7 @@ RSpec.describe BatchedRemoveStatusService, type: :service do
     stub_request(:post, 'http://example.com/inbox').to_return(status: 200)
 
     Fabricate(:subscription, account: alice, callback_url: 'http://example.com/push', confirmed: true, expires_at: 30.days.from_now)
-    jeff.user.update(current_sign_in_at: Time.now)
+    jeff.user.update(current_sign_in_at: Time.zone.now)
     jeff.follow!(alice)
     hank.follow!(alice)
 
diff --git a/spec/services/fetch_remote_account_service_spec.rb b/spec/services/fetch_remote_account_service_spec.rb
index 20dd505d0..3cd86708b 100644
--- a/spec/services/fetch_remote_account_service_spec.rb
+++ b/spec/services/fetch_remote_account_service_spec.rb
@@ -19,7 +19,7 @@ RSpec.describe FetchRemoteAccountService, type: :service do
   end
 
   let(:webfinger) { { subject: 'acct:alice@example.com', links: [{ rel: 'self', href: 'https://example.com/alice' }] } }
-  let(:xml) { File.read(File.join(Rails.root, 'spec', 'fixtures', 'xml', 'mastodon.atom')) }
+  let(:xml) { File.read(Rails.root.join('spec', 'fixtures', 'xml', 'mastodon.atom')) }
 
   shared_examples 'return Account' do
     it { is_expected.to be_an Account }
diff --git a/spec/services/process_feed_service_spec.rb b/spec/services/process_feed_service_spec.rb
index 1f26660ed..9d3465f3f 100644
--- a/spec/services/process_feed_service_spec.rb
+++ b/spec/services/process_feed_service_spec.rb
@@ -4,7 +4,7 @@ RSpec.describe ProcessFeedService, type: :service do
   subject { ProcessFeedService.new }
 
   describe 'processing a feed' do
-    let(:body) { File.read(File.join(Rails.root, 'spec', 'fixtures', 'xml', 'mastodon.atom')) }
+    let(:body) { File.read(Rails.root.join('spec', 'fixtures', 'xml', 'mastodon.atom')) }
     let(:account) { Fabricate(:account, username: 'localhost', domain: 'kickass.zone') }
 
     before do
diff --git a/spec/services/update_remote_profile_service_spec.rb b/spec/services/update_remote_profile_service_spec.rb
index 7ac3a809a..f3ea70b80 100644
--- a/spec/services/update_remote_profile_service_spec.rb
+++ b/spec/services/update_remote_profile_service_spec.rb
@@ -1,7 +1,7 @@
 require 'rails_helper'
 
 RSpec.describe UpdateRemoteProfileService, type: :service do
-  let(:xml) { File.read(File.join(Rails.root, 'spec', 'fixtures', 'push', 'feed.atom')) }
+  let(:xml) { File.read(Rails.root.join('spec', 'fixtures', 'push', 'feed.atom')) }
 
   subject { UpdateRemoteProfileService.new }
 
diff --git a/spec/views/about/show.html.haml_spec.rb b/spec/views/about/show.html.haml_spec.rb
index e15c72cec..65124fcf2 100644
--- a/spec/views/about/show.html.haml_spec.rb
+++ b/spec/views/about/show.html.haml_spec.rb
@@ -20,6 +20,7 @@ describe 'about/show.html.haml', without_verify_partial_doubles: true do
                                 open_registrations: false,
                                 thumbnail: nil,
                                 hero: nil,
+                                mascot: nil,
                                 user_count: 0,
                                 status_count: 0,
                                 commit_hash: commit_hash,
diff --git a/streaming/index.js b/streaming/index.js
index 1c6004b77..debf7c8bf 100644
--- a/streaming/index.js
+++ b/streaming/index.js
@@ -485,7 +485,8 @@ const startWorker = (workerId) => {
   });
 
   app.get('/api/v1/streaming/direct', (req, res) => {
-    streamFrom(`timeline:direct:${req.accountId}`, req, streamToHttp(req, res), streamHttpEnd(req), true);
+    const channel = `timeline:direct:${req.accountId}`;
+    streamFrom(channel, req, streamToHttp(req, res), streamHttpEnd(req, subscriptionHeartbeat(channel)), true);
   });
 
   app.get('/api/v1/streaming/hashtag', (req, res) => {
@@ -525,9 +526,11 @@ const startWorker = (workerId) => {
       ws.isAlive = true;
     });
 
+    let channel;
+
     switch(location.query.stream) {
     case 'user':
-      const channel = `timeline:${req.accountId}`;
+      channel = `timeline:${req.accountId}`;
       streamFrom(channel, req, streamToWs(req, ws), streamWsEnd(req, ws, subscriptionHeartbeat(channel)));
       break;
     case 'user:notification':
@@ -546,7 +549,8 @@ const startWorker = (workerId) => {
       streamFrom('timeline:public:local:media', req, streamToWs(req, ws), streamWsEnd(req, ws), true);
       break;
     case 'direct':
-      streamFrom(`timeline:direct:${req.accountId}`, req, streamToWs(req, ws), streamWsEnd(req, ws), true);
+      channel = `timeline:direct:${req.accountId}`;
+      streamFrom(channel, req, streamToWs(req, ws), streamWsEnd(req, ws, subscriptionHeartbeat(channel)), true);
       break;
     case 'hashtag':
       streamFrom(`timeline:hashtag:${location.query.tag.toLowerCase()}`, req, streamToWs(req, ws), streamWsEnd(req, ws), true);
@@ -563,7 +567,7 @@ const startWorker = (workerId) => {
           return;
         }
 
-        const channel = `timeline:list:${listId}`;
+        channel = `timeline:list:${listId}`;
         streamFrom(channel, req, streamToWs(req, ws), streamWsEnd(req, ws, subscriptionHeartbeat(channel)));
       });
       break;