From 273a9991673635faeeda357969b2ac161442e333 Mon Sep 17 00:00:00 2001 From: M-ZubairAhmed Date: Mon, 27 May 2024 12:34:38 +0000 Subject: [PATCH] [MM-57384] Investigate app performance on repeated calls to `users/status/ids` and `users/ids` calls on posted event (#26644) https://mattermost.atlassian.net/browse/MM-57384 https://mattermost.atlassian.net/browse/MM-58109 https://mattermost.atlassian.net/browse/MM-58110 --- server/config/client.go | 1 + server/public/model/config.go | 28 +- .../src/actions/status_actions.test.ts | 22 +- webapp/channels/src/actions/status_actions.ts | 35 ++- .../src/actions/websocket_actions.jsx | 20 +- .../src/actions/websocket_actions.test.jsx | 30 +- .../admin_console/admin_definition.tsx | 9 + .../channel_controller.test.tsx | 10 +- .../channel_layout/channel_controller.tsx | 11 +- .../team_controller/actions/index.ts | 7 +- webapp/channels/src/i18n/en.json | 2 + .../src/action_types/posts.ts | 1 - .../src/actions/posts.test.ts | 17 +- .../mattermost-redux/src/actions/posts.ts | 83 +++-- .../src/actions/status_profile_polling.ts | 289 ++++++++++++++++++ .../mattermost-redux/src/actions/users.ts | 16 +- .../src/selectors/entities/general.ts | 13 + webapp/platform/types/src/config.ts | 1 + 18 files changed, 469 insertions(+), 126 deletions(-) create mode 100644 webapp/channels/src/packages/mattermost-redux/src/actions/status_profile_polling.ts diff --git a/server/config/client.go b/server/config/client.go index 8d9372b6dd..dc8517de03 100644 --- a/server/config/client.go +++ b/server/config/client.go @@ -97,6 +97,7 @@ func GenerateClientConfig(c *model.Config, telemetryID string, license *model.Li props["DisableRefetchingOnBrowserFocus"] = strconv.FormatBool(*c.ExperimentalSettings.DisableRefetchingOnBrowserFocus) props["DisableWakeUpReconnectHandler"] = strconv.FormatBool(*c.ExperimentalSettings.DisableWakeUpReconnectHandler) + props["UsersStatusAndProfileFetchingPollIntervalMilliseconds"] = strconv.FormatInt(*c.ExperimentalSettings.UsersStatusAndProfileFetchingPollIntervalMilliseconds, 10) // Set default values for all options that require a license. props["ExperimentalEnableAuthenticationTransfer"] = "true" diff --git a/server/public/model/config.go b/server/public/model/config.go index 8be987db12..1d96227e45 100644 --- a/server/public/model/config.go +++ b/server/public/model/config.go @@ -178,7 +178,8 @@ const ( NativeappSettingsDefaultAndroidAppDownloadLink = "https://mattermost.com/pl/android-app/" NativeappSettingsDefaultIosAppDownloadLink = "https://mattermost.com/pl/ios-app/" - ExperimentalSettingsDefaultLinkMetadataTimeoutMilliseconds = 5000 + ExperimentalSettingsDefaultLinkMetadataTimeoutMilliseconds = 5000 + ExperimentalSettingsDefaultUsersStatusAndProfileFetchingPollIntervalMilliseconds = 3000 AnalyticsSettingsDefaultMaxUsersForStatistics = 2500 @@ -1010,16 +1011,17 @@ func (s *MetricsSettings) SetDefaults() { } type ExperimentalSettings struct { - ClientSideCertEnable *bool `access:"experimental_features,cloud_restrictable"` - ClientSideCertCheck *string `access:"experimental_features,cloud_restrictable"` - LinkMetadataTimeoutMilliseconds *int64 `access:"experimental_features,write_restrictable,cloud_restrictable"` - RestrictSystemAdmin *bool `access:"experimental_features,write_restrictable"` - EnableSharedChannels *bool `access:"experimental_features"` - EnableRemoteClusterService *bool `access:"experimental_features"` - DisableAppBar *bool `access:"experimental_features"` - DisableRefetchingOnBrowserFocus *bool `access:"experimental_features"` - DelayChannelAutocomplete *bool `access:"experimental_features"` - DisableWakeUpReconnectHandler *bool `access:"experimental_features"` + ClientSideCertEnable *bool `access:"experimental_features,cloud_restrictable"` + ClientSideCertCheck *string `access:"experimental_features,cloud_restrictable"` + LinkMetadataTimeoutMilliseconds *int64 `access:"experimental_features,write_restrictable,cloud_restrictable"` + RestrictSystemAdmin *bool `access:"experimental_features,write_restrictable"` + EnableSharedChannels *bool `access:"experimental_features"` + EnableRemoteClusterService *bool `access:"experimental_features"` + DisableAppBar *bool `access:"experimental_features"` + DisableRefetchingOnBrowserFocus *bool `access:"experimental_features"` + DelayChannelAutocomplete *bool `access:"experimental_features"` + DisableWakeUpReconnectHandler *bool `access:"experimental_features"` + UsersStatusAndProfileFetchingPollIntervalMilliseconds *int64 `access:"experimental_features"` } func (s *ExperimentalSettings) SetDefaults() { @@ -1062,6 +1064,10 @@ func (s *ExperimentalSettings) SetDefaults() { if s.DisableWakeUpReconnectHandler == nil { s.DisableWakeUpReconnectHandler = NewBool(false) } + + if s.UsersStatusAndProfileFetchingPollIntervalMilliseconds == nil { + s.UsersStatusAndProfileFetchingPollIntervalMilliseconds = NewInt64(ExperimentalSettingsDefaultUsersStatusAndProfileFetchingPollIntervalMilliseconds) + } } type AnalyticsSettings struct { diff --git a/webapp/channels/src/actions/status_actions.test.ts b/webapp/channels/src/actions/status_actions.test.ts index 0718ae4cb7..d041685f95 100644 --- a/webapp/channels/src/actions/status_actions.test.ts +++ b/webapp/channels/src/actions/status_actions.test.ts @@ -5,6 +5,7 @@ import cloneDeep from 'lodash/cloneDeep'; import type {UserProfile} from '@mattermost/types/users'; +import {addUserIdsForStatusAndProfileFetchingPoll} from 'mattermost-redux/actions/status_profile_polling'; import {getStatusesByIds} from 'mattermost-redux/actions/users'; import {Preferences} from 'mattermost-redux/constants'; @@ -18,6 +19,12 @@ jest.mock('mattermost-redux/actions/users', () => ({ }), })); +jest.mock('mattermost-redux/actions/status_profile_polling', () => ({ + addUserIdsForStatusAndProfileFetchingPoll: jest.fn(() => { + return {type: ''}; + }), +})); + interface CustomMatchers { arrayContainingExactly(stringArray: string[]): R; } @@ -76,20 +83,21 @@ describe('actions/status_actions', () => { }, }; - describe('loadStatusesForChannelAndSidebar', () => { + describe('addUserIdsForStatusAndProfileFetchingPoll', () => { test('load statuses with posts in channel and user in sidebar', () => { const state = cloneDeep(initialState); const testStore = mockStore(state); - testStore.dispatch(Actions.loadStatusesForChannelAndSidebar()); - expect(getStatusesByIds).toHaveBeenCalledWith((expect as GreatExpectations).arrayContainingExactly(['current_user_id', 'user_id2', 'user_id3'])); + testStore.dispatch(Actions.addVisibleUsersInCurrentChannelToStatusPoll()); + expect(addUserIdsForStatusAndProfileFetchingPoll).toHaveBeenCalled(); + expect(addUserIdsForStatusAndProfileFetchingPoll).toHaveBeenCalledWith({userIdsForStatus: ['user_id2', 'user_id3']}); }); test('load statuses with empty channel and user in sidebar', () => { const state = cloneDeep(initialState); state.entities.channels.currentChannelId = 'channel_id2'; const testStore = mockStore(state); - testStore.dispatch(Actions.loadStatusesForChannelAndSidebar()); - expect(getStatusesByIds).toHaveBeenCalledWith((expect as GreatExpectations).arrayContainingExactly(['current_user_id', 'user_id3'])); + testStore.dispatch(Actions.addVisibleUsersInCurrentChannelToStatusPoll()); + expect(addUserIdsForStatusAndProfileFetchingPoll).toHaveBeenCalledWith({userIdsForStatus: ['user_id3']}); }); test('load statuses with empty channel and no users in sidebar', () => { @@ -97,8 +105,8 @@ describe('actions/status_actions', () => { state.entities.channels.currentChannelId = 'channel_id2'; state.entities.preferences.myPreferences = {}; const testStore = mockStore(state); - testStore.dispatch(Actions.loadStatusesForChannelAndSidebar()); - expect(getStatusesByIds).toHaveBeenCalledWith((expect as GreatExpectations).arrayContainingExactly(['current_user_id'])); + testStore.dispatch(Actions.addVisibleUsersInCurrentChannelToStatusPoll()); + expect(addUserIdsForStatusAndProfileFetchingPoll).not.toHaveBeenCalled(); }); }); diff --git a/webapp/channels/src/actions/status_actions.ts b/webapp/channels/src/actions/status_actions.ts index 82e024285d..4835421101 100644 --- a/webapp/channels/src/actions/status_actions.ts +++ b/webapp/channels/src/actions/status_actions.ts @@ -3,6 +3,7 @@ import type {UserProfile} from '@mattermost/types/users'; +import {addUserIdsForStatusAndProfileFetchingPoll} from 'mattermost-redux/actions/status_profile_polling'; import {getStatusesByIds} from 'mattermost-redux/actions/users'; import {getCurrentChannelId} from 'mattermost-redux/selectors/entities/channels'; import {getIsUserStatusesConfigEnabled} from 'mattermost-redux/selectors/entities/common'; @@ -15,36 +16,44 @@ import {loadCustomEmojisForCustomStatusesByUserIds} from 'actions/emoji_actions' import type {GlobalState} from 'types/store'; -export function loadStatusesForChannelAndSidebar(): ActionFunc { +/** + * Adds all the visible users of the current channel i.e users who have recently posted in the current channel + * and users who have DMs open with the current user to the status pool for fetching their statuses. + */ +export function addVisibleUsersInCurrentChannelToStatusPoll(): ActionFunc { return (dispatch, getState) => { const state = getState(); - - const channelId = getCurrentChannelId(state); + const currentUserId = getCurrentUserId(state); + const currentChannelId = getCurrentChannelId(state); const postsInChannel = getPostsInCurrentChannel(state); + const numberOfPostsVisibleInCurrentChannel = state.views.channel.postVisibility[currentChannelId] || 0; - const userIds = new Set(); + const userIdsToFetchStatusFor = new Set(); - if (postsInChannel) { - const posts = postsInChannel.slice(0, state.views.channel.postVisibility[channelId] || 0); + // We fetch for users who have recently posted in the current channel + if (postsInChannel && numberOfPostsVisibleInCurrentChannel > 0) { + const posts = postsInChannel.slice(0, numberOfPostsVisibleInCurrentChannel); for (const post of posts) { - if (post.user_id) { - userIds.add(post.user_id); + if (post.user_id && post.user_id !== currentUserId) { + userIdsToFetchStatusFor.add(post.user_id); } } } + // We also fetch for users who have DMs open with the current user const directShowPreferences = getDirectShowPreferences(state); for (const directShowPreference of directShowPreferences) { if (directShowPreference.value === 'true') { // This is the other user's id in the DM - userIds.add(directShowPreference.name); + userIdsToFetchStatusFor.add(directShowPreference.name); } } - const currentUserId = getCurrentUserId(state); - userIds.add(currentUserId); - - dispatch(loadStatusesByIds(Array.from(userIds))); + // Both the users in the DM list and recent posts constitute for all the visible users in the current channel + const userIdsForStatus = Array.from(userIdsToFetchStatusFor); + if (userIdsForStatus.length > 0) { + dispatch(addUserIdsForStatusAndProfileFetchingPoll({userIdsForStatus})); + } return {data: true}; }; diff --git a/webapp/channels/src/actions/websocket_actions.jsx b/webapp/channels/src/actions/websocket_actions.jsx index 27f779e547..faaa160fce 100644 --- a/webapp/channels/src/actions/websocket_actions.jsx +++ b/webapp/channels/src/actions/websocket_actions.jsx @@ -39,13 +39,13 @@ import { getCustomEmojiForReaction, getPosts, getPostThread, - getMentionsAndStatusesForPosts, getPostThreads, postDeleted, receivedNewPost, receivedPost, } from 'mattermost-redux/actions/posts'; import {loadRolesIfNeeded} from 'mattermost-redux/actions/roles'; +import {batchFetchStatusesProfilesGroupsFromPosts} from 'mattermost-redux/actions/status_profile_polling'; import * as TeamActions from 'mattermost-redux/actions/teams'; import { getThread as fetchThread, @@ -74,6 +74,7 @@ import { getCurrentChannelId, getRedirectChannelNameForTeam, } from 'mattermost-redux/selectors/entities/channels'; +import {getIsUserStatusesConfigEnabled} from 'mattermost-redux/selectors/entities/common'; import {getConfig, getLicense} from 'mattermost-redux/selectors/entities/general'; import {getGroup} from 'mattermost-redux/selectors/entities/groups'; import {getPost, getMostRecentPostIdInChannel, getTeamIdFromPost} from 'mattermost-redux/selectors/entities/posts'; @@ -241,7 +242,11 @@ export function reconnect() { // we can request for getPosts again when socket is connected dispatch(getPosts(currentChannelId)); } - dispatch(StatusActions.loadStatusesForChannelAndSidebar()); + + const enabledUserStatuses = getIsUserStatusesConfigEnabled(state); + if (enabledUserStatuses) { + dispatch(StatusActions.addVisibleUsersInCurrentChannelToStatusPoll()); + } const crtEnabled = isCollapsedThreadsEnabled(state); dispatch(TeamActions.getMyTeamUnreads(crtEnabled, true)); @@ -702,8 +707,7 @@ export function handleNewPostEvent(msg) { } myDispatch(handleNewPost(post, msg)); - - getMentionsAndStatusesForPosts([post], myDispatch, myGetState); + myDispatch(batchFetchStatusesProfilesGroupsFromPosts([post])); // Since status updates aren't real time, assume another user is online if they have posted and: // 1. The user hasn't set their status manually to something that isn't online @@ -746,9 +750,7 @@ export function handleNewPostEvents(queue) { // Load the posts' threads myDispatch(getPostThreads(posts)); - - // And any other data needed for them - getMentionsAndStatusesForPosts(posts, myDispatch, myGetState); + myDispatch(batchFetchStatusesProfilesGroupsFromPosts(posts)); }; } @@ -764,7 +766,7 @@ export function handlePostEditEvent(msg) { const crtEnabled = isCollapsedThreadsEnabled(getState()); dispatch(receivedPost(post, crtEnabled)); - getMentionsAndStatusesForPosts([post], dispatch, getState); + dispatch(batchFetchStatusesProfilesGroupsFromPosts([post])); } async function handlePostDeleteEvent(msg) { @@ -1141,7 +1143,7 @@ export async function handleUserUpdatedEvent(msg) { if (currentUser.id === user.id) { if (user.update_at > currentUser.update_at) { - // update user to unsanitized user data recieved from websocket message + // update user to unsanitized user data received from websocket message dispatch({ type: UserTypes.RECEIVED_ME, data: user, diff --git a/webapp/channels/src/actions/websocket_actions.test.jsx b/webapp/channels/src/actions/websocket_actions.test.jsx index 651ddff497..5b3485f3ad 100644 --- a/webapp/channels/src/actions/websocket_actions.test.jsx +++ b/webapp/channels/src/actions/websocket_actions.test.jsx @@ -4,10 +4,10 @@ import {UserTypes, CloudTypes} from 'mattermost-redux/action_types'; import {getGroup} from 'mattermost-redux/actions/groups'; import { - getMentionsAndStatusesForPosts, getPostThreads, receivedNewPost, } from 'mattermost-redux/actions/posts'; +import {batchFetchStatusesProfilesGroupsFromPosts} from 'mattermost-redux/actions/status_profile_polling'; import {getUser} from 'mattermost-redux/actions/users'; import {handleNewPost} from 'actions/post_actions'; @@ -44,6 +44,11 @@ jest.mock('mattermost-redux/actions/posts', () => ({ getMentionsAndStatusesForPosts: jest.fn(), })); +jest.mock('mattermost-redux/actions/status_profile_polling', () => ({ + ...jest.requireActual('mattermost-redux/actions/status_profile_polling'), + batchFetchStatusesProfilesGroupsFromPosts: jest.fn(() => ({type: ''})), +})); + jest.mock('mattermost-redux/actions/groups', () => ({ ...jest.requireActual('mattermost-redux/actions/groups'), getGroup: jest.fn(() => ({type: 'RECEIVED_GROUP'})), @@ -488,8 +493,8 @@ describe('handleNewPostEvent', () => { }; testStore.dispatch(handleNewPostEvent(msg)); - expect(getMentionsAndStatusesForPosts).toHaveBeenCalledWith([post], expect.anything(), expect.anything()); expect(handleNewPost).toHaveBeenCalledWith(post, msg); + expect(batchFetchStatusesProfilesGroupsFromPosts).toHaveBeenCalledWith([post]); }); test('should set other user to online', () => { @@ -609,18 +614,17 @@ describe('handleNewPostEvents', () => { testStore.dispatch(handleNewPostEvents(queue)); - expect(testStore.getActions()).toEqual([ - { - meta: {batch: true}, - payload: posts.map((post) => receivedNewPost(post, false)), - type: 'BATCHING_REDUCER.BATCH', - }, - { - type: 'GET_THREADS_FOR_POSTS', - }, - ]); + expect(testStore.getActions()[0]).toEqual({ + type: 'BATCHING_REDUCER.BATCH', + meta: {batch: true}, + payload: posts.map((post) => receivedNewPost(post, false)), + }); + expect(testStore.getActions()[1]).toEqual({ + type: 'GET_THREADS_FOR_POSTS', + }); + expect(getPostThreads).toHaveBeenCalledWith(posts); - expect(getMentionsAndStatusesForPosts).toHaveBeenCalledWith(posts, expect.anything(), expect.anything()); + expect(batchFetchStatusesProfilesGroupsFromPosts).toHaveBeenCalledWith(posts); }); }); diff --git a/webapp/channels/src/components/admin_console/admin_definition.tsx b/webapp/channels/src/components/admin_console/admin_definition.tsx index 21e99e673f..76acebeabc 100644 --- a/webapp/channels/src/components/admin_console/admin_definition.tsx +++ b/webapp/channels/src/components/admin_console/admin_definition.tsx @@ -6146,6 +6146,15 @@ const AdminDefinition: AdminDefinitionType = { it.stateIsFalse('ServiceSettings.EnableUserTypingMessages'), ), }, + { + type: 'number', + key: 'ExperimentalSettings.UsersStatusAndProfileFetchingPollIntervalMilliseconds', + label: defineMessage({id: 'admin.experimental.UsersStatusAndProfileFetchingPollIntervalMilliseconds.title', defaultMessage: 'User\'s Status and Profile Fetching Poll Interval:'}), + help_text: defineMessage({id: 'admin.experimental.UsersStatusAndProfileFetchingPollIntervalMilliseconds.desc', defaultMessage: 'The number of milliseconds to wait between fetching user statuses and profiles periodically.'}), + help_text_markdown: false, + placeholder: defineMessage({id: 'admin.experimental.timeBetweenUserTypingUpdatesMilliseconds.example', defaultMessage: 'E.g.: "5000"'}), + isDisabled: it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.EXPERIMENTAL.FEATURES)), + }, { type: 'text', key: 'TeamSettings.ExperimentalPrimaryTeam', diff --git a/webapp/channels/src/components/channel_layout/channel_controller.test.tsx b/webapp/channels/src/components/channel_layout/channel_controller.test.tsx index cad1da32f5..d7025d4cf7 100644 --- a/webapp/channels/src/components/channel_layout/channel_controller.test.tsx +++ b/webapp/channels/src/components/channel_layout/channel_controller.test.tsx @@ -25,7 +25,7 @@ jest.mock('components/product_notices_modal', () => () =>
); jest.mock('plugins/pluggable', () => () =>
); jest.mock('actions/status_actions', () => ({ - loadStatusesForChannelAndSidebar: jest.fn().mockImplementation(() => () => {}), + addVisibleUsersInCurrentChannelToStatusPoll: jest.fn().mockImplementation(() => () => {}), })); jest.mock('mattermost-redux/selectors/entities/general', () => ({ @@ -46,7 +46,7 @@ describe('ChannelController', () => { jest.useFakeTimers(); }); - it('dispatches loadStatusesForChannelAndSidebar when enableUserStatuses is true', () => { + it('dispatches addVisibleUsersInCurrentChannelToStatusPoll when enableUserStatuses is true', () => { mockState.entities.general.config.EnableUserStatuses = 'true'; const store = mockStore(mockState); @@ -60,10 +60,10 @@ describe('ChannelController', () => { jest.advanceTimersByTime(Constants.STATUS_INTERVAL); }); - expect(actions.loadStatusesForChannelAndSidebar).toHaveBeenCalled(); + expect(actions.addVisibleUsersInCurrentChannelToStatusPoll).toHaveBeenCalled(); }); - it('does not dispatch loadStatusesForChannelAndSidebar when enableUserStatuses is false', () => { + it('does not dispatch addVisibleUsersInCurrentChannelToStatusPoll when enableUserStatuses is false', () => { const store = mockStore(mockState); mockState.entities.general.config.EnableUserStatuses = 'false'; @@ -77,7 +77,7 @@ describe('ChannelController', () => { jest.advanceTimersByTime(Constants.STATUS_INTERVAL); }); - expect(actions.loadStatusesForChannelAndSidebar).not.toHaveBeenCalled(); + expect(actions.addVisibleUsersInCurrentChannelToStatusPoll).not.toHaveBeenCalled(); }); }); diff --git a/webapp/channels/src/components/channel_layout/channel_controller.tsx b/webapp/channels/src/components/channel_layout/channel_controller.tsx index a2440771de..5902dcbf89 100644 --- a/webapp/channels/src/components/channel_layout/channel_controller.tsx +++ b/webapp/channels/src/components/channel_layout/channel_controller.tsx @@ -5,9 +5,10 @@ import classNames from 'classnames'; import React, {useEffect} from 'react'; import {useDispatch, useSelector} from 'react-redux'; +import {cleanUpStatusAndProfileFetchingPoll} from 'mattermost-redux/actions/status_profile_polling'; import {getIsUserStatusesConfigEnabled} from 'mattermost-redux/selectors/entities/common'; -import {loadStatusesForChannelAndSidebar} from 'actions/status_actions'; +import {addVisibleUsersInCurrentChannelToStatusPoll} from 'actions/status_actions'; import CenterChannel from 'components/channel_layout/center_channel'; import LoadingScreen from 'components/loading_screen'; @@ -40,6 +41,10 @@ export default function ChannelController(props: Props) { return () => { document.body.classList.remove(...BODY_CLASS_FOR_CHANNEL); + + // This cleans up the status and profile setInterval of fetching poll we use to batch requests + // when fetching statuses and profiles for a list of users. + cleanUpStatusAndProfileFetchingPoll(); }; }, []); @@ -47,14 +52,14 @@ export default function ChannelController(props: Props) { let loadStatusesIntervalId: NodeJS.Timeout; if (enabledUserStatuses) { loadStatusesIntervalId = setInterval(() => { - dispatch(loadStatusesForChannelAndSidebar()); + dispatch(addVisibleUsersInCurrentChannelToStatusPoll()); }, Constants.STATUS_INTERVAL); } return () => { clearInterval(loadStatusesIntervalId); }; - }, [dispatch, enabledUserStatuses]); + }, [enabledUserStatuses]); return ( <> diff --git a/webapp/channels/src/components/team_controller/actions/index.ts b/webapp/channels/src/components/team_controller/actions/index.ts index ff63cf1001..13e0d866fe 100644 --- a/webapp/channels/src/components/team_controller/actions/index.ts +++ b/webapp/channels/src/components/team_controller/actions/index.ts @@ -16,7 +16,7 @@ import {isCustomGroupsEnabled} from 'mattermost-redux/selectors/entities/prefere import {getCurrentUser} from 'mattermost-redux/selectors/entities/users'; import type {ActionFuncAsync} from 'mattermost-redux/types/actions'; -import {loadStatusesForChannelAndSidebar} from 'actions/status_actions'; +import {addVisibleUsersInCurrentChannelToStatusPoll} from 'actions/status_actions'; import {addUserToTeam} from 'actions/team_actions'; import LocalStorageStore from 'stores/local_storage_store'; @@ -40,11 +40,8 @@ export function initializeTeam(team: Team): ActionFuncAsync { } const enabledUserStatuses = getIsUserStatusesConfigEnabled(state); - if (enabledUserStatuses) { - // This is the first time in the pool of user statuses that we request, - // subsequent requests will be done via setInterval at channel_controller.tsx - dispatch(loadStatusesForChannelAndSidebar()); + dispatch(addVisibleUsersInCurrentChannelToStatusPoll()); } const license = getLicense(state); diff --git a/webapp/channels/src/i18n/en.json b/webapp/channels/src/i18n/en.json index 087c20ec31..66330f02f2 100644 --- a/webapp/channels/src/i18n/en.json +++ b/webapp/channels/src/i18n/en.json @@ -989,6 +989,8 @@ "admin.experimental.timeBetweenUserTypingUpdatesMilliseconds.title": "User Typing Timeout:", "admin.experimental.useChannelInEmailNotifications.desc": "When true, channel and team name appears in email notification subject lines. Useful for servers using only one team. When false, only team name appears in email notification subject line.", "admin.experimental.useChannelInEmailNotifications.title": "Use Channel Name in Email Notifications:", + "admin.experimental.UsersStatusAndProfileFetchingPollIntervalMilliseconds.desc": "The number of milliseconds to wait between fetching user statuses and profiles periodically.", + "admin.experimental.UsersStatusAndProfileFetchingPollIntervalMilliseconds.title": "User's Status and Profile Fetching Poll Interval:", "admin.experimental.userStatusAwayTimeout.desc": "This setting defines the number of seconds after which the user’s status indicator changes to \"Away\", when they are away from Mattermost.", "admin.experimental.userStatusAwayTimeout.example": "E.g.: \"300\"", "admin.experimental.userStatusAwayTimeout.title": "User Status Away Timeout:", diff --git a/webapp/channels/src/packages/mattermost-redux/src/action_types/posts.ts b/webapp/channels/src/packages/mattermost-redux/src/action_types/posts.ts index 2d1bca6eb4..21ccd6c05b 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/action_types/posts.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/action_types/posts.ts @@ -19,7 +19,6 @@ export default keyMirror({ GET_POSTS_SUCCESS: null, GET_POSTS_FAILURE: null, - GET_POSTS_SINCE_SUCCESS: null, GET_POST_THREAD_WITH_RETRY_ATTEMPT: null, GET_POSTS_WITH_RETRY_ATTEMPT: null, diff --git a/webapp/channels/src/packages/mattermost-redux/src/actions/posts.test.ts b/webapp/channels/src/packages/mattermost-redux/src/actions/posts.test.ts index d8852c681e..69fd0485eb 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/actions/posts.test.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/actions/posts.test.ts @@ -25,6 +25,12 @@ import {Preferences, Posts, RequestStatus} from '../constants'; const OK_RESPONSE = {status: 'OK'}; +jest.mock('mattermost-redux/actions/status_profile_polling', () => ({ + batchFetchStatusesProfilesGroupsFromPosts: jest.fn(() => { + return {type: ''}; + }), +})); + describe('Actions.Posts', () => { let store = configureStore(); beforeAll(() => { @@ -1633,6 +1639,7 @@ describe('getPostThreads', () => { users: { currentUserId: 'currentUserId', statuses: {}, + profiles: {}, }, general: { config: {}, @@ -1671,15 +1678,15 @@ describe('getPostThreads', () => { await testStore.dispatch(Actions.getPostThreads([comment])); - expect(testStore.getActions()[1].payload[0].type).toEqual('RECEIVED_POSTS'); - expect(testStore.getActions()[1].payload[0].data.posts).toEqual({ + expect(testStore.getActions()[0].payload[0].type).toEqual('RECEIVED_POSTS'); + expect(testStore.getActions()[0].payload[0].data.posts).toEqual({ [post1.id]: post1, [comment.id]: comment, }); - expect(testStore.getActions()[1].payload[1].type).toEqual('RECEIVED_POSTS_IN_THREAD'); - expect(testStore.getActions()[1].payload[1].rootId).toEqual(post1.id); - expect(testStore.getActions()[1].payload[1].data.posts).toEqual({ + expect(testStore.getActions()[0].payload[1].type).toEqual('RECEIVED_POSTS_IN_THREAD'); + expect(testStore.getActions()[0].payload[1].rootId).toEqual(post1.id); + expect(testStore.getActions()[0].payload[1].data.posts).toEqual({ [post1.id]: post1, [comment.id]: comment, }); diff --git a/webapp/channels/src/packages/mattermost-redux/src/actions/posts.ts b/webapp/channels/src/packages/mattermost-redux/src/actions/posts.ts index 894cc39d6b..4d4d47b236 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/actions/posts.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/actions/posts.ts @@ -20,6 +20,7 @@ import { deletePreferences, savePreferences, } from 'mattermost-redux/actions/preferences'; +import {batchFetchStatusesProfilesGroupsFromPosts} from 'mattermost-redux/actions/status_profile_polling'; import {decrementThreadCounts} from 'mattermost-redux/actions/threads'; import {getProfilesByIds, getProfilesByUsernames, getStatusesByIds} from 'mattermost-redux/actions/users'; import {Client4, DEFAULT_LIMIT_AFTER, DEFAULT_LIMIT_BEFORE} from 'mattermost-redux/client'; @@ -153,7 +154,6 @@ export function getPost(postId: string): ActionFuncAsync { try { post = await Client4.getPost(postId); - getMentionsAndStatusesForPosts([post], dispatch, getState); } catch (error) { forceLogoutIfNecessary(error, dispatch, getState); dispatch({type: PostTypes.GET_POSTS_FAILURE, error}); @@ -161,12 +161,8 @@ export function getPost(postId: string): ActionFuncAsync { return {error}; } - dispatch(batchActions([ - receivedPost(post, crtEnabled), - { - type: PostTypes.GET_POSTS_SUCCESS, - }, - ])); + dispatch(receivedPost(post, crtEnabled)); + dispatch(batchFetchStatusesProfilesGroupsFromPosts([post])); return {data: post}; }; @@ -709,16 +705,15 @@ async function getPaginatedPostThread(rootId: string, options: FetchPaginatedThr export function getPostThread(rootId: string, fetchThreads = true): ActionFuncAsync { return async (dispatch, getState) => { - dispatch({type: PostTypes.GET_POST_THREAD_REQUEST}); - const collapsedThreadsEnabled = isCollapsedThreadsEnabled(getState()); + const state = getState(); + const collapsedThreadsEnabled = isCollapsedThreadsEnabled(state); + const enabledUserStatuses = getIsUserStatusesConfigEnabled(state); let posts; try { posts = await getPaginatedPostThread(rootId, {fetchThreads, collapsedThreads: collapsedThreadsEnabled}); - getMentionsAndStatusesForPosts(posts.posts, dispatch, getState); } catch (error) { forceLogoutIfNecessary(error, dispatch, getState); - dispatch({type: PostTypes.GET_POST_THREAD_FAILURE, error}); dispatch(logError(error)); return {error}; } @@ -726,11 +721,12 @@ export function getPostThread(rootId: string, fetchThreads = true): ActionFuncAs dispatch(batchActions([ receivedPosts(posts), receivedPostsInThread(posts, rootId), - { - type: PostTypes.GET_POST_THREAD_SUCCESS, - }, ])); + if (enabledUserStatuses) { + dispatch(batchFetchStatusesProfilesGroupsFromPosts(posts.posts)); + } + return {data: posts}; }; } @@ -756,7 +752,6 @@ export function getNewestPostThread(rootId: string): ActionFuncAsync { let posts; try { posts = await getPaginatedPostThread(rootId, options); - getMentionsAndStatusesForPosts(posts.posts, dispatch, getState); } catch (error) { forceLogoutIfNecessary(error, dispatch, getState); dispatch({type: PostTypes.GET_POST_THREAD_FAILURE, error}); @@ -767,10 +762,8 @@ export function getNewestPostThread(rootId: string): ActionFuncAsync { dispatch(batchActions([ receivedPosts(posts), receivedPostsInThread(posts, rootId), - { - type: PostTypes.GET_POST_THREAD_SUCCESS, - }, ])); + dispatch(batchFetchStatusesProfilesGroupsFromPosts(posts.posts)); return {data: posts}; }; @@ -782,7 +775,6 @@ export function getPosts(channelId: string, page = 0, perPage = Posts.POST_CHUNK const collapsedThreadsEnabled = isCollapsedThreadsEnabled(getState()); try { posts = await Client4.getPosts(channelId, page, perPage, fetchThreads, collapsedThreadsEnabled, collapsedThreadsExtended); - getMentionsAndStatusesForPosts(posts.posts, dispatch, getState); } catch (error) { forceLogoutIfNecessary(error, dispatch, getState); dispatch(logError(error)); @@ -793,6 +785,7 @@ export function getPosts(channelId: string, page = 0, perPage = Posts.POST_CHUNK receivedPosts(posts), receivedPostsInChannel(posts, channelId, page === 0, posts.prev_post_id === ''), ])); + dispatch(batchFetchStatusesProfilesGroupsFromPosts(posts.posts)); return {data: posts}; }; @@ -800,9 +793,11 @@ export function getPosts(channelId: string, page = 0, perPage = Posts.POST_CHUNK export function getPostsUnread(channelId: string, fetchThreads = true, collapsedThreadsExtended = false): ActionFuncAsync { return async (dispatch, getState) => { - const shouldLoadRecent = getUnreadScrollPositionPreference(getState()) === Preferences.UNREAD_SCROLL_POSITION_START_FROM_NEWEST; - const collapsedThreadsEnabled = isCollapsedThreadsEnabled(getState()); - const userId = getCurrentUserId(getState()); + const state = getState(); + const shouldLoadRecent = getUnreadScrollPositionPreference(state) === Preferences.UNREAD_SCROLL_POSITION_START_FROM_NEWEST; + const collapsedThreadsEnabled = isCollapsedThreadsEnabled(state); + const userId = getCurrentUserId(state); + let posts; let recentPosts; try { @@ -811,29 +806,29 @@ export function getPostsUnread(channelId: string, fetchThreads = true, collapsed if (posts.next_post_id && shouldLoadRecent) { recentPosts = await Client4.getPosts(channelId, 0, Posts.POST_CHUNK_SIZE / 2, fetchThreads, collapsedThreadsEnabled, collapsedThreadsExtended); } - - getMentionsAndStatusesForPosts(posts.posts, dispatch, getState); } catch (error) { forceLogoutIfNecessary(error, dispatch, getState); dispatch(logError(error)); return {error}; } - const recentPostsActions = recentPosts ? [ - receivedPosts(recentPosts), - receivedPostsInChannel(recentPosts, channelId, recentPosts.next_post_id === '', recentPosts.prev_post_id === ''), - ] : []; - - dispatch(batchActions([ - receivedPosts(posts), + const actions: AnyAction[] = [ + { + type: PostTypes.RECEIVED_POSTS, + data: posts, + channelId, + }, receivedPostsInChannel(posts, channelId, posts.next_post_id === '', posts.prev_post_id === ''), - ...recentPostsActions, - ])); - dispatch({ - type: PostTypes.RECEIVED_POSTS, - data: posts, - channelId, - }); + ]; + if (recentPosts) { + actions.push( + receivedPosts(recentPosts), + receivedPostsInChannel(recentPosts, channelId, recentPosts.next_post_id === '', recentPosts.prev_post_id === ''), + ); + } + + dispatch(batchActions(actions)); + dispatch(batchFetchStatusesProfilesGroupsFromPosts(posts.posts)); return {data: posts}; }; @@ -845,7 +840,6 @@ export function getPostsSince(channelId: string, since: number, fetchThreads = t try { const collapsedThreadsEnabled = isCollapsedThreadsEnabled(getState()); posts = await Client4.getPostsSince(channelId, since, fetchThreads, collapsedThreadsEnabled, collapsedThreadsExtended); - getMentionsAndStatusesForPosts(posts.posts, dispatch, getState); } catch (error) { forceLogoutIfNecessary(error, dispatch, getState); dispatch(logError(error)); @@ -855,10 +849,8 @@ export function getPostsSince(channelId: string, since: number, fetchThreads = t dispatch(batchActions([ receivedPosts(posts), receivedPostsSince(posts, channelId), - { - type: PostTypes.GET_POSTS_SINCE_SUCCESS, - }, ])); + dispatch(batchFetchStatusesProfilesGroupsFromPosts(posts.posts)); return {data: posts}; }; @@ -870,7 +862,6 @@ export function getPostsBefore(channelId: string, postId: string, page = 0, perP try { const collapsedThreadsEnabled = isCollapsedThreadsEnabled(getState()); posts = await Client4.getPostsBefore(channelId, postId, page, perPage, fetchThreads, collapsedThreadsEnabled, collapsedThreadsExtended); - getMentionsAndStatusesForPosts(posts.posts, dispatch, getState); } catch (error) { forceLogoutIfNecessary(error, dispatch, getState); dispatch(logError(error)); @@ -881,6 +872,7 @@ export function getPostsBefore(channelId: string, postId: string, page = 0, perP receivedPosts(posts), receivedPostsBefore(posts, channelId, postId, posts.prev_post_id === ''), ])); + dispatch(batchFetchStatusesProfilesGroupsFromPosts(posts.posts)); return {data: posts}; }; @@ -892,7 +884,6 @@ export function getPostsAfter(channelId: string, postId: string, page = 0, perPa try { const collapsedThreadsEnabled = isCollapsedThreadsEnabled(getState()); posts = await Client4.getPostsAfter(channelId, postId, page, perPage, fetchThreads, collapsedThreadsEnabled, collapsedThreadsExtended); - getMentionsAndStatusesForPosts(posts.posts, dispatch, getState); } catch (error) { forceLogoutIfNecessary(error, dispatch, getState); dispatch(logError(error)); @@ -903,6 +894,7 @@ export function getPostsAfter(channelId: string, postId: string, page = 0, perPa receivedPosts(posts), receivedPostsAfter(posts, channelId, postId, posts.next_post_id === ''), ])); + dispatch(batchFetchStatusesProfilesGroupsFromPosts(posts.posts)); return {data: posts}; }; @@ -944,12 +936,11 @@ export function getPostsAround(channelId: string, postId: string, perPage = Post first_inaccessible_post_time: Math.max(before.first_inaccessible_post_time, after.first_inaccessible_post_time, thread.first_inaccessible_post_time) || 0, }; - getMentionsAndStatusesForPosts(posts.posts, dispatch, getState); - dispatch(batchActions([ receivedPosts(posts), receivedPostsInChannel(posts, channelId, after.next_post_id === '', before.prev_post_id === ''), ])); + dispatch(batchFetchStatusesProfilesGroupsFromPosts(posts.posts)); return {data: posts}; }; diff --git a/webapp/channels/src/packages/mattermost-redux/src/actions/status_profile_polling.ts b/webapp/channels/src/packages/mattermost-redux/src/actions/status_profile_polling.ts new file mode 100644 index 0000000000..c4cccb16f9 --- /dev/null +++ b/webapp/channels/src/packages/mattermost-redux/src/actions/status_profile_polling.ts @@ -0,0 +1,289 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import type {GroupSearchParams} from '@mattermost/types/groups'; +import type {PostList, Post, PostAcknowledgement, PostEmbed, PostPreviewMetadata} from '@mattermost/types/posts'; +import type {UserProfile} from '@mattermost/types/users'; + +import {searchGroups} from 'mattermost-redux/actions/groups'; +import {getNeededAtMentionedUsernamesAndGroups} from 'mattermost-redux/actions/posts'; +import {getProfilesByIds, getProfilesByUsernames, getStatusesByIds} from 'mattermost-redux/actions/users'; +import {getCurrentUser, getCurrentUserId, getIsUserStatusesConfigEnabled, getUsers} from 'mattermost-redux/selectors/entities/common'; +import {getUsersStatusAndProfileFetchingPollInterval} from 'mattermost-redux/selectors/entities/general'; +import {getUserStatuses} from 'mattermost-redux/selectors/entities/users'; +import type {ActionFunc, ActionFuncAsync} from 'mattermost-redux/types/actions'; + +const MAX_USER_IDS_PER_STATUS_REQUEST = 200; // users ids per 'users/status/ids'request +const MAX_USER_IDS_PER_PROFILES_REQUEST = 100; // users ids per 'users/ids' request + +const pendingUserIdsForStatuses = new Set(); +const pendingUserIdsForProfiles = new Set(); + +let intervalIdForFetchingPoll: NodeJS.Timeout | null = null; + +type UserIdsSingleOrArray = Array | UserProfile['id']; +type AddUserIdsForStatusAndProfileFetchingPoll = { + userIdsForStatus?: UserIdsSingleOrArray; + userIdsForProfile?: UserIdsSingleOrArray; +} + +/** + * Adds list(s) of user id(s) to the status and profile fetching poll. Which gets fetched based on user interval polling duration + * Do not use if status or profile is required immediately. + */ +export function addUserIdsForStatusAndProfileFetchingPoll({userIdsForStatus, userIdsForProfile}: AddUserIdsForStatusAndProfileFetchingPoll): ActionFunc { + return (dispatch, getState) => { + function getPendingStatusesById() { + // Since we can only fetch a defined number of user statuses at a time, we need to batch the requests + if (pendingUserIdsForStatuses.size >= MAX_USER_IDS_PER_STATUS_REQUEST) { + // We use temp buffer here to store up until max buffer size + // and clear out processed user ids + const bufferedUserIds: string[] = []; + let bufferCounter = 0; + for (const pendingUserId of pendingUserIdsForStatuses) { + if (pendingUserId.length === 0) { + continue; + } + + bufferedUserIds.push(pendingUserId); + pendingUserIdsForStatuses.delete(pendingUserId); + + bufferCounter++; + + if (bufferCounter >= MAX_USER_IDS_PER_STATUS_REQUEST) { + break; + } + } + + if (bufferedUserIds.length > 0) { + dispatch(getStatusesByIds(bufferedUserIds)); + } + } else { + // If we have less than max buffer size, we can directly fetch the statuses + const lessThanBufferUserIds = Array.from(pendingUserIdsForStatuses); + if (lessThanBufferUserIds.length > 0) { + dispatch(getStatusesByIds(lessThanBufferUserIds)); + + pendingUserIdsForStatuses.clear(); + } + } + } + + function getPendingProfilesById() { + if (pendingUserIdsForProfiles.size >= MAX_USER_IDS_PER_PROFILES_REQUEST) { + const bufferedUserIds: Array = []; + let bufferCounter = 0; + for (const pendingUserId of pendingUserIdsForProfiles) { + if (pendingUserId.length === 0) { + continue; + } + + bufferedUserIds.push(pendingUserId); + pendingUserIdsForProfiles.delete(pendingUserId); + + bufferCounter++; + + // We can only fetch a defined number of user profiles at a time + // So we break out of the loop if we reach the max batch size + if (bufferCounter >= MAX_USER_IDS_PER_PROFILES_REQUEST) { + break; + } + } + + if (bufferedUserIds.length > 0) { + dispatch(getProfilesByIds(bufferedUserIds)); + } + } else { + const lessThanBufferUserIds = Array.from(pendingUserIdsForProfiles); + if (lessThanBufferUserIds.length > 0) { + dispatch(getProfilesByIds(lessThanBufferUserIds)); + + pendingUserIdsForProfiles.clear(); + } + } + } + + const pollingInterval = getUsersStatusAndProfileFetchingPollInterval(getState()); + + if (userIdsForStatus) { + if (Array.isArray(userIdsForStatus)) { + userIdsForStatus.forEach((userId) => { + if (userId.length > 0) { + pendingUserIdsForStatuses.add(userId); + } + }); + } else { + pendingUserIdsForStatuses.add(userIdsForStatus); + } + } + + if (userIdsForProfile) { + if (Array.isArray(userIdsForProfile)) { + userIdsForProfile.forEach((userId) => { + if (userId.length > 0) { + pendingUserIdsForProfiles.add(userId); + } + }); + } else { + pendingUserIdsForProfiles.add(userIdsForProfile); + } + } + + // Escape hatch to fetch immediately or when we haven't received the polling interval from config yet + if (!pollingInterval || pollingInterval <= 0) { + if (pendingUserIdsForStatuses.size > 0) { + getPendingStatusesById(); + } + + if (pendingUserIdsForProfiles.size > 0) { + getPendingProfilesById(); + } + } else if (intervalIdForFetchingPoll === null) { + // Start the interval if it is not already running + intervalIdForFetchingPoll = setInterval(() => { + if (pendingUserIdsForStatuses.size > 0) { + getPendingStatusesById(); + } + + if (pendingUserIdsForProfiles.size > 0) { + getPendingProfilesById(); + } + }, pollingInterval); + } + + // Now here the interval is already running and we have added the user ids to the poll so we don't need to do anything + return {data: true}; + }; +} + +export function cleanUpStatusAndProfileFetchingPoll() { + if (intervalIdForFetchingPoll !== null) { + clearInterval(intervalIdForFetchingPoll); + intervalIdForFetchingPoll = null; + } +} + +/** + * Gets in batch the user profiles, user statuses and user groups for the users in the posts list + * This action however doesn't refetch the profiles and statuses except for groups if they are already fetched once + */ +export function batchFetchStatusesProfilesGroupsFromPosts(postsArrayOrMap: Post[]|PostList['posts']|Post): ActionFunc { + return (dispatch, getState) => { + if (!postsArrayOrMap) { + return {data: false}; + } + + let posts: Post[] = []; + if (Array.isArray(postsArrayOrMap)) { + posts = postsArrayOrMap; + } else if (typeof postsArrayOrMap === 'object' && 'id' in postsArrayOrMap) { + posts = [postsArrayOrMap as Post]; + } else if (typeof postsArrayOrMap === 'object') { + posts = Object.values(postsArrayOrMap); + } + + if (posts.length === 0) { + return {data: false}; + } + + const mentionedUsernamesAndGroupsInPosts = new Set(); + + const state = getState(); + const currentUser = getCurrentUser(state); + const currentUserId = getCurrentUserId(state); + const isUserStatusesConfigEnabled = getIsUserStatusesConfigEnabled(state); + const users = getUsers(state); + const userStatuses = getUserStatuses(state); + + posts.forEach((post) => { + if (post.metadata) { + // Add users listed in permalink previews + if (post.metadata.embeds) { + post.metadata.embeds.forEach((embed: PostEmbed) => { + if (embed.type === 'permalink' && embed.data) { + const permalinkPostPreviewMetaData = embed.data as PostPreviewMetadata; + + if (permalinkPostPreviewMetaData.post?.user_id && !users[permalinkPostPreviewMetaData.post.user_id] && permalinkPostPreviewMetaData.post.user_id !== currentUserId) { + dispatch(addUserIdsForStatusAndProfileFetchingPoll({userIdsForProfile: permalinkPostPreviewMetaData.post.user_id})); + } + if (permalinkPostPreviewMetaData.post?.user_id && !userStatuses[permalinkPostPreviewMetaData.post.user_id] && permalinkPostPreviewMetaData.post.user_id !== currentUserId && isUserStatusesConfigEnabled) { + dispatch(addUserIdsForStatusAndProfileFetchingPoll({userIdsForStatus: permalinkPostPreviewMetaData.post.user_id})); + } + } + }); + } + + // Add users listed in the Post Acknowledgement feature + if (post.metadata.acknowledgements) { + post.metadata.acknowledgements.forEach((ack: PostAcknowledgement) => { + if (ack.acknowledged_at > 0 && ack.user_id && !users[ack.user_id] && ack.user_id !== currentUserId) { + dispatch(addUserIdsForStatusAndProfileFetchingPoll({userIdsForProfile: ack.user_id})); + } + }); + } + } + + // This is sufficient to check if the profile is already fetched + // as we receive the websocket events for the profiles changes + if (!users[post.user_id] && post.user_id !== currentUserId) { + dispatch(addUserIdsForStatusAndProfileFetchingPoll({userIdsForProfile: post.user_id})); + } + + // This is sufficient to check if the status is already fetched + // as we do the polling for statuses for current channel's channel members every 1 minute in channel_controller + if (!userStatuses[post.user_id] && post.user_id !== currentUserId && isUserStatusesConfigEnabled) { + dispatch(addUserIdsForStatusAndProfileFetchingPoll({userIdsForStatus: post.user_id})); + } + + // We need to check for all @mentions in the post, they can be either users or groups + const mentioned = getNeededAtMentionedUsernamesAndGroups(state, [post]); + if (mentioned.size > 0) { + mentioned.forEach((atMention) => { + if (atMention !== currentUser.username) { + mentionedUsernamesAndGroupsInPosts.add(atMention); + } + }); + } + }); + + if (mentionedUsernamesAndGroupsInPosts.size > 0) { + dispatch(getUsersFromMentionedUsernamesAndGroups(Array.from(mentionedUsernamesAndGroupsInPosts))); + } + + return {data: true}; + }; +} + +export function getUsersFromMentionedUsernamesAndGroups(usernamesAndGroups: string[]): ActionFuncAsync { + return async (dispatch) => { + // We run the at-mentioned be it user or group through the user profile search + const {data: userProfiles} = await dispatch(getProfilesByUsernames(usernamesAndGroups)); + + const mentionedUsernames: Array = []; + + // The user at-mentioned will be the userProfiles + if (userProfiles) { + for (const user of userProfiles) { + if (user && user.username) { + mentionedUsernames.push(user.username); + } + } + } + + // Removing usernames from the list will leave only the group names + const mentionedGroups = usernamesAndGroups.filter((name) => !mentionedUsernames.includes(name)); + + for (const group of mentionedGroups) { + const groupSearchParam: GroupSearchParams = { + q: group, + filter_allow_reference: true, + page: 0, + per_page: 60, + }; + + dispatch(searchGroups(groupSearchParam)); + } + + return {data: mentionedGroups}; + }; +} diff --git a/webapp/channels/src/packages/mattermost-redux/src/actions/users.ts b/webapp/channels/src/packages/mattermost-redux/src/actions/users.ts index aa67e6ea33..306a987704 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/actions/users.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/actions/users.ts @@ -595,9 +595,9 @@ export function getStatusesByIds(userIds: Array): ActionFuncA return {data: []}; } - let recievedStatuses: UserStatus[]; + let receivedStatuses: UserStatus[]; try { - recievedStatuses = await Client4.getStatusesByIds(userIds); + receivedStatuses = await Client4.getStatusesByIds(userIds); } catch (error) { forceLogoutIfNecessary(error, dispatch, getState); dispatch(logError(error)); @@ -609,11 +609,11 @@ export function getStatusesByIds(userIds: Array): ActionFuncA const isManualStatuses: Record = {}; const lastActivity: Record = {}; - for (const recievedStatus of recievedStatuses) { - statuses[recievedStatus.user_id] = recievedStatus?.status ?? ''; - dndEndTimes[recievedStatus.user_id] = recievedStatus?.dnd_end_time ?? 0; - isManualStatuses[recievedStatus.user_id] = recievedStatus?.manual ?? false; - lastActivity[recievedStatus.user_id] = recievedStatus?.last_activity_at ?? 0; + for (const receivedStatus of receivedStatuses) { + statuses[receivedStatus.user_id] = receivedStatus?.status ?? ''; + dndEndTimes[receivedStatus.user_id] = receivedStatus?.dnd_end_time ?? 0; + isManualStatuses[receivedStatus.user_id] = receivedStatus?.manual ?? false; + lastActivity[receivedStatus.user_id] = receivedStatus?.last_activity_at ?? 0; } dispatch(batchActions([ @@ -637,7 +637,7 @@ export function getStatusesByIds(userIds: Array): ActionFuncA 'BATCHING_STATUSES', )); - return {data: recievedStatuses}; + return {data: receivedStatuses}; }; } diff --git a/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/general.ts b/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/general.ts index e8cd01ac3c..9b8358eb59 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/general.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/general.ts @@ -122,3 +122,16 @@ export const getGiphyFetchInstance: (state: GlobalState) => GiphyFetch | null = return null; }, ); + +export const getUsersStatusAndProfileFetchingPollInterval: (state: GlobalState) => number | null = createSelector( + 'getUsersStatusAndProfileFetchingPollInterval', + getConfig, + (config) => { + const usersStatusAndProfileFetchingPollInterval = config.UsersStatusAndProfileFetchingPollIntervalMilliseconds; + if (usersStatusAndProfileFetchingPollInterval) { + return parseInt(usersStatusAndProfileFetchingPollInterval, 10); + } + + return null; + }, +); diff --git a/webapp/platform/types/src/config.ts b/webapp/platform/types/src/config.ts index f2d1860e41..509422c7e4 100644 --- a/webapp/platform/types/src/config.ts +++ b/webapp/platform/types/src/config.ts @@ -210,6 +210,7 @@ export type ClientConfig = { WranglerMoveThreadFromGroupMessageChannelEnable: string; ServiceEnvironment: string; UniqueEmojiReactionLimitPerPost: string; + UsersStatusAndProfileFetchingPollIntervalMilliseconds: string; }; export type License = {