[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 <build@mattermost.com>
Этот коммит содержится в:
Devin Binnie
2025-06-12 17:20:45 -04:00
коммит произвёл GitHub
родитель 926c7f1e49
Коммит 07edaa875b
12 изменённых файлов: 189 добавлений и 2 удалений

Просмотреть файл

@@ -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,
};
}

Просмотреть файл

@@ -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(<Readout/>, {
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();
});
});

Просмотреть файл

@@ -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 (
<div
className='sr-only'
role='status'
aria-live='polite'
aria-atomic='true'
>
{message}
</div>
);
};
export default Readout;

Просмотреть файл

@@ -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<Props, State> {
</div>
<Pluggable pluggableName='Global'/>
<AppBar/>
<Readout/>
</CompassThemeProvider>
</Switch>
</RootProvider>

Просмотреть файл

@@ -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: {

Просмотреть файл

@@ -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])}
/>
<Menu.ItemAction
text={formatMessage({
@@ -118,7 +134,11 @@ function ThreadMenu({
})}
onClick={useCallback(() => {
goToInChannel(threadId);
}, [threadId])}
readAloud(formatMessage({
id: 'threading.threadMenu.openingChannel',
defaultMessage: 'Opening channel',
}));
}, [threadId, readAloud, formatMessage])}
/>
<Menu.ItemAction
text={hasUnreads ? formatMessage({
@@ -141,6 +161,13 @@ function ThreadMenu({
})}
onClick={useCallback(() => {
dispatch(isSaved ? unsavePost(threadId) : savePost(threadId));
readAloud(isSaved ? formatMessage({
id: 'threading.threadMenu.unsaved',
defaultMessage: 'Unsaved',
}) : formatMessage({
id: 'threading.threadMenu.saved',
defaultMessage: 'Saved',
}));
}, [threadId, isSaved])}
/>
<Menu.ItemAction
@@ -150,6 +177,10 @@ function ThreadMenu({
})}
onClick={useCallback(() => {
copyToClipboard(`${getSiteURL()}/${team}/pl/${threadId}`);
readAloud(formatMessage({
id: 'threading.threadMenu.linkCopied',
defaultMessage: 'Link copied',
}));
}, [team, threadId])}
/>
</Menu>

17
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;
};

Просмотреть файл

@@ -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 wont 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",

Просмотреть файл

@@ -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,
});

Просмотреть файл

@@ -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;

Просмотреть файл

@@ -130,6 +130,10 @@ export type ViewsState = {
lhs: LhsViewState;
readout: {
message: string | null;
};
search: {
modalSearch: string;
popoverSearch: string;

Просмотреть файл

@@ -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({