Refactor conversations components in web UI (#28833)

Co-authored-by: Claire <claire.github-309c@sitedethib.com>
This commit is contained in:
Eugen Rochko 2024-01-25 15:34:26 +01:00 committed by GitHub
parent 274a48a9f4
commit 3205a654ca
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 274 additions and 361 deletions

View File

@ -1,17 +1,24 @@
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { useCallback } from 'react';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
import classNames from 'classnames'; import classNames from 'classnames';
import { Link, withRouter } from 'react-router-dom'; import { Link, useHistory } from 'react-router-dom';
import { createSelector } from '@reduxjs/toolkit';
import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component'; import { useDispatch, useSelector } from 'react-redux';
import { HotKeys } from 'react-hotkeys'; import { HotKeys } from 'react-hotkeys';
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react'; import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
import ReplyIcon from '@/material-icons/400-24px/reply.svg?react'; import ReplyIcon from '@/material-icons/400-24px/reply.svg?react';
import { replyCompose } from 'mastodon/actions/compose';
import { markConversationRead, deleteConversation } from 'mastodon/actions/conversations';
import { openModal } from 'mastodon/actions/modal';
import { muteStatus, unmuteStatus, revealStatus, hideStatus } from 'mastodon/actions/statuses';
import AttachmentList from 'mastodon/components/attachment_list'; import AttachmentList from 'mastodon/components/attachment_list';
import AvatarComposite from 'mastodon/components/avatar_composite'; import AvatarComposite from 'mastodon/components/avatar_composite';
import { IconButton } from 'mastodon/components/icon_button'; import { IconButton } from 'mastodon/components/icon_button';
@ -19,7 +26,7 @@ import { RelativeTimestamp } from 'mastodon/components/relative_timestamp';
import StatusContent from 'mastodon/components/status_content'; import StatusContent from 'mastodon/components/status_content';
import DropdownMenuContainer from 'mastodon/containers/dropdown_menu_container'; import DropdownMenuContainer from 'mastodon/containers/dropdown_menu_container';
import { autoPlayGif } from 'mastodon/initial_state'; import { autoPlayGif } from 'mastodon/initial_state';
import { WithRouterPropTypes } from 'mastodon/utils/react_router'; import { makeGetStatus } from 'mastodon/selectors';
const messages = defineMessages({ const messages = defineMessages({
more: { id: 'status.more', defaultMessage: 'More' }, more: { id: 'status.more', defaultMessage: 'More' },
@ -29,25 +36,31 @@ const messages = defineMessages({
delete: { id: 'conversation.delete', defaultMessage: 'Delete conversation' }, delete: { id: 'conversation.delete', defaultMessage: 'Delete conversation' },
muteConversation: { id: 'status.mute_conversation', defaultMessage: 'Mute conversation' }, muteConversation: { id: 'status.mute_conversation', defaultMessage: 'Mute conversation' },
unmuteConversation: { id: 'status.unmute_conversation', defaultMessage: 'Unmute conversation' }, unmuteConversation: { id: 'status.unmute_conversation', defaultMessage: 'Unmute conversation' },
replyConfirm: { id: 'confirmations.reply.confirm', defaultMessage: 'Reply' },
replyMessage: { id: 'confirmations.reply.message', defaultMessage: 'Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?' },
}); });
class Conversation extends ImmutablePureComponent { const getAccounts = createSelector(
(state) => state.get('accounts'),
(_, accountIds) => accountIds,
(accounts, accountIds) =>
accountIds.map(id => accounts.get(id))
);
static propTypes = { const getStatus = makeGetStatus();
conversationId: PropTypes.string.isRequired,
accounts: ImmutablePropTypes.list.isRequired,
lastStatus: ImmutablePropTypes.map,
unread:PropTypes.bool.isRequired,
scrollKey: PropTypes.string,
onMoveUp: PropTypes.func,
onMoveDown: PropTypes.func,
markRead: PropTypes.func.isRequired,
delete: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
...WithRouterPropTypes,
};
handleMouseEnter = ({ currentTarget }) => { export const Conversation = ({ conversation, scrollKey, onMoveUp, onMoveDown }) => {
const id = conversation.get('id');
const unread = conversation.get('unread');
const lastStatusId = conversation.get('last_status');
const accountIds = conversation.get('accounts');
const intl = useIntl();
const dispatch = useDispatch();
const history = useHistory();
const lastStatus = useSelector(state => getStatus(state, { id: lastStatusId }));
const accounts = useSelector(state => getAccounts(state, accountIds));
const handleMouseEnter = useCallback(({ currentTarget }) => {
if (autoPlayGif) { if (autoPlayGif) {
return; return;
} }
@ -58,9 +71,9 @@ class Conversation extends ImmutablePureComponent {
let emoji = emojis[i]; let emoji = emojis[i];
emoji.src = emoji.getAttribute('data-original'); emoji.src = emoji.getAttribute('data-original');
} }
}; }, []);
handleMouseLeave = ({ currentTarget }) => { const handleMouseLeave = useCallback(({ currentTarget }) => {
if (autoPlayGif) { if (autoPlayGif) {
return; return;
} }
@ -71,85 +84,107 @@ class Conversation extends ImmutablePureComponent {
let emoji = emojis[i]; let emoji = emojis[i];
emoji.src = emoji.getAttribute('data-static'); emoji.src = emoji.getAttribute('data-static');
} }
}; }, []);
handleClick = () => {
if (!this.props.history) {
return;
}
const { lastStatus, unread, markRead } = this.props;
const handleClick = useCallback(() => {
if (unread) { if (unread) {
markRead(); dispatch(markConversationRead(id));
} }
this.props.history.push(`/@${lastStatus.getIn(['account', 'acct'])}/${lastStatus.get('id')}`); history.push(`/@${lastStatus.getIn(['account', 'acct'])}/${lastStatus.get('id')}`);
}; }, [dispatch, history, unread, id, lastStatus]);
handleMarkAsRead = () => { const handleMarkAsRead = useCallback(() => {
this.props.markRead(); dispatch(markConversationRead(id));
}; }, [dispatch, id]);
handleReply = () => { const handleReply = useCallback(() => {
this.props.reply(this.props.lastStatus, this.props.history); dispatch((_, getState) => {
}; let state = getState();
handleDelete = () => { if (state.getIn(['compose', 'text']).trim().length !== 0) {
this.props.delete(); dispatch(openModal({
}; modalType: 'CONFIRM',
modalProps: {
message: intl.formatMessage(messages.replyMessage),
confirm: intl.formatMessage(messages.replyConfirm),
onConfirm: () => dispatch(replyCompose(lastStatus, history)),
},
}));
} else {
dispatch(replyCompose(lastStatus, history));
}
});
}, [dispatch, lastStatus, history, intl]);
handleHotkeyMoveUp = () => { const handleDelete = useCallback(() => {
this.props.onMoveUp(this.props.conversationId); dispatch(deleteConversation(id));
}; }, [dispatch, id]);
handleHotkeyMoveDown = () => { const handleHotkeyMoveUp = useCallback(() => {
this.props.onMoveDown(this.props.conversationId); onMoveUp(id);
}; }, [id, onMoveUp]);
handleConversationMute = () => { const handleHotkeyMoveDown = useCallback(() => {
this.props.onMute(this.props.lastStatus); onMoveDown(id);
}; }, [id, onMoveDown]);
handleShowMore = () => { const handleConversationMute = useCallback(() => {
this.props.onToggleHidden(this.props.lastStatus); if (lastStatus.get('muted')) {
}; dispatch(unmuteStatus(lastStatus.get('id')));
} else {
dispatch(muteStatus(lastStatus.get('id')));
}
}, [dispatch, lastStatus]);
render () { const handleShowMore = useCallback(() => {
const { accounts, lastStatus, unread, scrollKey, intl } = this.props; if (lastStatus.get('hidden')) {
dispatch(revealStatus(lastStatus.get('id')));
} else {
dispatch(hideStatus(lastStatus.get('id')));
}
}, [dispatch, lastStatus]);
if (lastStatus === null) { if (!lastStatus) {
return null; return null;
} }
const menu = [ const menu = [
{ text: intl.formatMessage(messages.open), action: this.handleClick }, { text: intl.formatMessage(messages.open), action: handleClick },
null, null,
{ text: intl.formatMessage(lastStatus.get('muted') ? messages.unmuteConversation : messages.muteConversation), action: handleConversationMute },
]; ];
menu.push({ text: intl.formatMessage(lastStatus.get('muted') ? messages.unmuteConversation : messages.muteConversation), action: this.handleConversationMute });
if (unread) { if (unread) {
menu.push({ text: intl.formatMessage(messages.markAsRead), action: this.handleMarkAsRead }); menu.push({ text: intl.formatMessage(messages.markAsRead), action: handleMarkAsRead });
menu.push(null); menu.push(null);
} }
menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDelete }); menu.push({ text: intl.formatMessage(messages.delete), action: handleDelete });
const names = accounts.map(a => <Link to={`/@${a.get('acct')}`} key={a.get('id')} title={a.get('acct')}><bdi><strong className='display-name__html' dangerouslySetInnerHTML={{ __html: a.get('display_name_html') }} /></bdi></Link>).reduce((prev, cur) => [prev, ', ', cur]); const names = accounts.map(a => (
<Link to={`/@${a.get('acct')}`} key={a.get('id')} title={a.get('acct')}>
<bdi>
<strong
className='display-name__html'
dangerouslySetInnerHTML={{ __html: a.get('display_name_html') }}
/>
</bdi>
</Link>
)).reduce((prev, cur) => [prev, ', ', cur]);
const handlers = { const handlers = {
reply: this.handleReply, reply: handleReply,
open: this.handleClick, open: handleClick,
moveUp: this.handleHotkeyMoveUp, moveUp: handleHotkeyMoveUp,
moveDown: this.handleHotkeyMoveDown, moveDown: handleHotkeyMoveDown,
toggleHidden: this.handleShowMore, toggleHidden: handleShowMore,
}; };
return ( return (
<HotKeys handlers={handlers}> <HotKeys handlers={handlers}>
<div className={classNames('conversation focusable muted', { 'conversation--unread': unread })} tabIndex={0}> <div className={classNames('conversation focusable muted', { 'conversation--unread': unread })} tabIndex={0}>
<div className='conversation__avatar' onClick={this.handleClick} role='presentation'> <div className='conversation__avatar' onClick={handleClick} role='presentation'>
<AvatarComposite accounts={accounts} size={48} /> <AvatarComposite accounts={accounts} size={48} />
</div> </div>
@ -159,16 +194,16 @@ class Conversation extends ImmutablePureComponent {
{unread && <span className='conversation__unread' />} <RelativeTimestamp timestamp={lastStatus.get('created_at')} /> {unread && <span className='conversation__unread' />} <RelativeTimestamp timestamp={lastStatus.get('created_at')} />
</div> </div>
<div className='conversation__content__names' onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}> <div className='conversation__content__names' onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave}>
<FormattedMessage id='conversation.with' defaultMessage='With {names}' values={{ names: <span>{names}</span> }} /> <FormattedMessage id='conversation.with' defaultMessage='With {names}' values={{ names: <span>{names}</span> }} />
</div> </div>
</div> </div>
<StatusContent <StatusContent
status={lastStatus} status={lastStatus}
onClick={this.handleClick} onClick={handleClick}
expanded={!lastStatus.get('hidden')} expanded={!lastStatus.get('hidden')}
onExpandedToggle={this.handleShowMore} onExpandedToggle={handleShowMore}
collapsible collapsible
/> />
@ -180,7 +215,7 @@ class Conversation extends ImmutablePureComponent {
)} )}
<div className='status__action-bar'> <div className='status__action-bar'>
<IconButton className='status__action-bar-button' title={intl.formatMessage(messages.reply)} icon='reply' iconComponent={ReplyIcon} onClick={this.handleReply} /> <IconButton className='status__action-bar-button' title={intl.formatMessage(messages.reply)} icon='reply' iconComponent={ReplyIcon} onClick={handleReply} />
<div className='status__action-bar-dropdown'> <div className='status__action-bar-dropdown'>
<DropdownMenuContainer <DropdownMenuContainer
@ -199,8 +234,11 @@ class Conversation extends ImmutablePureComponent {
</div> </div>
</HotKeys> </HotKeys>
); );
} };
} Conversation.propTypes = {
conversation: ImmutablePropTypes.map.isRequired,
export default withRouter(injectIntl(Conversation)); scrollKey: PropTypes.string,
onMoveUp: PropTypes.func,
onMoveDown: PropTypes.func,
};

