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