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 8a35049b32..278fbc8985 100644
--- a/app/javascript/mastodon/actions/compose.js
+++ b/app/javascript/mastodon/actions/compose.js
@@ -128,6 +128,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') {
+ dispatch(updateTimeline('direct', { ...response.data }));
}
}).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 973c8a4aef..94dabd4ad3 100644
--- a/app/javascript/mastodon/features/getting_started/index.js
+++ b/app/javascript/mastodon/features/getting_started/index.js
@@ -16,6 +16,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' },
follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' },
sign_out: { id: 'navigation_bar.logout', defaultMessage: 'Logout' },
@@ -65,18 +66,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 70e451373d..cf51f0fb67 100644
--- a/app/javascript/mastodon/features/ui/index.js
+++ b/app/javascript/mastodon/features/ui/index.js
@@ -28,6 +28,7 @@ import {
Following,
Reblogs,
Favourites,
+ DirectTimeline,
HashtagTimeline,
Notifications,
FollowRequests,
@@ -350,6 +351,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 8f7b91d218..f86c2266c2 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 a9f3f95296..8b8bf165a7 100644
--- a/app/javascript/mastodon/reducers/settings.js
+++ b/app/javascript/mastodon/reducers/settings.js
@@ -57,6 +57,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 5a72456135..346282e2ae 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/config/routes.rb b/config/routes.rb
index 5a6351f779..8263c477be 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;