From 4507c9b0b35ba7cd0665b4a09e84b71be486cbca Mon Sep 17 00:00:00 2001 From: diondiondion Date: Tue, 1 Sep 2026 17:44:36 +0200 Subject: [PATCH] Refactor Messages/Private Mentions page to TS (#40347) --- app/javascript/mastodon/actions/streaming.js | 18 +- .../{conversation.jsx => conversation.tsx} | 186 +++++++++++++----- .../direct_timeline/{index.jsx => index.tsx} | 73 +++++-- app/javascript/mastodon/stream.js | 5 +- 4 files changed, 207 insertions(+), 75 deletions(-) rename app/javascript/mastodon/features/direct_timeline/components/{conversation.jsx => conversation.tsx} (50%) rename app/javascript/mastodon/features/direct_timeline/{index.jsx => index.tsx} (53%) diff --git a/app/javascript/mastodon/actions/streaming.js b/app/javascript/mastodon/actions/streaming.js index 62accd632e9..6bb4f94cde5 100644 --- a/app/javascript/mastodon/actions/streaming.js +++ b/app/javascript/mastodon/actions/streaming.js @@ -39,6 +39,10 @@ const randomUpTo = max => * @typedef {(dispatch: Dispatch, getState: GetState) => Promise} 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 }, { diff --git a/app/javascript/mastodon/features/direct_timeline/components/conversation.jsx b/app/javascript/mastodon/features/direct_timeline/components/conversation.tsx similarity index 50% rename from app/javascript/mastodon/features/direct_timeline/components/conversation.jsx rename to app/javascript/mastodon/features/direct_timeline/components/conversation.tsx index fbe37f58a29..48cac2894ac 100644 --- a/app/javascript/mastodon/features/direct_timeline/components/conversation.jsx +++ b/app/javascript/mastodon/features/direct_timeline/components/conversation.tsx @@ -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) => accountIds, + ( + accounts: ImmutableMap, + accountIds: ImmutableList, + ) => accountIds.map((id) => accounts.get(id)), ); const getStatus = makeGetStatus(); -export const Conversation = ({ conversation, scrollKey }) => { +interface Conversation { + id: string; + unread: boolean; + accounts: ImmutableList; + last_status: string | null; +} + +export const Conversation: React.FC<{ + conversation: ImmutableRecord; + 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 + | 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) => ( - - )).reduce((prev, cur) => [prev, ', ', cur]); + const names: React.ReactNode = accounts + .map((account) => + account ? ( + + ) : null, + ) + .filter(Boolean) + .reduce((prev, cur) => [prev, ', ', cur]); const handlers = { reply: handleReply, @@ -126,23 +204,37 @@ export const Conversation = ({ conversation, scrollKey }) => { return ( -
-
+
+
- {unread && } + {unread && }{' '} +
- {names} }} /> + {names} }} + />
{ collapsible /> - {lastStatus.get('media_attachments').size > 0 && ( + {lastStatus + // @ts-expect-error Status isn't properly typed yet + .get('media_attachments').size > 0 && ( { )}
- +
{ ); }; - -Conversation.propTypes = { - conversation: ImmutablePropTypes.map.isRequired, - scrollKey: PropTypes.string, -}; diff --git a/app/javascript/mastodon/features/direct_timeline/index.jsx b/app/javascript/mastodon/features/direct_timeline/index.tsx similarity index 53% rename from app/javascript/mastodon/features/direct_timeline/index.jsx rename to app/javascript/mastodon/features/direct_timeline/index.tsx index 5efe7712e9a..0990b73f55b 100644 --- a/app/javascript/mastodon/features/direct_timeline/index.jsx +++ b/app/javascript/mastodon/features/direct_timeline/index.tsx @@ -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 = ({ 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 ( - + { } + emptyMessage={ + + } bindToDocument={!multiColumn} - prepend={
} + prepend={ +
+ + {' '} + + + + +
+ } 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; diff --git a/app/javascript/mastodon/stream.js b/app/javascript/mastodon/stream.js index d66332083fc..afeea80abb6 100644 --- a/app/javascript/mastodon/stream.js +++ b/app/javascript/mastodon/stream.js @@ -147,11 +147,10 @@ const channelNameWithInlineParams = (channelName, params) => { * @param {string} channelName * @param {Object.} 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);