Add DM conversations mode similar to upstream

pull/1102/head
Thibaut Girka 2019-06-09 12:07:23 +02:00 committed by ThibG
parent e16c8fbc7a
commit d61a6271c6
20 changed files with 704 additions and 69 deletions

View File

@ -0,0 +1,84 @@
import api, { getLinks } from 'flavours/glitch/util/api';
import {
importFetchedAccounts,
importFetchedStatuses,
importFetchedStatus,
} from './importer';
export const CONVERSATIONS_MOUNT = 'CONVERSATIONS_MOUNT';
export const CONVERSATIONS_UNMOUNT = 'CONVERSATIONS_UNMOUNT';
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 CONVERSATIONS_READ = 'CONVERSATIONS_READ';
export const mountConversations = () => ({
type: CONVERSATIONS_MOUNT,
});
export const unmountConversations = () => ({
type: CONVERSATIONS_UNMOUNT,
});
export const markConversationRead = conversationId => (dispatch, getState) => {
dispatch({
type: CONVERSATIONS_READ,
id: conversationId,
});
api(getState).post(`/api/v1/conversations/${conversationId}/read`);
};
export const expandConversations = ({ maxId } = {}) => (dispatch, getState) => {
dispatch(expandConversationsRequest());
const params = { max_id: maxId };
if (!maxId) {
params.since_id = getState().getIn(['conversations', 'items', 0, 'last_status']);
}
const isLoadingRecent = !!params.since_id;
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, isLoadingRecent));
})
.catch(err => dispatch(expandConversationsFail(err)));
};
export const expandConversationsRequest = () => ({
type: CONVERSATIONS_FETCH_REQUEST,
});
export const expandConversationsSuccess = (conversations, next, isLoadingRecent) => ({
type: CONVERSATIONS_FETCH_SUCCESS,
conversations,
next,
isLoadingRecent,
});
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

