diff --git a/server/config/client.go b/server/config/client.go index 81250ca8cc..b1db928075 100644 --- a/server/config/client.go +++ b/server/config/client.go @@ -284,6 +284,7 @@ func GenerateLimitedClientConfig(c *model.Config, telemetryID string, license *m props["DefaultClientLocale"] = *c.LocalizationSettings.DefaultClientLocale props["EnableCustomEmoji"] = strconv.FormatBool(*c.ServiceSettings.EnableCustomEmoji) + props["EnableUserStatuses"] = strconv.FormatBool(*c.ServiceSettings.EnableUserStatuses) props["AppDownloadLink"] = *c.NativeAppSettings.AppDownloadLink props["AndroidAppDownloadLink"] = *c.NativeAppSettings.AndroidAppDownloadLink props["IosAppDownloadLink"] = *c.NativeAppSettings.IosAppDownloadLink diff --git a/server/config/client_test.go b/server/config/client_test.go index 0ccbae9413..f8cc783a42 100644 --- a/server/config/client_test.go +++ b/server/config/client_test.go @@ -240,6 +240,19 @@ func TestGetClientConfig(t *testing.T) { "ExperimentalSharedChannels": "true", }, }, + { + "disable EnableUserStatuses", + &model.Config{ + ServiceSettings: model.ServiceSettings{ + EnableUserStatuses: model.NewBool(false), + }, + }, + "", + nil, + map[string]string{ + "EnableUserStatuses": "false", + }, + }, { "Shared channels enterprise license", &model.Config{ diff --git a/webapp/channels/src/actions/status_actions.test.ts b/webapp/channels/src/actions/status_actions.test.ts index 863b4b80c5..0481c8951f 100644 --- a/webapp/channels/src/actions/status_actions.test.ts +++ b/webapp/channels/src/actions/status_actions.test.ts @@ -44,6 +44,7 @@ describe('actions/status_actions', () => { config: { EnableCustomEmoji: 'true', EnableCustomUserStatuses: 'true', + EnableUserStatuses: 'true', }, }, teams: { diff --git a/webapp/channels/src/actions/status_actions.ts b/webapp/channels/src/actions/status_actions.ts index 813599d11a..d1197deaa2 100644 --- a/webapp/channels/src/actions/status_actions.ts +++ b/webapp/channels/src/actions/status_actions.ts @@ -5,6 +5,7 @@ import type {UserProfile} from '@mattermost/types/users'; import {getStatusesByIds} from 'mattermost-redux/actions/users'; import {getCurrentChannelId} from 'mattermost-redux/selectors/entities/channels'; +import {getIsUserStatusesConfigEnabled} from 'mattermost-redux/selectors/entities/common'; import {getPostsInCurrentChannel} from 'mattermost-redux/selectors/entities/posts'; import {getDirectShowPreferences} from 'mattermost-redux/selectors/entities/preferences'; import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users'; @@ -84,8 +85,11 @@ export function loadStatusesForProfilesMap(users: Record | } export function loadStatusesByIds(userIds: string[]): ActionFunc { - return (dispatch) => { - if (userIds.length === 0) { + return (dispatch, getState) => { + const state = getState(); + const enabledUserStatuses = getIsUserStatusesConfigEnabled(state); + + if (userIds.length === 0 || !enabledUserStatuses) { return {data: false}; } @@ -98,13 +102,15 @@ export function loadStatusesByIds(userIds: string[]): ActionFunc { export function loadProfilesMissingStatus(users: UserProfile[]): ActionFunc { return (dispatch, getState) => { const state = getState(); + const enabledUserStatuses = getIsUserStatusesConfigEnabled(state); + const statuses = state.entities.users.statuses; const missingStatusByIds = users. filter((user) => !statuses[user.id]). map((user) => user.id); - if (missingStatusByIds.length === 0) { + if (missingStatusByIds.length === 0 || !enabledUserStatuses) { return {data: false}; } diff --git a/webapp/channels/src/actions/user_actions.test.ts b/webapp/channels/src/actions/user_actions.test.ts index 894be793dd..30f3553e77 100644 --- a/webapp/channels/src/actions/user_actions.test.ts +++ b/webapp/channels/src/actions/user_actions.test.ts @@ -102,7 +102,9 @@ describe('Actions.User', () => { }, } as unknown as GlobalState['entities']['channels'], general: { - config: {}, + config: { + EnableUserStatuses: 'true', + }, } as GlobalState['entities']['general'], preferences: { myPreferences: { diff --git a/webapp/channels/src/actions/user_actions.ts b/webapp/channels/src/actions/user_actions.ts index 7ee306c12b..93973084ba 100644 --- a/webapp/channels/src/actions/user_actions.ts +++ b/webapp/channels/src/actions/user_actions.ts @@ -20,6 +20,7 @@ import { getMyChannelMember, getMyChannels, } from 'mattermost-redux/selectors/entities/channels'; +import {getIsUserStatusesConfigEnabled} from 'mattermost-redux/selectors/entities/common'; import {getBool, isCollapsedThreadsEnabled} from 'mattermost-redux/selectors/entities/preferences'; import {getCurrentTeamId, getTeamMember} from 'mattermost-redux/selectors/entities/teams'; import * as Selectors from 'mattermost-redux/selectors/entities/users'; @@ -423,10 +424,17 @@ export function autocompleteUsers(username: string): ThunkActionFunc { return async (doDispatch) => { - const {currentUserId} = getState().entities.users; + const state = getState(); + const enabledUserStatuses = getIsUserStatusesConfigEnabled(state); + + if (!enabledUserStatuses) { + return {data: undefined}; + } + + const {currentUserId} = state.entities.users; const {data: userStatus} = await doDispatch(UserActions.getStatus(currentUserId)); - if (userStatus!.status === UserStatuses.OUT_OF_OFFICE || !userStatus!.manual) { + if (userStatus?.status === UserStatuses.OUT_OF_OFFICE || !userStatus?.manual) { return {data: userStatus}; } 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 c5dd75e5d5..cad1da32f5 100644 --- a/webapp/channels/src/components/channel_layout/channel_controller.test.tsx +++ b/webapp/channels/src/components/channel_layout/channel_controller.test.tsx @@ -1,9 +1,20 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +import {render, act} from '@testing-library/react'; import React from 'react'; +import {Provider} from 'react-redux'; -import {getClassnamesForBody} from './channel_controller'; +import * as actions from 'actions/status_actions'; + +import mockStore from 'tests/test_store'; +import Constants from 'utils/constants'; + +import type {GlobalState} from 'types/store'; + +import ChannelController, {getClassnamesForBody} from './channel_controller'; + +let mockState: GlobalState; jest.mock('components/reset_status_modal', () => () =>
); jest.mock('components/sidebar', () => () =>
); @@ -13,6 +24,63 @@ jest.mock('components/unreads_status_handler', () => () =>
); jest.mock('components/product_notices_modal', () => () =>
); jest.mock('plugins/pluggable', () => () =>
); +jest.mock('actions/status_actions', () => ({ + loadStatusesForChannelAndSidebar: jest.fn().mockImplementation(() => () => {}), +})); + +jest.mock('mattermost-redux/selectors/entities/general', () => ({ + ...jest.requireActual('mattermost-redux/selectors/entities/general') as typeof import('mattermost-redux/selectors/entities/general'), +})); + +describe('ChannelController', () => { + beforeEach(() => { + mockState = { + entities: { + general: { + config: { + EnableUserStatuses: 'false', + }, + }, + }, + } as unknown as GlobalState; + jest.useFakeTimers(); + }); + + it('dispatches loadStatusesForChannelAndSidebar when enableUserStatuses is true', () => { + mockState.entities.general.config.EnableUserStatuses = 'true'; + const store = mockStore(mockState); + + render( + + + , + ); + + act(() => { + jest.advanceTimersByTime(Constants.STATUS_INTERVAL); + }); + + expect(actions.loadStatusesForChannelAndSidebar).toHaveBeenCalled(); + }); + + it('does not dispatch loadStatusesForChannelAndSidebar when enableUserStatuses is false', () => { + const store = mockStore(mockState); + mockState.entities.general.config.EnableUserStatuses = 'false'; + + render( + + + , + ); + + act(() => { + jest.advanceTimersByTime(Constants.STATUS_INTERVAL); + }); + + expect(actions.loadStatusesForChannelAndSidebar).not.toHaveBeenCalled(); + }); +}); + describe('components/channel_layout/ChannelController', () => { test('Should have app__body and channel-view classes by default', () => { expect(getClassnamesForBody('')).toEqual(['app__body', 'channel-view']); diff --git a/webapp/channels/src/components/channel_layout/channel_controller.tsx b/webapp/channels/src/components/channel_layout/channel_controller.tsx index 03cdbe81df..8fd6fac33f 100644 --- a/webapp/channels/src/components/channel_layout/channel_controller.tsx +++ b/webapp/channels/src/components/channel_layout/channel_controller.tsx @@ -3,7 +3,9 @@ import classNames from 'classnames'; import React, {useEffect} from 'react'; -import {useDispatch} from 'react-redux'; +import {useDispatch, useSelector} from 'react-redux'; + +import {getIsUserStatusesConfigEnabled} from 'mattermost-redux/selectors/entities/common'; import {loadStatusesForChannelAndSidebar} from 'actions/status_actions'; @@ -25,11 +27,15 @@ type Props = { } export default function ChannelController(props: Props) { + const enabledUserStatuses = useSelector(getIsUserStatusesConfigEnabled); const dispatch = useDispatch(); useEffect(() => { const isMsBrowser = isInternetExplorer() || isEdge(); - const platform = window.navigator.platform; + const {navigator} = window; + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + const platform = navigator?.userAgentData?.platform || navigator?.platform || 'unknown'; document.body.classList.add(...getClassnamesForBody(platform, isMsBrowser)); return () => { @@ -38,14 +44,16 @@ export default function ChannelController(props: Props) { }, []); useEffect(() => { - const loadStatusesIntervalId = setInterval(() => { - dispatch(loadStatusesForChannelAndSidebar()); - }, Constants.STATUS_INTERVAL); - + let loadStatusesIntervalId: ReturnType; + if (enabledUserStatuses) { + loadStatusesIntervalId = setInterval(() => { + dispatch(loadStatusesForChannelAndSidebar()); + }, Constants.STATUS_INTERVAL); + } return () => { clearInterval(loadStatusesIntervalId); }; - }, []); + }, [dispatch, enabledUserStatuses]); return ( <> diff --git a/webapp/channels/src/components/msg_typing/actions.test.ts b/webapp/channels/src/components/msg_typing/actions.test.ts index b1860da1d2..d02fc786d9 100644 --- a/webapp/channels/src/components/msg_typing/actions.test.ts +++ b/webapp/channels/src/components/msg_typing/actions.test.ts @@ -18,7 +18,9 @@ describe('handleUserTypingEvent', () => { const initialState = { entities: { general: { - config: {}, + config: { + EnableUserStatuses: 'true', + }, }, users: { currentUserId: 'user', @@ -81,6 +83,25 @@ describe('handleUserTypingEvent', () => { expect(getStatusesByIds).toHaveBeenCalled(); }); + test('should NOT load statuses for users if enableUserStatuses config is disabled', async () => { + const state = mergeObjects(initialState, { + entities: { + general: { + config: { + EnableUserStatuses: 'false', + }, + }, + }, + }); + const store = configureStore(state); + store.dispatch(userStartedTyping(userId, channelId, rootId, Date.now())); + + // Wait for side effects to resolve + await Promise.resolve(); + + expect(getStatusesByIds).not.toHaveBeenCalled(); + }); + test('should not load statuses for users that are online', async () => { const state = mergeObjects(initialState, { entities: { diff --git a/webapp/channels/src/components/msg_typing/actions.ts b/webapp/channels/src/components/msg_typing/actions.ts index d04ef67e86..b2f37e0047 100644 --- a/webapp/channels/src/components/msg_typing/actions.ts +++ b/webapp/channels/src/components/msg_typing/actions.ts @@ -5,6 +5,7 @@ import type {GlobalState} from '@mattermost/types/store'; import {getMissingProfilesByIds, getStatusesByIds} from 'mattermost-redux/actions/users'; import {General, Preferences, WebsocketEvents} from 'mattermost-redux/constants'; +import {getIsUserStatusesConfigEnabled} from 'mattermost-redux/selectors/entities/common'; import {getConfig, isPerformanceDebuggingEnabled} from 'mattermost-redux/selectors/entities/general'; import {getBool} from 'mattermost-redux/selectors/entities/preferences'; import {getCurrentUserId, getStatusForUserId} from 'mattermost-redux/selectors/entities/users'; @@ -49,6 +50,7 @@ function fillInMissingInfo(userId: string): ActionFuncAsync { return async (dispatch, getState) => { const state = getState(); const currentUserId = getCurrentUserId(state); + const enabledUserStatuses = getIsUserStatusesConfigEnabled(state); if (userId !== currentUserId) { const result = await dispatch(getMissingProfilesByIds([userId])); @@ -59,7 +61,7 @@ function fillInMissingInfo(userId: string): ActionFuncAsync { } const status = getStatusForUserId(state, userId); - if (status !== General.ONLINE) { + if (status !== General.ONLINE && enabledUserStatuses) { dispatch(getStatusesByIds([userId])); } diff --git a/webapp/channels/src/components/reset_status_modal/reset_status_modal.tsx b/webapp/channels/src/components/reset_status_modal/reset_status_modal.tsx index 609c19b716..b43ddbd0aa 100644 --- a/webapp/channels/src/components/reset_status_modal/reset_status_modal.tsx +++ b/webapp/channels/src/components/reset_status_modal/reset_status_modal.tsx @@ -174,13 +174,16 @@ export default class ResetStatusModal extends React.PureComponent public componentDidMount(): void { this.props.actions.autoResetStatus().then( (result) => { + if (result.data! === null) { + return; + } const status = result.data!; - const statusIsManual = status.manual; + const statusIsManual = status?.manual; const autoResetPrefNotSet = this.props.autoResetPref === ''; this.setState({ currentUserStatus: status, // Set in state until status refactor where we store 'manual' field in redux - show: Boolean(status.status === UserStatuses.OUT_OF_OFFICE || (statusIsManual && autoResetPrefNotSet)), + show: Boolean(status?.status === UserStatuses.OUT_OF_OFFICE || (statusIsManual && autoResetPrefNotSet)), }); }, ); @@ -220,7 +223,7 @@ export default class ResetStatusModal extends React.PureComponent }; public render(): JSX.Element { - const userStatus = this.state.currentUserStatus.status || ''; + const userStatus = this.state.currentUserStatus?.status || ''; const manualStatusTitle = messages[userStatus] ? () : ''; const manualStatusMessage = this.renderModalMessage(); 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 a06c6e60f9..540e02ac19 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/actions/posts.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/actions/posts.ts @@ -25,6 +25,7 @@ import {getProfilesByIds, getProfilesByUsernames, getStatusesByIds} from 'matter import {Client4, DEFAULT_LIMIT_AFTER, DEFAULT_LIMIT_BEFORE} from 'mattermost-redux/client'; import {General, Preferences, Posts} from 'mattermost-redux/constants'; import {getCurrentChannelId, getMyChannelMember as getMyChannelMemberSelector} from 'mattermost-redux/selectors/entities/channels'; +import {getIsUserStatusesConfigEnabled} from 'mattermost-redux/selectors/entities/common'; import {getCustomEmojisByName as selectCustomEmojisByName} from 'mattermost-redux/selectors/entities/emojis'; import {getAllGroupsByName} from 'mattermost-redux/selectors/entities/groups'; import * as PostSelectors from 'mattermost-redux/selectors/entities/posts'; @@ -1004,6 +1005,7 @@ export async function getMentionsAndStatusesForPosts(postsArrayOrMap: Post[]|Pos const state = getState(); const {currentUserId, profiles, statuses} = state.entities.users; + const enabledUserStatuses = getIsUserStatusesConfigEnabled(state); // Statuses and profiles of the users who made the posts const userIdsToLoad = new Set(); @@ -1053,7 +1055,7 @@ export async function getMentionsAndStatusesForPosts(postsArrayOrMap: Post[]|Pos promises.push(dispatch(getProfilesByIds(Array.from(userIdsToLoad)))); } - if (statusesToLoad.size > 0) { + if (statusesToLoad.size > 0 && enabledUserStatuses) { promises.push(dispatch(getStatusesByIds(Array.from(statusesToLoad)))); } diff --git a/webapp/channels/src/packages/mattermost-redux/src/actions/teams.ts b/webapp/channels/src/packages/mattermost-redux/src/actions/teams.ts index 26b07df069..a46243b86c 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/actions/teams.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/actions/teams.ts @@ -16,17 +16,21 @@ import {loadRolesIfNeeded} from 'mattermost-redux/actions/roles'; import {getProfilesByIds, getStatusesByIds} from 'mattermost-redux/actions/users'; import {Client4} from 'mattermost-redux/client'; import {General} from 'mattermost-redux/constants'; +import {getIsUserStatusesConfigEnabled} from 'mattermost-redux/selectors/entities/common'; import {getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams'; import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users'; import type {ActionResult, DispatchFunc, GetStateFunc, ActionFuncAsync} from 'mattermost-redux/types/actions'; import EventEmitter from 'mattermost-redux/utils/event_emitter'; async function getProfilesAndStatusesForMembers(userIds: string[], dispatch: DispatchFunc, getState: GetStateFunc) { + const state = getState(); const { currentUserId, profiles, statuses, - } = getState().entities.users; + } = state.entities.users; + const enabledUserStatuses = getIsUserStatusesConfigEnabled(state); + const profilesToLoad: string[] = []; const statusesToLoad: string[] = []; userIds.forEach((userId) => { @@ -44,7 +48,7 @@ async function getProfilesAndStatusesForMembers(userIds: string[], dispatch: Dis requests.push(dispatch(getProfilesByIds(profilesToLoad))); } - if (statusesToLoad.length) { + if (statusesToLoad.length && enabledUserStatuses) { requests.push(dispatch(getStatusesByIds(statusesToLoad))); } 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 0e3f43f57d..8e42864d68 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/actions/users.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/actions/users.ts @@ -18,6 +18,7 @@ import {loadRolesIfNeeded} from 'mattermost-redux/actions/roles'; import {getMyTeams, getMyTeamMembers, getMyTeamUnreads} from 'mattermost-redux/actions/teams'; import {Client4} from 'mattermost-redux/client'; import {General} from 'mattermost-redux/constants'; +import {getIsUserStatusesConfigEnabled} from 'mattermost-redux/selectors/entities/common'; import {getServerVersion} from 'mattermost-redux/selectors/entities/general'; import {isCollapsedThreadsEnabled} from 'mattermost-redux/selectors/entities/preferences'; import {getCurrentUserId, getUsers} from 'mattermost-redux/selectors/entities/users'; @@ -153,7 +154,9 @@ export function getProfiles(page = 0, perPage: number = General.PROFILE_CHUNK_SI export function getMissingProfilesByIds(userIds: string[]): ActionFuncAsync { return async (dispatch, getState) => { - const {profiles} = getState().entities.users; + const state = getState(); + const {profiles} = state.entities.users; + const enabledUserStatuses = getIsUserStatusesConfigEnabled(state); const missingIds: string[] = []; userIds.forEach((id) => { if (!profiles[id]) { @@ -162,7 +165,9 @@ export function getMissingProfilesByIds(userIds: string[]): ActionFuncAsync 0) { - dispatch(getStatusesByIds(missingIds)); + if (enabledUserStatuses) { + dispatch(getStatusesByIds(missingIds)); + } return dispatch(getProfilesByIds(missingIds)); } diff --git a/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/common.ts b/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/common.ts index 225462be61..a79ffa89d3 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/common.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/common.ts @@ -82,3 +82,10 @@ export function getCallsConfig(state: GlobalState): CallsConfig { // @ts-ignore return state[CALLS_PLUGIN].callsConfig; } + +// Config +export const getIsUserStatusesConfigEnabled: (a: GlobalState) => boolean = createSelector( + 'getIsUserStatusesConfigEnabled', + (state: GlobalState) => state.entities.general.config.EnableUserStatuses, + (EnableUserStatuses) => EnableUserStatuses === 'true', +); diff --git a/webapp/platform/types/src/config.ts b/webapp/platform/types/src/config.ts index a23e7d6304..7a39c899c3 100644 --- a/webapp/platform/types/src/config.ts +++ b/webapp/platform/types/src/config.ts @@ -57,6 +57,7 @@ export type ClientConfig = { EnableCustomEmoji: string; EnableCustomGroups: string; EnableCustomUserStatuses: string; + EnableUserStatuses: string; EnableLastActiveTime: string; EnableTimedDND: string; EnableCustomTermsOfService: string;