From 07edaa875b4209ad4b8f670afc222a5374b0dd4c Mon Sep 17 00:00:00 2001 From: Devin Binnie <52460000+devinbinnie@users.noreply.github.com> Date: Thu, 12 Jun 2025 17:20:45 -0400 Subject: [PATCH] [MM-63045] Add root component to read arbitrary text globally, ensure thread menu reads its actions on execution (#31353) * [MM-63045] Add root component to read arbitrary text globally, ensure thread menu reads its actions on execution * PR feedback --------- Co-authored-by: Mattermost Build --- webapp/channels/src/actions/views/root.ts | 7 +++ .../src/components/readout/readout.test.tsx | 36 ++++++++++++++++ .../src/components/readout/readout.tsx | 43 +++++++++++++++++++ webapp/channels/src/components/root/root.tsx | 2 + .../thread_menu/thread_menu.test.tsx | 3 ++ .../thread_menu/thread_menu.tsx | 35 ++++++++++++++- webapp/channels/src/hooks/useReadout.ts | 17 ++++++++ webapp/channels/src/i18n/en.json | 8 ++++ webapp/channels/src/reducers/views/index.ts | 2 + webapp/channels/src/reducers/views/readout.ts | 31 +++++++++++++ webapp/channels/src/types/store/views.ts | 4 ++ webapp/channels/src/utils/constants.tsx | 3 ++ 12 files changed, 189 insertions(+), 2 deletions(-) create mode 100644 webapp/channels/src/components/readout/readout.test.tsx create mode 100644 webapp/channels/src/components/readout/readout.tsx create mode 100644 webapp/channels/src/hooks/useReadout.ts create mode 100644 webapp/channels/src/reducers/views/readout.ts diff --git a/webapp/channels/src/actions/views/root.ts b/webapp/channels/src/actions/views/root.ts index 0c7c9c439e..f9c7866d73 100644 --- a/webapp/channels/src/actions/views/root.ts +++ b/webapp/channels/src/actions/views/root.ts @@ -66,3 +66,10 @@ export function loadTranslations(locale: string, url: string): ActionFuncAsync { return {data: true}; }; } + +export function setReadout(message: string) { + return { + type: ActionTypes.SET_READOUT, + data: message, + }; +} diff --git a/webapp/channels/src/components/readout/readout.test.tsx b/webapp/channels/src/components/readout/readout.test.tsx new file mode 100644 index 0000000000..8d22a54622 --- /dev/null +++ b/webapp/channels/src/components/readout/readout.test.tsx @@ -0,0 +1,36 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {screen, act} from '@testing-library/react'; +import React from 'react'; + +import {renderWithContext} from 'tests/react_testing_utils'; + +import Readout from './readout'; + +describe('Readout', () => { + it('should render message and clear it after timeout', async () => { + jest.useFakeTimers(); + + renderWithContext(, { + views: { + readout: { + message: 'Test message', + }, + }, + }); + + // Message should be visible + expect(screen.getByText('Test message')).toBeInTheDocument(); + + // Fast-forward 2 seconds + act(() => { + jest.advanceTimersByTime(2000); + }); + + // Message should be cleared + expect(screen.queryByText('Test message')).not.toBeInTheDocument(); + + jest.useRealTimers(); + }); +}); diff --git a/webapp/channels/src/components/readout/readout.tsx b/webapp/channels/src/components/readout/readout.tsx new file mode 100644 index 0000000000..c02453badf --- /dev/null +++ b/webapp/channels/src/components/readout/readout.tsx @@ -0,0 +1,43 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useEffect} from 'react'; +import {useSelector, useDispatch} from 'react-redux'; + +import {ActionTypes} from 'utils/constants'; + +import type {GlobalState} from 'types/store'; + +const Readout = (): JSX.Element => { + const dispatch = useDispatch(); + const {message} = useSelector((state: GlobalState) => state.views.readout); + + useEffect(() => { + if (message) { + // Clear the message after 2 seconds + const timeout = setTimeout(() => { + dispatch({ + type: ActionTypes.CLEAR_READOUT, + }); + }, 2000); + + return () => { + clearTimeout(timeout); + }; + } + return undefined; + }, [message, dispatch]); + + return ( +
+ {message} +
+ ); +}; + +export default Readout; diff --git a/webapp/channels/src/components/root/root.tsx b/webapp/channels/src/components/root/root.tsx index 89348bc376..ff0413cd6f 100644 --- a/webapp/channels/src/components/root/root.tsx +++ b/webapp/channels/src/components/root/root.tsx @@ -26,6 +26,7 @@ import LoggedIn from 'components/logged_in'; import LoggedInRoute from 'components/logged_in_route'; import {LAUNCHING_WORKSPACE_FULLSCREEN_Z_INDEX} from 'components/preparing_workspace/launching_workspace'; import {Animations} from 'components/preparing_workspace/steps'; +import Readout from 'components/readout/readout'; import webSocketClient from 'client/web_websocket_client'; import {initializePlugins} from 'plugins'; @@ -582,6 +583,7 @@ export default class Root extends React.PureComponent { + diff --git a/webapp/channels/src/components/threading/global_threads/thread_menu/thread_menu.test.tsx b/webapp/channels/src/components/threading/global_threads/thread_menu/thread_menu.test.tsx index d0c482eb82..700e411914 100644 --- a/webapp/channels/src/components/threading/global_threads/thread_menu/thread_menu.test.tsx +++ b/webapp/channels/src/components/threading/global_threads/thread_menu/thread_menu.test.tsx @@ -27,6 +27,9 @@ jest.mock('mattermost-redux/actions/threads'); jest.mock('actions/views/threads'); jest.mock('actions/post_actions'); jest.mock('utils/utils'); +jest.mock('hooks/useReadout', () => ({ + useReadout: () => jest.fn(), +})); const mockRouting = { params: { diff --git a/webapp/channels/src/components/threading/global_threads/thread_menu/thread_menu.tsx b/webapp/channels/src/components/threading/global_threads/thread_menu/thread_menu.tsx index 5663d488aa..7a5001f78a 100644 --- a/webapp/channels/src/components/threading/global_threads/thread_menu/thread_menu.tsx +++ b/webapp/channels/src/components/threading/global_threads/thread_menu/thread_menu.tsx @@ -20,6 +20,7 @@ import {manuallyMarkThreadAsUnread} from 'actions/views/threads'; import Menu from 'components/widgets/menu/menu'; import MenuWrapper from 'components/widgets/menu/menu_wrapper'; +import {useReadout} from 'hooks/useReadout'; import {getSiteURL} from 'utils/url'; import {copyToClipboard} from 'utils/utils'; @@ -56,8 +57,16 @@ function ThreadMenu({ } = useThreadRouting(); const isSaved = useSelector((state: GlobalState) => isPostFlagged(state, threadId)); + const readAloud = useReadout(); const handleReadUnread = useCallback(() => { + readAloud(hasUnreads ? formatMessage({ + id: 'threading.threadMenu.markedRead', + defaultMessage: 'Marked as read', + }) : formatMessage({ + id: 'threading.threadMenu.markedUnread', + defaultMessage: 'Marked as unread', + })); const lastViewedAt = hasUnreads ? Date.now() : unreadTimestamp; dispatch(manuallyMarkThreadAsUnread(threadId, lastViewedAt)); @@ -109,7 +118,14 @@ function ThreadMenu({ }} onClick={useCallback(() => { dispatch(setThreadFollow(currentUserId, currentTeamId, threadId, !isFollowing)); - }, [currentUserId, currentTeamId, threadId, isFollowing, setThreadFollow])} + readAloud(isFollowing ? formatMessage({ + id: 'threading.threadMenu.unfollowed', + defaultMessage: 'Unfollowed thread', + }) : formatMessage({ + id: 'threading.threadMenu.followed', + defaultMessage: 'Followed thread', + })); + }, [currentUserId, currentTeamId, threadId, isFollowing, setThreadFollow, readAloud, formatMessage])} /> { goToInChannel(threadId); - }, [threadId])} + readAloud(formatMessage({ + id: 'threading.threadMenu.openingChannel', + defaultMessage: 'Opening channel', + })); + }, [threadId, readAloud, formatMessage])} /> { dispatch(isSaved ? unsavePost(threadId) : savePost(threadId)); + readAloud(isSaved ? formatMessage({ + id: 'threading.threadMenu.unsaved', + defaultMessage: 'Unsaved', + }) : formatMessage({ + id: 'threading.threadMenu.saved', + defaultMessage: 'Saved', + })); }, [threadId, isSaved])} /> { copyToClipboard(`${getSiteURL()}/${team}/pl/${threadId}`); + readAloud(formatMessage({ + id: 'threading.threadMenu.linkCopied', + defaultMessage: 'Link copied', + })); }, [team, threadId])} /> diff --git a/webapp/channels/src/hooks/useReadout.ts b/webapp/channels/src/hooks/useReadout.ts new file mode 100644 index 0000000000..76e2fa3f5a --- /dev/null +++ b/webapp/channels/src/hooks/useReadout.ts @@ -0,0 +1,17 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {useCallback} from 'react'; +import {useDispatch} from 'react-redux'; + +import {setReadout} from 'actions/views/root'; + +export const useReadout = () => { + const dispatch = useDispatch(); + + const readAloud = useCallback((message: string) => { + dispatch(setReadout(message)); + }, [dispatch]); + + return readAloud; +}; diff --git a/webapp/channels/src/i18n/en.json b/webapp/channels/src/i18n/en.json index 4925fb28f6..5fb8f77121 100644 --- a/webapp/channels/src/i18n/en.json +++ b/webapp/channels/src/i18n/en.json @@ -5612,16 +5612,24 @@ "threading.threadList.tabsLabel": "Filter visible threads", "threading.threadMenu.copy": "Copy link", "threading.threadMenu.follow": "Follow thread", + "threading.threadMenu.followed": "Followed thread", "threading.threadMenu.followExtra": "You will be notified about replies", "threading.threadMenu.followMessage": "Follow message", + "threading.threadMenu.linkCopied": "Link copied", + "threading.threadMenu.markedRead": "Marked as read", + "threading.threadMenu.markedUnread": "Marked as unread", "threading.threadMenu.markRead": "Mark as read", "threading.threadMenu.markUnread": "Mark as unread", "threading.threadMenu.openInChannel": "Open in channel", + "threading.threadMenu.openingChannel": "Opening channel", "threading.threadMenu.save": "Save", + "threading.threadMenu.saved": "Saved", "threading.threadMenu.unfollow": "Unfollow thread", + "threading.threadMenu.unfollowed": "Unfollowed thread", "threading.threadMenu.unfollowExtra": "You won’t be notified about replies", "threading.threadMenu.unfollowMessage": "Unfollow message", "threading.threadMenu.unsave": "Unsave", + "threading.threadMenu.unsaved": "Unsaved", "three_days_left_trial_modal.learnMore": "Learn more", "three_days_left_trial.modal.ldapDescription": "Use AD/LDAP groups to organize and apply actions to multiple users at once. Manage team and channel memberships, permissions and more.", "three_days_left_trial.modal.ldapTitle": "Synchronize your Active Directory/LDAP groups", diff --git a/webapp/channels/src/reducers/views/index.ts b/webapp/channels/src/reducers/views/index.ts index 22e34dd9e3..ffabe9d099 100644 --- a/webapp/channels/src/reducers/views/index.ts +++ b/webapp/channels/src/reducers/views/index.ts @@ -20,6 +20,7 @@ import notice from './notice'; import onboardingTasks from './onboarding_tasks'; import posts from './posts'; import productMenu from './product_menu'; +import readout from './readout'; import rhs from './rhs'; import rhsSuppressed from './rhs_suppressed'; import search from './search'; @@ -53,4 +54,5 @@ export default combineReducers({ threads, productMenu, drafts, + readout, }); diff --git a/webapp/channels/src/reducers/views/readout.ts b/webapp/channels/src/reducers/views/readout.ts new file mode 100644 index 0000000000..9bc9b9da21 --- /dev/null +++ b/webapp/channels/src/reducers/views/readout.ts @@ -0,0 +1,31 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {ActionTypes} from 'utils/constants'; + +import type {MMAction} from 'types/store'; + +export type ReadoutState = { + message: string | null; +}; + +const initialState: ReadoutState = { + message: null, +}; + +function readout(state: ReadoutState = initialState, action: MMAction): ReadoutState { + switch (action.type) { + case ActionTypes.SET_READOUT: + return { + message: action.data, + }; + case ActionTypes.CLEAR_READOUT: + return { + message: null, + }; + default: + return state; + } +} + +export default readout; diff --git a/webapp/channels/src/types/store/views.ts b/webapp/channels/src/types/store/views.ts index 91b135bb36..31ee04d2a0 100644 --- a/webapp/channels/src/types/store/views.ts +++ b/webapp/channels/src/types/store/views.ts @@ -130,6 +130,10 @@ export type ViewsState = { lhs: LhsViewState; + readout: { + message: string | null; + }; + search: { modalSearch: string; popoverSearch: string; diff --git a/webapp/channels/src/utils/constants.tsx b/webapp/channels/src/utils/constants.tsx index d3dd52f4d3..889800f592 100644 --- a/webapp/channels/src/utils/constants.tsx +++ b/webapp/channels/src/utils/constants.tsx @@ -328,6 +328,9 @@ export const ActionTypes = keyMirror({ SET_ADMIN_CONSOLE_USER_MANAGEMENT_TABLE_PROPERTIES: null, CLEAR_ADMIN_CONSOLE_USER_MANAGEMENT_TABLE_PROPERTIES: null, + + SET_READOUT: 'SET_READOUT', + CLEAR_READOUT: 'CLEAR_READOUT', }); export const PostRequestTypes = keyMirror({