@ -7,6 +7,7 @@ import {
disconnectTimeline, disconnectTimeline,
} from './timelines'; } from './timelines';
import { updateNotifications, expandNotifications } from './notifications'; import { updateNotifications, expandNotifications } from './notifications';
import { updateConversations } from './conversations';
import { fetchFilters } from './filters'; import { fetchFilters } from './filters';
import { getLocale } from 'mastodon/locales'; import { getLocale } from 'mastodon/locales';
@ -37,6 +38,9 @@ export function connectTimelineStream (timelineId, path, pollingRefresh = null,
case 'notification': case 'notification':
dispatch(updateNotifications(JSON.parse(data.payload), messages, locale)); dispatch(updateNotifications(JSON.parse(data.payload), messages, locale));
break; break;
case 'conversation':
dispatch(updateConversations(JSON.parse(data.payload)));
break;
case 'filters_changed': case 'filters_changed':
dispatch(fetchFilters()); dispatch(fetchFilters());
break; break;

View File

@ -0,0 +1,104 @@
import React from 'react';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { autoPlayGif } from 'flavours/glitch/util/initial_state';
export default class AvatarComposite extends React.PureComponent {
static propTypes = {
accounts: ImmutablePropTypes.list.isRequired,
animate: PropTypes.bool,
size: PropTypes.number.isRequired,
};
static defaultProps = {
animate: autoPlayGif,
};
renderItem (account, size, index) {
const { animate } = this.props;
let width = 50;
let height = 100;
let top = 'auto';
let left = 'auto';
let bottom = 'auto';
let right = 'auto';
if (size === 1) {
width = 100;
}
if (size === 4 || (size === 3 && index > 0)) {
height = 50;
}
if (size === 2) {
if (index === 0) {
right = '2px';
} else {
left = '2px';
}
} else if (size === 3) {
if (index === 0) {
right = '2px';
} else if (index > 0) {
left = '2px';
}
if (index === 1) {
bottom = '2px';
} else if (index > 1) {
top = '2px';
}
} else if (size === 4) {
if (index === 0 || index === 2) {
right = '2px';
}
if (index === 1 || index === 3) {
left = '2px';
}
if (index < 2) {
bottom = '2px';
} else {
top = '2px';
}
}
const style = {
left: left,
top: top,
right: right,
bottom: bottom,
width: `${width}%`,
height: `${height}%`,
backgroundSize: 'cover',
backgroundImage: `url(${account.get(animate ? 'avatar' : 'avatar_static')})`,
};
return (
<a
href={account.get('url')}
target='_blank'
onClick={(e) => this.props.onAccountClick(account.get('id'), e)}
title={`@${account.get('acct')}`}
key={account.get('id')}
>
<div style={style} data-avatar-of={`@${account.get('acct')}`} />
</a>
);
}
render() {
const { accounts, size } = this.props;
return (
<div className='account__avatar-composite' style={{ width: `${size}px`, height: `${size}px` }}>
{accounts.take(4).map((account, i) => this.renderItem(account, accounts.size, i))}
</div>
);
}
}

View File

@ -10,24 +10,56 @@ export default function DisplayName ({
className, className,
inline, inline,
localDomain, localDomain,
others,
onAccountClick,
}) { }) {
const computedClass = classNames('display-name', { inline }, className); const computedClass = classNames('display-name', { inline }, className);
if (!account) return null; if (!account) return null;
let displayName, suffix;
let acct = account.get('acct'); let acct = account.get('acct');
if (acct.indexOf('@') === -1 && localDomain) { if (acct.indexOf('@') === -1 && localDomain) {
acct = `${acct}@${localDomain}`; acct = `${acct}@${localDomain}`;
} }
// The result. if (others && others.size > 0) {
return account ? ( displayName = others.take(2).map(a => (
<a
href={a.get('url')}
target='_blank'
onClick={(e) => onAccountClick(a.get('id'), e)}
title={`@${a.get('acct')}`}
>
<bdi key={a.get('id')}>
<strong className='display-name__html' dangerouslySetInnerHTML={{ __html: a.get('display_name_html') }} />
</bdi>
</a>
)).reduce((prev, cur) => [prev, ', ', cur]);
if (others.size - 2 > 0) {
displayName.push(` +${others.size - 2}`);
}
suffix = (
<a href={account.get('url')} target='_blank' onClick={(e) => onAccountClick(account.get('id'), e)}>
<span className='display-name__account'>@{acct}</span>
</a>
);
} else {
displayName = <bdi><strong className='display-name__html' dangerouslySetInnerHTML={{ __html: account.get('display_name_html') }} /></bdi>;
suffix = <span className='display-name__account'>@{acct}</span>;
}
return (
<span className={computedClass}> <span className={computedClass}>
<bdi><strong className='display-name__html' dangerouslySetInnerHTML={{ __html: account.get('display_name_html') }} /></bdi> {displayName}
{inline ? ' ' : null} {inline ? ' ' : null}
<span className='display-name__account'>@{acct}</span> {suffix}
</span> </span>
) : null; );
} }
// Props. // Props.
@ -36,4 +68,6 @@ DisplayName.propTypes = {
className: PropTypes.string, className: PropTypes.string,
inline: PropTypes.bool, inline: PropTypes.bool,
localDomain: PropTypes.string, localDomain: PropTypes.string,
others: ImmutablePropTypes.list,
handleClick: PropTypes.func,
}; };

View File