View File

@ -1,77 +1,72 @@
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { useRef, useMemo, useCallback } from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes'; import { useSelector, useDispatch } from 'react-redux';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { debounce } from 'lodash'; import { debounce } from 'lodash';
import ScrollableList from '../../../components/scrollable_list'; import { expandConversations } from 'mastodon/actions/conversations';
import ConversationContainer from '../containers/conversation_container'; import ScrollableList from 'mastodon/components/scrollable_list';
export default class ConversationsList extends ImmutablePureComponent { import { Conversation } from './conversation';
static propTypes = { const focusChild = (node, index, alignTop) => {
conversations: ImmutablePropTypes.list.isRequired, const element = node.querySelector(`article:nth-of-type(${index + 1}) .focusable`);
scrollKey: PropTypes.string.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 (element) {
if (align_top && container.scrollTop > element.offsetTop) { if (alignTop && node.scrollTop > element.offsetTop) {
element.scrollIntoView(true); element.scrollIntoView(true);
} else if (!align_top && container.scrollTop + container.clientHeight < element.offsetTop + element.offsetHeight) { } else if (!alignTop && node.scrollTop + node.clientHeight < element.offsetTop + element.offsetHeight) {
element.scrollIntoView(false); element.scrollIntoView(false);
} }
element.focus(); element.focus();
} }
}
setRef = c => {
this.node = c;
}; };
handleLoadOlder = debounce(() => { export const ConversationsList = ({ scrollKey, ...other }) => {
const last = this.props.conversations.last(); const listRef = useRef();
const conversations = useSelector(state => state.getIn(['conversations', 'items']));
const isLoading = useSelector(state => state.getIn(['conversations', 'isLoading'], true));
const hasMore = useSelector(state => state.getIn(['conversations', 'hasMore'], false));
const dispatch = useDispatch();
const lastStatusId = conversations.last()?.get('last_status');
if (last && last.get('last_status')) { const handleMoveUp = useCallback(id => {
this.props.onLoadMore(last.get('last_status')); const elementIndex = conversations.findIndex(x => x.get('id') === id) - 1;
focusChild(listRef.current.node, elementIndex, true);
}, [listRef, conversations]);
const handleMoveDown = useCallback(id => {
const elementIndex = conversations.findIndex(x => x.get('id') === id) + 1;
focusChild(listRef.current.node, elementIndex, false);
}, [listRef, conversations]);
const debouncedLoadMore = useMemo(() => debounce(id => {
dispatch(expandConversations({ maxId: id }));
}, 300, { leading: true }), [dispatch]);
const handleLoadMore = useCallback(() => {
if (lastStatusId) {
debouncedLoadMore(lastStatusId);
} }
}, 300, { leading: true }); }, [debouncedLoadMore, lastStatusId]);
render () {
const { conversations, isLoading, onLoadMore, ...other } = this.props;
return ( return (
<ScrollableList {...other} isLoading={isLoading} showLoading={isLoading && conversations.isEmpty()} onLoadMore={onLoadMore && this.handleLoadOlder} ref={this.setRef}> <ScrollableList {...other} scrollKey={scrollKey} isLoading={isLoading} showLoading={isLoading && conversations.isEmpty()} hasMore={hasMore} onLoadMore={handleLoadMore} ref={listRef}>
{conversations.map(item => ( {conversations.map(item => (
<ConversationContainer <Conversation
key={item.get('id')} key={item.get('id')}
conversationId={item.get('id')} conversation={item}
onMoveUp={this.handleMoveUp} onMoveUp={handleMoveUp}
onMoveDown={this.handleMoveDown} onMoveDown={handleMoveDown}
scrollKey={this.props.scrollKey} scrollKey={scrollKey}
/> />
))} ))}
</ScrollableList> </ScrollableList>
); );
} };
} ConversationsList.propTypes = {
scrollKey: PropTypes.string.isRequired,
};

