Skip to content

Commit

Permalink
Refactor conversations components in web UI (mastodon#28833)
Browse files Browse the repository at this point in the history
Co-authored-by: Claire <claire.github-309c@sitedethib.com>
  • Loading branch information
Gargron and ClearlyClaire committed Jan 25, 2024
1 parent 274a48a commit 3205a65
Show file tree
Hide file tree
Showing 5 changed files with 296 additions and 383 deletions.
Original file line number Diff line number Diff line change
@@ -1,25 +1,32 @@
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 { 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 ImmutablePureComponent from 'react-immutable-pure-component';
import { useDispatch, useSelector } from 'react-redux';


import { HotKeys } from 'react-hotkeys';

import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.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 AvatarComposite from 'mastodon/components/avatar_composite';
import { IconButton } from 'mastodon/components/icon_button';
import { RelativeTimestamp } from 'mastodon/components/relative_timestamp';
import StatusContent from 'mastodon/components/status_content';
import DropdownMenuContainer from 'mastodon/containers/dropdown_menu_container';
import { autoPlayGif } from 'mastodon/initial_state';
import { WithRouterPropTypes } from 'mastodon/utils/react_router';
import { makeGetStatus } from 'mastodon/selectors';

const messages = defineMessages({
more: { id: 'status.more', defaultMessage: 'More' },
Expand All @@ -29,25 +36,31 @@ const messages = defineMessages({
delete: { id: 'conversation.delete', defaultMessage: 'Delete conversation' },
muteConversation: { id: 'status.mute_conversation', defaultMessage: 'Mute 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 {

static propTypes = {
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 }) => {
const getAccounts = createSelector(
(state) => state.get('accounts'),
(_, accountIds) => accountIds,
(accounts, accountIds) =>
accountIds.map(id => accounts.get(id))
);

const getStatus = makeGetStatus();

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) {
return;
}
Expand All @@ -58,9 +71,9 @@ class Conversation extends ImmutablePureComponent {
let emoji = emojis[i];
emoji.src = emoji.getAttribute('data-original');
}
};
}, []);

handleMouseLeave = ({ currentTarget }) => {
const handleMouseLeave = useCallback(({ currentTarget }) => {
if (autoPlayGif) {
return;
}
Expand All @@ -71,136 +84,161 @@ class Conversation extends ImmutablePureComponent {
let emoji = emojis[i];
emoji.src = emoji.getAttribute('data-static');
}
};

handleClick = () => {
if (!this.props.history) {
return;
}

const { lastStatus, unread, markRead } = this.props;
}, []);

const handleClick = useCallback(() => {
if (unread) {
markRead();
dispatch(markConversationRead(id));
}

this.props.history.push(`/@${lastStatus.getIn(['account', 'acct'])}/${lastStatus.get('id')}`);
};

handleMarkAsRead = () => {
this.props.markRead();
};

handleReply = () => {
this.props.reply(this.props.lastStatus, this.props.history);
};

handleDelete = () => {
this.props.delete();
};

handleHotkeyMoveUp = () => {
this.props.onMoveUp(this.props.conversationId);
};

handleHotkeyMoveDown = () => {
this.props.onMoveDown(this.props.conversationId);
};

handleConversationMute = () => {
this.props.onMute(this.props.lastStatus);
};

handleShowMore = () => {
this.props.onToggleHidden(this.props.lastStatus);
};

render () {
const { accounts, lastStatus, unread, scrollKey, intl } = this.props;

if (lastStatus === null) {
return null;
history.push(`/@${lastStatus.getIn(['account', 'acct'])}/${lastStatus.get('id')}`);
}, [dispatch, history, unread, id, lastStatus]);

const handleMarkAsRead = useCallback(() => {
dispatch(markConversationRead(id));
}, [dispatch, id]);

const handleReply = useCallback(() => {
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(lastStatus, history)),
},
}));
} else {
dispatch(replyCompose(lastStatus, history));
}
});
}, [dispatch, lastStatus, history, intl]);

const handleDelete = useCallback(() => {
dispatch(deleteConversation(id));
}, [dispatch, id]);

const handleHotkeyMoveUp = useCallback(() => {
onMoveUp(id);
}, [id, onMoveUp]);

const handleHotkeyMoveDown = useCallback(() => {
onMoveDown(id);
}, [id, onMoveDown]);

const handleConversationMute = useCallback(() => {
if (lastStatus.get('muted')) {
dispatch(unmuteStatus(lastStatus.get('id')));
} else {
dispatch(muteStatus(lastStatus.get('id')));
}
}, [dispatch, lastStatus]);

const menu = [
{ text: intl.formatMessage(messages.open), action: this.handleClick },
null,
];

menu.push({ text: intl.formatMessage(lastStatus.get('muted') ? messages.unmuteConversation : messages.muteConversation), action: this.handleConversationMute });

