Add conversations API (#8832)
* Add conversations API * Add web UI for conversations * Add test for conversations API * Add tests for ConversationAccount * Improve web UI * Rename ConversationAccount to AccountConversation * Remove conversations on block and mute * Change last_status_id to be a denormalization of status_ids * Add optimistic lockingmain
parent
25744d43b0
commit
774ac47373
@ -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
|
@ -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,
|
||||||
|
});
|
||||||
|
};
|
@ -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);
|
@ -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;
|
||||||
|
}
|
||||||
|
};
|
@ -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
|
@ -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
|
@ -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
|
@ -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
|
@ -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
|
@ -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
|
@ -0,0 +1,6 @@
|
|||||||
|
Fabricator(:conversation_account) do
|
||||||
|
account nil
|
||||||
|
conversation nil
|
||||||
|
participant_account_ids ""
|
||||||
|
last_status nil
|
||||||
|
end
|
@ -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
|
Loading…
Reference in new issue