[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>
Этот коммит содержится в:
коммит произвёл
GitHub
родитель
926c7f1e49
Коммит
07edaa875b
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
36
webapp/channels/src/components/readout/readout.test.tsx
Обычный файл
36
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(<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();
|
||||
});
|
||||
});
|
||||
43
webapp/channels/src/components/readout/readout.tsx
Обычный файл
43
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 (
|
||||
<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
Обычный файл
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 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",
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
31
webapp/channels/src/reducers/views/readout.ts
Обычный файл
31
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;
|
||||
@@ -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({
|
||||
|
||||
Ссылка в новой задаче
Block a user