Refactor Messages/Private Mentions page to TS (#40347)

This commit is contained in:
diondiondion
2026-09-01 17:44:36 +02:00
committed by GitHub
parent eb03e214b3
commit 4507c9b0b3
4 changed files with 207 additions and 75 deletions

View File

@@ -39,6 +39,10 @@ const randomUpTo = max =>
* @typedef {(dispatch: Dispatch, getState: GetState) => Promise<void>} FallbackFunction
*/
/**
* @typedef {(dispatch: Dispatch, getState: GetState) => () => void} StreamThunk
*/
/**
* @param {string} timelineId
* @param {string} channelName
@@ -47,7 +51,7 @@ const randomUpTo = max =>
* @param {FallbackFunction} [options.fallback]
* @param {() => UnknownAction} [options.fillGaps]
* @param {(status: object) => boolean} [options.accept]
* @returns {() => void}
* @returns {StreamThunk}
*/
export const connectTimelineStream = (timelineId, channelName, params = {}, options = {}) => {
const { messages } = getLocale();
@@ -159,7 +163,7 @@ async function refreshHomeTimelineAndNotification(dispatch) {
}
/**
* @returns {() => void}
* @returns {StreamThunk}
*/
export const connectUserStream = () =>
connectTimelineStream('home', 'user', {}, {
@@ -171,7 +175,7 @@ export const connectUserStream = () =>
/**
* @param {Object} options
* @param {boolean} [options.onlyMedia]
* @returns {() => void}
* @returns {StreamThunk}
*/
export const connectCommunityStream = ({ onlyMedia } = {}) =>
connectTimelineStream(`community${onlyMedia ? ':media' : ''}`, `public:local${onlyMedia ? ':media' : ''}`, {}, {
@@ -183,7 +187,7 @@ export const connectCommunityStream = ({ onlyMedia } = {}) =>
* @param {Object} options
* @param {boolean} [options.onlyMedia]
* @param {boolean} [options.onlyRemote]
* @returns {() => void}
* @returns {StreamThunk}
*/
export const connectPublicStream = ({ onlyMedia, onlyRemote } = {}) =>
connectTimelineStream(`public${onlyRemote ? ':remote' : ''}${onlyMedia ? ':media' : ''}`, `public${onlyRemote ? ':remote' : ''}${onlyMedia ? ':media' : ''}`, {}, {
@@ -196,20 +200,20 @@ export const connectPublicStream = ({ onlyMedia, onlyRemote } = {}) =>
* @param {string} tagName
* @param {boolean} onlyLocal
* @param {(status: object) => boolean} accept
* @returns {() => void}
* @returns {StreamThunk}
*/
export const connectHashtagStream = (columnId, tagName, onlyLocal, accept) =>
connectTimelineStream(`hashtag:${columnId}${onlyLocal ? ':local' : ''}`, `hashtag${onlyLocal ? ':local' : ''}`, { tag: tagName }, { accept });
/**
* @returns {() => void}
* @returns {StreamThunk}
*/
export const connectDirectStream = () =>
connectTimelineStream('direct', 'direct');
/**
* @param {string} listId
* @returns {() => void}
* @returns {StreamThunk}
*/
export const connectListStream = listId =>
connectTimelineStream(`list:${listId}`, 'list', { list: listId }, {

View File

@@ -1,68 +1,114 @@
import PropTypes from 'prop-types';
import { useCallback } from 'react';
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import { Link, useHistory } from 'react-router-dom';
import { useHistory } from 'react-router-dom';
import { createSelector } from '@reduxjs/toolkit';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { useDispatch, useSelector } from 'react-redux';
import type {
Map as ImmutableMap,
List as ImmutableList,
Record as ImmutableRecord,
} from 'immutable';
import { LinkedDisplayName } from '@/mastodon/components/display_name';
import { AnimateEmojiProvider } from '@/mastodon/components/emoji/context';
import type { Account } from '@/mastodon/models/account';
import type { StatusShape } from '@/mastodon/models/status';
import {
createAppSelector,
useAppDispatch,
useAppSelector,
} from '@/mastodon/store';
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 {
markConversationRead,
deleteConversation,
} from 'mastodon/actions/conversations';
import { openModal } from 'mastodon/actions/modal';
import { muteStatus, unmuteStatus, toggleStatusSpoilers } from 'mastodon/actions/statuses';
import { Hotkeys } from 'mastodon/components/hotkeys';
import {
muteStatus,
unmuteStatus,
toggleStatusSpoilers,
} from 'mastodon/actions/statuses';
import AttachmentList from 'mastodon/components/attachment_list';
import AvatarComposite from 'mastodon/components/avatar_composite';
import { Dropdown } from 'mastodon/components/dropdown_menu';
import { Hotkeys } from 'mastodon/components/hotkeys';
import { IconButton } from 'mastodon/components/icon_button';
import { RelativeTimestamp } from 'mastodon/components/relative_timestamp';
import StatusContent from 'mastodon/components/status_content';
import { Dropdown } from 'mastodon/components/dropdown_menu';
import { makeGetStatus } from 'mastodon/selectors';
import { LinkedDisplayName } from '@/mastodon/components/display_name';
import { AnimateEmojiProvider } from '@/mastodon/components/emoji/context';
const messages = defineMessages({
more: { id: 'status.more', defaultMessage: 'More' },
open: { id: 'conversation.open', defaultMessage: 'View conversation' },
reply: { id: 'status.reply', defaultMessage: 'Reply' },
markAsRead: { id: 'conversation.mark_as_read', defaultMessage: 'Mark as read' },
markAsRead: {
id: 'conversation.mark_as_read',
defaultMessage: 'Mark as read',
},
delete: { id: 'conversation.delete', defaultMessage: 'Delete conversation' },
muteConversation: { id: 'status.mute_conversation', defaultMessage: 'Mute conversation' },
unmuteConversation: { id: 'status.unmute_conversation', defaultMessage: 'Unmute conversation' },
muteConversation: {
id: 'status.mute_conversation',
defaultMessage: 'Mute conversation',
},
unmuteConversation: {
id: 'status.unmute_conversation',
defaultMessage: 'Unmute conversation',
},
});
const getAccounts = createSelector(
(state) => state.get('accounts'),
(_, accountIds) => accountIds,
(accounts, accountIds) =>
accountIds.map(id => accounts.get(id))
const getAccounts = createAppSelector(
(state) => state.accounts,
(_, accountIds: ImmutableList<string>) => accountIds,
(
accounts: ImmutableMap<string, Account>,
accountIds: ImmutableList<string>,
) => accountIds.map((id) => accounts.get(id)),
);
const getStatus = makeGetStatus();
export const Conversation = ({ conversation, scrollKey }) => {
interface Conversation {
id: string;
unread: boolean;
accounts: ImmutableList<string>;
last_status: string | null;
}
export const Conversation: React.FC<{
conversation: ImmutableRecord<Conversation>;
scrollKey: string;
}> = ({ conversation, scrollKey }) => {
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 dispatch = useAppDispatch();
const history = useHistory();
const lastStatus = useSelector(state => getStatus(state, { id: lastStatusId }));
const accounts = useSelector(state => getAccounts(state, accountIds));
const lastStatus = useAppSelector(
(state) =>
// @ts-expect-error getStatus isn't properly typed yet
getStatus(state, { id: lastStatusId }) as
| ImmutableRecord<StatusShape>
| undefined,
);
const accounts = useAppSelector((state) => getAccounts(state, accountIds));
const handleClick = useCallback(() => {
if (unread) {
dispatch(markConversationRead(id));
}
history.push(`/@${lastStatus.getIn(['account', 'acct'])}/${lastStatus.get('id')}`);
if (lastStatus) {
history.push(
`/@${lastStatus.getIn(['account', 'acct']) as string}/${lastStatus.get('id')}`,
);
}
}, [dispatch, history, unread, id, lastStatus]);
const handleMarkAsRead = useCallback(() => {
@@ -71,10 +117,17 @@ export const Conversation = ({ conversation, scrollKey }) => {
const handleReply = useCallback(() => {
dispatch((_, getState) => {
let state = getState();
const state = getState();
const composeText = state.compose.get('text');
const text = typeof composeText === 'string' ? composeText.trim() : '';
if (state.getIn(['compose', 'text']).trim().length !== 0) {
dispatch(openModal({ modalType: 'CONFIRM_REPLY', modalProps: { status: lastStatus } }));
if (text.length !== 0) {
dispatch(
openModal({
modalType: 'CONFIRM_REPLY',
modalProps: { status: lastStatus },
}),
);
} else {
dispatch(replyCompose(lastStatus));
}
@@ -86,6 +139,8 @@ export const Conversation = ({ conversation, scrollKey }) => {
}, [dispatch, id]);
const handleConversationMute = useCallback(() => {
if (!lastStatus) return;
if (lastStatus.get('muted')) {
dispatch(unmuteStatus(lastStatus.get('id')));
} else {
@@ -94,7 +149,9 @@ export const Conversation = ({ conversation, scrollKey }) => {
}, [dispatch, lastStatus]);
const handleShowMore = useCallback(() => {
dispatch(toggleStatusSpoilers(lastStatus.get('id')));
if (lastStatus) {
dispatch(toggleStatusSpoilers(lastStatus.get('id')));
}
}, [dispatch, lastStatus]);
if (!lastStatus) {
@@ -104,19 +161,40 @@ export const Conversation = ({ conversation, scrollKey }) => {
const menu = [
{ text: intl.formatMessage(messages.open), action: handleClick },
null,
{ text: intl.formatMessage(lastStatus.get('muted') ? messages.unmuteConversation : messages.muteConversation), action: handleConversationMute },
{
text: intl.formatMessage(
lastStatus.get('muted')
? messages.unmuteConversation
: messages.muteConversation,
),
action: handleConversationMute,
},
];
if (unread) {
menu.push({ text: intl.formatMessage(messages.markAsRead), action: handleMarkAsRead });
menu.push({
text: intl.formatMessage(messages.markAsRead),
action: handleMarkAsRead,
});
menu.push(null);
}
menu.push({ text: intl.formatMessage(messages.delete), action: handleDelete });
menu.push({
text: intl.formatMessage(messages.delete),
action: handleDelete,
});
const names = accounts.map((account) => (
<LinkedDisplayName displayProps={{account, variant: 'simple'}} key={account.get('id')} />
)).reduce((prev, cur) => [prev, ', ', cur]);
const names: React.ReactNode = accounts
.map((account) =>
account ? (
<LinkedDisplayName
displayProps={{ account, variant: 'simple' }}
key={account.get('id')}
/>
) : null,
)
.filter(Boolean)
.reduce((prev, cur) => [prev, ', ', cur]);
const handlers = {
reply: handleReply,
@@ -126,23 +204,37 @@ export const Conversation = ({ conversation, scrollKey }) => {
return (
<Hotkeys handlers={handlers}>
<div className={classNames('conversation focusable muted', { unread })} tabIndex={0}>
<div className='conversation__avatar' onClick={handleClick} role='presentation'>
<div
className={classNames('conversation focusable muted', { unread })}
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
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')} />
{unread && <span className='conversation__unread' />}{' '}
<RelativeTimestamp timestamp={lastStatus.get('created_at')} />
</div>
<AnimateEmojiProvider className='conversation__content__names'>
<FormattedMessage id='conversation.with' defaultMessage='With {names}' values={{ names: <span>{names}</span> }} />
<FormattedMessage
id='conversation.with'
defaultMessage='With {names}'
values={{ names: <span>{names}</span> }}
/>
</AnimateEmojiProvider>
</div>
<StatusContent
// @ts-expect-error StatusContent isn't typed yet
status={lastStatus}
onClick={handleClick}
expanded={!lastStatus.get('hidden')}
@@ -150,7 +242,9 @@ export const Conversation = ({ conversation, scrollKey }) => {
collapsible
/>
{lastStatus.get('media_attachments').size > 0 && (
{lastStatus
// @ts-expect-error Status isn't properly typed yet
.get('media_attachments').size > 0 && (
<AttachmentList
compact
media={lastStatus.get('media_attachments')}
@@ -158,11 +252,18 @@ export const Conversation = ({ conversation, scrollKey }) => {
)}
<div className='status__action-bar'>
<IconButton className='status__action-bar-button' title={intl.formatMessage(messages.reply)} icon='reply' iconComponent={ReplyIcon} onClick={handleReply} />
<IconButton
className='status__action-bar-button'
title={intl.formatMessage(messages.reply)}
icon='reply'
iconComponent={ReplyIcon}
onClick={handleReply}
/>
<div className='status__action-bar-dropdown'>
<Dropdown
scrollKey={scrollKey}
// @ts-expect-error Dropdown status prop isn't properly typed
status={lastStatus}
items={menu}
icon='ellipsis-h'
@@ -178,8 +279,3 @@ export const Conversation = ({ conversation, scrollKey }) => {
</Hotkeys>
);
};
Conversation.propTypes = {
conversation: ImmutablePropTypes.map.isRequired,
scrollKey: PropTypes.string,
};

View File

@@ -1,18 +1,20 @@
import PropTypes from 'prop-types';
import { useCallback, useEffect } from 'react';
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
import { Helmet } from '@unhead/react/helmet';
import { useDispatch } from 'react-redux';
import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?react';
import { addColumn, removeColumn, moveColumn } from 'mastodon/actions/columns';
import { mountConversations, unmountConversations, expandConversations } from 'mastodon/actions/conversations';
import { connectDirectStream } from 'mastodon/actions/streaming';
import { Column } from '@/mastodon/components/column';
import { ColumnHeader } from '@/mastodon/components/column/header';
import { useAppDispatch } from '@/mastodon/store';
import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?react';
import { addColumn, removeColumn, moveColumn } from 'mastodon/actions/columns';
import {
mountConversations,
unmountConversations,
expandConversations,
} from 'mastodon/actions/conversations';
import { connectDirectStream } from 'mastodon/actions/streaming';
import { ConversationsList } from './components/conversations_list';
@@ -20,9 +22,14 @@ const messages = defineMessages({
title: { id: 'column.direct', defaultMessage: 'Private mentions' },
});
const DirectTimeline = ({ columnId, multiColumn }) => {
interface ColumnBase {
columnId?: string;
multiColumn?: boolean;
}
const DirectTimeline: React.FC<ColumnBase> = ({ columnId, multiColumn }) => {
const intl = useIntl();
const dispatch = useDispatch();
const dispatch = useAppDispatch();
const pinned = !!columnId;
const handlePin = useCallback(() => {
@@ -33,9 +40,12 @@ const DirectTimeline = ({ columnId, multiColumn }) => {
}
}, [dispatch, columnId]);
const handleMove = useCallback((dir) => {
dispatch(moveColumn(columnId, dir));
}, [dispatch, columnId]);
const handleMove = useCallback(
(dir: number) => {
dispatch(moveColumn(columnId, dir));
},
[dispatch, columnId],
);
useEffect(() => {
dispatch(mountConversations());
@@ -50,7 +60,10 @@ const DirectTimeline = ({ columnId, multiColumn }) => {
}, [dispatch]);
return (
<Column bindToDocument={!multiColumn} label={intl.formatMessage(messages.title)}>
<Column
bindToDocument={!multiColumn}
label={intl.formatMessage(messages.title)}
>
<ColumnHeader
icon='at'
iconComponent={AlternateEmailIcon}
@@ -65,9 +78,33 @@ const DirectTimeline = ({ columnId, multiColumn }) => {
<ConversationsList
trackScroll={!pinned}
scrollKey={`direct_timeline-${columnId}`}
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." />}
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}
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='https://docs.joinmastodon.org/user/posting/#private' rel='noreferrer' 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='https://docs.joinmastodon.org/user/posting/#private'
rel='noreferrer'
target='_blank'
>
<FormattedMessage
id='compose_form.direct_message_warning_learn_more'
defaultMessage='Learn more'
/>
</a>
</span>
</div>
}
alwaysPrepend
/>
@@ -79,9 +116,5 @@ const DirectTimeline = ({ columnId, multiColumn }) => {
);
};
DirectTimeline.propTypes = {
columnId: PropTypes.string,
multiColumn: PropTypes.bool,
};
// eslint-disable-next-line import/no-default-export
export default DirectTimeline;

View File

@@ -147,11 +147,10 @@ const channelNameWithInlineParams = (channelName, params) => {
* @param {string} channelName
* @param {Object.<string, string>} params
* @param {(dispatch: Dispatch, getState: GetState) => { onConnect: () => void, onReceive: (event: StreamEvent) => void, onDisconnect: () => void }} callbacks
* @returns {() => void}
* @returns {(dispatch: Dispatch, getState: GetState) => () => void}
*/
// @ts-expect-error
export const connectStream = (channelName, params, callbacks) => (dispatch, getState) => {
const streamingAPIBaseURL = getState().getIn(['meta', 'streaming_api_base_url']);
const streamingAPIBaseURL = getState().meta.get('streaming_api_base_url');
const accessToken = getAccessToken();
const { onConnect, onReceive, onDisconnect } = callbacks(dispatch, getState);