From 18ffb656aba971a1be577312269487ecc23f46f4 Mon Sep 17 00:00:00 2001 From: Devin Binnie <52460000+devinbinnie@users.noreply.github.com> Date: Thu, 22 Aug 2024 11:35:33 -0400 Subject: [PATCH] [MM-58567] Added `window.isActive` check for marking threads as active when they're open, mark the threads as read when the window becomes active again (#27988) * [MM-58567] Added `window.isActive` check for marking threads as active when they're open, mark the threads as read when the window becomes active again * PR feedback --- webapp/channels/src/actions/new_post.ts | 2 +- .../src/actions/views/channel.test.js | 38 +++++++++++++++++-- webapp/channels/src/actions/views/channel.ts | 21 ++++++++-- webapp/channels/src/actions/views/threads.ts | 22 +++++++++++ .../src/actions/websocket_actions.jsx | 2 +- .../src/components/team_controller/index.ts | 6 ++- .../team_controller/team_controller.tsx | 11 ++---- 7 files changed, 83 insertions(+), 19 deletions(-) diff --git a/webapp/channels/src/actions/new_post.ts b/webapp/channels/src/actions/new_post.ts index 68dc631261..c136f56388 100644 --- a/webapp/channels/src/actions/new_post.ts +++ b/webapp/channels/src/actions/new_post.ts @@ -148,7 +148,7 @@ export function setThreadRead(post: Post): ActionFunc { const thread = getThread(state, post.root_id); // mark a thread as read (when the user is viewing the thread) - if (thread && isThreadOpen(state, thread.id)) { + if (thread && isThreadOpen(state, thread.id) && window.isActive) { // update the new messages line (when there are no previous unreads) if (thread.last_reply_at < getThreadLastViewedAt(state, thread.id)) { dispatch(updateThreadLastOpened(thread.id, post.create_at)); diff --git a/webapp/channels/src/actions/views/channel.test.js b/webapp/channels/src/actions/views/channel.test.js index cde0d101e4..8761b50e82 100644 --- a/webapp/channels/src/actions/views/channel.test.js +++ b/webapp/channels/src/actions/views/channel.test.js @@ -8,6 +8,7 @@ import {General, Posts, RequestStatus} from 'mattermost-redux/constants'; import * as Actions from 'actions/views/channel'; import {closeRightHandSide} from 'actions/views/rhs'; +import {markThreadAsRead} from 'actions/views/threads'; import mockStore from 'tests/test_store'; import {getHistory} from 'utils/browser_history'; @@ -39,6 +40,11 @@ jest.mock('actions/views/rhs', () => ({ closeRightHandSide: jest.fn(() => ({type: ''})), })); +jest.mock('actions/views/threads', () => ({ + ...jest.requireActual('actions/views/threads'), + markThreadAsRead: jest.fn(() => ({type: ''})), +})); + jest.mock('mattermost-redux/actions/posts'); jest.mock('selectors/local_storage', () => ({ @@ -110,6 +116,7 @@ describe('channel view actions', () => { rhs: { selectedPostId: '', }, + threads: {}, }, }; @@ -589,11 +596,11 @@ describe('channel view actions', () => { }); }); - describe('markChannelAsReadOnFocus', () => { + describe('markAsReadOnFocus', () => { test('should mark channel as read when channel is not manually unread', async () => { store = mockStore(initialState); - await store.dispatch(Actions.markChannelAsReadOnFocus(channel1.id)); + await store.dispatch(Actions.markAsReadOnFocus()); expect(markChannelAsRead).toHaveBeenCalledWith(channel1.id); }); @@ -612,11 +619,36 @@ describe('channel view actions', () => { }, }); - await store.dispatch(Actions.markChannelAsReadOnFocus(channel1.id)); + await store.dispatch(Actions.markAsReadOnFocus()); expect(markChannelAsRead).not.toHaveBeenCalled(); }); + test('should dispatch markThreadAsRead when threads are selected', async () => { + store = mockStore({ + ...initialState, + views: { + ...initialState.views, + rhs: { + ...initialState.views.rhs, + selectedPostId: 'post_id', + }, + threads: { + ...initialState.views.threads, + selectedThreadIdInTeam: { + teamid1: 'thread_id', + }, + }, + }, + }); + + await store.dispatch(Actions.markAsReadOnFocus()); + + expect(markThreadAsRead).toHaveBeenCalledTimes(2); + expect(markThreadAsRead).toHaveBeenCalledWith('thread_id'); + expect(markThreadAsRead).toHaveBeenCalledWith('post_id'); + }); + test('should match actions for PREFETCH_POSTS_FOR_CHANNEL when prefetch argument and getPostsSince sucess', async () => { const channelId = 'channel1'; PostActions.getPostsSince.mockReturnValue(() => ({data: []})); diff --git a/webapp/channels/src/actions/views/channel.ts b/webapp/channels/src/actions/views/channel.ts index 7efec68449..1e77f28a7f 100644 --- a/webapp/channels/src/actions/views/channel.ts +++ b/webapp/channels/src/actions/views/channel.ts @@ -49,9 +49,11 @@ import EventEmitter from 'mattermost-redux/utils/event_emitter'; import {openDirectChannelToUserId} from 'actions/channel_actions'; import {loadCustomStatusEmojisForPostList} from 'actions/emoji_actions'; import {closeRightHandSide} from 'actions/views/rhs'; +import {markThreadAsRead} from 'actions/views/threads'; import {getLastViewedChannelName} from 'selectors/local_storage'; import {getSelectedPost, getSelectedPostId} from 'selectors/rhs'; import {getLastPostsApiTimeForChannel} from 'selectors/views/channel'; +import {getSelectedThreadIdInCurrentTeam} from 'selectors/views/threads'; import {getSocketStatus} from 'selectors/views/websocket'; import LocalStorageStore from 'stores/local_storage_store'; @@ -493,13 +495,24 @@ export function scrollPostListToBottom() { }; } -export function markChannelAsReadOnFocus(channelId: string): ThunkActionFunc { +export function markAsReadOnFocus(): ThunkActionFunc { return (dispatch, getState) => { - if (isManuallyUnread(getState(), channelId)) { - return; + const state = getState(); + const currentChannelId = getCurrentChannelId(state); + const selectedThreadId = getSelectedThreadIdInCurrentTeam(state); + const selectedPostId = getSelectedPostId(state); + + if (!isManuallyUnread(getState(), currentChannelId)) { + dispatch(markChannelAsRead(currentChannelId)); } - dispatch(markChannelAsRead(channelId)); + if (selectedThreadId) { + dispatch(markThreadAsRead(selectedThreadId)); + } + + if (currentChannelId && selectedPostId) { + dispatch(markThreadAsRead(selectedPostId)); + } }; } diff --git a/webapp/channels/src/actions/views/threads.ts b/webapp/channels/src/actions/views/threads.ts index a5f91f81a4..1200f3da01 100644 --- a/webapp/channels/src/actions/views/threads.ts +++ b/webapp/channels/src/actions/views/threads.ts @@ -3,8 +3,17 @@ import {batchActions} from 'redux-batched-actions'; +import {updateThreadRead} from 'mattermost-redux/actions/threads'; +import {getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams'; +import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users'; +import type {ThunkActionFunc} from 'mattermost-redux/types/actions'; + +import {isThreadManuallyUnread, isThreadOpen} from 'selectors/views/threads'; + import {ActionTypes, Threads} from 'utils/constants'; +import type {GlobalState} from 'types/store'; + export function updateThreadLastOpened(threadId: string, lastViewedAt: number) { return { type: Threads.CHANGED_LAST_VIEWED_AT, @@ -41,3 +50,16 @@ export function updateThreadToastStatus(status: boolean) { data: status, }; } + +export function markThreadAsRead(threadId: string): ThunkActionFunc { + return (dispatch, getState) => { + const state = getState(); + const currentUserId = getCurrentUserId(state); + const currentTeamId = getCurrentTeamId(state); + + if (isThreadOpen(state, threadId) && window.isActive && !isThreadManuallyUnread(state, threadId)) { + // mark thread as read on the server + dispatch(updateThreadRead(currentUserId, currentTeamId, threadId, Date.now())); + } + }; +} diff --git a/webapp/channels/src/actions/websocket_actions.jsx b/webapp/channels/src/actions/websocket_actions.jsx index 84732fdf68..61e04a69aa 100644 --- a/webapp/channels/src/actions/websocket_actions.jsx +++ b/webapp/channels/src/actions/websocket_actions.jsx @@ -1680,7 +1680,7 @@ function handleThreadUpdated(msg) { threadData.is_following = true; } - if (isThreadOpen(state, threadData.id) && !isThreadManuallyUnread(state, threadData.id)) { + if (isThreadOpen(state, threadData.id) && window.isActive && !isThreadManuallyUnread(state, threadData.id)) { lastViewedAt = Date.now(); // Sometimes `Date.now()` was generating a timestamp before the diff --git a/webapp/channels/src/components/team_controller/index.ts b/webapp/channels/src/components/team_controller/index.ts index 886a8ac6a4..1cff6ac154 100644 --- a/webapp/channels/src/components/team_controller/index.ts +++ b/webapp/channels/src/components/team_controller/index.ts @@ -11,7 +11,8 @@ import {getLicense, getConfig} from 'mattermost-redux/selectors/entities/general import {getCurrentTeamId, getMyTeams} from 'mattermost-redux/selectors/entities/teams'; import {getCurrentUser} from 'mattermost-redux/selectors/entities/users'; -import {markChannelAsReadOnFocus} from 'actions/views/channel'; +import {markAsReadOnFocus} from 'actions/views/channel'; +import {getSelectedPostId} from 'selectors/rhs'; import {getSelectedThreadIdInCurrentTeam} from 'selectors/views/threads'; import {initializeTeam, joinTeam} from 'components/team_controller/actions'; @@ -43,6 +44,7 @@ function mapStateToProps(state: GlobalState, ownProps: OwnProps) { teamsList: getMyTeams(state), plugins, selectedThreadId: getSelectedThreadIdInCurrentTeam(state), + selectedPostId: getSelectedPostId(state), mfaRequired: checkIfMFARequired(currentUser, license, config, ownProps.match.url), disableRefetchingOnBrowserFocus, disableWakeUpReconnectHandler, @@ -52,7 +54,7 @@ function mapStateToProps(state: GlobalState, ownProps: OwnProps) { const mapDispatchToProps = { fetchChannelsAndMembers, fetchAllMyTeamsChannelsAndChannelMembersREST, - markChannelAsReadOnFocus, + markAsReadOnFocus, initializeTeam, joinTeam, unsetActiveChannelOnServer, diff --git a/webapp/channels/src/components/team_controller/team_controller.tsx b/webapp/channels/src/components/team_controller/team_controller.tsx index 8dc3113dea..c81e616471 100644 --- a/webapp/channels/src/components/team_controller/team_controller.tsx +++ b/webapp/channels/src/components/team_controller/team_controller.tsx @@ -84,13 +84,8 @@ function TeamController(props: Props) { // Effect runs on mount, add event listeners on windows object useEffect(() => { function handleFocus() { - if (props.selectedThreadId) { - window.isActive = true; - } - if (props.currentChannelId) { - window.isActive = true; - props.markChannelAsReadOnFocus(props.currentChannelId); - } + window.isActive = true; + props.markAsReadOnFocus(); // Temporary flag to disable refetching of channel members on browser focus if (!props.disableRefetchingOnBrowserFocus) { @@ -130,7 +125,7 @@ function TeamController(props: Props) { window.removeEventListener('blur', handleBlur); window.removeEventListener('keydown', handleKeydown); }; - }, [props.selectedThreadId, props.currentChannelId, props.currentTeamId]); + }, [props.currentTeamId]); // Effect runs on mount, adds active state to window useEffect(() => {