Merge pull request #198 from glitch-soc/gs-direct-timeline
Direct messages timeline from tootsuite/mastodon#4514lolsob-rspec
commit
a5b1005315
|
@ -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
|
|
@ -8,6 +8,7 @@ import {
|
||||||
refreshHomeTimeline,
|
refreshHomeTimeline,
|
||||||
refreshCommunityTimeline,
|
refreshCommunityTimeline,
|
||||||
refreshPublicTimeline,
|
refreshPublicTimeline,
|
||||||
|
refreshDirectTimeline,
|
||||||
} from './timelines';
|
} from './timelines';
|
||||||
|
|
||||||
export const COMPOSE_CHANGE = 'COMPOSE_CHANGE';
|
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') {
|
if (response.data.in_reply_to_id === null && response.data.visibility === 'public') {
|
||||||
insertOrRefresh('community', refreshCommunityTimeline);
|
insertOrRefresh('community', refreshCommunityTimeline);
|
||||||
insertOrRefresh('public', refreshPublicTimeline);
|
insertOrRefresh('public', refreshPublicTimeline);
|
||||||
|
} else if (response.data.visibility === 'direct') {
|
||||||
|
insertOrRefresh('direct', refreshDirectTimeline);
|
||||||
}
|
}
|
||||||
}).catch(function (error) {
|
}).catch(function (error) {
|
||||||
dispatch(submitComposeFail(error));
|
dispatch(submitComposeFail(error));
|
||||||
|
|
|
@ -92,3 +92,4 @@ export const connectCommunityStream = () => connectTimelineStream('community', '
|
||||||
export const connectMediaStream = () => connectTimelineStream('community', 'public:local');
|
export const connectMediaStream = () => connectTimelineStream('community', 'public:local');
|
||||||
export const connectPublicStream = () => connectTimelineStream('public', 'public');
|
export const connectPublicStream = () => connectTimelineStream('public', 'public');
|
||||||
export const connectHashtagStream = (tag) => connectTimelineStream(`hashtag:${tag}`, `hashtag&tag=${tag}`);
|
export const connectHashtagStream = (tag) => connectTimelineStream(`hashtag:${tag}`, `hashtag&tag=${tag}`);
|
||||||
|
export const connectDirectStream = () => connectTimelineStream('direct', 'direct');
|
||||||
|
|
|
@ -115,6 +115,7 @@ export function refreshTimeline(timelineId, path, params = {}) {
|
||||||
export const refreshHomeTimeline = () => refreshTimeline('home', '/api/v1/timelines/home');
|
export const refreshHomeTimeline = () => refreshTimeline('home', '/api/v1/timelines/home');
|
||||||
export const refreshPublicTimeline = () => refreshTimeline('public', '/api/v1/timelines/public');
|
export const refreshPublicTimeline = () => refreshTimeline('public', '/api/v1/timelines/public');
|
||||||
export const refreshCommunityTimeline = () => refreshTimeline('community', '/api/v1/timelines/public', { local: true });
|
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 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 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}`);
|
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 expandHomeTimeline = () => expandTimeline('home', '/api/v1/timelines/home');
|
||||||
export const expandPublicTimeline = () => expandTimeline('public', '/api/v1/timelines/public');
|
export const expandPublicTimeline = () => expandTimeline('public', '/api/v1/timelines/public');
|
||||||
export const expandCommunityTimeline = () => expandTimeline('community', '/api/v1/timelines/public', { local: true });
|
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 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 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}`);
|
export const expandHashtagTimeline = hashtag => expandTimeline(`hashtag:${hashtag}`, `/api/v1/timelines/tag/${hashtag}`);
|
||||||
|
|
|
@ -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);
|
|
@ -0,0 +1,107 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { connect } from 'react-redux';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import StatusListContainer from '../ui/containers/status_list_container';
|
||||||
|
import Column from '../../components/column';
|
||||||
|
import ColumnHeader from '../../components/column_header';
|
||||||
|
import {
|
||||||
|
refreshDirectTimeline,
|
||||||
|
expandDirectTimeline,
|
||||||
|
} from '../../actions/timelines';
|
||||||
|
import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
|
||||||
|
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||||
|
import ColumnSettingsContainer from './containers/column_settings_container';
|
||||||
|
import { connectDirectStream } from '../../actions/streaming';
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
title: { id: 'column.direct', defaultMessage: 'Direct messages' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const mapStateToProps = state => ({
|
||||||
|
hasUnread: state.getIn(['timelines', 'direct', 'unread']) > 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
@connect(mapStateToProps)
|
||||||
|
@injectIntl
|
||||||
|
export default class DirectTimeline extends React.PureComponent {
|
||||||
|
|
||||||
|
static propTypes = {
|
||||||
|
dispatch: PropTypes.func.isRequired,
|
||||||
|
columnId: PropTypes.string,
|
||||||
|
intl: PropTypes.object.isRequired,
|
||||||
|
hasUnread: PropTypes.bool,
|
||||||
|
multiColumn: PropTypes.bool,
|
||||||
|
};
|
||||||
|
|
||||||
|
handlePin = () => {
|
||||||
|
const { columnId, dispatch } = this.props;
|
||||||
|
|
||||||
|
if (columnId) {
|
||||||
|
dispatch(removeColumn(columnId));
|
||||||
|
} else {
|
||||||
|
dispatch(addColumn('DIRECT', {}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleMove = (dir) => {
|
||||||
|
const { columnId, dispatch } = this.props;
|
||||||
|
dispatch(moveColumn(columnId, dir));
|
||||||
|
}
|
||||||
|
|
||||||
|
handleHeaderClick = () => {
|
||||||
|
this.column.scrollTop();
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount () {
|
||||||
|
const { dispatch } = this.props;
|
||||||
|
|
||||||
|
dispatch(refreshDirectTimeline());
|
||||||
|
this.disconnect = dispatch(connectDirectStream());
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount () {
|
||||||
|
if (this.disconnect) {
|
||||||
|
this.disconnect();
|
||||||
|
this.disconnect = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setRef = c => {
|
||||||
|
this.column = c;
|
||||||
|
}
|
||||||
|
|
||||||
|
handleLoadMore = () => {
|
||||||
|
this.props.dispatch(expandDirectTimeline());
|
||||||
|
}
|
||||||
|
|
||||||
|
render () {
|
||||||
|
const { intl, hasUnread, columnId, multiColumn } = this.props;
|
||||||
|
const pinned = !!columnId;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Column ref={this.setRef}>
|
||||||
|
<ColumnHeader
|
||||||
|
icon='envelope'
|
||||||
|
active={hasUnread}
|
||||||
|
title={intl.formatMessage(messages.title)}
|
||||||
|
onPin={this.handlePin}
|
||||||
|
onMove={this.handleMove}
|
||||||
|
onClick={this.handleHeaderClick}
|
||||||
|
pinned={pinned}
|
||||||
|
multiColumn={multiColumn}
|
||||||
|
>
|
||||||
|
<ColumnSettingsContainer />
|
||||||
|
</ColumnHeader>
|
||||||
|
|
||||||
|
<StatusListContainer
|
||||||
|
trackScroll={!pinned}
|
||||||
|
scrollKey={`direct_timeline-${columnId}`}
|
||||||
|
timelineId='direct'
|
||||||
|
loadMore={this.handleLoadMore}
|
||||||
|
emptyMessage={<FormattedMessage id='empty_column.direct' defaultMessage="You don't have any direct messages yet. When you send or receive one, it will show up here." />}
|
||||||
|
/>
|
||||||
|
</Column>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -17,6 +17,7 @@ const messages = defineMessages({
|
||||||
navigation_subheading: { id: 'column_subheading.navigation', defaultMessage: 'Navigation' },
|
navigation_subheading: { id: 'column_subheading.navigation', defaultMessage: 'Navigation' },
|
||||||
settings_subheading: { id: 'column_subheading.settings', defaultMessage: 'Settings' },
|
settings_subheading: { id: 'column_subheading.settings', defaultMessage: 'Settings' },
|
||||||
community_timeline: { id: 'navigation_bar.community_timeline', defaultMessage: 'Local timeline' },
|
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' },
|
preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' },
|
||||||
settings: { id: 'navigation_bar.app_settings', defaultMessage: 'App settings' },
|
settings: { id: 'navigation_bar.app_settings', defaultMessage: 'App settings' },
|
||||||
follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' },
|
follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' },
|
||||||
|
@ -78,18 +79,22 @@ export default class GettingStarted extends ImmutablePureComponent {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
navItems = navItems.concat([
|
if (!multiColumn || !columns.find(item => item.get('id') === 'DIRECT')) {
|
||||||
<ColumnLink key='4' icon='star' text={intl.formatMessage(messages.favourites)} to='/favourites' />,
|
navItems.push(<ColumnLink key='4' icon='envelope' text={intl.formatMessage(messages.direct)} to='/timelines/direct' />);
|
||||||
<ColumnLink key='5' icon='thumb-tack' text={intl.formatMessage(messages.pins)} to='/pinned' />,
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (me.get('locked')) {
|
|
||||||
navItems.push(<ColumnLink key='6' icon='users' text={intl.formatMessage(messages.follow_requests)} to='/follow_requests' />);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
navItems = navItems.concat([
|
navItems = navItems.concat([
|
||||||
<ColumnLink key='7' icon='volume-off' text={intl.formatMessage(messages.mutes)} to='/mutes' />,
|
<ColumnLink key='5' icon='star' text={intl.formatMessage(messages.favourites)} to='/favourites' />,
|
||||||
<ColumnLink key='8' icon='ban' text={intl.formatMessage(messages.blocks)} to='/blocks' />,
|
<ColumnLink key='6' icon='thumb-tack' text={intl.formatMessage(messages.pins)} to='/pinned' />,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (me.get('locked')) {
|
||||||
|
navItems.push(<ColumnLink key='7' icon='users' text={intl.formatMessage(messages.follow_requests)} to='/follow_requests' />);
|
||||||
|
}
|
||||||
|
|
||||||
|
navItems = navItems.concat([
|
||||||
|
<ColumnLink key='8' icon='volume-off' text={intl.formatMessage(messages.mutes)} to='/mutes' />,
|
||||||
|
<ColumnLink key='9' icon='ban' text={intl.formatMessage(messages.blocks)} to='/blocks' />,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
@ -11,7 +11,7 @@ import BundleContainer from '../containers/bundle_container';
|
||||||
import ColumnLoading from './column_loading';
|
import ColumnLoading from './column_loading';
|
||||||
import DrawerLoading from './drawer_loading';
|
import DrawerLoading from './drawer_loading';
|
||||||
import BundleColumnError from './bundle_column_error';
|
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 detectPassiveEvents from 'detect-passive-events';
|
||||||
import { scrollRight } from '../../../scroll';
|
import { scrollRight } from '../../../scroll';
|
||||||
|
@ -23,6 +23,7 @@ const componentMap = {
|
||||||
'PUBLIC': PublicTimeline,
|
'PUBLIC': PublicTimeline,
|
||||||
'COMMUNITY': CommunityTimeline,
|
'COMMUNITY': CommunityTimeline,
|
||||||
'HASHTAG': HashtagTimeline,
|
'HASHTAG': HashtagTimeline,
|
||||||
|
'DIRECT': DirectTimeline,
|
||||||
'FAVOURITES': FavouritedStatuses,
|
'FAVOURITES': FavouritedStatuses,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -29,6 +29,7 @@ import {
|
||||||
Following,
|
Following,
|
||||||
Reblogs,
|
Reblogs,
|
||||||
Favourites,
|
Favourites,
|
||||||
|
DirectTimeline,
|
||||||
HashtagTimeline,
|
HashtagTimeline,
|
||||||
Notifications,
|
Notifications,
|
||||||
FollowRequests,
|
FollowRequests,
|
||||||
|
@ -71,6 +72,7 @@ const keyMap = {
|
||||||
goToNotifications: 'g n',
|
goToNotifications: 'g n',
|
||||||
goToLocal: 'g l',
|
goToLocal: 'g l',
|
||||||
goToFederated: 'g t',
|
goToFederated: 'g t',
|
||||||
|
goToDirect: 'g d',
|
||||||
goToStart: 'g s',
|
goToStart: 'g s',
|
||||||
goToFavourites: 'g f',
|
goToFavourites: 'g f',
|
||||||
goToPinned: 'g p',
|
goToPinned: 'g p',
|
||||||
|
@ -302,6 +304,10 @@ export default class UI extends React.Component {
|
||||||
this.context.router.history.push('/timelines/public');
|
this.context.router.history.push('/timelines/public');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handleHotkeyGoToDirect = () => {
|
||||||
|
this.context.router.history.push('/timelines/direct');
|
||||||
|
}
|
||||||
|
|
||||||
handleHotkeyGoToStart = () => {
|
handleHotkeyGoToStart = () => {
|
||||||
this.context.router.history.push('/getting-started');
|
this.context.router.history.push('/getting-started');
|
||||||
}
|
}
|
||||||
|
@ -357,6 +363,7 @@ export default class UI extends React.Component {
|
||||||
goToNotifications: this.handleHotkeyGoToNotifications,
|
goToNotifications: this.handleHotkeyGoToNotifications,
|
||||||
goToLocal: this.handleHotkeyGoToLocal,
|
goToLocal: this.handleHotkeyGoToLocal,
|
||||||
goToFederated: this.handleHotkeyGoToFederated,
|
goToFederated: this.handleHotkeyGoToFederated,
|
||||||
|
goToDirect: this.handleHotkeyGoToDirect,
|
||||||
goToStart: this.handleHotkeyGoToStart,
|
goToStart: this.handleHotkeyGoToStart,
|
||||||
goToFavourites: this.handleHotkeyGoToFavourites,
|
goToFavourites: this.handleHotkeyGoToFavourites,
|
||||||
goToPinned: this.handleHotkeyGoToPinned,
|
goToPinned: this.handleHotkeyGoToPinned,
|
||||||
|
@ -377,6 +384,7 @@ export default class UI extends React.Component {
|
||||||
<WrappedRoute path='/timelines/home' component={HomeTimeline} content={children} />
|
<WrappedRoute path='/timelines/home' component={HomeTimeline} content={children} />
|
||||||
<WrappedRoute path='/timelines/public' exact component={PublicTimeline} content={children} />
|
<WrappedRoute path='/timelines/public' exact component={PublicTimeline} content={children} />
|
||||||
<WrappedRoute path='/timelines/public/local' component={CommunityTimeline} content={children} />
|
<WrappedRoute path='/timelines/public/local' component={CommunityTimeline} content={children} />
|
||||||
|
<WrappedRoute path='/timelines/direct' component={DirectTimeline} content={children} />
|
||||||
<WrappedRoute path='/timelines/tag/:id' component={HashtagTimeline} content={children} />
|
<WrappedRoute path='/timelines/tag/:id' component={HashtagTimeline} content={children} />
|
||||||
|
|
||||||
<WrappedRoute path='/notifications' component={Notifications} content={children} />
|
<WrappedRoute path='/notifications' component={Notifications} content={children} />
|
||||||
|
|
|
@ -26,6 +26,10 @@ export function HashtagTimeline () {
|
||||||
return import(/* webpackChunkName: "features/hashtag_timeline" */'../../hashtag_timeline');
|
return import(/* webpackChunkName: "features/hashtag_timeline" */'../../hashtag_timeline');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function DirectTimeline() {
|
||||||
|
return import(/* webpackChunkName: "features/direct_timeline" */'../../direct_timeline');
|
||||||
|
}
|
||||||
|
|
||||||
export function Status () {
|
export function Status () {
|
||||||
return import(/* webpackChunkName: "features/status" */'../../status');
|
return import(/* webpackChunkName: "features/status" */'../../status');
|
||||||
}
|
}
|
||||||
|
|
|
@ -755,6 +755,19 @@
|
||||||
],
|
],
|
||||||
"path": "app/javascript/mastodon/features/compose/index.json"
|
"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": [
|
"descriptors": [
|
||||||
{
|
{
|
||||||
|
@ -816,6 +829,10 @@
|
||||||
"defaultMessage": "Local timeline",
|
"defaultMessage": "Local timeline",
|
||||||
"id": "navigation_bar.community_timeline"
|
"id": "navigation_bar.community_timeline"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"defaultMessage": "Direct messages",
|
||||||
|
"id": "navigation_bar.direct"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"defaultMessage": "Preferences",
|
"defaultMessage": "Preferences",
|
||||||
"id": "navigation_bar.preferences"
|
"id": "navigation_bar.preferences"
|
||||||
|
|
|
@ -28,6 +28,7 @@
|
||||||
"bundle_modal_error.retry": "Try again",
|
"bundle_modal_error.retry": "Try again",
|
||||||
"column.blocks": "Blocked users",
|
"column.blocks": "Blocked users",
|
||||||
"column.community": "Local timeline",
|
"column.community": "Local timeline",
|
||||||
|
"column.direct": "Direct messages",
|
||||||
"column.favourites": "Favourites",
|
"column.favourites": "Favourites",
|
||||||
"column.follow_requests": "Follow requests",
|
"column.follow_requests": "Follow requests",
|
||||||
"column.home": "Home",
|
"column.home": "Home",
|
||||||
|
@ -80,6 +81,7 @@
|
||||||
"emoji_button.symbols": "Symbols",
|
"emoji_button.symbols": "Symbols",
|
||||||
"emoji_button.travel": "Travel & Places",
|
"emoji_button.travel": "Travel & Places",
|
||||||
"empty_column.community": "The local timeline is empty. Write something publicly to get the ball rolling!",
|
"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.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": "Your home timeline is empty! Visit {public} or use search to get started and meet other users.",
|
||||||
"empty_column.home.public_timeline": "the public timeline",
|
"empty_column.home.public_timeline": "the public timeline",
|
||||||
|
@ -106,6 +108,7 @@
|
||||||
"missing_indicator.label": "Not found",
|
"missing_indicator.label": "Not found",
|
||||||
"navigation_bar.blocks": "Blocked users",
|
"navigation_bar.blocks": "Blocked users",
|
||||||
"navigation_bar.community_timeline": "Local timeline",
|
"navigation_bar.community_timeline": "Local timeline",
|
||||||
|
"navigation_bar.direct": "Direct messages",
|
||||||
"navigation_bar.edit_profile": "Edit profile",
|
"navigation_bar.edit_profile": "Edit profile",
|
||||||
"navigation_bar.favourites": "Favourites",
|
"navigation_bar.favourites": "Favourites",
|
||||||
"navigation_bar.follow_requests": "Follow requests",
|
"navigation_bar.follow_requests": "Follow requests",
|
||||||
|
|
|
@ -58,6 +58,12 @@ const initialState = ImmutableMap({
|
||||||
body: '',
|
body: '',
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
direct: ImmutableMap({
|
||||||
|
regex: ImmutableMap({
|
||||||
|
body: '',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const defaultColumns = fromJS([
|
const defaultColumns = fromJS([
|
||||||
|
|
|
@ -154,6 +154,14 @@ class Status < ApplicationRecord
|
||||||
where(account: [account] + account.following).where(visibility: [:public, :unlisted, :private])
|
where(account: [account] + account.following).where(visibility: [:public, :unlisted, :private])
|
||||||
end
|
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)
|
def as_public_timeline(account = nil, local_only = false)
|
||||||
query = timeline_scope(local_only).without_replies
|
query = timeline_scope(local_only).without_replies
|
||||||
|
|
||||||
|
|
|
@ -40,6 +40,7 @@ class BatchedRemoveStatusService < BaseService
|
||||||
# Cannot be batched
|
# Cannot be batched
|
||||||
statuses.each do |status|
|
statuses.each do |status|
|
||||||
unpush_from_public_timelines(status)
|
unpush_from_public_timelines(status)
|
||||||
|
unpush_from_direct_timelines(status) if status.direct_visibility?
|
||||||
batch_salmon_slaps(status) if status.local?
|
batch_salmon_slaps(status) if status.local?
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -100,6 +101,16 @@ class BatchedRemoveStatusService < BaseService
|
||||||
end
|
end
|
||||||
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)
|
def batch_salmon_slaps(status)
|
||||||
return if @mentions[status.id].empty?
|
return if @mentions[status.id].empty?
|
||||||
|
|
||||||
|
|
|
@ -10,15 +10,17 @@ class FanOutOnWriteService < BaseService
|
||||||
|
|
||||||
deliver_to_self(status) if status.account.local?
|
deliver_to_self(status) if status.account.local?
|
||||||
|
|
||||||
|
render_anonymous_payload(status)
|
||||||
|
|
||||||
if status.direct_visibility?
|
if status.direct_visibility?
|
||||||
deliver_to_mentioned_followers(status)
|
deliver_to_mentioned_followers(status)
|
||||||
|
deliver_to_direct_timelines(status)
|
||||||
else
|
else
|
||||||
deliver_to_followers(status)
|
deliver_to_followers(status)
|
||||||
end
|
end
|
||||||
|
|
||||||
return if status.account.silenced? || !status.public_visibility? || status.reblog?
|
return if status.account.silenced? || !status.public_visibility? || status.reblog?
|
||||||
|
|
||||||
render_anonymous_payload(status)
|
|
||||||
deliver_to_hashtags(status)
|
deliver_to_hashtags(status)
|
||||||
|
|
||||||
return if status.reply? && status.in_reply_to_account_id != status.account_id
|
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', @payload)
|
||||||
Redis.current.publish('timeline:public:local', @payload) if status.local?
|
Redis.current.publish('timeline:public:local', @payload) if status.local?
|
||||||
end
|
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
|
end
|
||||||
|
|
|
@ -18,6 +18,7 @@ class RemoveStatusService < BaseService
|
||||||
remove_reblogs
|
remove_reblogs
|
||||||
remove_from_hashtags
|
remove_from_hashtags
|
||||||
remove_from_public
|
remove_from_public
|
||||||
|
remove_from_direct if status.direct_visibility?
|
||||||
|
|
||||||
@status.destroy!
|
@status.destroy!
|
||||||
|
|
||||||
|
@ -121,6 +122,13 @@ class RemoveStatusService < BaseService
|
||||||
Redis.current.publish('timeline:public:local', @payload) if @status.local?
|
Redis.current.publish('timeline:public:local', @payload) if @status.local?
|
||||||
end
|
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
|
def redis
|
||||||
Redis.current
|
Redis.current
|
||||||
end
|
end
|
||||||
|
|
|
@ -5,6 +5,7 @@
|
||||||
%link{ href: asset_pack_path('features/notifications.js'), crossorigin: 'anonymous', rel: 'preload', as: 'script' }/
|
%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/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/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}
|
%meta{name: 'applicationServerKey', content: Rails.configuration.x.vapid_public_key}
|
||||||
%script#initial-state{ type: 'application/json' }!= json_escape(@initial_state_json)
|
%script#initial-state{ type: 'application/json' }!= json_escape(@initial_state_json)
|
||||||
|
|
||||||
|
|
|
@ -193,6 +193,7 @@ Rails.application.routes.draw do
|
||||||
end
|
end
|
||||||
|
|
||||||
namespace :timelines do
|
namespace :timelines do
|
||||||
|
resource :direct, only: :show, controller: :direct
|
||||||
resource :home, only: :show, controller: :home
|
resource :home, only: :show, controller: :home
|
||||||
resource :public, only: :show, controller: :public
|
resource :public, only: :show, controller: :public
|
||||||
resources :tag, only: :show
|
resources :tag, only: :show
|
||||||
|
|
|
@ -232,6 +232,55 @@ RSpec.describe Status, type: :model do
|
||||||
end
|
end
|
||||||
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
|
describe '.as_public_timeline' do
|
||||||
it 'only includes statuses with public visibility' do
|
it 'only includes statuses with public visibility' do
|
||||||
public_status = Fabricate(:status, visibility: :public)
|
public_status = Fabricate(:status, visibility: :public)
|
||||||
|
|
|
@ -402,6 +402,10 @@ const startWorker = (workerId) => {
|
||||||
streamFrom('timeline:public:local', req, streamToHttp(req, res), streamHttpEnd(req), true);
|
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) => {
|
app.get('/api/v1/streaming/hashtag', (req, res) => {
|
||||||
streamFrom(`timeline:hashtag:${req.query.tag.toLowerCase()}`, req, streamToHttp(req, res), streamHttpEnd(req), true);
|
streamFrom(`timeline:hashtag:${req.query.tag.toLowerCase()}`, req, streamToHttp(req, res), streamHttpEnd(req), true);
|
||||||
});
|
});
|
||||||
|
@ -437,6 +441,9 @@ const startWorker = (workerId) => {
|
||||||
case 'public:local':
|
case 'public:local':
|
||||||
streamFrom('timeline:public:local', req, streamToWs(req, ws), streamWsEnd(req, ws), true);
|
streamFrom('timeline:public:local', req, streamToWs(req, ws), streamWsEnd(req, ws), true);
|
||||||
break;
|
break;
|
||||||
|
case 'direct':
|
||||||
|
streamFrom(`timeline:direct:${req.accountId}`, req, streamToWs(req, ws), streamWsEnd(req, ws), true);
|
||||||
|
break;
|
||||||
case 'hashtag':
|
case 'hashtag':
|
||||||
streamFrom(`timeline:hashtag:${location.query.tag.toLowerCase()}`, req, streamToWs(req, ws), streamWsEnd(req, ws), true);
|
streamFrom(`timeline:hashtag:${location.query.tag.toLowerCase()}`, req, streamToWs(req, ws), streamWsEnd(req, ws), true);
|
||||||
break;
|
break;
|
||||||
|
|
Loading…
Reference in New Issue