[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
Этот коммит содержится в:
M-ZubairAhmed
2024-05-27 12:34:38 +00:00
коммит произвёл GitHub
родитель b00b68920d
Коммит 273a999167
18 изменённых файлов: 469 добавлений и 126 удалений

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

@@ -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"

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

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

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

@@ -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<R = unknown> {
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();
});
});

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

@@ -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<boolean, GlobalState> {
/**
* 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<boolean, GlobalState> {
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<string>();
const userIdsToFetchStatusFor = new Set<string>();
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};
};

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

@@ -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,

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

@@ -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);
});
});

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

@@ -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',

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

@@ -25,7 +25,7 @@ jest.mock('components/product_notices_modal', () => () => <div/>);
jest.mock('plugins/pluggable', () => () => <div/>);
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();
});
});

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

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

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

@@ -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<Team, GlobalState> {
}
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);

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

@@ -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 users 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:",

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

@@ -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,

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

@@ -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,
});

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

@@ -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<Post> {
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<Post> {
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<PostList> {
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<PostList> {
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};
};

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

@@ -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<string>();
const pendingUserIdsForProfiles = new Set<string>();
let intervalIdForFetchingPoll: NodeJS.Timeout | null = null;
type UserIdsSingleOrArray = Array<UserProfile['id']> | 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<boolean> {
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<UserProfile['id']> = [];
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<boolean> {
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<string>();
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<string[]> {
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<UserProfile['username']> = [];
// 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};
};
}

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

@@ -595,9 +595,9 @@ export function getStatusesByIds(userIds: Array<UserProfile['id']>): 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<UserProfile['id']>): ActionFuncA
const isManualStatuses: Record<UserProfile['id'], UserStatus['manual']> = {};
const lastActivity: Record<UserProfile['id'], UserStatus['last_activity_at']> = {};
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<UserProfile['id']>): ActionFuncA
'BATCHING_STATUSES',
));
return {data: recievedStatuses};
return {data: receivedStatuses};
};
}

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

@@ -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;
},
);

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

@@ -210,6 +210,7 @@ export type ClientConfig = {
WranglerMoveThreadFromGroupMessageChannelEnable: string;
ServiceEnvironment: string;
UniqueEmojiReactionLimitPerPost: string;
UsersStatusAndProfileFetchingPollIntervalMilliseconds: string;
};
export type License = {