mirror of
				https://github.com/lunaisnotaboy/mastodon.git
				synced 2025-10-31 04:05:23 +00:00 
			
		
		
		
	Added a timeline for Direct statuses
* Lists all Direct statuses you've sent and received * Displayed in Getting Started * Streaming server support for direct TL
This commit is contained in:
		
							parent
							
								
									09d81defcd
								
							
						
					
					
						commit
						3db80f75a6
					
				
							
								
								
									
										60
									
								
								app/controllers/api/v1/timelines/direct_controller.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										60
									
								
								app/controllers/api/v1/timelines/direct_controller.rb
									
									
									
									
									
										Normal file
									
								
							|  | @ -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 | ||||
|  | @ -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)); | ||||
|  |  | |||
|  | @ -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'); | ||||
|  |  | |||
|  | @ -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}`); | ||||
|  |  | |||
|  | @ -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); | ||||
							
								
								
									
										107
									
								
								app/javascript/mastodon/features/direct_timeline/index.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										107
									
								
								app/javascript/mastodon/features/direct_timeline/index.js
									
									
									
									
									
										Normal file
									
								
							|  | @ -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> | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
| } | ||||
|  | @ -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([ | ||||
|       <ColumnLink key='4' icon='star' text={intl.formatMessage(messages.favourites)} to='/favourites' />, | ||||
|       <ColumnLink key='5' icon='thumb-tack' text={intl.formatMessage(messages.pins)} to='/pinned' />, | ||||
|     ]); | ||||
| 
 | ||||
|     if (me.get('locked')) { | ||||
|       navItems.push(<ColumnLink key='6' icon='users' text={intl.formatMessage(messages.follow_requests)} to='/follow_requests' />); | ||||
|     if (!multiColumn || !columns.find(item => item.get('id') === 'DIRECT')) { | ||||
|       navItems.push(<ColumnLink key='4' icon='envelope' text={intl.formatMessage(messages.direct)} to='/timelines/direct' />); | ||||
|     } | ||||
| 
 | ||||
|     navItems = navItems.concat([ | ||||
|       <ColumnLink key='7' icon='volume-off' text={intl.formatMessage(messages.mutes)} to='/mutes' />, | ||||
|       <ColumnLink key='8' icon='ban' text={intl.formatMessage(messages.blocks)} to='/blocks' />, | ||||
|       <ColumnLink key='5' icon='star' text={intl.formatMessage(messages.favourites)} to='/favourites' />, | ||||
|       <ColumnLink key='6' icon='thumb-tack' text={intl.formatMessage(messages.pins)} to='/pinned' />, | ||||
|     ]); | ||||
| 
 | ||||
|     if (me.get('locked')) { | ||||
|       navItems.push(<ColumnLink key='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 ( | ||||
|  |  | |||
|  | @ -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, | ||||
| }; | ||||
| 
 | ||||
|  |  | |||
|  | @ -28,6 +28,7 @@ import { | |||
|   Following, | ||||
|   Reblogs, | ||||
|   Favourites, | ||||
|   DirectTimeline, | ||||
|   HashtagTimeline, | ||||
|   Notifications, | ||||
|   FollowRequests, | ||||
|  | @ -350,6 +351,7 @@ export default class UI extends React.Component { | |||
|               <WrappedRoute path='/timelines/home' component={HomeTimeline} content={children} /> | ||||
|               <WrappedRoute path='/timelines/public' exact component={PublicTimeline} content={children} /> | ||||
|               <WrappedRoute path='/timelines/public/local' component={CommunityTimeline} content={children} /> | ||||
|               <WrappedRoute path='/timelines/direct' component={DirectTimeline} content={children} /> | ||||
|               <WrappedRoute path='/timelines/tag/:id' component={HashtagTimeline} content={children} /> | ||||
| 
 | ||||
|               <WrappedRoute path='/notifications' component={Notifications} content={children} /> | ||||
|  |  | |||
|  | @ -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'); | ||||
| } | ||||
|  |  | |||
|  | @ -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" | ||||
|  |  | |||
|  | @ -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", | ||||
|  |  | |||
|  | @ -57,6 +57,12 @@ const initialState = ImmutableMap({ | |||
|       body: '', | ||||
|     }), | ||||
|   }), | ||||
| 
 | ||||
|   direct: ImmutableMap({ | ||||
|     regex: ImmutableMap({ | ||||
|       body: '', | ||||
|     }), | ||||
|   }), | ||||
| }); | ||||
| 
 | ||||
| const defaultColumns = fromJS([ | ||||
|  |  | |||
|  | @ -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 | ||||
| 
 | ||||
|  |  | |||
|  | @ -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? | ||||
| 
 | ||||
|  |  | |||
|  | @ -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 | ||||
|  |  | |||
|  | @ -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 | ||||
|  |  | |||
|  | @ -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 | ||||
|  |  | |||
|  | @ -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) | ||||
|  |  | |||
|  | @ -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; | ||||
|  |  | |||
		Loading…
	
		Reference in a new issue