View File

@ -1,80 +0,0 @@
import { defineMessages, injectIntl } from 'react-intl';
import { connect } from 'react-redux';
import { replyCompose } from 'mastodon/actions/compose';
import { markConversationRead, deleteConversation } from 'mastodon/actions/conversations';
import { openModal } from 'mastodon/actions/modal';
import { muteStatus, unmuteStatus, hideStatus, revealStatus } from 'mastodon/actions/statuses';
import { makeGetStatus } from 'mastodon/selectors';
import Conversation from '../components/conversation';
const messages = defineMessages({
replyConfirm: { id: 'confirmations.reply.confirm', defaultMessage: 'Reply' },
replyMessage: { id: 'confirmations.reply.message', defaultMessage: 'Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?' },
});
const mapStateToProps = () => {
const getStatus = makeGetStatus();
return (state, { conversationId }) => {
const conversation = state.getIn(['conversations', 'items']).find(x => x.get('id') === conversationId);
const lastStatusId = conversation.get('last_status', null);
return {
accounts: conversation.get('accounts').map(accountId => state.getIn(['accounts', accountId], null)),
unread: conversation.get('unread'),
lastStatus: lastStatusId && getStatus(state, { id: lastStatusId }),
};
};
};
const mapDispatchToProps = (dispatch, { intl, conversationId }) => ({
markRead () {
dispatch(markConversationRead(conversationId));
},
reply (status, router) {
dispatch((_, getState) => {
let state = getState();
if (state.getIn(['compose', 'text']).trim().length !== 0) {
dispatch(openModal({
modalType: 'CONFIRM',
modalProps: {
message: intl.formatMessage(messages.replyMessage),
confirm: intl.formatMessage(messages.replyConfirm),
onConfirm: () => dispatch(replyCompose(status, router)),
},
}));
} else {
dispatch(replyCompose(status, router));
}
});
},
delete () {
dispatch(deleteConversation(conversationId));
},
onMute (status) {
if (status.get('muted')) {
dispatch(unmuteStatus(status.get('id')));
} else {
dispatch(muteStatus(status.get('id')));
}
},
onToggleHidden (status) {
if (status.get('hidden')) {
dispatch(revealStatus(status.get('id')));
} else {
dispatch(hideStatus(status.get('id')));
}
},
});
export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(Conversation));

