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 0000000000..d455227eb5
--- /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/javascript/mastodon/actions/compose.js b/app/javascript/mastodon/actions/compose.js
index 24e64e06cc..3ee9e1e7bb 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 7802694a3c..a2e25c9302 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 09abe2702a..935bbb6f0b 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/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 0000000000..1833f69e5c
--- /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 0000000000..05e092ee01
--- /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 (
+
+
+
+
+
+ }
+ />
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/getting_started/index.js b/app/javascript/mastodon/features/getting_started/index.js
index 68267c54f1..9b94b98308 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 {
}
}
- navItems = navItems.concat([
- ,
- ,
- ]);
-
- if (me.get('locked')) {
- navItems.push();
+ if (!multiColumn || !columns.find(item => item.get('id') === 'DIRECT')) {
+ navItems.push();
}
navItems = navItems.concat([
- ,
- ,
+ ,
+ ,
+ ]);
+
+ if (me.get('locked')) {
+ navItems.push();
+ }
+
+ navItems = navItems.concat([
+ ,
+ ,
]);
return (
diff --git a/app/javascript/mastodon/features/ui/components/columns_area.js b/app/javascript/mastodon/features/ui/components/columns_area.js
index 5610095b99..ee1064229d 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 883bfe0557..9f77ab5aa9 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 {
+
diff --git a/app/javascript/mastodon/features/ui/util/async-components.js b/app/javascript/mastodon/features/ui/util/async-components.js
index 7f2b303a75..dc8e9dfb90 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 f400b283fd..ebb514e69b 100644
--- a/app/javascript/mastodon/locales/defaultMessages.json
+++ b/app/javascript/mastodon/locales/defaultMessages.json
@@ -755,6 +755,19 @@
],
"path": "app/javascript/mastodon/features/compose/index.json"
},
+ {
+ "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": [
{
@@ -816,6 +829,10 @@
"defaultMessage": "Local timeline",
"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 1d0bbcee55..efe0e1de99 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/reducers/settings.js b/app/javascript/mastodon/reducers/settings.js
index 0c0dae388d..4b8a652d13 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/models/status.rb b/app/models/status.rb
index 30d53f298b..d78a921b5d 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/services/batched_remove_status_service.rb b/app/services/batched_remove_status_service.rb
index 5d83771c9d..aa2229f13b 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 47a47a7354..2214d73dd6 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 96d9208cce..8eef3e57e7 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/home/index.html.haml b/app/views/home/index.html.haml
index c3f5cb8422..cba2bbbd46 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/config/routes.rb b/config/routes.rb
index 9ed081e508..047de73ba2 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -193,6 +193,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/spec/models/status_spec.rb b/spec/models/status_spec.rb
index 9cb71d715d..12e8571692 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 83903b89b2..8adc5174a1 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;