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