View File

@ -1,16 +0,0 @@
import { connect } from 'react-redux';
import { expandConversations } from '../../../actions/conversations';
import ConversationsList from '../components/conversations_list';
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

@ -1,11 +1,11 @@
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { PureComponent } from 'react'; import { useRef, useCallback, useEffect } from 'react';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
import { Helmet } from 'react-helmet'; import { Helmet } from 'react-helmet';
import { connect } from 'react-redux'; import { useDispatch } from 'react-redux';
import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?react'; import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?react';
import { addColumn, removeColumn, moveColumn } from 'mastodon/actions/columns'; import { addColumn, removeColumn, moveColumn } from 'mastodon/actions/columns';
@ -14,93 +14,66 @@ import { connectDirectStream } from 'mastodon/actions/streaming';
import Column from 'mastodon/components/column'; import Column from 'mastodon/components/column';
import ColumnHeader from 'mastodon/components/column_header'; import ColumnHeader from 'mastodon/components/column_header';
import ConversationsListContainer from './containers/conversations_list_container'; import { ConversationsList } from './components/conversations_list';
const messages = defineMessages({ const messages = defineMessages({
title: { id: 'column.direct', defaultMessage: 'Private mentions' }, title: { id: 'column.direct', defaultMessage: 'Private mentions' },
}); });
class DirectTimeline extends PureComponent { const DirectTimeline = ({ columnId, multiColumn }) => {
const columnRef = useRef();
static propTypes = { const intl = useIntl();
dispatch: PropTypes.func.isRequired, const dispatch = useDispatch();
columnId: PropTypes.string, const pinned = !!columnId;
intl: PropTypes.object.isRequired,
hasUnread: PropTypes.bool,
multiColumn: PropTypes.bool,
};
handlePin = () => {
const { columnId, dispatch } = this.props;
const handlePin = useCallback(() => {
if (columnId) { if (columnId) {
dispatch(removeColumn(columnId)); dispatch(removeColumn(columnId));
} else { } else {
dispatch(addColumn('DIRECT', {})); dispatch(addColumn('DIRECT', {}));
} }
}; }, [dispatch, columnId]);
handleMove = (dir) => { const handleMove = useCallback((dir) => {
const { columnId, dispatch } = this.props;
dispatch(moveColumn(columnId, dir)); dispatch(moveColumn(columnId, dir));
}; }, [dispatch, columnId]);
handleHeaderClick = () => { const handleHeaderClick = useCallback(() => {
this.column.scrollTop(); columnRef.current.scrollTop();
}; }, [columnRef]);
componentDidMount () {
const { dispatch } = this.props;
useEffect(() => {
dispatch(mountConversations()); dispatch(mountConversations());
dispatch(expandConversations()); dispatch(expandConversations());
this.disconnect = dispatch(connectDirectStream());
}
componentWillUnmount () { const disconnect = dispatch(connectDirectStream());
this.props.dispatch(unmountConversations());
if (this.disconnect) { return () => {
this.disconnect(); dispatch(unmountConversations());
this.disconnect = null; disconnect();
}
}
setRef = c => {
this.column = c;
}; };
}, [dispatch]);
handleLoadMore = maxId => {
this.props.dispatch(expandConversations({ maxId }));
};
render () {
const { intl, hasUnread, columnId, multiColumn } = this.props;
const pinned = !!columnId;
return ( return (
<Column bindToDocument={!multiColumn} ref={this.setRef} label={intl.formatMessage(messages.title)}> <Column bindToDocument={!multiColumn} ref={columnRef} label={intl.formatMessage(messages.title)}>
<ColumnHeader <ColumnHeader
icon='at' icon='at'
iconComponent={AlternateEmailIcon} iconComponent={AlternateEmailIcon}
active={hasUnread}
title={intl.formatMessage(messages.title)} title={intl.formatMessage(messages.title)}
onPin={this.handlePin} onPin={handlePin}
onMove={this.handleMove} onMove={handleMove}
onClick={this.handleHeaderClick} onClick={handleHeaderClick}
pinned={pinned} pinned={pinned}
multiColumn={multiColumn} multiColumn={multiColumn}
/> />
<ConversationsListContainer <ConversationsList
trackScroll={!pinned} trackScroll={!pinned}
scrollKey={`direct_timeline-${columnId}`} scrollKey={`direct_timeline-${columnId}`}
timelineId='direct' emptyMessage={<FormattedMessage id='empty_column.direct' defaultMessage="You don't have any private mentions yet. When you send or receive one, it will show up here." />}
bindToDocument={!multiColumn} bindToDocument={!multiColumn}
onLoadMore={this.handleLoadMore}
prepend={<div className='follow_requests-unlocked_explanation'><span><FormattedMessage id='compose_form.encryption_warning' defaultMessage='Posts on Mastodon are not end-to-end encrypted. Do not share any dangerous information over Mastodon.' /> <a href='/terms' target='_blank'><FormattedMessage id='compose_form.direct_message_warning_learn_more' defaultMessage='Learn more' /></a></span></div>} prepend={<div className='follow_requests-unlocked_explanation'><span><FormattedMessage id='compose_form.encryption_warning' defaultMessage='Posts on Mastodon are not end-to-end encrypted. Do not share any dangerous information over Mastodon.' /> <a href='/terms' target='_blank'><FormattedMessage id='compose_form.direct_message_warning_learn_more' defaultMessage='Learn more' /></a></span></div>}
alwaysPrepend alwaysPrepend
emptyMessage={<FormattedMessage id='empty_column.direct' defaultMessage="You don't have any private mentions yet. When you send or receive one, it will show up here." />}
/> />
<Helmet> <Helmet>
@ -109,8 +82,11 @@ class DirectTimeline extends PureComponent {
</Helmet> </Helmet>
</Column> </Column>
); );
} };
} DirectTimeline.propTypes = {
columnId: PropTypes.string,
multiColumn: PropTypes.bool,
};
export default connect()(injectIntl(DirectTimeline)); export default DirectTimeline;