0
0
Fork 0

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 locking
This commit is contained in:
Eugen Rochko 2018-10-07 23:44:58 +02:00 committed by GitHub
parent 25744d43b0
commit 774ac47373
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
33 changed files with 816 additions and 44 deletions

View 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,
});
};

View file

@ -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;

View file

@ -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 });

View file

@ -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>
);
}

View file

@ -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>
);
}
}

View file

@ -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>
);
}
}

View file

@ -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);

View file

@ -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);

View file

@ -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>
);
}

View 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;
}
};

View file

@ -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);

View file

@ -69,7 +69,7 @@ const expandNormalizedNotifications = (state, notifications, next) => {
}
if (!next) {
mutable.set('hasMore', true);
mutable.set('hasMore', false);
}
mutable.set('isLoading', false);

View file

@ -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;
}
}