@ -66,6 +66,7 @@ export default class Status extends ImmutablePureComponent {
containerId: PropTypes.string, containerId: PropTypes.string,
id: PropTypes.string, id: PropTypes.string,
status: ImmutablePropTypes.map, status: ImmutablePropTypes.map,
otherAccounts: ImmutablePropTypes.list,
account: ImmutablePropTypes.map, account: ImmutablePropTypes.map,
onReply: PropTypes.func, onReply: PropTypes.func,
onFavourite: PropTypes.func, onFavourite: PropTypes.func,
@ -83,6 +84,7 @@ export default class Status extends ImmutablePureComponent {
muted: PropTypes.bool, muted: PropTypes.bool,
collapse: PropTypes.bool, collapse: PropTypes.bool,
hidden: PropTypes.bool, hidden: PropTypes.bool,
unread: PropTypes.bool,
prepend: PropTypes.string, prepend: PropTypes.string,
withDismiss: PropTypes.bool, withDismiss: PropTypes.bool,
onMoveUp: PropTypes.func, onMoveUp: PropTypes.func,
@ -93,6 +95,7 @@ export default class Status extends ImmutablePureComponent {
intl: PropTypes.object.isRequired, intl: PropTypes.object.isRequired,
cacheMediaWidth: PropTypes.func, cacheMediaWidth: PropTypes.func,
cachedMediaWidth: PropTypes.number, cachedMediaWidth: PropTypes.number,
onClick: PropTypes.func,
}; };
state = { state = {
@ -321,17 +324,21 @@ export default class Status extends ImmutablePureComponent {
const { status } = this.props; const { status } = this.props;
const { isCollapsed } = this.state; const { isCollapsed } = this.state;
if (!router) return; if (!router) return;
if (destination === undefined) {
destination = `/statuses/${
status.getIn(['reblog', 'id'], status.get('id'))
}`;
}
if (e.button === 0 && !(e.ctrlKey || e.altKey || e.metaKey)) { if (e.button === 0 && !(e.ctrlKey || e.altKey || e.metaKey)) {
if (isCollapsed) this.setCollapsed(false); if (isCollapsed) this.setCollapsed(false);
else if (e.shiftKey) { else if (e.shiftKey) {
this.setCollapsed(true); this.setCollapsed(true);
document.getSelection().removeAllRanges(); document.getSelection().removeAllRanges();
} else if (this.props.onClick) {
this.props.onClick();
return;
} else { } else {
if (destination === undefined) {
destination = `/statuses/${
status.getIn(['reblog', 'id'], status.get('id'))
}`;
}
let state = {...router.history.location.state}; let state = {...router.history.location.state};
state.mastodonBackSteps = (state.mastodonBackSteps || 0) + 1; state.mastodonBackSteps = (state.mastodonBackSteps || 0) + 1;
router.history.push(destination, state); router.history.push(destination, state);
@ -441,6 +448,7 @@ export default class Status extends ImmutablePureComponent {
intl, intl,
status, status,
account, account,
otherAccounts,
settings, settings,
collapsed, collapsed,
muted, muted,
@ -450,6 +458,7 @@ export default class Status extends ImmutablePureComponent {
onOpenMedia, onOpenMedia,
notification, notification,
hidden, hidden,
unread,
featured, featured,
...other ...other
} = this.props; } = this.props;
@ -617,6 +626,7 @@ export default class Status extends ImmutablePureComponent {
collapsed: isCollapsed, collapsed: isCollapsed,
'has-background': isCollapsed && background, 'has-background': isCollapsed && background,
'status__wrapper-reply': !!status.get('in_reply_to_id'), 'status__wrapper-reply': !!status.get('in_reply_to_id'),
read: unread === false,
muted, muted,
}, 'focusable'); }, 'focusable');
@ -647,6 +657,7 @@ export default class Status extends ImmutablePureComponent {
friend={account} friend={account}
collapsed={isCollapsed} collapsed={isCollapsed}
parseClick={parseClick} parseClick={parseClick}
otherAccounts={otherAccounts}
/> />
) : null} ) : null}
</span> </span>
@ -656,6 +667,7 @@ export default class Status extends ImmutablePureComponent {
collapsible={settings.getIn(['collapsed', 'enabled'])} collapsible={settings.getIn(['collapsed', 'enabled'])}
collapsed={isCollapsed} collapsed={isCollapsed}
setCollapsed={setCollapsed} setCollapsed={setCollapsed}
directMessage={!!otherAccounts}
/> />
</header> </header>
<StatusContent <StatusContent
@ -673,6 +685,7 @@ export default class Status extends ImmutablePureComponent {
status={status} status={status}
account={status.get('account')} account={status.get('account')}
showReplyCount={settings.get('show_reply_count')} showReplyCount={settings.get('show_reply_count')}
directMessage={!!otherAccounts}
/> />
) : null} ) : null}
{notification ? ( {notification ? (

View File

@ -71,6 +71,7 @@ export default class StatusActionBar extends ImmutablePureComponent {
onBookmark: PropTypes.func, onBookmark: PropTypes.func,
withDismiss: PropTypes.bool, withDismiss: PropTypes.bool,
showReplyCount: PropTypes.bool, showReplyCount: PropTypes.bool,
directMessage: PropTypes.bool,
intl: PropTypes.object.isRequired, intl: PropTypes.object.isRequired,
}; };
@ -191,7 +192,7 @@ export default class StatusActionBar extends ImmutablePureComponent {
} }
render () { render () {
const { status, intl, withDismiss, showReplyCount } = this.props; const { status, intl, withDismiss, showReplyCount, directMessage } = this.props;
const mutingConversation = status.get('muted'); const mutingConversation = status.get('muted');
const anonymousAccess = !me; const anonymousAccess = !me;
@ -282,14 +283,15 @@ export default class StatusActionBar extends ImmutablePureComponent {
return ( return (
<div className='status__action-bar'> <div className='status__action-bar'>
{replyButton} {replyButton}
<IconButton className='status__action-bar-button' disabled={reblogDisabled} active={status.get('reblogged')} pressed={status.get('reblogged')} title={reblogDisabled ? intl.formatMessage(messages.cannot_reblog) : intl.formatMessage(reblogMessage)} icon={reblogIcon} onClick={this.handleReblogClick} /> {!directMessage && [
<IconButton className='status__action-bar-button star-icon' animate active={status.get('favourited')} pressed={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} /> <IconButton className='status__action-bar-button' disabled={reblogDisabled} active={status.get('reblogged')} pressed={status.get('reblogged')} title={reblogDisabled ? intl.formatMessage(messages.cannot_reblog) : intl.formatMessage(reblogMessage)} icon={reblogIcon} onClick={this.handleReblogClick} />,
{shareButton} <IconButton className='status__action-bar-button star-icon' animate active={status.get('favourited')} pressed={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} />,
<IconButton className='status__action-bar-button bookmark-icon' disabled={anonymousAccess} active={status.get('bookmarked')} pressed={status.get('bookmarked')} title={intl.formatMessage(messages.bookmark)} icon='bookmark' onClick={this.handleBookmarkClick} /> shareButton,
<IconButton className='status__action-bar-button bookmark-icon' disabled={anonymousAccess} active={status.get('bookmarked')} pressed={status.get('bookmarked')} title={intl.formatMessage(messages.bookmark)} icon='bookmark' onClick={this.handleBookmarkClick} />,
<div className='status__action-bar-dropdown'> <div className='status__action-bar-dropdown'>
<DropdownMenuContainer disabled={anonymousAccess} status={status} items={menu} icon='ellipsis-h' size={18} direction='right' ariaLabel={intl.formatMessage(messages.more)} /> <DropdownMenuContainer disabled={anonymousAccess} status={status} items={menu} icon='ellipsis-h' size={18} direction='right' ariaLabel={intl.formatMessage(messages.more)} />
</div> </div>,
]}
<a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener'><RelativeTimestamp timestamp={status.get('created_at')} /></a> <a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener'><RelativeTimestamp timestamp={status.get('created_at')} /></a>
</div> </div>

View File

@ -6,6 +6,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
// Mastodon imports. // Mastodon imports.
import Avatar from './avatar'; import Avatar from './avatar';
import AvatarOverlay from './avatar_overlay'; import AvatarOverlay from './avatar_overlay';
import AvatarComposite from './avatar_composite';
import DisplayName from './display_name'; import DisplayName from './display_name';
export default class StatusHeader extends React.PureComponent { export default class StatusHeader extends React.PureComponent {
@ -14,12 +15,18 @@ export default class StatusHeader extends React.PureComponent {
status: ImmutablePropTypes.map.isRequired, status: ImmutablePropTypes.map.isRequired,
friend: ImmutablePropTypes.map, friend: ImmutablePropTypes.map,
parseClick: PropTypes.func.isRequired, parseClick: PropTypes.func.isRequired,
otherAccounts: ImmutablePropTypes.list,
}; };
// Handles clicks on account name/image // Handles clicks on account name/image
handleClick = (id, e) => {
const { parseClick } = this.props;
parseClick(e, `/accounts/${id}`);
}
handleAccountClick = (e) => { handleAccountClick = (e) => {
const { status, parseClick } = this.props; const { status } = this.props;
parseClick(e, `/accounts/${status.getIn(['account', 'id'])}`); this.handleClick(status.getIn(['account', 'id']), e);
} }
// Rendering. // Rendering.
@ -27,36 +34,55 @@ export default class StatusHeader extends React.PureComponent {
const { const {
status, status,
friend, friend,
otherAccounts,
} = this.props; } = this.props;
const account = status.get('account'); const account = status.get('account');
return ( let statusAvatar;
<div className='status__info__account' > if (otherAccounts && otherAccounts.size > 0) {
<a statusAvatar = <AvatarComposite accounts={otherAccounts} size={48} onAccountClick={this.handleClick} />;
href={account.get('url')} } else if (friend === undefined || friend === null) {
target='_blank' statusAvatar = <Avatar account={account} size={48} />;
className='status__avatar' } else {
onClick={this.handleAccountClick} statusAvatar = <AvatarOverlay account={account} friend={friend} />;
> }
{
friend ? ( if (!otherAccounts) {
<AvatarOverlay account={account} friend={friend} /> return (
) : ( <div className='status__info__account'>
<Avatar account={account} size={48} /> <a
) href={account.get('url')}
} target='_blank'
</a> className='status__avatar'
<a onClick={this.handleAccountClick}
href={account.get('url')} >
target='_blank' {statusAvatar}
className='status__display-name' </a>
onClick={this.handleAccountClick} <a
> href={account.get('url')}
<DisplayName account={account} /> target='_blank'
</a> className='status__display-name'
</div> onClick={this.handleAccountClick}
); >
<DisplayName account={account} others={otherAccounts} />
</a>
</div>
);
} else {
// This is a DM conversation
return (
<div className='status__info__account'>
<span className='status__avatar'>
{statusAvatar}
</span>
<span className='status__display-name'>
<DisplayName account={account} others={otherAccounts} onAccountClick={this.handleClick} />
</span>
</div>
);
}
} }
} }

View File

@ -22,6 +22,7 @@ export default class StatusIcons extends React.PureComponent {
mediaIcon: PropTypes.string, mediaIcon: PropTypes.string,
collapsible: PropTypes.bool, collapsible: PropTypes.bool,
collapsed: PropTypes.bool, collapsed: PropTypes.bool,
directMessage: PropTypes.bool,
setCollapsed: PropTypes.func.isRequired, setCollapsed: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired, intl: PropTypes.object.isRequired,
}; };
@ -42,6 +43,7 @@ export default class StatusIcons extends React.PureComponent {
mediaIcon, mediaIcon,
collapsible, collapsible,
collapsed, collapsed,
directMessage,
intl, intl,
} = this.props; } = this.props;
@ -59,9 +61,7 @@ export default class StatusIcons extends React.PureComponent {
aria-hidden='true' aria-hidden='true'
/> />
) : null} ) : null}
{( {!directMessage && <VisibilityIcon visibility={status.get('visibility')} />}
<VisibilityIcon visibility={status.get('visibility')} />
)}
{collapsible ? ( {collapsible ? (
<IconButton <IconButton
className='status__collapse-button' className='status__collapse-button'

View File

@ -0,0 +1,64 @@
import React from 'react';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import StatusContainer from 'flavours/glitch/containers/status_container';
export default class Conversation extends ImmutablePureComponent {
static contextTypes = {
router: PropTypes.object,
};
static propTypes = {
conversationId: PropTypes.string.isRequired,
accounts: ImmutablePropTypes.list.isRequired,
lastStatusId: PropTypes.string,
unread:PropTypes.bool.isRequired,
onMoveUp: PropTypes.func,
onMoveDown: PropTypes.func,
markRead: PropTypes.func.isRequired,
};
handleClick = () => {
if (!this.context.router) {
return;
}
const { lastStatusId, unread, markRead } = this.props;
if (unread) {
markRead();
}
this.context.router.history.push(`/statuses/${lastStatusId}`);
}
handleHotkeyMoveUp = () => {
this.props.onMoveUp(this.props.conversationId);
}
handleHotkeyMoveDown = () => {
this.props.onMoveDown(this.props.conversationId);
}
render () {
const { accounts, lastStatusId, unread } = this.props;
if (lastStatusId === null) {
return null;
}
return (
<StatusContainer
id={lastStatusId}
unread={unread}
otherAccounts={accounts}
onMoveUp={this.handleHotkeyMoveUp}
onMoveDown={this.handleHotkeyMoveDown}
onClick={this.handleClick}
/>
);
}
}

View File

@ -0,0 +1,73 @@
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 'flavours/glitch/components/scrollable_list';
import { debounce } from 'lodash';
export default class ConversationsList extends ImmutablePureComponent {
static propTypes = {
conversations: ImmutablePropTypes.list.isRequired,
hasMore: PropTypes.bool,
isLoading: PropTypes.bool,
onLoadMore: PropTypes.func,
};
getCurrentIndex = id => this.props.conversations.findIndex(x => x.get('id') === id)
handleMoveUp = id => {
const elementIndex = this.getCurrentIndex(id) - 1;
this._selectChild(elementIndex, true);
}
handleMoveDown = id => {
const elementIndex = this.getCurrentIndex(id) + 1;
this._selectChild(elementIndex, false);
}
_selectChild (index, align_top) {
const container = this.node.node;
const element = container.querySelector(`article:nth-of-type(${index + 1}) .focusable`);
if (element) {
if (align_top && container.scrollTop > element.offsetTop) {
element.scrollIntoView(true);
} else if (!align_top && container.scrollTop + container.clientHeight < element.offsetTop + element.offsetHeight) {
element.scrollIntoView(false);
}
element.focus();
}
}
setRef = c => {
this.node = c;
}
handleLoadOlder = debounce(() => {
const last = this.props.conversations.last();
if (last && last.get('last_status')) {
this.props.onLoadMore(last.get('last_status'));
}
}, 300, { leading: true })
render () {
const { conversations, onLoadMore, ...other } = this.props;
return (
<ScrollableList {...other} onLoadMore={onLoadMore && this.handleLoadOlder} scrollKey='direct' ref={this.setRef}>
{conversations.map(item => (
<ConversationContainer
key={item.get('id')}
conversationId={item.get('id')}
onMoveUp={this.handleMoveUp}
onMoveDown={this.handleMoveDown}
/>
))}
</ScrollableList>
);
}
}

View File

@ -0,0 +1,19 @@
import { connect } from 'react-redux';
import Conversation from '../components/conversation';
import { markConversationRead } from '../../../actions/conversations';
const mapStateToProps = (state, { conversationId }) => {
const conversation = state.getIn(['conversations', 'items']).find(x => x.get('id') === conversationId);
return {
accounts: conversation.get('accounts').map(accountId => state.getIn(['accounts', accountId], null)),
unread: conversation.get('unread'),
lastStatusId: conversation.get('last_status', null),
};
};
const mapDispatchToProps = (dispatch, { conversationId }) => ({
markRead: () => dispatch(markConversationRead(conversationId)),
});
export default connect(mapStateToProps, mapDispatchToProps)(Conversation);

View File

@ -0,0 +1,15 @@
import { connect } from 'react-redux';
import ConversationsList from '../components/conversations_list';
import { expandConversations } from 'flavours/glitch/actions/conversations';
const mapStateToProps = state => ({
conversations: state.getIn(['conversations', 'items']),
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

@ -5,10 +5,13 @@ import StatusListContainer from 'flavours/glitch/features/ui/containers/status_l
import Column from 'flavours/glitch/components/column'; import Column from 'flavours/glitch/components/column';
import ColumnHeader from 'flavours/glitch/components/column_header'; import ColumnHeader from 'flavours/glitch/components/column_header';
import { expandDirectTimeline } from 'flavours/glitch/actions/timelines'; import { expandDirectTimeline } from 'flavours/glitch/actions/timelines';
import { mountConversations, unmountConversations, expandConversations } from 'flavours/glitch/actions/conversations';
import { addColumn, removeColumn, moveColumn } from 'flavours/glitch/actions/columns'; import { addColumn, removeColumn, moveColumn } from 'flavours/glitch/actions/columns';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import ColumnSettingsContainer from './containers/column_settings_container'; import ColumnSettingsContainer from './containers/column_settings_container';
import { connectDirectStream } from 'flavours/glitch/actions/streaming'; import { connectDirectStream } from 'flavours/glitch/actions/streaming';
import { changeSetting } from 'flavours/glitch/actions/settings';
import ConversationsListContainer from './containers/conversations_list_container';
const messages = defineMessages({ const messages = defineMessages({
title: { id: 'column.direct', defaultMessage: 'Direct messages' }, title: { id: 'column.direct', defaultMessage: 'Direct messages' },
@ -16,6 +19,7 @@ const messages = defineMessages({
const mapStateToProps = state => ({ const mapStateToProps = state => ({
hasUnread: state.getIn(['timelines', 'direct', 'unread']) > 0, hasUnread: state.getIn(['timelines', 'direct', 'unread']) > 0,
conversationsMode: state.getIn(['settings', 'direct', 'conversations']),
}); });
@connect(mapStateToProps) @connect(mapStateToProps)
@ -28,6 +32,7 @@ export default class DirectTimeline extends React.PureComponent {
intl: PropTypes.object.isRequired, intl: PropTypes.object.isRequired,
hasUnread: PropTypes.bool, hasUnread: PropTypes.bool,
multiColumn: PropTypes.bool, multiColumn: PropTypes.bool,
conversationsMode: PropTypes.bool,
}; };
handlePin = () => { handlePin = () => {
@ -50,13 +55,32 @@ export default class DirectTimeline extends React.PureComponent {
} }
componentDidMount () { componentDidMount () {
const { dispatch } = this.props; const { dispatch, conversationsMode } = this.props;
dispatch(mountConversations());
if (conversationsMode) {
dispatch(expandConversations());
} else {
dispatch(expandDirectTimeline());
}
dispatch(expandDirectTimeline());
this.disconnect = dispatch(connectDirectStream()); this.disconnect = dispatch(connectDirectStream());
} }
componentDidUpdate(prevProps) {
const { dispatch, conversationsMode } = this.props;
if (prevProps.conversationsMode && !conversationsMode) {
dispatch(expandDirectTimeline());
} else if (!prevProps.conversationsMode && conversationsMode) {
dispatch(expandConversations());
}
}
componentWillUnmount () { componentWillUnmount () {
this.props.dispatch(unmountConversations());
if (this.disconnect) { if (this.disconnect) {
this.disconnect(); this.disconnect();
this.disconnect = null; this.disconnect = null;
@ -67,14 +91,49 @@ export default class DirectTimeline extends React.PureComponent {
this.column = c; this.column = c;
} }
handleLoadMore = maxId => { handleLoadMoreTimeline = maxId => {
this.props.dispatch(expandDirectTimeline({ maxId })); this.props.dispatch(expandDirectTimeline({ maxId }));
} }
handleLoadMoreConversations = maxId => {
this.props.dispatch(expandConversations({ maxId }));
}
handleTimelineClick = () => {
this.props.dispatch(changeSetting(['direct', 'conversations'], false));
}
handleConversationsClick = () => {
this.props.dispatch(changeSetting(['direct', 'conversations'], true));
}
render () { render () {
const { intl, hasUnread, columnId, multiColumn } = this.props; const { intl, hasUnread, columnId, multiColumn, conversationsMode } = this.props;
const pinned = !!columnId; const pinned = !!columnId;
let contents;
if (conversationsMode) {
contents = (
<ConversationsListContainer
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." />}
/>
);
} else {
contents = (
<StatusListContainer
trackScroll={!pinned}
scrollKey={`direct_timeline-${columnId}`}
timelineId='direct'
onLoadMore={this.handleLoadMoreTimeline}
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." />}
/>
);
}
return ( return (
<Column ref={this.setRef} label={intl.formatMessage(messages.title)}> <Column ref={this.setRef} label={intl.formatMessage(messages.title)}>
<ColumnHeader <ColumnHeader
@ -90,13 +149,28 @@ export default class DirectTimeline extends React.PureComponent {
<ColumnSettingsContainer /> <ColumnSettingsContainer />
</ColumnHeader> </ColumnHeader>
<StatusListContainer <div className='notification__filter-bar'>
trackScroll={!pinned} <button
scrollKey={`direct_timeline-${columnId}`} className={conversationsMode ? 'active' : ''}
timelineId='direct' onClick={this.handleConversationsClick}
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." />} <FormattedMessage
/> id='direct.conversations_mode'
defaultMessage='Conversations'
/>
</button>
<button
className={conversationsMode ? '' : 'active'}
onClick={this.handleTimelineClick}
>
<FormattedMessage
id='direct.timeline_mode'
defaultMessage='Timeline'
/>
</button>
</div>
{contents}
</Column> </Column>
); );
} }

View File

@ -0,0 +1,102 @@
import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
import {
CONVERSATIONS_MOUNT,
CONVERSATIONS_UNMOUNT,
CONVERSATIONS_FETCH_REQUEST,
CONVERSATIONS_FETCH_SUCCESS,
CONVERSATIONS_FETCH_FAIL,
CONVERSATIONS_UPDATE,
CONVERSATIONS_READ,
} from '../actions/conversations';
import compareId from 'flavours/glitch/util/compare_id';
const initialState = ImmutableMap({
items: ImmutableList(),
isLoading: false,
hasMore: true,
mounted: 0,
});
const conversationToMap = item => ImmutableMap({
id: item.id,
unread: item.unread,
accounts: ImmutableList(item.accounts.map(a => a.id)),
last_status: item.last_status ? item.last_status.id : null,
});
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, isLoadingRecent) => {
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) => {
if(a === null || b === null) {
return -1;
}
return compareId(a, b) * -1;
});
});
}
if (!next && !isLoadingRecent) {
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, action.isLoadingRecent);
case CONVERSATIONS_UPDATE:
return updateConversation(state, action.conversation);
case CONVERSATIONS_MOUNT:
return state.update('mounted', count => count + 1);
case CONVERSATIONS_UNMOUNT:
return state.update('mounted', count => count - 1);
case CONVERSATIONS_READ:
return state.update('items', list => list.map(item => {
if (item.get('id') === action.id) {
return item.set('unread', false);
}
return item;
}));
default:
return state;
}
};

View File

@ -28,6 +28,7 @@ import lists from './lists';
import listEditor from './list_editor'; import listEditor from './list_editor';
import listAdder from './list_adder'; import listAdder from './list_adder';
import filters from './filters'; import filters from './filters';
import conversations from './conversations';
import suggestions from './suggestions'; import suggestions from './suggestions';
import pinnedAccountsEditor from './pinned_accounts_editor'; import pinnedAccountsEditor from './pinned_accounts_editor';
import polls from './polls'; import polls from './polls';
@ -64,6 +65,7 @@ const reducers = {
listEditor, listEditor,
listAdder, listAdder,
filters, filters,
conversations,
suggestions, suggestions,
pinnedAccountsEditor, pinnedAccountsEditor,
polls, polls,

View File

@ -72,6 +72,7 @@ const initialState = ImmutableMap({
}), }),
direct: ImmutableMap({ direct: ImmutableMap({
conversations: true,
regex: ImmutableMap({ regex: ImmutableMap({
body: '', body: '',
}), }),

View File

@ -46,6 +46,18 @@
vertical-align: middle; vertical-align: middle;
margin-right: 5px; margin-right: 5px;
} }
&-composite {
@include avatar-radius;
overflow: hidden;
& div {
@include avatar-radius;
float: left;
position: relative;
box-sizing: border-box;
}
}
} }
.account__avatar-overlay { .account__avatar-overlay {

View File

@ -287,8 +287,12 @@
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
a {
color: inherit;
text-decoration: inherit;
}
strong { strong {
display: block;
height: 18px; height: 18px;
font-size: 16px; font-size: 16px;
font-weight: 500; font-weight: 500;
@ -308,7 +312,7 @@
white-space: nowrap; white-space: nowrap;
} }
&:hover { > a:hover {
strong { strong {
text-decoration: underline; text-decoration: underline;
} }

View File

@ -209,7 +209,7 @@
outline: 0; outline: 0;
background: lighten($ui-base-color, 4%); background: lighten($ui-base-color, 4%);
.status.status-direct { &.status.status-direct:not(.read) {
background: lighten($ui-base-color, 12%); background: lighten($ui-base-color, 12%);
&.muted { &.muted {
@ -249,8 +249,9 @@
margin-top: 8px; margin-top: 8px;
} }
&.status-direct { &.status-direct:not(.read) {
background: lighten($ui-base-color, 8%); background: lighten($ui-base-color, 8%);
border-bottom-color: lighten($ui-base-color, 12%);
} }
&.light { &.light {
@ -333,7 +334,7 @@
&:focus > .status__content:after { &:focus > .status__content:after {
background: linear-gradient(rgba(lighten($ui-base-color, 4%), 0), rgba(lighten($ui-base-color, 4%), 1)); background: linear-gradient(rgba(lighten($ui-base-color, 4%), 0), rgba(lighten($ui-base-color, 4%), 1));
} }
&.status-direct> .status__content:after { &.status-direct:not(.read)> .status__content:after {
background: linear-gradient(rgba(lighten($ui-base-color, 8%), 0), rgba(lighten($ui-base-color, 8%), 1)); background: linear-gradient(rgba(lighten($ui-base-color, 8%), 0), rgba(lighten($ui-base-color, 8%), 1));
} }
@ -599,7 +600,7 @@
} }
} }
.status__display-name, a.status__display-name,
.reply-indicator__display-name, .reply-indicator__display-name,
.detailed-status__display-name, .detailed-status__display-name,
.account__display-name { .account__display-name {

View File

@ -27,15 +27,16 @@
} }
} }
.status.status-direct { .status.status-direct:not(.read) {
background: darken($ui-base-color, 8%); background: darken($ui-base-color, 8%);
border-bottom-color: darken($ui-base-color, 12%);
&.collapsed> .status__content:after { &.collapsed> .status__content:after {
background: linear-gradient(rgba(darken($ui-base-color, 8%), 0), rgba(darken($ui-base-color, 8%), 1)); background: linear-gradient(rgba(darken($ui-base-color, 8%), 0), rgba(darken($ui-base-color, 8%), 1));
} }
} }
.focusable:focus.status.status-direct { .focusable:focus.status.status-direct:not(.read) {
background: darken($ui-base-color, 4%); background: darken($ui-base-color, 4%);
&.collapsed> .status__content:after { &.collapsed> .status__content:after {