if (unread) {
menu.push({ text: intl.formatMessage(messages.markAsRead), action: this.handleMarkAsRead });
menu.push(null);
const handleShowMore = useCallback(() => {
if (lastStatus.get('hidden')) {
dispatch(revealStatus(lastStatus.get('id')));
} else {
dispatch(hideStatus(lastStatus.get('id')));
}
}, [dispatch, lastStatus]);

if (!lastStatus) {
return null;
}

menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDelete });
const menu = [
{ text: intl.formatMessage(messages.open), action: handleClick },
null,
{ text: intl.formatMessage(lastStatus.get('muted') ? messages.unmuteConversation : messages.muteConversation), action: handleConversationMute },
];

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]);
if (unread) {
menu.push({ text: intl.formatMessage(messages.markAsRead), action: handleMarkAsRead });
menu.push(null);
}

const handlers = {
reply: this.handleReply,
open: this.handleClick,
moveUp: this.handleHotkeyMoveUp,
moveDown: this.handleHotkeyMoveDown,
toggleHidden: this.handleShowMore,
};
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 handlers = {
reply: handleReply,
open: handleClick,
moveUp: handleHotkeyMoveUp,
moveDown: handleHotkeyMoveDown,
toggleHidden: handleShowMore,
};

return (
<HotKeys handlers={handlers}>
<div className={classNames('conversation focusable muted', { 'conversation--unread': unread })} tabIndex={0}>
<div className='conversation__avatar' onClick={this.handleClick} role='presentation'>
<AvatarComposite accounts={accounts} size={48} />
</div>
return (
<HotKeys handlers={handlers}>
<div className={classNames('conversation focusable muted', { 'conversation--unread': unread })} tabIndex={0}>
<div className='conversation__avatar' onClick={handleClick} role='presentation'>
<AvatarComposite accounts={accounts} size={48} />
</div>

<div className='conversation__content'>
<div className='conversation__content__info'>
<div className='conversation__content__relative-time'>
{unread && <span className='conversation__unread' />} <RelativeTimestamp timestamp={lastStatus.get('created_at')} />
</div>
<div className='conversation__content'>
<div className='conversation__content__info'>
<div className='conversation__content__relative-time'>
{unread && <span className='conversation__unread' />} <RelativeTimestamp timestamp={lastStatus.get('created_at')} />
</div>

<div className='conversation__content__names' onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}>
<FormattedMessage id='conversation.with' defaultMessage='With {names}' values={{ names: <span>{names}</span> }} />
</div>
<div className='conversation__content__names' onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave}>
<FormattedMessage id='conversation.with' defaultMessage='With {names}' values={{ names: <span>{names}</span> }} />
</div>
</div>

<StatusContent
status={lastStatus}
onClick={this.handleClick}
expanded={!lastStatus.get('hidden')}
onExpandedToggle={this.handleShowMore}
collapsible
<StatusContent
status={lastStatus}
onClick={handleClick}
expanded={!lastStatus.get('hidden')}
onExpandedToggle={handleShowMore}
collapsible
/>

{lastStatus.get('media_attachments').size > 0 && (
<AttachmentList
compact
media={lastStatus.get('media_attachments')}
/>

{lastStatus.get('media_attachments').size > 0 && (
<AttachmentList
compact
media={lastStatus.get('media_attachments')}
)}

<div className='status__action-bar'>
<IconButton className='status__action-bar-button' title={intl.formatMessage(messages.reply)} icon='reply' iconComponent={ReplyIcon} onClick={handleReply} />

<div className='status__action-bar-dropdown'>
<DropdownMenuContainer
scrollKey={scrollKey}
status={lastStatus}
items={menu}
icon='ellipsis-h'
iconComponent={MoreHorizIcon}
size={18}
direction='right'
title={intl.formatMessage(messages.more)}
/>
)}

<div className='status__action-bar'>
<IconButton className='status__action-bar-button' title={intl.formatMessage(messages.reply)} icon='reply' iconComponent={ReplyIcon} onClick={this.handleReply} />

<div className='status__action-bar-dropdown'>
<DropdownMenuContainer
scrollKey={scrollKey}
status={lastStatus}
items={menu}
icon='ellipsis-h'
iconComponent={MoreHorizIcon}
size={18}
direction='right'
title={intl.formatMessage(messages.more)}
/>
</div>
</div>
</div>
</div>
</HotKeys>
);
}

}

export default withRouter(injectIntl(Conversation));
</div>
</HotKeys>
);
};

Conversation.propTypes = {
conversation: ImmutablePropTypes.map.isRequired,
scrollKey: PropTypes.string,
onMoveUp: PropTypes.func,
onMoveDown: PropTypes.func,
};
Loading

0 comments on commit 3205a65

Please sign in to comment.