[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};
|
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 LoggedInRoute from 'components/logged_in_route';
|
||||||
import {LAUNCHING_WORKSPACE_FULLSCREEN_Z_INDEX} from 'components/preparing_workspace/launching_workspace';
|
import {LAUNCHING_WORKSPACE_FULLSCREEN_Z_INDEX} from 'components/preparing_workspace/launching_workspace';
|
||||||
import {Animations} from 'components/preparing_workspace/steps';
|
import {Animations} from 'components/preparing_workspace/steps';
|
||||||
|
import Readout from 'components/readout/readout';
|
||||||
|
|
||||||
import webSocketClient from 'client/web_websocket_client';
|
import webSocketClient from 'client/web_websocket_client';
|
||||||
import {initializePlugins} from 'plugins';
|
import {initializePlugins} from 'plugins';
|
||||||
@@ -582,6 +583,7 @@ export default class Root extends React.PureComponent<Props, State> {
|
|||||||
</div>
|
</div>
|
||||||
<Pluggable pluggableName='Global'/>
|
<Pluggable pluggableName='Global'/>
|
||||||
<AppBar/>
|
<AppBar/>
|
||||||
|
<Readout/>
|
||||||
</CompassThemeProvider>
|
</CompassThemeProvider>
|
||||||
</Switch>
|
</Switch>
|
||||||
</RootProvider>
|
</RootProvider>
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ jest.mock('mattermost-redux/actions/threads');
|
|||||||
jest.mock('actions/views/threads');
|
jest.mock('actions/views/threads');
|
||||||
jest.mock('actions/post_actions');
|
jest.mock('actions/post_actions');
|
||||||
jest.mock('utils/utils');
|
jest.mock('utils/utils');
|
||||||
|
jest.mock('hooks/useReadout', () => ({
|
||||||
|
useReadout: () => jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
const mockRouting = {
|
const mockRouting = {
|
||||||
params: {
|
params: {
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {manuallyMarkThreadAsUnread} from 'actions/views/threads';
|
|||||||
import Menu from 'components/widgets/menu/menu';
|
import Menu from 'components/widgets/menu/menu';
|
||||||
import MenuWrapper from 'components/widgets/menu/menu_wrapper';
|
import MenuWrapper from 'components/widgets/menu/menu_wrapper';
|
||||||
|
|
||||||
|
import {useReadout} from 'hooks/useReadout';
|
||||||
import {getSiteURL} from 'utils/url';
|
import {getSiteURL} from 'utils/url';
|
||||||
import {copyToClipboard} from 'utils/utils';
|
import {copyToClipboard} from 'utils/utils';
|
||||||
|
|
||||||
@@ -56,8 +57,16 @@ function ThreadMenu({
|
|||||||
} = useThreadRouting();
|
} = useThreadRouting();
|
||||||
|
|
||||||
const isSaved = useSelector((state: GlobalState) => isPostFlagged(state, threadId));
|
const isSaved = useSelector((state: GlobalState) => isPostFlagged(state, threadId));
|
||||||
|
const readAloud = useReadout();
|
||||||
|
|
||||||
const handleReadUnread = useCallback(() => {
|
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;
|
const lastViewedAt = hasUnreads ? Date.now() : unreadTimestamp;
|
||||||
|
|
||||||
dispatch(manuallyMarkThreadAsUnread(threadId, lastViewedAt));
|
dispatch(manuallyMarkThreadAsUnread(threadId, lastViewedAt));
|
||||||
@@ -109,7 +118,14 @@ function ThreadMenu({
|
|||||||
}}
|
}}
|
||||||
onClick={useCallback(() => {
|
onClick={useCallback(() => {
|
||||||
dispatch(setThreadFollow(currentUserId, currentTeamId, threadId, !isFollowing));
|
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
|
<Menu.ItemAction
|
||||||
text={formatMessage({
|
text={formatMessage({
|
||||||
@@ -118,7 +134,11 @@ function ThreadMenu({
|
|||||||
})}
|
})}
|
||||||
onClick={useCallback(() => {
|
onClick={useCallback(() => {
|
||||||
goToInChannel(threadId);
|
goToInChannel(threadId);
|
||||||
}, [threadId])}
|
readAloud(formatMessage({
|
||||||
|
id: 'threading.threadMenu.openingChannel',
|
||||||
|
defaultMessage: 'Opening channel',
|
||||||
|
}));
|
||||||
|
}, [threadId, readAloud, formatMessage])}
|
||||||
/>
|
/>
|
||||||
<Menu.ItemAction
|
<Menu.ItemAction
|
||||||
text={hasUnreads ? formatMessage({
|
text={hasUnreads ? formatMessage({
|
||||||
@@ -141,6 +161,13 @@ function ThreadMenu({
|
|||||||
})}
|
})}
|
||||||
onClick={useCallback(() => {
|
onClick={useCallback(() => {
|
||||||
dispatch(isSaved ? unsavePost(threadId) : savePost(threadId));
|
dispatch(isSaved ? unsavePost(threadId) : savePost(threadId));
|
||||||
|
readAloud(isSaved ? formatMessage({
|
||||||
|
id: 'threading.threadMenu.unsaved',
|
||||||
|
defaultMessage: 'Unsaved',
|
||||||
|
}) : formatMessage({
|
||||||
|
id: 'threading.threadMenu.saved',
|
||||||
|
defaultMessage: 'Saved',
|
||||||
|
}));
|
||||||
}, [threadId, isSaved])}
|
}, [threadId, isSaved])}
|
||||||
/>
|
/>
|
||||||
<Menu.ItemAction
|
<Menu.ItemAction
|
||||||
@@ -150,6 +177,10 @@ function ThreadMenu({
|
|||||||
})}
|
})}
|
||||||
onClick={useCallback(() => {
|
onClick={useCallback(() => {
|
||||||
copyToClipboard(`${getSiteURL()}/${team}/pl/${threadId}`);
|
copyToClipboard(`${getSiteURL()}/${team}/pl/${threadId}`);
|
||||||
|
readAloud(formatMessage({
|
||||||
|
id: 'threading.threadMenu.linkCopied',
|
||||||
|
defaultMessage: 'Link copied',
|
||||||
|
}));
|
||||||
}, [team, threadId])}
|
}, [team, threadId])}
|
||||||
/>
|
/>
|
||||||
</Menu>
|
</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.threadList.tabsLabel": "Filter visible threads",
|
||||||
"threading.threadMenu.copy": "Copy link",
|
"threading.threadMenu.copy": "Copy link",
|
||||||
"threading.threadMenu.follow": "Follow thread",
|
"threading.threadMenu.follow": "Follow thread",
|
||||||
|
"threading.threadMenu.followed": "Followed thread",
|
||||||
"threading.threadMenu.followExtra": "You will be notified about replies",
|
"threading.threadMenu.followExtra": "You will be notified about replies",
|
||||||
"threading.threadMenu.followMessage": "Follow message",
|
"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.markRead": "Mark as read",
|
||||||
"threading.threadMenu.markUnread": "Mark as unread",
|
"threading.threadMenu.markUnread": "Mark as unread",
|
||||||
"threading.threadMenu.openInChannel": "Open in channel",
|
"threading.threadMenu.openInChannel": "Open in channel",
|
||||||
|
"threading.threadMenu.openingChannel": "Opening channel",
|
||||||
"threading.threadMenu.save": "Save",
|
"threading.threadMenu.save": "Save",
|
||||||
|
"threading.threadMenu.saved": "Saved",
|
||||||
"threading.threadMenu.unfollow": "Unfollow thread",
|
"threading.threadMenu.unfollow": "Unfollow thread",
|
||||||
|
"threading.threadMenu.unfollowed": "Unfollowed thread",
|
||||||
"threading.threadMenu.unfollowExtra": "You won’t be notified about replies",
|
"threading.threadMenu.unfollowExtra": "You won’t be notified about replies",
|
||||||
"threading.threadMenu.unfollowMessage": "Unfollow message",
|
"threading.threadMenu.unfollowMessage": "Unfollow message",
|
||||||
"threading.threadMenu.unsave": "Unsave",
|
"threading.threadMenu.unsave": "Unsave",
|
||||||
|
"threading.threadMenu.unsaved": "Unsaved",
|
||||||
"three_days_left_trial_modal.learnMore": "Learn more",
|
"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.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",
|
"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 onboardingTasks from './onboarding_tasks';
|
||||||
import posts from './posts';
|
import posts from './posts';
|
||||||
import productMenu from './product_menu';
|
import productMenu from './product_menu';
|
||||||
|
import readout from './readout';
|
||||||
import rhs from './rhs';
|
import rhs from './rhs';
|
||||||
import rhsSuppressed from './rhs_suppressed';
|
import rhsSuppressed from './rhs_suppressed';
|
||||||
import search from './search';
|
import search from './search';
|
||||||
@@ -53,4 +54,5 @@ export default combineReducers({
|
|||||||
threads,
|
threads,
|
||||||
productMenu,
|
productMenu,
|
||||||
drafts,
|
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;
|
lhs: LhsViewState;
|
||||||
|
|
||||||
|
readout: {
|
||||||
|
message: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
search: {
|
search: {
|
||||||
modalSearch: string;
|
modalSearch: string;
|
||||||
popoverSearch: string;
|
popoverSearch: string;
|
||||||
|
|||||||
@@ -328,6 +328,9 @@ export const ActionTypes = keyMirror({
|
|||||||
|
|
||||||
SET_ADMIN_CONSOLE_USER_MANAGEMENT_TABLE_PROPERTIES: null,
|
SET_ADMIN_CONSOLE_USER_MANAGEMENT_TABLE_PROPERTIES: null,
|
||||||
CLEAR_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({
|
export const PostRequestTypes = keyMirror({
|
||||||
|
|||||||
Ссылка в новой задаче
Block a user