[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
Этот коммит содержится в:
Devin Binnie
2024-08-22 11:35:33 -04:00
коммит произвёл GitHub
родитель 5482792697
Коммит 18ffb656ab
7 изменённых файлов: 83 добавлений и 19 удалений

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

@@ -148,7 +148,7 @@ export function setThreadRead(post: Post): ActionFunc<boolean, GlobalState> {
const thread = getThread(state, post.root_id); const thread = getThread(state, post.root_id);
// mark a thread as read (when the user is viewing the thread) // 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) // update the new messages line (when there are no previous unreads)
if (thread.last_reply_at < getThreadLastViewedAt(state, thread.id)) { if (thread.last_reply_at < getThreadLastViewedAt(state, thread.id)) {
dispatch(updateThreadLastOpened(thread.id, post.create_at)); dispatch(updateThreadLastOpened(thread.id, post.create_at));

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

@@ -8,6 +8,7 @@ import {General, Posts, RequestStatus} from 'mattermost-redux/constants';
import * as Actions from 'actions/views/channel'; import * as Actions from 'actions/views/channel';
import {closeRightHandSide} from 'actions/views/rhs'; import {closeRightHandSide} from 'actions/views/rhs';
import {markThreadAsRead} from 'actions/views/threads';
import mockStore from 'tests/test_store'; import mockStore from 'tests/test_store';
import {getHistory} from 'utils/browser_history'; import {getHistory} from 'utils/browser_history';
@@ -39,6 +40,11 @@ jest.mock('actions/views/rhs', () => ({
closeRightHandSide: jest.fn(() => ({type: ''})), 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('mattermost-redux/actions/posts');
jest.mock('selectors/local_storage', () => ({ jest.mock('selectors/local_storage', () => ({
@@ -110,6 +116,7 @@ describe('channel view actions', () => {
rhs: { rhs: {
selectedPostId: '', 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 () => { test('should mark channel as read when channel is not manually unread', async () => {
store = mockStore(initialState); store = mockStore(initialState);
await store.dispatch(Actions.markChannelAsReadOnFocus(channel1.id)); await store.dispatch(Actions.markAsReadOnFocus());
expect(markChannelAsRead).toHaveBeenCalledWith(channel1.id); 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(); 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 () => { test('should match actions for PREFETCH_POSTS_FOR_CHANNEL when prefetch argument and getPostsSince sucess', async () => {
const channelId = 'channel1'; const channelId = 'channel1';
PostActions.getPostsSince.mockReturnValue(() => ({data: []})); PostActions.getPostsSince.mockReturnValue(() => ({data: []}));

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

@@ -49,9 +49,11 @@ import EventEmitter from 'mattermost-redux/utils/event_emitter';
import {openDirectChannelToUserId} from 'actions/channel_actions'; import {openDirectChannelToUserId} from 'actions/channel_actions';
import {loadCustomStatusEmojisForPostList} from 'actions/emoji_actions'; import {loadCustomStatusEmojisForPostList} from 'actions/emoji_actions';
import {closeRightHandSide} from 'actions/views/rhs'; import {closeRightHandSide} from 'actions/views/rhs';
import {markThreadAsRead} from 'actions/views/threads';
import {getLastViewedChannelName} from 'selectors/local_storage'; import {getLastViewedChannelName} from 'selectors/local_storage';
import {getSelectedPost, getSelectedPostId} from 'selectors/rhs'; import {getSelectedPost, getSelectedPostId} from 'selectors/rhs';
import {getLastPostsApiTimeForChannel} from 'selectors/views/channel'; import {getLastPostsApiTimeForChannel} from 'selectors/views/channel';
import {getSelectedThreadIdInCurrentTeam} from 'selectors/views/threads';
import {getSocketStatus} from 'selectors/views/websocket'; import {getSocketStatus} from 'selectors/views/websocket';
import LocalStorageStore from 'stores/local_storage_store'; import LocalStorageStore from 'stores/local_storage_store';
@@ -493,13 +495,24 @@ export function scrollPostListToBottom() {
}; };
} }
export function markChannelAsReadOnFocus(channelId: string): ThunkActionFunc<void> { export function markAsReadOnFocus(): ThunkActionFunc<void, GlobalState> {
return (dispatch, getState) => { return (dispatch, getState) => {
if (isManuallyUnread(getState(), channelId)) { const state = getState();
return; 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));
}
}; };
} }

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

@@ -3,8 +3,17 @@
import {batchActions} from 'redux-batched-actions'; 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 {ActionTypes, Threads} from 'utils/constants';
import type {GlobalState} from 'types/store';
export function updateThreadLastOpened(threadId: string, lastViewedAt: number) { export function updateThreadLastOpened(threadId: string, lastViewedAt: number) {
return { return {
type: Threads.CHANGED_LAST_VIEWED_AT, type: Threads.CHANGED_LAST_VIEWED_AT,
@@ -41,3 +50,16 @@ export function updateThreadToastStatus(status: boolean) {
data: status, data: status,
}; };
} }
export function markThreadAsRead(threadId: string): ThunkActionFunc<void, GlobalState> {
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()));
}
};
}

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

@@ -1680,7 +1680,7 @@ function handleThreadUpdated(msg) {
threadData.is_following = true; 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(); lastViewedAt = Date.now();
// Sometimes `Date.now()` was generating a timestamp before the // Sometimes `Date.now()` was generating a timestamp before the

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

@@ -11,7 +11,8 @@ import {getLicense, getConfig} from 'mattermost-redux/selectors/entities/general
import {getCurrentTeamId, getMyTeams} from 'mattermost-redux/selectors/entities/teams'; import {getCurrentTeamId, getMyTeams} from 'mattermost-redux/selectors/entities/teams';
import {getCurrentUser} from 'mattermost-redux/selectors/entities/users'; 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 {getSelectedThreadIdInCurrentTeam} from 'selectors/views/threads';
import {initializeTeam, joinTeam} from 'components/team_controller/actions'; import {initializeTeam, joinTeam} from 'components/team_controller/actions';
@@ -43,6 +44,7 @@ function mapStateToProps(state: GlobalState, ownProps: OwnProps) {
teamsList: getMyTeams(state), teamsList: getMyTeams(state),
plugins, plugins,
selectedThreadId: getSelectedThreadIdInCurrentTeam(state), selectedThreadId: getSelectedThreadIdInCurrentTeam(state),
selectedPostId: getSelectedPostId(state),
mfaRequired: checkIfMFARequired(currentUser, license, config, ownProps.match.url), mfaRequired: checkIfMFARequired(currentUser, license, config, ownProps.match.url),
disableRefetchingOnBrowserFocus, disableRefetchingOnBrowserFocus,
disableWakeUpReconnectHandler, disableWakeUpReconnectHandler,
@@ -52,7 +54,7 @@ function mapStateToProps(state: GlobalState, ownProps: OwnProps) {
const mapDispatchToProps = { const mapDispatchToProps = {
fetchChannelsAndMembers, fetchChannelsAndMembers,
fetchAllMyTeamsChannelsAndChannelMembersREST, fetchAllMyTeamsChannelsAndChannelMembersREST,
markChannelAsReadOnFocus, markAsReadOnFocus,
initializeTeam, initializeTeam,
joinTeam, joinTeam,
unsetActiveChannelOnServer, unsetActiveChannelOnServer,

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

@@ -84,13 +84,8 @@ function TeamController(props: Props) {
// Effect runs on mount, add event listeners on windows object // Effect runs on mount, add event listeners on windows object
useEffect(() => { useEffect(() => {
function handleFocus() { function handleFocus() {
if (props.selectedThreadId) { window.isActive = true;
window.isActive = true; props.markAsReadOnFocus();
}
if (props.currentChannelId) {
window.isActive = true;
props.markChannelAsReadOnFocus(props.currentChannelId);
}
// Temporary flag to disable refetching of channel members on browser focus // Temporary flag to disable refetching of channel members on browser focus
if (!props.disableRefetchingOnBrowserFocus) { if (!props.disableRefetchingOnBrowserFocus) {
@@ -130,7 +125,7 @@ function TeamController(props: Props) {
window.removeEventListener('blur', handleBlur); window.removeEventListener('blur', handleBlur);
window.removeEventListener('keydown', handleKeydown); window.removeEventListener('keydown', handleKeydown);
}; };
}, [props.selectedThreadId, props.currentChannelId, props.currentTeamId]); }, [props.currentTeamId]);
// Effect runs on mount, adds active state to window // Effect runs on mount, adds active state to window
useEffect(() => { useEffect(() => {