diff options
58 files changed, 875 insertions, 106 deletions
diff --git a/Gemfile b/Gemfile index b1b422097..d77a29b79 100644 --- a/Gemfile +++ b/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' diff --git a/Gemfile.lock b/Gemfile.lock index 0efe99db0..3583bc396 100644 --- a/Gemfile.lock +++ b/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) diff --git a/app/controllers/admin/settings_controller.rb b/app/controllers/admin/settings_controller.rb index c05c4c841..46e9b2c07 100644 --- a/app/controllers/admin/settings_controller.rb +++ b/app/controllers/admin/settings_controller.rb @@ -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 diff --git a/app/controllers/api/v1/conversations_controller.rb b/app/controllers/api/v1/conversations_controller.rb new file mode 100644 index 000000000..736cb21ca --- /dev/null +++ b/app/controllers/api/v1/conversations_controller.rb @@ -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 diff --git a/app/controllers/concerns/signature_verification.rb b/app/controllers/concerns/signature_verification.rb index 4d77fa432..5f95fa346 100644 --- a/app/controllers/concerns/signature_verification.rb +++ b/app/controllers/concerns/signature_verification.rb @@ -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 diff --git a/app/javascript/mastodon/actions/conversations.js b/app/javascript/mastodon/actions/conversations.js new file mode 100644 index 000000000..3840d23ca --- /dev/null +++ b/app/javascript/mastodon/actions/conversations.js @@ -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, + }); +}; diff --git a/app/javascript/mastodon/actions/streaming.js b/app/javascript/mastodon/actions/streaming.js index 32fc67e67..8cf055540 100644 --- a/app/javascript/mastodon/actions/streaming.js +++ b/app/javascript/mastodon/actions/streaming.js @@ -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; diff --git a/app/javascript/mastodon/actions/timelines.js b/app/javascript/mastodon/actions/timelines.js index e8fd441e1..c4fc6448c 100644 --- a/app/javascript/mastodon/actions/timelines.js +++ b/app/javascript/mastodon/actions/timelines.js @@ -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 }); diff --git a/app/javascript/mastodon/components/display_name.js b/app/javascript/mastodon/components/display_name.js index a1c56ae35..c3a9ab921 100644 --- a/app/javascript/mastodon/components/display_name.js +++ b/app/javascript/mastodon/components/display_name.js @@ -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> ); } diff --git a/app/javascript/mastodon/features/direct_timeline/components/conversation.js b/app/javascript/mastodon/features/direct_timeline/components/conversation.js new file mode 100644 index 000000000..f9a8d4f72 --- /dev/null +++ b/app/javascript/mastodon/features/direct_timeline/components/conversation.js @@ -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> + ); + } + +} diff --git a/app/javascript/mastodon/features/direct_timeline/components/conversations_list.js b/app/javascript/mastodon/features/direct_timeline/components/conversations_list.js new file mode 100644 index 000000000..4684548e0 --- /dev/null +++ b/app/javascript/mastodon/features/direct_timeline/components/conversations_list.js @@ -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> + ); + } + +} diff --git a/app/javascript/mastodon/features/direct_timeline/containers/conversation_container.js b/app/javascript/mastodon/features/direct_timeline/containers/conversation_container.js new file mode 100644 index 000000000..4166ee2ac --- /dev/null +++ b/app/javascript/mastodon/features/direct_timeline/containers/conversation_container.js @@ -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); diff --git a/app/javascript/mastodon/features/direct_timeline/containers/conversations_list_container.js b/app/javascript/mastodon/features/direct_timeline/containers/conversations_list_container.js new file mode 100644 index 000000000..81ea812ad --- /dev/null +++ b/app/javascript/mastodon/features/direct_timeline/containers/conversations_list_container.js @@ -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); diff --git a/app/javascript/mastodon/features/direct_timeline/index.js b/app/javascript/mastodon/features/direct_timeline/index.js index 3c7e2d007..4c8485690 100644 --- a/app/javascript/mastodon/features/direct_timeline/index.js +++ b/app/javascript/mastodon/features/direct_timeline/index.js @@ -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 { defineMessages, injectIntl } 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,14 +84,7 @@ class DirectTimeline extends React.PureComponent { multiColumn={multiColumn} /> - <StatusListContainer - trackScroll={!pinned} - scrollKey={`direct_timeline-${columnId}`} - timelineId='direct' - onLoadMore={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." />} - shouldUpdateScroll={shouldUpdateScroll} - /> + <ConversationsListContainer shouldUpdateScroll={shouldUpdateScroll} /> </Column> ); } diff --git a/app/javascript/mastodon/reducers/conversations.js b/app/javascript/mastodon/reducers/conversations.js new file mode 100644 index 000000000..f339abf56 --- /dev/null +++ b/app/javascript/mastodon/reducers/conversations.js @@ -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; + } +}; diff --git a/app/javascript/mastodon/reducers/index.js b/app/javascript/mastodon/reducers/index.js index 4a981fada..d3b98d4f6 100644 --- a/app/javascript/mastodon/reducers/index.js +++ b/app/javascript/mastodon/reducers/index.js @@ -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); diff --git a/app/javascript/mastodon/reducers/notifications.js b/app/javascript/mastodon/reducers/notifications.js index 0b29f19fa..d71ae00ae 100644 --- a/app/javascript/mastodon/reducers/notifications.js +++ b/app/javascript/mastodon/reducers/notifications.js @@ -69,7 +69,7 @@ const expandNormalizedNotifications = (state, notifications, next) => { } if (!next) { - mutable.set('hasMore', true); + mutable.set('hasMore', false); } mutable.set('isLoading', false); diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss index ecd1a8063..6aabf5777 100644 --- a/app/javascript/styles/mastodon/components.scss +++ b/app/javascript/styles/mastodon/components.scss @@ -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; + } +} diff --git a/app/lib/inline_renderer.rb b/app/lib/inline_renderer.rb index 7cd9758ec..761a8822d 100644 --- a/app/lib/inline_renderer.rb +++ b/app/lib/inline_renderer.rb @@ -13,6 +13,8 @@ class InlineRenderer serializer = REST::StatusSerializer when :notification serializer = REST::NotificationSerializer + when :conversation + serializer = REST::ConversationSerializer else return end diff --git a/app/models/account_conversation.rb b/app/models/account_conversation.rb new file mode 100644 index 000000000..a7205ec1a --- /dev/null +++ b/app/models/account_conversation.rb @@ -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 diff --git a/app/models/concerns/omniauthable.rb b/app/models/concerns/omniauthable.rb index 50288e700..f263fe7af 100644 --- a/app/models/concerns/omniauthable.rb +++ b/app/models/concerns/omniauthable.rb @@ -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), diff --git a/app/models/status.rb b/app/models/status.rb index 028927cc3..ad25cc8df 100644 --- a/app/models/status.rb +++ b/app/models/status.rb @@ -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 diff --git a/app/presenters/instance_presenter.rb b/app/presenters/instance_presenter.rb index 0249c134f..5d22962cf 100644 --- a/app/presenters/instance_presenter.rb +++ b/app/presenters/instance_presenter.rb @@ -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 diff --git a/app/serializers/rest/conversation_serializer.rb b/app/serializers/rest/conversation_serializer.rb new file mode 100644 index 000000000..08cea47d2 --- /dev/null +++ b/app/serializers/rest/conversation_serializer.rb @@ -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 diff --git a/app/services/after_block_service.rb b/app/services/after_block_service.rb index a7dce08c7..706db0d63 100644 --- a/app/services/after_block_service.rb +++ b/app/services/after_block_service.rb @@ -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 diff --git a/app/services/fan_out_on_write_service.rb b/app/services/fan_out_on_write_service.rb index 5efd3edb2..ab520276b 100644 --- a/app/services/fan_out_on_write_service.rb +++ b/app/services/fan_out_on_write_service.rb @@ -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 diff --git a/app/services/mute_service.rb b/app/services/mute_service.rb index c6122a152..676804cb9 100644 --- a/app/services/mute_service.rb +++ b/app/services/mute_service.rb @@ -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 diff --git a/app/services/notify_service.rb b/app/services/notify_service.rb index 7d0dcc7ad..63bf8f17a 100644 --- a/app/services/notify_service.rb +++ b/app/services/notify_service.rb @@ -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 diff --git a/app/views/about/more.html.haml b/app/views/about/more.html.haml index 99028935f..87f1071d9 100644 --- a/app/views/about/more.html.haml +++ b/app/views/about/more.html.haml @@ -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 diff --git a/app/views/about/show.html.haml b/app/views/about/show.html.haml index 48435fe9c..6c28f83ce 100644 --- a/app/views/about/show.html.haml +++ b/app/views/about/show.html.haml @@ -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 diff --git a/app/views/admin/settings/edit.html.haml b/app/views/admin/settings/edit.html.haml index b4abbf815..361e249e0 100644 --- a/app/views/admin/settings/edit.html.haml +++ b/app/views/admin/settings/edit.html.haml @@ -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/ diff --git a/app/workers/block_worker.rb b/app/workers/block_worker.rb index 0820490d3..25f5dd808 100644 --- a/app/workers/block_worker.rb +++ b/app/workers/block_worker.rb @@ -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 diff --git a/app/workers/mute_worker.rb b/app/workers/mute_worker.rb new file mode 100644 index 000000000..7bf0923a5 --- /dev/null +++ b/app/workers/mute_worker.rb @@ -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 diff --git a/app/workers/push_conversation_worker.rb b/app/workers/push_conversation_worker.rb new file mode 100644 index 000000000..16f538215 --- /dev/null +++ b/app/workers/push_conversation_worker.rb @@ -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 diff --git a/config/environments/development.rb b/config/environments/development.rb index b6478f16e..0791b82ab 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -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?(*) diff --git a/config/initializers/open_uri_redirection.rb b/config/initializers/open_uri_redirection.rb index ea2dcffea..e9de85bdc 100644 --- a/config/initializers/open_uri_redirection.rb +++ b/config/initializers/open_uri_redirection.rb @@ -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 diff --git a/config/locales/en.yml b/config/locales/en.yml index fd36d4727..553c62e58 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -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 diff --git a/config/routes.rb b/config/routes.rb index 01cb6b140..8895b7272 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -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 diff --git a/db/migrate/20180929222014_create_account_conversations.rb b/db/migrate/20180929222014_create_account_conversations.rb new file mode 100644 index 000000000..53fa137e1 --- /dev/null +++ b/db/migrate/20180929222014_create_account_conversations.rb @@ -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 diff --git a/db/schema.rb b/db/schema.rb index 1e1f0ed99..8ab3ea3c0 100644 --- a/db/schema.rb +++ b/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" diff --git a/lib/mastodon/migration_helpers.rb b/lib/mastodon/migration_helpers.rb index 5c135685f..f5dc7e1c6 100644 --- a/lib/mastodon/migration_helpers.rb +++ b/lib/mastodon/migration_helpers.rb @@ -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. diff --git a/spec/controllers/api/salmon_controller_spec.rb b/spec/controllers/api/salmon_controller_spec.rb index 8ce4913a5..235a29af0 100644 --- a/spec/controllers/api/salmon_controller_spec.rb +++ b/spec/controllers/api/salmon_controller_spec.rb @@ -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 diff --git a/spec/controllers/api/subscriptions_controller_spec.rb b/spec/controllers/api/subscriptions_controller_spec.rb index b46971a54..7a4252fe6 100644 --- a/spec/controllers/api/subscriptions_controller_spec.rb +++ b/spec/controllers/api/subscriptions_controller_spec.rb @@ -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 => {}) diff --git a/spec/controllers/api/v1/conversations_controller_spec.rb b/spec/controllers/api/v1/conversations_controller_spec.rb new file mode 100644 index 000000000..2e9525855 --- /dev/null +++ b/spec/controllers/api/v1/conversations_controller_spec.rb @@ -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 diff --git a/spec/fabricators/conversation_account_fabricator.rb b/spec/fabricators/conversation_account_fabricator.rb new file mode 100644 index 000000000..f57ffd535 --- /dev/null +++ b/spec/fabricators/conversation_account_fabricator.rb @@ -0,0 +1,6 @@ +Fabricator(:conversation_account) do + account nil + conversation nil + participant_account_ids "" + last_status nil +end diff --git a/spec/fabricators/user_fabricator.rb b/spec/fabricators/user_fabricator.rb index cf51fe81d..7dfbdb52d 100644 --- a/spec/fabricators/user_fabricator.rb +++ b/spec/fabricators/user_fabricator.rb @@ -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 diff --git a/spec/features/log_in_spec.rb b/spec/features/log_in_spec.rb index ed626d880..53a1f9b12 100644 --- a/spec/features/log_in_spec.rb +++ b/spec/features/log_in_spec.rb @@ -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) diff --git a/spec/lib/ostatus/atom_serializer_spec.rb b/spec/lib/ostatus/atom_serializer_spec.rb index 3bc4b7efb..891871c1c 100644 --- a/spec/lib/ostatus/atom_serializer_spec.rb +++ b/spec/lib/ostatus/atom_serializer_spec.rb @@ -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')) diff --git a/spec/models/account_conversation_spec.rb b/spec/models/account_conversation_spec.rb new file mode 100644 index 000000000..70a76281e --- /dev/null +++ b/spec/models/account_conversation_spec.rb @@ -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 diff --git a/spec/models/status_spec.rb b/spec/models/status_spec.rb index 512dc258e..8e90b92d0 100644 --- a/spec/models/status_spec.rb +++ b/spec/models/status_spec.rb @@ -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 diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 42198cb4d..8c6778edc 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -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 diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index 79e80220c..1ded751ab 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -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! diff --git a/spec/services/batched_remove_status_service_spec.rb b/spec/services/batched_remove_status_service_spec.rb index 23c122e59..c66214555 100644 --- a/spec/services/batched_remove_status_service_spec.rb +++ b/spec/services/batched_remove_status_service_spec.rb @@ -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) diff --git a/spec/services/fetch_remote_account_service_spec.rb b/spec/services/fetch_remote_account_service_spec.rb index 20dd505d0..3cd86708b 100644 --- a/spec/services/fetch_remote_account_service_spec.rb +++ b/spec/services/fetch_remote_account_service_spec.rb @@ -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 } diff --git a/spec/services/process_feed_service_spec.rb b/spec/services/process_feed_service_spec.rb index 1f26660ed..9d3465f3f 100644 --- a/spec/services/process_feed_service_spec.rb +++ b/spec/services/process_feed_service_spec.rb @@ -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 diff --git a/spec/services/update_remote_profile_service_spec.rb b/spec/services/update_remote_profile_service_spec.rb index 7ac3a809a..f3ea70b80 100644 --- a/spec/services/update_remote_profile_service_spec.rb +++ b/spec/services/update_remote_profile_service_spec.rb @@ -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 } diff --git a/spec/views/about/show.html.haml_spec.rb b/spec/views/about/show.html.haml_spec.rb index e15c72cec..65124fcf2 100644 --- a/spec/views/about/show.html.haml_spec.rb +++ b/spec/views/about/show.html.haml_spec.rb @@ -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, diff --git a/streaming/index.js b/streaming/index.js index 1c6004b77..debf7c8bf 100644 --- a/streaming/index.js +++ b/streaming/index.js @@ -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; |